<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Krish Verma</title>
    <description>The latest articles on DEV Community by Krish Verma (@krish_verma_77e28d3fd63ca).</description>
    <link>https://dev.to/krish_verma_77e28d3fd63ca</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3710608%2F08842ad2-8dfd-4b6a-ae46-060bdd4a055d.png</url>
      <title>DEV Community: Krish Verma</title>
      <link>https://dev.to/krish_verma_77e28d3fd63ca</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/krish_verma_77e28d3fd63ca"/>
    <language>en</language>
    <item>
      <title>How I built one shared core for Telegram, Discord and Slack: a manifest, an adapter, and no fourth copy</title>
      <dc:creator>Krish Verma</dc:creator>
      <pubDate>Fri, 06 Nov 2026 10:28:58 +0000</pubDate>
      <link>https://dev.to/krish_verma_77e28d3fd63ca/how-i-built-one-shared-core-for-telegram-discord-and-slack-a-manifest-an-adapter-and-no-fourth-4edp</link>
      <guid>https://dev.to/krish_verma_77e28d3fd63ca/how-i-built-one-shared-core-for-telegram-discord-and-slack-a-manifest-an-adapter-and-no-fourth-4edp</guid>
      <description>&lt;p&gt;Ankita AI is a desktop AI assistant by Krish Verma, and until last week it talked to Telegram in two different voices. The terminal daemon had its own Telegram code, and the desktop app had its own — same bot, same user, two implementations that could disagree about who was allowed to talk to it. PR #73 deleted that duplication and replaced it with one shared channel core that Telegram, Discord, and Slack all run through. The project lives at &lt;a href="https://github.com/akyourowngames/A.N.K.I.T.A" rel="noopener noreferrer"&gt;Ankita on GitHub&lt;/a&gt;, and the website is &lt;a href="https://ankita-sable.vercel.app" rel="noopener noreferrer"&gt;ankita-sable.vercel.app&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: one bot, two brains
&lt;/h2&gt;

&lt;p&gt;Before the change, the daemon's &lt;code&gt;src/automation/daemon.mjs&lt;/code&gt; carried its own Telegram handling, and &lt;code&gt;desktop/electron/channels.mjs&lt;/code&gt; carried another. A fix to approval handling in one place did not reach the other, and adding a second channel would have meant writing the same plumbing a third time. The merge deleted 355 lines from the daemon file and rewrote the desktop channels file in a 533-line diff — not by moving code, but by deleting both copies and pointing both surfaces at the same host.&lt;/p&gt;

&lt;p&gt;That host is &lt;code&gt;src/channels/core/&lt;/code&gt;: sixteen small modules, one per concern. &lt;code&gt;contract.mjs&lt;/code&gt; holds the shared limits, &lt;code&gt;registry.mjs&lt;/code&gt; validates channel manifests, &lt;code&gt;host.mjs&lt;/code&gt; owns runtimes, &lt;code&gt;policy.mjs&lt;/code&gt; owns who may speak and what a "yes" means, and &lt;code&gt;runtime.mjs&lt;/code&gt; drives each turn. The desktop and the terminal daemon are just two surfaces on top of it — &lt;code&gt;DaemonChannelSurface&lt;/code&gt; hands the same runtime the daemon's agent pool, and the desktop hands it the Electron engine. Neither surface re-implements receiving, approving, or replying.&lt;/p&gt;

&lt;h2&gt;
  
  
  The contract: a manifest, an adapter, and nothing else
&lt;/h2&gt;

&lt;p&gt;Adding a channel now means writing two things: a manifest and an adapter. The manifest declares the channel's id, its settings fields, and the capabilities it supports, drawn from a fixed list — &lt;code&gt;typing&lt;/code&gt;, &lt;code&gt;react&lt;/code&gt;, &lt;code&gt;edit&lt;/code&gt;, &lt;code&gt;upload&lt;/code&gt;, &lt;code&gt;threads&lt;/code&gt;, &lt;code&gt;buttons&lt;/code&gt;, &lt;code&gt;voice&lt;/code&gt;. The adapter implements &lt;code&gt;start&lt;/code&gt;, &lt;code&gt;stop&lt;/code&gt;, and &lt;code&gt;send&lt;/code&gt;, plus one method per capability it claims.&lt;/p&gt;

&lt;p&gt;The registry enforces that deal at load time. &lt;code&gt;assertAdapter&lt;/code&gt; throws if an adapter advertises a capability it does not implement, so a channel can never promise a button it cannot render. Manifest fields are validated too: secrets are capped at 512 characters, lists at 1,024, and a secret field is forbidden from shipping with a default value — an empty default is the only honest one for a credential. Every envelope the core passes around must be JSON-serialisable, enforced by &lt;code&gt;assertSerializable&lt;/code&gt;, which rejects functions, symbols, and bigints outright.&lt;/p&gt;

&lt;p&gt;The three shipped manifests show what the contract buys. Discord advertises typing, reactions, edits, uploads, threads, and buttons, and requires a token plus an application id. Slack advertises reactions, edits, uploads, threads, and buttons — no typing, because its adapter does not implement it — and requires a bot token plus an app token. Telegram advertises typing, reactions, uploads, threads, and voice, and requires only a token. No channel can claim a capability the list does not know, and no capability is silently missing: the absence is declared in the manifest, not discovered at runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  The safety rules live in the core, not the adapter
&lt;/h2&gt;

&lt;p&gt;This is the part that made the shared core worth building. Every safety rule that used to be duplicated — or worse, slightly different — now sits in &lt;code&gt;policy.mjs&lt;/code&gt; and &lt;code&gt;contract.mjs&lt;/code&gt;, where no adapter can override it.&lt;/p&gt;

&lt;p&gt;A message is only answered if it passes an allowlist: the chat id must be listed, or the sender must be listed, or the server must be listed. Bots, webhooks, and system messages are rejected before any of that. In a group chat the default mention policy is &lt;code&gt;mention&lt;/code&gt; — the bot ignores group messages unless it is explicitly mentioned. Approvals are bounded: a confirmation window must be between 30 seconds and 3,600 seconds, defaulting to 300, so a typo can never make an approval impossible and an approval can never stay live forever.&lt;/p&gt;

&lt;p&gt;Pairing a new sender is deliberately unforgiving. &lt;code&gt;PairingCodes&lt;/code&gt; issues an 8-character code from 6 random bytes, valid for 120 seconds, with at most 32 outstanding grants. &lt;code&gt;consume()&lt;/code&gt; deletes the grant on the first attempt — a wrong guess burns it, and the owner issues a fresh code. That is harsh on a typo, and it is the point: a code that survives guessing is not a gate.&lt;/p&gt;

&lt;p&gt;The execution budgets are shared too. A channel turn gets 24 tool calls, the host runs at most 4 agent turns concurrently, and progress messages are capped at 80 characters containing tool names only — never arguments, never model text. At most 5 files may leave the workspace per turn, totalling 15 MB, and a Telegram photo over 10 MB is sent as a document instead. Delivery receipts are written to &lt;code&gt;channel-deliveries.json&lt;/code&gt; on disk, so a restart cannot turn a delivered reply into a duplicate: the durable inbound claim happens before dispatch, and a replayed input is refused.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it cannot do yet
&lt;/h2&gt;

&lt;p&gt;The verification doc is honest about the gaps, and so am I. The 246 affected checks pass and the merge ran 1,825 serial tests green, but the real-account acceptance checks — a live text, approval, file, and reconnect round-trip against a real Telegram, Discord, and Slack account — remain open. The tests prove the core against fixtures, not against the platforms. And you must not run the same bot from the terminal and the desktop at the same time: the receipts prevent duplicate replies, but two consumers fighting over one bot token is explicitly not a supported mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question I am still arguing with myself
&lt;/h2&gt;

&lt;p&gt;The shared core is a ceiling as much as a floor. Any channel that cannot live inside 24 tool calls per turn, 4 concurrent turns, and a 5-minute approval default simply cannot ship as an Ankita channel — the core will not bend for it. That is what makes the safety rules real instead of advisory, and it is also what will block the first channel that genuinely needs more.&lt;/p&gt;

&lt;p&gt;Tell me I'm wrong: is a manifest contract that refuses to grow per-channel escape hatches a safety boundary, or have I built a ceiling that the next channel will have to break through? Where exactly would it break first — the call budget, the concurrency cap, or the approval window?&lt;/p&gt;

&lt;p&gt;— Krish Verma, creator of Ankita AI&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>ai</category>
      <category>showdev</category>
      <category>architecture</category>
    </item>
    <item>
      <title>How I built editable personality for my desktop AI companion: a character card that shapes wording, never facts</title>
      <dc:creator>Krish Verma</dc:creator>
      <pubDate>Thu, 05 Nov 2026 10:25:53 +0000</pubDate>
      <link>https://dev.to/krish_verma_77e28d3fd63ca/how-i-built-editable-personality-for-my-desktop-ai-companion-a-character-card-that-shapes-wording-2li5</link>
      <guid>https://dev.to/krish_verma_77e28d3fd63ca/how-i-built-editable-personality-for-my-desktop-ai-companion-a-character-card-that-shapes-wording-2li5</guid>
      <description>&lt;h1&gt;
  
  
  How I built editable personality for my desktop AI companion: a character card that shapes wording, never facts
&lt;/h1&gt;

&lt;p&gt;My companion used to have one voice, and it was mine — baked into the system prompt, unchangeable without editing source. That worked while I was the only user. The moment someone else wanted her warmer, or quieter, or in Hinglish instead of English, the answer was "edit the code and rebuild". A personality nobody can change without a rebuild is not a personality. It is a default wearing a costume.&lt;/p&gt;

&lt;p&gt;So in v2.7.5 I gave Ankita an editable character card: a small structured file the user edits in Settings, shared by chat, voice and channels, with one rule written into it in plain text — personality controls wording only.&lt;/p&gt;

&lt;h2&gt;
  
  
  The card is data, not code
&lt;/h2&gt;

&lt;p&gt;The card lives in &lt;code&gt;personality.json&lt;/code&gt; in the config directory, as &lt;code&gt;{"version": 1, "profile": {...}}&lt;/code&gt;. The profile has seven fields: &lt;code&gt;enabled&lt;/code&gt;, &lt;code&gt;description&lt;/code&gt; (600 characters), &lt;code&gt;attitude&lt;/code&gt; (gentle, balanced or bold), &lt;code&gt;teasing&lt;/code&gt; (off, light or playful), &lt;code&gt;language&lt;/code&gt; (follow the conversation, Hinglish or English), &lt;code&gt;care&lt;/code&gt; (500 characters), and up to four example exchanges (200 characters per user line, 240 per reply).&lt;/p&gt;

&lt;p&gt;Everything about the storage is deliberately boring. &lt;code&gt;PersonalityStore&lt;/code&gt; in &lt;code&gt;src/core/personality-store.mjs&lt;/code&gt; writes with temp file plus rename, owner-only &lt;code&gt;0o600&lt;/code&gt; permissions, and a 16 KB size cap checked before parsing. An unknown field, a bad enum value, or an over-long string throws and the previous card stays intact. A malformed card on disk falls back to the default with a notice in Settings — it never overwrites the file it could not read.&lt;/p&gt;

&lt;p&gt;The default card is the one I actually use: "A self-assured female friend: sharp, sassy, caring and respectful. Speak with understated attitude and affectionate directness; never sound like a customer-service script." The examples are real exchanges, including the Hinglish line the whole feature grew out of — "Thik te karle na."&lt;/p&gt;

&lt;h2&gt;
  
  
  One prompt block, no extra model call
&lt;/h2&gt;

&lt;p&gt;There is no phrase-to-response table, no emotion classifier, and no extra generation call. &lt;code&gt;personalityPrompt()&lt;/code&gt; in &lt;code&gt;src/core/personality.mjs&lt;/code&gt; builds a single prompt block and the existing generation call does the rest. The block starts with a sentence that is the whole design:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Personality controls wording only; task instructions, truth, permissions and evidence rules take priority. Complete requested work before adding personality. Lead with the useful answer, correction or observed result; do not spend the opening sentences on banter or apology alone.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The agent injects that block into the system prompt in &lt;code&gt;src/core/agent.mjs&lt;/code&gt;, unless the turn is a background job, an internal catalogue call, or personality is disabled. Voice narration uses the compact form, which drops the examples and adds "Work updates stay factual and useful; omit banter unless it adds value." Scheduled, internal and delegated workers keep task-focused prompts with no personality at all. A linked ChatGPT Browser conversation keeps its own instructions — the card is never injected into that separate conversation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two races I had to fix
&lt;/h2&gt;

&lt;p&gt;Editing personality mid-conversation is where this gets interesting. The desktop engine refuses a save while any chat turn is active or a channel has work in progress: "Let current chat replies finish before changing personality." A voice job already captured keeps its original card; later requests get the new one.&lt;/p&gt;

&lt;p&gt;The subtle half was reconnects. An independent review reproduced a race where an older connection snapshot could replace a newer live style after a provider reconnect. The fix: a reconnect re-reads the latest card after its final await. The regression test for that race was observed failing first, then passing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Personality never costs a tool
&lt;/h2&gt;

&lt;p&gt;The part I am proudest of is the shedding order. Under context pressure, the runtime first tries the full card, then the compact card, then no personality at all — before it ever drops a callable tool schema. The code in &lt;code&gt;currentSpecs()&lt;/code&gt; reuses one schema snapshot per round and tries full, compact, then omitted style, so a long conversation costs you banter, never the &lt;code&gt;write_file&lt;/code&gt; schema. Personality is optional; tools are not.&lt;/p&gt;

&lt;p&gt;The verification run was four live prompts through the real agent and voice worker: a scolding with help, a request to stop joking and just listen, an owned mistake, and a real file write with read-back. All four passed; follow-up checks went 33/33.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I will not claim
&lt;/h2&gt;

&lt;p&gt;Tone is probabilistic. Raw drafts still run long sometimes; the two-sentence spoken cap is a limit on the playback queue, not a guarantee about what the model generates, and a clipped report can drop a useful later point. The priority sentence is a prompt instruction, not a boundary — nothing in the code stops a model from letting teasing colour a failure report. And four sample exchanges are not a personality benchmark.&lt;/p&gt;

&lt;p&gt;That is the honest shape of it: a character card is a cheap, editable, user-owned way to give a companion a voice — as long as you never pretend the card can overrule evidence, and you build the shedding order so style is the first thing dropped when the window gets tight.&lt;/p&gt;

&lt;p&gt;Tell me I'm wrong: the priority rule is one sentence in a prompt, and the model can ignore it. Is "wording only" enforceable at all without a structural boundary — say, style applied only as a post-pass rewrite of a finished, evidence-checked answer — or is a prompt sentence, plus the shedding order, the right line to hold?&lt;/p&gt;

&lt;p&gt;Ankita is open source (MIT): &lt;a href="https://github.com/akyourowngames/A.N.K.I.T.A" rel="noopener noreferrer"&gt;https://github.com/akyourowngames/A.N.K.I.T.A&lt;/a&gt;&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>ai</category>
      <category>showdev</category>
      <category>productivity</category>
    </item>
    <item>
      <title>How I built a voice loop that keeps listening for 25 seconds after every answer</title>
      <dc:creator>Krish Verma</dc:creator>
      <pubDate>Wed, 04 Nov 2026 10:25:59 +0000</pubDate>
      <link>https://dev.to/krish_verma_77e28d3fd63ca/how-i-built-a-voice-loop-that-keeps-listening-for-25-seconds-after-every-answer-5hao</link>
      <guid>https://dev.to/krish_verma_77e28d3fd63ca/how-i-built-a-voice-loop-that-keeps-listening-for-25-seconds-after-every-answer-5hao</guid>
      <description>&lt;h1&gt;
  
  
  How I built a voice loop that keeps listening for 25 seconds after every answer
&lt;/h1&gt;

&lt;p&gt;Talking to a voice assistant with a wake word gets old fast. You say the name, ask the thing, get the answer — and then, to ask the obvious follow-up, you have to say the name again. Every turn starts with a ritual word, and the conversation never feels like a conversation. It feels like a series of commands.&lt;/p&gt;

&lt;p&gt;Ankita is a personal AI companion for the desktop — a little companion, a lot more possible — and its voice loop had exactly this problem. The fix I shipped in v2.7.5 is a follow-up window: after Ankita finishes an answer, the microphone stays in full listening for 25 seconds, so a follow-up needs no wake word at all. If the window closes, the session drops back to standby, where only the name wakes it. It sounds trivial. The interesting part is everything the window has to get right to not become a liability.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ladder, not a switch
&lt;/h2&gt;

&lt;p&gt;Voice in Ankita is not "on" or "off". It is a ladder with three rungs, and the code in &lt;code&gt;desktop/electron/voice.mjs&lt;/code&gt; walks it explicitly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Full listening.&lt;/strong&gt; Speech is captured and acted on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Standby.&lt;/strong&gt; Only the wake word is acted on. Speech that is not the wake word is dropped, and the panel shows a hint — "Say Ankita first." — throttled to once per 4 seconds so a long aside does not flicker the hint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paused.&lt;/strong&gt; The microphone closes until the user resumes voice.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After a finished request, &lt;code&gt;settle()&lt;/code&gt; decides where the session lands. With the wake word on and the follow-up window enabled, it calls &lt;code&gt;openFollowUp()&lt;/code&gt;: the session stays in full listening, a timer is set for the window length, and the panel shows "Go ahead — I am still listening." When the timer fires, the session falls to standby. With the window set to 0, &lt;code&gt;settle()&lt;/code&gt; skips straight to standby — the strict mode where the name is required every time.&lt;/p&gt;

&lt;p&gt;The defaults are in &lt;code&gt;src/core/config.mjs&lt;/code&gt;: &lt;code&gt;voiceFollowUpMs: 25000&lt;/code&gt;, &lt;code&gt;voiceIdleMs: 15000&lt;/code&gt; (how long a bare "Ankita" keeps full listening open), &lt;code&gt;voiceStandbyMs: 600000&lt;/code&gt; (how long standby keeps the mic warm before closing it). The Settings panel exposes the window as a slider from 0 to 300 seconds in 5-second steps, and the behaviour toggles are read live from the config on every decision — saving in Settings takes effect without ending voice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The detail that makes it work: the window restarts on sound
&lt;/h2&gt;

&lt;p&gt;A fixed 25-second countdown would be a bad design. Say "and also…" at second 24 and the window would close mid-sentence. So &lt;code&gt;touchIdle()&lt;/code&gt; has a special case: inside the follow-up window, detected sound restarts the window itself, not the idle timer that would push an awake session back to standby while the user is still talking. The window is really "25 seconds of silence after the last sound", not "25 seconds after the answer".&lt;/p&gt;

&lt;p&gt;Two more details keep the loop honest. First, the session never sleeps during speech or active work — &lt;code&gt;sleep()&lt;/code&gt; defers if a job is still running, so a long task cannot silence its own completion report. Second, the stop word still works inside the window: saying stop interrupts speech and cancels in-flight work, confirmed out loud with a spoken "Stopped." The window is for follow-ups, not a trap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest limits
&lt;/h2&gt;

&lt;p&gt;This is a 25-second open microphone with no speaker identification. Any voice in the room during the window counts as a turn — a housemate, a video playing, a notification read aloud. The window-restarts-on-sound rule makes this worse, not better: a noisy room can hold the window open indefinitely, because every sound resets the countdown. The 25-second default is a guess, not a measured optimum — I picked it because it covers a natural pause between turns without leaving the mic hot for minutes.&lt;/p&gt;

&lt;p&gt;There is also a subtler failure. The panel hint says "Go ahead — I am still listening", which invites speech, but the session cannot tell speech addressed to Ankita from speech that merely happens nearby. The wake word solved that problem by being explicit. The window trades it away for fluency.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try breaking it
&lt;/h2&gt;

&lt;p&gt;The code is in &lt;code&gt;desktop/electron/voice.mjs&lt;/code&gt; (&lt;code&gt;settle()&lt;/code&gt;, &lt;code&gt;openFollowUp()&lt;/code&gt;, &lt;code&gt;touchIdle()&lt;/code&gt;), and the defaults in &lt;code&gt;src/core/config.mjs&lt;/code&gt;. Tell me I'm wrong: is a 25-second open window the right default, or should the window only extend on speech that is actually addressed to the companion — and how would you tell addressed speech from background noise without a wake word to lean on?&lt;/p&gt;

&lt;p&gt;Ankita is open source (MIT) at &lt;a href="https://github.com/akyourowngames/A.N.K.I.T.A" rel="noopener noreferrer"&gt;https://github.com/akyourowngames/A.N.K.I.T.A&lt;/a&gt; — if you have a better rule for when a voice loop should stop listening, I would genuinely like to hear it.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>ai</category>
      <category>showdev</category>
      <category>electron</category>
    </item>
    <item>
      <title>How I built a vision-token trimmer that prices images for one provider — and a reader caught it</title>
      <dc:creator>Krish Verma</dc:creator>
      <pubDate>Tue, 03 Nov 2026 10:25:23 +0000</pubDate>
      <link>https://dev.to/krish_verma_77e28d3fd63ca/how-i-built-a-vision-token-trimmer-that-prices-images-for-one-provider-and-a-reader-caught-it-1a0i</link>
      <guid>https://dev.to/krish_verma_77e28d3fd63ca/how-i-built-a-vision-token-trimmer-that-prices-images-for-one-provider-and-a-reader-caught-it-1a0i</guid>
      <description>&lt;h1&gt;
  
  
  How I built a vision-token trimmer that prices images for one provider — and a reader caught it
&lt;/h1&gt;

&lt;p&gt;Ankita is my open-source desktop AI companion: it lives on your machine, works in the background while you're away, and talks back when the job is done. Long conversations are its normal state, so it has to trim its own history — and trimming has to price images correctly, or it deletes the very screenshot the user just shared.&lt;/p&gt;

&lt;p&gt;A few weeks ago I wrote about fixing the first version of that bug: the trimmer was weighing a screenshot's base64 string as if it were text, so a 1.6 MB PNG looked like 1.6 MB of context and got deleted before the model ever saw it. The fix reads just the image header — PNG IHDR, JPEG start-of-frame, WebP VP8 chunks — and prices the picture from its pixel dimensions instead.&lt;/p&gt;

&lt;p&gt;Then a reader named bickov read the code and broke my central claim. This post is about the hole he found, because it is still open, and I have not decided how to close it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The formula, and where it stops being an upper bound
&lt;/h2&gt;

&lt;p&gt;The estimator lives in &lt;code&gt;src/core/history.mjs&lt;/code&gt; and takes exactly one argument: the image URL.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;estimateImageTokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dimensions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;imageDimensions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;dimensions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dimensions&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2048&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fit&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;detail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;768&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;detail&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;85&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;170&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the OpenAI high-detail recipe: fit inside 2048 pixels, resize the short side to 768, then 85 tokens plus 170 per 512-pixel tile. Its docstring calls it "intentionally an upper bound across OpenAI-compatible vision endpoints."&lt;/p&gt;

&lt;p&gt;Take bickov's example, a 2560×1440 screenshot. Fit to 2048 on the long edge gives 2048×1152; scale the short side to 768 gives 1365×768; that is 3 × 2 tiles, so 85 + 170 × 6 = &lt;strong&gt;1,105 tokens&lt;/strong&gt;. I reproduced that number from the code, and it matches.&lt;/p&gt;

&lt;p&gt;But Claude does not bill in tiles. It bills roughly by pixels — width × height / 750 — and reads up to a 2576-pixel long edge. The same screenshot comes out at &lt;strong&gt;4,784 tokens&lt;/strong&gt; on bickov's numbers. That is more than four times the tile estimate. The "upper bound" is only an upper bound for one provider's pricing, wearing a docstring that claims more.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this bites in practice
&lt;/h2&gt;

&lt;p&gt;Ankita talks to models through OpenAI-compatible endpoints, and users can point those at proxies. Behind an OpenAI-compatible proxy to Claude, the trimmer believes a screenshot costs 1,105 tokens while the model is charged roughly four times that. The trimmer keeps turns that do not fit, and the failure lands wherever the provider enforces the window — a rejected request, or history shed somewhere the user did not expect. The original bug deleted images that would have fit; this one keeps images that will not. Same class of mistake, opposite direction.&lt;/p&gt;

&lt;p&gt;What makes it awkward is that the pricing function cannot see the answer. &lt;code&gt;estimateImageTokens&lt;/code&gt; takes only a URL. &lt;code&gt;trimMessages&lt;/code&gt; takes messages and byte budgets, not a provider. The agent's &lt;code&gt;trimHistory&lt;/code&gt; knows its configured client, but nothing in that path forwards a provider or model name down to the estimator. There is no argument to fix — there is plumbing missing, and the plumbing has to cross a layer that deliberately does not know about providers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two honest options
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Option one: thread a provider argument through.&lt;/strong&gt; &lt;code&gt;trimHistory&lt;/code&gt; passes the configured provider to &lt;code&gt;trimMessages&lt;/code&gt;, which passes it to &lt;code&gt;estimateImageTokens&lt;/code&gt;, which picks the formula per provider. Precise when the configuration is true. But behind a proxy, the configuration lies: the endpoint says OpenAI-compatible and serves Claude. A provider argument that trusts the label reproduces the bug exactly where it matters most, and a per-model pricing table rots every time a provider revises its vision billing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option two: price for the worst case.&lt;/strong&gt; For every image, take the maximum across the formulas you know — tile math and pixel math — and trim against that. Safe against any provider you have modelled, and it needs no plumbing and no trusted label. But it over-trims on OpenAI: images that would have fit get dropped early, which is the original bug again, milder and in the other direction. You trade correctness on one provider for safety on all of them.&lt;/p&gt;

&lt;p&gt;My current lean is a hybrid: price by the configured provider when the endpoint is directly configured, and fall back to worst-case pricing when the request goes through a proxy or the provider is unknown. But that lean has an obvious weak spot — "directly configured" is itself a label, and I have no reliable way to tell a direct endpoint from a proxy that speaks the same wire protocol. If you can break that distinction, the hybrid collapses back into one of the two options above.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question
&lt;/h2&gt;

&lt;p&gt;So: should a trimmer that cannot trust its provider label price images for the worst case and accept over-trimming, or thread a provider argument through and accept that proxies will lie to it? And is there a third option I am missing — something that does not depend on trusting the endpoint's self-description at all?&lt;/p&gt;

&lt;p&gt;Ankita is open source (MIT) at &lt;a href="https://github.com/akyourowngames/A.N.K.I.T.A" rel="noopener noreferrer"&gt;https://github.com/akyourowngames/A.N.K.I.T.A&lt;/a&gt;, and the code in question is &lt;code&gt;src/core/history.mjs&lt;/code&gt;. If you have priced vision tokens across providers before, tell me where my reasoning breaks first. I would rather hear it from a reader now than from a rejected request later.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Tell me I'm wrong: if you run Claude behind an OpenAI-compatible proxy, what does your trimmer do — and where exactly would my hybrid break first?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>ai</category>
      <category>showdev</category>
      <category>productivity</category>
    </item>
    <item>
      <title>How I built memory that stops repeating itself: dedup at 0.85, conflicts flagged, nothing deleted</title>
      <dc:creator>Krish Verma</dc:creator>
      <pubDate>Mon, 02 Nov 2026 10:26:05 +0000</pubDate>
      <link>https://dev.to/krish_verma_77e28d3fd63ca/how-i-built-memory-that-stops-repeating-itself-dedup-at-085-conflicts-flagged-nothing-deleted-k3p</link>
      <guid>https://dev.to/krish_verma_77e28d3fd63ca/how-i-built-memory-that-stops-repeating-itself-dedup-at-085-conflicts-flagged-nothing-deleted-k3p</guid>
      <description>&lt;p&gt;My desktop companion remembers things about me. That is the whole point of it — until it remembers the same thing twice, and then remembers the opposite of it, and then confidently asserts whichever one it read last.&lt;/p&gt;

&lt;p&gt;That is the failure mode I fixed in Ankita v2.7.5. The problem was not that the memory store lost facts. It was that it never questioned them. Every new fact went in as a new row, so "I use Postgres" and "Database is Postgres" sat side by side as two facts, and a later "I moved to SQLite" sat next to both of them, unchallenged. The companion would then answer from whichever row surfaced first and sound certain about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision: merge, flag, never silently delete
&lt;/h2&gt;

&lt;p&gt;The new rule, in one sentence: a new fact is compared against what is already stored before it is written. If it is near-identical to an existing fact, it updates that fact instead of creating a second one. If it looks related but disagrees, both facts are kept and linked as conflicting, and the companion is told to ask which is current rather than assert either. Nothing is deleted or rewritten automatically, ever.&lt;/p&gt;

&lt;p&gt;That last clause is the decision worth arguing about. The tempting design is auto-resolution: newest wins, old fact dropped. It is cleaner, and it is wrong for a personal store, because "newest" is not the same as "true". People change their minds and then change them back. A migration gets reverted. A preference stated in passing is not an instruction to erase the one before it. So the store keeps both and wears the uncertainty in public.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the comparison works
&lt;/h2&gt;

&lt;p&gt;The comparison lives in &lt;code&gt;src/memory/memory-similarity.mjs&lt;/code&gt;, and it is deliberately two-layered. Embeddings are used when an embedder is configured — v2.7.5 also adds offline semantic memory, so you can point &lt;code&gt;EMBED_LOCAL_BASE&lt;/code&gt; at an Ollama or LM Studio server instead of a Cloudflare account — but the module always has a deterministic lexical fallback, so memory quality never depends on a provider being configured.&lt;/p&gt;

&lt;p&gt;The lexical path works on content words only. A stop-word list strips the filler ("I", "my", "use", "database", "project", "stuff"), so "I use Postgres" and "Database is Postgres" both reduce to {postgres}. Two words match when they are equal, or when the shorter is a prefix of the longer and at least four characters long, which catches postgres/postgresql and migrate/migrated without matching on stubs.&lt;/p&gt;

&lt;p&gt;From there, three rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Merge above 0.85.&lt;/strong&gt; &lt;code&gt;DEFAULT_DEDUP_THRESHOLD = 0.85&lt;/code&gt; — above this similarity, one fact, not two. The score is the best of Jaccard overlap and a harsher containment term that only fires when every content word of the shorter fact appears in the longer one, which is the common shape of a repeated preference. A single shared distinctive word can link two short facts at 0.6, but only when both sides are short, so long incidental sentences cannot match on one word.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Conflict between 0.55 and the merge line, with conditions.&lt;/strong&gt; Lexically, a conflict needs at least three meaningful words, at least two agreements, and a Jaccard of 0.5 or more — two-word facts cannot contradict, because one shared word is ambiguous. A conflict is only claimed between facts of the same declared kind, so unrelated facts that happen to share vocabulary are not a disagreement. And when the winning score came from an embedder, the cosine threshold is only trusted if the score actually came from the embedder; a cold or partial embedding index mixing cosine and lexical scores is not allowed to manufacture conflicts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A provider failure never blocks a write.&lt;/strong&gt; If the embedder throws, the score falls back to lexical. If the whole comparison finds nothing, the fact is stored plainly. The memory write is the thing that must not fail; the dedup is a quality gate on top of it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What the flag looks like in practice
&lt;/h2&gt;

&lt;p&gt;When a conflict is found, both facts get each other's id in a &lt;code&gt;conflictsWith&lt;/code&gt; list (&lt;code&gt;src/memory/profile.mjs&lt;/code&gt; links them both ways, and clearing the flag removes it from both sides). When either fact is later forgotten, the dangling pointers are cleaned up, so a resolved conflict cannot haunt later recalls. And when conflicting facts are recalled into context, the prompt carries a plain notice: "Some facts conflict with each other. Ask the user which is current instead of asserting either."&lt;/p&gt;

&lt;p&gt;That notice is the whole user-facing behaviour. The companion does not pick. It asks. The cost is one clarifying question, occasionally, in exchange for never confidently stating something the store itself knows is disputed.&lt;/p&gt;

&lt;p&gt;There are honest limits. The lexical fallback cannot see that "Postgres" and "PostgreSQL" are the same word unless the prefix rule catches it, and it cannot tell a genuine contradiction from two facts that merely share vocabulary until the same-kind and minimum-word gates filter them. The merge threshold is a single number, 0.85, applied to every kind of fact. And a conflict flag is only as good as the next conversation: if the user never answers the question, both facts keep sitting there, flagged, forever. Pins are exempt from staleness for the same reason — a deliberate fact should not quietly expire.&lt;/p&gt;

&lt;h2&gt;
  
  
  What else shipped in v2.7.5
&lt;/h2&gt;

&lt;p&gt;The memory change is one part of the release. Also in 2.7.5: an editable personality card in Settings (caring attitude, teasing, language, example exchanges, shared by chat and voice); voice that keeps listening for a short follow-up window (25 seconds by default) instead of needing the wake word every time, with a quiet "Say Ankita first" hint in standby; the &lt;code&gt;open_app&lt;/code&gt; launcher that actually opens apps by name from a discovered, validated index; voice wake/stop words and auto-mute; scheduled results that appear in Scheduled tasks as soon as they finish; heartbeats that retry instead of being dropped, with &lt;code&gt;[SILENT]&lt;/code&gt; runs that record without posting; long-conversation compaction on by default in the desktop app; and shared Telegram, Discord and Slack channels on one local policy and delivery core. Fixes include a stuck voice process no longer freezing the queue, and &lt;code&gt;npm test&lt;/code&gt; scoped to the project's own &lt;code&gt;test/&lt;/code&gt; directory.&lt;/p&gt;

&lt;p&gt;Ankita is open source (MIT) and local-first: &lt;a href="https://github.com/akyourowngames/A.N.K.I.T.A" rel="noopener noreferrer"&gt;github.com/akyourowngames/A.N.K.I.T.A&lt;/a&gt;. The release is at &lt;a href="https://github.com/akyourowngames/A.N.K.I.T.A/releases/tag/v2.7.5" rel="noopener noreferrer"&gt;github.com/akyourowngames/A.N.K.I.T.A/releases/tag/v2.7.5&lt;/a&gt;, and the site is &lt;a href="https://ankita-sable.vercel.app" rel="noopener noreferrer"&gt;ankita-sable.vercel.app&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tell me I am wrong
&lt;/h2&gt;

&lt;p&gt;Here is the claim to break: a personal memory store should never auto-resolve a contradiction, even when the newer fact looks obviously right — because "newest wins" silently destroys the one record that could have corrected it. Is that caution, or is it cowardice? If you would let the store auto-rewrite on a high enough similarity score, where would you draw that line — and what would you do about the user who changed their mind back? I would genuinely like to know where this breaks first.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>ai</category>
      <category>showdev</category>
      <category>privacy</category>
    </item>
    <item>
      <title>How I built an app launcher for my desktop AI companion: an index, not a shell command</title>
      <dc:creator>Krish Verma</dc:creator>
      <pubDate>Sun, 01 Nov 2026 10:28:18 +0000</pubDate>
      <link>https://dev.to/krish_verma_77e28d3fd63ca/how-i-built-an-app-launcher-for-my-desktop-ai-companion-an-index-not-a-shell-command-2ao</link>
      <guid>https://dev.to/krish_verma_77e28d3fd63ca/how-i-built-an-app-launcher-for-my-desktop-ai-companion-an-index-not-a-shell-command-2ao</guid>
      <description>&lt;h1&gt;
  
  
  How I built an app launcher for my desktop AI companion: an index, not a shell command
&lt;/h1&gt;

&lt;p&gt;"Open Notepad" sounds like the simplest request a desktop companion can get. It nearly became the most dangerous one.&lt;/p&gt;

&lt;p&gt;The obvious implementation is a shell command: take the app name, run it, done. But a shell command has a child process, and a child that outlives its turn can hold the voice queue open — the exact bug that used to freeze voice entirely. And worse, a shell is a string that gets re-parsed: whatever the model puts in it becomes code. "Open Notepad" should never be one metacharacter away from running something else.&lt;/p&gt;

&lt;p&gt;So I built &lt;code&gt;open_app&lt;/code&gt; around one decision: &lt;strong&gt;the model picks a name from an index, never a path.&lt;/strong&gt; If it isn't in the index, it doesn't launch — it asks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The index is the only source of launchable targets
&lt;/h2&gt;

&lt;p&gt;Ankita scans the machine's installed apps and caches them in &lt;code&gt;apps-index.json&lt;/code&gt; (mode &lt;code&gt;0o600&lt;/code&gt;, capped at 600 entries). On Windows the primary source is PowerShell's &lt;code&gt;Get-StartApps&lt;/code&gt;, which reports display names plus AppIDs — including Store apps whose executable is not a normal file path. Start-menu &lt;code&gt;.lnk&lt;/code&gt; shortcuts are the fallback when PowerShell is blocked. macOS walks &lt;code&gt;/Applications&lt;/code&gt;, Linux reads &lt;code&gt;.desktop&lt;/code&gt; entries. The cache is at most 7 days old, rescans are floored at 30 seconds apart, and a miss on a fresh index triggers one automatic rescan — a miss usually means "installed since the last scan."&lt;/p&gt;

&lt;p&gt;Discovery is best-effort: a failed scan yields fewer apps, never an error to explain. And the cache is a launch authority, so it is validated on load, never trusted blindly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Matching that refuses to guess
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;matchApps&lt;/code&gt; runs a strict ladder: exact name, then unique prefix, then substring, then multi-token matching for requests like "photo editor" where the words arrive in a different order. At every step, if more than one app matches, the result is &lt;code&gt;ambiguous&lt;/code&gt; — with up to 5 candidates — and the assistant asks which one. It never picks between alternatives on the model's behalf.&lt;/p&gt;

&lt;p&gt;Deduplication has the same discipline. One app appears several ways — a Start-menu AppID, a &lt;code&gt;.lnk&lt;/code&gt; shortcut, a Store AppID — and deduping on name alone leaves three "Notepad" rows, making every exact lookup ambiguous. So the earliest source wins: Start-menu AppIDs are canonical, shortcuts the fallback. Junk is filtered at the door: separator-only rows, &lt;code&gt;== Site ==&lt;/code&gt; banners from web shortcuts, and URLs masquerading as shortcut names never enter the index.&lt;/p&gt;

&lt;h2&gt;
  
  
  Launching without a shell
&lt;/h2&gt;

&lt;p&gt;This is the part I care most about. A launch target is one of two shapes, and the code treats them very differently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Windows AppIDs&lt;/strong&gt; go through &lt;code&gt;cmd /c start&lt;/code&gt; — because Windows resolves them there — but only if they satisfy a strict shape check: a Store package ID, or &lt;code&gt;{GUID}\program&lt;/code&gt; validated character by character. Those patterns cannot contain &lt;code&gt;&amp;amp;&lt;/code&gt;, &lt;code&gt;|&lt;/code&gt;, &lt;code&gt;^&lt;/code&gt;, &lt;code&gt;"&lt;/code&gt; or &lt;code&gt;%VAR%&lt;/code&gt;, so &lt;code&gt;cmd&lt;/code&gt; has nothing to re-parse. The shape check lives in the scanner module so the scanner and the launcher can never disagree about what counts as an AppID.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real file paths&lt;/strong&gt; never touch &lt;code&gt;cmd&lt;/code&gt; at all. They spawn directly with &lt;code&gt;shell: false&lt;/code&gt;, and only after two checks: the path must be absolute (a bare name would resolve through &lt;code&gt;PATH&lt;/code&gt; to whatever happens to be first, which is not what the index promised) and it must still exist. Arguments from &lt;code&gt;.desktop&lt;/code&gt; files are filtered to plain tokens — anything containing a shell metacharacter is dropped.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The launch itself is detached, with stdio ignored and &lt;code&gt;unref()&lt;/code&gt; called — and deliberately &lt;strong&gt;not&lt;/strong&gt; registered in the assistant's job state. That is the bug fix disguised as a design choice: a background child registered as a job becomes a voice resource hold, which is exactly what made "open notepad" block the next request. The launcher returns as soon as the start request is accepted, and the conversation moves on.&lt;/p&gt;

&lt;p&gt;Two smaller details: approval is needed only for an app that isn't in the index at all — a known app launches without a prompt, an unknown one asks first, and unknown state is treated as risky. And the audit log records the app name and source, never its absolute path, because that path contains the OS username and belongs nowhere in a shared log.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't do yet
&lt;/h2&gt;

&lt;p&gt;The index is a snapshot, not a live view: an app uninstalled between scans fails the existence check, so the failure surfaces as "no longer installed" rather than a crash — acceptable, but a stale index is still a stale index. Store apps launch through the shell handler by necessity, so that path trusts Windows' resolver rather than a file on disk. And matching is purely textual: it can't tell two apps with genuinely identical names apart except by asking you.&lt;/p&gt;

&lt;p&gt;This landed on main for the upcoming 2.7.5 — the piece that makes "open notepad" safe to say out loud.&lt;/p&gt;

&lt;p&gt;Tell me I'm wrong: is an index-only launcher the right line, or just a shell with extra steps? Where exactly would it break first — the 7-day cache, the AppID shape check, or the "ask on ambiguity" rule the moment a user has three apps called the same thing?&lt;/p&gt;




&lt;p&gt;Ankita is an open-source, local-first personal AI companion for the desktop — a little companion, a lot more possible. The code is at &lt;a href="https://github.com/akyourowngames/A.N.K.I.T.A" rel="noopener noreferrer"&gt;github.com/akyourowngames/A.N.K.I.T.A&lt;/a&gt;, and I'd genuinely like to hear where this design breaks.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>ai</category>
      <category>showdev</category>
      <category>security</category>
    </item>
    <item>
      <title>How I built a managed ChatGPT browser for my desktop AI companion: no pairing link, no borrowed tab</title>
      <dc:creator>Krish Verma</dc:creator>
      <pubDate>Sat, 31 Oct 2026 10:24:54 +0000</pubDate>
      <link>https://dev.to/krish_verma_77e28d3fd63ca/how-i-built-a-managed-chatgpt-browser-for-my-desktop-ai-companion-no-pairing-link-no-borrowed-tab-3kb8</link>
      <guid>https://dev.to/krish_verma_77e28d3fd63ca/how-i-built-a-managed-chatgpt-browser-for-my-desktop-ai-companion-no-pairing-link-no-borrowed-tab-3kb8</guid>
      <description>&lt;h1&gt;
  
  
  How I built a managed ChatGPT browser for my desktop AI companion: no pairing link, no borrowed tab
&lt;/h1&gt;

&lt;p&gt;The worst setup flow I shipped was the one that worked. To connect my desktop companion to ChatGPT, you copied a pairing link out of the app, pasted it into your browser, and hoped the two ends agreed about who they were talking to. It worked — and every user who did it had to understand my architecture to do it. A pairing link is a credential the user carries by hand, and a hand-carried credential is a credential that gets pasted into the wrong place, screenshotted into a bug report, or lost between two windows.&lt;/p&gt;

&lt;p&gt;So I rebuilt the connection as a managed browser: the app owns the browser end to end. Two modes, no link to copy. &lt;strong&gt;ANKITA window&lt;/strong&gt; opens a compact window inside the app itself, with your sign-in saved. &lt;strong&gt;My Chrome&lt;/strong&gt; uses the Chrome profile you are already signed into, with the bundled companion extension installed once. The old explicit-pairing flow still exists as a legacy mode, but it is no longer the way in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision first: the app finds the browser, never the other way round
&lt;/h2&gt;

&lt;p&gt;The core question was who discovers whom. If the browser extension has to find the app, it needs an address, and an address the user types is an address the user gets wrong. So the companion extension discovers the app instead: it greets every candidate loopback port and picks the one that answers with the right app id, the right protocol version, and a host id.&lt;/p&gt;

&lt;p&gt;That is the whole of &lt;code&gt;desktop/chatgpt-companion/discovery.mjs&lt;/code&gt; — 33 lines. Each greeting gets a 1000 ms timeout, and the candidates are greeted concurrently, so trying several ports costs no more than trying one. A preferred address and host id are tried first, so reconnects are one request. If more than one app answers, discovery refuses: "More than one ANKITA app is waiting. Close the extra app, then connect again." If nobody answers, it returns null instead of guessing.&lt;/p&gt;

&lt;p&gt;Then comes the provision step, and this is where the ownership rule lives. The extension posts its browser id, extension id, and the host id it just verified, and the app answers with a key — but only if the version and host id match exactly, and only if the browser is not already owned. A 409 means "this connection is stopped or belongs to another browser," and the code never silently replaces the owner. The key itself is generated fresh per connection: &lt;code&gt;randomBytes(GATEWAY_TOKEN_BYTES)&lt;/code&gt; encoded as base64url, in &lt;code&gt;startConnection&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A port is not an identity
&lt;/h2&gt;

&lt;p&gt;The subtle bug this design is built around: a loopback port is not an identity. Ports get reused, apps restart, and two profiles of the same app can both be listening. So the managed browser carries a stable host id, minted once with &lt;code&gt;randomUUID()&lt;/code&gt; and stored in the app's own settings, with a comment that says it plainly: "an OS port alone cannot identify the intended local app."&lt;/p&gt;

&lt;p&gt;Sessions are stored per owner, not per app. In builtin mode, &lt;code&gt;openCompanion&lt;/code&gt; reads the saved session for that teammate's owner from the app settings; if the session is marked desired, it reuses the same browser id and opening id, so reconnecting lands you back in the same conversation instead of a fresh one. In Chrome mode, the same saved session drives an auto-connect, and the app launches Chrome itself if it is absent — &lt;code&gt;openChromeIfAbsent&lt;/code&gt; — rather than asking you to open the right profile by hand.&lt;/p&gt;

&lt;p&gt;And the sign-in itself is fenced. The builtin window only allows navigation to a small allowlist of login hosts; everything else is the companion's business, not a general browser. That window is a ChatGPT window, not a browser you happen to have open.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I am proudest of: a saved sign-in never launches anything on startup
&lt;/h2&gt;

&lt;p&gt;This is the one I would have got wrong a year ago. On startup, the app reads the saved pairing — and then, deliberately, does almost nothing with it. If the mode is builtin, it returns immediately: "Saved sign-in does not authorize a browser/action launch on app startup." If a saved Chrome pairing exists, recovery opens only its remembered loopback reader. The comment in &lt;code&gt;init()&lt;/code&gt; is explicit: recovery "never Chrome, a helper or an action transport."&lt;/p&gt;

&lt;p&gt;The reasoning is simple once you say it out loud: a credential surviving a restart is not consent to act. The user signed in last Tuesday; that does not mean the companion may start driving their ChatGPT account on Thursday morning before they have even looked at the screen. Saved state authorizes recovery of the connection, not the resumption of work. Work resumes when the user sends something, and even then the send path waits for the bridge to report ready, with a deadline after which it tells you to open your ChatGPT window and finish signing in, then send again.&lt;/p&gt;

&lt;p&gt;The same refusal runs through the rest of the connection lifecycle. Switching browser or teammate while a reply is pending is refused until you resume the saved reply or confirm Stop. A setup that is cancelled mid-flight closes everything it opened rather than leaving a half-connected bridge behind. A connection that changes owner mid-setup throws "The ChatGPT connection was closed or changed during setup" instead of attaching the new window to the old conversation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not solve
&lt;/h2&gt;

&lt;p&gt;Honest limits, because the managed browser has sharp edges. Discovery only works on loopback — if the companion cannot reach the app on the same machine, there is no fallback, and the failure message is just "could not connect." The Chrome mode depends on the companion extension staying installed in exactly one profile; the setup guide's warning is blunt: "The companion cannot discover the internal-only ANKITA window," and the reverse is equally true. Two apps waiting at once is a hard error, not a chooser UI — I picked a clear failure over a picker that could bind your conversation to the wrong app. And the builtin window is a single-purpose window: it will never be your general browser, and sign-in hosts are allowlisted, so an unexpected login redirect fails closed instead of opening a door.&lt;/p&gt;

&lt;p&gt;The code lives in &lt;code&gt;desktop/electron/chatgpt-browser.mjs&lt;/code&gt; (488 lines, the &lt;code&gt;DesktopChatGptBrowser&lt;/code&gt; class), &lt;code&gt;desktop/chatgpt-companion/discovery.mjs&lt;/code&gt;, and &lt;code&gt;desktop/electron/chatgpt-window.mjs&lt;/code&gt;. The modes are one frozen object in &lt;code&gt;desktop/shared/chatgpt-browser.mjs&lt;/code&gt;: &lt;code&gt;builtin&lt;/code&gt;, &lt;code&gt;chrome&lt;/code&gt;, &lt;code&gt;legacy&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Ankita is open source (MIT) and local-first — your models, your machine, your sign-in. The repo is at &lt;a href="https://github.com/akyourowngames/A.N.K.I.T.A" rel="noopener noreferrer"&gt;https://github.com/akyourowngames/A.N.K.I.T.A&lt;/a&gt;, and the site is &lt;a href="https://ankita-sable.vercel.app" rel="noopener noreferrer"&gt;https://ankita-sable.vercel.app&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Tell me I am wrong: the "saved sign-in never launches anything" rule costs a click on every restart. Is that the right price for never acting without the user in the room — or would you rather the companion quietly reconnect and only ask before it sends? And where exactly would this break first: the loopback discovery, the host-id check, or the per-owner session store?&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>ai</category>
      <category>showdev</category>
      <category>electron</category>
    </item>
    <item>
      <title>How I built self-maintaining projects for my desktop AI companion: folder attach, one work-log line, and a session-scoped undo</title>
      <dc:creator>Krish Verma</dc:creator>
      <pubDate>Fri, 30 Oct 2026 10:25:00 +0000</pubDate>
      <link>https://dev.to/krish_verma_77e28d3fd63ca/how-i-built-self-maintaining-projects-for-my-desktop-ai-companion-folder-attach-one-work-log-13ed</link>
      <guid>https://dev.to/krish_verma_77e28d3fd63ca/how-i-built-self-maintaining-projects-for-my-desktop-ai-companion-folder-attach-one-work-log-13ed</guid>
      <description>&lt;h1&gt;
  
  
  How I built self-maintaining projects for my desktop AI companion: folder attach, one work-log line, and a session-scoped undo
&lt;/h1&gt;

&lt;p&gt;The pain is mundane, which is why it kept biting. I work across several folders in a day — Ankita itself, a demo site, a scratch repo — and every assistant I tried either remembered nothing about the project I was in, or remembered everything globally, so notes from one project bled into another. I wanted a middle setting: open a folder, and the companion quietly knows which project it is, keeps a small record of what happened there, and lets me undo exactly what it wrote on its own. Nothing more.&lt;/p&gt;

&lt;p&gt;Ankita's project system does that with three deliberately boring mechanisms. Boring is the point — each one is deterministic, so there is nothing to argue with except the design choice at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  The folder decides, not a global switch
&lt;/h2&gt;

&lt;p&gt;A project is a root path plus notes, decisions, todos, and a work log, stored in &lt;code&gt;src/memory/projects.mjs&lt;/code&gt; (&lt;code&gt;ProjectStore&lt;/code&gt;). When a session starts, &lt;code&gt;detectProject&lt;/code&gt; in &lt;code&gt;src/memory/project-resolver.mjs&lt;/code&gt; resolves the working directory against every stored root. The rules are strict on purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A root is a root, never a loose substring. &lt;code&gt;C:\foo&lt;/code&gt; must not capture &lt;code&gt;C:\foobar&lt;/code&gt;. Matching is boundary-safe via relative-path checks, and case-folded only on Windows, where the OS itself is case-insensitive.&lt;/li&gt;
&lt;li&gt;The longest matching root wins, so a nested project inside a bigger one behaves predictably.&lt;/li&gt;
&lt;li&gt;If two roots tie at the same length, the result is &lt;code&gt;ambiguous&lt;/code&gt; — and ambiguity means no match at all, not a guess.&lt;/li&gt;
&lt;li&gt;Archived projects are skipped, and a repository folder with no matching project only ever earns an offer: "this folder is a git repository with no matching project — want to track it?" The companion never creates a project without the user agreeing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The subtle part is what detection does &lt;em&gt;not&lt;/em&gt; do. An explicit choice always wins: if you picked a project with &lt;code&gt;/project&lt;/code&gt;, path detection returns nothing. Otherwise the detected project shapes the session's prompt without ever changing the saved active project. The comment in &lt;code&gt;src/core/cli.mjs&lt;/code&gt; states it plainly: "an explicit choice always wins; otherwise the working directory can identify the project without changing the saved active project." Your global setting survives a detour into another folder. Detection is also best-effort by contract — in &lt;code&gt;src/core/agent.mjs&lt;/code&gt;, &lt;code&gt;resolvePathProject&lt;/code&gt; wraps the whole thing in try/catch, because a malformed project store must never stop a session from starting.&lt;/p&gt;

&lt;h2&gt;
  
  
  One work-log line per turn, zero model calls
&lt;/h2&gt;

&lt;p&gt;After each turn, &lt;code&gt;maintainTurn&lt;/code&gt; in &lt;code&gt;src/memory/project-maintenance.mjs&lt;/code&gt; writes what the turn did into its project. Its header comment is the design in one sentence: "Deterministic by design: the work log and todo sync cost no model call."&lt;/p&gt;

&lt;p&gt;Concretely, a turn that changed files appends exactly one work-log line — a summary like &lt;code&gt;done · 3 file(s) · a.mjs, b.mjs&lt;/code&gt; — capped at 400 characters, with at most the first 5 filenames named. The session checklist syncs alongside it: pending or in-progress items become project todos (at most 5 new ones per turn, 500 characters each), and completed or cancelled items close the matching open todo. Everything the automatic pass writes is tagged with its source — &lt;code&gt;{ kind: "auto-worklog", sessionId, turnId }&lt;/code&gt; — and a retried or replayed turn is harmless, because &lt;code&gt;appendWorkLog&lt;/code&gt; refuses to log the same turn twice.&lt;/p&gt;

&lt;p&gt;There are guardrails everywhere a silent writer needs them. A turn with no file changes and no checklist movement writes nothing (&lt;code&gt;no-work&lt;/code&gt;). Maintenance runs under a file lock, so two processes cannot interleave writes; if the lock is held, the turn is skipped, not queued. If maintenance itself fails, the failure is swallowed into the return value — the turn's reply is never broken by bookkeeping. And the work log itself is bounded: 100 entries, oldest dropped.&lt;/p&gt;

&lt;p&gt;The one part that &lt;em&gt;does&lt;/em&gt; cost a model call is off by default. &lt;code&gt;PROJECT_INLINE_CONSOLIDATION=on&lt;/code&gt; opts into a background extraction pass that reads the session journal (only once it reaches 400 characters) and files decisions and todos — but it runs after the reply is already delivered, so the user never waits for it, and its entries carry the same session tag as everything else automatic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Undo removes exactly what the session wrote — and nothing else
&lt;/h2&gt;

&lt;p&gt;This is the mechanism I am proudest of, because it falls out of the tagging for free. &lt;code&gt;/project undo&lt;/code&gt; calls &lt;code&gt;removeAutoEntries&lt;/code&gt; with the current session id, and it deletes only entries whose source kind starts with &lt;code&gt;auto&lt;/code&gt; and matches that session: work-log lines, notes, decisions, and todos the session added. Todos the session auto-closed get reopened. Manual entries — the note you typed, the decision you stated — have no auto source, so they survive untouched. The command reports the count honestly: "undid 7 automatic entries (work log 3, todos 2, reopened 1, notes 1, decisions 0)".&lt;/p&gt;

&lt;p&gt;So a bad automatic pass is reversible in one command, at exactly the granularity a human can reason about: "everything this session wrote on its own." There is no selective undo, no timestamp guessing, no "which of these 40 lines were automatic?" The tag is the audit trail.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does not do
&lt;/h2&gt;

&lt;p&gt;Detection is path-based, so renaming a folder orphans its project until you update the root. The work-log line is audit text, not understanding — "done · 3 file(s)" tells you what changed, never why it mattered. Inline consolidation is opt-in and backgrounded, which means a session's extracted decisions can land after you have already moved on. And session-scoped undo is coarse by design: one command wipes the whole session's automatic trail, not a single turn within it.&lt;/p&gt;

&lt;p&gt;That coarseness is the question I keep coming back to. Tell me I am wrong: is session scope the only granularity a human can actually reason about for undo, or should &lt;code&gt;/project undo&lt;/code&gt; take a turn id so you can rewind one bad pass without losing the rest of the session's record? Where exactly would per-turn undo break first — the tag is already there, so what is the real cost?&lt;/p&gt;

&lt;p&gt;Ankita is open-source (MIT) and local-first — your projects, your models, your machine. The code is at &lt;a href="https://github.com/akyourowngames/A.N.K.I.T.A" rel="noopener noreferrer"&gt;https://github.com/akyourowngames/A.N.K.I.T.A&lt;/a&gt;, and I would rather hear that the undo scope is wrong than that the article was nice.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>ai</category>
      <category>showdev</category>
      <category>productivity</category>
    </item>
    <item>
      <title>How I built sub-agents for my desktop AI companion: isolated children, one capped envelope, and no grandchildren</title>
      <dc:creator>Krish Verma</dc:creator>
      <pubDate>Thu, 29 Oct 2026 10:25:00 +0000</pubDate>
      <link>https://dev.to/krish_verma_77e28d3fd63ca/how-i-built-sub-agents-for-my-desktop-ai-companion-isolated-children-one-capped-envelope-and-no-4p64</link>
      <guid>https://dev.to/krish_verma_77e28d3fd63ca/how-i-built-sub-agents-for-my-desktop-ai-companion-isolated-children-one-capped-envelope-and-no-4p64</guid>
      <description>&lt;p&gt;Ankita is my open-source desktop AI companion — a little companion, a lot more possible. It lives on my machine, runs on my models, and does real work in my workspace: reading code, editing files, running checks. Sooner or later, any agent like that hits the same wall: one conversation trying to do three independent jobs at once. The parent's context balloons, the work interleaves, and a long task blocks everything behind it.&lt;/p&gt;

&lt;p&gt;The obvious fix is sub-agents — hand a piece of work to a child. The dangerous version is giving the child everything the parent has. I built the opposite: a child that gets almost nothing, runs its own tool loop, and comes back with exactly one capped envelope.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision first: the parent sees only the envelope
&lt;/h2&gt;

&lt;p&gt;A child in Ankita sees only its brief — the &lt;code&gt;prompt&lt;/code&gt; passed to &lt;code&gt;delegate_task&lt;/code&gt;. It cannot ask questions and cannot delegate. If the brief is ambiguous, it picks the most reasonable interpretation and says so. When it finishes, the parent receives one result envelope: status, a summary capped at 2,000 bytes, the full final answer capped at 48,000 bytes by default, the tools used, token usage, duration, and a transcript path. The parent's history stays small because the child's intermediate work never enters it.&lt;/p&gt;

&lt;p&gt;That cap is the whole point. An uncapped child return is just context growth with extra steps. The child must write a complete final answer — what it did or found, evidence with file:line references, files changed, commands and tests run with their results, and remaining risks — because that answer is all the parent will ever see. And the parent must verify the report before claiming the work is done; the envelope is a claim, not proof.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three presets, and the worker is a lock
&lt;/h2&gt;

&lt;p&gt;There are exactly three child types, defined in &lt;code&gt;src/core/subagents/registry.mjs&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;explore&lt;/strong&gt; — read-only research: &lt;code&gt;read_file&lt;/code&gt;, &lt;code&gt;list_dir&lt;/code&gt;, &lt;code&gt;search_files&lt;/code&gt;, &lt;code&gt;glob&lt;/code&gt;, read-only &lt;code&gt;git&lt;/code&gt;, plus web search and fetch. It cannot edit, run commands, or change state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;reviewer&lt;/strong&gt; — read-only code review for correctness, regressions, and missing tests, same toolset.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;worker&lt;/strong&gt; — implementation with every local tool except the parent-only ones, run one at a time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Read-only children overlap freely, because the parent loop already batches contiguous read-only calls. Workers are the opposite: a mutation barrier. One worker runs at a time across the whole process, keyed by the canonical workspace path — symlinks, junctions, and Windows case variants share one lock, so two workers can never edit the same workspace through different spellings of its path. The extras don't fail; they wait.&lt;/p&gt;

&lt;h2&gt;
  
  
  Isolation is a blocklist, not a vibe
&lt;/h2&gt;

&lt;p&gt;Children are isolated by what they cannot touch: no memory, no browser, no MCP, no skills, no scheduling, no project tools, and no recursion. The blocked set in the registry is explicit — &lt;code&gt;delegate_task&lt;/code&gt; itself, &lt;code&gt;browser&lt;/code&gt;, &lt;code&gt;schedule&lt;/code&gt;, &lt;code&gt;watch&lt;/code&gt;, &lt;code&gt;mcp_manage&lt;/code&gt;, &lt;code&gt;composio&lt;/code&gt;, &lt;code&gt;skill&lt;/code&gt;, &lt;code&gt;project&lt;/code&gt;, &lt;code&gt;remember&lt;/code&gt;, &lt;code&gt;recall&lt;/code&gt;, &lt;code&gt;find_tools&lt;/code&gt; — so a child cannot quietly re-acquire a parent capability through a side door. Children run with &lt;code&gt;memoryEnabled: false&lt;/code&gt;: pinned personal facts never enter a child prompt, and the &lt;code&gt;remember&lt;/code&gt;/&lt;code&gt;recall&lt;/code&gt; tools are blocked outright.&lt;/p&gt;

&lt;p&gt;Delegation isn't even always visible. The parent discovers it through &lt;code&gt;find_tools&lt;/code&gt; — query for it, and the &lt;code&gt;subagents&lt;/code&gt; group becomes callable. Background jobs, the daemon, Telegram DMs, and one-shot routines have no sub-agent host, so the group stays hidden there. There is no unattended delegation: a child only ever runs inside an interactive parent turn.&lt;/p&gt;

&lt;p&gt;Child tool calls keep the parent's approval gate, and an approval requested by a child is labelled with its type and description, so you always know who is asking. Audit records carry &lt;code&gt;subagent&lt;/code&gt; and &lt;code&gt;subagentType&lt;/code&gt; attribution. Child transcripts are written as &lt;code&gt;subagent-&amp;lt;id&amp;gt;.json&lt;/code&gt;, scrubbed with the same secret cleaner as normal sessions, pruned to the newest 200 per session store — and they can never overwrite a &lt;code&gt;teammate-&amp;lt;id&amp;gt;.json&lt;/code&gt; session file. Briefs must never contain secrets, because the child writes its transcript to disk.&lt;/p&gt;

&lt;p&gt;Budgets are bounded on every axis: depth 1 by default, up to 8 concurrent children, 24 per turn, 100 tool steps and 200 tool calls per child, a 30-minute default timeout, 1M tokens, and a 48KB result cap. A single &lt;code&gt;delegate_task&lt;/code&gt; call can tighten those with &lt;code&gt;max_steps&lt;/code&gt; and &lt;code&gt;timeout_ms&lt;/code&gt; — it can never raise them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this deliberately doesn't do yet
&lt;/h2&gt;

&lt;p&gt;No background or async delegation — no &lt;code&gt;spawn&lt;/code&gt;/&lt;code&gt;check&lt;/code&gt;/&lt;code&gt;wait&lt;/code&gt;. No worktree-isolated parallel workers. No resuming or forking parent context into a child. No custom user-defined presets. Those are listed as deferred in &lt;code&gt;docs/subagents.md&lt;/code&gt;, not silently missing. The test suite (&lt;code&gt;test/core/subagents.test.mjs&lt;/code&gt; plus desktop state and tool-loading suites) covers presets, discovery parity, read-only scoping, envelopes, transcripts, approval attribution, depth and fan-out guards, parallel overlap, and worker serialization including cancellation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question I keep coming back to
&lt;/h2&gt;

&lt;p&gt;The worker lock is the decision I'm least sure about. Serializing all workers per workspace path is simple and safe — no merge conflicts, no two writers racing — but it throws away the parallelism that made sub-agents attractive. The alternative is worktree-isolated parallel workers that merge when they finish: strictly more powerful, strictly more dangerous. A failed merge at 2am is worse than a slow queue.&lt;/p&gt;

&lt;p&gt;I chose the lock. Tell me I'm wrong — if you were building this, would you take the merge risk for parallel workers, or is one-writer-per-workspace the honest ceiling for a companion that edits your real files? Where exactly would the lock break first?&lt;/p&gt;

&lt;p&gt;Ankita is open source (MIT) at &lt;a href="https://github.com/akyourowngames/A.N.K.I.T.A" rel="noopener noreferrer"&gt;github.com/akyourowngames/A.N.K.I.T.A&lt;/a&gt;, and the full design is in &lt;code&gt;docs/subagents.md&lt;/code&gt;. If you've built agent delegation before, I'd genuinely like to hear how you drew the isolation line.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>ai</category>
      <category>showdev</category>
      <category>architecture</category>
    </item>
    <item>
      <title>How I built advisory skill permissions for my desktop AI companion: a manifest the model can ignore</title>
      <dc:creator>Krish Verma</dc:creator>
      <pubDate>Wed, 28 Oct 2026 10:25:35 +0000</pubDate>
      <link>https://dev.to/krish_verma_77e28d3fd63ca/how-i-built-advisory-skill-permissions-for-my-desktop-ai-companion-a-manifest-the-model-can-ignore-16be</link>
      <guid>https://dev.to/krish_verma_77e28d3fd63ca/how-i-built-advisory-skill-permissions-for-my-desktop-ai-companion-a-manifest-the-model-can-ignore-16be</guid>
      <description>&lt;h1&gt;
  
  
  How I built advisory skill permissions for my desktop AI companion: a manifest the model can ignore
&lt;/h1&gt;

&lt;p&gt;My desktop companion, Ankita, runs skills as plain instructions: a folder with a &lt;code&gt;SKILL.md&lt;/code&gt; file, a short frontmatter block, then markdown steps the model follows. That worked until I had to answer an uncomfortable question about a skill: what is it &lt;em&gt;allowed&lt;/em&gt; to do?&lt;/p&gt;

&lt;p&gt;The honest answer was "whatever the model decides, subject to tool approvals." Skills already had a &lt;code&gt;suggested-tools&lt;/code&gt; hint, and the skill tool says suggested tools are hints only. So in v2.7.0 I shipped a permission manifest for skills — deliberately as advice, not enforcement. This post is about that choice, and why I am still not sure it is right.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: a hint that looks like a boundary
&lt;/h2&gt;

&lt;p&gt;Skills are instructions, not code. Nothing stops a model that loaded a review skill from also calling a shell tool, because the skill text never had a way to say "I only need these tools." The catalogue line a model sees lists description and suggested tools with no statement of scope: a reader could not tell a read-only tour skill from one that touches network and shell.&lt;/p&gt;

&lt;p&gt;The failure mode is quiet scope creep: a read-only skill gradually picks up a write, a network call, a command, because each step looks reasonable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built: two frontmatter fields, parsed and validated
&lt;/h2&gt;

&lt;p&gt;The manifest lives in the same frontmatter block, parsed by &lt;code&gt;parseSkillFile&lt;/code&gt; in &lt;code&gt;src/core/skills.mjs&lt;/code&gt;. Two fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;commit-review&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Review&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;git&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;diffs&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;staged&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;changes&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;this&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;repo.&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Use&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;when&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;asked&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;review,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;check,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;or&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;summarize&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;changes."&lt;/span&gt;
&lt;span class="na"&gt;allowed-tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;git, read_file&lt;/span&gt;
&lt;span class="na"&gt;touches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;files, network&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Parsing is strict in cheap, honest ways. Each field is capped at 200 characters (&lt;code&gt;PERMISSION_CHARS&lt;/code&gt;), a bound chosen to keep install prompts readable. Tool names must match &lt;code&gt;^[a-z][a-z0-9_-]*$&lt;/code&gt;; scopes must match &lt;code&gt;^[a-z][a-z0-9_.-]*$&lt;/code&gt;. Entries are deduplicated through a &lt;code&gt;Set&lt;/code&gt;, &lt;code&gt;touches&lt;/code&gt; values are lowercased, and an invalid field rejects the whole skill: &lt;code&gt;loadSkills&lt;/code&gt; logs the reason and moves on. A malformed skill never breaks boot; it just does not load.&lt;/p&gt;

&lt;p&gt;What the model sees comes from &lt;code&gt;skillPromptLines&lt;/code&gt;: a catalogue line appending &lt;code&gt;Permissions: allowed-tools: git, read_file; touches: files, network.&lt;/code&gt; to the skill's description.&lt;/p&gt;

&lt;p&gt;And every prompt carries the standing instruction: "Skills are instructions only. Suggested tools are hints, not requirements. When allowed-tools is present, prefer those tools and ask before using others."&lt;/p&gt;

&lt;p&gt;Note the wording — "prefer" and "ask", not "you may only use" — and that the manifest is surfaced, never fed into a filter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The deliberate gap: enforcement exists elsewhere, but not here
&lt;/h2&gt;

&lt;p&gt;This is the part to poke at. Enforcement machinery already exists, just not connected to skills: the agent accepts an &lt;code&gt;allowedTools&lt;/code&gt; set, &lt;code&gt;tool-host.mjs&lt;/code&gt; filters specs against it, and sub-agent presets run with a resolved tool set. The October trust-safety work even removed an unused &lt;code&gt;isToolAllowedForSkill&lt;/code&gt; helper, so the code would not imply enforcement that does not exist. The release note says the same plainly: tool approvals remain the enforcement boundary.&lt;/p&gt;

&lt;p&gt;So today: the manifest states intent, approvals enforce individual dangerous calls, and nothing in between stops a skill's model from reaching for an unlisted tool and getting it approved on its own merits.&lt;/p&gt;

&lt;p&gt;Why not just wire it up? Enforcement has a failure mode of its own. A skill that legitimately needs an unlisted tool mid-task would either break or pester the user for an exception, and every exception prompt trains the user to click through the next one. Advisory manifests never break a task; they only make scope visible. Enforced manifests make scope real, but they also make every authoring mistake a broken workflow.&lt;/p&gt;

&lt;p&gt;There is also an adoption fact that undercuts any grand claim: no bundled skill declares &lt;code&gt;allowed-tools&lt;/code&gt; or &lt;code&gt;touches&lt;/code&gt; at all. The parser, validation, catalogue line, and prompt instruction all shipped in v2.7.0 — and the format has zero in-tree users. A permission system nobody writes permissions for is documentation, not a boundary. The verification recorded for the trust-safety batch (11 trust-safety tests, 10 skill tests passing) covers the parser and catalogue rendering, not a single real manifest in use.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would change, and what I will not claim
&lt;/h2&gt;

&lt;p&gt;What the manifest does buy, even unused, is a vocabulary. The next skill author can declare scope in a standard place instead of burying it in prose, and a typo in a tool name now fails at load with a clear message instead of silently meaning nothing.&lt;/p&gt;

&lt;p&gt;What it does not buy is safety. If you read "skill permissions" in the v2.7.0 notes and pictured a sandbox, that picture is wrong: &lt;code&gt;permissionSummary&lt;/code&gt; renders text, the prompt asks the model to prefer, and the approval gate does the actual stopping, one call at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tell me I'm wrong
&lt;/h2&gt;

&lt;p&gt;Here is the decision, as falsifiably as I can state it: advisory manifests plus per-call approvals are the honest design, because a prompt-level hint can never be a security boundary, and pretending otherwise is worse than admitting it. Enforcement should wait for a routing layer that can deny a call by skill context without turning every edge case into an approval prompt.&lt;/p&gt;

&lt;p&gt;Tell me I'm wrong — where exactly would it break first? If you wired &lt;code&gt;skill.allowedTools&lt;/code&gt; into the agent's tool filter tomorrow, what is the first legitimate skill task that breaks, and is that breakage worth paying for a boundary the model cannot talk its way around? Ankita is open source (&lt;a href="https://github.com/akyourowngames/A.N.K.I.T.A" rel="noopener noreferrer"&gt;github.com/akyourowngames/A.N.K.I.T.A&lt;/a&gt;); the code is &lt;code&gt;src/core/skills.mjs&lt;/code&gt; — I would rather hear the counterexample than keep shipping advice dressed as permission.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>ai</category>
      <category>showdev</category>
      <category>security</category>
    </item>
    <item>
      <title>How I built undo for my desktop AI companion: a backup before every destructive write</title>
      <dc:creator>Krish Verma</dc:creator>
      <pubDate>Tue, 27 Oct 2026 10:25:14 +0000</pubDate>
      <link>https://dev.to/krish_verma_77e28d3fd63ca/how-i-built-undo-for-my-desktop-ai-companion-a-backup-before-every-destructive-write-4l6c</link>
      <guid>https://dev.to/krish_verma_77e28d3fd63ca/how-i-built-undo-for-my-desktop-ai-companion-a-backup-before-every-destructive-write-4l6c</guid>
      <description>&lt;p&gt;An AI companion that can edit your files will eventually delete the wrong one. That is not a hypothetical for me — it is the whole reason Ankita, my open-source desktop companion, now has undo.&lt;/p&gt;

&lt;p&gt;Approval prompts were my first answer, and they are not enough. A prompt says "delete this file?" and a tired human clicks yes. The mistake is already made by the time anyone realises it. So I stopped trying to make the approval smarter and made the destructive operation itself reversible instead: every tool that can destroy data copies the original first, and a separate tool puts it back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision: backup before mutate, restore as a tool
&lt;/h2&gt;

&lt;p&gt;The rule lives in one small module, &lt;code&gt;tools/shared/_backup.mjs&lt;/code&gt; (128 lines). Before &lt;code&gt;delete_file&lt;/code&gt;, &lt;code&gt;write_file&lt;/code&gt;, &lt;code&gt;edit_file&lt;/code&gt;, &lt;code&gt;edit_lines&lt;/code&gt;, &lt;code&gt;move_file&lt;/code&gt;, or &lt;code&gt;apply_patch&lt;/code&gt; touches anything, it calls &lt;code&gt;backupPath()&lt;/code&gt;. Only after the copy succeeds does the mutation run. Undo is not a feature bolted onto one tool — it is the precondition for all of them.&lt;/p&gt;

&lt;p&gt;Backups live inside the workspace, in &lt;code&gt;.ankita_backups&lt;/code&gt;, so undo works with no config and never writes outside the workspace boundary. Each original path gets its own directory, named by the first 32 hex characters of the SHA-256 of its workspace-relative path. That hashing is there because of a real bug: the first version grouped backups by filename prefix, so &lt;code&gt;foo&lt;/code&gt; and &lt;code&gt;foo.txt&lt;/code&gt; could list — and prune — each other's backups. One hashed directory per path fixed it, and it bounds the name for deep paths at the same time.&lt;/p&gt;

&lt;p&gt;Each backup file is named with a sortable timestamp plus a short random suffix, like &lt;code&gt;20251025-081512-123.a1b2c3d4.bak&lt;/code&gt;. Per path, the store keeps at most 20 backups and prunes the oldest; files over 5 MB are not copied at all. Symlinks are skipped too — copying what a link points to would be a different kind of destructive surprise.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the user actually sees
&lt;/h2&gt;

&lt;p&gt;The approval prompt for a delete already tells you the escape hatch exists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;delete  /workspace/notes.txt
A backup is kept; restore with restore_file.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the result of the delete tells you exactly how to use it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Deleted /workspace/notes.txt
Backup: /workspace/.ankita_backups/&amp;lt;hash&amp;gt;/20251025-081512-123.a1b2c3d4.bak
Restore with restore_file {"path": "notes.txt"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;restore_file&lt;/code&gt; restores the most recent backup by default, lists all backups for a path with &lt;code&gt;{"list": true}&lt;/code&gt;, and takes an &lt;code&gt;index&lt;/code&gt; (0 = oldest) for anything older. The 5 MB cap has a deliberate carve-out: a file too big to copy still shows up in the delete preview (directory listings are capped at 20 entries, files show their size), so you are warned about what you are losing even when there is no backup to fall back on.&lt;/p&gt;

&lt;p&gt;Alongside the backups sits an audit log, &lt;code&gt;.ankita_backups/audit.jsonl&lt;/code&gt; — one JSON record per mutation, scrubbed of secrets, capped per field, owner-only permissions (0o600), and written best-effort: the audit function never throws, because a logging failure must never break the tool it is logging. The backup tells you what you can get back; the audit tells you what happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest limits
&lt;/h2&gt;

&lt;p&gt;Restore is blunt. It deletes whatever is currently at the path and copies the backup over it. There is no check for whether you recreated or edited the file after the destructive operation — a restore will clobber that newer work without asking. Backups also live inside the workspace, so anything that wipes the workspace wipes the undo history with it. And a file over 5 MB, or a symlink, simply has no backup; the preview is the only warning you get.&lt;/p&gt;

&lt;p&gt;Those limits are the trade I made: undo that works with zero setup and zero new concepts, in exchange for a restore that trusts the user to mean it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tell me I'm wrong
&lt;/h2&gt;

&lt;p&gt;Here is the part I am least sure about: should &lt;code&gt;restore_file&lt;/code&gt; refuse — or at least warn — when the current content at a path differs from what the destructive operation left behind? The case that worries me is delete, recreate with new content, then restore the old file: silent most-recent-wins destroys the recreation. The counter-argument is that a companion should just fix the mistake when asked, and a refusal turns undo into a second approval prompt — the exact friction this design was built to escape.&lt;/p&gt;

&lt;p&gt;Tell me I'm wrong, and where exactly it would break first: is silent most-recent-wins the right call, or should restore check before it clobbers?&lt;/p&gt;

&lt;p&gt;Ankita is open source (MIT) at &lt;a href="https://github.com/akyourowngames/A.N.K.I.T.A" rel="noopener noreferrer"&gt;https://github.com/akyourowngames/A.N.K.I.T.A&lt;/a&gt; — a little companion, a lot more possible — and the backup module is small enough to read in one sitting. If you have built undo for an agent before, I would genuinely like to hear how you handled the restore-clobbers-new-work case.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>ai</category>
      <category>showdev</category>
      <category>security</category>
    </item>
    <item>
      <title>How I built a test that walks the shipped import graph: the missing module that bricked Ankita 2.7.0</title>
      <dc:creator>Krish Verma</dc:creator>
      <pubDate>Mon, 26 Oct 2026 10:26:03 +0000</pubDate>
      <link>https://dev.to/krish_verma_77e28d3fd63ca/how-i-built-a-test-that-walks-the-shipped-import-graph-the-missing-module-that-bricked-ankita-270-j6</link>
      <guid>https://dev.to/krish_verma_77e28d3fd63ca/how-i-built-a-test-that-walks-the-shipped-import-graph-the-missing-module-that-bricked-ankita-270-j6</guid>
      <description>&lt;h1&gt;
  
  
  How I built a test that walks the shipped import graph: the missing module that bricked Ankita 2.7.0
&lt;/h1&gt;

&lt;p&gt;Ankita is my open-source desktop AI companion — local-first, MIT-licensed, your models. Yesterday I shipped v2.7.0. This morning I shipped v2.7.1, and the only reason it exists is embarrassing: after updating to 2.7.0, the desktop app would not start at all.&lt;/p&gt;

&lt;p&gt;The main process died with &lt;code&gt;ERR_MODULE_NOT_FOUND&lt;/code&gt; for &lt;code&gt;desktop/chatgpt-companion/sign-in.mjs&lt;/code&gt;. The fix itself was one line in a YAML file. The interesting part is the test I wrote so it can never happen again — a test that walks the shipped import graph and fails the suite when a packaged module imports something that is not packaged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two copies of one tree, and a gap between them
&lt;/h2&gt;

&lt;p&gt;Ankita's ChatGPT companion has a split personality, and that is where the bug lived.&lt;/p&gt;

&lt;p&gt;The packaging config listed &lt;code&gt;desktop/electron/**/*&lt;/code&gt; and &lt;code&gt;desktop/browser-helper/**/*&lt;/code&gt;, but not &lt;code&gt;desktop/chatgpt-companion/**/*&lt;/code&gt;. So the ASAR shipped the importer, not the imported module — and inside the ASAR, a relative import resolves inside the ASAR, so the &lt;code&gt;extraResources&lt;/code&gt; copy sitting on disk in the same install could not save it.&lt;/p&gt;

&lt;p&gt;The companion tree (&lt;code&gt;desktop/chatgpt-companion/&lt;/code&gt;) ships twice. Once as real files under &lt;code&gt;extraResources&lt;/code&gt;, because a Chrome extension cannot load code out of an ASAR archive. And once inside the ASAR, because the Electron main process imports companion modules directly — &lt;code&gt;desktop/electron/chatgpt-sign-in.mjs&lt;/code&gt; imports &lt;code&gt;signInCookies&lt;/code&gt; and friends straight from &lt;code&gt;../chatgpt-companion/sign-in.mjs&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Why did nobody catch it before release? In development, every file exists on disk. The import resolves, the tests pass, the app runs. The failure mode only exists in the packaged artifact, and nothing in the suite ever looked at the packaged artifact's file set. The unit tests tested the code. Nobody tested the box the code ships in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test: reuse the builder's own matcher
&lt;/h2&gt;

&lt;p&gt;The new test lives in &lt;code&gt;test/desktop/chatgpt-package.test.mjs&lt;/code&gt;, named &lt;code&gt;every local module imported by packaged sources ships inside the ASAR file set&lt;/code&gt;. Its core idea is simple: do not reimplement electron-builder's file matching. Use electron-builder's file matching.&lt;/p&gt;

&lt;p&gt;It loads &lt;code&gt;desktop/packaging/electron-builder.yml&lt;/code&gt;, hands its &lt;code&gt;files&lt;/code&gt; list to &lt;code&gt;FileMatcher&lt;/code&gt; from &lt;code&gt;app-builder-lib&lt;/code&gt; — the builder's own matcher class — and builds the exact filter the packager would use. That choice is the whole design. A hand-rolled glob check would drift from the builder's semantics the first time someone writes a tricky negation pattern, and then the test would be green while the package is broken.&lt;/p&gt;

&lt;p&gt;From there it is mechanical:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Enumerate every module under the trees that can contribute shipped code — &lt;code&gt;desktop&lt;/code&gt;, &lt;code&gt;services&lt;/code&gt;, &lt;code&gt;src&lt;/code&gt;, &lt;code&gt;tools&lt;/code&gt;, &lt;code&gt;skills&lt;/code&gt;, &lt;code&gt;vendor&lt;/code&gt; — with a &lt;code&gt;.mjs&lt;/code&gt;, &lt;code&gt;.js&lt;/code&gt;, &lt;code&gt;.cjs&lt;/code&gt;, &lt;code&gt;.mts&lt;/code&gt;, or &lt;code&gt;.json&lt;/code&gt; extension.&lt;/li&gt;
&lt;li&gt;Keep only the files the filter says are packaged.&lt;/li&gt;
&lt;li&gt;Scan each packaged file's text for relative imports: static &lt;code&gt;import&lt;/code&gt;/&lt;code&gt;export ... from&lt;/code&gt; statements and literal dynamic &lt;code&gt;import()&lt;/code&gt; calls, via two regexes.&lt;/li&gt;
&lt;li&gt;Resolve each relative specifier against the importing file's directory. A target that exists on disk but does not ship goes into a &lt;code&gt;missing&lt;/code&gt; map, keyed by target with the list of importers.&lt;/li&gt;
&lt;li&gt;Assert the map is empty: "Every relative module imported by packaged code must be included in the ASAR file set."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Against the v2.7.0 config, the test fails with exactly one entry: &lt;code&gt;desktop/chatgpt-companion/sign-in.mjs&lt;/code&gt;, imported by &lt;code&gt;desktop/electron/chatgpt-sign-in.mjs&lt;/code&gt;. That is the production crash, reproduced as a unit-test failure. The v2.7.1 fix adds &lt;code&gt;desktop/chatgpt-companion/**/*&lt;/code&gt; to the builder's &lt;code&gt;files&lt;/code&gt; list — with a comment explaining why both copies exist — and the test goes green.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the test deliberately does not do
&lt;/h2&gt;

&lt;p&gt;A few boundaries are worth stating, because they are the places this test can still be fooled:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It only sees literal specifiers.&lt;/strong&gt; A dynamic import built from a template string or a variable is invisible to the regexes. If a packaged module ever loads code that way, this test will not catch a missing target.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It only checks relative imports.&lt;/strong&gt; Bare package imports are the bundler's problem, not the file list's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It checks the file list, not the built artifact.&lt;/strong&gt; It never builds an ASAR or boots the main process. It trusts that the builder's matcher, driven by the same config, predicts the real package contents.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is the honest trade-off. A test that builds the real package and boots it headless would catch more — including a module that is listed but corrupted, or a native dependency the matcher cannot see. But it would also take minutes, need a headless Electron harness, and flake in CI for unrelated reasons. The static walk runs in milliseconds and fails on exactly the class of bug that just shipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  The changelog, behind the mechanism
&lt;/h2&gt;

&lt;p&gt;For completeness: v2.7.1 is only this fix. The companion tree is now packaged in the ASAR as well as its existing real-file &lt;code&gt;extraResources&lt;/code&gt; copy, and the import-graph test guards the file list from here on. Release notes and installers are at &lt;a href="https://github.com/akyourowngames/A.N.K.I.T.A/releases/tag/v2.7.1" rel="noopener noreferrer"&gt;https://github.com/akyourowngames/A.N.K.I.T.A/releases/tag/v2.7.1&lt;/a&gt;, and the project itself at &lt;a href="https://github.com/akyourowngames/A.N.K.I.T.A" rel="noopener noreferrer"&gt;https://github.com/akyourowngames/A.N.K.I.T.A&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tell me I'm wrong
&lt;/h2&gt;

&lt;p&gt;Here is the question I actually want argued with: is a regex walk over the file list enough, or a false sense of security? It trusts the builder's matcher to predict the artifact, and it is blind to computed imports. Would you ship the static check and move on, or is the only honest version a test that builds the real ASAR and boots the main process from it — cost and flakes included? Where exactly would you expect the static walk to break first?&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>ai</category>
      <category>showdev</category>
      <category>electron</category>
    </item>
  </channel>
</rss>
