Architecture¶
esbi-cli is a modular monolith (one Python package, one process) organised as ports and adapters (hexagonal): the logic that decides what a note says sits in the middle, and everything that touches the outside world (a model, the network, a mailbox, the Keychain, launchd, the disk) sits behind a small seam that tests replace with a fake. This page says what that means in this code, and why; for the data flow and the file layout see Internals.
The one principle that shapes everything¶
The model never touches files and never needs tool calling. The worker retrieves candidate pages, asks the model for a JSON edit plan, validates it against a schema, checks what it claims against the source, and only then writes. So the model is an adapter that turns text into validated data, and nothing else. That is what lets small local models work: they only have to produce JSON, not drive tools.
Core, ports, adapters¶
| What it is | Where | |
|---|---|---|
| Core | Ingest (read, plan, apply), consolidated summaries, the retrieval and ask logic, the queue and run loop, the daily index, lint, privacy rules, the machine-readable output, signal handling | ingest/ (including consolidate.py), ask/, run.py, queue.py, report/, lint/, privacy.py, vault.py, index.py, jsonout.py, interrupts.py |
| Ports | The few things the core needs from outside, written as a small protocol or as a callable passed in | LLM (complete_json, tokens_used, sends_text_out), MailClient, the extractor callable, ingest_fn and on_event in run_queue, the launchctl runner, the DNS resolver, the Keychain backend |
| Adapters | The real implementations | llm/adapter.py (Ollama, LM Studio, OpenAI-compatible, Anthropic, the claude tool, the codex tool, embeddings, OCR), ocr_models.py (which vision models exist, what each needs, and whether the local Ollama has one), mail/imap.py, extract/ (web, PDF, image, clip), netguard.py, mail/credentials.py (Keychain, or the system keyring off macOS), schedule.py (launchd), gitops.py |
The dependency rule: the core depends on the ports (today the LLM protocol and its errors live in llm/adapter.py, next to the adapters) and never builds or imports a concrete provider. The CLI (cli.py) is the composition root that builds the adapters from the config (make_llm, make_embedder, make_ocr, make_mail_client) and hands them in. One known leak: the ingest pipeline asks mail/fetch.py whether a file came from email (is_mail_file, called is_mail_pdf before images were kept too), because the privacy rules need that fact. The vault is the one place the core touches the disk directly, because the vault is the product: plain Markdown files you own.
The ports are plain Python protocols and injected callables, not abstract base classes: nothing in this project needs a class hierarchy to express "a thing that returns JSON for a prompt".
Modules that carry a rule¶
Four modules exist for one rule each, and the rest of the code goes through them.
interrupts.py: a signal stops a run only where that is safe.SIGINTandSIGTERMare turned into an exception in the main thread, but only insidehandling(), and a signal raises (Interrupted) only inside aninterruptible()block. Anything that writes more than one file wraps the writing indeferred(): a signal that arrives there waits until the block ends, so a source note, the concept pages it touched and the log line are written whole or not at all.run_queuemakes the call to the modelinterruptible()and puts the item back in the queue without counting an attempt;sb ingestandsb reingestuseexit_when_interrupted(), which exits with128plus the signal number.Interruptedderives fromBaseException, likeKeyboardInterrupt, so anexcept Exceptionmeant for a failed source cannot swallow it.jsonout.py: the one place that knows a machine-readable shape. Commands with--jsoncall a function in it (jsonout.status(...),jsonout.event(...),jsonout.fail(...)) and never build a dict themselves, so the contract can be read, and tested, in one file. It also owns thecontractnumber (additive change: same number; breaking change: bump) andonly_events(), which drops the human lines from stdout duringsb run --jsonso stdout carries events and nothing else.run_queue(on_event=...)is its port: the loop reportsstarted,source_started,source_failedthrough a callback and the CLI adds the rest, so the loop has no idea what JSON is.ingest/consolidate.py: summaries from evidence, checked before they are written. It builds the## Summaryof a concept page from the page's ownFromsections only (never the sources), checks the answer against those sections (size, markup, language, nothing stated that they do not say), retries once, and otherwise writes a note towiki/review/and leaves the page as it was. It is also where the privacy rule applies: a page that email helped write is summarised only by a model that runs on this machine.sb consolidateand the automatic pass (consolidate_due, capped by[run].max_consolidations_per_run) both call it.- The
mail-linkorigin andingest(from_email=...). A link found in a mail is queued with the originmail-link;runpassesfrom_email=Trueto the ingest of any queue item with that origin, and the pipeline then marks the sourcekind: email, so everything downstream (the private model, the hidden-from-cloud pages) treats a page reached through a mail as email. The same reason makes.esbi/mail-pdfs.txthold the hash of every attachment saved from a mail, images included.
Why every external effect is behind a seam¶
Because tests never touch the network, a real model, the Keychain or launchd. They fake at the seams and nowhere else: a FakeLLM that returns queued JSON, a fake mail client, a fake launchctl, a resolver that returns chosen addresses, a fake Keychain backend. A new adapter or a new effect gets its seam first and its fake second; code that reaches the outside world without a seam cannot be tested and does not get merged.
Patterns that earn their place¶
- Adapter: one per model provider and per source type, chosen by a prefix in the config (
ollama/…,claude-cli/…). - Facade:
Vaulthides the layout of the folders and the page index behind a few methods. - Repository:
Queue(SQLite) is the only owner of the work queue state. - Composition root:
cli.pywires the parts; no module builds its own collaborators.
Patterns are not added for their own sake: a layer or an interface that has one implementation and no seam to fake is not worth having.
Rules the design holds to¶
- KISS and YAGNI: the smallest change that works; a setting needs a reason to exist.
- Separation of concerns: extracting text, planning, applying and reporting are separate steps with separate tests.
- DRY, without forcing it: one source of truth for a fact (for example the language labels live in
lang.py; the privacy decision lives inprivacy.py). - Safe by default: email never reaches a model that sends text away; fetching goes through
netguard.safe_getonly; secrets live in the Keychain.
Gotchas for developers¶
- iCloud and
.venv. In an iCloud-synced folder (~/Documents), macOS flags files inside dot-folders as hidden and Python 3.13 then skips hidden.pthfiles, soimport esbi_clifails. Keep the environment outside, withUV_PROJECT_ENVIRONMENT. - One writer at a time.
RunLockis anflock, so a crashed run can never leave a stuck lock. The order in a run is: lock, then the "is it due" check, then build the model. - One way to fetch. All URL fetching goes through
netguard.safe_get(public addresses only, every redirect hop checked, the connection pinned to the address that was checked, size and time capped). Do not callhttpxdirectly. - The nightly time is in the plist. The launchd agent is static: changing
[run].nightly_timeneedssb schedule installagain (sb doctorwarns on a mismatch). Launchd agents cannot read~/Documentsuntil macOS privacy allows that binary, which only the user can grant. - The daily note is a view. It is regenerated from state (source notes and the queue); the only input channel is the user's ticked checkboxes, read before each regeneration. Never make the worker depend on text it wrote earlier in that note.
Home.mdis partly the user's: only the block between theesbimarkers is rewritten. - State lives in the vault.
.esbi/holds the queue, the page index and logs. Anything that changes the queue table needs a migration (Queue.__init__migrates old databases), because the real database lives in users' vaults. - Injected defaults bind at definition time. A function that takes an injectable runner as a default argument (
launchctl=run_launchctl) must be given it explicitly at the call site, or a test's patch is silently bypassed and the test talks to the real system. - A signal raises only where it is safe. Handlers are installed only inside
interrupts.handling()(and nothing is installed off the main thread). New code that writes several files must do it insidedeferred(), or a Ctrl-C can leave half a note; new code that waits on a model or the network can be wrapped ininterruptible(). Atry/except Exceptionis not enough to clean up after an interruption, sinceInterruptedis aBaseException: usefinally, or letrun_queuerelease the item. jsonout.event(kind, /, **fields)takes its kind positionally. Astepevent has a field calledname, and a keyword callednamewould collide with a parameter of the same name. Keep the/, and never name a parameter after a field an event can carry.- Every
--jsoncommand goes throughjsonout. A new field is additive and keeps the contract number; a renamed or removed one is a breaking change that bumps it (andtests/test_json_output.pyholds the golden copy of each shape, checked strictly: a key that a command emits and the golden shape does not list fails the test, so a new field has to be written down in the shape, and in JSON output, in the same change). Printing to stdout from a command that has--jsonbreaks the "stdout is JSON only" rule: use stderr for anything human. - The proxy setting is per command.
netguard.use_environment_proxyis module state thatload_configsets from[network]andreset_loaded()puts back toFalseat the start of each invocation, so the menu or a test that runs several commands in one process cannot carry a proxy setting from one to the next. - A clip's
doc.textis the stripped text.extract/clip.pyremoves known page chrome (extract/noise.py) before it builds theExtractedDoc, so everything after extraction (the content hash, theraw/snapshot, the chunks, quote checks) sees the stripped text, never the clip as it arrived; that original lives only inraw/inbox/.ExtractedDoc.stripped_linescarries the count into the note's frontmatter. A rule that works on the original clip text has to readraw/inbox/, notdoc.text. - PDFs: PDFium is not thread-safe, and its y axis points up. The PDF reader (
extract/pdf.py,pdf_text.py,pdf_figures.py) opens a document, reads it and closes it in one call, in one thread; PDFium (pypdfium2's engine) does not support concurrent use, so never read two PDFs from parallel threads. Coordinates are PDF points with the origin at the bottom left (Rectis left, bottom, right, top), so a line lower on the page has a smallerbaseline, and "below" means a smaller y. PDFium gives characters with a font size and weight and nothing else: paragraphs, headings and table rows are rebuilt bypdf_text.py, and a scan has no characters at all (OCR takes over below 200 characters). - PDF tests read committed files. The fixtures are in
tests/fixtures/pdf/(a scan, a locked file, a rotated page, charts, drawn figures, a figure repeated 100 times, a huge image...), written once with a throwaway script, so no PDF library is needed to build them and the reader is tested on files it did not make. Add a case by committing a small PDF, not by generating one in the test. - Heading names are data. Note headings come from the language catalogue (
lang.py), and readers recognise the headings of every catalogued language. Do not compare against a hard-coded heading.