<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Ryan Skidmore</title><description>Hey, I&apos;m Ryan. I&apos;m a software engineer currently living in London. I really enjoy solving weird problems and building cool things, whether that&apos;s at work or in my spare time.</description><link>https://skids.dev/</link><language>en-gb</language><item><title>Tokenomics: the 62.5-minute rule for Claude&apos;s cache</title><link>https://skids.dev/blog/anthropic-cache-tokenomics/</link><guid isPermaLink="true">https://skids.dev/blog/anthropic-cache-tokenomics/</guid><description>Is it more efficient to refresh the 5-min cache, let it expire, or just rely compaction?</description><pubDate>Sun, 17 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import CacheRefreshChart from &apos;../../components/CacheRefreshChart.astro&apos;;
import CacheCompactionChart from &apos;../../components/CacheCompactionChart.astro&apos;;&lt;/p&gt;
&lt;p&gt;Unfortunately one of the downsides of being a chronic tokenmaxxer is regularly hitting 5-hour and weekly token limits across several providers. This often comes at the most inconvinient time possible when you&apos;re in the middle of something and ideally I&apos;d prefer to not spend any more money on additional AI subscriptions if possible. I started looking a little more closely at my request logs to see if this was a skill issue and I noticed that I&apos;m writing my entire context (which can be as high as 400k/500k in some sessions) to the cache a little more often than I should be. Each write was pretty small in isolation, but added up pretty quickly.&lt;/p&gt;
&lt;p&gt;5 minutes really isn&apos;t a long time, so it&apos;s easy to get distracted and miss the cache refresh and pay for the full prefix write. This got me thinking, if a prompt cache is about to expire and I don&apos;t have a real request to send, is it cheaper to ping it with a keep-alive, or let it die and rewrite it later?&lt;/p&gt;
&lt;p&gt;tl;dr: The answer is &lt;strong&gt;62.5 minutes&lt;/strong&gt;. If you expect to need the cache again before then, refresh it. If not, let it expire. That number doesn&apos;t move when you switch between models and it doesn&apos;t move when the cached prefix grows from 5K tokens to 500K. The dollars change, but the decision point is still the same.&lt;/p&gt;
&lt;h2&gt;The numbers&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://platform.claude.com/docs/en/about-claude/pricing&quot;&gt;Anthropic&apos;s pricing page&lt;/a&gt; lists prompt caching as a set of multipliers on the normal input-token price:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Base input&lt;/th&gt;
&lt;th&gt;5-min cache write&lt;/th&gt;
&lt;th&gt;1-hour cache write&lt;/th&gt;
&lt;th&gt;Cache read / refresh&lt;/th&gt;
&lt;th&gt;Output&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Opus 4.7&lt;/td&gt;
&lt;td&gt;$5 / MTok&lt;/td&gt;
&lt;td&gt;$6.25 / MTok&lt;/td&gt;
&lt;td&gt;$10 / MTok&lt;/td&gt;
&lt;td&gt;$0.50 / MTok&lt;/td&gt;
&lt;td&gt;$25 / MTok&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sonnet 4.6&lt;/td&gt;
&lt;td&gt;$3 / MTok&lt;/td&gt;
&lt;td&gt;$3.75 / MTok&lt;/td&gt;
&lt;td&gt;$6 / MTok&lt;/td&gt;
&lt;td&gt;$0.30 / MTok&lt;/td&gt;
&lt;td&gt;$15 / MTok&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Haiku 4.5&lt;/td&gt;
&lt;td&gt;$1 / MTok&lt;/td&gt;
&lt;td&gt;$1.25 / MTok&lt;/td&gt;
&lt;td&gt;$2 / MTok&lt;/td&gt;
&lt;td&gt;$0.10 / MTok&lt;/td&gt;
&lt;td&gt;$5 / MTok&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The multipliers are the same for every model: a 5-minute cache write costs 1.25x the base input price, a 1-hour cache write costs 2x, and a cache read costs 0.10x.&lt;/p&gt;
&lt;p&gt;Read operations do two jobs: A request that hits a live cache is billed at the read rate, and the same request refreshes the cache TTL back to 5 minutes, so cache hit = cache refresh.&lt;/p&gt;
&lt;p&gt;The trick to keeping the cache warm is a super tiny request that reads the cached prefix before the TTL runs out. The cost is 10% of the normal input price for that prefix, but the catch is that you have to keep doing it until you need it again.&lt;/p&gt;
&lt;h2&gt;A case study of a 100K-token prefix&lt;/h2&gt;
&lt;p&gt;Let&apos;s take Opus 4.7 and a 100K-token cached prefix as an example. That&apos;s not a massive context window, but really easy to hit considering it&apos;s usually just enough to cover a system prompt, tool definitions, a project sketch, and some running notes from an agent session.&lt;/p&gt;
&lt;p&gt;Writing that prefix to the 5-minute cache costs:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;100K tokens * $6.25 / MTok = $0.625
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Reading it, which also refreshes it, costs:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;100K tokens * $0.50 / MTok = $0.05
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If I keep the cache alive for &lt;code&gt;T&lt;/code&gt; minutes, I pay the first write and then one read every 5 minutes:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;refresh_cost(T) = W + R * floor(T / 5)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If I let the cache expire and come back later, I pay the first write and then a second write:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;rewrite_cost(T) = W + W
                = 2W
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The break-even is where the refresh reads add up to one extra write:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;W + R * (T / 5) = 2W
R * (T / 5)     = W
T               = 5 * (W / R)
                = 5 * (1.25 / 0.10)
                = 62.5 minutes
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The exact boundary is a little stair-stepped in practice, because you refresh in 5-minute chunks rather than in continuous time. That doesn&apos;t change the rule though because below about an hour, refreshing always wins. Past an hour, it&apos;s no longer efficient to keep paying the keepalive tax.&lt;/p&gt;
&lt;h2&gt;What cancels out&lt;/h2&gt;
&lt;p&gt;I expected the answer to depend on the model or the text size, but surprisingly it doesn&apos;t. Both sides of the comparison scale with the model&apos;s base input price and the number of cached tokens. A bigger prefix makes both strategies more expensive and Opus makes both strategies more expensive than Sonnet, but when you divide the write price by the refresh price, all of that disappears:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;W / R = (N * base * 1.25) / (N * base * 0.10)
      = 1.25 / 0.10
      = 12.5
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is why the 62.5 minute timing rule is the same for a 5K Sonnet prefix and a 500K Opus prefix, but the &lt;em&gt;dollar damage&lt;/em&gt; from choosing suboptimally changes between the two models.&lt;/p&gt;
&lt;p&gt;For a 100K prefix on Opus 4.7 and Sonnet 4.6, both pairs land on the same x-axis:&lt;/p&gt;

