Skip to content

spike(plugins): plugin system prototype + voice-driven meme generator — DO NOT MERGE - #243

Closed
initcore0 wants to merge 15 commits into
mainfrom
spike/plugin-system
Closed

spike(plugins): plugin system prototype + voice-driven meme generator — DO NOT MERGE#243
initcore0 wants to merge 15 commits into
mainfrom
spike/plugin-system

Conversation

@initcore0

@initcore0 initcore0 commented Aug 2, 2026

Copy link
Copy Markdown
Owner

➡️ Superseded by #244

The productionized version is open at #244 (feat/plugin-system-v1),
branched fresh off main and mergeable — no do-not-merge marker.

Everything you tested across these ten iterations is carried byte-identical.
What #244 adds on top:

  • PLUGINS ships on by default (PLUGINS=0 is the lean escape hatch), wired
    into build-dmg.sh too so the released DMG isn't the only build with an empty
    Plugins pane. A verify-plugins-binary.sh guard fails the build if the plugin
    symbols are missing.
  • MemeTrace and the launch probes are compiled out of consumer builds
    (OPENWHISP_INSTRUMENTATION), verified against real binaries in both
    directions. The missing scripts/meme-runtime-proof.sh now exists.
  • MAK-100 contracts landed: clipboardAccess (declared and gated, with
    the plumbing + tests), destination (reserved routes validated and refused
    honestly), and appAffinity — all forward-compatible.
  • docs/PLUGINS.md, carrying this PR's hot-swap comparison as a committed
    roadmap
    , plus CI jobs pinning both sides of the PLUGINS flag.

This PR is left open for you to close, since the iteration log below is the
design record for every decision in #244.


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.sh carries 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 by swift test), IO and AppKit in the app.

Core (OpenWhisp/Services/, all tested):

  • PluginManifest — id, name, version, SF Symbol, entry kind, and a networkHosts disclosure. 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.
  • Menu bar → Plugins submenu, present only when something is enabled.

The provider seam (this is the part that matters)

PluginHost does not know about the compile-time registry. It enumerates providers:

private static var providers: [PluginDiscovery.Provider] {
    [
        .init(source: .builtIn)  { PluginRegistry.builtInManifests },
        .init(source: .external) { PluginDiscovery.loadExternalManifests(in: externalDirectory) },
    ]
}

The registry is one entry in that list. The disk provider re-reads ~/Library/Application Support/OpenWhisp/Plugins/<id>/manifest.json on every reload(), 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 (the liveChunks branch 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.sh still 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.

  • Security: the only option with real isolation. The plugin runs as its own process, gets its own sandbox profile, and does not inherit the host's TCC grants — a plugin cannot read the screen or the clipboard just because OpenWhisp can. A crash takes out the plugin, not the dictation pipeline.
  • Distribution: the app already ships helper binaries (whisper, llama, the openwhisp CLI at Contents/Helpers/) and already has a local-socket bridge protocol precedent (Agent Bridge + MCP). This reuses both.
  • Cost: an IPC surface and a UI-contribution schema. The real work.
  • Signing: third-party binaries need their own notarization story, or the host must be willing to run unnotarized helpers behind an explicit consent gate. This is the hard part, and it is a policy problem rather than an architecture one.

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). ScriptPostProcessor and ConfigPack are already this shape.

  • Security: the host owns every capability, so the plugin can only compose things the user already consented to. Reviewable by reading a manifest.
  • Cost: lowest. Ships fastest.
  • Limit: can't express the meme plugin's live preview. Good for text/actions, weak for custom UI.

3. WKWebView-hosted plugin UIs — middle ground

Plugin ships HTML/JS; the host exposes a narrow message-passing bridge.

  • Security: the web sandbox is genuinely good and the bridge is an explicit allowlist. But a web view is also a network egress the user may not expect, so CSP has to be locked down.
  • Cost: medium. Buys arbitrary UI without native code.
  • Fit: pairs well with (1) — process for logic, web view for UI.

4. Loadable bundles / dylibs — do not do this

  • Security: unacceptable. In-process code inherits Accessibility and clipboard rights wholesale — a plugin becomes a keylogger with the app's own consent prompts already granted. docs/ROADMAP.md §6 already rejects this.
  • Signing: loading unsigned/third-party code breaks the hardened runtime and library validation; the entitlement to permit it weakens the whole app.
  • ABI: Swift has no stable ABI guarantee across a plugin boundary you don't compile together, so every app update risks breaking every plugin.
  • Crash isolation: none. A bad plugin crashes dictation.

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 networkHosts field is an honest label, not a sandbox — that distinction is called out in the code.

What is real vs stubbed

Real:

  • Discovery, merge/precedence, enable/disable, pruning, persistence.
  • Settings pane, menu-bar submenu, plugin window lifecycle.
  • Dictation lands in a focused plugin window (both AppState branches).
  • Meme flow end to end: live imgflip catalog → LLM (given the real template names) → validated ranked candidates → candidate strip / Browse-all override → download → local CoreGraphics captioning from the box model → draggable editor → Export PNG → Share.
  • Verified against the live API: all 100 templates decode.
  • v2: template candidates are validated against the real catalog, so an invented name can no longer become a confident wrong template. See the v2 section below.

Stubbed / deliberately not done:

  • No loader. External plugins are listed but never runnable; the pane says so.
  • PLUGINS=1 ./build.sh is a compile-time toggle, not an installer.
  • Per-plugin config is a fixed switch on plugin id — a real system needs a declared settings schema.
  • The meme plugin has no model picker; it follows Settings → Cleanup.
  • The corpus is imgflip's public top 100 — the real ceiling behind the "yoda" report. Expanding it is future work (see v2).
  • Edited memes are not persisted; closing the window loses the boxes (the box model is Codable, so this is a decision rather than a blocker).
  • No plugin sandbox, no capability enforcement, no signature checking.
  • Windows are not literally "tabs" — a menu-bar submenu entry per plugin, which is the cheap equivalent.

In-repo vs separate repo

In-repo was right for the spike and I would keep it for now:

  • The plugin needs summarizeResolved, ScratchpadAIModel, SummaryModelResolver, and BridgeWire.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 made OpenWhispCore a versioned contract, and doing that a second time before the plugin API has settled would freeze the wrong shape.
  • swift test covers the plugin's rules in the same run as everything else. That stops the day it moves out.
  • The friction is real though: plugins/ is outside build.sh's glob, so it needed its own flag, and Package.swift's explicit sources: 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:

  1. "yoda meme" found nothing in the top-100 and silently fell back to Drake.
  2. The AI should think and propose multiple templates to pick from.
  3. There should be a manual editor"AI makes its best guess, human makes it perfect."

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.parseRanked validates 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:

  • Candidate strip — the ranked picks as thumbnails under the status line. The best one auto-renders; clicking another re-renders the same captions onto it (captions and box positions carry over, which is what normalized geometry buys).
  • Browse all… — a searchable grid over the whole catalog, always available (it loads the catalog on demand, so it works before any generate). Search is local, case/punctuation-insensitive, and never falls back: no match shows an empty grid saying so.
  • Honest fallback — falling back still happens, because the user asked for a meme. But it now comes with a warning triangle, a status line naming the corpus ("this is imgflip's top 100, and your meme isn't one of them"), and the candidate strip + Browse all visible. The complaint was never that a fallback happened; it was that it was invisible.
  • The fallback list itself is ranked against the user's own description first (MemeTemplateMatcher.ranked), with popularity only filling the remaining slots.

