Skip to content

LLM analysis: token-budgeted digests, an MCP server, and verdicts painted back onto the map - #36

Merged
rducom merged 15 commits into
mainfrom
feat/llm-briefing
Aug 16, 2026
Merged

rducom merged 15 commits into
mainfrom
feat/llm-briefing

Conversation

@rducom

@rducom rducom commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

Why

claude -p is API-token-only, so the app cannot spawn a session on a subscription. The flow inverts: the user starts the session, and the session calls the app.

The guiding constraint throughout: the scanner measures, the LLM classifies. SpaceMatters knows how many bytes are in a folder and when it was last written. It does not know that workspaceStorage holds state for repositories deleted two years ago, or that 38 GiB of .raw is a photo library that belongs on an external drive. Everything here exists to hand a model the smallest payload that still supports a correct verdict — and to bring that verdict back to the map, where the user already is.

Full design and measurements: SPEC-14.

What ships

TreeDigest — a 3 M-file tree cannot go into a chat context, and every obvious truncation lies about the disk. A depth cap buries the interesting folder; a top-N cap drops the context that made it interesting. Instead a hard line budget is spent greedily, largest-subtree-first, with single-child chains collapsed and small siblings rolled up. ~/Library/Application Support (35,5 GiB, 198 k files) at maxNodes: 200 → 199 lines, ~2,5–3 k tokens.

⌘⇧C "Copy briefing" — the same digest on the clipboard, for any assistant, no install.

ATTR_CMN_MODTIME — size alone cannot say "delete this"; regenerable and untouched for a year can. It rides the bulk buffer the walk already fills: same attribute list, same syscall, no measurable cost (base 1,38–1,43 s vs 1,23–1,47 s on 198 k files). Max-propagated by CAS on the same ancestor walk that sum-propagates sizes. Directory mtimes are excluded on purpose — they move on any entry add/remove. 0 means unknown, never 1970.

A read-only MCP server (--mcp) — overview, tree, top, types, find, aged, explain, cleanup_targets. No tool can delete anything, and a test fails if a tool name starts to look like a mutation. find is the one with no cheap shell equivalent: 19 node_modules, 6,67 GiB between them, nested matches counted once. A real session on $HOME — 190 GiB / 1,9 M files scanned once in 13,6 s — spent ~2 100 tokens across five calls.

Connected mode + verdicts — with the app open the session attaches over a Unix socket: no re-scan, and it sees the tree on screen. annotate paints safe / review / keep onto the treemap, the sunburst and the outline; focus moves the app to what is being discussed. A reason is mandatory — a colour is not an argument for deleting something — and it shows on hover. The outline switches itself to marked-only on the session's first mark, and one toolbar toggle controls it after that.

A skill (Packaging/skills/space) carrying the procedure and, in references/directories.md, what a model cannot infer from bytes and dates: which caches regenerate, which only look like caches and hold state you cannot get back, what is never safe. Setup is a ✦ button in the toolbar showing exactly what it will write, first.

Decisions worth reviewing

  • Verdict colour replaces the hue outright rather than blending. At any blend below 1 the type hue keeps contributing, so the same verdict rendered a different colour depending on what it sat on — review orange over a green subtree came out olive. Only brightness survives, which is what carried relative size.
  • Verdicts are stored by path, not by node. SPEC-02 invalidate rebuilds subtrees as fresh objects, and a verdict silently migrating to the wrong node is worse than losing it.
  • Registration goes through claude mcp add -s user, never by editing ~/.claude.json.
  • The access caveat is gated on errors > 0, not on the FDA permission: crying wolf on a clean scoped scan teaches a model to ignore the warning on the scan where it matters.

Corrected against reality

Three things the spec assumed turned out to be false, and are corrected in the spec rather than worked around:

  1. An exact per-subtree extension rollup cannot exist — no per-directory extension table survives a scan, because collapsing files into their parent is the memory design. Reconstructed from dominant types instead and labelled an estimate everywhere. Validated against the exact table on a real 35 GiB tree: top-10 overlap 10/10.
  2. Full Disk Access is not granted to the app, so whether a grant would follow a binary spawned by Claude Code stays open. Detached mode is no blinder than the GUI today; what it mandates is reporting the denial.
  3. The TCC risk was measured before any protocol was written, as the spec demanded.

Fixed from real use

  • The accept loop served each client inline, so one session held the listener for its whole lifetime and a second silently fell back to a full re-scan. One thread per connection now; verified with two concurrent relays.
  • A socket file outlives its listener. fileExists() said yes, connect() said ECONNREFUSED, and the relay reported "the app isn't running" — so a session told the user their open app was closed. The two cases are now distinguished and the bridge re-binds an unreachable socket on every scan completion.
  • The toolbar's .lineLimit(1) was inherited by the assistant popover and truncated every explanation to "…".
  • The outline did not recolour when a mark landed: the row's other inputs were unchanged, so SwiftUI diffed the view as identical. The verdict is now a row input.

Verification

195 tests green. Beyond unit coverage, each phase was live-verified: menu path driven through System Events; mtime cross-checked against find -exec stat on three real subtrees (180 / 313 / 529 days → cold:6mo / 10mo / 17mo, exact); the verdict painting proved by A/B rather than by eye — mean map colour (107, 158, 115) with verdicts, a +51 green bias, against (117, 120, 116) after clearing.

Not done

Phase 3 attaches to whatever scope the app happens to show, and the socket only opens when a scan finishes — a session started during a long scan goes detached for its whole life. Both are noted in the spec.

rducom added 10 commits August 16, 2026 17:43
A 3 M-file tree cannot go into a chat context, and every obvious truncation
lies about the disk: a depth cap buries the interesting folder, a top-N cap
drops the context that made it interesting. TreeDigest spends a hard line
budget greedily, largest-subtree-first — the same ranking a human applies by
hand, with lines instead of pixels as the scarce resource.

- TreeDigest: greedy max-heap expansion under an exact line cap, single-child
  chain collapsing (Users/rducom prints as one line), own-files block ranked
  against sub-folders, remainder rollup, cache:<id> / sparse / ~.ext
  annotations. Pure over FSNode + scalars, so the GUI and the future headless
  MCP server render byte-identical payloads.
- Leaves omit their own-files line: the folder's size line already states
  those bytes, and leaves are the most numerous node kind.
- Folder names are sanitised and framed as data — an unpacked archive can
  carry a directory named to read as an instruction.
- ⌘⇧C copies the briefing for the current zoom root; scan-wide counters and
  the file-type table are withheld under a zoom root rather than shown against
  the wrong denominator.
- --briefing <path> [max-nodes] prints the same text, so the digest is
  observable on a real disk without driving the GUI.

35,5 GiB / 198 k files at maxNodes 200 → 199 tree lines, ~3 k tokens.
17 new tests, full suite 141 green, menu path live-verified.
Size alone cannot say "delete this". "Regenerable AND untouched for a year"
can, and that needs a timestamp the scanner never asked for.

- ATTR_CMN_MODTIME rides the bulk buffer the walk already fills: same
  attribute list, same syscall. Parsed between OBJTYPE and FLAGS as a whole
  timespec — the position is the exposure, since a wrong offset misaligns
  every attribute after it (ScannerGoldenTests is the net).
- FSNode.newestMTime is max-propagated by CAS on the same ancestor walk that
  sum-propagates sizes. Directory mtimes are excluded on purpose: they move on
  any entry add or remove, which would make nearly every folder read as warm.
- 0 means unknown, never 1970. Streamed VM/SSH scans carry no timestamps, and
  reporting "56 years" for a missing attribute is worse than saying nothing.
- cold:8mo / cold:3y in the digest, inherited rather than repeated: a child is
  always at least as cold as its parent, so re-stating the bucket costs a
  token and says nothing. An older child still earns its own mark.
- TreeQuery.approximateTypes: the exact per-subtree rollup the spec planned
  cannot exist — no per-directory extension table survives a scan, because
  collapsing files into their parent IS the memory design. Reconstructed from
  dominant types instead, labelled an estimate everywhere it is shown.

Cost: none measurable (base 1,38-1,43 s vs 1,23-1,47 s on 198 k files).
Accuracy cross-checked against find/stat on three real subtrees: 180/313/529
days → cold:6mo/10mo/17mo, exact. Estimate validated against the exact table:
top-10 overlap 10/10.

12 new tests, full suite 153 green.
claude -p is API-token-only, so the app cannot spawn a session. The flow
inverts: the user starts the session and it calls the app.

TCC was measured before a line of protocol was written, as the spec demanded,
and it did not go as assumed. Full Disk Access turns out not to be granted to
SpaceMatters at all here — the bundle is denied on ~/Library/Safari even when
LaunchServices launches it — so whether a grant would follow a spawned binary
stays open. Moot for shipping: the user already runs the GUI without it. What
it mandates is reporting the denial, and the app owns a better detector than
the ratio heuristic the spec proposed: FullDiskAccess.isGranted.

- JSONRPC: newline-delimited JSON-RPC 2.0 on stdio, no dependency. stdout is
  the wire, so diagnostics go to stderr and a malformed line is skipped rather
  than ending the loop.
- MCPServer: initialize/tools/list/tools/call/ping, eight read-only tools,
  lazy scan held for the process lifetime. Tool failures return isError
  results, not protocol errors, so the model can correct itself.
- No deletion tool exists, and a test asserts no tool name reads as a
  mutation. The read-only boundary is why this is installable without a
  security conversation.
- The access caveat is two-tier: the full paragraph on overview, one line
  elsewhere. Seventy words on twenty calls is the waste this design avoids.
- TreeQuery gains the fence (a path outside the scan fails, never wanders onto
  the real filesystem), find (nested matches counted once), aged (outermost
  cold folder only), top, and size/duration parsing.
- --mcp defaults to $HOME, not /: that is where the actionable bytes are.

Real session on $HOME: 190 GiB / 1.9 M files scanned in 13.6 s once, then five
tool calls totalling ~2 100 tokens against the spec's 15 k budget. find
produced the finding it exists for: 19 node_modules, 6,67 GiB, several cold.

22 new tests, full suite 172 green.
The payoff phase: the scanner measures, the model classifies, and the map is
where they meet. A verdict recolours the treemap and the sunburst, so a
conclusion lands where the user already is instead of only in a transcript
they have to read next to it.

- MCPSocketServer listens on ~/Library/Application Support/SpaceMatters/
  mcp.sock (0600), opened only once a scan has finished so an attaching
  session always finds a tree. MCPRelay makes --mcp try that socket first and
  fall back to scanning standalone. One config line works either way, which is
  the only way an MCP entry in a config file can be reliable.
- The tools were written once: MCPScanSource abstracts "a scan" and
  respond(to:) returns the response object, so the two transports differ only
  in where that object goes. annotate/focus are advertised only when an app is
  attached — offering them against a standalone scan would earn the model an
  error per call.
- The socket thread reads the tree directly; only the handle is taken on the
  main actor. FSNode's atomics and gTreeLock exist precisely so a reader
  outside the UI is safe. Mutations hop back to main.
- Verdicts are stored by path, not by node: SPEC-02 invalidate rebuilds
  subtrees as fresh objects, and a verdict silently migrating to the wrong node
  is worse than losing it. Re-resolved on every tree bump, inherited by
  descendants so marking a folder paints its region.
- The tint blends after the palette LUT in both renderers, so a verdict never
  poisons the cached type colours, and rides the existing highlight repack — a
  recolour, not a relayout.
- reason is mandatory. A colour on the map is not an argument for deleting
  something, so the sentence behind it travels with it and shows on hover.
- Fixed a false alarm the smoke test exposed: the access caveat fired on scans
  where nothing had been denied, because it was gated on the FDA permission
  rather than on what happened. Crying wolf there teaches a model to ignore the
  warning on the scan where it matters.

Live-verified: session attached, ten tools listed, three annotates accepted, a
bad path refused, focus selected the folder. Proof by A/B rather than by eye —
mean map colour (107,158,115) with verdicts, a +51 green bias, against
(117,120,116) after clearing, +3. Socket removed on quit.

9 new tests, full suite 181 green.
The MCP server is the data; the skill is the method. Packaging/skills/space
carries the procedure (overview first, find before drilling, aged on every
candidate, explain before proposing) and — in references/directories.md — what
a model cannot infer from bytes and dates: which caches regenerate, which only
look like caches and hold state you cannot get back (IndexedDB, Local Storage,
browser profiles), what is never safe (photo libraries, .git, Time Machine
snapshots), and which sizes are sparse and therefore lie.

Setup lives in the analysis, not in a dialog. The first cut was an NSAlert and
it was too intrusive: handing a scan to an assistant is an option, not a step
in using the app. It is now a sparkles icon in the results toolbar beside the
reconciliation "?", opening a small popover — copy-briefing on top, then the
Claude Code state, with the exact lines it will write and run shown before
either button is pressed. sparkles rather than Claude's own mark: naming the
tool is fine, shipping a third party's logo is not.

- Registration goes through `claude mcp add -s user`, never by editing
  ~/.claude.json — that file is the CLI's business and a GUI app rewriting it
  is how someone loses their MCP setup. The CLI defaults to `local` scope,
  which is per-directory and wrong for a disk analyser.
- The CLI is looked for at its install paths, not through the shell: a GUI app
  inherits a minimal PATH. Not found → the skill still installs and the command
  is shown to copy.
- Failures report what the CLI actually said; "already exists" and a broken
  install need different responses.

Syntax verified against the real CLI with a throwaway entry, then removed.
Live-verified in the bundled app: the icon is in the toolbar, the popover
resolves the skill out of Contents/Resources and finds the CLI, and dismissing
it wrote nothing.

3 new tests, full suite 184 green.
Two problems found by using it.

The toolbar wraps its whole row in .lineLimit(1) so the bar stays one line
high (#12). A popover is a child of that view, so it inherited the clamp and
every explanation in the panel truncated to "…" — including the three-step
instructions, which lost steps 2 and 3 entirely. Undone locally, plus the
fixedSize the multi-line step text needed to stop being compressed, and an
explicit panel background: without one the treemap showed through the text.

And "Claude Code is connected" is a status, not an action — there was nothing
telling anyone what to do with it. The connected state now spells out the
three steps and offers the starter prompt to copy, phrased to name the server
so the session reaches for it instead of shelling out to du, and to ask for
the map marks so conclusions land where the user can see them.
health-checking the network to answer a local question

Reported as "c'est un échec": a session found no spacematters tools even
though the server was correctly registered and connected. It was: a session
builds its tool roster at startup, so one already open when the server was
added will never see it, and reports that in ways that read like a broken
install. The panel now says so and points at /mcp, which is the difference
between the feature working and the feature looking broken.

Also: the registration probe ran `claude mcp list`, which health-checks every
server the user has — Drive, Gmail, Calendar, datadog, linear — over the
network, spending seconds of spinner to answer a question about one local
entry. It now reads ~/.claude.json for the key, off the main actor since that
file carries session history and can run to megabytes. Registration still goes
through the CLI: writing is the half that must survive a format change.

And the guidance is one string literal, not a `+` concatenation — SwiftUI only
parses markdown out of literals, so the built-up version would have rendered
its asterisks and backticks verbatim.
…ting it

A real session reported "the SpaceMatters app isn't running" while the app was
open on screen, and re-scanned 190 GiB instead of reading the tree in front of
it. Three separate defects, all in the attach path.

1. The accept loop served each client inline, so one session held the listener
   for its whole lifetime. A second session queued behind it and, once the
   backlog filled, was refused outright — silently falling back to a full
   re-scan. Each connection now gets its own thread; verified with two
   concurrent relays, both attaching with all ten tools.

2. A socket *file* outlives the listener that owned it: a second app instance
   unlinks and re-binds the shared path, and an instance killed without
   applicationWillTerminate leaves the path pointing at a dead inode.
   fileExists() says yes and connect() says ECONNREFUSED. The bridge now
   probes reachability on every scan completion and re-binds when the answer
   is no — one connect() per scan to rule out the failure that looks fine from
   the inside.

3. The relay treated "refused" as "not running". A model then sees no annotate
   tool, concludes the app is closed, and tells the user so — sending them to
   look in entirely the wrong place. The two cases are now distinguished, and
   overview carries the reason, including an explicit instruction not to claim
   the app is closed when its socket merely went stale.

Also: accept() errors no longer spin at 100% CPU on a listener that will never
recover, and the backlog went 4 → 16.

5 new tests, including the file-without-a-listener case that started this.
Full suite 189 green.
…ine too

Reported from real use: "worth reviewing" was rendering green, and nothing in
the left outline said which folders had been marked.

The colour bug was in the blend. Mixing 60% toward the tint left 40% of the
type hue contributing, so the same verdict rendered a different colour
depending on what it sat on — review orange over a green subtree came out
olive, and read as neither warning nor safe. A verdict has to look identical
everywhere or it means nothing, so the hue is now replaced outright and only
the brightness of the original survives, which is what carried relative size.
review also moved from amber to a saturated orange: this app's palette is full
of golds and an amber verdict was indistinguishable from an unmarked tile.

keep moved from blue to slate for the same class of reason — the outline draws
every unmarked folder in the accent, which is blue.

In the outline, a verdict now recolours the folder icon (scanned vertically, a
tinted icon reads where a trailing chip does not) and the reason is the row's
tooltip. The verdict is passed in as a row input rather than read from the
controller inside the view: the row's other inputs do not change when a mark
lands, so SwiftUI diffed the view as identical and skipped the redraw — the
treemap recoloured and the outline did not.

And a filter, since a mark eight levels down is invisible in a 335 k-folder
tree: one toolbar toggle prunes the outline to the marked folders plus the
ancestors needed to place them, and nothing below a mark — its contents are
not the finding, it is. The button only exists once something is marked, and
the filter turns itself off when the last verdict is cleared: a filter that
can only produce an empty list is worse than no filter.

3 new tests. Full suite 192 green.
A session marks as it goes, and one folder highlighted eight levels down a
335 k-folder tree is a finding nobody sees. The first annotate of a run now
switches the outline to marked-only, so the analysis is visible while it
happens and each new mark appears in a short list instead of vanishing into
the tree.

Once only, and latched: a user who turns the filter off mid-analysis will not
have it turned back on by the session's next mark. Clearing the verdicts
re-arms it for the next run.

Also fixes a neighbouring lie: verdictCount counted stored paths rather than
paths that resolve against the current tree, so after scanning a different
disk the filter button stayed visible while having nothing to show. It now
counts what resolved. The paths themselves stay — durable, so the marks come
back if the user returns to that disk.

3 new tests. Full suite 195 green.
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Aug 16, 2026
rducom added 4 commits August 16, 2026 19:55
…s ends

Verdicts survived everything, so re-picking a disk from the splash brought
back colours from a previous session's conclusions — asserting about a fresh
scan things nobody had said about it.

They belong to the analysis that produced them:

- A rescan of the same root keeps them. The user is refreshing what they are
  working on, often right after deleting something, and the marks are the work.
- Going Home ends the analysis. Re-picking the same disk starts a new one.
- Scanning a different root drops them too — switching folders without going
  through the splash is still a different analysis.

The discard path touches no nodes, so it is safe to call from startBackend
while the tree is being torn down and replaced.

3 new tests. Full suite 198 green.
CI caught this as a timing failure in IncrementalRefreshTests; the cause was
worse than the symptom. ScanController started the MCP socket server on every
scan completion — including in the test process. So `swift test` unlinked a
running app's socket, bound its own, and exited, leaving the app holding a
listener nobody could reach.

That is precisely the stale-socket failure the relay learned to detect two
commits ago, and it explains where the one that broke a real session came
from: I ran the suite dozens of times with the app open.

It also explains the CI failure. Every scan completion bound a socket, started
a thread, and (since the reachability probe) made a blocking connect() —
enough added latency in the refresh path to turn a coalesced burst into two
sub-scans on a slower machine.

The socket path is a machine-wide rendezvous, so ownership is now explicit:
the app calls MCPBridge.enable() at launch and nothing else does. A headless
scan, a --briefing run and the test suite all leave it alone. This also undoes
a layering violation — the controller had no business starting a server.

Verified: the app's socket keeps its creation timestamp across a full
`swift test` and stays reachable throughout. Locked by a test asserting the
bridge never runs in the test process.

Plus the two vertical-whitespace lint violations my edits left behind.

Full suite 199 green.
…rest

"L'icône IA n'est pas très compréhensible" — a lone glyph cannot answer "what
does this do", and the bar already speaks in words (Home, Rescan). It now says
"Ask Claude" and sits before the search field, where the eye lands, instead of
among the trailing glyphs where preferences live. It is a feature to discover,
not a setting to adjust.

The word Claude, not the mark: naming the tool an app integrates with is
ordinary; shipping someone else's logo in an unaffiliated app implies an
endorsement that does not exist. The name is also simply clearer than any
pictogram would be.

And tooltips for the controls that had none — Home, Stop, Rescan, the search
field and its clear button, the disk-changed action, and both Full Disk Access
buttons. Each says what the control does and what it costs, not what it is
called: Home mentions that it discards the analysis, Rescan that it keeps the
assistant's marks, Relaunch why a permission granted mid-session needs one.
NSToolTipManager waits over a second by default. That is right for a hint you
already know the answer to, and wrong for a toolbar where the tooltip *is* the
documentation — the delay is long enough that most people move on before it
appears, which makes the labels I just wrote nearly pointless.

300 ms, and put in the registration domain — the lowest-priority one — so a
user who has set NSInitialToolTipDelay themselves, per-app or globally, still
wins.

Measured rather than assumed: synthetic hover over the toolbar, screenshot at
450 ms, tooltip present.
@rducom rducom changed the title SPEC-14 — LLM analysis: token-budgeted digests, an MCP server, and verdicts painted back onto the map LLM analysis: token-budgeted digests, an MCP server, and verdicts painted back onto the map Aug 16, 2026
CI failed twice in a row on different timing-sensitive tests. The second was
mine, and reproducible locally at roughly one run in twenty — but only inside
the full parallel suite, never alone (12/12 clean) and never in isolation
(2500/2500 correct, including under heavy fd churn).

Diagnostics caught it: distinct descriptors, the path still a socket, and
connect() returning 0 anyway. So the test was asserting a kernel guarantee
about the instant after close() — not a behaviour of my code, and not a state
production ever observes. There the owner process is either alive, or gone
with its entire descriptor table.

So the test now builds the real thing: `nc -lU` binds the path, the connection
is proved to succeed, the process is SIGKILLed and *reaped* with
waitUntilExit(), and only then is the connection proved to be refused. No
window to race, and it covers both directions — live owner accepts, dead owner
refused — which is exactly what the relay has to tell apart. The narrower
live-listener test folded into it.

15 consecutive full-suite runs, no failures. 198 tests.
@rducom
rducom merged commit 8aa8fcd into main Aug 16, 2026
6 checks passed
@rducom
rducom deleted the feat/llm-briefing branch August 16, 2026 18:56
@rducom rducom added enhancement New feature or request and removed documentation Improvements or additions to documentation labels Aug 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant