spike(plugins): plugin system prototype + voice-driven meme generator — DO NOT MERGE - #243
spike(plugins): plugin system prototype + voice-driven meme generator — DO NOT MERGE#243initcore0 wants to merge 15 commits into
Conversation
The Foundation-only half of the plugin spike, all covered by `swift test`: * `PluginManifest` — id/name/version/symbol/entry kind + a network-hosts disclosure (the app is local-first, so a plugin that reaches out says so). Id validation is strict because the id becomes a path component under Application Support: traversal-shaped ids are refused before they are ever joined onto a URL. * `PluginDiscovery` — merges the compile-time registry with `~/Library/Application Support/OpenWhisp/Plugins/<id>/manifest.json`. Built-in ALWAYS wins an id collision so a writable directory can never shadow a reviewed in-repo plugin. External plugins are listed but never runnable — a manifest cannot promote itself. * `PluginEnablement` — the enabled set, default-OFF, on its own UserDefaults key rather than as new `@Published` state on AppState (MAK-32 ratchet is at zero headroom). Prunes ids that disappeared, so removing and reinstalling a plugin means re-consenting rather than silently restoring a surface. * `PluginRegistry` — the compile-time list of in-repo plugins. * Meme generator logic: `MemeAI` (prompt + a forgiving-about-packaging, strict-about-content JSON parser), `MemeTemplateMatcher` (local lexical match against the imgflip catalog, honest fallback when nothing scores), `MemeCaptionLayout` (uppercase, greedy wrap, shrink-to-fit). 58 new tests. `plugins/MemeGenerator/manifest.json` is checked in as the authored source of truth, with a test pinning it against the shipped literal. Co-Authored-By: Claude <noreply@anthropic.com>
…generator The app layer on top of the pure core, plus the provider seam that keeps the design honest about being hot-swappable. Host + UI: * `PluginHost` — discovers plugins through an ORDERED PROVIDER LIST, tracks the enabled set, and owns plugin windows. The compile-time registry is just ONE provider; the manifest-on-disk provider re-reads the filesystem on every reload, so a real installation path is a new provider + a runner, not a restructure. Earlier providers win id collisions (descending trust). * Settings → Plugins — per-plugin enable toggle, the network disclosure next to the switch that turns it on, and an honest callout when a discovered plugin can't be loaded by this build. * Menu bar → Plugins submenu, present only when something is enabled. * Dictation lands in a focused plugin window via `PluginDictationSink`, wired at BOTH AppState call sites (the liveChunks branch is the one MAK-49 had to go back and fix). AppState is unchanged in NET LOC — the ratchet still reports exactly 7051 — because the checks fold into the existing conditionals. Meme plugin (`plugins/MemeGenerator/`, compiled in with `PLUGINS=1 ./build.sh`): dictate a description -> LLM (reusing `summarizeResolved`, inheriting its busy-reject/fail-closed guarantees) -> imgflip's key-less public catalog -> template matched LOCALLY -> captions drawn LOCALLY with AppKit -> preview, Export PNG, Share. No text ever leaves the Mac; only the blank template comes down. Fails honestly offline. `PLUGINS` defaults to OFF, so a stock build carries no plugin code at all. 2368 tests (2305 baseline + 63). Ratchet OK. Default, plugins, and lean builds all compile. Co-Authored-By: Claude <noreply@anthropic.com>
…not points -fontSize*0.14 double-scaled the outline to ~12% of the font size; at caption sizes the neighboring glyphs' black strokes swallowed each other's white fill and lines rendered as solid merged blobs (owner screenshot). The attribute is already font-size-relative — use the classic ~4% outline. Co-Authored-By: Claude <noreply@anthropic.com>
Owner feedback after live testing: "yoda meme" found nothing in imgflip's top 100 and silently rendered onto Drake, with no way to tell that had happened. Two changes, both aimed at replacing a confident guess with a visible choice. **Template selection v2.** The LLM now receives the ACTUAL catalog names and returns a RANKED list of up to five, copied verbatim. `parseRanked` drops any name that isn't in the catalog, so an invented "Yoda" is discarded rather than fuzzy-matched onto something unrelated — and an empty candidate list is a SUCCESS, the honest "not in this corpus" answer. The UI shows the candidates as a thumbnail strip (clicking one re-renders the same captions onto it) plus a searchable "Browse all" grid over the whole catalog. Falling back still happens — the user asked for a meme — but never without the strip, a warning, and a status line naming the corpus. v1's `MemeAI.prompt`/`parse` and `MemeTemplateMatcher.bestMatch` are DELETED rather than kept. `bestMatch`'s built-in "or the most popular one" is precisely the reported bug, and leaving a second tested-but-dead parser behind is the trap this spike should expose, not commit. Its replacement `ranked` refuses to guess: no match returns an empty list and the fallback POLICY moves to the call site, where it is visible. **Manual editor.** Captions are now an array of `CaptionBox` with NORMALIZED geometry (0-1, top-left origin) and font size as a share of image height — which is what lets a box dragged on the preview render identically into a full-resolution export, and lets positions survive a switch to a differently-sized template. Each box is a draggable handle over the preview, with text, size, width and font controls in a side panel, plus add/delete. The preview IS the export: both go through `MemeRenderer.render(template:boxes:)`, and the AppKit coordinate flip happens in exactly one place. Layout math stays host-independent and tested per the hot-swap note; the renderer supplies font metrics and does nothing else. Gates: swift test 2395 passed (0 failures) · appstate ratchet OK at 7051 · PLUGINS=1 ./build.sh, plain ./build.sh, and lean (WHISPERKIT=0 PARAKEET=0 SPARKLE=0 PLUGINS=1) all compile · no new warnings in the touched files. Co-Authored-By: Claude <noreply@anthropic.com>
…te machine The pure half of v3, from owner live-testing feedback. Four new Foundation-only files, all covered by `swift test`. **Template providers (feedback #1 — "too limited and America-centric").** One catalog cannot fix a corpus problem: imgflip's top 100 is an English-language US popularity list, and any curated remote list is somebody else's culture. So the corpus becomes a MERGE of three sources — imgflip, memegen.link (~200 more, key-less, and it ships KEYWORDS), and the user's own imported library. `MemeTemplateCatalog.merge` puts the USER FIRST: their imported "Drake" beats imgflip's. De-duplication is by NORMALIZED NAME rather than id, because imgflip and memegen genuinely both carry Distracted Boyfriend under different ids and a by-id de-dup would silently do nothing. Ids are source-qualified so two providers can never collide into one image-cache entry. Search now spans names AND keywords, and tokens may span the two fields — memegen names a template "Sweet Brown" and keys the phrase people actually type ("Ain't Nobody Got Time For That") in `keywords`, so a name-only search misses the exact query a user would write. It still NEVER falls back: no match stays empty. **The user library is the actual answer to "worldwide".** No remote catalog contains a Russian or Ukrainian meme nobody uploaded to it, so any image can become a template. `suggestedName` preserves the script — "кот-в-шоке.png" becomes "кот в шоке", with no transliteration, which would defeat the point. The user's name never touches the filesystem (opaque UUID filenames), and `file` values read back off disk are validated before being joined onto a URL, the same rule `PluginManifest` applies to plugin ids and for the same reason. **Busy state (feedback #3 — "stuck loading, can't switch templates").** The cause was structural, not a missed line. v2 tracked in-flight work with a Bool cleared by a `finish()` that several exits never reached: every superseded-ticket bail read `guard ... else { return }` and returned WITHOUT clearing it. That is correct only when another task owns the flag — false after a window close, or when the LLM threw between two guards. Either left `isBusy` true forever, which disabled Generate AND (because `select` began with `guard !isBusy`) froze the candidate strip and Browse — exactly the two reported symptoms. `MemeGenerationState` makes the phase a value with one transition function. `finish` is idempotent, total, and ticket-guarded, so a stale result cannot un-stick newer work and a redundant finish cannot overwrite a status that already landed. `canSelectTemplate` is unconditionally true and says why: switching templates is a local re-render, never an LLM round-trip, so gating it on the busy flag was reflex — and that reflex is what turned a stuck flag into a frozen window. **Cache policy (feedback #1's "instant + offline").** `MemeCatalogCache.decide` always shows what is on disk first and treats the network as a background refresh. A refresh that fails while templates are on screen returns NO message: reporting every fetch failure is what made a cold start look broken. 60 new tests: merge precedence and cross-source name collisions, keyword/Cyrillic search, the live memegen wire shape, cache staleness (including a skewed clock and a future-version cache), library naming/uniqueness/pruning/traversal refusal, and the state machine's out-of-order, duplicate, and superseded orderings. Co-Authored-By: Claude <noreply@anthropic.com>
…lates, imgflip-shaped editor The app half of v3. Wires the new pure layer live and fixes the three remaining owner reports. **"First Generate fails: network error and model loading" (#2).** Neither string exists in the sources — it was a request hitting a llama-server that had not started, surfacing connection-refused as a network error. Nothing warmed the LLM on window open (Scratchpad doesn't either), so the first Generate always paid the cold start. `MemeGeneratorWindowController` now calls `model.windowDidOpen()`, which warms the model and opens the catalog. Generate WAITS behind an honest "Preparing model…" instead of firing into a dead socket. `AppState.warmLlamaServerIfPossible` gained a `provider:` parameter, because the global version only fires when Settings → Cleanup is ITSELF set to the bundled provider — a plugin resolved to bundled would never have warmed. That is the same split `ensureBundledLLMReady(provider:)` already makes for MAK-53. Paid for in the AppState ratchet by folding two comments and dropping a `warmLlamaServerIfPossible` call that `ensureLLMModelExists` already makes on both its exit paths; the ratchet still reports exactly 7051. **Catalog at window open, cached thereafter.** Disk cache paints first, the network only upgrades it, and a failed refresh with templates already on screen is silent. Retry is offered when — and only when — there is nothing to show. Thumbnails are cached as small JPEGs so a second open paints instantly and works offline. **Import your own template.** File picker, drag-drop onto the template column, or ⌘V. Images are COPIED into Application Support rather than referenced, because a template pointing at ~/Downloads breaks the first time the user tidies up. A user-library template is a `file:` URL flowing through the SAME fetch, merge, prompt, and render path as a remote one — which is why importing needed no changes anywhere in the render or export code. **Editor polish (#4).** "Add text" is now outside every conditional. v2 rendered the whole editor panel only when `boxes` was non-empty, so deleting the last box removed the only control that could add one back — a dead end with no way out but regenerating. The empty canvas and empty box list both say what to do next. The window is three columns toward the imgflip.com shape: template search/browse and import on the left (prominent, not behind a sheet), canvas center, box controls right. User templates carry a badge so a mixed grid is legible. Nothing local shows a spinner. **Cancel + timeout.** A Cancel button appears only while work is in flight, and a 120s ceiling recovers a hung request — v2 had neither, so a model that never answered left the surface busy until the window closed. The manifest declares api.memegen.link. A plugin quietly contacting an undeclared host is exactly what the `networkHosts` label exists to prevent, so the new disclosure string is pinned by a test. memegen's server-side captioning API is deliberately NOT used: it would put the user's words on someone else's server. Co-Authored-By: Claude <noreply@anthropic.com>
Three defects from the owner's live pass on v3.
1. SEARCH couldn't find a template by describing it. Both `MemeTemplateMatcher.
search` and `MemeTemplateCatalog.search` required EVERY query token to appear
(`needles.allSatisfy`), so "the worst day for the planet" returned nothing:
"planet" is in neither the Bart template's name nor its keywords, and one
unmatched token vetoed the three that matched perfectly. A content description
can essentially never satisfy an all-tokens rule.
Search is now SCORED over name + keywords (`MemeTemplateCatalog.score`):
exact name > whole-phrase containment > per-token hits, where a name-token hit
outranks a keyword hit and a prefix hit counts for less. Results are ordered
best-first with the catalog's popularity order as the tie-break. It still
never falls back — no match at all is still an empty result.
The LLM path benefits too: `prefilter` scores the whole merged corpus against
the user's own words and hands the model the top 30 WITH their keywords
(`promptLines`), instead of v3's first-100-by-popularity name-only list. The
relevant template can no longer be truncated off the end of the prompt.
2. TEMPLATE DOWNLOAD stuck on "Downloading <name>" forever. `renderTemplate` had
two BARE returns on a stale/cancelled ticket that never touched the state
machine, and `select()` began a `.downloading` ticket with no timeout at all.
Closing and reopening the window then revived the stranded phase, because
`windowDidOpen` cleared `isCancelled` while the phase survived — leaving a
busy surface with no task, no timer and no Retry behind it.
Every exit now ends at a ticket-guarded, idempotent `finish`; downloads get
their own finite ceiling (`downloadTimeout`, well under the generate one);
`windowDidOpen` calls the new `state.reset()` so a reopened window can never
inherit a phase; and an image failure surfaces an honest error plus its own
Retry (`imageFailed` / `retryTemplate`), separate from the catalog's.
3. FIRST GENERATES still failed with a raw network error. v3 gated Generate on a
guessed 2.5s sleep, which expires long before a cold llama-server binds its
port. The real readiness signal already existed and was being discarded:
`ensureRunning` polls `/health` and calls back only when it answers, but
`warmLlamaServerIfPossible` threw that completion away (`{ _ in }`).
It now forwards readiness, the plugin's `warm` seam carries it, and
"Preparing model…" lasts until the model can actually take a request. A
request that still hits a refused connection retries with backoff
(`MemeGenerateRetry`, 3 attempts, matched on URL error CODES so a localized
Mac doesn't silently stop retrying) before anything is reported.
Warm POLICY moved into the pure `LLMWarmReadiness` resolver and the engine call
into an `AppState` extension, so AppState SHRANK by 15 lines; the ratchet budget
is lowered to 7036 to lock the win in.
Tests: 2456 -> 2485. Includes the exact repro
(`testWorstDayDescriptionFindsTheBartTemplate`), verified to fail against the v3
all-tokens rule before the fix.
Co-Authored-By: Claude <noreply@anthropic.com>
…w meme Two live-soak defects from leaving the app running for a day, plus a manifest-declared menu shortcut. ## 1. Downloads stop working after ~a day of uptime Two independent causes, both requiring uptime + a close to appear. ROOT CAUSE A (primary) — the window lifecycle was unbalanced. `PluginHost.open()` caches a plugin's window controller for the app's lifetime and reuses it, so `MemeGeneratorWindowController.init` — the only caller of `model.windowDidOpen()` — ran EXACTLY ONCE. But `windowWillClose` ran on every close, and it calls `model.cancel()`, which sets `isCancelled = true`. Only `windowDidOpen` clears that flag. So the first time the user closed the window, every subsequent async result was dropped by `guard !isCancelled` — permanently, for the rest of the launch. Downloads did not hang; they completed and were thrown away. Fix: a `PluginWindowLifecycle` seam. `PluginHost` tells a cached controller it is being shown again, and the meme controller re-runs `windowDidOpen()`. Setup and teardown are now balanced however many times the window opens. ROOT CAUSE B — the URLSession was unreplaceable, which made Retry a no-op. `MemeTemplateService.session` was a process-lifetime `static let`. A URLSession is a connection pool, and a pooled connection can outlive its validity across a sleep/wake, a network change, or an expired captive-portal lease — after which every request through it fails identically until relaunch. Retry re-ran the request through that same session, so it inherited exactly the broken pool: a no-op by construction. Fix: the session is rebuildable. A transport-shaped failure invalidates it (`invalidateAndCancel`) and the next request builds a fresh pool. Both Retry paths (image and catalog) invalidate first, so a retry is always a genuinely fresh session AND a fresh `URLRequest` rather than a replay. Which failures count is the pure, tested `MemeGenerationState.isTransportFailure` — narrow on purpose: a 404 or an undecodable image keeps the pool. AUDITED AND CLEARED — the catalog cache TTL. An expired cache resolves to `.useCacheAndRefresh` (show it, refresh behind it), never to a wedge, and a background refresh failure is silent only while templates are on screen. A week-old cache still serves. Pinned with clock-injected tests at both TTL boundaries so this stays true. ## 2. No way to start from scratch "New meme" (`arrow.counterclockwise`, ⌘N) clears the description, caption boxes, candidate strip, selected template, rendered meme, search text, and every error — and calls `state.reset()` FIRST, so in-flight work is refused by the existing ticket guard instead of landing on the cleared surface. The clearing is a pure `MemeComposition` value whose `reset()` returns `.empty` wholesale, so a field added later is reset by construction rather than by remembering a line. The test asserts a fully-populated composition comes back exactly equal to `.empty` — the property a partial reset fails. Deliberately survives: the catalog (a corpus, not this meme — clearing it would make New meme a network round-trip) and the image cache. ## 3. ⌘M opens the Meme Generator Manifests now carry an optional `keyEquivalent`. The plugin ASKS; the host DECIDES (`PluginKeyEquivalent`), because only the host can see the whole menu — a plugin must not be able to shadow ⌘Q. Collisions resolve against the app's reserved set then by list order, first-wins, matching the precedence `PluginDiscovery` already uses. A refusal is silent and costs only the shortcut, never the row. The Plugins pane shows the GRANTED shortcut, resolved through the same pass, so it can't advertise a refused key. Decode is forward-compatible; a malformed shortcut is reported by `validate()` but is not fatal. Gates: swift test 2520 passing (2485 baseline + 35 new); ratchet 7036/7036; PLUGINS=1 ./build.sh, plain ./build.sh, and lean all compile with no new warnings in touched files. Co-Authored-By: Claude <noreply@anthropic.com>
…efit, learned ranking
v5 assumed every meme is a top line and a bottom line, and asked the model to
copy template names verbatim. Both assumptions were wrong for most of the
corpus, and both had the data to do better sitting unused on the wire.
Per-template caption STRUCTURE. imgflip ships `box_count` and memegen ships
`lines`; v5 decoded neither. Both now become `MemeTemplate.captionSlots`
(clamped 1...8, defaulting to 2 so a v5 cache and the user library are
unchanged), the LLM is asked for a captions ARRAY sized to the top candidate's
slots, and boxes are seeded per slot. Drake gets 2, Distracted Boyfriend 3,
Expanding Brain 4 — instead of two captions on a four-panel joke.
Slot GEOMETRY is synthesized, not sourced — verified against both live APIs on
2026-08-03: memegen's `/templates` and `/templates/<id>` return `lines` as a
COUNT with no geometry field anywhere, and imgflip's key-less `get_memes` is the
same. (imgflip exposes per-box rectangles only through the authenticated
captioning endpoint, which this plugin deliberately doesn't use — that would put
the user's words on someone else's server.) So: 1 = centered, 2 = the classic
top/bottom (unchanged — two thirds of the corpus), 3-4 = a stacked left column
at narrower width and smaller type, 5+ = an even full-width column. The 3-4 case
is an explicit spike-grade compromise: it lands captions roughly right for
vertically-panelled templates and only approximately right for horizontal ones.
Approximately-right and draggable beats confidently-wrong and invisible.
Numbered candidate references. The shortlist was already numbered and the
numbers went unused. Asking a tiny local model to transcribe names exactly is
the most fragile thing you can ask it for — it re-capitalizes, expands, folds in
the parenthesized keywords, and every one of those was a silently dropped
candidate. It now answers `{"templates": [3, 17, 1]}`; the parser accepts
numbers, numeric strings, exact names, and any mix, validates the range
(out-of-range is DROPPED, never clamped — clamping would be v1's confident-Drake
bug wearing a number), and dedupes on the resolved template so a number and its
name can't occupy two strip slots.
Captions re-fit on a candidate switch. A slot-count change runs a small second
LLM call ("same joke, N slots") on its own ticket through the existing state
machine — cancellable, never blocking the strip, silent on failure so a failed
nicety can't make a working switch look broken. Same count takes a fast path
with no round-trip, which is the common case.
Generate no longer clobbers manual work. The rule, chosen over a confirmation
dialog: Generate replaces AI-seeded boxes and PRESERVES boxes the user added. A
dialog would tax every regenerate to protect the rare one; this is a rule a user
can predict, and an unwanted surviving box is one click to delete whereas a
destroyed caption is retyped from memory. Edits to a seeded box are still
replaced — that box is the AI's answer to the old description.
Cheap learning signal. Clicking past the model's first pick is a correction and
the cheapest supervision this plugin will get. Each one adds a small capped boost
(12/pick, ceiling 120) applied in the prefilter ranking, persisted per plugin.
Bounded so it can't hijack ranking: it is smaller than one keyword-token match,
it saturates after ten picks, it only ever boosts templates the query ALREADY
matched — so "nothing matches" stays reachable — and it does not reorder an
unfiltered Browse grid.
Also drops v5's "think about which templates fit" invitation, which asked for
reasoning the parser then discarded — pure token cost on the models least able
to afford it. The model now returns one short `reason` that the candidate strip
shows as a tooltip: the same request, routed somewhere the user can read it.
swift test 2593 (2520 + 73 new in MemeStructureTests). One v5 test renamed —
it pinned the "copied exactly" wording; the guard it protects is now the
numbering, so the assertion moved rather than being dropped.
Co-Authored-By: Claude <noreply@anthropic.com>
…rce slots The live failure: "expanding brain: typing, dictating, dictating memes, dictating memes by voice" picked Expanding Brain (4 slots) and rendered TWO captions. Three independent things had to be true, and v7 breaks all three. 1. Deterministic caption extraction (highest leverage). The captions were RIGHT THERE in the dictation, comma-separated after a colon, and we asked a 1.5B local model to re-derive them anyway. MemeCaptionExtraction reads list-shaped descriptions directly — colon lists, numbered runs, newline lists, spoken "and"/"then" joiners — and uses the items verbatim, skipping LLM caption-writing entirely. The LLM keeps the one job it is needed for: picking a template. Deliberately narrow (an enumeration signal, a plausible count, and caption-sized items are all required), so prose with commas — "a drake meme about rust, python and go" — falls through to the v6 path unchanged. 2. Host-side slot enforcement. MemeAI.fit is now the single rule: a caption count that doesn't match the template REFITS via the existing ticket-guarded call, with an honest "Model wrote 2 of 4 — refitting…". The legacy top/bottom form is accepted as final ONLY for 2-slot templates — on any N≠2 template it is the bug signature it was. Captions always seed into the template's own slot geometry, so a 4-slot template can never render as a classic two-liner. The refit path is extracted so generate and template-switch share one implementation. 3. Constrained decoding (the systemic fix). Every other guard here is a parser deciding after the fact; that is a losing game one shape at a time. The seam now carries a JSON schema down to llama-server, which compiles it to a GBNF grammar and constrains the sampler — so `templates` typed as integers makes an invented name unrepresentable, and the refit schema's minItems == maxItems == N makes "wrote 2 of 4" impossible to emit. Optional and omitted when nil, so every existing caller's request bytes are unchanged. Template search also prefers matching slot counts when the item count is known — reordering only, never filtering, since hiding a described template is the confident-Drake bug in a new costume. swift test 2624 (was 2593); AppState ratchet unchanged at 7036 (the plumbing adds zero net lines); PLUGINS=1, plain, and lean builds all clean with no new warnings in touched files. Co-Authored-By: Claude <noreply@anthropic.com>
The owner's v7 screenshot (four-item Expanding Brain rendered as two boxes carrying the first and last items) is v6 code. The v7 commit e0386cf that fixes it landed at 14:51 today, after that build. Reproduced the exact output against v6's applyRanked: let slots = picks.first?.captionSlots ?? MemeCaptionSlots.default seedBoxes(captions: spec.captions, slots: slots) // no extraction, no fit with the legacy top_text/bottom_text pair decoding to ["typing", "dictating memes by voice"], padded to ["typing", "dictating memes by voice", "", ""]. But the real defect is structural and outlived the fix: v7's tests passed while the app was broken, because they re-implemented the app's sequence in the test body (with `slots: 4` as a literal) while the code that CHAINS extraction → replace → fit → seed lived in plugins/MemeGeneratorModel.swift — PLUGINS=1 only, outside the swift test target. The chain was untested by construction. So the chain moves to core: * NEW MemeCaptionSeeding.resolve(description:specCaptions:wasLegacyShape: templateSlots:) owns the whole decision and returns boxes + an owed refit. Three rules, each closing one way the bug returns: the user's own list beats the model's captions; the geometry always comes from the TEMPLATE's slot count; a count mismatch refits instead of padding blanks. * MemeCaptionSeeding.templateQuery shares that one extraction with the template search, so query and slot preference can't disagree about list-shapedness. * applyRanked is now a call to resolve + UI glue. `apply(seed:)` is the only place boxes are assigned; the description-reading logic is gone from plugins/. Two-caption entry points closed so this can't regress silently: * MemeRenderer.render(template:topText:bottomText:) — @available unavailable (it had no callers; any future one would collapse N→2 at the boundary). * MemeCaptionLayout.seedBoxes(topText:bottomText:) and RankedSpec(templateNames:topText:bottomText:) — deprecated, no production callers left; kept for tests that are legitimately about the 2-slot layout. Tests: 10 new in MemeCaptionSeedingTests drive the REAL function with a raw legacy-shape model reply — text, order, slot geometry, refit reachability. Verified they fail against the v6 behaviour, reproducing the screenshot output exactly, rather than passing vacuously. Re-verified the other v7 wiring claims against the app path: both aiCall sites pass a schema, and it survives configureAI → summarizeResolved → processFinalText → encodeIfPresent(responseFormat); the refit is reachable from applyRanked's real caller via seed.refit. swift test 2634 green (2624 + 10), ratchet 7036/7036, PLUGINS=1 + plain + lean build clean with no new warnings in touched files. Co-Authored-By: Claude <noreply@anthropic.com>
The owner's four-item Expanding Brain prompt rendered as two captions for the
third time. v7 and v8 each traced the caption wiring by eye, found it correct,
and shipped; the caption wiring WAS correct. It was being handed `slots: 2`.
`MemeTemplate.captionSlots` arrived in v6 without a bump to
`MemeCatalogCache.currentVersion`, and `decide` admitted anything
`<= currentVersion`. So a catalog cached by a v5-era build stayed version 1 and
was accepted as current, while `MemeTemplate.init(from:)` — deliberately
tolerant so an old cache still loads — defaulted the missing field to 2. Every
template in the corpus reported two caption slots, Expanding Brain included.
Extraction then read four captions correctly, matched the right template, and
the count-mismatch rule refit them DOWN to two — which is why the meme kept the
first and last items and dropped the middle two.
Fixed by bumping the version to 2 and gating on equality: a pre-captionSlots
cache is refetched, because the slot counts were never written to that file and
there is nothing to migrate them from.
Proved at runtime rather than by reading, since reading had failed twice.
`MemeTrace` emits a breadcrumb at each decision, and a launch-gated probe
(OPENWHISP_MEME_PROBE_PROMPT) drives the real `generate()` through `PluginHost`,
so a build can capture what the shipping binary decides.
Before, against the owner's own stale cache:
extraction fired: 4 items, theme: "expanding brain"
resolve(..., specCaptions: 4, slots: 2) -> 2 boxes, refit: 4->2
probe result: 2 boxes, texts=["typing", "dictating memes by voice"]
After, same binary, same prompt, cache refetched:
resolve(..., specCaptions: 4, slots: 4) -> 4 boxes, refit: none
probe result: 4 boxes, texts=["typing", "dictating", "dictating memes",
"dictating memes by voice"]
Also fixes the drag: the caption is burned into the preview, so dragging moved
an empty dashed outline while the text stayed behind until drop. The dragged
box now renders empty (`MemeCaptionLayout.hidingText`) while the handle carries
a live copy, so the text travels with the cursor. The duplicate is explicit and
lasts only for the gesture — on drop the real renderer's output is what remains,
so it can never reach the export.
Tests: the cache-policy helper defaulted to a literal `version: 1`, so the suite
was asserting the stale-cache behaviour was correct. It now defaults to
`currentVersion`, plus regressions for the older-version gate, the tolerant
decode, a slot-count round-trip, the owner's prompt at 4 and at 2 slots, and the
drag helper. 2645 pass.
Co-Authored-By: Claude <noreply@anthropic.com>
v9 — the captions were never the bugYou ran a hash-verified v8 binary with the exact prompt and still got two boxes. That The divergent line
guard let cached, cached.version <= currentVersion, !cached.templates.isEmpty else {So a catalog cached by a v5-era build stayed captionSlots = MemeCaptionSlots.clamp(
(try? c.decode(Int.self, forKey: .captionSlots)) ?? MemeCaptionSlots.default) // 2Your actual cache file, before this fix: Every template in the corpus reported two caption slots. Extraction read your four items No amount of reading the caption code could find this, because the caption code was Runtime proofReading had failed twice, so this round is evidence from the running binary. Before, Your bug, reproduced exactly. Note After, same binary with the version gate, cache refetched: Four boxes, your words verbatim and in order, no refit round-trip. The cache on disk is The fix is a version bump plus gating on equality rather than The probe and the breadcrumbs are permanent but inert: both are env-gated DragThe caption is burned into the preview image, so dragging moved an empty dashed outline What would have caught itThe cache-policy tests all built their fixture with a literal Gates
|
… router
The trigger layer for MAK-100: a plugin declares the spoken phrases that should
route a refine instruction to it, and a pure core router decides which (if any)
plugin claims an instruction. Nothing is hardcoded next to the meme plugin — a
second plugin gains voice commands by shipping a manifest.
`PluginManifest.voiceTriggers` decodes forward-compatibly (a manifest written
before this field still decodes) and normalizes to trimmed, lowercased, de-duped
phrases. An all-empty list is REPORTED (`.emptyVoiceTriggers`) but never fatal —
same trade `keyEquivalent` already makes, since losing a working plugin over a
stray `""` in a JSON file would be a bad deal. Crucially, normalization is what
stops an empty prefix from matching EVERY instruction the user ever speaks.
`PluginVoiceCommandRouter.match` is deliberately strict, because a match REDIRECTS
a dictation away from the user's editor and a false positive costs them text:
- PREFIX only, so "summarize this, then create a meme" stays a normal refine
- WORD BOUNDARY, so "create a memo about Q3" does not match "create a mem"+"o"
- exact phrases, case/whitespace-insensitive; no fuzzy or edit-distance matching
- longest trigger wins, so a specific phrase can't be shadowed by a general one
The remainder is sliced from the ORIGINAL text (not the lowercased form) so the
user's capitalization reaches the rendered captions, and only leading separator
punctuation is trimmed — the interior commas in "typing, dictating, …" ARE the
list the extraction step later reads.
The meme manifest declares EN + RU ("сделай мем", "создай мем") because the owner
dictates in both; the checked-in manifest.json matches the registry literal, which
the existing parity test pins.
Tests: 22 router cases, negatives carrying most of the weight (memo, mid-sentence
mention, "create a memes", empty instruction), plus enablement gating, precedence,
normalization, and forward-compat decode.
Co-Authored-By: Claude <noreply@anthropic.com>
…ertion
Wires the trigger layer into the refine pipeline for the owner's two flows:
CASE 1 — select text anywhere, dictate, tap Refine, say "create a meme based on
that": the SELECTION (the refine content snapshot) is the material.
CASE 2 — Refine with nothing selected, say "create a meme expanding brain:
typing, dictating, …": the spoken remainder is.
CASE 2 needed a real fix, not just a call. `armRefineMidSession` bailed with
"Nothing to refine yet" whenever there was no in-session text, no selection, and
no last dictation — so `refineContentSnapshot` stayed nil, `deliverFinalText`'s
refine branch never ran, and the command could never reach a router. It now arms
with an EMPTY snapshot, but only when an enabled plugin actually declares triggers
(`PluginHost.armsWithoutContent`); with no such plugin the old status is byte-for-
byte what it was. When the instruction turns out NOT to be a command, RefineFlow's
existing empty-content rule produces the same "Nothing to refine" outcome as before.
NO INSERTION on the routed path. The route is consulted in `deliverFinalText`
BEFORE `runLLM` and before any insert, and a claimed command returns
`RefineFlow.Effect.finishQuietly` — the same teardown a contentless refine already
uses, so the session ends through one auditable no-insert path instead of a second
hand-rolled one. Verified against the real delivery path: nothing reaches
`insertCompletedText`, so nothing lands in the focused app.
Failure never eats the dictation. A non-match, a disabled plugin, or a window that
can't take the command all fall through to the untouched normal refine. A disabled
plugin additionally sets a one-line hint, and ONLY when the trigger matched exactly
(`matchIgnoringEnablement`) — so it can't fire on an unrelated refine.
Overlay: the acknowledgment names the plugin ("Meme Generator — creating…") and
reaches the overlay via `statusMessage`, which `FinalizingCaption.resolve` already
surfaces verbatim — no new OverlayPhase case, no view change. A test pins that the
two agree.
AppState pays for itself: the feature adds ~10 lines there (one `if let` and one
guard, both delegating to PluginHost), and the ratchet BUDGET DROPS 7036 -> 7024
because the consent-decision, dictation-stats-event, rules-firing, and refinement-
mode constructions moved into the pure core where swift test can reach them.
Co-Authored-By: Claude <noreply@anthropic.com>
v9's lesson was that reading the wiring is not evidence. `swift test` proves the ROUTER but cannot prove the pipeline ever REACHES it — the refine path lives on AppState, which the core test target doesn't compile, and this project's wiring bugs have all passed their unit tests while the live gate was dead. So the launch-gated probe now drives the route itself. `startRefineRouteProbe` calls the SAME `PluginHost.routeVoiceCommand` that `AppState.deliverFinalText` calls when a mid-dictation refine finalizes, with the same (instruction, content) pair — only the source of those two strings differs (env vars instead of the mic). Setting `..._REFINE_CONTENT` expresses CASE 1; omitting it expresses CASE 2. One harness gotcha worth recording: the probe MUST run from a bundle. `build.sh` emits a loose executable, and AppKit never services the main run loop for one, so every `asyncAfter` — the v9 probe's included — sits queued forever and the run looks like a silent hang. The v9 probe has this same defect; the script now hand-assembles a minimal ad-hoc-signed .app (binary + Info.plist + Resources), which is enough since no third_party runtime matters to the trigger layer. The canvas report is factored out of the v9 probe so both prove the outcome the same way: N boxes, and the text in them. Co-Authored-By: Claude <noreply@anthropic.com>
…ration spike, productionized) (#244) * feat(plugins): plugin system core + in-repo meme generator plugin Carries the owner-tested spike (PR #243, 10 iterations) onto main. This commit is the spike content verbatim; the production hygiene, the shipping build defaults, the MAK-100 manifest contracts, CI, and the docs follow as their own commits on top, so the diff between "what was tested" and "what productionizing changed" stays readable. THE SYSTEM - `PluginManifest` — the host/plugin contract: id, name, version, SF Symbol, entry kind, `networkHosts` disclosure, `keyEquivalent` request, and `voiceTriggers`. Id validation is strict because the id becomes a path component under Application Support, so traversal-shaped ids are refused before they are ever joined onto a URL. - `PluginDiscovery` — merges an ORDERED provider list. Providers are passed in DESCENDING trust order and earlier wins, so a writable directory can never shadow a reviewed plugin. The compile-time registry is one entry in that list, which is the seam a real loader plugs into rather than replaces. - `PluginEnablement` — the enabled set, DEFAULT-OFF, with pruning. - `PluginRegistry` — the compile-time list of in-repo plugins. - `PluginHost` / `PluginsPane` / menu-bar submenu — the app-side surface, none of it on AppState (the MAK-32 ratchet is at zero headroom). THE PLUGIN A voice-driven meme generator: dictate a description, and it picks a template, writes the captions, renders them locally, and lets you edit, export, or share. Three merged template providers (imgflip, memegen.link, and the user's own imported library, which is the only one that works offline and in any language), scored search over names + keywords, per-template caption slots, schema- constrained decoding, a normalized box model whose preview IS the export, a busy-state machine whose `finish` is idempotent and ticket-guarded, a rebuildable URLSession, and a learned template affinity that is bounded so it can never resurrect the confident-wrong-template bug this plugin was built to kill. Every DECISION lives in `OpenWhisp/Services` and is covered by `swift test`; `plugins/MemeGenerator/` holds only AppKit/SwiftUI and IO. Co-Authored-By: Claude <noreply@anthropic.com> * feat(plugins): ship-ready gates — PLUGINS on by default, instrumentation-only probes, MAK-100 manifest contracts Productionizes the carried spike. Three groups of change, all verified against real builds rather than by reading. 1. PLUGINS SHIPS BY DEFAULT `PLUGINS` now defaults to 1, matching WHISPERKIT / PARAKEET / SPARKLE; `PLUGINS=0` stays the lean escape hatch. Two things this deliberately does NOT change: plugins remain DISABLED at runtime until the user enables one per-plugin in Settings → Plugins (`PluginEnablement` defaults to the empty set), and the pure plugin core is compiled and tested either way. The source list moved into `scripts/plugin-source-args.sh`, sourced by BOTH build.sh and build-dmg.sh. build-dmg.sh is the RELEASE path and previously had no plugin support at all — left as it was, the shipped DMG would have been the only build with an empty Plugins pane. Sharing the helper is the same pattern whisperkit-link-args.sh / sparkle-link-args.sh already use, for the same reason. `scripts/verify-plugins-binary.sh` guards package.sh and build-dmg.sh the way verify-whisperkit-binary.sh does. Plugins live OUTSIDE build.sh's OpenWhisp/ glob, so a broken source list drops every one of them with NO compile error — the app builds and runs perfectly with an empty pane. Verified non-vacuous: the guard fails on a PLUGINS=0 binary and skips when PLUGINS=0 is intentional. 2. DEVELOPER SURFACES ARE COMPILED OUT OF CONSUMER BUILDS `MemeTrace`'s emission and the two launch-gated probes in AppMain (plus the window controller's probe hooks) are now behind `OPENWHISP_INSTRUMENTATION`, the gate `LLMBenchRunner` and `LLMLabView` already use. An env var that opens a window and drives a generate is a fine debugging tool and a poor thing to leave reachable in a signed, notarized app holding Accessibility and mic grants. Verified in both directions rather than asserted: `[MemeGen]` appears 0 times in a default binary's strings and 1 time in an INSTRUMENTATION=1 one. The pure LINE BUILDERS stay compiled — they are `swift test`-covered functions and the core target has no instrumentation define. Only the side effect is conditional. `scripts/meme-runtime-proof.sh` was referenced by a doc comment but never existed; it now does, alongside the voice-command harness it mirrors. 3. MAK-100 CONTRACTS (all additive, all forward-compatible) - `clipboardAccess` (bool, default false) — WIRED, not just declared. The host reads the pasteboard only for a plugin that declared it, guarded by `needsPasteboard` BEFORE the read, so an undeclared plugin causes no NSPasteboard access at all. The rule lives in the pure, tested `PluginInvocationContext`; the pane discloses it beside the network hosts. The meme plugin declines it deliberately. - `destination` (enum, default ownWindow) — `cursor` / `outputTarget` are RESERVED: validated and reported, never fatal, and `effectiveDestination` falls back in one place so a declared-but-unimplemented route is refused honestly instead of silently doing something else. - `appAffinity` ([String]) — reserved router metadata. A HINT, never a priority: a test pins that declaring it buys a plugin no ranking advantage, because MAK-100's ~15-tool cap makes trigger surface a host-arbitrated resource. An unknown `destination` decodes to the default rather than throwing, so a manifest written for a future host degrades instead of vanishing from the list. Also swept spike-era provenance markers from doc comments and the user-facing "this prototype" strings, and corrected the `PLUGINS=1` references that inverted meaning when the flag became default-on. Gates: `swift test` 2685 passing (2670 carried + 15 new contract tests) · ratchet 7024/7024 · default / PLUGINS=0 / lean / INSTRUMENTATION=1 builds all compile · zero warnings in any plugin-owned file. Co-Authored-By: Claude <noreply@anthropic.com> * docs(plugins): PLUGINS.md + CI coverage for both sides of the flag DOCS `docs/PLUGINS.md` — the reference the plugin system didn't have: architecture (core/app split, the provider seam, the three window seams), a field-by-field manifest schema including the new contract fields, how to write an in-repo plugin today, the security and trust model, and the path to hot-swappable. Two sections carry decisions rather than description: - **Security and trust.** Why plugins are runtime-opt-in, why providers are ordered by descending trust (a user-writable directory must never shadow a reviewed plugin in an app holding Accessibility + mic + clipboard rights), and the honest limits — `networkHosts` is disclosure not a sandbox, and the clipboard gate is real at the host but cannot bind an in-process plugin. Enforcement only becomes meaningful at a process boundary. - **Path to hot-swappable.** The owner's shipping requirement is installing a plugin without a rebuild, so this is written as a committed roadmap rather than an open question: manifest/script-driven plugins FIRST (cheapest, and delivers install-without-rebuild on its own — `ScriptPostProcessor` and `ConfigPack` are already this shape), out-of-process executables NEXT (real isolation, and it reuses two precedents this app already ships: helper binaries at Contents/Helpers and the Agent Bridge's local-socket protocol), WKWebView as an optional UI layer, and dylibs permanently rejected per ROADMAP §6. Also documents the testing trap the system is shaped around: `plugins/` is outside the `swift test` target, so a test that re-implements the app's sequence proves nothing about the chain — extract the decision into one core function and have both the app and the test call it. README gains a plugin line and a docs link; ARCHITECTURE gains the `plugins/` row; ROADMAP §6 gets a status banner and two table rows (v1 shipped, out-of-process next), with the dylib rejection left exactly as it was. CI Two jobs now pin OPPOSITE sides of the PLUGINS flag, which is the point: - `build-app` is pinned to `PLUGINS=0`. Without the flag it would have silently started building WITH plugins when the default flipped, and the lean escape hatch would have rotted untested. - `build-app-plugins` (new) builds the DEFAULT configuration users actually get, then runs `verify-plugins-binary.sh`. The build succeeding is not evidence the plugins linked — a broken source list yields a working app with an empty Plugins pane and no error anywhere — so CI asserts the symbols instead. The nightly full-fat `package.sh` job picks up the plugins and the new verify guard automatically; its comment now says so. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
DO NOT MERGE. Prototype to make the plugin idea concrete and argue about a real thing instead of a diagram. Quality bar is spike-level: it compiles, it works on the happy path, and the shortcuts are labelled rather than hidden.
What this demonstrates
An optional plugin layer. Plugins are not part of the base app: nothing is compiled in by default, nothing is enabled by default, and a stock
./build.shcarries no plugin code at all. Each enabled plugin contributes its own window plus its own configuration in Settings.The first plugin is a voice-driven meme generator: dictate a description, and it picks a template, writes the captions, renders them, and lets you export or share.
Architecture
Two layers, split on the line the codebase already draws — pure rules in
OpenWhispCore(covered byswift test), IO and AppKit in the app.Core (
OpenWhisp/Services/, all tested):PluginManifest— id, name, version, SF Symbol, entry kind, and anetworkHostsdisclosure. Id validation is strict because the id becomes a path component under Application Support, so traversal-shaped ids (..,foo/bar) are refused before they are ever joined onto a URL.PluginDiscovery— merges an ordered list of providers into the plugin list. Earlier providers win id collisions.PluginEnablement— the enabled set, default-off, with pruning.PluginRegistry— the compile-time list of in-repo plugins.App:
PluginHost— the provider list, the enabled set, and plugin windows. Not on AppState.PluginsPane— Settings → Plugins.The provider seam (this is the part that matters)
PluginHostdoes not know about the compile-time registry. It enumerates providers:The registry is one entry in that list. The disk provider re-reads
~/Library/Application Support/OpenWhisp/Plugins/<id>/manifest.jsonon everyreload(), so a manifest dropped there shows up in the pane without a rebuild or a relaunch — the listing half of hot-swap already works today. What is missing is a runner, not a restructure.Providers are passed in descending trust order and earlier wins, so a writable directory can never shadow a reviewed plugin. That property is worth pinning now even though the spike can't execute external plugins at all, because a future loader inherits it.
The manifest describes an entry point kind (
builtIn/dynamicLibrary/externalProcess), never a Swift type name — so the schema does not assume the plugin lives in this binary.AppState is unchanged in net LOC
Dictation routing needed both call sites in
insertCompletedText(theliveChunksbranch is the one MAK-49 had to go back and fix — a window wired into only the first branch silently drops text). The MAK-32 ratchet had zero headroom at 7051, so the checks fold into the existing conditionals with||and the surrounding comments were condensed to pay for the wrap.scripts/check-appstate-ratchet.shstill reports exactly 7051.Path to hot-swappable
The compile-time registry is fine for this spike but must not be the shipping answer. Four realistic options for a signed, notarized, local-first app that holds Accessibility + microphone + clipboard:
1. Out-of-process plugin executables — recommended
A plugin is a helper binary the app launches and speaks to over a local protocol; UI is contributed declaratively or by the plugin's own window.
whisper,llama, theopenwhispCLI atContents/Helpers/) and already has a local-socket bridge protocol precedent (Agent Bridge + MCP). This reuses both.2. Script / manifest-driven plugins — recommended as the first shipped tier
Declarative UI plus a constrained action set the host executes (call the LLM, fetch a URL, run a shell script, write a file).
ScriptPostProcessorandConfigPackare already this shape.3. WKWebView-hosted plugin UIs — middle ground
Plugin ships HTML/JS; the host exposes a narrow message-passing bridge.
4. Loadable bundles / dylibs — do not do this
docs/ROADMAP.md§6 already rejects this.Recommendation: ship (2) first because it is cheap and covers most asks, then (1) for anything needing custom UI or real compute, with (3) as the UI layer if declarative proves too limiting. Keep (4) closed permanently.
Review model. While plugins are owner-reviewed and in-repo, the trust question is deferred rather than answered — which is exactly why the provider ordering and the "external is never runnable" rule are already in place. The moment plugins come from elsewhere, the host needs: a capability list in the manifest that the user consents to per-plugin, enforcement outside the plugin's own manifest (a manifest cannot be trusted to declare its own limits), a signature/provenance check, and a kill switch. The current
networkHostsfield is an honest label, not a sandbox — that distinction is called out in the code.What is real vs stubbed
Real:
Stubbed / deliberately not done:
PLUGINS=1 ./build.shis a compile-time toggle, not an installer.switchon plugin id — a real system needs a declared settings schema.Codable, so this is a decision rather than a blocker).In-repo vs separate repo
In-repo was right for the spike and I would keep it for now:
summarizeResolved,ScratchpadAIModel,SummaryModelResolver, andBridgeWire.ErrorObject. None are a stable public API. A separate repo would have to pin a contract that does not exist yet — the iOS companion already madeOpenWhispCorea versioned contract, and doing that a second time before the plugin API has settled would freeze the wrong shape.swift testcovers the plugin's rules in the same run as everything else. That stops the day it moves out.plugins/is outsidebuild.sh's glob, so it needed its own flag, andPackage.swift's explicitsources:list means every pure file has to be registered by hand. Both are symptoms of the plugin being neither fully in nor fully out.Once the boundary is an IPC protocol rather than a Swift call, out-of-repo becomes natural — the protocol is the contract, and that is the point at which third-party plugins stop being a security question the owner has to answer by reading every diff.
v2: candidate picker + manual editor
Owner feedback after live testing the first cut:
Template selection v2 — the model picks from the real corpus
The LLM now receives the actual catalog names and returns a ranked list of up to five, copied verbatim, plus captions.
MemeAI.parseRankedvalidates every name against the catalog and drops the ones that don't exist — a model that answers "Yoda" gets that candidate discarded rather than fuzzy-matched onto something unrelated.An empty candidate list is a success, not an error: it is the honest "nothing in this corpus fits" answer, and the UI states it.
UI:
MemeTemplateMatcher.ranked), with popularity only filling the remaining slots.v1 code was deleted, not left behind
MemeAI.prompt/userPayload/parse/MemeSpecandMemeTemplateMatcher.bestMatch/Match/minimumScoreare gone, along with the fixed top/bottom geometry constants.bestMatch's built-in "…or the most popular one if nothing scores" is the reported bug — the fallback was baked into the matcher where no caller could see it. Its replacementrankedrefuses to guess (no match → empty list) and moves the fallback policy to the call site, where it is visible and can be narrated.The alternative — keeping v1 as a "fallback path" — would have left a second parser that tests cover but nothing calls. That is exactly the dead-wiring trap this spike exists to expose, so it wasn't committed.
Manual editor — the box model
Captions are now
[MemeCaptionLayout.CaptionBox]rather than a fixed top/bottom pair:Geometry is normalized on purpose. A box dragged on a ~500pt preview renders identically into a 1200px export, and survives being moved to a differently-sized template when the user picks another candidate. Font size is a share of image height for the same reason.
−/+steppers, width slider, font picker (only faces actually installed, plus the bold system font — a picker offering a font that silently resolves to something else is a lie).The preview IS the export. Both go through
MemeRenderer.render(template:boxes:), so WYSIWYG is structural rather than a promise. The AppKit bottom-left coordinate flip happens in exactly one place, which is what stops the editor and the export from disagreeing about which way is up.Rendering is local CoreGraphics onto an already-downloaded image, so it is cheap enough to redraw per keystroke and per drag — no debounce needed.
The layout math stays pure and host-independent (
MemeCaptionLayout, Foundation-only,swift test-covered) per the hot-swap architecture note. The renderer supplies real font metrics and does nothing else. The user's font size is a ceiling: a caption too long for its box still shrinks rather than overflowing.Design decisions worth arguing about
rankedUserPayloadalready truncates for short-context models.AsyncImagerather than the plugin's own fetch service — the grid can show 100 of them andAsyncImagealready handles per-view cancellation and URLCache reuse. Same imgflip CDN GET either way; still nothing uploaded.Not verified
The rendered output, drag feel, and thumbnail grid have not been visually confirmed — this environment can't screenshot the Mac app (the name resolves to the iOS bundle). The geometry is pinned by tests, but "does the handle sit exactly on the text" and "does the strip look right" are open until someone runs it.
How to test
PLUGINS=1 ./build.sh && ./build/OpenWhispdrake,two buttons,bling(multi-token search works:drake blingfinds Drake Hotline Bling). Typeyodaand confirm you get an empty grid that says so rather than a substituted template. Pick anything to override the model entirely.−/+), width, and font. Use + to add a box and the trash icon to delete one.some-id/manifest.json, reopen the pane: it lists, with an honest "can't be loaded" callout.Gates:
swift test2395 passed, 0 failures ·scripts/check-appstate-ratchet.shOK at exactly 7051 (unchanged — v2 touches no AppState) ·./build.sh(default, no plugins) ·PLUGINS=1 ./build.sh· lean (WHISPERKIT=0 PARAKEET=0 SPARKLE=0 PLUGINS=1) all compile, no new warnings in the touched files.New tests cover the ranked-candidate parser (hallucinated names dropped, case-insensitive catalog matching, dedup, the 5-cap, single-string schema drift, fenced/prose JSON), lexical ranking and the Browse-all search filter (including "neither is allowed to invent a substitute"), and the box model (normalized geometry scaling with image size, clamping, per-box fonts reaching the metrics, shrink-to-fit, empty-box handling, filename derivation, Codable round-trip).
v3: three template providers, a warm model, and a busy state that can't stick
Owner feedback after live-testing v2. Each item below was a defect or a requirement,
not a preference.
1. The corpus is now three providers, merged
One catalog can't fix a corpus problem. imgflip's top 100 is an English-language, US
popularity list, and any curated remote list is someone else's culture. So the
corpus is a merge:
get_memes/templatesMemeTemplateCatalog.mergeputs the user first. Their imported "Drake" beatsimgflip's. This is
PluginDiscovery's "earlier provider wins" rule pointed the otherway on purpose: there, trust decreases with writability; here the writable source is
the trusted one, because the user put it there deliberately.
De-duplication is by normalized NAME, not id. imgflip and memegen genuinely both
carry Distracted Boyfriend under completely different ids, so a by-id de-dup would
silently do nothing and the grid would show it twice. Ids are source-qualified
(
imgflip:181913649,userLibrary:<uuid>) so two providers can never collide intoone image-cache entry — a bug that would present as a rendering glitch rather than as
an id collision.
Search spans names AND keywords, and a query may span both fields. memegen names a
template "Sweet Brown" and puts the phrase people actually type — "Ain't Nobody Got
Time For That" — in
keywords; a name-only search misses the exact query a user wouldwrite. Search still never falls back: no match stays empty.
The user library is the real answer to "worldwide." No remote catalog contains a
Russian or Ukrainian meme nobody uploaded to it. Import any image (file picker,
drag-drop onto the template column, or ⌘V) and it becomes a first-class template.
suggestedNamepreserves the script:кот-в-шоке.png→кот в шоке. Notransliteration, no ASCII folding — that would defeat the entire point.
with a slash, a colon, or an emoji is fine and renaming is a pure index edit.
at
~/Downloadsbreaks the first time the user tidies up.filevalues read back off disk are validated before being joined onto a URL —the index is a plain JSON file in a user-writable directory, so it is untrusted input
even though the user owns it. Same rule
PluginManifestapplies to plugin ids.A user-library template is a
file:URL flowing through the same fetch, merge,prompt, and render path as a remote one. That is why importing needed no changes
anywhere in the render or export code.
Caching.
MemeCatalogCache.decidealways paints what is on disk first and treatsthe network as a background refresh. Thumbnails are cached as small JPEGs, so a second
open is instant and works offline. A refresh that fails while templates are on
screen returns no message at all — v2's habit of reporting every fetch failure is
what made a cold start look broken. Retry is offered when, and only when, there is
nothing to show.
2. The model is warmed when the window opens
Neither "network error" nor "model loading" exists anywhere in the sources. The report
was a request hitting a llama-server that hadn't started, surfacing
connection-refused as a network error. Nothing warmed the LLM on window open —
Scratchpad doesn't either — so the first Generate always paid the full cold start.
windowDidOpen()now warms the model and opens the catalog. Generate waits behindan honest "Preparing model…" instead of firing into a dead socket.
AppState.warmLlamaServerIfPossiblegained aprovider:parameter. The global versiononly fires when Settings → Cleanup is itself set to the bundled provider, so a plugin
resolved to bundled would never have warmed — the same split
ensureBundledLLMReady(provider:)already makes for MAK-53.Ratchet: paid for by folding two comments and dropping one
warmLlamaServerIfPossible()call thatensureLLMModelExistsalready makes on bothits exit paths.
scripts/check-appstate-ratchet.shstill reports exactly 7051.3. The stuck spinner was structural, so the fix is a type
v2 tracked in-flight work with a
Boolcleared by afinish()that several exit pathsnever reached. Every superseded-ticket bail read:
That is correct only when another task owns the flag — true when a newer request
supersedes this one, false after a window close, or when the LLM threw between two
guards. Either case left
isBusy == trueforever, which disabled Generate and(because
select(template:)began withguard !isBusy) froze the candidate strip andBrowse. Both reported symptoms, one root cause.
MemeGenerationStatemakes the phase a value with one transition function:finishis idempotent, total, and ticket-guarded. A stale result can't un-sticknewer work; a redundant finish can't overwrite a status that already landed.
canSelectTemplateis unconditionallytrue, and says why. Switching templatesre-renders the same boxes onto a cached-or-one-GET-away image — no LLM round-trip —
so gating it on the busy flag was pure reflex, and that reflex is what turned a stuck
flag into a frozen window. Picking a template mid-generation takes its own ticket, so
the user's explicit choice supersedes the machine's pending guess rather than
racing it.
hung request. v2 had neither: a model that never answered left the surface busy until
the window was closed.
Ticket ownership is now explicit too — the background catalog refresh owns no ticket
and never touches the phase, so it can't disturb (or un-stick) whatever the user is
doing while it runs.
4. Editor polish toward the imgflip.com shape
"Add text" is now outside every conditional. v2 rendered the whole editor panel
only when
boxeswas non-empty, so deleting the last box removed the only control thatcould add one back — a dead end with no way out but regenerating. The empty canvas and
the empty box list both say what to do next.
The window is three columns:
Template search and browse are prominent rather than behind a sheet, because
picking the template is the decision the corpus expansion exists to serve. User
templates carry a badge so a mixed grid is legible at a glance. Nothing local shows a
spinner.
All v2 wins are kept: ranked candidates, honest no-match, the normalized box model, the
single render path (preview is the export), and the -4.0 stroke.
Notes for MAK-100 (plugin API contracts) — design only, not implemented here
The research ticket's three constraints were checked against this spike's manifest
schema. All three are additive; none require a breaking change:
PluginManifestdescribes anentry point kind, never an input type — nothing in the schema says
"dictated text". The meme plugin's own
importFromPasteboard()already reads theclipboard, so the pattern has a working precedent. What's missing is declaration:
clipboard access should become a declared capability, since the current
networkHostsfield is the only disclosure the manifest carries and an undeclaredclipboard read is a bigger privacy fact than a network host.
OutputTargetprotocol. Safe, and worthdoing:
PluginDictationSinkcurrently hard-codes "the plugin's own window" as bothinput and output. A
destinationfield defaulting to.ownWindowwould preserveevery existing plugin while letting new ones target cursor/file/webhook/Shortcut.
The meme plugin would plausibly declare
.file.(trigger phrases, app affinity). One caution worth recording now: the cap makes tool
surface a scarce, host-arbitrated resource, so the manifest must not let a
plugin self-assign priority. Trigger phrases belong in the manifest as hints; the
ranking and the cap must be enforced host-side, for the same reason
networkHostsis an honest label rather than a sandbox — a manifest can never be trusted to
declare its own limits.
The one schema pressure worth flagging: all three point at a capability list the
user consents to per-plugin (clipboard, destination, trigger registration). That is
already the direction the PR's security section argues for, so it's convergent rather
than a new constraint.
Not verified
Same honest caveat as v2, plus more surface: none of the v3 UI has been visually
confirmed — this environment can't screenshot the Mac app (the name resolves to the
iOS bundle). Specifically open until someone runs it:
at the minimum window width.
onDropprovider resolution is written but neverexercised against a real Finder drag.
(
warmLlamaServerIfPossiblestarts the server with no completion), but the originalsymptom was reported live and has not been reproduced-then-fixed under observation.
The 2.5s warm window is a guess, not a measurement.
curl(shape, count, keywords), butnot through the app's own decode path against the live endpoint.
The pure layer is pinned by tests; everything above is a UI/integration claim that
tests can't make.
How to test v3
PLUGINS=1 ./build.sh && ./build/OpenWhispSettings → More features → Plugins → enable Meme Generator, then menu bar →
Plugins → Meme Generator.
immediately. Expect a status of "Preparing model…" and then a normal
generation — not a network error. This is the exact moment that failed before.
Search
sweet brownandnobody got time— both find the same memegen template(name vs keyword).
кот-в-шоке.png.кот в шоке— Cyrillicintact, not transliterated.
котandшоке— both find it. Searchkotfinds it too (the filenamerides along as a keyword).
your template, because user templates sort first into the prompt.
fills from cache, your imported templates still work end to end, and there is no
error — the cache is doing its job. Now delete
~/Library/Application Support/OpenWhisp/Plugins/MemeGenerator/catalog-cache.json,stay offline, and reopen: now you get an error with a Retry button, plus the
hint to import your own.
Then force a failure (unplug the network mid-generate, or point Cleanup at a dead
endpoint): the spinner must clear and Generate must be pressable again. It must
never be necessary to close the window to recover.
icons. The right panel must still show "Add text", and the canvas a hint. Press
it — a box appears and renders. v2 left you stuck here.
the warning triangle, the honest status, the candidate strip, and no silent
Drake. Search
yodain the grid → empty, and it says so. Edit a caption, thenExport PNG… — the file must match the preview exactly at full resolution.
and if it was on screen the canvas clears rather than showing a meme built on a
template that no longer exists.
Gates:
swift test2456 passed, 0 failures ·scripts/check-appstate-ratchet.shOK at exactly 7051 ·
PLUGINS=1 ./build.sh· plain./build.sh(no plugin code) ·lean (
WHISPERKIT=0 PARAKEET=0 SPARKLE=0 PLUGINS=1) — all compile, no new warningsin the touched files (the AppState warnings that remain are pre-existing and at
untouched lines).
New tests (60, in
MemeProviderTests.swift): merge precedence and the user-firstrule, cross-source name collisions with differing ids, keyword/cross-field/Cyrillic
search, the live memegen wire shape, older-cache decoding, cache staleness including a
skewed clock and a future-version cache, silent-vs-reported refresh failure, library
naming/uniqueness/pruning, traversal-shaped filename refusal, and the busy-state
machine's out-of-order, duplicate, superseded, cancel, warming, and
always-selectable orderings. Plus one in
PluginSystemTestspinning the newapi.memegen.linkdisclosure string.Open questions
plugins/in this repo the long-term home, or a staging area until the IPC contract exists?Is imgflip's top 100 enough?Answered in v3: no. The corpus is now imgflip + memegen.link + a user-importable library. The remaining question is narrower — should the plugin ship a starter pack of non-English templates, or is user import the right and only answer? v3 bets on import, on the grounds that any curated list is somebody's culture and the shipped one would be wrong for most users.Codable— persisting it would make the editor a document surface rather than a one-shot. v3 raises the stakes: a user who imported a template and captioned it has done real work that closing the window still discards.v4 — three defects from the owner's live pass on v3
All three were reported against a running build. Each one turned out to be a
signal that already existed and was being discarded — which is the theme worth
taking out of this round.
1. Search couldn't find a template by describing it
Report: "the worst day for the planet" / "the worst day so far" does not
surface the Bart Simpson Worst Day Of My Life So Far template, even though it
is in the corpus.
Root cause. Both search entry points required EVERY query token to appear:
"planet" appears in neither that template's name nor its keywords, so one
unmatched token vetoed the three that matched perfectly, and the result was
empty. A description of a meme's content can essentially never satisfy an
all-tokens rule — which made describing a meme the one thing search couldn't do.
v3 had pinned this as intended behaviour in
testSearchRequiresEveryTokenToAppear.Fix. Matching is now SCORED over name + keywords
(
MemeTemplateCatalog.score), ordered best-first:Ties break on the catalog's own order, which is the popularity ranking. What did
not change: a query matching nothing at all still returns an empty list.
Ranking partial matches and inventing a match are different things, and the
silent substitution is the bug this spike exists to fix.
The LLM path benefits from the same scoring. v3 sent the model
promptNames(catalog, limit: 100)— the first hundred by popularity, names only.That fails twice: the relevant template can sit at position 180 of a merged ~300
corpus and never enter the prompt, and bare names give a model nothing to connect
a content description to. v4 prefilters locally first
(
MemeTemplateCatalog.prefilter, top 30 by score against the user's own words)and sends those with their keywords (
promptLines), so the model ranks ashortlist that is guaranteed to contain the right answer and is small enough for
a tiny local model to attend to. The name stays first and unadorned on each line
so
MemeAI.validatestill rejects hallucinations.2. Template download stuck on "Downloading " forever
Root cause.
renderTemplatehad two barereturns on a stale/cancelledticket that never touched the state machine, and
select()began a.downloadingticket with no timeout at all (onlygenerate()armed one).The ordering that strands it: close the window (
cancel()setsisCancelled),reopen it (
windowDidOpenclearsisCancelledwhile the phase survives) — nowthe surface is busy with no task, no timer and no Retry behind it.
This is the same bug class v3's own
MemeGenerationStatedoc comment identifiedand then reintroduced two call sites later: a bail that returns without clearing
the phase is only safe if some other task owns it, and here nothing did.
Fix.
cancellation — ends at a ticket-guarded, idempotent
finish. Finishing asuperseded ticket is a harmless no-op, so this cannot unstick newer work; what
it can no longer do is leave a phase with nobody responsible for it.
downloadTimeout, 30 s — well underthe 120 s generate ceiling, because an image is not a model load), plus a
shorter per-request transport timeout so the real reason surfaces first.
windowDidOpencalls the newstate.reset(), so a reopened window can neverinherit a phase.
(
imageFailed/retryTemplate) — distinct from the catalog's Retry, whichre-fetches the catalog and does nothing for a failed image.
3. The first generates still failed with a raw network error
Root cause. v3 gated Generate behind a guessed 2.5 s sleep. A cold
llama-server takes far longer than that to bind its port, so the guess expired
while the socket was still refusing connections and the UI fired into it.
The readiness signal existed the whole time and was being thrown away:
LlamaServerEngine.ensureRunningpolls the server's/healthendpoint and callsback only once it answers, but
AppState.warmLlamaServerIfPossiblediscardedthat completion (
{ _ in }).Fix. No timing guesses anywhere in this path.
warmLlamaServerIfPossible(provider:completion:)forwards real readiness.warmseam carries it, so "Preparing model…" lasts exactlyuntil the model can take a request — instant on a warm server, a minute on a
cold one. A warm that fails says so rather than presenting an
available-looking button in front of a model that isn't there.
check and refuse the next connection mid-restart) retries with backoff —
MemeGenerateRetry, 3 attempts, 0 / 0.75 / 2.0 s. Matched on URL errorcodes, not message text, so a non-English Mac doesn't silently stop
retrying.
park Generate behind "Preparing model…" forever — that would just be defect 2
wearing a different hat.
AppState got smaller
Warm policy moved into the pure
LLMWarmReadinessresolver (which providersneed a local server; why an explicitly-resolved provider bypasses the Cleanup
toggle, per MAK-53) and the engine call into an
AppStateextension. Net effect:AppState shrank by 15 lines, and
scripts/appstate-loc-budget.txtdrops7051 → 7036 to lock the win in.
Gates
swift test— 2485 passing, 0 failures (2456 baseline + 29 new).PLUGINS=1 ./build.sh, plain./build.sh, and lean(
WHISPERKIT=0 PARAKEET=0) all compile. No new warnings in any touched file.New tests
The repro is pinned directly, and was verified to fail against the v3
all-tokens rule before the fix (both "worst day" tests returned
nil):testWorstDayDescriptionFindsTheBartTemplate— the owner's exact query.testWorstDaySoFarDescriptionFindsTheBartTemplate— his second phrasing.testARelevantTemplateOutranksPopularOnesThatDoNotMatch,testANameMatchOutranksAKeywordMatch,testAPrefixMatchScoresBelowAWholeTokenMatch,testAPartialTokenMatchStillSurfacesTheTemplate,testMoreMatchedTokensRanksHigher,testEquallyScoringHitsKeepPopularityOrder,testAStopwordOnlyQueryStillFilters,testRankedSearchStillReturnsNothingWhenNothingMatchesAtAll— the scoringcontract, including the no-fallback guarantee.
testPrefilterPutsTheRelevantTemplateInFrontOfTheModel,testPrefilterFallsBackToPopularityWhenNothingMatches,testPrefilterNeverRepeatsATemplateWhenToppingUp,testPromptLinesCarryKeywordsAfterAnUnadornedName,testAModelCopyingTheNameOffAPromptLineValidates— the LLM shortlist.testAReopenedWindowNeverInheritsADownloadingPhase,testResetRefusesTheAbandonedDownloadsLateResult,testFinishingASupersededDownloadCannotDisturbTheNewerOne,testADownloadHasItsOwnFiniteCeilingShorterThanAGenerates,testDownloadTimeoutMessageNamesTheTemplateAndOffersRetry— the stuck-downloadorderings.
testARefusedConnectionIsTreatedAsNotReadyYet,testARealFailureIsNotRetried,testRetriesAreBoundedAndThenReportHonestly,testRetryDelaysBackOff,testNotReadyDetectionDoesNotDependOnLocalizedText,testRetryStatusNamesTheAttempt,testAWarmThatNeverCompletesCannotBlockGenerateForever— the retry policy.testTheBundledProviderIsWarmedByWaitingForItsLocalServer,testANonBundledProviderIsReadyImmediately,testTheBundledProviderWithoutItsModelIsUnavailable,testAnExplicitlyResolvedProviderBypassesTheCleanupToggle— warm policy.testSearchRequiresEveryTokenToAppearwas replaced, not deleted quietly — itencoded the defect as intended behaviour, and its successors state the new
intention.
Not verified in this round
The fixes are pinned by unit tests and the app compiles, but v4 has not been
exercised against a running app + live llama-server. The readiness gate, the
connection-refused retry, and the download timeout all depend on real transport
behaviour that
swift teststubs. The next live pass should specifically checkthat the first Generate after opening the window succeeds (or honestly says
it is still preparing), and that a template whose image 404s shows the error plus
a working Retry.
v5 — the day-long soak
The owner left the app running for a day and reported two things: template
downloads had stopped working entirely, and there was no way to start a meme
from scratch. A third item (a menu shortcut) came in alongside them.
1. Downloads stop working after ~a day of uptime
Two independent causes. Both need uptime and a window close to appear, which
is why they survived every previous round — v3 and v4 were both tested by opening
the window and using it, never by closing it and coming back.
Root cause A (primary) — the window lifecycle was unbalanced
PluginHost.open()caches a plugin's window controller for the app's lifetimeand reuses it on every subsequent open.
MemeGeneratorWindowController.initwasthe only caller of
model.windowDidOpen(), so that ran exactly once. ButwindowWillCloseran on every close, and it callsmodel.cancel(), whichsets
isCancelled = true. OnlywindowDidOpenclears that flag.So the first time the user closed the window,
isCancelledlatched true for therest of the launch, and every async result afterwards was dropped:
The downloads were not hanging. They were completing and being thrown away —
which matches the report exactly: fine all day, then permanently dead, with a
relaunch as the only cure.
Note this is the same bug class v4's own doc comments described and believed they
had fixed. v4 correctly identified that
windowDidOpenhad to reset the state,and added
state.reset()to it — but never checked whetherwindowDidOpenwasstill being called. The reset was right; it was unreachable.
Fix: a
PluginWindowLifecycleseam.PluginHosttells a cached controller itis being shown again, and the meme controller re-runs
windowDidOpen(). Setupand teardown are now balanced however many times the window is opened and closed.
The seam is on the host, not the plugin, because every cached plugin window has
this hazard the moment it does anything on close.
Root cause B — the session was unreplaceable, which made Retry a no-op
MemeTemplateService.sessionwas a process-lifetimestatic let. AURLSessionis a connection pool, and a pooled connection can outlive its own validity — the
Mac sleeps and wakes on a different network, a VPN comes up, a captive portal's
lease expires. After that, every request through that session fails identically
until relaunch.
This is also why Retry did nothing: it re-ran the request through the same
session, inheriting exactly the pool that was broken. A no-op by construction,
however many times it was pressed.
Fix: the session is rebuildable. A transport-shaped failure invalidates it
(
invalidateAndCancel, so the connections actually go away) and the next requestbuilds a fresh pool. Both Retry paths — image and catalog — invalidate first, so
a retry is always a fresh session and a fresh
URLRequestrather than areplay of the attempt that hung. Every network read funnels through one
gethelper so no path can skip the rule.
Which failures count is the pure, tested
MemeGenerationState.isTransportFailure,matched on
NSURLErrorDomaincodes (not localized message text). Narrow onpurpose: a 404 or an undecodable image says nothing about the transport and keeps
the pool.
Audited and cleared — the catalog cache TTL
Suspect (a) from the report was investigated and is not a cause. An expired
cache resolves to
.useCacheAndRefresh— show it, refresh behind it — never to awedge, and a week-old cache still serves rather than degrading to
.fetchNow. Abackground refresh failure is silent only while templates are on screen, which
is correct; with nothing on screen it surfaces the error and names Retry. Pinned
with clock-injected tests at both TTL boundaries so it stays true.
2. "New meme" — starting from scratch
A
New memebutton (SF Symbolarrow.counterclockwise, ⌘N) clears thedescription, caption boxes, candidate strip, selected template, rendered meme,
search text, and every error state. It calls
state.reset()first, soanything in flight is refused by the existing ticket guard rather than landing on
the surface the user just cleared.
The clearing is a pure
MemeCompositionvalue whosereset()returns.emptywholesale. That matters: a reset written as a dozen assignments passes its test
while silently missing the field someone adds next month. Here the test asserts a
fully-populated composition comes back exactly equal to
.empty— theproperty a partial reset actually fails.
Deliberately survives the reset: the template catalog (a corpus, not part of
this meme — clearing it would turn New meme into a network round-trip and a
spinner) and the downloaded image cache (keyed by template id, holds nothing
about this meme).
The button is always present but disabled on an untouched window, rather than
appearing when there's something to clear — a control that materializes is harder
to find than one that's dimmed, and this is the button someone reaches for when
the surface is in a state they don't understand. An error alone counts as
something to clear. The empty canvas now invites the next action instead of just
being blank.
3. ⌘M opens the Meme Generator
Manifests carry an optional
keyEquivalent. The plugin asks; the hostdecides (
PluginKeyEquivalent) — only the host can see the whole menu, and aplugin that could silently shadow ⌘Q would be a genuine hazard rather than a
papercut.
Collisions resolve against the app's reserved set (
q s , c x v a z— whatAppMainreally binds, not a padded guess) and then by list order, first-wins,matching the precedence
PluginDiscoveryalready uses for id collisions. Arefusal is silent and costs only the shortcut, never the menu row. The
Plugins pane shows the granted shortcut, resolved through the same pass, so
it can never advertise a key the menu refused.
Decode is forward-compatible (an older manifest still decodes), and a malformed
shortcut is reported by
validate()but is not fatal — losing a workingplugin over a cosmetic field would be the wrong trade.
This is the MAK-100 "manifests carry host metadata" direction: a second plugin
wanting a shortcut needs no change in
AppMainat all.Tests (35 new,
Tests/OpenWhispCoreTests/MemeRecoveryTests.swift)Clock-injected staleness:
testCacheOneSecondPastTheTTLIsShownAndRefreshedRatherThanDiscardedtestCacheOneSecondBeforeTheTTLStillAvoidsTheNetworktestAWeekOldCacheIsStillServedRatherThanForcingAFetchtestAFailedRefreshBehindAStaleCacheStaysSilenttestAFailedRefreshWithNothingCachedSurfacesTheErrorAndTheRetrySession recycling:
testSleepWakeTransportFailuresRecycleTheSessiontestNonTransportFailuresKeepTheSessiontestForeignErrorDomainsAreNotTreatedAsTransportFailuresFailure-then-recovery and the stranded phase:
testResetClearsAStrandedDownloadingPhaseWithoutATickettestWorkStrandedByAResetCannotFinishOverTheFreshStatetestADownloadFailureThenRetryRecoversTheSurfacetestTheDownloadCeilingIsFiniteAndTighterThanTheGenerateCeilingtestTemplateSelectionSurvivesAFailedDownloadReset totality:
testResetReturnsEveryFieldToTheInitialEmptyStatetestResetClearsThePromptCaptionsCandidatesAndErrorstestResetIsIdempotent,testAnUntouchedCompositionIsEmptytestACompositionHoldingOnlyAnErrorIsNotEmptytestACompositionHoldingOnlyADictatedDescriptionIsNotEmptyShortcuts:
PluginKeyEquivalentTests(9) +PluginManifestKeyEquivalentTests(6),covering normalization, the reserved set, list-order collisions, forward-compatible
decode, and non-fatal malformed shortcuts.
Gates
swift test— 2520 passing (2485 baseline + 35 new), 0 failuresPLUGINS=1 ./build.sh, plain./build.sh, and lean all compileNot verified in this round
inspection and pinned by the state-machine tests, but the actual
close-wait-a-day-reopen sequence was not performed. The specific live check
worth running: open the plugin, close it, reopen it, and confirm a template
download still completes — that alone exercises root cause A, and needs
seconds rather than a day.
isTransportFailureand the recycling are unit-tested, but no real sleep/wake cycle was performed
against the live CDNs, and
MemeTemplateServicesits behindPLUGINS=1so itis outside the
swift testtarget.sessionGenerationis surfaced in thefailure status line specifically so the next report can distinguish "never
recycled" from "recycled and still failing".
menu shortcut (it fires with the menu open), the same as the Scratchpad's
existing ⌘S — not a global hotkey.
v6 — the AI pipeline, critiqued
v5 fixed the plumbing (stuck states, dead retries, a recyclable session). v6 goes
after the algorithm, which had two assumptions baked in since v1: every meme is
a top line and a bottom line, and the model should copy template names verbatim.
Both are wrong for most of the corpus, and both had the data to do better sitting
unused on the wire.
1. Per-template caption structure — the biggest quality fix
Templates aren't all top/bottom. Drake is two side labels, Distracted Boyfriend is
three, Expanding Brain is four. Rendering a four-panel meme with a top line and a
bottom line isn't a stylistic choice — the joke lives in the per-panel captions, so
the meme is simply broken.
Both sources carried the structure and v5 discarded it:
get_memesbox_count/templateslinesBoth now decode into
MemeTemplate.captionSlots, clamped to1...8(
MemeCaptionSlots) and defaulting to 2 — so a v5 disk cache, a user-libraryimport, and any source that doesn't report it all behave exactly as before.
The LLM contract changes with it: the shortlist annotates each non-default template
[N captions], and the model returns a captions ARRAY sized to its top pick'sslot count instead of
top_text/bottom_text. The parser keeps accepting the legacypair as a 2-slot response, so a model that ignores the new schema is no worse off.
Slot geometry: synthesized, not sourced — the decision
The brief hoped memegen would supply real box positions. It does not. Verified
against both live APIs on 2026-08-03:
GET https://api.memegen.link/templatesandGET /templates/<id>both return{id, name, lines, overlays, styles, blank, example, source, keywords, _self}.linesis a count. There is no geometry field anywhere in either payload.get_memesis the same story:box_count, no rectangles.imgflip does expose per-box geometry — but only through the authenticated
caption_imageendpoint, which is a server-side captioning API this plugindeliberately does not use. Routing the user's words through it would put their
text on someone else's server, which is the local-first line the whole plugin holds.
So every position is synthesized from the count, and the doc comment on
MemeCaptionLayout.slotCenterssays so plainly rather than implying templateaccuracy — a wrong claim about provenance is how the next person "fixes" a fallback
that was never a fallback.
The fallback layouts:
The 3–4 case is an explicit compromise and worth naming as one. The templates
that matter here (Drake, Distracted Boyfriend, Expanding Brain, Galaxy Brain) are
panel memes: captions belong beside or inside stacked panels, never spread
top-to-bottom across the frame. A stacked column is roughly right for a
vertically-panelled template (Drake, Expanding Brain — the most common panel layout
by far) and only approximately right for a horizontally-panelled one (Distracted
Boyfriend). That trade is deliberate: approximately-right and draggable beats
confidently-wrong and invisible. The user sees N captions in N distinct places and
moves them, instead of getting two captions for a four-panel joke.
The real fix is per-template geometry, which needs a data source none of the key-less
APIs provide — a bundled table of hand-measured boxes for the top ~30 templates would
do it, and that is a deliberate non-goal for a spike.
2. Numbered candidate references
The shortlist has been numbered since v4 and the numbers went unused: v5 asked the
model to copy names "EXACTLY", then dropped anything that didn't match. That is a
transcription task, and it is the single most fragile thing you can ask a tiny local
model to do — it re-capitalizes, expands "Y U No" to "Why You No", drops or folds in
the parenthesized keywords, and every one of those was a silently discarded
candidate.
The model now answers
{"templates": [3, 17, 1]}.MemeAI.resolveaccepts:firstCandidateNumber), indexing the shortlist it was shown["3"]means the third template, not a template named "3"Out-of-range numbers are DROPPED, never clamped. Quietly handing back the last
template for
47would be v1's confident-Drake bug wearing a number. Dedupe is on theresolved template, so a number and its own name can't occupy two of the five strip
slots. An element of an unrecognized shape (an object, a
null) is skipped ratherthan failing the decode — one weird element must not cost the user the four good
candidates beside it.
3a. Captions re-fit on a candidate switch
The strip's promise is "same joke, different template". That held while everything was
two-slot and breaks the moment structure varies: Drake (2) → Expanding Brain (4) left
two captions on a four-panel meme; the reverse stacked four captions on two panels.
Redistributing locally can't work — going 2 → 4 needs two new lines invented in the
user's language and the joke's voice. So a small second LLM call ("same joke,
refit to N slots"), with no catalog in the payload and no ranking.
What keeps it from becoming the next stuck-state bug:
MemeGenerationState, so Cancel works and asuperseded refit can't overwrite a newer one.
the user can click straight past it and the in-flight refit is refused when it lands.
ticket, timeout — ends at
finishfor its own ticket.succeeded, so surfacing an error would make a working action look broken.
That's the common case (two thirds of the corpus is 2-slot), so the strip does not
get slower than v5.
3b. Regenerate no longer clobbers manual work — the rule
v5 assigned
boxes = seedBoxes(...)outright, so a user who pressed "Add text", typeda third caption and positioned it lost it the moment they tweaked the description and
regenerated. Silent destruction of manual work is the worst class of editor bug, and
there is no undo here.
Of the two options in the brief, this takes preserve-user-boxes over confirm-first:
hand-added) to protect the rare one. Generate is the plugin's primary verb.
and predict; a dialog they dismiss reflexively is not.
click on the trash, a destroyed caption is retyped and repositioned from memory.
Edits to a seeded box are still replaced — that box is the AI's answer to the old
description, and a regenerate asks for a new one. Keeping the old text would make
Generate look broken. Boxes typed before the first generate count as user-added and
survive.
4. Cheap learning signal
Clicking past the model's first pick is a correction — unambiguous, free, and produced
by an action the user was taking anyway. v5 discarded all of it.
MemeTemplateAffinityadds a small persisted boost per template, applied in theprefilter ranking. The bounds matter more than the signal, because an unbounded
boost is a personalized version of the confident-Drake bug:
name-token match (100). One correction reorders near-ties and nothing else.
the signal saturates instead of compounding, so a long-lived store can't slowly
take over the ranking. Still far below the whole-phrase (5,000) and exact-name
(10,000) tiers, so typing a template's name always wins.
rankedto templateswhose lexical score is already > 0, never to a zero-scoring one. This is the
load-bearing rule: "no template matches" stays reachable however much the store
has learned, and a favourite can never appear for an unrelated query.
popularity order, and floating favourites into it would make its order mean two
different things depending on whether the search box was empty.
correction, and boosting it would be a feedback loop rather than a lesson.
dominating boost.
Affinity survives New meme deliberately — it isn't part of this meme, it's what the
user has taught the ranker across all of them.
Also: the discarded "think first" invitation
v5's prompt said "Think about which ones could carry the joke, then rank your best
options" — reasoning the parser then threw away. Pure token cost with no consumer,
on the models least able to afford it.
Of the two options in the brief, this takes surface the reason over drop it. The
model now returns one short
reason, shown as an info tooltip on the candidate strip.Same request for reasoning, routed somewhere the user can read it: if the model picked
Drake for a bad reason, the user reads the bad reason instead of guessing why the
thumbnail is there. Reasoning that is displayed earns its tokens; reasoning that is
discarded does not. It's attached only to the first candidate, since that's the
pick the model was asked to justify. Thumbnails also now show the slot count, so a
switch that's about to reshape the captions is visible before the click.
All v5 wins are kept: scored search, the lifecycle seam, session rebuild, reset, ⌘M.
Tests (73 new,
MemeStructureTests)Slot counts off the wire:
testImgflipBoxCountBecomesCaptionSlots,testMemegenLinesBecomeCaptionSlots,testImgflipWithoutBoxCountDefaultsToTwoSlots,testMemegenWithoutLinesDefaultsToTwoSlots,testZeroAndNegativeSlotCountsAreClampedUp,testAbsurdSlotCountIsClampedDown,testAV5CacheWithoutSlotsStillDecodesAtTheDefault,testCaptionSlotsRoundTripThroughTheCacheSlot geometry + seeding:
testTwoSlotsKeepTheClassicTopAndBottomLayout,testOneSlotIsASingleCenteredCaption,testPanelSlotsAreDistinctEvenlySpacedAndInsideTheFrame,testPanelLayoutsUseNarrowerBoxesAndSmallerTypeThanTheClassicPair,testSlotGeometryIsClampedLikeEveryOtherSlotCount,testSeedingProducesOneBoxPerSlotInPanelOrder,testExtraCaptionsBeyondTheSlotCountAreDropped,testTooFewCaptionsStillFillEverySlotWithAnEmptyBox,testTheClassicTopBottomSeedIsTheTwoSlotCaseNumbered references:
testNumberedCandidatesResolveToTheShortlistEntriesTheyIndex,testTheNumberingIsOneBasedMatchingWhatThePayloadPrints,testOutOfRangeNumbersAreDroppedRatherThanClamped,testAnAllOutOfRangeAnswerLeavesNoUsableTemplate,testExactNamesAreStillAccepted,testNumbersAndNamesMayBeMixedInOneAnswer,testANumericStringIsTreatedAsAnIndexNotAName,testANumberAndItsNameCollapseToOneCandidate,testNumberedCandidatesAreCappedAtFive,testAnUnparseableElementIsDroppedWithoutFailingTheWholeAnswer,testAnEmptyShortlistResolvesNothingRatherThanCrashingCaption arrays + legacy shape:
testCaptionsArriveAsAnArrayInPanelOrder,testLegacyTopAndBottomTextDecodeAsATwoSlotResponse,testACaptionsArrayWinsOverStrayLegacyKeys,testATrailingEmptyCaptionIsDropped,testAnInteriorEmptyCaptionIsKeptSoPanelsDoNotShift,testTheModelsReasonSurvivesForTheStripTooltip,testAMissingReasonIsNotAFailure,testAnAnswerWithNeitherTemplateNorCaptionIsStillRejectedPrompt + payload:
testThePromptAsksForNumbersACaptionArrayAndAVisibleReason,testOnlyNonDefaultSlotCountsAreAnnotatedInThePrompt,testASlotArrayShorterThanTheLinesDegradesToTheDefault,testThePayloadCarriesTheSlotCountsAndExplainsTheUnmarkedCase,testPromptSlotsAlignPositionallyWithPromptLines,testPromptSlotsRespectTheSameLimitAsTheLinesRefit:
testNoRefitIsNeededWhenTheSlotCountMatches,testARefitIsNeededWhenTheSlotCountDiffers,testNoRefitForCaptionsThatAreAllEmpty,testRefitNeedIsJudgedAgainstTheClampedSlotCount,testTheRefitPayloadCarriesTheJokeTheCurrentCaptionsAndTheTarget,testTheRefitPromptPinsTheLanguageAndForbidsPadding,testARefitIsPaddedUpToTheSlotCount,testARefitIsTruncatedDownToTheSlotCount,testARefitDigsItsJSONOutOfProse,testAnUnusableRefitReplyIsRefusedRatherThanBlankingTheCaptionsRegenerate preservation:
testRegenerateReplacesTheAISeededBoxes,testRegeneratePreservesABoxTheUserAdded,testUserBoxesAreAppendedAfterTheNewSeedWhateverTheSlotCount,testAnEditedSeededBoxIsStillReplaced,testTheFirstGenerateOnAnEmptyCanvasJustSeeds,testBoxesTypedBeforeTheFirstGenerateAreTreatedAsUserAddedAffinity + its caps:
testAPickBoostsThatTemplate,testRepeatedPicksAccumulate,testTheBoostSaturatesAtTheCapAndNeverExceedsIt,testSaturationTakesTheDocumentedNumberOfPicks,testOneBoostIsWorthLessThanOneKeywordMatch,testDecodingReAppliesTheCap,testAffinityRoundTripsThroughJSON,testAnEmptyIDIsNotRecorded,testResetForgetsEverything,testABoostPromotesATemplateOverAnEquallyScoringOne,testASaturatedBoostCannotMakeANonMatchingTemplateAppear,testASaturatedBoostCannotOutrankAnExactNameMatch,testAnEmptyQueryKeepsPopularityOrderRegardlessOfAffinity,testThePrefilterHonoursTheLearnedBoost,testRankingWithoutAnAffinityIsUnchangedOne v5 test renamed:
testRankedPromptForbidsInventingNamesAndPinsCaptionLanguage→…ForbidsInventingCandidatesAndPinsCaptionLanguage. It pinned the literal "copiedexactly" wording; the anti-invention guard it protects is now the numbering, so the
assertion moved rather than being dropped. The language guard is untouched.
v6 gates
swift test— 2593 passing (2520 baseline + 73 new), 0 failuresPLUGINS=1 ./build.sh, plain./build.sh, and lean (WHISPERKIT=0 PARAKEET=0)all compile
v6 — not verified
rankedPrompt,refitPrompt)and the parser are pinned by unit tests against synthetic replies, but no actual
local model was asked to produce a numbered array or a refit. Whether a small
local model reliably returns
[3, 17, 1]and exactly N captions is precisely theclaim tests can't make. This is the highest-value live check:
./scripts/e2e-app-features.shwith the app running andllm=configured, thengenerate a 4-slot meme (e.g. "expanding brain about deploys") and confirm four
captions land in four boxes.
pinned as distinct, ordered, evenly spaced, inside the frame; whether the stacked
left column actually sits well on Distracted Boyfriend is a visual judgement no test
makes, and the Mac app can't be screenshotted in this environment.
through candidates with differing slot counts should supersede each refit; the
ticket guards are the same ones v5's tests cover, but the specific
click-through-mid-refit sequence was not performed against a running model.
MemeTemplateAffinityis fullycovered as a pure value, but
MemeLibraryStore.loadAffinity/saveAffinitysitbehind
PLUGINS=1, outside theswift testtarget — the same structural limit therest of the store has.
box_count/linesdistributions quoted above are from live calls that day. Both fields are optional in
the decoders, so a schema change degrades to 2-slot rather than breaking the catalog.
v7 — the model shouldn't have been writing those captions at all
The v6 live failure, from the owner's screenshot. Prompt:
Expanding Brain was picked correctly (4 slots) and only TWO captions rendered,
seeded as a classic top/bottom pair. Root cause, confirmed by reading the v6 code
rather than guessing:
top_text/bottom_textform.RankedWire's backward-compat branch accepted it — that branch can only everproduce exactly two captions.
applyRankedhanded two captions to a 4-slotseedBoxes, which padded withblanks and rendered.
v6 had already tightened this prompt twice. A third tightening would have been
treating the symptom, so v7 changes the algorithm in three places.
1. Read the captions the user already said (highest leverage)
Look again at the prompt: the four captions are right there, comma-separated,
in order, after a colon. Asking a 1.5B local model to re-derive four strings it was
just handed is inventing a language task where none exists — and every such
round-trip is a chance to get two back instead of four.
MemeCaptionExtraction(new, Foundation-only, in core) reads list-shapeddescriptions directly and uses the items verbatim as captions, skipping LLM
caption-writing entirely. The LLM keeps the one job it is actually needed for:
picking a template. A model cannot return the wrong number of captions for a
request that was never made.
Recognised shapes: colon lists (
theme: a, b, c), numbered runs (1. a 2. b),newline/bulleted lists, and the spoken final joiners (
a, b and c,then, plusи/затем/und).The negative cases matter more than the positive ones. This runs before the LLM
on every generate, so a false positive would hijack ordinary prose and caption the
meme with sentence fragments. Three independent gates:
enough — that is how people write prose.
make me a drake meme about rust, python and gois prose about three things and is not extracted (pinned by a test).hold it.
steps: first you plan the whole thing out carefully, then you throw it away entirelyis prose with a colon, andfalls through.
Anything failing a gate goes down the v6 LLM path completely unchanged.
The extracted theme (
expanding brain) is also split off and used as the templatequery instead of the whole sentence, so the caption words don't pollute the search.
2. Host-side slot enforcement
MemeAI.fitis now the single rule for whether captions may be rendered:The refit uses the ticket-guarded call that already existed ("same joke, exactly N
captions") — v6 built it for template switches and never reached for it on the
generate path.
refitCaptionsis now extracted so both entry points share oneimplementation and can't drift; the status line differs because the two moments mean
different things to the user.
RankedSpec.wasLegacyShaperecords which wire shape produced the captions, so thehost can tell a deliberate 2-slot answer from a model that never engaged with the
slot count. The count is what decides today — the flag is what makes the
distinction legible without re-parsing.
Slot geometry is now unconditional, which is the screenshot's second bug: boxes
always seed into the template's own layout, so a 4-slot template gets four
panel-positioned boxes no matter what the model wrote. Ordering is deliberate — the
template renders first, then the refit runs, so the correction is visible rather
than a longer wait.
3. Constrained decoding — grammar enforcement DID land
Yes, this works. Every other guard in this file is a parser deciding after the
fact, which is a losing game against a small local model one shape at a time.
llama-server implements OpenAI-compatible
response_format: {"type": "json_schema"}by compiling the schema to a GBNF grammar and constraining the sampler. So the
bad shapes become unrepresentable rather than rejected downstream:
templatesis typedinteger→ an invented template name is not a reachabletoken sequence. v6's numbered-reference idea becomes airtight rather than
best-effort.
minItems == maxItems == N→ "wrote 2 of 4" cannot beemitted. The refit is the one call where the required count is known up front.
reach for them. The v6 bug's entry point is closed at the sampler.
The plumbing turned out to be tractable.
ChatCompletionRequestgained an optionalresponse_format(encoded withencodeIfPresent, so every existing caller'srequest bytes are byte-identical to v6);
processFinalTextandsummarizeResolvedgained a defaulted-nil parameter; the plugin's
AICallseam carries the schemabecause only the model knows which of the two shapes a given call expects. Schemas
are built as
JSONValuevalues in core rather than raw strings — a schema stored asa string literal would be exactly the untested wiring this spike exists to avoid.
AppStategrew zero net lines — the ratchet is still at exactly 7036.Host-side
fitis kept regardless: not every endpoint enforces schemas, and theparser must stay correct for the ones that don't.
Also: slot-count-aware template search
When the item count is known,
prefilterstably reorders exact-slot matches first.Reordering, never filtering — a 4-item list whose best lexical match is a 2-slot
template should still see it (the refit path handles the mismatch); hiding a template
the user described would be the confident-Drake bug in a new costume.
v7 tests (31 new,
MemeSlotEnforcementTests+ 2 inMemeStructureTests)testTheScreenshotPromptYieldsExactlyFourCaptionsInOrder— the exact reprostring → exactly 4 captions, in order, theme split off.
testTheReproEndsWithFourFilledBoxesAndNoRefit— end-to-end minus the network:extract → replace → fit → seed, with the model answering in the legacy shape.
testTwoCaptionsOnAFourSlotTemplateRefitRatherThanRender— the v6 bug, now caught.testTheLegacyShapeIsAcceptedAsFinalOnATwoSlotTemplate/testACaptionsArrayOfTwoIsNotFlaggedAsTheLegacyShape— the 2-slot-only rule.testTwoCaptionsOnAFourSlotTemplateStillSeedFourPanelBoxes— asserts the boxes arenot at the classic top/bottom centers.
testProseWithCommasIsNotTreatedAsAListand four more negative cases — thefalse-positive guard.
testTheRefitSchemaPinsExactlyTheRequestedCaptionCount,testTheRankedSchemaForcesNumericTemplateReferences— schema contents, includingthat
top_text/bottom_textappear nowhere.2-slot-only rule rather than deleted.
v7 gates
swift test— 2624 passing (2593 baseline + 31), 0 failures.PLUGINS=1 ./build.sh, plain./build.sh, andWHISPERKIT=0 PARAKEET=0 ./build.sh— all succeed.
files.
v7 — not verified
by tests and the plumbing compiles, but whether this llama-server build accepts
this
response_formatshape and honours the grammar is exactly the claim a unittest cannot make. This is the highest-value live check — run
./scripts/e2e-app-features.shwithllm=configured, generate with the reproprompt, and confirm four captions. If the endpoint rejects the key, the host-side
fitstill catches the mismatch and refits, so the failure mode is v6-with-a-refitrather than a break — but that fallback path was not exercised live either.
mismatch produces
.refitand that the seed happens first; that the status linereads well as the captions visibly change is a judgement no test makes.
of real dictations. The 6-word caption ceiling and the colon requirement are
judgement calls. A user who dictates lists differently (semicolons, "first… second…")
falls through to the LLM path — correct but unproven against real speech.
и/затем/undare in thesplitter, but no test dictation exercises them.
render was verified through box geometry assertions rather than by looking at it.
v8 — the tests passed and the app was still broken
Root cause of the live failure: it was v6 code
The owner's screenshot (Expanding Brain, four spoken items, two boxes carrying
"typing"and"dictating memes by voice"seeded top/bottom) was produced by v6,not by v7.
MemeCaptionExtraction.swift— the whole v7 caption fix — first appears ine0386cf, committed at 14:51 today; the build in the screenshot predates it.Reproduced exactly against v6's
applyRanked:Those are the lines that collapsed 4 → 2.
slotswas already correctly4(imgflipreally does ship
box_count: 4for Expanding Brain — verified against the live API), butspec.captionscame from the legacytop_text/bottom_textbranch ofRankedWire,which can only ever yield two.
seedBoxespadded the rest, and nothing compared thecount to the template:
That is the screenshot, character for character.
The defect that outlived the fix
The v7 fix was real, but the reason v7 could ship a passing test suite over a broken app
is still present, and that is what this section actually addresses.
testTheReproEndsWithFourFilledBoxesAndNoRefitre-implements the app's sequence insidethe test body — extract, then
replacingCaptions, thenfit, withslots: 4written asa literal. Every core piece was proved in isolation. But the code that chains them
lived in
plugins/MemeGenerator/MemeGeneratorModel.swift, which compiles only underPLUGINS=1and sits outside theswift testtarget. So the chain was untested byconstruction, and a test that spells out the right sequence proves nothing about an app
that performs a different one — which is exactly what v6 did.
Per the wiring-review lesson in CLAUDE.md: the wiring, not the rule, is what shipped broken.
The structural fix
New
OpenWhisp/Services/MemeCaptionSeeding.swift(registered inPackage.swift) ownsthe entire captions→boxes decision:
Three rules, each closing one way the bug returns:
the captions verbatim; a model can't return the wrong count for a question never asked.
count — an N≠2 template cannot render as a classic two-liner.
templateQuery(for:)shares that same single extraction with the template search, so thequery and the slot preference can't disagree about whether the description was a list.
applyRankedis now a call toresolveplus UI glue.apply(seed:)is the only placeboxesis assigned; no description-reading logic remains inplugins/.Two-caption entry points closed
MemeRenderer.render(template:topText:bottomText:)→@available(*, unavailable). It hadno callers; leaving a top/bottom-shaped overload in reach is precisely how a 4-slot
template gets collapsed at the boundary without failing a test.
MemeCaptionLayout.seedBoxes(topText:bottomText:)andRankedSpec(templateNames:topText:bottomText:)→@available(*, deprecated). Noproduction callers remain; kept (deprecated, not unavailable) because several test
assertions are legitimately about the 2-slot layout and rewriting them would lose that
coverage.
The test that would have caught it
Tests/OpenWhispCoreTests/MemeCaptionSeedingTests.swift— 10 tests driving the samefunction the app calls, from a raw legacy-shape model reply through to boxes: caption
text and order, slot geometry (four distinct stacked centers, not the classic
0.12/0.88 pair), refit reachability, and the negative assertion that the first-and-last
pair never appears.
Verified these fail against the v6 behaviour rather than passing vacuously — with
extraction disabled the repro test reports exactly
["typing", "dictating memes by voice", "", ""].Re-verified v7's other wiring claims against the app path
aiCallsites pass one, and it survives the fullchain:
configureAI→ResponseFormat.jsonSchema→AppState.summarizeResolved→
processFinalText→encodeIfPresent(responseFormat)on the wire.applyRanked's real caller, now viaseed.refit.Remaining un-testable surface (honest)
renderTemplate, image download,redraw, ticket/timeout state — AppKit + network,reachable only under
PLUGINS=1. They carry no caption-count decisions.refitCaptionsis still app-layer: the decision (MemeAI.fit,parseRefit,refitStatus) is core-tested, but the async round-trip, ticket guards and status writesare not. Extracting it needs an injectable LLM seam — worth doing, out of scope here.
seedBoxes(captions:slots:)'s two remaining callers (pick-a-template-before-generatingand apply-a-completed-refit) legitimately have no description to read; both route through
the same core layout and the single
applypath.screenshotted in this environment, so geometry is asserted numerically.
Gates
swift test2634 green (2624 baseline + 10 new) · ratchet 7036/7036 ·PLUGINS=1 ./build.sh, plain./build.sh, and lean all compile · no new warnings intouched files. Not merged.
v10 — refine-mode voice commands route to the plugin (first MAK-100 trigger layer)
Until now the plugin was something you opened. v10 makes it something you can ask
for mid-dictation: with refine armed, an instruction that starts with a phrase the
plugin declared is handed to the plugin instead of to the refine LLM.
The owner's two flows, both working:
What CASE 2 required (it was genuinely broken)
Refine with no content never armed.
armRefineMidSessionbailed with "Nothing torefine yet — dictate first, then tap Refine" whenever there was no in-session text, no
selection, and no last dictation. So
refineContentSnapshotstayednil,deliverFinalText's refine branch never ran, and a spoken command could not reach arouter no matter how good the router was.
It now arms with an empty snapshot — but only when an enabled plugin actually
declares triggers (
PluginHost.armsWithoutContent). With no such plugin the old statusstring is byte-for-byte what it was. And when the instruction turns out not to be a
command,
RefineFlow's existing empty-content rule produces exactly the same "Nothingto refine" outcome as before.
InstructionChain.instructionSuffixneeded no change:with empty content,
hasPrefix("")is true, so the whole utterance becomes theinstruction.
Architecture — declared, not hardcoded
PluginManifest.voiceTriggers: [String](forward-compat decode; normalized to trimmed,lowercased, de-duped phrases). An all-empty list is reported as
.emptyVoiceTriggersbut is never fatal — the same trade
keyEquivalentalready makes. Thatnormalization is load-bearing: an empty prefix would otherwise match every
instruction the user ever spoke.
PluginVoiceCommandRouter.match(instruction:enabledPlugins:) -> Match?is pure core.The matching is deliberately strict, because a match redirects a dictation away from
the user's editor and a false positive costs them text:
"create a mem"+"o"The remainder is sliced from the original text (not the lowercased form) so the
user's capitalization reaches the rendered captions, and only leading separator
punctuation is trimmed — the interior commas in
"typing, dictating, …"are thelist the extraction step later reads.
The meme manifest declares EN + RU —
create a meme,make a meme,generate a meme,сделай мем,создай мем— because the owner dictates in both. The checked-inmanifest.jsonmatches the registry literal (the existing parity test pins this).No insertion, and failure never eats the dictation
The route is consulted in
deliverFinalTextbeforerunLLMand before anyinsert. A claimed command returns
RefineFlow.Effect.finishQuietly— the sameteardown a contentless refine already uses — so the session ends through one auditable
no-insert path rather than a second hand-rolled one. Verified against the real delivery
path: nothing reaches
insertCompletedText, so nothing lands in the focused app.A non-match, a disabled plugin, or a window that can't take the command all fall
through to the untouched normal refine. The disabled case additionally sets a one-line
hint, and only when the trigger matched exactly (
matchIgnoringEnablement) — so itcan never fire on an unrelated refine.
Overlay: the acknowledgment names the plugin (
"Meme Generator — creating…") andreaches the overlay through
statusMessage, whichFinalizingCaption.resolvealreadysurfaces verbatim — no new
OverlayPhasecase and no view change. A core test pinsthat the two agree.
AppState touch: ~10 lines (one
if let, one guard, both delegating toPluginHost).The ratchet budget drops 7036 → 7024: the consent-decision, dictation-stats-event,
rules-firing, and refinement-mode constructions moved into the pure core, so AppState
net shrank while gaining the feature.
Runtime proof
swift testproves the router but cannot prove the pipeline reaches it — the refinepath lives on AppState, which the core test target doesn't compile. So the launch-gated
probe now drives
PluginHost.routeVoiceCommand, the same callAppState.deliverFinalTextmakes when a mid-dictation refine finalizes, with the same(instruction, content)pair.scripts/meme-voice-command-proof.sh [case1|case2|nearmiss|all].CASE 1 — selection is the material:
CASE 2 — the owner's exact expanding-brain prompt, all four items survive:
Near miss — "create a memo…" must stay a normal refine:
Plugin disabled — matched, but falls back with the hint (run under a separate bundle
id so the real user defaults were never touched):
Still true / still shortcuts
gets a normal refine. Deliberate for now — leading-filler tolerance is the first thing
worth adding, and it should be tested against the negatives above before it ships.
plugin; a real system probably wants the user to arbitrate.
there is no notion of "the selection is the subject, the remainder is the style."
Gates
swift test2670 green (2645 baseline + 25 new) · ratchet OK, budget lowered7036 → 7024 ·
PLUGINS=1 ./build.sh, plain./build.sh, and lean all compile ·no new warnings in touched files. Not merged.
New tests:
PluginVoiceCommandRouterTests— trigger matching (bare prefix, selectionphrasing, the owner's colon-list prompt, casing/whitespace, original-casing remainder,
RU), negatives (
create a memo,create a memes, mid-sentence mention, unrelated,empty), enablement gating +
matchIgnoringEnablement, longest/list-order precedence,trigger normalization incl. the empty-prefix trap, forward-compat decode, the shipping
manifest, and the overlay acknowledgment/disabled-hint strings incl. their round-trip
through
FinalizingCaption.🤖 Generated with Claude Code