&lt;p&gt;The Opus lines sit higher because Opus costs more per token, but the crossover time is identical.&lt;/p&gt;
&lt;h2&gt;The cache footguns&lt;/h2&gt;
&lt;p&gt;The 62.5-minute rule was the thing I wanted, but it wasn&apos;t the only useful number on the pricing page.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Opus 4.7 can use up to 35% more tokens for the same fixed text.&lt;/strong&gt; Anthropic calls this out in a note under the model pricing table: Opus 4.7 uses a new tokenizer, and the same text may become up to 35% larger in token terms. If you move a cached prompt from Opus 4.6 to 4.7, don&apos;t assume the old token count still holds. A 100K-token prefix could become 135K tokens, and every cache write/read calculation moves with it. Run the prompt through Anthropic&apos;s &lt;a href=&quot;https://platform.claude.com/docs/en/build-with-claude/token-counting&quot;&gt;token counting endpoint&lt;/a&gt; before you move anything expensive.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Small prefixes don&apos;t cache.&lt;/strong&gt; Opus 4.5, 4.6, and 4.7 need at least 4,096 cacheable tokens. Sonnet 4.6 needs 1,024. If your prefix is under the floor, the API does not throw a helpful error. It just processes the request without caching it. The only reliable signal is the usage block: if &lt;code&gt;cache_creation_input_tokens&lt;/code&gt; and &lt;code&gt;cache_read_input_tokens&lt;/code&gt; stay at 0, your cache isn&apos;t doing anything.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The lookback window is 20 blocks.&lt;/strong&gt; Each cache breakpoint can scan backward through 20 content blocks looking for a prior write. If your agent adds more than 20 blocks between cache hits, the cache entry you wanted can fall outside the search window. I hit this once and assumed some field in the request was invalidating the cache. I had 23 blocks in a request, and the system stopped looking at block 20. The &lt;a href=&quot;https://platform.claude.com/docs/en/build-with-claude/prompt-caching#explicit-cache-breakpoints&quot;&gt;explicit breakpoint docs&lt;/a&gt; show the fix: add another breakpoint earlier in the prefix before you need it.&lt;/p&gt;
&lt;h2&gt;The dollars are small until they aren&apos;t&lt;/h2&gt;
&lt;p&gt;The ratio is model-independent, but the bill is very much model specific. On Opus 4.7, one cycle is: write the cache once, go idle for &lt;code&gt;T&lt;/code&gt; minutes, then make the next real request.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Prefix size&lt;/th&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;th&gt;T = 5 min&lt;/th&gt;
&lt;th&gt;T = 30 min&lt;/th&gt;
&lt;th&gt;T = 60 min&lt;/th&gt;
&lt;th&gt;T = 90 min&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;50K tokens&lt;/td&gt;
&lt;td&gt;refresh + read at T&lt;/td&gt;
&lt;td&gt;$0.338&lt;/td&gt;
&lt;td&gt;$0.463&lt;/td&gt;
&lt;td&gt;$0.613&lt;/td&gt;
&lt;td&gt;$0.763&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50K tokens&lt;/td&gt;
&lt;td&gt;rewrite at T&lt;/td&gt;
&lt;td&gt;$0.625&lt;/td&gt;
&lt;td&gt;$0.625&lt;/td&gt;
&lt;td&gt;$0.625&lt;/td&gt;
&lt;td&gt;$0.625&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100K tokens&lt;/td&gt;
&lt;td&gt;refresh + read at T&lt;/td&gt;
&lt;td&gt;$0.675&lt;/td&gt;
&lt;td&gt;$0.925&lt;/td&gt;
&lt;td&gt;$1.225&lt;/td&gt;
&lt;td&gt;$1.525&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100K tokens&lt;/td&gt;
&lt;td&gt;rewrite at T&lt;/td&gt;
&lt;td&gt;$1.250&lt;/td&gt;
&lt;td&gt;$1.250&lt;/td&gt;
&lt;td&gt;$1.250&lt;/td&gt;
&lt;td&gt;$1.250&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;500K tokens&lt;/td&gt;
&lt;td&gt;refresh + read at T&lt;/td&gt;
&lt;td&gt;$3.375&lt;/td&gt;
&lt;td&gt;$4.625&lt;/td&gt;
&lt;td&gt;$6.125&lt;/td&gt;
&lt;td&gt;$7.625&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;500K tokens&lt;/td&gt;
&lt;td&gt;rewrite at T&lt;/td&gt;
&lt;td&gt;$6.250&lt;/td&gt;
&lt;td&gt;$6.250&lt;/td&gt;
&lt;td&gt;$6.250&lt;/td&gt;
&lt;td&gt;$6.250&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;At 30 minutes, keeping a 500K Opus prefix warm saves $1.625. At 60 minutes, it saves only $0.125. At 90 minutes, refreshing has become the wrong choice and costs $1.375 more than letting the cache expire. The savings are largest on shorter idle gaps and larger prefixes. Right before the crossover, there is barely any money left to save.&lt;/p&gt;
&lt;h2&gt;Compaction is not a free lunch&lt;/h2&gt;
&lt;p&gt;The other thing agents do is compact context: take the growing transcript, ask the model to summarise it, and continue from the summary instead of the original. Claude Code, OpenCode, etc all have a &lt;code&gt;/compact&lt;/code&gt; command - and almost all agents do it automatically at certain points too when you&apos;re nearing the context limit.&lt;/p&gt;
&lt;p&gt;Say the conversation has &lt;code&gt;N&lt;/code&gt; cached input tokens and the summary has &lt;code&gt;S&lt;/code&gt; tokens. Compacting costs three things:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;read the old &lt;code&gt;N&lt;/code&gt; tokens from cache: &lt;code&gt;N * R&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;generate &lt;code&gt;S&lt;/code&gt; output tokens at 5x base: &lt;code&gt;S * 5B&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;write the new &lt;code&gt;S&lt;/code&gt;-token prefix back to cache: &lt;code&gt;S * W&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;After that, each future turn reads &lt;code&gt;S&lt;/code&gt; cached tokens instead of &lt;code&gt;N&lt;/code&gt;, saving &lt;code&gt;(N - S) * R&lt;/code&gt; per turn. The break-even number of future turns is:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;break_even_turns = (N + 62.5*S) / (N - S)
                 = (1 + 62.5*r) / (1 - r), where r = S/N
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Again, the absolute context size cancels, only the compression ratio matters.&lt;/p&gt;
&lt;p&gt;That curve, &lt;code&gt;(1 + 62.5r) / (1 - r)&lt;/code&gt;, looks like this:&lt;/p&gt;