v1 code was deleted, not left behind

MemeAI.prompt / userPayload / parse / MemeSpec and MemeTemplateMatcher.bestMatch / Match / minimumScore are 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 replacement ranked refuses 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:

public struct CaptionBox: Equatable, Sendable, Identifiable, Codable {
    public var text: String
    public var centerX: Double       // 0…1, origin TOP-LEFT
    public var centerY: Double
    public var fontSizeShare: Double // share of image HEIGHT, not points
    public var widthShare: Double
    public var fontName: String?
}

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.

  • Drag any caption directly on the preview; positions commit in normalized units on drag end.
  • Side panel per box: editable text, size slider + /+ 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).
  • Add text box / delete. New boxes stack down the middle so successive adds don't land on top of each other.
  • Colors were left out to keep the scope sane, as agreed.

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

  • The drag handle is a dashed rectangle, not a re-drawn caption. Drawing the text again in SwiftUI would mean two renderers to keep in agreement, and they would drift (different wrapping, different outline weight). The burned-in render is the visual; the handle is a positioned outline you grab. The cost is that the handle is box-shaped rather than glyph-tight.
  • Candidates are capped at 5 — the most that fits a strip without scrolling, and the most anyone considers before reaching for Browse all.
  • The corpus is still imgflip's public top 100. That is the actual limit behind the "yoda" report, and no amount of prompting fixes it. Future work: memegen.link's template API (much larger, also key-less) or Imgflip premium search would expand the corpus; the parser and picker are already shaped to take a bigger list, and rankedUserPayload already truncates for short-context models.
  • Thumbnails use AsyncImage rather than the plugin's own fetch service — the grid can show 100 of them and AsyncImage already 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/OpenWhisp
  1. Enable it — Settings → More features → Plugins → toggle Meme Generator. Note it is off until you do, and that the pane states it fetches template images from imgflip.
  2. Open it — menu bar → PluginsMeme Generator. The description field takes focus on open.
  3. Dictate — with that window frontmost, hit your dictation hotkey and say something like "distracted boyfriend, but he's looking at Rust and his girlfriend is Python". The words land in the description field (this is the seam that would otherwise drop them — the focused-app paste path declines while OpenWhisp is frontmost).
  4. Generate (⌘↩) — status walks through loading templates → asking the model → downloading → rendered preview, with a candidate strip underneath.
  5. Pick another candidate — click any thumbnail in the strip. The same captions re-render onto it; the box positions carry over.
  6. The "yoda" case (the regression this section exists for) — generate with "yoda meme, do or do not there is no try". Yoda is not in imgflip's top 100, so expect: a warning triangle, a status line saying the corpus is imgflip's top 100 and yours isn't in it, a candidate strip labelled as a fallback, and no silent Drake.
  7. Browse all… — opens the searchable grid. Type drake, two buttons, bling (multi-token search works: drake bling finds Drake Hotline Bling). Type yoda and confirm you get an empty grid that says so rather than a substituted template. Pick anything to override the model entirely.
  8. Edit — drag a caption on the preview; it moves and re-renders. Select a box (click it, or click its row in the side panel) and change text, size (slider or /+), width, and font. Use + to add a box and the trash icon to delete one.
  9. WYSIWYG checkExport PNG… after editing and open the file. Caption positions, sizes, and fonts should match the preview exactly, at full template resolution.
  10. Export / Share — the suggested filename follows the edited captions, not the AI's originals.
  11. Offline — turn off Wi-Fi and generate: it says it couldn't reach imgflip rather than hanging or silently failing.
  12. Discovery — Settings → Plugins → Show in Finder, drop in some-id/manifest.json, reopen the pane: it lists, with an honest "can't be loaded" callout.

Gates: swift test 2395 passed, 0 failures · scripts/check-appstate-ratchet.sh OK 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. Templates too limited and America-centric.
  2. First Generate failed with "network error and model loading".
  3. Stuck loading, and templates couldn't be switched during or after.
  4. Deleting every text box left no way to add one back.

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:

Provider Count Key? Notes
imgflip get_memes ~100 no popularity-ranked; the corpus the prompt was tuned on
memegen.link /templates ~212 no verified live; ships keywords
The user's own library n/a any image, any language, works offline

MemeTemplateCatalog.merge puts the user first. Their imported "Drake" beats
imgflip's. This is PluginDiscovery's "earlier provider wins" rule pointed the other
way 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 into
one 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 would
write. 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.

  • suggestedName preserves the script: кот-в-шоке.pngкот в шоке. No
    transliteration, no ASCII folding — that would defeat the entire point.
  • The user's name never touches the filesystem (opaque UUID filenames), so a name
    with a slash, a colon, or an emoji is fine and renaming is a pure index edit.
  • Images are copied into Application Support, not referenced: a template pointing
    at ~/Downloads breaks the first time the user tidies up.
  • file values 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 PluginManifest applies 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.decide always paints what is on disk first and treats
the 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 behind
an honest "Preparing model…"
instead of firing into a dead socket.

AppState.warmLlamaServerIfPossible gained a provider: parameter. The global version
only 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 that ensureLLMModelExists already makes on both
its exit paths. scripts/check-appstate-ratchet.sh still reports exactly 7051.

3. The stuck spinner was structural, so the fix is a type

v2 tracked in-flight work with a Bool cleared by a finish() that several exit paths
never reached. Every superseded-ticket bail read:

guard !self.isCancelled, myTicket == self.ticket else { return }   // isBusy still true

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 == true forever, which disabled Generate and
(because select(template:) began with guard !isBusy) froze the candidate strip and
Browse. Both reported symptoms, one root cause.

MemeGenerationState makes the phase a value with one transition function:

  • finish is idempotent, total, and ticket-guarded. A stale result can't un-stick
    newer work; a redundant finish can't overwrite a status that already landed.
  • canSelectTemplate is unconditionally true, and says why. Switching templates
    re-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.
  • Cancel appears only while work is in flight, and a 120s ceiling recovers a
    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 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
the empty box list both say what to do next.

The window is three columns:

