<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Thomas Conway]]></title><description><![CDATA[Thomas Conway]]></description><link>https://thomasconway.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Wed, 28 Oct 2026 13:24:58 GMT</lastBuildDate><atom:link href="https://thomasconway.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Asking a language model for a map instead of a paragraph: how Proto turns one idea into clickable branches]]></title><description><![CDATA[Same model as chat, a stricter output shape, and only the card you branch goes back to the model]]></description><link>https://thomasconway.hashnode.dev/asking-a-language-model-for-a-map-instead-of-a-paragraph-how-proto-turns-one-idea-into-clickable-branches</link><guid isPermaLink="true">https://thomasconway.hashnode.dev/asking-a-language-model-for-a-map-instead-of-a-paragraph-how-proto-turns-one-idea-into-clickable-branches</guid><category><![CDATA[UX]]></category><category><![CDATA[AI]]></category><category><![CDATA[llm]]></category><category><![CDATA[Productivity]]></category><dc:creator><![CDATA[Thomas Conway]]></dc:creator><pubDate>Tue, 27 Oct 2026 14:11:51 GMT</pubDate><content:encoded><![CDATA[<p><em>Disclosure: I'm Thomas Conway, and I built cognira and Proto. This is a shortened write-up of <a href="https://cognira.dev/blog/mapping-an-idea-with-proto">the original post on the cognira blog</a>.</em></p>
<p>A chat reply is a paragraph. Proto is the same model, cognira Retrain, asked for a map instead: a title, a sentence, and a few branches you can click.</p>
<h2>You start with a sentence</h2>
<p>The composer is the same one as chat. You describe one idea (a shed the street already has, a loaf club, a fact you want a model to remember) and send it. While the model works you get the same waiting state as a chat reply, and if the machine is busy you see the queue.</p>
<p>Nothing is drawn until the model answers in the shape Proto can read.</p>
<h2>Edit a card, branch it when you want more</h2>
<p>The first reply is four to six branches, specific to that idea. A few design choices I made:</p>
<ul>
<li>Clicking a card lets you change its name or the sentence under it. That edit stays in your browser and doesn't call the model.</li>
<li>Right-click a card and choose <strong>Branch</strong> to get children. Only that card is sent back to the model, along with the path that got you there. The other cards stay put.</li>
<li>The map stops four steps out from the centre, so it stays readable on one screen.</li>
<li>If the model replies with a paragraph, or a single lonely branch, Proto doesn't invent the rest. You get a message and the grid stays as it was.</li>
</ul>
<p>That last one matters to me. A tool that quietly fills gaps with made-up structure is worse than one that tells you the answer didn't come back in the right shape.</p>
<h2>No second meter</h2>
<p>A new map and each branch you grow count toward the same weekly tokens as chat, and share the same short burst limit. A call that reaches the model is charged; a call you stop, or that never reaches the model, releases the hold.</p>
<p>The idea isn't filed into your chat history. The map stays in your browser, under the account that drew it, and you can copy an outline or download the cards as an image.</p>
<p>Proto is in the header on <a href="https://cognira.dev">cognira.dev</a>, and inside chat it sits in the sidebar under Workspace.</p>
]]></content:encoded></item><item><title><![CDATA[One chat list across web and phone: continuing a conversation from cognira.dev in the Android app]]></title><description><![CDATA[How the app's chat tab works when the list belongs to the account, not the device]]></description><link>https://thomasconway.hashnode.dev/one-chat-list-across-web-and-phone-continuing-a-conversation-from-cognira-dev-in-the-android-app</link><guid isPermaLink="true">https://thomasconway.hashnode.dev/one-chat-list-across-web-and-phone-continuing-a-conversation-from-cognira-dev-in-the-android-app</guid><category><![CDATA[Android]]></category><category><![CDATA[mobile]]></category><category><![CDATA[UX]]></category><category><![CDATA[AI]]></category><dc:creator><![CDATA[Thomas Conway]]></dc:creator><pubDate>Mon, 26 Oct 2026 14:18:28 GMT</pubDate><content:encoded><![CDATA[<p><em>Disclosure: I'm Thomas Conway, and I built cognira and its Android app. This is a shortened write-up of <a href="https://cognira.dev/blog/chats-that-follow-you">the original post on the cognira blog</a>.</em></p>
<p>A chat you start on the website should carry on when you pick up your phone. In cognira's Android app, the Chat tab opens on the list, not on a blank composer, and that list is the account's: conversations from cognira.dev and from the app, merged, pinned ones first, then grouped by day.</p>
<h2>Open it, reply, and both sides have the turn</h2>
<p>Tap a chat you started on the website and the messages are there, including the rating you left and the memories that reply used. Send another message and you're in the same conversation. The next time the website loads that chat, the new turn is in it.</p>
<p>A few details I cared about:</p>
<ul>
<li>Replies type themselves out in Markdown: headings, lists, quotes, and code you can copy.</li>
<li>If the model returned its reasoning, a row labelled <strong>Thought for</strong> (with the seconds it took) opens into it.</li>
<li>Ordinary web links open in the in-app browser.</li>
<li>The app never invents a reply when the iMac that serves the model can't be reached. You get the error, and a retry.</li>
</ul>
<h2>The list</h2>
<ul>
<li>Search appears as soon as there's a chat.</li>
<li>Long-press deletes one.</li>
<li>A <strong>New chat</strong> button floats over the list; the ask bar on Home does the same thing and opens the keyboard.</li>
<li>Android's back button, the back arrow labelled <strong>All chats</strong>, or tapping the Chat tab again returns you to the list.</li>
</ul>
<p>Home keeps a shorter row of recent chats under the week's numbers. The empty state says "Your conversations, from here and cognira.dev, land here", and it's literal. If a chat you just had on the website is missing, pull down on Home or on the list to sync.</p>
<h2>One allowance</h2>
<p>Tokens count against the plan on the account, the same weekly number the website shows. Home draws it as a bar with when it resets, and <strong>Upgrade plan</strong> opens the plans page. Free, Pro, and Max all talk to cognira Retrain, the model cognira runs. The phone doesn't get a second quota.</p>
<hr />
<p>More on cognira at <a href="https://cognira.dev">cognira.dev</a>. The full post, with screenshots and context, is <a href="https://cognira.dev/blog/chats-that-follow-you">on the cognira blog</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Reading chat replies aloud on Android: the phone's own voice, Markdown stripped, one reply at a time]]></title><description><![CDATA[The small rules behind a Read aloud button and an auto-read switch in an assistant app]]></description><link>https://thomasconway.hashnode.dev/reading-chat-replies-aloud-on-android-the-phone-s-own-voice-markdown-stripped-one-reply-at-a-time</link><guid isPermaLink="true">https://thomasconway.hashnode.dev/reading-chat-replies-aloud-on-android-the-phone-s-own-voice-markdown-stripped-one-reply-at-a-time</guid><category><![CDATA[Android]]></category><category><![CDATA[Accessibility]]></category><category><![CDATA[mobile]]></category><category><![CDATA[AI]]></category><dc:creator><![CDATA[Thomas Conway]]></dc:creator><pubDate>Sun, 25 Oct 2026 14:08:06 GMT</pubDate><content:encoded><![CDATA[<p><em>Disclosure: I'm Thomas Conway, and I built cognira and its Android app. This is a shortened write-up of <a href="https://cognira.dev/blog/read-a-reply-aloud">the original post on the cognira blog</a>.</em></p>
<p>cognira's Android app can read an assistant reply out loud. It sounds like a one-line feature, but most of the work was deciding the rules: what speaks, when it starts, what stops it, and what never gets read at all. Here's where I landed.</p>
<h2>The phone's own voice, not ours</h2>
<p>Read aloud uses the phone's speech engine, in British English. That's separate from Speak on the cognira website, which is a recording made on our side. On the phone, the audio never leaves the handset.</p>
<p>Before anything is spoken, the Markdown is stripped. If a reply has nothing left after cleaning (only markup), it's skipped rather than read as silence or symbols.</p>
<h2>One reply at a time</h2>
<ul>
<li>Tap <strong>Read aloud</strong> and that reply starts.</li>
<li>Tap it again (it's now labelled <strong>Stop reading</strong>) and it stops.</li>
<li>Starting another reply stops the one that was going.</li>
<li>A line of sound sits on the message while it speaks, so you can see which turn is the one in the air.</li>
</ul>
<p>Only one thing ever talks, and the button always tells you what pressing it will do.</p>
<h2>Or every reply, as it arrives</h2>
<p>Settings has a <strong>Read replies aloud</strong> switch.</p>
<ul>
<li><strong>On:</strong> each finished reply speaks by itself, and it takes the speaker if something else was already being read.</li>
<li><strong>Off:</strong> only the button starts speech, and a new reply leaves a reading you started alone.</li>
</ul>
<p>The choice is kept on the phone between launches, next to the haptics switch. Haptics (the small taps on buttons and tabs) are independent of the voice.</p>
<p>One deliberate choice: speech starts when the reply has landed, not one token at a time while it's still streaming.</p>
<h2>What it won't read</h2>
<ul>
<li>The button is only on assistant messages. Your own turns stay text.</li>
<li>Code fences lose their fence marks and are read as the words inside. That's useful for a short snippet and noisy for a long one.</li>
<li>For a long snippet, <strong>copy</strong> is the other action on the same row, and it keeps the original Markdown.</li>
</ul>
<h2>Try it</h2>
<p>cognira for Android is free on Google Play, and the full post with the rest of the details is on the <a href="https://cognira.dev/blog/read-a-reply-aloud">cognira blog</a>. I'm happy to answer questions about how it's built in the comments.</p>
]]></content:encoded></item><item><title><![CDATA[One streak reminder at 7pm: a server push that knows about the website, plus an on-phone fallback]]></title><description><![CDATA[How I built a daily nudge for an Android app that only fires on days you really haven't shown up]]></description><link>https://thomasconway.hashnode.dev/one-streak-reminder-at-7pm-a-server-push-that-knows-about-the-website-plus-an-on-phone-fallback</link><guid isPermaLink="true">https://thomasconway.hashnode.dev/one-streak-reminder-at-7pm-a-server-push-that-knows-about-the-website-plus-an-on-phone-fallback</guid><category><![CDATA[Expo]]></category><category><![CDATA[mobile]]></category><category><![CDATA[Android]]></category><category><![CDATA[React Native]]></category><dc:creator><![CDATA[Thomas Conway]]></dc:creator><pubDate>Sat, 24 Oct 2026 13:11:29 GMT</pubDate><content:encoded><![CDATA[<p><em>Disclosure: I'm Thomas Conway, and I built cognira and its Android app. This is a shortened write-up of <a href="https://cognira.dev/blog/one-nudge-at-7pm">the original post on the cognira blog</a>.</em></p>
<p>cognira's Android app has a streak on its Home screen, and an optional reminder to keep it going. The rule I set for that reminder was simple: one nudge at 7pm, and only on days you haven't used cognira. Getting "haven't used cognira" right turned out to be the interesting part, because people use it on the website as well as the phone.</p>
<h2>Ask once, then get out of the way</h2>
<p>The first time there's a streak to keep, Home shows a card: "Keep your streak going?" <strong>Turn on</strong> asks Android for notification permission (on Android 13 and later that's the system dialog). <strong>Not now</strong> dismisses the card. After that the switch lives in Settings, next to haptics and read aloud, so changing your mind doesn't mean hunting for it.</p>
<p>The card says exactly what you're signing up for: one nudge at 7pm, on days you haven't opened cognira. The notification title is "Don't lose your streak", and the body names the length, either a first day or the number you're on.</p>
<h2>Two ways it can fire, and why both exist</h2>
<p><strong>Server push (the main path).</strong> With permission granted, the phone registers an Expo push token with the account, along with the time zone and whether reminders are on. The server sends the 7pm nudge only when that account has been quiet everywhere that day, the app and the website included. Signing out removes the token, so the pushes stop.</p>
<p><strong>On-phone reminder (the fallback).</strong> If the token never lands, because permission was refused or the network was down, a local reminder takes over. Each time you open the app it moves that reminder to tomorrow evening, so it only fires on a day the app itself wasn't opened.</p>
<p>The trade-off is worth stating plainly: the fallback can't see a day you spent only on cognira.dev, and the server push can. That's why the server path is the main one. And a reminder that fails to schedule is just dropped; it isn't worth an error on screen.</p>
<h2>What the streak actually counts</h2>
<p>The marks are days the account was active, recorded by the backend from signed-in requests, not taps counted on the phone. Under the streak are three numbers: questions and maps touched in the last seven days, and how many chats are saved. The card also names your best run when the current one is shorter, or tells you this is your best. A day that gets marked plays a short burst on the card, once.</p>
<p>The reminder is optional. Chat, Proto (cognira's idea-mapping tool) and robot control all work with it off.</p>
<h2>The takeaway for anyone building a streak reminder</h2>
<p>If your product lives on more than one surface, the device alone can't know whether the user showed up today. Put the "was this account active today?" check on the server, where every signed-in request lands, and keep a local reminder only as a fallback for when the push token can't be registered.</p>
<hr />
<p>cognira is an AI assistant you can teach, running its own model (cognira Retrain 12B on a Gemma 4 base). More at <a href="https://cognira.dev">cognira.dev</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Store memories as statements, not instructions: how my assistant's user memory resists prompt injection]]></title><description><![CDATA[Teaching mid-conversation, recall receipts, a real Forget button, and a seven-check gate before anything is shared]]></description><link>https://thomasconway.hashnode.dev/store-memories-as-statements-not-instructions-how-my-assistant-s-user-memory-resists-prompt-injection</link><guid isPermaLink="true">https://thomasconway.hashnode.dev/store-memories-as-statements-not-instructions-how-my-assistant-s-user-memory-resists-prompt-injection</guid><category><![CDATA[AI]]></category><category><![CDATA[llm]]></category><category><![CDATA[Security]]></category><category><![CDATA[chatbot]]></category><dc:creator><![CDATA[Thomas Conway]]></dc:creator><pubDate>Fri, 23 Oct 2026 13:07:50 GMT</pubDate><content:encoded><![CDATA[<p><em>Disclosure: I'm Thomas Conway, and I built cognira, the assistant described here. This is a shortened write-up of <a href="https://cognira.dev/blog/how-to-teach-cognira">the original post on the cognira blog</a>.</em></p>
<p>cognira is an AI assistant you can teach things to in plain conversation. The part I most want other people building memory features to steal is a small design choice: <strong>every memory is stored as a third-person statement about the user, never as an instruction.</strong> Here is how the whole flow works and why that choice matters.</p>
<h2>Teaching happens mid-sentence</h2>
<p>There's no settings panel and no special prefix. If a message matches a pattern cognira knows, it's saved before the reply comes back. Some real examples:</p>
<table>
<thead>
<tr>
<th>You type</th>
<th>It saves</th>
</tr>
</thead>
<tbody><tr>
<td>call me Tom</td>
<td>Prefers to be called "Tom".</td>
</tr>
<tr>
<td>i live in Leeds</td>
<td>Is based in Leeds.</td>
</tr>
<tr>
<td>keep it short</td>
<td>Prefers replies kept short.</td>
</tr>
<tr>
<td>don't use emojis</td>
<td>Requests no emojis in replies.</td>
</tr>
<tr>
<td>talk like a doctor</td>
<td>Asks for replies in a clinical, medical register.</td>
</tr>
</tbody></table>
<p>You can also teach vocabulary: <code>glimble means a tiny useful shortcut</code>, <code>EOD = end of day</code>, or a correction like <code>no, glimble is a small workaround</code>. If you use a word it doesn't know and the conversation depends on it, it asks what you meant, and your plain answer is saved as a confirmed definition. That's the highest-confidence signal in the system, because you were answering a direct question about a specific word.</p>
<h2>What happens when you hit send</h2>
<p>Three things, in order, before the first token of the reply:</p>
<ol>
<li>The message is scanned for anything teachable, and a match is written to private memory immediately.</li>
<li>Everything relevant you've taught before is recalled and put in front of the model, including things from a different conversation weeks ago. The store is indexed, so "keep it short" on a Tuesday still shapes an unrelated answer on Friday.</li>
<li>Both are attached to the reply so the interface can show exactly what was used.</li>
</ol>
<p>Under each reply there's a receipt: a chip like <em>Learned · "prefers replies kept short" · Undo</em>, and <em>Used 2 memories</em>, which expands into the actual statements, where each came from, and a Forget button per line. Saving is instant and doesn't block the reply; the slower machinery runs later on an idle machine.</p>
<h2>Statements, not orders</h2>
<p>When you say "keep it short", cognira stores <em>Prefers replies kept short.</em> It does not store <em>Always reply in under three sentences.</em> The first is data about a person; the second is a standing order.</p>
<p>Memories are injected into the model's context inside a fixed frame that says, in effect, "here is some known context about this user", never as rules. That's the firewall. If memories were stored as instructions, anyone could type a sentence designed to be remembered as a rule, and that rule would be silently re-applied to every future conversation. Storing statements means something you typed can inform a reply but can never quietly become policy.</p>
<p>Every memory also keeps its evidence: the conversation and message it came from. That's what makes "where did that come from?" answerable, and what makes deleting it mean something.</p>
<h2>Forgetting is real</h2>
<p>You can remove anything from the chat (expand <em>Used N memories</em> and hit Forget) or from Settings → Learning, which lists everything with Mute (stop using it, keep it) and Forget (gone). A forgotten memory drops out of recall on the next message. If it was vouching for a piece of shared knowledge, that entry loses your corroboration too, and can be withdrawn if it no longer has enough independent support.</p>
<h2>The narrow path to shared knowledge</h2>
<p>Almost everything you teach stays yours. Only one category can travel: a word meaning. Never your name, job, location or preferences. Three people liking short replies doesn't make short replies a fact about the world.</p>
<p>A definition has to clear seven checks, in order, and any one stops it:</p>
<ol>
<li><strong>Independent:</strong> at least three different accounts taught it.</li>
<li><strong>Spread out:</strong> the lessons are at least a week apart.</li>
<li><strong>In agreement:</strong> the definitions actually match.</li>
<li><strong>Screened:</strong> no personal details, secrets or unsafe content.</li>
<li><strong>Non-conflicting:</strong> it doesn't contradict shared knowledge already live.</li>
<li><strong>Reworded:</strong> rewritten generally, and rejected if it reproduces anyone's sentence.</li>
<li><strong>Quizzed:</strong> the rewrite is tested back against every original definition.</li>
</ol>
<p>Then it serves to a small slice of traffic for a week before going fully live. It's deliberately slow: the failure I care about isn't "shared knowledge grew too slowly", it's "one confident person redefined a word for everybody".</p>
<p>Every check writes to an append-only ledger, pass or fail, and it's public at <a href="https://cognira.dev/learning">cognira.dev/learning</a>. Showing what got rejected is the only version of "look what our AI learned" you can actually check. (It's cached, so a new lesson can take a couple of minutes to show.)</p>
<h2>What it doesn't do yet</h2>
<ul>
<li><strong>It doesn't catch every phrasing.</strong> Capture is tuned for precision over coverage, because a wrong memory is worse than a missing one. No receipt means no memory.</li>
<li><strong>Shared knowledge is nearly empty.</strong> Three unrelated accounts teaching the same word a week apart is a high bar on a young service.</li>
<li><strong>Nothing has changed the model's weights yet.</strong> The eval-gated path for folding shared knowledge into the checkpoint is built and tested, but no candidate has been promoted. When one is, it'll appear in the same public ledger with its scores.</li>
</ul>
<p>If you're building memory into your own assistant, the one thing I'd pass on is the statements-not-instructions rule. It costs nothing and closes a whole class of persistent prompt injection.</p>
<p>You can try it at <a href="https://cognira.dev">cognira.dev</a>: type "call me" and your name, then ask anything, and look at the receipt under the reply.</p>
]]></content:encoded></item><item><title><![CDATA[Using a small self-hosted model as a coding agent in opencode: config, timings and one honest test]]></title><description><![CDATA[Pointing opencode at an OpenAI-compatible endpoint, and what one sentence of prompt produced]]></description><link>https://thomasconway.hashnode.dev/using-a-small-self-hosted-model-as-a-coding-agent-in-opencode-config-timings-and-one-honest-test</link><guid isPermaLink="true">https://thomasconway.hashnode.dev/using-a-small-self-hosted-model-as-a-coding-agent-in-opencode-config-timings-and-one-honest-test</guid><category><![CDATA[AI]]></category><category><![CDATA[llm]]></category><category><![CDATA[opencode]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Thomas Conway]]></dc:creator><pubDate>Thu, 22 Oct 2026 13:16:24 GMT</pubDate><content:encoded><![CDATA[<p><em>Disclosure: I'm Thomas Conway, and I built cognira, the model and API used here. This is a shortened write-up of <a href="https://cognira.dev/blog/coding-with-retrain-in-opencode">the original post on the cognira blog</a>.</em></p>
<p>cognira runs its own models (cognira Retrain 12B, retrained on a Gemma 4 base, and the older Omega 188M) behind an OpenAI-compatible API. I wanted to see how it holds up as the brain of a terminal coding agent, so I wired it into <a href="https://opencode.ai">opencode</a> and gave it one sentence.</p>
<h2>The config</h2>
<p>opencode reads providers from <code>~/.config/opencode/opencode.json</code> (global) or <code>opencode.json</code> in a project root. Because the API is OpenAI-compatible, the generic <code>@ai-sdk/openai-compatible</code> adapter is all you need: no plugin, no shim.</p>
<pre><code class="language-json">{
  "$schema": "https://opencode.ai/config.json",
  "provider": {
    "cognira": {
      "npm": "@ai-sdk/openai-compatible",
      "name": "cognira",
      "options": {
        "baseURL": "https://cognira.dev/v1",
        "apiKey": "{env:COGNIRA_API_KEY}"
      },
      "models": {
        "cognira-retrain": { "name": "cognira/cognira-retrain" },
        "cognira-omega": { "name": "cognira/cognira-omega" }
      }
    }
  }
}
</code></pre>
<p>Using <code>{env:COGNIRA_API_KEY}</code> keeps the key out of the file (and out of git). Two models then show up in the picker: Retrain answers straight out, Omega thinks first. Both bill from the same balance at £1.50 per million tokens, input and output.</p>
<h2>Say hello first</h2>
<p>Before giving an agent a real task I spend one prompt on "hello". It proves the auth, base URL and streaming path work. In my run:</p>
<ul>
<li>Retrain: no thinking block, 20.3 seconds end to end.</li>
<li>Omega: a 7.4-second thought, then the reply, 26.2 seconds total.</li>
</ul>
<p>For the actual build I stayed on Retrain. When a task is mostly "emit a lot of correct boilerplate", thinking tokens are just latency.</p>
<h2>One prompt, no follow-ups</h2>
<p>The prompt was a single sentence asking for a "full stack html portfolio" for me. After 37.5 seconds it started streaming, and it opened by pushing back: a portfolio doesn't need a backend. Then it delivered the front end anyway, as three files (<code>index.html</code>, <code>style.css</code>, <code>script.js</code>), with sectioned, commented HTML, anchor nav wired to matching IDs, a custom-property palette, and a mobile breakpoint, which is the part models usually forget.</p>
<p>It also left a marker instead of inventing an asset URL, <code>url('path/to/tech_bg.jpg')</code> with a comment to drop in a background image. Saving one image was the only edit I made by hand.</p>
<h2>What it got wrong</h2>
<p>It isn't a design award. The social links were <code>href="#"</code> placeholders, the projects were invented, and the copyright said 2024. But it was responsive and shippable after about ten minutes of filling in real content.</p>
<h2>Notes if you try this with any OpenAI-compatible model</h2>
<ul>
<li>Use the fast model for volume (code generation) and the thinking model for debugging and architecture calls.</li>
<li>Expect a wait before the first token; once streaming starts it doesn't stall, so don't kill the process at 30 seconds.</li>
<li>It may argue with your prompt. If you want the over-engineered version, say so.</li>
<li>Watch opencode's context and spend readout to notice a long session eating tokens.</li>
</ul>
<p>The same config shape works for anything that takes an OpenAI-compatible base URL: Cline, Aider, the OpenAI SDKs, or your own scripts. Docs and keys are on the <a href="https://cognira.dev/platform">cognira API platform page</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Building Chromium on one 32GB PC: the ThinLTO link that ran out of memory, and the one-line fix]]></title><description><![CDATA[Notes from compiling exceeder, a Chromium fork with 65 patches, on a single 6-core Ryzen]]></description><link>https://thomasconway.hashnode.dev/building-chromium-on-one-32gb-pc-the-thinlto-link-that-ran-out-of-memory-and-the-one-line-fix</link><guid isPermaLink="true">https://thomasconway.hashnode.dev/building-chromium-on-one-32gb-pc-the-thinlto-link-that-ran-out-of-memory-and-the-one-line-fix</guid><category><![CDATA[chromium]]></category><category><![CDATA[cplusplus]]></category><category><![CDATA[Windows]]></category><category><![CDATA[Browsers]]></category><dc:creator><![CDATA[Thomas Conway]]></dc:creator><pubDate>Wed, 21 Oct 2026 13:08:10 GMT</pubDate><content:encoded><![CDATA[<p><em>Disclosure: I'm Thomas Conway, and I built exceeder and cognira. This is a shortened write-up of the <a href="https://cognira.dev/blog/building-exceeder">original build notes on the cognira blog</a>.</em></p>
<p>exceeder is a Chromium browser with my assistant, cognira, built in. It isn't a web app dressed up as a browser: it's Chromium compiled from source on one Windows PC, with 65 patches and our own code on top. Here's what that took, including the afternoon the linker ran a 32GB machine out of memory.</p>
<h2>Why a fork and not Electron</h2>
<p>The first prototype was an Electron app. It was a fine sketch of the design, but Electron is an app framework, not a browser. Chrome extensions, the Web Store, a real tab model and Chrome's settings would all have had to be rebuilt by hand, and every web page would have lived inside another web page. So exceeder is Chromium itself: Blink, V8, the sandbox, the tab strip and the extension system. The Electron prototype stays in the repo as the design reference.</p>
<h2>How the tree is laid out</h2>
<p>The layout borrows from Brave's: our code never lives only inside a Chromium checkout. The repo holds three things, and one script lays them onto a clean checkout:</p>
<ul>
<li><code>exceeder/</code>: our own C++ and TypeScript (the cognira panel, setup pages, importer, new tab additions).</li>
<li><code>patches/</code>: one patch per upstream file we change, 65 of them, kept small on purpose. Most wire something of ours in or switch a Chrome behaviour off.</li>
<li><code>overlay/</code>: files that replace upstream ones outright, like icons generated from the brand kit.</li>
</ul>
<pre><code class="language-bash">python scripts/exceeder.py apply           # our tree + overlay + patches + rebrand
python scripts/exceeder.py build           # the dev build
python scripts/exceeder.py update-patches  # turn edits in the checkout back into patches
</code></pre>
<p>Chrome features we don't want (the avatar sign-in pill, the restore bar, new tab promos, AI Mode entry points) are switched off from one file of feature defaults instead of being patched out where they live. The next Chromium version is then a rebase of a short list.</p>
<h2>The numbers</h2>
<table>
<thead>
<tr>
<th></th>
<th></th>
</tr>
</thead>
<tbody><tr>
<td>Chromium version</td>
<td>155.0.8059.85</td>
</tr>
<tr>
<td>Patches on upstream files</td>
<td>65, one file each</td>
</tr>
<tr>
<td>Steps in a release build</td>
<td>66,925</td>
</tr>
<tr>
<td>Object files</td>
<td>47,772</td>
</tr>
<tr>
<td>chrome.dll, release</td>
<td>346 MB</td>
</tr>
<tr>
<td>Installer</td>
<td>132 MB</td>
</tr>
<tr>
<td>Machine</td>
<td>6-core Ryzen 5 7600X, 32 GB RAM</td>
</tr>
<tr>
<td>Dev rebuild after a UI change</td>
<td>58 seconds</td>
</tr>
</tbody></table>
<p>The release build uses Chrome's "official" configuration, which turns on ThinLTO: the compiler leaves optimisation half-done and the linker finishes it across the whole program. You get a faster browser and a long, hungry link. Compiling took a little over five hours on this machine.</p>
<h2>The link that ran out of memory</h2>
<p>With everything compiled, the build reached the link of <code>chrome.dll</code>. Chromium tells the linker to run its ThinLTO work on <em>all</em> of the machine's threads: twelve here, each holding its own slice of the program in memory. The PC ran critically low on memory and the build was stopped.</p>
<p>The obvious fixes were worse:</p>
<ul>
<li>The thread setting is hard-coded in Chromium's build files. Changing it changes the link command of every program in the build, so dozens of build tools relink and everything they generate is treated as new.</li>
<li>The linker's <code>_LINK_</code> environment variable looked like an escape hatch, but lld puts its contents <em>before</em> the command line, so the build's own setting still wins. I checked: the output name from the command line beat the one from the variable.</li>
</ul>
<p>The fix was to tell the linker it had fewer CPUs. LLVM's "all" counts the CPUs the process is allowed to run on, and child processes inherit that. On a test link the linker peaked at 18 threads with every CPU allowed, 12 with six, and 8 with two. So the restarted build ran pinned to one logical CPU per physical core:</p>
<pre><code class="language-python"># One logical CPU per core on a 6-core, 12-thread Ryzen: 0b010101010101.
kernel32.SetProcessAffinityMask(kernel32.GetCurrentProcess(), 0x555)
</code></pre>
<p>Six ThinLTO jobs instead of twelve, and no build command changed. The linker also keeps a cache of work it has already finished, so the restart picked up where the stopped link left off, and the restarted build finished in 11 minutes.</p>
<h2>A bug in my own tooling</h2>
<p>The first release build showed two headers on the cognira panel, even though the change removing one was already in the repo. The apply script copied changed files with their original timestamps, so the copies looked older than the already-compiled objects and the build kept the stale ones. Now a copied file gets the current time and an unchanged one keeps its timestamp, so builds stay incremental. The bug lasted one build.</p>
<h2>Day to day</h2>
<p>Daily work happens in a component build, which splits Chromium into many small libraries so a change relinks quickly. A small UI change rebuilds in about a minute (58 seconds for the change that added the panel's <code>Ctrl+Shift+.</code> shortcut). The setup pages and the cognira panel are TypeScript, and a small preview tool compiles them with Chromium's own TypeScript settings against a fake browser and screenshots them in seconds, so most design work never waits for a build.</p>
<h2>Try it</h2>
<p>exceeder is free in early access for Windows 10 and 11 (64-bit). Chrome extensions work, and anything the assistant would send, buy or delete waits for your OK. Details are on the <a href="https://cognira.dev/exceeder">exceeder page</a>. If you've fought Chromium's ThinLTO link on a desktop machine and found a better trick, I'd genuinely like to hear it.</p>
]]></content:encoded></item><item><title><![CDATA[Designing a Stop button you can trust: safety choices in a tiny Python robotics SDK]]></title><description><![CDATA[How cognira-robotics turns Python functions into web controls, and why the SDK, not each script, handles stopping]]></description><link>https://thomasconway.hashnode.dev/designing-a-stop-button-you-can-trust-safety-choices-in-a-tiny-python-robotics-sdk</link><guid isPermaLink="true">https://thomasconway.hashnode.dev/designing-a-stop-button-you-can-trust-safety-choices-in-a-tiny-python-robotics-sdk</guid><category><![CDATA[Python]]></category><category><![CDATA[robotics]]></category><category><![CDATA[iot]]></category><category><![CDATA[Raspberry Pi]]></category><dc:creator><![CDATA[Thomas Conway]]></dc:creator><pubDate>Sun, 18 Oct 2026 13:06:33 GMT</pubDate><content:encoded><![CDATA[<p><em>Disclosure: I'm Thomas Conway and I built cognira and its Robotics SDK. This is a write-up of my own project, not a neutral review. A version of it first appeared on the <a href="https://cognira.dev/blog/driving-a-robot-with-python">cognira blog</a>.</em></p>
<p>Most hobby robot projects end up with a little web page for driving them, and most of those pages are built by hand, once per robot. I wanted to skip that step: write the Python functions you already have, put a decorator on each, and get a control panel for free.</p>
<p>That part turned out to be the easy bit. The part I spent the most time on was <strong>stopping</strong>. A robot is not a chat window. A button press that arrives late can knock something off a table, so I decided the safety behaviour had to live in the SDK itself instead of being left to every script.</p>
<h2>The basic idea</h2>
<pre><code class="language-bash">pip install cognira-robotics
</code></pre>
<p>It needs Python 3.8 or newer and has no dependencies outside the standard library, so it runs on a Raspberry Pi, a Jetson, or a laptop wired to an Arduino.</p>
<pre><code class="language-python">from cognira_robotics import Robot