&lt;p&gt;The rule of thumb is roughly 10:1. If you can turn 100K tokens into a 10K-token summary and you expect at least eight more turns, compaction pays for itself on token cost alone. At 20:1, it pays back in about four turns. At 5:1, you need about 17 future turns. At 2:1, you need about 65 turns, which is not a compaction strategy so much as a very expensive tl;dr.&lt;/p&gt;
&lt;p&gt;The output price is why the curve gets ugly. Cache reads are cheap, summary tokens are output tokens, and output is 5x base. A verbose summary can be a strict loss even if it technically reduces the prompt.&lt;/p&gt;
&lt;p&gt;There is also a quality cost that the numbers don&apos;t show. A compaction that drops the exact error message, branch name, or failed hypothesis from ten turns ago might save a few cents and then risk the agent having to rediscover the same thing again.&lt;/p&gt;
&lt;h2&gt;Where the shortcut lies&lt;/h2&gt;
&lt;p&gt;The 62.5-minute rule assumes you will actually make another request. If 30% of sessions ask one question and leave, your expected-value math changes, and the right answer may be not caching at all. Interactive coding agents are usually on the other side of that line.&lt;/p&gt;
&lt;p&gt;It also assumes the prefix is really cached. Check &lt;code&gt;cache_creation_input_tokens&lt;/code&gt; and &lt;code&gt;cache_read_input_tokens&lt;/code&gt; before trusting your own instrumentation. A cache below the minimum token floor, or a cache entry outside the 20-block lookback window, is not a cache. It&apos;s just a more expensive prompt with wishful thinking attached.&lt;/p&gt;
</content:encoded><category>ai</category><category>tokenomics</category><author>Ryan Skidmore</author></item><item><title>Patching Berkeley Mono with Nerd Fonts for Windows Terminal/WSL</title><link>https://skids.dev/blog/patching-berkeley-mono-with-nerd-fonts/</link><guid isPermaLink="true">https://skids.dev/blog/patching-berkeley-mono-with-nerd-fonts/</guid><description>How to patch Berkeley Mono with Nerd Fonts, fix OpenType name tables to preserve Windows font families, and get bold/italic working in Windows Terminal.</description><pubDate>Sun, 10 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I bought &lt;a href=&quot;https://usgraphics.com/products/berkeley-mono&quot;&gt;Berkeley Mono v2&lt;/a&gt; ages ago and finally got around to installing it on my Windows Terminal/WSL setup. When I set it as the font in Windows Terminal, my &lt;a href=&quot;https://github.com/romkatv/powerlevel10k&quot;&gt;Powerlevel10k&lt;/a&gt; prompt rendered empty squares instead of powerline arrows and git branch glyphs. Unsurprisingly, Berkeley Mono doesn&apos;t include the &lt;a href=&quot;https://www.nerdfonts.com/&quot;&gt;Nerd Fonts&lt;/a&gt; glyphs required by a lot terminal prompts.&lt;/p&gt;
&lt;p&gt;The &lt;a href=&quot;https://github.com/ryanoasis/nerd-fonts?tab=readme-ov-file#option-10-patch-your-own-font&quot;&gt;official Nerd Fonts patcher&lt;/a&gt; ships as a Docker image. &lt;a href=&quot;https://www.richardneililagan.com/posts/patching-berkeley-mono-v2-nerd-fonts/&quot;&gt;Richard Ilagan&apos;s blog post&lt;/a&gt; documents the standard command to patch the font. Running that command produces a font that fails to render correctly in Windows. OpenType naming rules, the Windows font cache, and font smoothing utilities require a weird Windows-specific patching approach.&lt;/p&gt;
&lt;h2&gt;Use TTF source files instead of OTF&lt;/h2&gt;
&lt;p&gt;US Graphics provides Berkeley Mono in both &lt;code&gt;.otf&lt;/code&gt; and &lt;code&gt;.ttf&lt;/code&gt; formats. The patcher accepts OTF files and runs without errors, but the resulting font renders garbage characters in Windows Terminal. Thankfully this is a known &lt;a href=&quot;https://github.com/ryanoasis/nerd-fonts/issues/1772&quot;&gt;issue in the Nerd Fonts repository&lt;/a&gt;. You need to download the TTF format from the US Graphics portal and use those as your source files.&lt;/p&gt;
&lt;h2&gt;Bypassing the 31-character family name limit&lt;/h2&gt;
&lt;p&gt;Running the patcher with the standard flags triggers a name length error:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker run --rm \
  -v ./original:/in \
  -v ./patched:/out \
  nerdfonts/patcher \
  --complete \
  --single-width-glyphs \
  --adjust-line-height
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The output fails with:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ERROR: ====-&amp;lt; Family (ID 1) too long (37 &amp;gt; 31): BerkeleyMono Nerd Font Mono SemiLight
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Name ID 1 in the OpenType &lt;code&gt;name&lt;/code&gt; table is limited to 31 characters. The patcher&apos;s default behavior concatenates the original family name, &quot;Nerd Font Mono&quot;, and the variant name (&quot;SemiLight&quot;), exceeding the limit.&lt;/p&gt;
&lt;p&gt;Using the patcher&apos;s &lt;code&gt;--name FORCE_NAME&lt;/code&gt; flag overrides the family name, but it collapses all patched weights into a single &quot;Regular&quot; subfamily, destroying the weight metadata. The correct flag is &lt;code&gt;--makegroups 0&lt;/code&gt;. This keeps the family name stable across all variants and correctly pushes the weight data into the SubFamily field:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker run --rm -v ./original:/in -v ./patched:/out \
  nerdfonts/patcher --complete --single-width-glyphs \
  --adjust-line-height --makegroups 0
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Adding bold and italic weights for Windows Terminal&lt;/h2&gt;
&lt;p&gt;With the patched TTF installed, the prompt renders correctly, but bold text appears at the same weight as regular text.&lt;/p&gt;
&lt;p&gt;Unlike terminals that allow you to explicitly define a bold font file, Windows Terminal selects a single font family and asks the DirectWrite API for its bold variant. If you only install the SemiLight weight (weight 380), Windows cannot locate a heavier weight. It either synthesizes a faux-bold that looks identical to the original, or it just renders SemiLight.&lt;/p&gt;
&lt;p&gt;To get working bold text, you have to download a heavier TTF weight, patch it, and install it alongside your base font. I use SemiLight for regular text and SemiBold (weight 600) for bold text. The 220-point weight difference produces visibly thicker glyphs without relying on Windows to synthesize them. You also need to patch and install the corresponding Oblique variants, or Windows will fall back to a system italic font (or just the same font).&lt;/p&gt;
&lt;h2&gt;Rewriting the SubFamily field for RIBBI compliance&lt;/h2&gt;
&lt;p&gt;After patching SemiLight, SemiLight-Oblique, SemiBold, and SemiBold-Oblique, the Windows font picker displayed two separate entries: &quot;BerkeleyMono Nerd Font Mono&quot; and &quot;BerkeleyMono Nerd Font Mono SemiLight&quot;.&lt;/p&gt;
&lt;p&gt;Dumping the &lt;code&gt;name&lt;/code&gt; table on the patcher output confirms the cause. Name ID 2 (Subfamily) carries the original variant string straight through from the source TTF:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ otfinfo -n BerkeleyMonoNerdFontMono-SemiLight.ttf
Family:              BerkeleyMono Nerd Font Mono
Subfamily:           SemiLight
Full name:           BerkeleyMono Nerd Font Mono SemiLight
PostScript name:     BerkeleyMonoNerdFontMono-SemiLight
Preferred family:    BerkeleyMono Nerd Font Mono
Preferred subfamily: SemiLight
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;OpenType uses a convention called RIBBI, which stands for the four canonical subfamilies: Regular, Italic, Bold, and Bold Italic. When a font&apos;s SubFamily (name ID 2) matches one of these four, Windows groups the variants into a single family. When the SubFamily is &quot;SemiLight&quot; or &quot;Medium&quot;, Windows often splits the fonts into separate families.&lt;/p&gt;
&lt;p&gt;To force Windows to group them, you have to rewrite the SubFamily field on the patched fonts to use the canonical RIBBI names:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source variant&lt;/th&gt;
&lt;th&gt;RIBBI slot&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SemiLight&lt;/td&gt;
&lt;td&gt;Regular&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SemiLight Oblique&lt;/td&gt;
&lt;td&gt;Italic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SemiBold&lt;/td&gt;
&lt;td&gt;Bold&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SemiBold Oblique&lt;/td&gt;
&lt;td&gt;Bold Italic&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;This renaming means typographic applications like Photoshop will display the weight as &quot;Bold&quot; instead of &quot;SemiBold&quot;. For terminal use, this inaccuracy is invisible.&lt;/p&gt;
&lt;h2&gt;Removing Preferred Family and Styles metadata&lt;/h2&gt;
&lt;p&gt;After applying the RIBBI subfamilies and reinstalling, the Windows font picker split the fonts into four distinct entries.&lt;/p&gt;
&lt;p&gt;Dumping the &lt;code&gt;name&lt;/code&gt; table on the renamed font shows the culprit. IDs 1 and 2 are correct, but IDs 16 and 17 still carry the patcher&apos;s original strings:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ otfinfo -n BerkeleyMonoNF-Regular.ttf
Family:              BerkeleyMonoNF
Subfamily:           Regular
Full name:           BerkeleyMonoNF Regular
PostScript name:     BerkeleyMonoNF-Regular
Preferred family:    BerkeleyMono Nerd Font Mono
Preferred subfamily: SemiLight
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The patcher includes name IDs 16 (Preferred Family) and 17 (Preferred Styles) in its output. These fields retained the original variant names. When IDs 16 and 17 are present, Windows constructs display names by concatenating them, ignoring the RIBBI values in IDs 1 and 2. That&apos;s how the four-way split happens: each variant&apos;s ID 16 + ID 17 reads as a different family.&lt;/p&gt;
&lt;p&gt;The fix is to delete name IDs 16 and 17 entirely. Without them, Windows falls back to ID 1 and ID 2, which now contain the correct RIBBI values. You also need to remove ID 18 (Compatible Full), which carries similar legacy data.&lt;/p&gt;
&lt;p&gt;The final state after the rewrite has only RIBBI-compliant fields:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ otfinfo -n BerkeleyMonoNF-Regular.ttf
Family:              BerkeleyMonoNF
Subfamily:           Regular
Full name:           BerkeleyMonoNF Regular
PostScript name:     BerkeleyMonoNF-Regular
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Clearing the Windows FontCache&lt;/h2&gt;
&lt;p&gt;Windows aggressively caches font metadata. Uninstalling the split fonts and installing the corrected ones often results in the system continuing to display the old names. The &lt;code&gt;FontCache&lt;/code&gt; service retains the original metadata for fast boot enumeration.&lt;/p&gt;
&lt;p&gt;You can clear the cache manually in PowerShell:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Stop-Service FontCache
Stop-Service FontCache3.0.0.0 -ErrorAction SilentlyContinue
Remove-Item -Force -Recurse $env:WinDir\ServiceProfiles\LocalService\AppData\Local\FontCache\*
Remove-Item -Force $env:WinDir\System32\FNTCACHE.DAT -ErrorAction SilentlyContinue
Start-Service FontCache
Start-Service FontCache3.0.0.0 -ErrorAction SilentlyContinue
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Rebooting the machine achieves the same result. Windows Terminal also caches font lookups per-process, requiring you to fully close the application, not just the current tab.&lt;/p&gt;
&lt;p&gt;To bypass the cache entirely, I renamed the font family to &lt;code&gt;BerkeleyMonoNF&lt;/code&gt;. Because this name had never been installed, the Windows cache had no conflicting metadata to apply to it.&lt;/p&gt;
&lt;h2&gt;Excluding patched fonts from MacType&lt;/h2&gt;
&lt;p&gt;After a clean installation, I noticed faint vertical artifacts next to adjacent letters like &lt;code&gt;tt&lt;/code&gt; and &lt;code&gt;ff&lt;/code&gt;. I use &lt;a href=&quot;https://www.mactype.net/&quot;&gt;MacType&lt;/a&gt; for system-wide font smoothing (oh, the troubles of OLED), and the artifacts were caused by its rendering engine.&lt;/p&gt;
&lt;p&gt;The Nerd Fonts patcher runs &lt;code&gt;ttfautohint&lt;/code&gt; over its output, replacing the source font&apos;s manual hinting. MacType applies its own subpixel anti-aliasing logic on top of this. The combination of &lt;code&gt;ttfautohint&lt;/code&gt; and MacType produces visual artifacts that do not occur on the stock fonts.&lt;/p&gt;
&lt;p&gt;To resolve this, right-click the MacType tray icon, open the wizard, and add &lt;code&gt;WindowsTerminal.exe&lt;/code&gt; and &lt;code&gt;OpenConsole.exe&lt;/code&gt; to the exclusion list. Restart MacType and Windows Terminal, and the artifacts disappear.&lt;/p&gt;
&lt;h2&gt;The complete patching scripts&lt;/h2&gt;
&lt;p&gt;The entire process requires two scripts running in the same directory. Both rely exclusively on Docker.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;patch-fonts.sh&lt;/code&gt;&lt;/strong&gt; runs the Nerd Fonts patcher against the TTFs in the source directory, then executes the renaming script.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/usr/bin/env bash
# Usage: ./patch-fonts.sh &amp;lt;source-dir&amp;gt; &amp;lt;output-dir&amp;gt;
set -euo pipefail