┌──────────────┬─────────────────────────┬──────────────┐
│ TEMPLATES    │        CANVAS           │  TEXT BOXES  │
│ search +     │   (the rendered meme,   │  add/delete, │
│ grid+import  │    drag the captions)   │  size, font  │
└──────────────┴─────────────────────────┴──────────────┘

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:

  1. Clipboard as an implicit argument. Safe. PluginManifest describes an
    entry point kind, never an input type — nothing in the schema says
    "dictated text". The meme plugin's own importFromPasteboard() already reads the
    clipboard, so the pattern has a working precedent. What's missing is declaration:
    clipboard access should become a declared capability, since the current
    networkHosts field is the only disclosure the manifest carries and an undeclared
    clipboard read is a bigger privacy fact than a network host.
  2. Declared destination via the existing OutputTarget protocol. Safe, and worth
    doing: PluginDictationSink currently hard-codes "the plugin's own window" as both
    input and output. A destination field defaulting to .ownWindow would preserve
    every existing plugin while letting new ones target cursor/file/webhook/Shortcut.
    The meme plugin would plausibly declare .file.
  3. Voice-router metadata + the ~15-tool cap. Safe — additive optional fields
    (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 networkHosts
    is 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:

  • The three-column split-view proportions and whether the template grid is comfortable
    at the minimum window width.
  • Drag-drop import — the onDrop provider resolution is written but never
    exercised against a real Finder drag.
  • The warm actually removing the cold-start failure. The mechanism is verified
    (warmLlamaServerIfPossible starts the server with no completion), but the original
    symptom was reported live and has not been reproduced-then-fixed under observation.
    The 2.5s warm window is a guess, not a measurement.
  • memegen's ~212 templates were verified live via curl (shape, count, keywords), but
    not 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/OpenWhisp

Settings → More features → Plugins → enable Meme Generator, then menu bar →
PluginsMeme Generator.

  1. Warm, not broken (the Fix WhisperEngine: serverLock main-thread hang, pipe deadlock, transcript logging #2 regression). Open the window and press ⌘↩
    immediately. Expect a status of "Preparing model…" and then a normal
    generation — not a network error. This is the exact moment that failed before.
  2. The corpus grew. The left column should report ~300 templates, not 100.
    Search sweet brown and nobody got time — both find the same memegen template
    (name vs keyword).
  3. Import a Russian template (the Fix audio capture: 16kHz resample, scoped mic device, streaming races #1 headline).
    • Save any Russian meme image as e.g. кот-в-шоке.png.
    • Drag it onto the template column (or Import → Import images…, or ⌘V).
    • It appears immediately, badged as yours, named кот в шоке — Cyrillic
      intact, not transliterated.
    • Search кот and шоке — both find it. Search kot finds it too (the filename
      rides along as a keyword).
    • Click it: it renders, and you can caption it like any other template.
    • Dictate "кот в шоке, когда увидел счёт" and Generate — the model can now pick
      your template, because user templates sort first into the prompt.
  4. Offline (the cache). Turn Wi-Fi off and reopen the window. The grid still
    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.
  5. Nothing sticks (the Fix KeyboardSynthesizer: move paste off main actor, preserve non-string clipboard #3 regression). Start a Generate and, while it runs:
    • Click a candidate thumbnail — it switches instantly. v2 ignored this.
    • Click a Browse grid template — same.
    • Press Cancel — the spinner clears and the surface is immediately usable.
      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.
  6. Add text back (the Fix streaming pipeline & session lifecycle in AppState #4 regression). Delete every text box with the trash
    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.
  7. v2 wins still hold. Generate "yoda meme, do or do not there is no try": expect
    the warning triangle, the honest status, the candidate strip, and no silent
    Drake
    . Search yoda in the grid → empty, and it says so. Edit a caption, then
    Export PNG… — the file must match the preview exactly at full resolution.
  8. Delete a user template. Right-click one of yours → Delete. It leaves the grid,
    and if it was on screen the canvas clears rather than showing a meme built on a
    template that no longer exists.

Gates: swift test 2456 passed, 0 failures · scripts/check-appstate-ratchet.sh
OK 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 warnings
in 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-first
rule, 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 PluginSystemTests pinning the new
api.memegen.link disclosure string.

Open questions

  1. Is a window per plugin the right surface, or should plugins contribute a pane inside the existing Settings window?
  2. How much UI should a plugin be able to express? Declarative (safe, limited) vs its own window (flexible, needs process isolation) decides between tiers 1 and 2 above.
  3. Should plugins be able to register a hotkey / voice action? The meme plugin would obviously want "make a meme about…" as a spoken command, which means plugins need into the trigger layer, not just the window layer. MAK-100 sharpens this: the voice router will cap the exposed tool surface at ~15, so trigger registration is a scarce resource the host must arbitrate — a plugin cannot be allowed to self-assign priority.
  4. Does a plugin get its own model config, or always follow Cleanup? v3 makes this more pressing: the plugin now warms a model on window open, so "which model" has a cost attached rather than being purely a routing question.
  5. Is plugins/ in this repo the long-term home, or a staging area until the IPC contract exists?
  6. 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.
  7. Should edited memes be saveable/reopenable? The box model is already 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.
  8. New in v3 — should the user template library be a plugin-owned store or an app-level one? It currently lives under the plugin's Application Support directory, which is right for a spike. But "images the user imported" is plausibly a shared resource (Scratchpad attachments, future plugins), and moving it later means migrating user-owned files that are not re-downloadable.

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:

needles.allSatisfy { haystack.contains($0) }

"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:

tier score why
exact name 10 000 typing a name means you want that template
whole-phrase containment 5 000 + closeness "drake" ⊂ "Drake Hotline Bling"
token in name 100 strongest per-token evidence
token in keyword 60 an alias is real, but weaker than a name
prefix hit (name / keyword) 25 / 15 partial matches count, and count less
coverage bonus ≤ 50 matching more of what the user actually said

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 a
shortlist 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.validate still rejects hallucinations.

2. Template download stuck on "Downloading " forever

Root cause. 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 (only generate() armed one).
The ordering that strands it: close the window (cancel() sets isCancelled),
reopen it (windowDidOpen clears isCancelled while the phase survives) — now
the surface is busy with no task, no timer and no Retry behind it.

This is the same bug class v3's own MemeGenerationState doc comment identified
and 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.

  • Every exit — cache hit, fetch failure, decode failure, stale ticket,
    cancellation — ends at a ticket-guarded, idempotent finish. Finishing a
    superseded 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.
  • Downloads get their own finite ceiling (downloadTimeout, 30 s — well under
    the 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.
  • windowDidOpen calls the new state.reset(), so a reopened window can never
    inherit a phase.
  • An image failure now surfaces an honest error and its own Retry
    (imageFailed / retryTemplate) — distinct from the catalog's Retry, which
    re-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.ensureRunning polls the server's /health endpoint and calls
back only once it answers, but AppState.warmLlamaServerIfPossible discarded
that completion ({ _ in }).

Fix. No timing guesses anywhere in this path.

  • warmLlamaServerIfPossible(provider:completion:) forwards real readiness.
  • The plugin's warm seam carries it, so "Preparing model…" lasts exactly
    until 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.
  • A request that still hits a refused connection (the server can pass a health
    check and refuse the next connection mid-restart) retries with backoff
    MemeGenerateRetry, 3 attempts, 0 / 0.75 / 2.0 s. Matched on URL error
    codes, not message text, so a non-English Mac doesn't silently stop
    retrying.
  • The warm itself has a ceiling too, so a server that never becomes healthy can't
    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 LLMWarmReadiness resolver (which providers
need a local server; why an explicitly-resolved provider bypasses the Cleanup
toggle, per MAK-53) and the engine call into an AppState extension. Net effect:
AppState shrank by 15 lines, and scripts/appstate-loc-budget.txt drops
7051 → 7036 to lock the win in.

Gates

  • swift test2485 passing, 0 failures (2456 baseline + 29 new).
  • AppState ratchet — OK, budget lowered to 7036.
  • 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 scoring
    contract, including the no-fallback guarantee.
  • testPrefilterPutsTheRelevantTemplateInFrontOfTheModel,
    testPrefilterFallsBackToPopularityWhenNothingMatches,
    testPrefilterNeverRepeatsATemplateWhenToppingUp,
    testPromptLinesCarryKeywordsAfterAnUnadornedName,
    testAModelCopyingTheNameOffAPromptLineValidates — the LLM shortlist.
  • testAReopenedWindowNeverInheritsADownloadingPhase,
    testResetRefusesTheAbandonedDownloadsLateResult,
    testFinishingASupersededDownloadCannotDisturbTheNewerOne,
    testADownloadHasItsOwnFiniteCeilingShorterThanAGenerates,
    testDownloadTimeoutMessageNamesTheTemplateAndOffersRetry — the stuck-download
    orderings.
  • testARefusedConnectionIsTreatedAsNotReadyYet, testARealFailureIsNotRetried,
    testRetriesAreBoundedAndThenReportHonestly, testRetryDelaysBackOff,
    testNotReadyDetectionDoesNotDependOnLocalizedText,
    testRetryStatusNamesTheAttempt,
    testAWarmThatNeverCompletesCannotBlockGenerateForever — the retry policy.
  • testTheBundledProviderIsWarmedByWaitingForItsLocalServer,
    testANonBundledProviderIsReadyImmediately,
    testTheBundledProviderWithoutItsModelIsUnavailable,
    testAnExplicitlyResolvedProviderBypassesTheCleanupToggle — warm policy.

testSearchRequiresEveryTokenToAppear was replaced, not deleted quietly — it
encoded 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 test stubs. The next live pass should specifically check
that 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 lifetime
and reuses it on every subsequent open. MemeGeneratorWindowController.init was
the only caller of model.windowDidOpen(), so that 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, isCancelled latched true for the
rest of the launch, and every async result afterwards was dropped:

guard !self.isCancelled, self.state.accepts(ticket: ticket) else { return }

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 windowDidOpen had to reset the state,
and added state.reset() to it — but never checked whether windowDidOpen was
still being called. The reset was right; it was unreachable.

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 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.session was a process-lifetime static let. A URLSession
is 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 request
builds a fresh pool. Both Retry paths — image and catalog — invalidate first, so
a retry is always a fresh session and a fresh URLRequest rather than a
replay of the attempt that hung. Every network read funnels through one get
helper so no path can skip the rule.

Which failures count is the pure, tested MemeGenerationState.isTransportFailure,
matched on NSURLErrorDomain codes (not localized message text). Narrow on
purpose: 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 a
wedge, and a week-old cache still serves rather than degrading to .fetchNow. A
background 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 meme button (SF Symbol arrow.counterclockwise, ⌘N) clears the
description, caption boxes, candidate strip, selected template, rendered meme,
search text, and every error state. It calls state.reset() first, so
anything in flight is refused by the existing ticket guard rather than landing on
the surface the user just cleared.

The clearing is a pure MemeComposition value whose reset() returns .empty
wholesale. 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 — the
property 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 host
decides (PluginKeyEquivalent) — only the host can see the whole menu, and a
plugin 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 — what
AppMain really binds, not a padded guess) and then by list order, first-wins,
matching the precedence PluginDiscovery already uses for id collisions. A
refusal 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 working
plugin 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 AppMain at all.

Tests (35 new, Tests/OpenWhispCoreTests/MemeRecoveryTests.swift)

Clock-injected staleness:

  • testCacheOneSecondPastTheTTLIsShownAndRefreshedRatherThanDiscarded
  • testCacheOneSecondBeforeTheTTLStillAvoidsTheNetwork
  • testAWeekOldCacheIsStillServedRatherThanForcingAFetch
  • testAFailedRefreshBehindAStaleCacheStaysSilent
  • testAFailedRefreshWithNothingCachedSurfacesTheErrorAndTheRetry

Session recycling:

  • testSleepWakeTransportFailuresRecycleTheSession
  • testNonTransportFailuresKeepTheSession
  • testForeignErrorDomainsAreNotTreatedAsTransportFailures

Failure-then-recovery and the stranded phase:

  • testResetClearsAStrandedDownloadingPhaseWithoutATicket
  • testWorkStrandedByAResetCannotFinishOverTheFreshState
  • testADownloadFailureThenRetryRecoversTheSurface
  • testTheDownloadCeilingIsFiniteAndTighterThanTheGenerateCeiling
  • testTemplateSelectionSurvivesAFailedDownload

Reset totality:

  • testResetReturnsEveryFieldToTheInitialEmptyState
  • testResetClearsThePromptCaptionsCandidatesAndErrors
  • testResetIsIdempotent, testAnUntouchedCompositionIsEmpty
  • testACompositionHoldingOnlyAnErrorIsNotEmpty
  • testACompositionHoldingOnlyADictatedDescriptionIsNotEmpty

Shortcuts: PluginKeyEquivalentTests (9) + PluginManifestKeyEquivalentTests (6),
covering normalization, the reserved set, list-order collisions, forward-compatible
decode, and non-fatal malformed shortcuts.

Gates

  • swift test2520 passing (2485 baseline + 35 new), 0 failures
  • AppState ratchet — 7036 / 7036, unchanged (nothing added to AppState)
  • PLUGINS=1 ./build.sh, plain ./build.sh, and lean all compile
  • No new warnings in any touched file

Not verified in this round

  • No screenshots — the Mac app can't be screenshotted in this environment.
  • The day-long idle can only be simulated. The reopen bug is proved by code
    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.
  • The wedged connection pool is inferred, not observed. isTransportFailure
    and the recycling are unit-tested, but no real sleep/wake cycle was performed
    against the live CDNs, and MemeTemplateService sits behind PLUGINS=1 so it
    is outside the swift test target. sessionGeneration is surfaced in the
    failure status line specifically so the next report can distinguish "never
    recycled" from "recycled and still failing".
  • ⌘M and ⌘N were not exercised on a running app. Note ⌘M is a menu-bar
    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:

Source Field Verified shape
imgflip get_memes box_count 100 templates: 1×1, 66×2, 23×3, 9×4, 1×5
memegen /templates lines 212 templates: 7×1, 166×2, 23×3, 7×4, 4×5, 3×6, 2×8

Both now decode into MemeTemplate.captionSlots, clamped to 1...8
(MemeCaptionSlots) and defaulting to 2 — so a v5 disk cache, a user-library
import, 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's
slot count instead of top_text/bottom_text. The parser keeps accepting the legacy
pair 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/templates and GET /templates/<id> both return
    {id, name, lines, overlays, styles, blank, example, source, keywords, _self}.
    lines is a count. There is no geometry field anywhere in either payload.
  • imgflip's key-less get_memes is the same story: box_count, no rectangles.
    imgflip does expose per-box geometry — but only through the authenticated
    caption_image endpoint, which is a server-side captioning API this plugin
    deliberately 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.slotCenters says so plainly rather than implying template
accuracy — a wrong claim about provenance is how the next person "fixes" a fallback
that was never a fallback.

The fallback layouts:

Slots Layout Why
1 One centered caption near the top The impact-font one-liner
2 Classic top/bottom at 0.12 / 0.88 Unchanged from v1 — two thirds of the corpus. Regressing the common case to win the rare one is a bad trade
3–4 Stacked left column (x = 0.30), narrower boxes, smaller type The spike-grade compromise — see below
5+ Even full-width column Past four slots there's no dominant convention left to approximate

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.resolve accepts:

  • numbers — 1-based (firstCandidateNumber), indexing the shortlist it was shown
  • numeric strings["3"] means the third template, not a template named "3"
  • exact names — the v5 path, kept verbatim for backward compatibility
  • any mix of the above in one answer

Out-of-range numbers are DROPPED, never clamped. Quietly handing back the last
template for 47 would be v1's confident-Drake bug wearing a number. Dedupe is on the
resolved 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 rather
than 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:

  • Own ticket through the existing MemeGenerationState, so Cancel works and a
    superseded refit can't overwrite a newer one.
  • Never blocks the strip. The template has already rendered when the refit starts;
    the user can click straight past it and the in-flight refit is refused when it lands.
  • Every exit path — no-op, no LLM configured, parse failure, transport failure, stale
    ticket, timeout — ends at finish for its own ticket.
  • Failure is silent and leaves the previous captions in place. The switch itself
    succeeded, so surfacing an error would make a working action look broken.
  • Fast path: same slot count reuses the captions instantly with no round-trip.
    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

Generate replaces AI-seeded boxes and PRESERVES boxes the user added.

v5 assigned boxes = seedBoxes(...) outright, so a user who pressed "Add text", typed
a 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:

  • A dialog charges every regenerate (the common, harmless case: nothing was
    hand-added) to protect the rare one. Generate is the plugin's primary verb.
  • "Your boxes survive, the AI's are rewritten" is a rule a user can hold in their head
    and predict; a dialog they dismiss reflexively is not.
  • It's reversible in the direction that matters: an unwanted surviving box is one
    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.

MemeTemplateAffinity adds a small persisted boost per template, applied in the
prefilter ranking. The bounds matter more than the signal, because an unbounded
boost is a personalized version of the confident-Drake bug:

  • 12 per pick — smaller than one keyword-token match (60), far smaller than a
    name-token match (100). One correction reorders near-ties and nothing else.
  • Ceiling 120 — roughly two name-token matches, reached after ten picks. Past that
    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.
  • Only boosts templates that ALREADY match. Applied inside ranked to templates
    whose 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.
  • Does not reorder an unfiltered Browse grid — that grid is the corpus in
    popularity order, and floating favourites into it would make its order mean two
    different things depending on whether the search box was empty.
  • Agreement doesn't count: clicking the candidate already ranked first is not a
    correction, and boosting it would be a feedback loop rather than a lesson.
  • The decoder re-applies the cap, so the hand-inspectable JSON can't inject a
    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,
testCaptionSlotsRoundTripThroughTheCache

Slot geometry + seeding: testTwoSlotsKeepTheClassicTopAndBottomLayout,
testOneSlotIsASingleCenteredCaption,
testPanelSlotsAreDistinctEvenlySpacedAndInsideTheFrame,
testPanelLayoutsUseNarrowerBoxesAndSmallerTypeThanTheClassicPair,
testSlotGeometryIsClampedLikeEveryOtherSlotCount,
testSeedingProducesOneBoxPerSlotInPanelOrder,
testExtraCaptionsBeyondTheSlotCountAreDropped,
testTooFewCaptionsStillFillEverySlotWithAnEmptyBox,
testTheClassicTopBottomSeedIsTheTwoSlotCase

Numbered references: testNumberedCandidatesResolveToTheShortlistEntriesTheyIndex,
testTheNumberingIsOneBasedMatchingWhatThePayloadPrints,
testOutOfRangeNumbersAreDroppedRatherThanClamped,
testAnAllOutOfRangeAnswerLeavesNoUsableTemplate,
testExactNamesAreStillAccepted, testNumbersAndNamesMayBeMixedInOneAnswer,
testANumericStringIsTreatedAsAnIndexNotAName,
testANumberAndItsNameCollapseToOneCandidate,
testNumberedCandidatesAreCappedAtFive,
testAnUnparseableElementIsDroppedWithoutFailingTheWholeAnswer,
testAnEmptyShortlistResolvesNothingRatherThanCrashing

Caption arrays + legacy shape: testCaptionsArriveAsAnArrayInPanelOrder,
testLegacyTopAndBottomTextDecodeAsATwoSlotResponse,
testACaptionsArrayWinsOverStrayLegacyKeys, testATrailingEmptyCaptionIsDropped,
testAnInteriorEmptyCaptionIsKeptSoPanelsDoNotShift,
testTheModelsReasonSurvivesForTheStripTooltip, testAMissingReasonIsNotAFailure,
testAnAnswerWithNeitherTemplateNorCaptionIsStillRejected

Prompt + payload: testThePromptAsksForNumbersACaptionArrayAndAVisibleReason,
testOnlyNonDefaultSlotCountsAreAnnotatedInThePrompt,
testASlotArrayShorterThanTheLinesDegradesToTheDefault,
testThePayloadCarriesTheSlotCountsAndExplainsTheUnmarkedCase,
testPromptSlotsAlignPositionallyWithPromptLines,
testPromptSlotsRespectTheSameLimitAsTheLines

Refit: testNoRefitIsNeededWhenTheSlotCountMatches,
testARefitIsNeededWhenTheSlotCountDiffers,
testNoRefitForCaptionsThatAreAllEmpty,
testRefitNeedIsJudgedAgainstTheClampedSlotCount,
testTheRefitPayloadCarriesTheJokeTheCurrentCaptionsAndTheTarget,
testTheRefitPromptPinsTheLanguageAndForbidsPadding,
testARefitIsPaddedUpToTheSlotCount, testARefitIsTruncatedDownToTheSlotCount,
testARefitDigsItsJSONOutOfProse,
testAnUnusableRefitReplyIsRefusedRatherThanBlankingTheCaptions

Regenerate preservation: testRegenerateReplacesTheAISeededBoxes,
testRegeneratePreservesABoxTheUserAdded,
testUserBoxesAreAppendedAfterTheNewSeedWhateverTheSlotCount,
testAnEditedSeededBoxIsStillReplaced,
testTheFirstGenerateOnAnEmptyCanvasJustSeeds,
testBoxesTypedBeforeTheFirstGenerateAreTreatedAsUserAdded

Affinity + its caps: testAPickBoostsThatTemplate, testRepeatedPicksAccumulate,
testTheBoostSaturatesAtTheCapAndNeverExceedsIt,
testSaturationTakesTheDocumentedNumberOfPicks,
testOneBoostIsWorthLessThanOneKeywordMatch, testDecodingReAppliesTheCap,
testAffinityRoundTripsThroughJSON, testAnEmptyIDIsNotRecorded,
testResetForgetsEverything, testABoostPromotesATemplateOverAnEquallyScoringOne,
testASaturatedBoostCannotMakeANonMatchingTemplateAppear,
testASaturatedBoostCannotOutrankAnExactNameMatch,
testAnEmptyQueryKeepsPopularityOrderRegardlessOfAffinity,
testThePrefilterHonoursTheLearnedBoost,
testRankingWithoutAnAffinityIsUnchanged

One v5 test renamed: testRankedPromptForbidsInventingNamesAndPinsCaptionLanguage
…ForbidsInventingCandidatesAndPinsCaptionLanguage. It pinned the literal "copied
exactly" 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 test2593 passing (2520 baseline + 73 new), 0 failures
  • AppState ratchet — 7036 / 7036, unchanged (nothing added to AppState)
  • PLUGINS=1 ./build.sh, plain ./build.sh, and lean (WHISPERKIT=0 PARAKEET=0)
    all compile
  • No new warnings in any touched file

v6 — not verified

  • No real-LLM round-trip was run. The new prompts (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 the
    claim tests can't make. This is the highest-value live check:
    ./scripts/e2e-app-features.sh with the app running and llm=configured, then
    generate a 4-slot meme (e.g. "expanding brain about deploys") and confirm four
    captions land in four boxes.
  • The 3–4 slot layout was not eyeballed on a real template. The geometry is
    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.
  • The refit's cancellation ordering was not exercised live. Clicking rapidly
    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.
  • Affinity persistence is untested on disk. MemeTemplateAffinity is fully
    covered as a pure value, but MemeLibraryStore.loadAffinity/saveAffinity sit
    behind PLUGINS=1, outside the swift test target — the same structural limit the
    rest of the store has.
  • API shapes were verified on 2026-08-03 only. The box_count / lines
    distributions 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: typing, dictating, dictating memes, dictating memes by voice

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:

  1. The local model answered in the legacy top_text/bottom_text form.
  2. RankedWire's backward-compat branch accepted it — that branch can only ever
    produce exactly two captions.
  3. Nothing compared the caption count to the chosen template's slot count.
    applyRanked handed two captions to a 4-slot seedBoxes, which padded with
    blanks 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-shaped
descriptions 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:

  • An enumeration signal is required. A bare comma run inside a sentence is not
    enough — that is how people write prose. make me a drake meme about rust, python and go is prose about three things and is not extracted (pinned by a test).
  • The item count must be 2…8. One item is a phrase; past eight no template could
    hold it.
  • Every item must be caption-sized (≤ 6 words). steps: first you plan the whole thing out carefully, then you throw it away entirely is prose with a colon, and
    falls 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 template
query instead of the whole sentence, so the caption words don't pollute the search.

2. Host-side slot enforcement

MemeAI.fit is now the single rule for whether captions may be rendered:

Situation v6 v7
count == slots render render
count != slots silently padded and rendered refit — "Model wrote 2 of 4 — refitting…"
legacy top/bottom on a 2-slot template accepted accepted (correct here)
legacy top/bottom on an N≠2 template accepted → the bug refit

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. refitCaptions is now extracted so both entry points share one
implementation and can't drift; the status line differs because the two moments mean
different things to the user.

RankedSpec.wasLegacyShape records which wire shape produced the captions, so the
host 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:

  • templates is typed integer → an invented template name is not a reachable
    token sequence. v6's numbered-reference idea becomes airtight rather than
    best-effort.
  • The refit schema pins minItems == maxItems == N"wrote 2 of 4" cannot be
    emitted.
    The refit is the one call where the required count is known up front.
  • The legacy keys are absent from the schema entirely, so a constrained model cannot
    reach for them. The v6 bug's entry point is closed at the sampler.

The plumbing turned out to be tractable. ChatCompletionRequest gained an optional
response_format (encoded with encodeIfPresent, so every existing caller's
request bytes are byte-identical to v6
); processFinalText and summarizeResolved
gained a defaulted-nil parameter; the plugin's AICall seam carries the schema
because only the model knows which of the two shapes a given call expects. Schemas
are built as JSONValue values in core rather than raw strings — a schema stored as
a string literal would be exactly the untested wiring this spike exists to avoid.

AppState grew zero net lines — the ratchet is still at exactly 7036.

Host-side fit is kept regardless: not every endpoint enforces schemas, and the
parser must stay correct for the ones that don't.

Also: slot-count-aware template search

When the item count is known, prefilter stably 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 in MemeStructureTests)

  • testTheScreenshotPromptYieldsExactlyFourCaptionsInOrder — the exact repro
    string → 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 are
    not at the classic top/bottom centers.
  • testProseWithCommasIsNotTreatedAsAList and four more negative cases — the
    false-positive guard.
  • testTheRefitSchemaPinsExactlyTheRequestedCaptionCount,
    testTheRankedSchemaForcesNumericTemplateReferences — schema contents, including
    that top_text/bottom_text appear nowhere.
  • The two v6 tests that pinned legacy acceptance were updated to the new
    2-slot-only rule rather than deleted.

v7 gates

  • swift test2624 passing (2593 baseline + 31), 0 failures.
  • AppState ratchet — PASS at exactly 7036 (zero net growth).
  • PLUGINS=1 ./build.sh, plain ./build.sh, and WHISPERKIT=0 PARAKEET=0 ./build.sh
    — all succeed.
  • Warnings: 112 with the change vs 113 on baseline — no new warnings in touched
    files.

v7 — not verified

  • No live model ran against the schema-constrained request. The schema is pinned
    by tests and the plumbing compiles, but whether this llama-server build accepts
    this response_format shape and honours the grammar is exactly the claim a unit
    test cannot make. This is the highest-value live check — run
    ./scripts/e2e-app-features.sh with llm=configured, generate with the repro
    prompt, and confirm four captions. If the endpoint rejects the key, the host-side
    fit still catches the mismatch and refits, so the failure mode is v6-with-a-refit
    rather than a break — but that fallback path was not exercised live either.
  • The refit-after-generate ordering was not watched on screen. Tests pin that a
    mismatch produces .refit and that the seed happens first; that the status line
    reads well as the captions visibly change is a judgement no test makes.
  • List extraction was tuned against the repro and constructed cases, not a corpus
    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.
  • Non-Latin joiners are covered by inspection only. и/затем/und are in the
    splitter, but no test dictation exercises them.
  • The Mac app still can't be screenshotted in this environment, so the four-panel
    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 in
e0386cf, committed at 14:51 today; the build in the screenshot predates it.

Reproduced exactly against v6's applyRanked:

// 3247bdf plugins/MemeGenerator/MemeGeneratorModel.swift:657-658
let slots = picks.first?.captionSlots ?? MemeCaptionSlots.default
seedBoxes(captions: spec.captions, slots: slots)   // no extraction, no fit

Those are the lines that collapsed 4 → 2. slots was already correctly 4 (imgflip
really does ship box_count: 4 for Expanding Brain — verified against the live API), but
spec.captions came from the legacy top_text/bottom_text branch of RankedWire,
which can only ever yield two. seedBoxes padded the rest, and nothing compared the
count to the template:

spec.captions -> ["typing", "dictating memes by voice"]   legacy: true
v6 boxes      -> ["typing", "dictating memes by voice", "", ""]
v6 FILLED     -> 2

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.

testTheReproEndsWithFourFilledBoxesAndNoRefit re-implements the app's sequence inside
the test body
— extract, then replacingCaptions, then fit, with slots: 4 written as
a literal. Every core piece was proved in isolation. But the code that chains them
lived in plugins/MemeGenerator/MemeGeneratorModel.swift, which compiles only under
PLUGINS=1 and sits outside the swift test target. So the chain was untested by
construction
, 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 in Package.swift) owns
the entire captions→boxes decision:

MemeCaptionSeeding.resolve(
    description:, specCaptions:, wasLegacyShape:, templateSlots:) -> Seed

Three rules, each closing one way the bug returns:

  1. The user's own list beats the model's captions. A list-shaped description supplies
    the captions verbatim; a model can't return the wrong count for a question never asked.
  2. The geometry always comes from the TEMPLATE's slot count, never from the caption
    count — an N≠2 template cannot render as a classic two-liner.
  3. A count mismatch refits rather than padding blanks.

templateQuery(for:) shares that same single extraction with the template search, so the
query and the slot preference can't disagree about whether the description was a list.

applyRanked is now a call to resolve plus UI glue. apply(seed:) is the only place
boxes is assigned; no description-reading logic remains in plugins/.

Two-caption entry points closed

  • MemeRenderer.render(template:topText:bottomText:)@available(*, unavailable). It had
    no 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:) and
    RankedSpec(templateNames:topText:bottomText:)@available(*, deprecated). No
    production 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 same
function 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

  • Schema is genuinely attached. Both aiCall sites pass one, and it survives the full
    chain: configureAIResponseFormat.jsonSchemaAppState.summarizeResolved
    processFinalTextencodeIfPresent(responseFormat) on the wire.
  • The refit is reachable from applyRanked's real caller, now via seed.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.
  • refitCaptions is still app-layer: the decision (MemeAI.fit, parseRefit,
    refitStatus) is core-tested, but the async round-trip, ticket guards and status writes
    are 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-generating
    and apply-a-completed-refit) legitimately have no description to read; both route through
    the same core layout and the single apply path.
  • Still not run live. No real four-panel render was put on screen; the Mac app can't be
    screenshotted in this environment, so geometry is asserted numerically.

Gates

swift test 2634 green (2624 baseline + 10 new) · ratchet 7036/7036 ·
PLUGINS=1 ./build.sh, plain ./build.sh, and lean all compile · no new warnings in
touched 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:

you do material
CASE 1 select text anywhere → Fn (dictate) → Refine key → "create a meme based on that" the selection (the refine content snapshot)
CASE 2 Refine with nothing selected → "create a meme expanding brain: typing, dictating, dictating memes, dictating memes by voice" the spoken remainder

What CASE 2 required (it was genuinely broken)

Refine with no content never armed. armRefineMidSession bailed with "Nothing to
refine yet — dictate first, then tap Refine"
whenever there was no in-session text, no
selection, and no last dictation. So refineContentSnapshot stayed nil,
deliverFinalText's refine branch never ran, and a spoken command could not reach a
router 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 status
string 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 "Nothing
to refine" outcome as before. InstructionChain.instructionSuffix needed no change:
with empty content, hasPrefix("") is true, so the whole utterance becomes the
instruction.

Architecture — declared, not hardcoded

PluginManifest.voiceTriggers: [String] (forward-compat decode; normalized to trimmed,
lowercased, de-duped phrases). An all-empty list is reported as .emptyVoiceTriggers
but is never fatal — the same trade keyEquivalent already makes. That
normalization 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:

  • prefix only"summarize this, then create a meme" stays a normal refine
  • word boundary"create a memo about Q3" does not match "create a mem"+"o"
  • exact phrases, case/whitespace-insensitive; no fuzzy/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 — create a meme, make a meme, generate a meme,
сделай мем, создай мем — because the owner dictates in both. The checked-in
manifest.json matches the registry literal (the existing parity test pins this).

No insertion, and failure never eats the dictation

The route is consulted in deliverFinalText before runLLM and before any
insert
. 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 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 it
can never fire on an unrelated refine.

Overlay: the acknowledgment names the plugin ("Meme Generator — creating…") and
reaches the overlay through statusMessage, which FinalizingCaption.resolve already
surfaces verbatim — no new OverlayPhase case and no view change. A core test pins
that the two agree.

AppState touch: ~10 lines (one if let, one guard, both delegating to PluginHost).
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 test proves the router but cannot prove the pipeline reaches it — the refine
path lives on AppState, which the core test target doesn't compile. So the launch-gated
probe now drives PluginHost.routeVoiceCommand, the same call
AppState.deliverFinalText makes when a mid-dictation refine finalizes, with the same
(instruction, content) pair. scripts/meme-voice-command-proof.sh [case1|case2|nearmiss|all].

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
asyncAfterthe v9 probe's included — sits queued forever and the run looks like
a silent hang. The script hand-assembles a minimal ad-hoc-signed .app; no third_party
runtime matters to the trigger layer.

CASE 1 — selection is the material:

[MemeGen] refine-route probe requested: instruction="create a meme based on that" content="Our deploy pipeline takes 45 minutes and fails on the last step half the time."
[MemeGen] voice command MATCHED plugin=meme-generator trigger="create a meme" remainder="based on that" content=78 chars
[MemeGen] runVoiceCommand material="based on that
[MemeGen] voice command dispatched to meme-generator, material="based on that
[MemeGen] refine-route probe: ROUTED, refine effect=finishQuietly(status: "Meme Generator — creating…")
[MemeGen] LLM path, schema=true, captions=2, legacyShape=false
[MemeGen] seeding 3 boxes (canvas now 3)
[MemeGen] probe result: 3 boxes on canvas, texts=["Deploy pipeline takes 45 minutes", "Fails on the last step", "Half the time"]
[MemeGen] probe done

CASE 2 — the owner's exact expanding-brain prompt, all four items survive:

[MemeGen] refine-route probe requested: instruction="create a meme expanding brain: typing, dictating, dictating memes, dictating memes by voice" content=nil
[MemeGen] voice command MATCHED plugin=meme-generator trigger="create a meme" remainder="expanding brain: typing, dictating, dictating memes, dictating memes by voice" content=0 chars
[MemeGen] voice command dispatched to meme-generator, material="expanding brain: typing, dictating, dictating memes, dictating memes by voice"
[MemeGen] refine-route probe: ROUTED, refine effect=finishQuietly(status: "Meme Generator — creating…")
[MemeGen] extraction fired: 4 items, theme: "expanding brain"
[MemeGen] resolve(prompt: "expanding brain: typing, dictating, dictating memes, dictating memes by voice", specCaptions: 4, slots: 4) -> 4 boxes, captions: 4, fromUser: true, refit: none
[MemeGen] seeding 4 boxes (canvas now 4)
[MemeGen] probe result: 4 boxes on canvas, texts=["typing", "dictating", "dictating memes", "dictating memes by voice"]
[MemeGen] probe done

Near miss — "create a memo…" must stay a normal refine:

[MemeGen] refine-route probe requested: instruction="create a memo about the Q3 numbers" content="Revenue was up 12 percent."
[MemeGen] refine-route probe: NOT ROUTED -> normal refine (status="Ready")
[MemeGen] refine-route probe done (no meme window was opened)

Plugin disabled — matched, but falls back with the hint (run under a separate bundle
id so the real user defaults were never touched):

[MemeGen] voice command matched but plugin disabled -> normal refine
[MemeGen] refine-route probe: NOT ROUTED -> normal refine (status="Meme Generator plugin is disabled")
[MemeGen] refine-route probe done (no meme window was opened)

Still true / still shortcuts

  • The router is prefix-exact, so a user who says "can you create a meme about X"
    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.
  • Trigger collisions between plugins resolve by longest-then-list-order. Fine for one
    plugin; a real system probably wants the user to arbitrate.
  • The disabled-plugin hint is best-effort UI copy, not a prompt to enable the plugin.
  • CASE 1 joins remainder + selection with a newline and lets the plugin sort it out;
    there is no notion of "the selection is the subject, the remainder is the style."

Gates

swift test 2670 green (2645 baseline + 25 new) · ratchet OK, budget lowered
7036 → 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, selection
phrasing, 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

initcore0 and others added 12 commits August 2, 2026 13:27
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>
@initcore0

Copy link
Copy Markdown
Owner Author

v9 — the captions were never the bug

You ran a hash-verified v8 binary with the exact prompt and still got two boxes. That
was the right call to insist on: v7 and v8 each traced the caption wiring by reading it,
found it correct, and shipped. The caption wiring was correct. It was being handed
slots: 2.

The divergent line

MemeTemplate.captionSlots arrived in v6 without bumping MemeCatalogCache.currentVersion,
and decide admitted anything at or below the current version:

guard let cached, cached.version <= currentVersion, !cached.templates.isEmpty else {

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:

captionSlots = MemeCaptionSlots.clamp(
    (try? c.decode(Int.self, forKey: .captionSlots)) ?? MemeCaptionSlots.default)  // 2

Your actual cache file, before this fix:

version: 1 | 285 templates | entries with captionSlots: 0
Expanding Brain | slots = None

Every template in the corpus reported two caption slots. Extraction read your four items
correctly, matched the right template, and then the count-mismatch rule did exactly what
it is supposed to do with four captions for a two-slot template: refit them down to
two. An LLM compressing a four-step progression keeps the endpoints — hence "typing" and
"dictating memes by voice", the first and last, which is the signature you reported.

No amount of reading the caption code could find this, because the caption code was
right. The bad input came from a file older than the feature.

Runtime proof

Reading had failed twice, so this round is evidence from the running binary.
MemeTrace logs each decision, and a launch-gated probe drives the real generate()
through PluginHost — same window the menu item opens, same method the button calls.

Before, PLUGINS=1 build against your own stale cache:

[MemeGen] probe start, prompt="expanding brain: typing, dictating, dictating memes, dictating memes by voice"
[MemeGen] LLM path, schema=true, captions=4, legacyShape=false
[MemeGen] extraction fired: 4 items, theme: "expanding brain"
[MemeGen] resolve(prompt: "expanding brain: ...", specCaptions: 4, slots: 2) -> 2 boxes, captions: 4, fromUser: true, refit: 4->2
[MemeGen] seeding 2 boxes (canvas now 2)
[MemeGen] probe result: 2 boxes on canvas, texts=["typing", "dictating memes by voice"]

Your bug, reproduced exactly. Note slots: 2 — that is the whole fault, and note that
extraction fired with 4 items, so v7/v8's work was doing its job all along.

After, same binary with the version gate, cache refetched:

[MemeGen] LLM path, schema=true, captions=4, legacyShape=false
[MemeGen] extraction fired: 4 items, theme: "expanding brain"
[MemeGen] resolve(prompt: "expanding brain: ...", specCaptions: 4, slots: 4) -> 4 boxes, captions: 4, fromUser: true, refit: none
[MemeGen] seeding 4 boxes (canvas now 4)
[MemeGen] probe result: 4 boxes on canvas, texts=["typing", "dictating", "dictating memes", "dictating memes by voice"]

Four boxes, your words verbatim and in order, no refit round-trip. The cache on disk is
now version: 2 | 285 templates | with slots: 285, and Expanding Brain | captionSlots = 4.

The fix is a version bump plus gating on equality rather than <=. There is nothing to
migrate — the slot counts were never written to that file — so refetching is the only
honest option, and it is one key-less GET behind the existing offline fallback.

The probe and the breadcrumbs are permanent but inert: both are env-gated
(OPENWHISP_MEME_TRACE=1, OPENWHISP_MEME_PROBE_PROMPT=...), so a normal launch is
unchanged.

Drag

The caption is burned into the preview image, so dragging moved an empty dashed outline
while the text sat at the old position until drop. The dragged box now renders empty
(MemeCaptionLayout.hidingText) while the handle draws a live copy that travels with the
cursor. The duplication is explicit and lasts only for the gesture — on drop the mask
lifts and the real renderer's output is what remains, so the SwiftUI approximation can
never reach the export. Re-rendering per frame was the alternative and is worse: it would
tie the drag's smoothness to the template's pixel count.

What would have caught it

The cache-policy tests all built their fixture with a literal version: 1, so when the
format really did change the suite was actively asserting the stale-cache behaviour was
correct
. That helper now defaults to currentVersion, plus regressions for the
older-version gate, the tolerant decode, a slot-count round-trip, your prompt at 4 slots
and at 2, and the drag helper.

Gates

  • swift test — 2645 pass, 0 failures (2634 baseline + 11 new)
  • PLUGINS=1 ./build.sh, plain ./build.sh, lean — all clean
  • No new warnings (25 pre-existing actor-isolation ones, none in touched files)
  • AppState LOC 6894 / 7036 ratchet

initcore0 and others added 3 commits August 3, 2026 17:39
… 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>
initcore0 added a commit that referenced this pull request Aug 4, 2026
…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>
@initcore0 initcore0 closed this Aug 4, 2026
@initcore0
initcore0 deleted the spike/plugin-system branch August 4, 2026 21:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant