<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.3.4">Jekyll</generator><link href="https://charoiteai.github.io/Charoite_audio/feed.xml" rel="self" type="application/atom+xml" /><link href="https://charoiteai.github.io/Charoite_audio/" rel="alternate" type="text/html" /><updated>2026-08-31T07:32:15+00:00</updated><id>https://charoiteai.github.io/Charoite_audio/feed.xml</id><title type="html">Charoite devlog</title><subtitle>Notes from building a fully local meeting assistant for macOS: speaker diarization, on-device models, a knowledge graph that survives crashes.</subtitle><entry><title type="html">Phase zero, closed</title><link href="https://charoiteai.github.io/Charoite_audio/2026/08/25/phase-zero-closed.html" rel="alternate" type="text/html" title="Phase zero, closed" /><published>2026-08-25T21:50:00+00:00</published><updated>2026-08-25T21:50:00+00:00</updated><id>https://charoiteai.github.io/Charoite_audio/2026/08/25/phase-zero-closed</id><content type="html" xml:base="https://charoiteai.github.io/Charoite_audio/2026/08/25/phase-zero-closed.html"><![CDATA[<p>The overhaul’s phase zero is merged — eleven batches in three days, each
one small enough to review in a sitting: the live transcript aggregate
carved into its own module, a brain client, one Claude-CLI resolver, a
file-lock helper, one config loader, a single owner for every Python
launch, live probes out of the deterministic suite, and a
characterization net over the seams of rename → forget → repeat.</p>

<p>The rhythm did the work. One or two batches a day, each through the same
gate: a reviewer circle that stops only on a clean round. The circles
earned their keep — of the last three batches, every one had a finding
that survived verification, and the strongest was against my own fix:
the guard I loosened to stop false alarms turned out to miss the exact
mutation it existed to catch, and the head that caught it also proved
the inventory covered three of seven launch sites. Three rounds later
the guard is smaller than where it started: the compensating layer I
had added is gone, the inventory is complete, and the residual risks
are written down instead of papered over.</p>

<p>That is the phase-zero lesson in one line: when a guard needs a third
patch in a day, stop patching the guard — the invariant is usually
structural, and the fix is to finish an inventory, not to add a layer.</p>

<p>The characterization net closed the phase the same way it started:
tests written against what the code <em>does</em>, verified by running it,
not against what anyone remembered it doing. Renaming a meeting keeps
the graph node’s filename and rewrites its title; forgetting finds the
meeting by stamp boundary, so a renamed meeting stays forgettable for
free; a second forget is a no-op. None of that was designed this week —
it was measured, and now it is pinned, which is what lets phase one
move structure without holding its breath.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[The overhaul’s phase zero is merged — eleven batches in three days, each one small enough to review in a sitting: the live transcript aggregate carved into its own module, a brain client, one Claude-CLI resolver, a file-lock helper, one config loader, a single owner for every Python launch, live probes out of the deterministic suite, and a characterization net over the seams of rename → forget → repeat.]]></summary></entry><entry><title type="html">A crash in a background thread should fail the run</title><link href="https://charoiteai.github.io/Charoite_audio/2026/08/25/background-thread-crashes-fail-the-run.html" rel="alternate" type="text/html" title="A crash in a background thread should fail the run" /><published>2026-08-25T14:45:00+00:00</published><updated>2026-08-25T14:45:00+00:00</updated><id>https://charoiteai.github.io/Charoite_audio/2026/08/25/background-thread-crashes-fail-the-run</id><content type="html" xml:base="https://charoiteai.github.io/Charoite_audio/2026/08/25/background-thread-crashes-fail-the-run.html"><![CDATA[<p>This devlog keeps returning to false green; apparently that is the theme.</p>

<p>Charoite’s daemon is thread-heavy: capture pumps, STT loops, watchdogs. In
tests, those threads die the way threads do — an exception nobody joins on,
a traceback printed to a log nobody reads, and pytest reports the run green,
because from the main thread’s point of view nothing went wrong. The worst
version of this: an assertion <em>inside</em> a background thread fails, which is a
crash of the thread and a pass of the test.</p>

<p>This week the suite got a gate for it. A <code class="language-plaintext highlighter-rouge">threading.excepthook</code> installed for
every test records background-thread exceptions, and the run fails if any
were seen. Sixty lines of test code, eleven lines of <code class="language-plaintext highlighter-rouge">pyproject.toml</code>, plus
a canary test that deliberately crashes a thread and asserts the gate catches
it.</p>

<p>Two details survived review and are worth writing down.</p>

<p>First, the honest boundary. The reviewer’s finding was not a bug but an
overclaim: the gate’s comment promised it catches background crashes,
period. It doesn’t — it catches a crash that happens <em>inside its test’s
window</em>. A thread that leaks past the end of its test attributes the
exception to a report that is already closed, and the run stays green. Late
crashes are caught by something else entirely: join discipline, every test
joining what it spawns. The comment now says exactly that. A guard that
states its limits is a guard; a guard that overstates them is the false
green it was built against, one level up.</p>

<p>Second, the canary is <em>deliberately</em> fragile. It asserts the gate is wired
in <code class="language-plaintext highlighter-rouge">pyproject.toml</code> specifically, so moving the gate breaks the canary
loudly instead of leaving a silently disarmed suite. Coupling a test to a
config file’s location is normally a smell — here it is the point.</p>

<p>The gate found its first real victim immediately: a dead-channel test whose
cleanup raised <code class="language-plaintext highlighter-rouge">IndexError</code> in a <code class="language-plaintext highlighter-rouge">finally</code> block, masking the actual
failure underneath for who knows how long. Joined the pump threads before
asserting, and the real failure surfaced. Green that lies is worse than
red: red gets investigated.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[This devlog keeps returning to false green; apparently that is the theme.]]></summary></entry><entry><title type="html">A mechanical move, verified mechanically</title><link href="https://charoiteai.github.io/Charoite_audio/2026/08/25/a-mechanical-move-verified-mechanically.html" rel="alternate" type="text/html" title="A mechanical move, verified mechanically" /><published>2026-08-25T14:15:00+00:00</published><updated>2026-08-25T14:15:00+00:00</updated><id>https://charoiteai.github.io/Charoite_audio/2026/08/25/a-mechanical-move-verified-mechanically</id><content type="html" xml:base="https://charoiteai.github.io/Charoite_audio/2026/08/25/a-mechanical-move-verified-mechanically.html"><![CDATA[<p>Charoite is in an overhaul phase: no new features for a while, just carving
the codebase into pieces that can be reasoned about. The rhythm is one or two
small batches a day, each merged through the usual review circle. This week
that produced a lock helper, a single config-fallback loader, a single
resolver for an external CLI the pipeline shells out to — and the first real
aggregate extraction.</p>

<p>The live transcript used to be a region inside <code class="language-plaintext highlighter-rouge">main.py</code>: the structure that
accumulates recognized speech for both audio channels, merges overlaps, cuts
repeated fragments, and renders the text every downstream consumer reads.
Everything else in the pipeline imports it. Moving it is the definition of a
scary refactor: 243 lines that must land in a new module byte-for-byte
equivalent, or a week later some meeting note quietly renders wrong.</p>

<p>The move itself was done by a coding agent running in a parallel session,
with instructions to relocate, not to improve. The interesting question is
how you <em>check</em> that. Reading the diff proves nothing: a 250-line block that
moved between files is exactly the diff a human rubber-stamps. Green tests
prove less than you’d hope — a subtle behaviour change in text merging can
pass every existing test and still corrupt output on real speech.</p>

<p>So the check was mechanical too:</p>

<ul>
  <li>Before the move: characterization tests written against the <em>old</em> code,
168 lines of them, pinning current behaviour — including the ugly cases
(overlap cut across chunk boundaries, a channel going silent mid-merge).</li>
  <li>After the move: parse both versions of the relocated functions and compare
their ASTs. Not the text, the syntax tree — whitespace and import order
may differ, logic may not.</li>
  <li>Then the full suite, all 1153 tests, on top.</li>
</ul>

<p>The AST comparison is the part I’d keep from this week. It converts “trust
the agent’s diff” into “the transformation is identity, checked by a
program”. <code class="language-plaintext highlighter-rouge">main.py</code> went from 365 lines to 122, the aggregate now lives in
its own <code class="language-plaintext highlighter-rouge">transcript.py</code> with its own tests, and nobody had to <em>believe</em>
anything.</p>

<p>Delegating mechanical work is fine. Delegating the verification of
mechanical work is not — that part should not run on trust at all.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Charoite is in an overhaul phase: no new features for a while, just carving the codebase into pieces that can be reasoned about. The rhythm is one or two small batches a day, each merged through the usual review circle. This week that produced a lock helper, a single config-fallback loader, a single resolver for an external CLI the pipeline shells out to — and the first real aggregate extraction.]]></summary></entry><entry><title type="html">A green test for an event nobody sends</title><link href="https://charoiteai.github.io/Charoite_audio/2026/08/19/a-green-test-for-an-event-nobody-sends.html" rel="alternate" type="text/html" title="A green test for an event nobody sends" /><published>2026-08-19T18:30:00+00:00</published><updated>2026-08-19T18:30:00+00:00</updated><id>https://charoiteai.github.io/Charoite_audio/2026/08/19/a-green-test-for-an-event-nobody-sends</id><content type="html" xml:base="https://charoiteai.github.io/Charoite_audio/2026/08/19/a-green-test-for-an-event-nobody-sends.html"><![CDATA[<p>Stopping a recording is not an action, it is a wait. The daemon has to flush
audio, run the post-meeting pipeline and release its lock, and the app must not
open a new meeting until the old process is actually gone. In Charoite that
wait used to live in five scattered flags on one service object, which is how a
daemon surviving <code class="language-plaintext highlighter-rouge">SIGKILL</code> could leave the app in “stopping” forever, with the
Stop button doing nothing at all.</p>

<p>Today that turned into one pure type. <code class="language-plaintext highlighter-rouge">ShutdownMachine</code> has phases (<code class="language-plaintext highlighter-rouge">idle</code>,
<code class="language-plaintext highlighter-rouge">waitingDaemon</code>, <code class="language-plaintext highlighter-rouge">stuck</code>, <code class="language-plaintext highlighter-rouge">done</code>), events (Stop pressed, daemon exited, poll
tick, kill timeout) and actions (close the capture, poll again, report, force
kill, finish). It holds no reference to the service, so every arc is testable
without a running process. Timings: <code class="language-plaintext highlighter-rouge">terminate()</code> at 8 seconds, <code class="language-plaintext highlighter-rouge">SIGKILL</code> at
12, a backup timer at 13, then polling twice a second; after 30 waits the phase
becomes <code class="language-plaintext highlighter-rouge">stuck</code>, the app says so in plain words, and a second press of Stop is
a request to force-kill rather than a no-op.</p>

<p>The interesting part is not the machine. It is the defect that appeared twice
in a row while building it.</p>

<h2 id="the-same-bug-twice">The same bug, twice</h2>

<p>Round one of review found that <code class="language-plaintext highlighter-rouge">killTimeout</code> was declared in the machine,
covered by a green test, and never sent by the service. The safety timer called
its own code path directly and the machine never heard about it. Fixed it, ran
round two — and the reviewer found <code class="language-plaintext highlighter-rouge">daemonExited</code> in exactly the same state:
declared, tested, never delivered.</p>

<p>Two different events, same shape of failure. A test was pinning down behaviour
the system did not have, which is worse than having no test: the green check
tells you the arc works, so nobody goes looking.</p>

<p>That is not a patch-level problem. As long as <em>submitting an event</em> and
<em>executing the resulting action</em> live in different places in the code, the
trap reproduces — someone adds a path, calls the old helper, and the machine
silently stops seeing reality. The fix was structural: a single entry point in
the service. Events go in, actions come out, and the action is executed right
there, immediately. Every place that used to reach into shutdown internals now
goes through that one door.</p>

<p>Round three then found two more bugs — in the fixes themselves. Guarding
against recursion, I had passed <code class="language-plaintext highlighter-rouge">nil</code> as the token, and the wait loop lost its
ability to schedule the next poll; and resetting the phase inside <code class="language-plaintext highlighter-rouge">stop()</code>
turned the backup timer into a no-op precisely when the daemon refused to die.
Both were mine, both were the cost of patching an edge instead of fixing the
model.</p>

<h2 id="what-the-reviewers-cost-and-returned">What the reviewers cost and returned</h2>

<p>Six rounds in total, across a cloud model and a local one. Cloud model: three
Critical, four Important, one false positive — it proposed <code class="language-plaintext highlighter-rouge">private(set)</code> on a
gate that is a <code class="language-plaintext highlighter-rouge">struct</code>, where mutating methods count as writes and the
compiler would have rejected it. The finding under that false fix was real
though, so it got closed a different way: the field went back to <code class="language-plaintext highlighter-rouge">private</code> and
four narrow wrappers went out instead, which also removed two methods from the
module’s reach entirely.</p>

<p>The local 35B model is free and has no privacy boundary, so it runs every
round. Its useful range is narrow: on a 30K-character diff it reported nothing,
on a 5K one it verified the wrappers correctly. Earlier measurements said the
same — 2 true findings out of 10 on a 21K diff, but a hit on the same Critical
as the cloud model at 6.5K. Feed it per commit, not per branch, and treat
silence as no verdict at all.</p>

<p>Shipped in <a href="https://github.com/charoiteai/Charoite_audio/releases/tag/v0.55.0">v0.55.0</a>
together with owner identification by capture channel — the feature that
replaced storing a voiceprint.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Stopping a recording is not an action, it is a wait. The daemon has to flush audio, run the post-meeting pipeline and release its lock, and the app must not open a new meeting until the old process is actually gone. In Charoite that wait used to live in five scattered flags on one service object, which is how a daemon surviving SIGKILL could leave the app in “stopping” forever, with the Stop button doing nothing at all.]]></summary></entry></feed>