if [ &quot;$#&quot; -ne 2 ]; then
  echo &quot;usage: $0 &amp;lt;source-dir&amp;gt; &amp;lt;output-dir&amp;gt;&quot; &amp;gt;&amp;amp;2
  exit 1
fi

SRC=&quot;$(cd &quot;$1&quot; &amp;amp;&amp;amp; pwd)&quot;
OUT=&quot;$(mkdir -p &quot;$2&quot; &amp;amp;&amp;amp; cd &quot;$2&quot; &amp;amp;&amp;amp; pwd)&quot;
SCRIPT_DIR=&quot;$(cd &quot;$(dirname &quot;$0&quot;)&quot; &amp;amp;&amp;amp; pwd)&quot;

docker pull -q nerdfonts/patcher &amp;gt;/dev/null

# Patch with Nerd Font glyphs
docker run --rm -v &quot;$SRC:/in&quot; -v &quot;$OUT:/out&quot; \
  nerdfonts/patcher \
  --complete --single-width-glyphs --adjust-line-height \
  --makegroups 0 --quiet

# Rewrite name table for RIBBI compliance
docker run --rm -v &quot;$OUT:/out&quot; -v &quot;$SCRIPT_DIR/fix-font-names.py:/fix.py&quot; \
  --entrypoint fontforge nerdfonts/patcher \
  -script /fix.py /out

ls &quot;$OUT&quot;/*.ttf
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;fix-font-names.py&lt;/code&gt;&lt;/strong&gt; executes inside the patcher&apos;s Docker image using its bundled fontforge environment. It renames the family to &lt;code&gt;BerkeleyMonoNF&lt;/code&gt;, maps the source variants to RIBBI subfamilies, drops name IDs 16 and 17, and sets the OS/2 weight class.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/usr/bin/env python3
&quot;&quot;&quot;Rewrite the name table on patched TTFs so they form a single RIBBI family.&quot;&quot;&quot;

import sys, os, re, glob
import fontforge

FAMILY = &quot;BerkeleyMonoNF&quot;

# Source variant -&amp;gt; (RIBBI subfamily, italic angle, OS/2 weight class)
VARIANT_MAP = {
    &quot;SemiLight&quot;:        (&quot;Regular&quot;,     0,   380),
    &quot;SemiLightOblique&quot;: (&quot;Italic&quot;,      -10, 380),
    &quot;SemiBold&quot;:         (&quot;Bold&quot;,        0,   600),
    &quot;SemiBoldOblique&quot;:  (&quot;Bold Italic&quot;, -10, 600),
}

DROP_KEYS = {&quot;Preferred Family&quot;, &quot;Preferred Styles&quot;}

def fix_one(path):
    m = re.match(r&quot;BerkeleyMonoNerdFontMono-(\w+)\.ttf$&quot;, os.path.basename(path))
    if not m or m.group(1) not in VARIANT_MAP:
        return False

    ribbi, italic_angle, weight = VARIANT_MAP[m.group(1)]
    psname_subfamily = ribbi.replace(&quot; &quot;, &quot;&quot;)

    f = fontforge.open(path)
    f.familyname = FAMILY
    f.fullname = f&quot;{FAMILY} {ribbi}&quot;
    f.fontname = f&quot;{FAMILY}-{psname_subfamily}&quot;

    override = {
        &quot;Family&quot;:          FAMILY,
        &quot;SubFamily&quot;:       ribbi,
        &quot;Fullname&quot;:        f&quot;{FAMILY} {ribbi}&quot;,
        &quot;PostScriptName&quot;:  f.fontname,
        &quot;Compatible Full&quot;: f&quot;{FAMILY} {ribbi}&quot;,
        &quot;UniqueID&quot;:        f&quot;{FAMILY}-{psname_subfamily} v1&quot;,
    }
    
    f.sfnt_names = tuple(
        (lang, k, override.get(k, v))
        for lang, k, v in f.sfnt_names
        if k not in DROP_KEYS
    )

    f.os2_weight = weight
    style = 0
    if &quot;Italic&quot; in ribbi:  style |= 0x0001
    if &quot;Bold&quot; in ribbi:    style |= 0x0020
    if ribbi == &quot;Regular&quot;: style |= 0x0040
    f.os2_stylemap = style
    f.macstyle = (2 if &quot;Italic&quot; in ribbi else 0) | (1 if &quot;Bold&quot; in ribbi else 0)
    
    if italic_angle and f.italicangle == 0:
        f.italicangle = italic_angle

    new_path = os.path.join(os.path.dirname(path), f&quot;{f.fontname}.ttf&quot;)
    f.generate(new_path, flags=(&quot;opentype&quot;,))
    
    if new_path != path:
        os.remove(path)
        
    print(f&quot;✓ {os.path.basename(new_path)} → {ribbi}, weight={weight}&quot;)
    return True

if __name__ == &quot;__main__&quot;:
    out_dir = sys.argv[1] if len(sys.argv) &amp;gt; 1 else &quot;.&quot;
    for p in glob.glob(os.path.join(out_dir, &quot;BerkeleyMonoNerdFontMono-*.ttf&quot;)):
        fix_one(p)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To run the patcher, place the TTF files in an &lt;code&gt;incoming&lt;/code&gt; directory and execute the bash script:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;chmod +x patch-fonts.sh

mkdir incoming
cp ~/Downloads/BerkeleyMono-{SemiLight,SemiLight-Oblique,SemiBold,SemiBold-Oblique}.ttf incoming/

./patch-fonts.sh incoming/ patched/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The script outputs the patched and renamed fonts into the &lt;code&gt;patched/&lt;/code&gt; directory. Copy those files to Windows, install them, and set the Windows Terminal font to &lt;code&gt;BerkeleyMonoNF&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;Adjusting for other fonts&lt;/h2&gt;
&lt;p&gt;This naming table structure applies to any commercial font patched with Nerd Fonts. The patcher consistently emits non-RIBBI subfamily names, which causes Windows to fragment the font family. If you patch another font, you can use the same scripts by modifying the &lt;code&gt;VARIANT_MAP&lt;/code&gt; dict and the filename regex in the Python file.&lt;/p&gt;
</content:encoded><category>env</category><author>Ryan Skidmore</author></item><item><title>Slopdocs: notes for the next clanker</title><link>https://skids.dev/blog/notes-for-the-next-clanker/</link><guid isPermaLink="true">https://skids.dev/blog/notes-for-the-next-clanker/</guid><description>Why I started keeping the planning docs and bug postmortems my clanker generated, instead of letting them disappear with each new session.</description><pubDate>Sat, 02 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;As a fully-certified AI stan who uses AI almost religiously both at home and at work, I kept running into a frustrating papercut where I&apos;d set an agent to go work on a task
without much (or sometimes any) guidance and I&apos;d tell it to go figure it out without me. This is fantastic for working on 14 tasks in parallel, but starts to become a real
problem when you&apos;re trying to figure out why one of your clankers made a certain decision or implemented something in a certain way.&lt;/p&gt;
&lt;p&gt;A lot of agents now do pretty comprehensive planning before implementing features or making changes, but often that plan ends up in the ether and along with it, all of the
context about the decision making and thinking that happened.&lt;/p&gt;
&lt;h2&gt;The stateless agent problem&lt;/h2&gt;
&lt;p&gt;Agents don&apos;t remember anything, and sometimes they forget things you&apos;ve told them in the very same session. Each session starts from a blank slate,
and whatever you taught the last one lives in your head until you need to explain it again in the next session, the next morning, or the next branch.&lt;/p&gt;
&lt;p&gt;You stop realizing how much mental baggage you&apos;re carrying, and after a few weeks, you become the only reliable source of truth in the system. You end up acting as the human
glue holding together a chain of sessions from the last few weeks.&lt;/p&gt;
&lt;h2&gt;Diffs don&apos;t capture the thinking&lt;/h2&gt;
&lt;p&gt;Code goes into git, but the reasoning, thinking and exploration behind it rarely does. A diff tells the next reader what changed, but it doesn&apos;t tell them
what wasn&apos;t tried, or why a weird-looking workaround was actually the best approach.&lt;/p&gt;
&lt;p&gt;A few weeks later, the code looks normal and the alternatives look obvious. Someone, whether an agent or a human, refactors the weirdness away,
and the original constraint resurfaces soon after. The reasoning for that weird choice had been written down in the original plan. It just wasn&apos;t
anywhere reachable by the next session.&lt;/p&gt;
&lt;h2&gt;Let the slop flow!&lt;/h2&gt;
&lt;p&gt;I stopped trying to tidy up all of the AI-generated slop documentation and started letting it hang out. Initially I didn&apos;t have any kind of methodology or system in place
(which was about as disastrous as you imagine), but I quickly adopted a &quot;slopdocs&quot; convention where I (read: the robot) tidied away all of the messy documentation in a &lt;code&gt;slopdocs&lt;/code&gt; directory.&lt;/p&gt;
&lt;p&gt;My approach uses three folders:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;slopdocs/
├── features/
├── bugs/
└── plans/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Files in &lt;code&gt;bugs/&lt;/code&gt; and &lt;code&gt;plans/&lt;/code&gt; get a date prefix so they act as point-in-time snapshots, while features get one living document that gets updated over time. None of it is intended for humans to consume.&lt;/p&gt;
&lt;p&gt;I wrote the full convention up at &lt;a href=&quot;https://slopdocs.dev&quot;&gt;slopdocs.dev&lt;/a&gt;. It includes an agent skill that drops into any codebase in a few seconds. The skill teaches your agent the directory structure and, more
importantly, when to skip writing a document. If you save every output, the folder becomes a garbage dump and your agent stops being able to wrangle it.&lt;/p&gt;
&lt;p&gt;It plays nicely alongside skills that handle the actual writing, like &lt;a href=&quot;https://github.com/obra/superpowers&quot;&gt;superpowers&lt;/a&gt;. Slopdocs just decides where the output goes. My agent now reads the existing slopdocs
before touching an area, and the plethora of documentation becomes a shared history at a repository level.&lt;/p&gt;
</content:encoded><category>ai</category><author>Ryan Skidmore</author></item><item><title>Orchestrating AI Code Review at scale</title><link>https://blog.cloudflare.com/ai-code-review/</link><guid isPermaLink="true">https://blog.cloudflare.com/ai-code-review/</guid><description>Learn about how we built a CI-native AI code reviewer using OpenCode that helps our engineers ship better, safer code.</description><pubDate>Mon, 20 Apr 2026 00:00:00 GMT</pubDate><category>ai</category><category>cloudflare</category><author>Ryan Skidmore</author></item><item><title>We&apos;re back baby! New year, new blog updates 🚀</title><link>https://skids.dev/blog/2025-blog-updates/</link><guid isPermaLink="true">https://skids.dev/blog/2025-blog-updates/</guid><description>It&apos;s a new year, which means it&apos;s time for me to make some completely necessary (and not at all frivolous) updates to my personal site!</description><pubDate>Tue, 22 Apr 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I haven&apos;t blogged once since my last post &lt;a href=&quot;https://skids.dev/blog/that-new-blog-smell/&quot;&gt;✨ That new blog smell ✨&lt;/a&gt; about a year ago,
where I similarly talked about updates I&apos;d made to my personal site. Well, we&apos;re back again with almost the exact same
thing, but the 2025 edition!&lt;/p&gt;
&lt;p&gt;This time I&apos;ve not made quite as many changes. My site still runs on &lt;a href=&quot;https://nextjs.org/&quot;&gt;Next.js&lt;/a&gt; but a lot of other
things have changed. I previously commented how the performance of Next.js wasn&apos;t quite as good as SSGs like
&lt;a href=&quot;https://www.gatsbyjs.com/&quot;&gt;Gatsby&lt;/a&gt; but since then I&apos;ve learned that this was due to a personal skill issue, not an
artefact of the framework itself. I was previously using the &lt;a href=&quot;https://outstatic.com/&quot;&gt;Outstatic&lt;/a&gt; framework to manage
my blog posts, which worked pretty well (and had a nice UI) but was a bit of a PITA to integrate nicely.&lt;/p&gt;
&lt;p&gt;I&apos;ve since switched to &lt;a href=&quot;https://nextra.site/&quot;&gt;Nextra&lt;/a&gt;, which seems a lot more promising and more importantly, easy to
integrate while remaining performant. Everything is pre-generated at build time, and blog posts are written in &lt;a href=&quot;https://mdxjs.com/&quot;&gt;MDX&lt;/a&gt;,
which opens a huge amount of possibilities.&lt;/p&gt;
&lt;p&gt;I was also previously hosting this site on &lt;a href=&quot;https://pages.cloudflare.com/&quot;&gt;Cloudflare Pages&lt;/a&gt; via
&lt;a href=&quot;https://github.com/cloudflare/next-on-pages&quot;&gt;next-on-pages&lt;/a&gt; - because that was the only real option for running Next.js
on Cloudflare. Since then, &lt;a href=&quot;https://opennext.js.org/&quot;&gt;OpenNext&lt;/a&gt; was released which enables running Next.js on the Workers
ecosystem as well as other non-Vercel platforms. OpenNext has been gaining in popularity recently following its first major
(1.x.x) release (okay admittedly it&apos;s a beta, but still!). Running Next.js on the Workers ecosystem is an absolute dream, and
I&apos;m not just saying that to be a corporate simp.&lt;/p&gt;
&lt;h2&gt;How does Next on Workers compare to Vercel?&lt;/h2&gt;
&lt;p&gt;I deployed a copy of this site to &lt;a href=&quot;https://vercel.com/&quot;&gt;Vercel&lt;/a&gt; to measure the performance and compare that to the active deployment on
Workers, and honestly there&apos;s not really that much in it. Both sites load within ~350ms, which is to be expected considering I&apos;m in a central
metro area with a beefy computer and fast internet. I imagine for bigger projects or applications with users in a lot of varied, distinct geographies
there might be a bit more of a difference - but for my use case it doesn&apos;t really matter too much. I think the biggest deciding factor is the ecosystem.
I&apos;ve been a fan of Vercel&apos;s platform for a while, but their pricing is pretty whack and that doesn&apos;t give me a huge amount of confidence to deploy
anything other than small hobby projects.&lt;/p&gt;
</content:encoded><category>meta</category><author>Ryan Skidmore</author></item><item><title>✨ That new blog smell ✨</title><link>https://skids.dev/blog/that-new-blog-smell/</link><guid isPermaLink="true">https://skids.dev/blog/that-new-blog-smell/</guid><description>Hello world, again!</description><pubDate>Sun, 23 Jun 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;It turns out I&apos;m not very good at blogging, in fact the last time (and first time) I wrote a blog post on the previous incarnation of this blog was November 2020. In lieu of biting the bullet and writing a second blog post, I thought it was about time I rebuilt my blog again! &lt;em&gt;and not on Kubernetes this time&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;My previous blog was built using &lt;a href=&quot;https://www.gatsbyjs.com/&quot;&gt;Gatsby&lt;/a&gt; and hosted on &lt;a href=&quot;https://www.netlify.com/&quot;&gt;Netlify&lt;/a&gt;. This stack works perfectly well but over the last few years I&apos;ve been using a lot of &lt;a href=&quot;https://nextjs.org/&quot;&gt;Next.js&lt;/a&gt; on &lt;a href=&quot;https://vercel.com/&quot;&gt;Vercel&lt;/a&gt;. The performance of Next.js isn&apos;t as good as Gatsby, but it&apos;s a lot more flexible at runtime and the ecosystem is huge - so it&apos;s a worthwhile trade-off. I was going to host this on Vercel like I&apos;ve done with my other Next.js projects, but I thought I&apos;d give &lt;a href=&quot;https://pages.cloudflare.com/&quot;&gt;Cloudflare Pages&lt;/a&gt; a go this time instead. After all, &lt;a href=&quot;https://www.linkedin.com/feed/update/urn:li:activity:7208162374577442816/&quot;&gt;I do work for Cloudflare now&lt;/a&gt;!&lt;/p&gt;
&lt;p&gt;See you again probably in 4 years folks 🫡&lt;/p&gt;
</content:encoded><category>meta</category><author>Ryan Skidmore</author></item></channel></rss>