This file is long and the operational rules sit far down. These are the misses that have cost the most review cycles. Apply them BEFORE writing, not after:
- Type external objects precisely. Never
getattr(obj, "field", default)on a field the object's type guarantees. Annotate the parameter with the real type and read the attribute directly.getattr-with-default is only for attributes that genuinely may not exist (dynamic reflection). - Don't inherit the surrounding code's pattern without checking it against
these rules. Nearby code may predate a rule. Copying an adjacent
getattr/isinstance(self.app, ...)/: strshape just propagates the smell into your diff. - One-line docstrings by default. Describe what the code IS, not how it got there. No "previously", "now also", "some versions", "kept for".
- The smell gate runs in
make lint.scripts/check_style_rules.pyfails on any NEW Code-Smell Trigger (see that section) on lines your diff adds vsmain. Runmake lintbefore committing; a genuinely dynamic case opts out inline with# style-check: allow-smellplus a written reason.
The full rules follow; this block is the part that gets skipped under time pressure, so it leads.
The whole local AI stack in one executable: a model manager plus a search engine you can talk to. Python 3.11+, pluggable LLM providers (a managed local llama-server fleet by default, Ollama/OpenAI via litellm), LanceDB for vectors. Managed with uv. Task tracking with beads (bd). Learned behaviors with floop.
Framing: Lead with "the whole local AI stack in one executable". Two pillars, always both: it runs and manages the models (its own model manager, no Ollama or LM Studio needed, works with both), and it is a search engine you can talk to (cited answers). "RAG" and "local-first" are properties, not the identity. lilbee is both a standalone multipurpose tool AND an AI agent backend.
bd ready # See what's ready to work on
bd update <id> -s in_progress # Claim a task
bd close <id> # Mark done
bd list # All issuesEvery code change MUST be tracked as a beads task. Create tasks before starting work, close them when done.
uv sync # Install dependencies
make check # Run all checks: lint, format, typecheck, test (same as CI)
make test # Tests with coverage
make lint # Ruff linting
make typecheck # Mypy
make format # Auto-format code
uv run lilbee init # Create .lilbee/ in current dir (per-project DB)
uv run lilbee sync # Sync documents to vector DB
uv run lilbee ask "question"
uv run lilbee chat
uv run lilbee status
uv run lilbee rebuildsrc/lilbee/— All source code- Per-project DB:
lilbee initcreates.lilbee/in cwd (like.git/) - Data location precedence:
--data-dir/LILBEE_DATA>.lilbee/(walk up from cwd) > global platform default- macOS:
~/Library/Application Support/lilbee/ - Linux:
~/.local/share/lilbee/ - Windows:
%LOCALAPPDATA%/lilbee/
- macOS:
- All settings configurable via
LILBEE_*env vars or CLI flags - Auto-sync: documents/ is source of truth, data/ is rebuilt from it
All settings override via environment variables:
LILBEE_DATA— data directory pathLILBEE_CHAT_MODEL— LLM model (default:qwen3:8b)LILBEE_EMBEDDING_MODEL— embedding model (default:nomic-embed-text)LILBEE_EMBEDDING_DIM— embedding dimensions (default:768)LILBEE_CHUNK_SIZE— tokens per chunk (default:512)LILBEE_CHUNK_OVERLAP— overlap tokens (default:100)LILBEE_TOP_K— retrieval result count (default:5)LILBEE_ANN_INDEX_THRESHOLD— chunk count at/above which sync builds an approximate (ANN) vector index for fast search at scale (default:50000,0= always exact flat search)LILBEE_MAX_DISTANCE— cosine distance threshold, 0-1 (default:0.9). Higher = more results, lower = stricter filteringLILBEE_ADAPTIVE_THRESHOLD— enable adaptive threshold widening (default:false). When true, widens distance threshold if too few results foundLILBEE_AUTO_SYNC— run a sync beforelilbee ask(default:true); set tofalseon large static corpora to skip the pre-answer re-hashLILBEE_VISION_MODEL— vision OCR model (default: none)LILBEE_RERANKER_TYPE— reranker serving mode:auto(default),cross_encoder, orllm.LILBEE_RERANKER_PROMPT— relevance prompt for LLM rerankers (blank uses the built-in template).LILBEE_OCR_TIMEOUT— per-page vision OCR timeout in seconds (default:300,0= no limit)LILBEE_TESSERACT_TIMEOUT: wall-clock timeout in seconds for the Tesseract OCR fallback (default:60,0= no limit). Only runs when no vision model is available.LILBEE_SSE_HEARTBEAT_INTERVAL— seconds between SSE heartbeat events when the producer queue is idle (default:30). Set to0to disable.LILBEE_LLM_PROVIDER— provider:auto(default; runs models locally on the managedllama-serverfleet) orremote(external OpenAI-compatible endpoint; requirespip install lilbee[litellm]).LILBEE_LLAMA_SERVER_PATH: path to allama-serverbinary; when set it always wins, even over the bundled wheel (default: the bundled wheel's binary, else PATH)LILBEE_OLLAMA_BASE_URL— Ollama server URL (blank useshttp://localhost:11434)LILBEE_LM_STUDIO_BASE_URL— LM Studio server URL (blank useshttp://localhost:1234/v1)LILBEE_DIVERSITY_MAX_PER_SOURCE— max chunks per source in results (default:3)LILBEE_MMR_LAMBDA— MMR relevance/diversity tradeoff, 0-1 (default:0.5)LILBEE_CANDIDATE_MULTIPLIER— extra candidates for MMR reranking (default:3)LILBEE_QUERY_EXPANSION_COUNT— LLM-generated query variants, 0=disabled (default:3)LILBEE_ADAPTIVE_THRESHOLD_STEP— distance threshold widening step (default:0.2). Only used when adaptive_threshold is true.LILBEE_LOG_LEVEL— logging level: DEBUG, INFO, WARNING, ERROR (default:WARNING)LILBEE_NO_SPLASH— set to any non-empty value to suppress the startup bee animation
CLI also accepts --model / -m for chat model, --data-dir / -d, --ocr-timeout, and --log-level.
- 100% test coverage required — enforced by
pytest-covwithfail_under = 100 - Write tests BEFORE or alongside implementation, not after
- Every public function MUST have at least one test
- Every test must assert something meaningful — no zero-assertion coverage padding
- Mock all external dependencies (LLM providers, filesystem I/O where needed) — tests must run without a live server
- Use
pytest.mark.skipifonly for integration tests that genuinely require live services - Use
tmp_pathfixtures for filesystem tests — never write to real paths - Test edge cases and error paths, not just the happy path
- Tests are documentation — name them descriptively (
test_add_nonexistent_fails, nottest_add_3) - Never run tests in background — user must see output
- Kill tests that hang past 2-3 minutes — investigate the hang, don't wait
- Integration tests must test real multi-system flows, not mocked unit test duplicates
- Target only affected test files during development; full suite once at the end
- Don't Repeat Yourself — extract shared logic into helpers when duplicated
- Single Responsibility — each function does one thing well
- Small functions — max ~20 lines, max 2 levels of nesting
- Low cyclomatic complexity — extract helpers when branches exceed 3
- Use maps for classification/dispatch — if-chains that map values to categories belong in a dict, not repeated
if/elifblocks - Compose small functions rather than writing monolithic ones
- If you need to copy-paste code, refactor into a shared function instead
- No LangChain — provider abstraction (no raw SDK calls)
- Type hints on all public functions
- Dataclasses for structured return types (not raw dicts)
- Named constants for magic numbers AND magic strings — never use raw string literals when a constant exists. Grep globally for the raw value of any new constant to ensure no duplicates remain.
- Closed value sets use
StrEnum/IntEnum, not parallel module constants. Two or more named values for the same field (status, mode, role, kind, event tag) is a sign of an enum. Defineclass FooStatus(StrEnum)so the field type can beFooStatus(Pydantic validates, mypy narrows, IDEs autocomplete) and the variants live in one namespace. Examples already in repo:EventType,SseEvent,BatchStatus,WikiEntityMode,ChatMode,WireKind,ModelTask,BackendName,WorkerRole. A bare module-level constant is only correct when the value stands alone (sentinel likeCRAWL_TOTAL_UNKNOWN = -1) — not when it's one of a small fixed set. - String-typed parameters for closed sets are a smell. A
task: str,kind: str,role: str,event_type: strparameter signals the type system isn't carrying the constraint. Convert to the corresponding StrEnum at the boundary and let mypy enforce exhaustiveness on every dispatch site. HTTP / IPC boundaries decode the string into the enum (ModelTask(value)raisesValueErroron bad input), production code never sees the bare string. - Sealed unions dispatch on a
kinddiscriminator, not isinstance. When you have a small, closed set of dataclasses sharing a common shape, give them akind: Literal["foo"]field and dispatch onrow.kind == "foo"(ormatch row.kind). The Literal makes mypy enforce exhaustiveness; isinstance chains scattered across many call sites do not. - Polymorphic getattr-with-default is a contract leak.
getattr(row, "field", default)only makes sense when the field genuinely doesn't exist on some union members. If every member should expose the field, declare it. If not, the dispatch dict is mistyped: tighten the type so the helper only sees members that carry the field, and split per-variant helpers when needed. Thegetattrfallback hides what the type should already say. - No positional array indexes — use named constants or membership checks, not
array[0] - Descriptive variable names —
pending_segmentsnotcurrent,chunk_sizenotn - Logging with
logging.getLogger(__name__)— no bareexcept: pass - No hardcoded values — all configurable through
config.pywith env var overrides - Imports: stdlib first, then third-party, then local — no star imports. See Import Discipline below for the rules on function-local imports.
- Linting:
ruff check+ruff format(line length 100) - Type checking:
mypywith strict settings - No em dashes (—) — use periods or commas instead
- No divider comments (
# ------) for grouping — use modules or classes instead - Don't scatter functions between constants. Within a module (or a topical block in one), keep the module-level constants contiguous and the functions/classes contiguous. A
defwedged into the middle of a run ofFOO = .../BAR = ...lines breaks the eye's scan. A helper that formats or validates a small group of constants may follow that group directly (the constants, then their one helper), but don't interleave:A = ...,def f(): ...,B = ...,def g(): ...,C = ...is wrong; group it asA = ...,B = ...,C = ...,def f(): ...,def g(): .... Order by concern (all the "skip" constants, then the skip helpers; all the "doc table" constants, then the doc-table dataclass), not by when each was added. - Annotate
isinstanceguards with a comment explaining why the check is needed (e.g. untyped frontmatter, untyped SDK response). NEVER useisinstance(self.app, LilbeeApp)style host-narrowing in production: if a screen / widget needs LilbeeApp-specific access, declareapp: LilbeeApp # type: ignore[assignment]at the class scope (mypy narrows; runtime is unchanged). Tests host viatests/_lilbee_app_test_host.LilbeeAppHost(a LilbeeApp subclass with_test_skip_auto_init = True), neverApp[None]/class _PlainApp(App). - Use literal unicode chars (
★) not escapes (\u2605); extract to named constants - No filterwarnings without explicit user approval; fix warnings at the source
- Docstrings describe what the code IS, not how it got there. Drop iteration-tracking phrasing like "Accepts X so callers don't have to import Y", "Used by Z as the on_complete callback", "Moved here because…", "Replaces the previous getattr fallback". That information is the change description, not the function's contract. Default to one-line docstrings; add detail only for non-obvious invariants (Big-O bounds, ordering guarantees, error semantics). The
grep -rnE "so callers don'?t|used by .* as the|replaces the|moved here because|previously" src/sweep should return zero new hits in a diff. - Inline comments state what, not a story. A comment names a non-obvious invariant or constraint in one line. Cut the narration and justification: not "Best-effort convenience: a rejected swap must never be fatal, log it and keep the ref so the app boots", just "A rejected swap must not be fatal at startup". Not "the task is reclassified so the pick agrees with the validator that runs on the subsequent swap; a wrong-task model would be rejected downstream"; just "tasks are reclassified so the pick matches the role validator". Multi-sentence comment blocks that walk through the reasoning behind the code are the smell. Default to no comment; let names carry the meaning.
- Don't fight the type system with
# type: ignorefor owned attributes. A# type: ignore[attr-defined]onself.app.task_barmeans mypy can't seetask_bar. Fix the source: declareapp: LilbeeAppon the host class, declaretask_bar: TaskBarControlleron LilbeeApp's__init__, and the ignore goes away. The ignore was the symptom; the missing declaration was the bug. - Adapter classes for SDK shape isolation. When you find yourself reading the same external library response with
getattr(chunk, "field", default)in three places, wrap the SDK shape once in a small_LibFooViewadapter at the import boundary. All callers consume the typed adapter, the SDK shape lives in one file, and SDK drift breaks the adapter (a sharp signal) instead of silently degrading every caller. @overloadfor stream / mode-narrowed returns. A method that returnsstr | Iterator[str]based on astream: boolflag is unhelpful: every callercast()s away the union. Add@overloadsignatures forLiteral[True]andLiteral[False]; the body still acceptsbool. Callers consumingstream=TruegetIterator[str],stream=Falsegetstr, and thecast()calls disappear from the call sites.- Module-level registries over
fn._marker = True. When a decorator marks functions for later lookup (e.g. read-only route handlers), keep the marker as a module-levelset[Callable]and provide anis_marked(fn)predicate. Thefn._marker = Truepattern requires# type: ignore[attr-defined]everywhere it's read and is invisible to grep. - Bound-method dispatch dicts beat
getattr(self, name)reflection. When mapping command names / event types to handler methods, build adict[str, Callable]in__init__from real bound methods (or a class-level registry decorator).getattr(self, handler_name)(args)reflection means mypy can't tell which methods exist, and a typo inhandler_namebecomes anAttributeErrorat runtime instead of a type error. - Encapsulate module globals on a class. Three or more module-level mutable variables sharing a concept (
_callback,_installed,_pending,_pending_level,_lock) form an implicit object. Promote toclass _ThingDispatcher: ...with named methods and a single module-level instance. Tests get a clean snapshot/restore seam; production code stops needingglobaldeclarations. - No test-aware branches in production. A production code path gated on
if isinstance(self.app, LilbeeApp),if hasattr(self.app, "task_bar"), orif not _is_test_env()is debt: it pollutes the production path with a fallback that exists only because tests mount differently. Fix tests to mount the production type (subclass with the heavy work skipped), and the production branch goes away entirely.
The package layers go bottom-up. A module may import from its own layer and any layer below; never above. New code must respect this.
| Layer | Packages | What lives here |
|---|---|---|
| 0 (foundation) | core/ |
Config singleton, settings I/O, security helpers, results. No upward imports. |
| 1 | catalog/ |
Featured catalog, HF ref helpers, downloads, shared domain types (ModelTask, ModelSource). |
| 2 | modelhub/, providers/, runtime/, data/ |
Model registry, lifecycle, providers, ingest pipeline. May import catalog + core. |
| 3 | retrieval/, wiki/, crawler/ |
Search, RAG, wiki generation, web crawl. May import any lower layer. |
| 4 (surfaces) | app/, cli/, server/, mcp_server.py |
Use-case orchestration and surfaces. May import anything below. |
Concrete rules:
- Domain types live in the lowest layer that uses them. If
ModelTaskis referenced bycatalog/andmodelhub/, it belongs incatalog/, notmodelhub/. Moving a type up (to a layer that imports it) is wrong; moving down (to the layer that owns it) restores one-way flow. - Side effects that cross layers go through callbacks, not upward imports. When a Layer-N module needs to trigger a Layer-N+1 effect (catalog/download wants modelhub to write a manifest; data/ingest wants wiki to re-cluster after a sync), expose a
Callableparameter (on_complete=,on_indexed=) and let the higher layer pass the function in. Layer-N stays unaware of Layer-N+1. - No re-export shims when moving symbols. When
ModelTaskmoves frommodelhub/models.pytocatalog/types.py, every importer updates in the same commit. Do not addfrom lilbee.catalog.types import ModelTaskback intomodelhub/models.pyto keep old paths working — that defers the cleanup forever and hides the layering fix. TYPE_CHECKINGis for circular annotations only, not runtime workarounds. ATYPE_CHECKINGimport that exists because runtime would cause a cycle is a sign the layering is wrong: fix the layer assignment instead of papering over with a string-typed annotation.- Mock targets follow the symbol. When a symbol moves between modules,
grep -rn '@patch("lilbee\.<old.path>"' tests/must return zero in the same commit. Stale@patchtargets silently no-op and let tests pass without exercising the real code.
- No mutable module-level globals — all config lives in the
Configdataclass singleton (from lilbee.core.config import cfg) - Never duplicate state across modules (e.g. no
store_mod.LANCEDB_DIRmirroringcfg.lancedb_dir) - Prefer dependency injection (pass values as parameters) over reading globals inside functions
- Access config via
cfg.attribute(late-bound), neverfrom lilbee.core.config import SOME_CONSTANT(early-bound copy) - No
getattr(self, "name", default)for own attributes. If_foois part of the class's state, declare it in__init__with an explicit type (self._foo: Foo | None = None) and access it asself._foo. Thegetattr-with-string-default pattern hides the contract, breaks type checking, and turns every typo into a silent fallback. Same rule forgetattr(self, "_flag", False)style flag checks: declare the flag in__init__. The pattern is allowed only when reflecting over genuinely dynamic attributes the class doesn't own (e.g. iterating dataclass fields).
Default: every import lives at module top, ordered stdlib, third-party, local. Function-local imports are the exception and must be justified by one of the three cases below. "Feels like it might be heavy" is not a justification.
Permitted reasons for a function-local import:
- Circular import. Module A's top-level already imports module B, and module B needs a symbol from A. Put A's import of B inside the one function in B that needs it, with a comment
# circular: B -> A via <symbol>. - Heavy third-party lib. Module-top import takes >50 ms measured by
python -X importtimeor loads native libraries. Known-heavy libs in this project:litellm(provider SDK fanout),lancedb(arrow + datafusion),kreuzberg(OCR stack),gguf(numpy),sentence_transformers,spacy,crawl4ai,textualwhen imported outside the TUI screen modules. (The local inference engine is the out-of-processllama-server, reached over HTTP, so it adds no heavy Python import.) Use importtime before declaring a lib heavy. - CLI startup path. The import lives inside a Typer command body so
lilbee --helpstays fast. Treat this as a sub-case of (2): the CLI loader stays lean so unused subcommands don't pay for their dependencies.
Never lazy-import the following:
- Stdlib modules (
os,struct,enum,io,fnmatch, …) — zero cost. - Local lilbee modules that don't pull in heavy third-party deps at their own module top (
lilbee.core.config,lilbee.app.services,lilbee.catalog,lilbee.modelhub.models,lilbee.modelhub.registry, …). - Third-party libs already dragged in transitively by the module's top-level imports — re-importing them later is pure noise.
- Project dependencies added explicitly to
pyproject.tomlthat measure under 50 ms (httpx,pydantic,tiktoken,numpy,pillow,gguf, …).
How to measure before arguing "it's heavy":
uv run python -X importtime -c "import <lib>" 2>&1 | tail -5
# the last "self" column in microseconds is the module-top costIf the result is under 50 000 µs, it belongs at the module top.
Optional imports (libs that may not be installed in every deployment):
The codebase has a small set of try: import X except ImportError: patterns for libraries that ship as opt-in extras. The is_X_available() style helper is the documented way to gate downstream code on whether the lib loaded.
| Library | Helper | Extra | Used by |
|---|---|---|---|
litellm |
lilbee.providers.litellm_sdk.litellm_available() |
lilbee[litellm] |
SDK provider, settings TUI |
crawl4ai |
lilbee.crawler.crawler_available() |
lilbee[crawler] |
Web crawler |
graspologic_native |
lilbee.retrieval.concepts.nlp.concepts_available() |
lilbee[graph] |
Concept-graph clustering |
lilbee_engine |
resolved in lilbee.providers.fleet.binary.resolve_engine_tool() |
bundled wheel | The local inference engine binaries (llama-server + llama-swap + gguf-parser) |
Any other try: import X should be either added to this table or refactored. CLI command bodies that branch on extras dispatch through these *_available() helpers, not via importlib.import_module(name).
Other rules:
- Never use
importlib.reload— it's a sign of bad design. If you need different config in tests, mutate thecfgsingleton. TYPE_CHECKINGguards are the right way to avoid circular imports only for type annotations. For runtime use, apply the circular-import rule above.
- Snapshot/restore pattern for config isolation:
from dataclasses import fields, replace from lilbee.core.config import cfg @pytest.fixture(autouse=True) def isolated_env(tmp_path): snapshot = replace(cfg) cfg.documents_dir = tmp_path / "documents" # ... set test values ... yield for f in fields(cfg): setattr(cfg, f.name, getattr(snapshot, f.name))
- Never save/restore individual fields manually — snapshot the whole object
- Never touch internal module state (e.g.
store_mod.LANCEDB_DIR) — only mutatecfg
- No inline CSS — screens and modals use
.tcssfiles viaCSS_PATH, notDEFAULT_CSSstrings. Widgets that don't supportCSS_PATHmay useDEFAULT_CSSor load from a file. - CSS class/ID sync — every CSS class or ID in a
.tcssfile must have a matching widget in the.pyfile and vice versa - TUI tests use
pilot.press()— notaction_*()method calls.Button.press()is acceptable since it's a public widget API, not an action bypass. - All user-facing text in
messages.py— inline strings in screens and widgets are forbidden - Confirmation modals reuse
ConfirmDialog; never roll a new yes/no screen withtextual.widgets.Button. The TUI's two-way pickers (ConfirmDialog,ChatModeToggle, the catalog grid/list toggle) are all built from focusableStaticpills withBinding("enter"/"space", "select"). They share the project's pill aesthetic and stay readable inside narrow modal panes. The TextualButtonwidget brings chunky framework chrome that fights the rest of the surface and collapses cards / modal headers. If you need a yes/no modal, composeConfirmDialog(title, message). If you need a custom pill picker, follow theStatic, can_focus=True+ bindings pattern inwidgets/confirm_dialog.py/widgets/model_bar.py::ChatModePill. A newfrom textual.widgets import Buttonin a screen module is a code-review red flag.
- When removing code, first make existing tests pass with the new implementation, then delete redundant tests
- Never delete tests and implementation in the same step
- Don't add features, abstractions, or config that isn't needed yet
- Three similar lines are better than a premature abstraction
- Only validate at system boundaries (user input, external APIs) — trust internal code
- No backwards-compatibility shims — if something is unused, delete it
Refactors update consumers, not the other way around. lilbee is a beta with no external pinned consumers; old import paths and old method names disappear the moment a refactor lands. Anything below is a code smell and must not appear in review:
- Private symbols re-exported at
__init__.py. If a private helper (_foo) lives inpkg/sub.py, callers (including tests) import it asfrom lilbee.pkg.sub import _foo, not through the package facade. The__init__.pyexposes only the genuine public API — everything else stays inside its host submodule. - Module re-imports inside
__init__.pypurely so test patches still fire.import timeorfrom somewhere import settingsat the top of a package__init__.pybecause a test patchespkg.timeorpkg.settingsis back- compat for stale tests. Update the test to patch where the symbol actually lives. - Docstrings and comments framed as preservation. Phrases like "preserves the historical X API", "kept for backwards compatibility", "legacy mock target", "so existing imports keep working" are warning lights. If a comment is justifying the existence of code, the code is probably scaffolding and should be deleted, not annotated.
- Shim modules at old paths after a move. A
gen.pyfile that just re- exports fromgeneration.pyexists only so stalefrom lilbee.x.gen importcallers keep compiling. Update the callers and delete the shim. # noqa: F401blocks on__init__.pyre-exports. A correct package facade lists its surface in__all__and ruff respects that natively. If noqa is fighting ruff, the imports are probably scaffolding.- Methods or attributes labeled "(for backward compat)" / "Prefer X instead". Delete the legacy alias and update the few internal callers, or delete the method entirely if it has no callers. Do not leave a tombstone.
When you refactor: grep every consumer (including tests, MCP tools, CLI commands, server routes, docs) and update them in the same commit. The old paths fully disappear; there is no migration window to soften.
- Every change tracked as a beads task (
bd create→bd close) - Run
make checkbefore closing any task — it mirrors CI exactly - Tests, lint, and type checks must pass before closing a task
- CI runs on every push and PR
- Never git push without explicit user approval — ask before pushing
- No Co-Authored-By lines in commits
- No "Phase N" numbering in commit messages
- Don't commit specs to repo;
docs/superpowers/is gitignored - Pre-commit checklist:
make format && make lint && make format-checkbefore every commit - Pre-push checklist: run full review workflow, verify code compiles, run affected tests. Never push optimistically.
- PR descriptions: short, human-readable, no implementation details or internal names, no test plan sections
- Don't rename branches with open PRs; use
gh pr editinstead - Use worktrees for parallel work, separate branches for distinct features
- Never stop to ask "should I keep going?" during multi-phase work. Just finish.
- Fix root causes individually, not downstream choke-point bandaids
- QA must run in a dedicated worktree — branch drift corrupts runs
- QA sessions: file bugs as beads issues, don't fix inline
- Use tmux send-keys for automated QA of TUI features
- Always ask before creating issues, PRs, or anything externally visible
Changes that add an enum variant, a new role/task slot, or anything else whose correctness depends on updating multiple parallel dispatch / validation sites.
- Write-boundary canonicalization beats read-path fallbacks. When the same
ref, identifier, or config field can be set through multiple entry points
(HTTP, CLI, TUI, env vars, TOML, direct
cfg.X = ...), canonicalize it ONCE at the write boundary (pydanticfield_validator(mode="after"), a setter, whatever is the single narrow gate) and return the canonical form. Alias fallbacks in resolvers are a code smell: they mean canonicalization landed in some places but not others. A non-canonical value works until it shows up in a log, UI, or diff and confuses the next reader. - Front-load the match-arm and entry-point audit. Before writing the
first line, grep every dispatch site for the existing variants (
grep -rn "ModelTask\.\|ModelRole\.\|SomeEnum\." src/ tests/). List every switch/if-chain/dict. Decide per-site whether it needs the new variant. Update every site in the SAME commit that introduces the variant. Leaving dispatch sites for reviewers to catch in round N means round N+1 is cleaning up regressions from round N's partial fix. - Capability / supports_X functions must be role-aware from commit 1. A
get_capabilities(model)that returns the same default for every input (["completion"],True) leaks cross-role models into the wrong surfaces. Start with role-specific output derived from the catalog entry's task. Don't ship the non-role-aware version and wait for a reviewer to flag it. - Defaults must round-trip through the tightened validator. When a
validator rejects previously-accepted inputs, update the config defaults
to values that pass under the new rules in the same commit. Otherwise the
first
Config()construction raises. Test:assert Config().X == expectedunder the new validator.
- Low complexity — max ~3 branches per function, extract helpers when exceeded
- DRY — reusable shared logic, no copy-paste
- No private API leaks — underscore-prefixed functions/attrs stay internal to their module
- Pythonic idioms — comprehensions, context managers, dataclasses, protocols over inheritance
- Named types over inline dicts — any repeated dict shape should be a dataclass or TypedDict
- Minimal changes — make smallest possible edit, don't rewrite large blocks for small fixes
- Exhaustive review — multiple review passes until no new findings emerge
- Compile before test — verify code compiles before running tests
- Separate architecture changes from hygiene sweeps. Mixing a docstring / comment cleanup with a semantic refactor makes review hard: reviewers can't tell "trimmed docstring" from "removed important rationale." Keep hygiene sweeps to their own commit at the end of a series, after architecture is frozen.
Each of these is a fast-grep that surfaces the patterns we've burned cycles on in past reviews. A reviewer (and the author, before requesting review) runs them. A non-empty result is either fixed or has a written justification in the PR body.
A subset is enforced automatically: scripts/check_style_rules.py (run by
make lint) fails when a line ADDED in src/ vs main introduces a
getattr-by-name, getattr-with-default, owned-attribute type: ignore,
isinstance(self.app, LilbeeApp), string-typed closed set, or module-level
global. It scopes to added lines so pre-existing hits don't block unrelated
work; the greps below remain the full set a reviewer still runs by eye.
grep -rnE "isinstance\(self\.app, LilbeeApp\)" src/— production host-narrowing for tests. Fix viaapp: LilbeeAppdeclaration + LilbeeAppHost in tests.grep -rn "getattr(self, \"" src/— getattr-by-name for owned attributes. Declare in__init__.grep -rn "getattr(.*, \".*\", " src/ | grep -v "__"— getattr-with-default on dataclass / object fields. If union member doesn't have the field, tighten the type instead of papering with the default.grep -rn "# type: ignore\[attr-defined\]" src/— owned-attribute ignore. Declare the attribute on the class.grep -rn "# type: ignore\[arg-type\]\|cast(.*self\.app" src/— host-cast paper trail; same fix as the isinstance rule above.grep -rnE "\b(task|kind|role|event_type|status|mode): str\b" src/— string-typed closed sets. Convert to StrEnum.grep -rn "\.fn\._\|fn\._lilbee\|fn\._marker" src/—fn._attr = Truedecorator marking. Use a module-level registry set +is_marked(fn)predicate.grep -rn "isinstance(.*, FrontierCatalogRow)\|isinstance(.*, LocalCatalogRow)" src/— dispatch on a sealed-union variant. Use the.kindLiteral discriminator.grep -rn "App\[None\]\|class _PlainApp\b\|class _BareApp\b" tests/— test host that bypasses LilbeeApp. Migrate to LilbeeAppHost.grep -cE "isinstance\(.*widget, (Input|Checkbox|Select|TextArea)\)" src/followed by "if more than one site" — type-keyed widget routing. Replace with adict[type, Callable].grep -rnE "^\s*global \w+" src/— module-level mutable globals. Encapsulate on a class.grep -rnE "def \w+\(.*\) -> .*:\s*$" src/ | xargs -I{} awk 'def_lines>20'(proxy: scan modules >700 LOC) — functions over ~20 lines need a split into named sub-helpers.grep -rn "from lilbee\.\(modelhub\|providers\|data\|retrieval\|wiki\|crawler\)" src/lilbee/catalog src/lilbee/core— upward imports from foundation layers. catalog and core never import from anything above them.grep -rn "from lilbee\.\(retrieval\|wiki\|crawler\|cli\|server\)" src/lilbee/data src/lilbee/modelhub src/lilbee/providers— Layer-2 modules importing Layer-3+. Invert via callback or move the helper down.grep -rn "include-package-data=lilbee_engine\|include-package-data=chardet" tools/wheel-build/— Nuitka--include-package-datafor a native-lib or opaque-data closure. Its heuristics silently drop.dylib/.bin; use--include-data-dir(verbatim). See Build & Release Artifacts.grep -rn "continue-on-error" .github/workflows/ | grep -iE "smoke|verify|self-check|artifact"— a swallowed correctness gate. A smoke/verify step must fail the job;continue-on-erroron one is how a broken artifact ships.
Run this mentally or explicitly before claiming work is done:
- Trace consumers — for every changed file, grep for who imports it. Go 2-3 levels deep for shared modules (types, constants, config).
- Constants vs raw strings — grep globally for the raw value of any new constant. Every occurrence must use the constant.
- CSS sync — every class/ID in
.tcsshas a matching widget in.pyand vice versa. - Mock signatures — after any refactor, verify test patches target the correct module path (patch where imported, not where defined). When hoisting an import between module-top and function-local, grep
@patch("...{Symbol}")decorators for that symbol and update them in the same commit. Stale patch targets mean the mock never fires and tests pass without exercising the path — the single worst review-cycle-burner in a refactor. - Dead code — removed/renamed functions have no remaining references. No orphaned imports.
- Test coverage — every new code path has a test with meaningful assertions. No untested branches.
- Type changes — field added/removed? All constructors, factories, serializers updated?
- Export changes — new export actually needed? Removed export has no dangling imports?
- No back-compat scaffolding —
grep -rni "historical\|backwards compat\|legacy mock\|preserves the.*api\|so existing imports keep working\|for backward compat" src/ tests/. Every hit is either real legacy data-migration code (allowed) or refactor scaffolding (delete). If a comment justifies the existence of code, the code is probably the scaffolding. - No new test-aware production branches —
grep -rnE "isinstance\(self\.app, LilbeeApp\)\|test apps aren't" src/must return zero new sites in your diff. The escape hatch was rip-and-replaced; new occurrences are regressions. - No new owned-attribute reflection —
git difffor addedgetattr(self, "X"and# type: ignore[attr-defined]lines. Each must have a written justification in the PR body or be removed. - Run the Code-Smell Triggers section grep set — every entry above. Compare counts vs
main. The number must not increase.
floopcaptures corrections and learned behaviors across sessions- Hooks run automatically via
~/.claude/settings.json(session-start, dynamic-context, detect-correction) floop active— show behaviors active in current contextfloop learn— manually capture a correction/behaviorfloop list— list all learned behaviorsfloop prompt— generate prompt section from active behaviors
The release ships standalone executables (Nuitka onefile) and pip wheels, each
carrying the out-of-process llama-server engine and its native library
closure. These rules exist because each one shipped a broken artifact once.
- Bundle native libs and opaque data with per-file
--include-data-files. Both--include-package-data=PKGAND--include-data-dir=SRC=DESTroute any.dylib/.so/.dllthrough Nuitka's DLL dependency tracker (which drops the engine's shared-library closure because nothing Nuitka scans references the bundledllama-server), and both silently drop.bindata (chardet's detection models). Neither is verbatim. Enumerate the files and pass each as--include-data-files=SRC=DEST— that copies verbatim, exec bit and baked@loader_path/$ORIGINrpath intact.build_lilbee_binary.shdoes this for the enginebin/and forchardet/models/*.bin. Symptom of getting it wrong: a binary that passes--versionbut errorsLibrary not loaded/ missing-data-file the moment it spawns the engine or crawls. - Copy the whole shared-library closure; dereference SONAME symlinks.
tools/wheel-build/build_llama_server.shcopies every linked lib from the entireserver-buildtree, not just the binary's directory — CMake scatters outputs and leaves a lib-less second copy of the binary, so find-first is nondeterministic. SONAME symlinks (libllama.0.dylib) are copied as real files under the names the binary loads. The script execs the bundled binary at build time so a missing lib fails on the builder, not the user. - A CUDA build bundles its CUDA runtime too.
cudart,cublasandcublasLtlive in the toolkit, not the build tree, and onlylibcuda/nvcudacomes from the driver, sobuild_llama_server.shcopies them beside the binary on Linux and Windows and fails the build when one is missing. The exec gate above proves nothing here — a driverless runner never loads the CUDA backend — so read the artifact instead:tools/qa/assert_cuda_bundle.pychecks the wheel in the build cells and again inverify-release, which also asserts every CUDA asset reached the release. - Every release channel has a real-inference gate.
tools/qa/artifact_smoke.shrunsself-check(real chat + embedding), ingest, search, a RAG ask, and an http crawl, sourcing models from theci-modelsmirror (no HuggingFace). Executable cells, thesmoke-wheelsPyPI-channel job, andverify-release.yml(against the downloadable assets) all run it.--version/--helppassing is the "test passes but covers nothing" smell applied to a binary — it does not count as verification, andmake release-promoterefuses to mark a tag latest without a greenverify-releaserun. - Never
continue-on-errora smoke or correctness gate. A swallowed gate is how the broken macOS bundle shipped. Skip a gate that genuinely can't run in an environment (GPU-driver backends linklibcuda.so.1/libamdhip64.soand can't exec on a driverless CI runner) with an explicitif:and a stated reason; do not blanket it incontinue-on-error. - Engine build cache is keyed on backend + build-env + toolkit + the hash of
the build scripts, so editing a build script busts it. Tag-ref cache saves are
not restorable by later runs, so
warm-engine-cache.ymlbuilds the matrix onmain; release runs restore. Bust manually by touching a build script.
lilbee has a local knowledge base you can query. Use it for domain-specific questions about the user's documents.
An MCP server is configured in .claude/settings.json for this project. Tools available:
| Tool | Description |
|---|---|
search(query, top_k) |
Search for relevant chunks |
status() |
Show indexed docs and config |
sync() |
Sync documents to vector store |
add(paths, force, vision_model) |
Add files/dirs and sync |
init(path) |
Initialize a local .lilbee/ knowledge base |
reset(confirm) |
Delete all documents and data (factory reset, requires confirm=true) |
Prefer search -- it returns pre-embedded chunks without calling the LLM at query time.
All commands accept --json (before the subcommand) for structured output:
lilbee --json search "query" --top-k 5
lilbee --json status
lilbee --json syncEvery command returns a single JSON object on stdout. Errors return non-zero exit + {"error": "message"}.
opencode and hermes are the supported agent integrations. lilbee launch opencode / lilbee launch hermes are the fast paths. Pull chat models via the TUI catalog first (lilbee -> /models). Launching registers lilbee as a provider (and the MCP search tool) directly in the agent's own config, sharing the user's existing setup rather than isolating it: only the lilbee keys and the active model are written, the token lives in the agent's secret store (not the config file), and --no-mcp (or agent_mcp_enabled=false) leaves lilbee as the model provider only. lilbee agent-config opencode|hermes prints the same block for pasting. lilbee serve exposes /v1/models and /v1/chat/completions directly for anything custom.
See the lilbee-mcp skill for the full MCP reference and the JSON CLI fallback for non-MCP agents.
app/— Shared use-case orchestration (status, models, reset, ingest, version) consumed by cli/, server/, mcp.py and the TUIcore/config/— All settings (env-var configurable)data/ingest/— Document sync engine (hash-based change detection)retrieval/query/— RAG pipeline (embed → search → generate)data/store/— LanceDB operationsdata/chunk.py— Text chunking (token-based recursive)data/code_chunker.py— Code chunking (tree-sitter AST)providers/— LLM provider abstraction (base protocol, thefleetllama-server engine, litellm SDK, routing/factory)catalog/— Model discovery from HuggingFacemodelhub/model_manager/— Model lifecycle (install, remove, list)retrieval/embedder.py— Embedding wrapper (uses provider abstraction)core/platform.py— OS helpers,find_local_root()for.lilbee/discoverycli/— Typer CLI with --model, --data-dir, --version, and --json flagsmcp.py— MCP server exposing search, ask, status, sync, init as toolsruntime/— process lifecycle (launcher, splash, asyncio loop, cancellation, progress, lock, crawl_task, temporal)
lilbee's wiki layer is modelled after Karpathy's LLM Wiki: concept and
entity pages that compound across sources rather than one page per
document. The layout under $data_root/$wiki_dir/ is:
concepts/ one page per LLM-curated concept from the source (e.g. braking-systems.md)
entities/ one page per proper-noun entity (e.g. henry-ford.md)
summaries/ legacy per-source pages (still supported, not the default)
synthesis/ cross-source pages produced by `wiki synthesize`
drafts/ low-faithfulness drafts, drift drafts, and PENDING markers
(parse-failure or slug-collision) surfaced via `wiki drafts list`
archive/ pages retired by `wiki prune` (plus the one-time Phase D
migration archive at `archive/concepts/` from the pre-Phase-D
noun-chunk generator)
index.md auto-generated table of contents, grouped by page type
log.md append-only audit trail (## [YYYY-MM-DD HH:MM] op | details)
Slugs are lowercase, hyphen-separated filenames that also double as
the [[link]] target (braking-systems, not Braking Systems). The
slug generator lives at wiki/shared.py:make_slug.
Page lifecycle. lilbee wiki build runs the Phase D migration once
(archives pre-Phase-D noun-chunk concept pages and unwraps stale
[[concept-slug]] links), extracts NER entities from the chunk store
via cfg.wiki_entity_mode (default ner_entities: spaCy NER only),
and then per source issues one batched LLM call that both identifies
3–5 concepts worth a wiki page AND writes a section for each identified
concept plus each extracted entity. Sections are split, citation-verified
per section against the shared chunk pool, embedding-faithfulness scored
(wiki/gen.py:_check_faithfulness, cosine of body vs mean chunk vector,
threshold cfg.wiki_embedding_faithfulness_threshold), and written to
concepts/ or entities/. Sections that fail to parse become PENDING
markers in drafts/ that the user resolves via wiki drafts accept/reject.
lilbee sync runs _incremental_wiki_update afterward with
extract_concepts=False so incremental re-ingest never churns concept
slugs; the cap is cfg.wiki_ingest_update_cap (default 20).
Retrieval inside wiki/gen.py. Each page is grounded in the top
cfg.wiki_concept_max_chunks_per_page chunks returned by the store's
hybrid search, optionally reordered by the native GGUF reranker when
cfg.reranker_model is set. Every path respects
cfg.diversity_max_per_source so one loud document can't monopolize a
topic page.
[[wiki links]]. After each build, wiki/links.py:rewrite_wiki_links
rewrites plain-text slug surface forms to Obsidian [[slug]] links
in the page body, skipping YAML frontmatter, code fences, and the
auto-generated citation block. The graph view in Obsidian is the point;
orphan detection in lilbee wiki lint flags concept or entity pages
with zero inbound [[links]].
Adding a new entity-extraction strategy. Implement
EntityExtractor from wiki/entity_extractor/base.py, add a new
WikiEntityMode enum value in config.py, wire it into
wiki/entity_extractor/factory.py:_EXTRACTOR_BY_MODE, and extend the
wiki_entity_mode entry in cli/settings_map.py (its choices field
reads from the enum, so no update needed there).
Adding a new config knob. All wiki settings live under the Wiki
group in config.py and cli/settings_map.py. Prefix with wiki_
(for module-level behaviour) or wiki_concept_ / wiki_entity_
(for strategy-scoped knobs). Use ConfigField(writable=True) so the
setting appears in /settings, the HTTP /set route, and
LILBEE_* env vars.
Writing to log.md. Use append_wiki_log(action, details) from
wiki/index.py with one of the WIKI_LOG_ACTION_* constants in
wiki/shared.py (BUILD, INGEST, LINT, GENERATED).
Don't hand-roll timestamps; the helper writes
## [YYYY-MM-DD HH:MM] action | details so grep '## \[' still
surfaces every entry.