robot = Robot("Hello robot")

@robot.button("Wave", key="w")
def wave():
    robot.log("waving")

robot.run()
</code></pre>
<p>The first <code>robot.run()</code> prints a code. You approve it on cognira.dev, and from then on the robot shows up under Robotics whenever its script is running, with each control drawn next to the function it calls. The robot only makes outbound HTTPS requests, so it works behind home Wi-Fi, a school network or a phone hotspot without opening any ports.</p>
<p>The decorators map to controls like this:</p>
<table>
<thead>
<tr>
<th>Decorator</th>
<th>Your function gets</th>
<th>Notes</th>
</tr>
</thead>
<tbody><tr>
<td><code>@robot.button</code></td>
<td>nothing</td>
<td>Runs once per press</td>
</tr>
<tr>
<td><code>@robot.hold</code></td>
<td><code>pressed</code></td>
<td>True on press, False on release</td>
</tr>
<tr>
<td><code>@robot.toggle</code></td>
<td><code>on</code></td>
<td>An on/off switch</td>
</tr>
<tr>
<td><code>@robot.slider</code></td>
<td><code>value</code></td>
<td>Snapped to its step; whole-number steps give an int</td>
</tr>
<tr>
<td><code>@robot.joystick</code></td>
<td><code>x, y</code></td>
<td>Each axis from -1 to 1</td>
</tr>
<tr>
<td><code>@robot.text</code></td>
<td><code>text</code></td>
<td>A text box with a Send button</td>
</tr>
</tbody></table>
<p>Any control can take a keyboard key, so <code>key="w"</code> and friends give you WASD driving. <code>robot.show()</code> and <code>robot.log()</code> stream values and log lines back to the page.</p>
<h2>Safety decision 1: Stop interrupts, it doesn't queue</h2>
<p>The red button (or Esc) calls your <code>@robot.on_stop</code> function straight away, even while another function is still running, and drops anything queued behind it.</p>
<p>The awkward case is a long-running action, like a sweep loop. For that, <code>robot.sleep()</code> returns <code>False</code> the moment Stop is pressed, so loops can end cleanly instead of finishing their routine:</p>
<pre><code class="language-python">@robot.button("Sweep", key="s")
def sweep():
    for target in (25, 155, 25, 155):
        send(f"a {target}")
        if not robot.sleep(0.6):   # False the moment Stop is pressed
            return

@robot.on_stop
def stop():
    send("off")                    # let the servo go limp
</code></pre>
<p>In my test with an ESP32 and a servo, Stop arrived about a second into a sweep. The servo went limp mid-swing at 104°, and the sweep's <code>robot.sleep()</code> woke up and returned.</p>
<h2>Safety decision 2: held controls need a pulse</h2>
<p>A joystick or hold-button is the dangerous kind of control, because "the user is still holding it" and "the user's Wi-Fi died while holding it" look the same to a naive robot.</p>
<p>So while you hold a joystick, the page re-sends its position four times a second. If those refreshes stop, because you let go, closed the tab or lost the connection, the robot lets go after 1.5 seconds by default: joysticks re-centre and held buttons release. When I steered the servo hard left and then stopped sending, it came back to 90° on its own.</p>
<h2>Safety decision 3: late commands are thrown away</h2>
<p>Reconnection is where replayed commands bite. If a robot drops off the network and comes back, the last thing you want is a burst of stale presses arriving all at once.</p>
<p>So a button press that can't be delivered within 10 seconds is discarded, and a joystick position within 1.5 seconds. Neither is replayed into a robot that has just reconnected.</p>
<h2>Clamping happens before the hardware</h2>
<p>Sliders carry their own limits. In the ESP32 test, a slider set to 140 moved the servo to 140°, and a value of 999 was capped at the slider's 155° maximum before it ever reached the board. Full left on the joystick went to 25° and half right to 122°, matching the <code>90 + x * 65</code> mapping in the script.</p>
<p>The ESP32 can't run desktop Python, so the SDK runs on the computer it's plugged into and passes one-line commands down the USB cable (<code>a 120</code> to move to 120°, <code>off</code> to go limp, <code>status</code> to report). The whole bridge is one short script using <code>pyserial</code>.</p>
<h2>Trying it without an account</h2>
<p><code>robot.run(local=True)</code> serves the same control panel from the robot itself at <code>http://localhost:8700</code>, with no sign-in. It only listens on your own machine, so other people on your network can't drive it. It's the quickest way to get the wiring right before linking anything.</p>
<p>Robotics is free on every cognira plan, including Free, with up to 25 robots per account.</p>
<h2>A question for you</h2>
<p>The 1.5-second release and the 10-second drop window are defaults I picked from testing on one servo, not from any standard. If you've built remote-control or teleoperation tools, <strong>what timeouts did you settle on, and did you make them per-control or global?</strong> I'd genuinely like to hear what's bitten other people.</p>
<p>Links: <a href="https://pypi.org/project/cognira-robotics/">cognira-robotics on PyPI</a> · <a href="https://cognira.dev/robotics">Robotics quickstart</a> · <a href="https://cognira.dev">cognira.dev</a> · X: <a href="https://x.com/cogniraAI">@cogniraAI</a></p>
]]></content:encoded></item><item><title><![CDATA[I built a self-learning assistant that runs on one 16GB iMac. Here's how it decides what to learn]]></title><description><![CDATA[Disclosure: I'm Thomas Conway and I built cognira. This is a build log about my own project, not a neutral review. For the past while I've been building…]]></description><link>https://thomasconway.hashnode.dev/i-built-a-self-learning-assistant-that-runs-on-one-16gb-imac-here-s-how-it-decides-what-to-learn</link><guid isPermaLink="true">https://thomasconway.hashnode.dev/i-built-a-self-learning-assistant-that-runs-on-one-16gb-imac-here-s-how-it-decides-what-to-learn</guid><category><![CDATA[AI]]></category><category><![CDATA[Machine Learning]]></category><category><![CDATA[llm]]></category><category><![CDATA[Apple]]></category><dc:creator><![CDATA[Thomas Conway]]></dc:creator><pubDate>Sat, 17 Oct 2026 10:09:50 GMT</pubDate><content:encoded><![CDATA[<p><em>Disclosure: I'm Thomas Conway and I built cognira. This is a build log about my own project, not a neutral review.</em></p>
<p>For the past while I've been building <a href="https://cognira.dev">cognira</a> on my own. It's an AI assistant that learns from the people who use it. Its model, cognira Retrain 12B, is retrained on top of a Gemma 4 base and served through MLX on a single 16GB iMac in my living room. Questions aren't forwarded to anyone else's API.</p>
<p>The part I want to write about is the hardest one: <strong>how does a self-learning assistant decide what it's allowed to learn?</strong> Letting users teach a model is easy. Stopping one bad chat from rewriting the system is the real work.</p>
<h2>Two kinds of learning, kept apart on purpose</h2>
<p>I split "learning" into two paths with very different rules.</p>
<p><strong>1. Private memory: immediate.</strong> If you tell cognira "call me Tom" or "keep answers short", that goes into <em>your</em> private memory and is used from the next reply. The reply then shows which memory it drew on, with a one-tap <strong>Forget</strong> button next to each one. On the site's example, a reply reads "Used 2 memories from what you've taught it", with "Preference: Keep answers short" and "Name: Call me Tom" listed underneath, each with Forget.</p>
<p>That was deliberate: if an assistant personalises itself quietly, you can't tell what it thinks it knows about you.</p>
<p><strong>2. Shared knowledge: slow and gated.</strong> Something one person teaches stays private. It can only become knowledge every conversation draws on after it passes a pipeline that's logged publicly at <a href="https://cognira.dev/learning">cognira.dev/learning</a>.</p>
<h2>The shared-knowledge pipeline</h2>
<p>Each stage is shown on the learning page with its counts:</p>
<ol>
<li><strong>Clustered.</strong> A claim only becomes a candidate when separate accounts arrive at it independently. A taught word has to come from several people, days apart.</li>
<li><strong>Screened.</strong> Unsafe, personal or contradicted material is thrown out before anything is written down. The claim is also reworded, so it's never anyone's literal sentence.</li>
<li><strong>Quizzed.</strong> The abstracted claim is questioned from angles nobody taught it. Most of the losses happen here.</li>
<li><strong>Canaried.</strong> Anything that survives goes live for a small slice of conversations first, and stays under watch.</li>
<li><strong>Shared.</strong> Only then does it become shared knowledge, and it can still be pulled.</li>
</ol>
<p>Personal detail never enters the public log. A claim has to be abstracted away from whoever taught it before it's recorded at all.</p>
<h2>Weight updates: never in the hot path</h2>
<p>Separately from the knowledge cards, the model itself can be retrained. A few rules govern that:</p>
<ul>
<li><strong>Filtering first.</strong> Sensitive, unsafe and badly formatted replies stay out. Only short, useful examples are ever copied. A reply you down-rate can only teach the model what <em>not</em> to say.</li>
<li><strong>Wait for enough clean data and an idle machine.</strong> Background updates only run once there are enough clean examples and the server isn't busy. On one iMac, inference wins every time, so learning pauses whenever people need the machine.</li>
<li><strong>A candidate must pass checks before it replaces anything live.</strong> If it doesn't, it gets rolled back.</li>
<li><strong>Your reply never waits on training.</strong> Confirmed teaching works straight away from private memory. Weight updates happen in the background.</li>
</ul>
<h2>What the public log actually shows</h2>
<p>I publish the whole thing, failures included. A few recent entries from cognira.dev/learning:</p>
<blockquote>
<p><strong>Rolled back weights</strong> (10d ago): "Recorded by the learning pipeline." Candidate adapter, Notes 5</p>
</blockquote>
<p>That entry appears paired with <strong>Eval started</strong> over and over in the recent log. Each pair is a candidate adapter that was evaluated and then rolled back instead of going live. That's the system working as intended: a candidate that doesn't earn its place doesn't replace the live model.</p>
<blockquote>
<p><strong>Quiz passed: proto</strong> (13d ago): "Survived being questioned from angles it was never explicitly taught." Quiz score 100%, method: source</p>
</blockquote>
<blockquote>
<p><strong>glimble:</strong> "A feeling of satisfaction and pride derived from successfully completing a task." 3 independent accounts, 100% on the quiz</p>
</blockquote>
<p>"glimble" is a made-up word, and it's my favourite test case. The model can't have known it from pretraining. It became shared knowledge only after three separate accounts taught it and it passed the quiz.</p>
<p>To be straight about scale: this is a small project. The contributor table currently lists 15 people, and there are 5 shared knowledge cards. Several of the newer cards came through a "trusted teacher" path with one account (mine), and the log says so. I'd rather show that than hide it.</p>
<h2>Honest limits</h2>
<p>cognira Retrain 12B is a compact model, served locally. Frontier systems will still beat it on breadth and trivia. Its edge is the loop: teaching works immediately from private memory, and verified updates can improve the model where people actually use it.</p>
<p>I also don't publish a carbon number. The service runs on one 16GB iMac, caps needless output, and defers background learning when inference is busy. But I can't meter energy properly yet, so I don't claim a figure.</p>
<h2>Running it yourself</h2>
<p>There's also <strong>cognira Entity</strong>, a local desktop app that runs the same models (cognira Retrain 12B and the older Omega 188M checkpoint) on your own machine. Memory and optional weight retraining stay on your disk. If you'd rather build on the hosted model, there's an OpenAI-compatible endpoint at api.cognira.dev.</p>
<h2>A question for you</h2>
<p>The part I keep rethinking is the clustering threshold. Requiring several independent people, days apart, keeps one bad actor out. But on a small user base it also means good, niche knowledge can sit in the queue for a long time. If you've built systems that learn from user feedback, <strong>how did you balance "hard to poison" against "actually learns something"?</strong> Did you lean on trusted contributors, longer canaries, or something else entirely?</p>
<p>Everything above, including the rejections, is at <a href="https://cognira.dev/learning">cognira.dev/learning</a>. I'm also on X as <a href="https://x.com/cogniraAI">@cogniraAI</a>.</p>
]]></content:encoded></item></channel></rss>