Architecture¶
Design Principles¶
- No background services — Everything runs reactively in response to Claude Code events (hooks, CLI invocations). No daemons, no cron jobs.
- Standalone project — Self-contained Python package managed by
uv. No system-wide installation required. - Memory-first — The memory system is the core differentiator. All other features feed into or read from memory.
- Companion identity — Not a generic assistant. The soul document and user context create a consistent personality.
- uv-managed — Packaging and environments are
uv's job; no manual venv activation. A shipped install isuv tool install, somait-codeand themc-tool-*entry points are called directly;uv runis the development path. - CLI tools + skills over MCP — Simpler, no process overhead, preprocessing injects results before Claude sees the prompt.
System Architecture¶
graph TD
subgraph claude_code ["Claude Code"]
CLAUDE_MD["CLAUDE.md<br/><i>identity + rules</i><br/>@soul_doc, @user_ctx, @MEMORY"]
HOOKS["Hooks<br/>SessionStart, PreCompact, SessionEnd"]
SKILLS["Skills<br/>/recall, /remember, /reflect, memory-store<br/>/remind, /reminders<br/>/board, /triage<br/>/commit, /pre-pr-review<br/>/web-fetch"]
end
subgraph mait_code ["mait-code (Python)"]
subgraph hooks_pkg ["hooks/"]
session_start["session_start/ <i>(SessionStart)</i>"]
observe["observe/ <i>(PreCompact, SessionEnd)</i><br/>cli, extractor, transcript<br/>cursor, storage, scope"]
auto_format["auto_format/ <i>(placeholder, not registered)</i>"]
end
subgraph tools_pkg ["tools/"]
memory_tool["memory/ <i>(CLI)</i><br/>cli, db, migrate, writer<br/>search, scoring, entities<br/>embeddings, reflect, review<br/>native, observations, stats"]
reminders_tool["reminders/ <i>(CLI)</i><br/>cli, db, migrate, service"]
board_tool["board/ <i>(CLI)</i><br/>cli, db, migrate, columns, service, export"]
inbox_tool["inbox/ <i>(CLI)</i><br/>cli, db, migrate, service"]
web_fetch_tool["web_fetch/ <i>(CLI)</i><br/>cli, fetch, convert"]
end
subgraph bridge_pkg ["bridge/ <i>(opt-in transport)</i>"]
bridge_core["base, registry, service, config<br/>control, ntfy, loopback"]
end
subgraph ui_pkg ["cli/ + tui/"]
cli_layer["cli/ <i>(mait-code)</i><br/>install, update, status, doctor<br/>settings, permissions, dashboard<br/>+ 10 Textual TUIs"]
tui_layer["tui/ <i>(shared)</i><br/>app, theme, palette, banner<br/>brand, confirm, filters, markdown"]
end
shared["config.py + context.py + console.py<br/>llm.py + logging.py + ssl.py <i>(shared)</i>"]
end
subgraph data_dir ["~/.claude/mait-code-data/"]
identity["soul_document.md<br/>user_context.md<br/>communication_style.md"]
memory_store["memory/<br/>MEMORY.md <i>(curated)</i><br/>memory.db <i>(SQLite)</i><br/>observations/ <i>(raw JSONL)</i><br/>reflections/ <i>(synthesised)</i>"]
other_dbs["reminders.db<br/>board.db<br/>inbox.db"]
cfg["dashboard.toml<br/>bridge.json<br/>project-aliases.json"]
end
HOOKS --> mait_code
SKILLS --> mait_code
mait_code --> data_dir
bridge_pkg -.->|"ntfy topic"| phone["Phone / other devices"]
Memory Architecture¶
Overview¶
The memory system combines three tiers of storage with a SQLite database for structured search. Raw observations flow in from hooks, get indexed in the database, and the highest-confidence facts are promoted to MEMORY.md for always-on context.
Database Schema¶
The memory database (memory.db) uses SQLite with two extensions:
Core table: memory_entries
| Column | Type | Description |
|---|---|---|
id |
INTEGER PK | Auto-incrementing identifier |
content |
TEXT | The memory content |
entry_type |
TEXT | fact, preference, event, decision, insight, task, relationship, procedure |
importance |
INTEGER | 1-10 scale (default 5) |
memory_class |
TEXT | episodic, semantic, or procedural (controls decay rate) |
scope |
TEXT | global, project, or branch (default global) |
project |
TEXT | Project identifier (null for global scope) |
branch |
TEXT | Git branch (set only for branch scope) |
created_at |
DATETIME | Timestamp of creation |
Scope semantics:
- global — visible everywhere,
projectandbranchare null - project — visible across all branches of one project,
projectset,branchnull - branch — visible only on one branch of one project, both set
- Default at write time:
branchif both project+branch detected, elseprojectif project detected, elseglobal. Override with--scope. - Query-time default: filter by current context;
--scope alldisables filtering.
Entry type to memory class mapping:
- Episodic (fast decay, 3-day half-life):
event,task - Semantic (slow decay, 90-day half-life):
fact,preference,decision,insight,relationship - Procedural (slowest decay, 180-day half-life):
procedure
FTS5 virtual table: memory_entries_fts
- Full-text search with BM25 ranking
- Kept in sync via triggers on insert/update/delete
Vec0 virtual table: memory_vec
- Cosine distance vectors via sqlite-vec (768d for local/nomic, 1024d for Bedrock/Titan)
- Populated by the configured embedding provider (local fastembed or AWS Bedrock)
- Embeddings are computed and stored automatically when new memory entries are created
- Delete trigger (memory_entries_vec_ad) keeps vec table in sync when entries are removed
- Dimension is a deployment-time decision; mc-tool-memory reindex handles migration
Entity table: memory_entities
| Column | Type | Description |
|---|---|---|
id |
INTEGER PK | Auto-incrementing identifier |
name |
TEXT UNIQUE NOCASE | Entity name (case-insensitive) |
entity_type |
TEXT | person, project, tool, service, concept, org, unknown |
first_seen |
DATETIME | When the entity was first observed |
last_seen |
DATETIME | When the entity was last mentioned |
mention_count |
INTEGER | How many times the entity has been seen |
Relationship table: memory_relationships
| Column | Type | Description |
|---|---|---|
id |
INTEGER PK | Auto-incrementing identifier |
source_entity_id |
INTEGER FK | References memory_entities |
target_entity_id |
INTEGER FK | References memory_entities |
relationship_type |
TEXT | uses, owns, contributes_to, depends_on, manages, related_to |
context |
TEXT | Description of the relationship |
first_seen |
DATETIME | When the relationship was first observed |
last_seen |
DATETIME | When the relationship was last seen |
Unique constraint on (source_entity_id, target_entity_id, relationship_type).
Indexes:
idx_memory_entries_created_at— temporal queriesidx_memory_entries_type— type filteringidx_memory_entries_importance— importance rankingidx_memory_entries_class— class filteringidx_memory_entries_scope— scope filteringidx_memory_entries_project— project filteringidx_memory_entries_project_scope— combined project + scope lookupsidx_entities_name— entity name lookupidx_entities_type— entity type filteringidx_rel_unique— relationship deduplicationidx_rel_source,idx_rel_target— relationship traversal
Reflection state: reflection_watermark
Tracks the last memory_entries.id reflected on per project, so /reflect is idempotent across runs. Primary key on project (empty string for global). Columns: project, last_reflected_id, last_reflected_at.
Composite Scoring¶
Memory retrieval results are ranked by a composite score:
The scope boost scales the whole weighted base: 1.0 for a branch match, 0.85
for a project match (both fixed), and scope-boost-global / scope-boost-cross-project
for the wider scopes.
Recency uses exponential decay:
recency = exp(-ln(2) × age_days / half_life)- Episodic half-life: 3 days (events decay fast)
- Semantic half-life: 90 days (facts persist)
- Procedural half-life: 180 days (workflows go stale when superseded, not with time)
- Default half-life: 7 days (unknown or missing class)
Importance is normalized from 1-10 to 0.0-1.0:
importance_norm = (importance - 1) / 9
Relevance is provided by the search method:
- Hybrid mode (default): combines FTS5 BM25 with vector cosine similarity
- FTS mode: keyword-only BM25 ranking
- Vector mode: semantic similarity only via the configured embedding provider
Deduplication¶
Before storing a new memory, the writer checks for near-duplicates (scoped to the new entry's project) using two complementary measures:
- Extract first 8 significant words (length > 2) from new content
- Gather candidates from both FTS5 keyword search and vector similarity search
- Compare each candidate two ways:
SequenceMatcherstring similarity and vector cosine similarity - If string similarity ≥
dedup-string-threshold(default 0.85) or cosine similarity ≥dedup-vector-threshold(default 0.92): treat as a duplicate — update the existing entry's timestamp and keep max importance - If cosine similarity falls in the contradiction band
[dedup-conflict-threshold, dedup-vector-threshold)(default[0.60, 0.92)): insert as new entry and return the near-neighbours aspotential_conflictsso a stale fact can be superseded - If no match: insert as new entry
Superseded entries (replaced via supersede_memory) are excluded as dedup candidates and hidden from default search/listing.
Data Flow¶
flowchart TD
A["store_memory()"] --> B["find_duplicate()"]
B --> C["FTS5 + vector candidates"]
C --> D["string sim >= 0.85<br/>OR cosine >= 0.92?"]
D -->|Yes| E["UPDATE timestamp"]
D -->|No| F["INSERT new entry"]
F --> G["embed_text()<br/><i>configured provider</i>"]
G --> H["INSERT into memory_vec"]
flowchart TD
A["search_memory()"] --> B["hybrid_search()"]
B --> C["FTS5 BM25<br/><i>keywords</i>"]
B --> D["vector_search<br/><i>cosine similarity</i>"]
C --> E["Merge by entry ID<br/><i>both: use vector similarity</i><br/><i>one only: default 0.3</i>"]
D --> E
E --> F["composite_score()<br/><i>recency + importance + relevance</i>"]
F --> G["Sort by score, return top N"]
Tier 1: Observations (raw)¶
- Extracted automatically by the
observehook at PreCompact and SessionEnd - Stored as JSONL files in
memory/observations/YYYY-MM-DD.jsonl - Contains facts, decisions, code patterns, user preferences, entities, and relationships
- Indexed into
memory.dbfor structured search - Entities and relationships stored in the knowledge graph tables
Tier 2: Reflections (synthesised)¶
- Generated by
/reflectskill ormc-tool-memory reflectCLI - Reads unreflected memory entries (tracked by per-project watermark)
- Calls Claude Haiku to identify patterns, themes, recurring issues
- Stores insights as
type=insight(importance=6) in memory.db - Proposes MEMORY.md additions for user approval
- Idempotent: watermark advances after each reflection; same entries never processed twice
- Batched: configurable batch size (default 50),
--drainfor full backlog processing
Tier 3: MEMORY.md (curated)¶
- The highest-confidence facts, loaded into every session via CLAUDE.md
- Manually edited or updated by the reflection system
- Kept under ~150 lines for context budget
Memory CLI Tool (mc-tool-memory)¶
Sync CLI tool invoked via Bash. Skills use preprocessing (!command``) or direct Bash calls.
| Subcommand | Args | Description |
|---|---|---|
search |
query, --limit?, --type?, --mode?, --project?, --branch?, --scope? | Hybrid (FTS5 + vector) search with composite score re-ranking |
store |
content, --type?, --importance?, --project?, --branch?, --scope? | Store with deduplication, auto-computes embedding |
list |
--limit?, --type?, --since?, --project?, --branch?, --scope? | List recent entries, optionally filtered by type and time period (e.g. 24h, 7d, 1w) |
delete |
id | Delete by ID (vec cleanup via trigger) |
stats |
— | Counts by type, class, scope, project, embedding coverage, and provider info |
entities |
query?, --limit? | Search or list knowledge graph entities |
relationships |
entity_name | Show relationships for an entity |
reindex |
— | Recompute vector embeddings for all entries |
restore |
--dry-run? | Restore memory database from observation JSONL log files, then reindex |
reflect |
--days?, --min-new?, --batch-size?, --drain?, --project?, --branch?, --scope? | Synthesise observations into insights, propose MEMORY.md updates |
canonicalize-projects |
--dry-run? | Rewrite stored project slugs per the project-alias map |
review |
--limit?, --json?, --project?, --branch?, --scope? | List memories due for review, most-decayed first |
reviewed |
id | Stamp reviewed_at = now, resetting the entry's decay curve |
supersede |
old_id, content, --importance? | Replace an entry with a revised version, keeping the old row for audit (importance inherits unless overridden) |
retire |
id | Drop an entry from recall, keeping the row for audit |
merge |
ids…, --into content, --importance? | Fold several entries into one, retiring the originals (importance defaults to the max among them) |
Scope flags apply to every command that touches memory_entries: --project and --branch override the auto-detected context; --scope filters or sets the entry scope (global, project, branch, or all — the last disables filtering at query time).
Memory Browser TUI (mait-code memory)¶
mait-code memory opens a full-screen, read-only master–detail browser over the memory store: a tree of memories grouped by entry type on the left (counts per group, newest first), the selected memory's body — rendered as markdown, plain text being a subset — plus its metadata (created, importance, scope, class) on the right. / focuses a live substring filter (groups expand to the matches, the subtitle reports the narrowed count), r re-reads the store, a Ctrl+P command palette exposes the actions, and ? opens the context help screen. It deliberately browses everything — across projects and scopes — and performs no mutations: reading is its whole job, and writes stay with mc-tool-memory. It is built on Textual over the same query layer as the CLI tool (search.list_entries), following the house TUI conventions: a TTY-gated launch (piped or redirected, it prints a grouped read-only summary instead), a lazily-imported app off the hot path of every other command, and a single connection held for the app's lifetime.
Review Queue TUI (mait-code review)¶
mait-code review is the memory browser's writing counterpart: a queue of curated memories whose recall probability has decayed below review-threshold while their importance sits at or above review-min-importance, most-decayed first. Where the browser deliberately never writes, this surface exists to write — each entry gets one of three verbs, and each backs the same store operation the CLI exposes: c confirm (mark_reviewed, resetting the decay curve), e refine (an editor that supersedes the entry on save), x retire (behind a confirm, since it hides a fact from recall). Moving the highlight without choosing leaves the memory in the queue. p narrows to one project using the memory browser's picker, r recomputes the queue. The resurfacing query lives in tools/memory/review.py and decays from the reviewed_at anchor (migration 13) rather than created_at, so a memory's clock restarts when it is confirmed, not when it was first stored — the recall figure is a decay function, not a usage count, since nothing tracks retrieval. Same house conventions: TTY-gated (piped, it prints the due list as text), lazily imported, one connection for the app's lifetime.
Observations Browser TUI (mait-code observations)¶
mait-code observations is the memory browser's sibling over the raw extraction tier: a read-only master–detail browser of every non-insight entry, grouped by capture day (newest first), each flagged pending or reflected against the reflection watermark — so what reads pending here is precisely what the next /reflect run will consider. Highlighting a day renders its capture sessions (trigger, project, per-category extraction counts) from the daily JSONL logs; highlighting an entry renders its body and metadata, including its reflection standing. / filters live by content; p narrows to one project via a Select dropdown (the board's filter pattern) and judges entries against that project's watermark, since reflection runs per-project. The query layer lives in tools/memory/observations.py — memory.db is the operative source (the watermark is defined over its entry IDs); the JSONL logs contribute only per-capture metadata, read best-effort. Same house conventions: TTY-gated (a day-grouped text summary when piped), lazily imported, one connection for the app's lifetime, and no mutations — synthesis stays with /reflect.
Board Database¶
The board database (board.db) stores a single cross-project kanban board. Cards carry a project field — so one board serves every project and UIs filter by it — and move through a fixed, hardcoded column workflow.
Cards table: cards
| Column | Type | Description |
|---|---|---|
id |
INTEGER PK | Auto-incrementing identifier |
project |
TEXT | Project identifier (free-form; defaults to basename of git root or cwd) |
title |
TEXT | Card title |
description |
TEXT | The what/why |
acceptance_criteria |
TEXT | Definition-of-done; filled by the refine step |
status |
TEXT | Column: backlog, refined, in_progress, done, or archived (default backlog) |
priority |
TEXT | low, medium, or high (default medium) |
completion_summary |
TEXT | Handoff summary, set when moved to done |
created_at |
DATETIME | Timestamp of creation |
updated_at |
DATETIME | Timestamp of last mutation |
completed_at |
DATETIME | Timestamp of completion (null unless done) |
Comments table: card_comments
| Column | Type | Description |
|---|---|---|
id |
INTEGER PK | Auto-incrementing identifier |
card_id |
INTEGER FK | References cards(id); ON DELETE CASCADE |
author |
TEXT | me or claude (default me) |
body |
TEXT | Comment text |
created_at |
DATETIME | Timestamp of creation |
Tags table: card_tags
| Column | Type | Description |
|---|---|---|
card_id |
INTEGER FK | References cards(id); ON DELETE CASCADE |
tag |
TEXT | Free-form tag value; UNIQUE(card_id, tag) makes adding idempotent |
Free-form tags ride alongside a card's status. blocked is the first consumer: blocking tags a card in place rather than moving it, so the card keeps its real flow position. Each card dict carries a sorted tags list.
Columns are fixed, not configurable. backlog → refined → in_progress → in_review → done is the whole flow; archived is a hidden terminal excluded from default views. blocked is not a column — it is a tag carried in place (via block/unblock), so a blocked card keeps its real status. The constants live in src/mait_code/tools/board/columns.py. Project detection uses mait_code.context.get_project(); pass --project for work with no git repo (e.g. an app idea).
Board CLI Tool (mc-tool-board)¶
Manually-driven kanban board. Claude in the live session acts as the worker ("pick up the next refined card"); there is no autonomous dispatcher — you drive the board. Cards are project-scoped via the basename of the git root (or cwd), stored in a shared board.db. Read commands accept --json for UI/skill consumption.
| Subcommand | Args | Description |
|---|---|---|
add |
title, --description?, --priority?, --project? | Add a card to the backlog |
list |
--all?, --status?, --archived?, --mine? | --session?, --json? | List cards grouped by column (current project by default; archived hidden); --mine/--session keep cards bound to a session, across all projects |
show |
id, --json? | Show a card and its comment thread |
move |
id, status | Move a card to any column (sets/clears completed_at around done; into in_progress binds the current session, out of it releases bindings) |
refine |
id, --description?, --acceptance? | Set description/acceptance and move to refined |
next |
--project?, --claim?, --json? | Show the next refined card (priority, then oldest); --claim moves it to in_progress and binds the current session |
complete |
id, --summary? | Move to done with a completion summary |
block |
id, reason? | Tag the card blocked in place (keeps its column); an optional reason is recorded as a comment |
unblock |
id | Remove the blocked tag (keeps the card's flow position) |
tag |
id, tag | Add a free-form tag to a card |
untag |
id, tag | Remove a tag from a card |
ref add |
id, label, value | Append a label→value reference (URL, file:// path, or bare ID) to a card |
ref remove |
id, position | Remove a reference by its 1-based position (see ref list) |
ref list |
id, --json? | List a card's references in order |
archive |
id | Archive a card (hidden, not deleted) |
bind |
id, --session? --pid? | Bind a Claude Code session (default: $CLAUDE_CODE_SESSION_ID/$CLAUDE_PID) to an In Progress card; refused without both, and --session needs its own --pid |
unbind |
id, --session? | Drop a session's binding from a card |
comment |
id, body, --author? | Append a comment (author me or claude) |
edit |
id, --title?, --description?, --priority?, --acceptance? | Edit card fields |
remove |
id | Delete a card permanently (cascades comments) |
summary |
--all?, --project?, --json? | Per-column counts (the session-start hook reads the same counts via service.summary_counts) |
export |
id?, --format?, --out?, --all?, --project?, --status?, --archived?, --search? | Export one card or the whole board as markdown or JSON (stdout, or a file with --out) |
Every query and mutation — including the done-invariant (completed_at is set on entering done and cleared on leaving) and the session-invariant (leaving in_progress releases every card_sessions binding) — lives in src/mait_code/tools/board/service.py, a presentation-agnostic layer over an open connection. The argparse handlers and the TUI both sit on top of it, so there is a single source of truth for the SQL and the workflow rules.
Board TUI (mait-code board)¶
mait-code board opens a full-screen, interactive kanban — one pane per status side by side, every project's cards visible with a p project-filter dropdown, arrow-key navigation, </> to move a card along the flow (backlog → refined → in_progress → in_review → done; In Review hides itself while empty unless v reveals it), t to toggle a tag and b/u to tag/untag blocked in place, plus a near-fullscreen card screen (Enter) that shows the card with its comment thread and flips to an edit form in place with e. That form is the single place a card is changed: title, priority, status, tags, references, description and acceptance criteria all on one form. Tags, references and status are a working copy — Save applies them together and returns to the view; cancel discards every pending change. Block/unblock stay outside the form (they carry a reason comment a plain tag can't), so the form's tag editor leaves blocked alone. n creates a card, C completes one with a handoff summary, and c comments. The card screen carries a References section — a list of label→value links, clickable where the value is a URL or file:// path. Priority and tags render as domain-coloured chips on the card rows (blocked distinct in the error colour). A Ctrl+P command palette exposes every action, ? opens a context help screen built from the live key-bindings, number keys jump between columns, and actions raise toasts. It is built on Textual and reuses the board service.py, mirroring the mait-code settings editor: a TTY-gated launch (piped or redirected, it prints a grouped read-only render instead), a lazily-imported app off the hot path of every other command, and a single connection held for the app's lifetime.
The board TUI is on-demand and foreground — it is launched explicitly, runs until you quit with q, and leaves nothing behind. The No background services principle is intact: there is no daemon polling the board, only a short-lived app you open when you want to see it.
Inbox Database¶
The inbox database (inbox.db) backs a single frictionless quick-capture holding pen — a "capture now, sort later" store so a thought can be dumped without deciding upfront whether it is a board card or a memory.
Inbox table: inbox_items
| Column | Type | Description |
|---|---|---|
id |
INTEGER PK | Auto-incrementing identifier |
body |
TEXT | The captured thought |
project |
TEXT | Capture-context project (a routing hint; nullable — the store is global) |
created_at |
TEXT | Timestamp of capture |
Unlike the board, the inbox is global, not project-scoped — project is only a hint to help triage route the item later.
Inbox CLI Tool (mc-tool-inbox)¶
A thin capture-and-drain CLI over inbox.db. add "<text>" captures an item (frictionless — no flags required), list [--json] shows the inbox oldest-first, remove <id> drains an item out, count prints the item total (the session-start hook reads the same count via service.count_items), and drain pulls any captures waiting on the Bridge into the inbox. Queries and mutations live in src/mait_code/tools/inbox/service.py, the presentation-agnostic layer mirroring the board's cli/service/db split.
The intended lifecycle is capture → triage → empty: the /triage skill walks the captured items, proposes a destination for each (board card or memory), creates it on the user's confirmation, and removes the item — keeping the inbox near-empty rather than letting it become a second backlog. Routing is suggestion-based: the companion proposes, the user decides.
Reminders CLI Tool (mc-tool-reminders)¶
| Subcommand | Args | Description |
|---|---|---|
set |
when, what | Schedule a reminder with natural language time parsing |
list |
--all? | List active (or all) reminders |
dismiss |
id | Dismiss a reminder by ID |
check |
— | Check for overdue reminders (the session-start hook builds the same text via tools/reminders/service.py) |
Web Fetch CLI Tool (mc-tool-web-fetch)¶
Local web fetcher that bypasses the claude.ai proxy. Works behind corporate firewalls via truststore SSL injection.
| Argument/Flag | Default | Description |
|---|---|---|
url (positional) |
— | URL to fetch |
--timeout |
30 |
Request timeout in seconds |
--max-size |
524288 |
Maximum response body in bytes (512KB) |
--max-chars |
100000 |
Maximum output characters (~25K tokens) |
--raw |
false |
Skip HTML-to-markdown conversion |
--allow-private |
false |
Allow fetching private/loopback IPs |
Content-type routing: HTML→markdown (via markdownify), JSON→pretty-printed, text→passthrough, binary→descriptive message. SSRF protection blocks private/loopback/link-local IPs by default.
Home Hub TUI (mait-code home)¶
mait-code home — or just mait-code with no subcommand on a terminal — opens the companion's front door: a tree-navigable hub over everything mait-code, not an at-a-glance readout. A slim tree sidebar (under a third of the width) lists the sections — Board, Memory, Reminders, Inbox, Identity, System — each tree node carrying a live status badge (active card count, memory total, overdue reminders in alarm colour, inbox count). The detail pane beside it renders the highlighted node in full, with no glance clipping: the whole live-card breakdown, the per-type memory tables, the complete doctor check list. A one-line install-health verdict (reusing the doctor checks) sits under the tree, and the installed version shows in the brand header. r re-reads every store (refreshing badges and the open detail), e embeds the memory entries missing a vector after a confirm (the hub's one write — it suspends to the terminal so the reindex progress prints normally), j/k and the arrows move the cursor, and a Ctrl+P palette exposes the actions.
Pressing Enter on a launch leaf — the board, the memory browser, the review queue, the observations browser, the graph explorer, the settings editor, the log viewer, the Bridge configurator, or the start-page setup editor — leaves home and opens that dedicated TUI; when it quits, home re-opens with freshly recomputed badges. The handoff is an exit-and-relaunch loop in the home command (_run_home_loop): home exits its event loop returning a HomeTarget, the loop launches that app, then re-enters a fresh home — one process, no nested event loops, and each launched app runs unchanged. The Identity → System prompt node renders what the companion is presented with at session start: the identity stack (soul document, user context, curated MEMORY.md) read from the data dir, then the live output of the session-start hook's context builder. It calls the same build_session_context() the hook calls, so the text on screen is exactly the text a new session opens with, less the per-session section naming the cards a session is bound to (the hub has no session).
This is also where the brand lives: the box-drawing wordmark — a plain-text fallback on narrow terminals, a half-height variant on short ones — the signature glyph, and the companion voice in every empty state, all from mait_code.tui.brand. The BrandBanner (mait_code.tui.banner) wraps them into one size-responsive masthead worn by every TUI in place of a stock header, carrying each surface's view name over the tagline and version. The hub follows the house TUI conventions: presentation over the same store layers the mc-tool-* CLIs use (nothing shells out, nothing writes), a TTY-gated launch (piped or redirected, mait-code home prints a compact text summary and bare mait-code keeps printing help), and per-view best-effort loading so one broken store renders a snag line rather than taking the hub down.
The Start Page (dashboard.toml)¶
The home hub's root node renders a user-authored start page rather than a fixed readout: a grid of tiles declared in dashboard.toml in the data directory, one to four columns wide. A tile is either a built-in widget — the six in _dashboard.py's BUILTIN_WIDGETS, reading the same store layers as the rest of the hub — or an arbitrary shell command whose stdout fills the tile. Shell tiles are what make the page personal (a git log, a deploy status, a weather one-liner) and are also the risk surface, so each runs under dashboard-tile-timeout seconds (default 5) and a failure renders a snag line in that tile instead of taking the page down. The guided setup editor (_dashboard_tui.py, reachable from the hub or its command palette) writes the TOML so the format stays an implementation detail unless you want it. Loading and validation live in cli/_dashboard.py, kept separate from the TUI so the config can be parsed and checked without a terminal.
The Bridge (bridge/)¶
The Bridge is the companion's optional link to devices that aren't the terminal — capture in, notifications out. It is disabled by default and makes zero network calls while disabled: the drain short-circuits before any request. That default is deliberate rather than cautious-by-habit — enabling it means outbound network access, which a work machine under corporate policy may not permit, so it is a per-machine opt-in.
The package is a small pluggable-channel design: base.py defines the channel protocol, registry.py maps a bridge-type setting to an implementation, and service.py holds the transport-agnostic drain/publish logic. Two channels ship — ntfy.py (a private ntfy topic, the real one) and loopback.py (in-process, for tests). Channel credentials live in bridge.json in the data directory; the per-machine drain watermark lives beside it in bridge-state.json, which is why that file must not be synced between machines.
Both directions ride the existing reactive triggers rather than a daemon: the session-start hook and mc-tool-inbox drain pull captures into the inbox, and the session-start hook and mc-tool-reminders check publish due reminders outward. A published reminder carries a Done action that posts mait-ctl:dismiss:<id> back to the capture topic; control.py intercepts any mait-ctl:-prefixed inbound message as a command rather than filing it as a capture, and the next drain dismisses the reminder. Each reminder publishes once, guarded by a notified_at stamp. doctor checks the Bridge but only ever warns — an unreachable phone is not a broken install.
Console and Theming (console.py)¶
Every CLI surface prints through one shared rich Console in console.py, carrying the same palette the TUIs theme from. It is why status, doctor and update look like the TUIs rather than like stock argparse output, and why --no-color is a single global switch rather than a per-command concern. The TUI side of the same palette lives in tui/theme.py.
Hooks¶
| Hook | Trigger | Mode | Purpose |
|---|---|---|---|
session_start |
SessionStart | sync | Inject companion context (the session's bound cards, reminders, board summary, inbox count) — built by hooks/session_start/context.py, which reads each tool's store layer directly and is shared with the home TUI's system prompt view. On resume/clear it first moves card↔session bindings to the new pid / session id |
observe |
PreCompact | async | Extract observations before context compaction |
observe |
SessionEnd | async | Final observation extraction |
auto_format |
not registered | — | Placeholder package — entry point exists (mc-hook-format) but no settings.json registration and no implementation |
Both observe hooks run asynchronously ("async": true) to avoid blocking the main conversation. They call Claude Haiku to extract structured observations (facts, preferences, decisions, bugs, entities, relationships) from new transcript lines.
macOS caveat: Async hooks on macOS may receive empty stdin due to a Claude Code bug (#38162). The observe hook handles this by falling back to transcript discovery from the filesystem — it derives the Claude Code project slug from cwd and finds the most recently modified .jsonl transcript.
Observation Pipeline¶
flowchart TD
A["PreCompact / SessionEnd"] --> B["cursor.py<br/>get byte offset for transcript"]
B --> C["transcript.py<br/>read new JSONL lines,<br/>filter user/assistant messages"]
C --> D["extractor.py<br/>call Claude Haiku for<br/>structured extraction"]
D --> E["storage.py<br/>store to memory.db +<br/>daily JSONL logs"]
E --> F["cursor.py<br/>save new byte offset"]
Logging¶
All entry points use a shared logging module (src/mait_code/logging.py) that writes to rotating log files. Logs never go to stdout/stderr to avoid interfering with hook JSON output.
Configuration (via settings.json env block or shell environment):
Primary knobs:
| Variable | Default | Description |
|---|---|---|
MAIT_CODE_DATA_DIR |
~/.claude/mait-code-data |
Data directory (memories, personalised files) |
MAIT_CODE_LOG_LEVEL |
INFO |
DEBUG, INFO, WARNING, ERROR |
MAIT_CODE_LOG_FILE |
~/.local/state/mait-code/mait-code.jsonl |
Override log file path |
MAIT_CODE_THEME |
mait-dark |
TUI colour theme; unknown names fall back to mait-dark |
MAIT_CODE_EMBEDDING_PROVIDER |
local |
Embedding provider: local (fastembed) or bedrock (AWS) |
MAIT_CODE_EMBEDDING_MODEL |
nomic-ai/nomic-embed-text-v1.5 |
Model for local embedding provider |
MAIT_CODE_BEDROCK_REGION |
eu-west-2 |
AWS region for Bedrock embedding provider |
MAIT_CODE_BEDROCK_MODEL_ID |
amazon.titan-embed-text-v2:0 |
Model ID for Bedrock embedding provider |
Advanced operational knobs (written commented-out in settings.toml; the built-in default applies until overridden):
| Variable | Default | Description |
|---|---|---|
MAIT_CODE_LOG_BACKUP_COUNT |
14 |
Days of rotated log files to keep |
MAIT_CODE_EXTRACTION_MODEL |
haiku |
Model used for memory extraction |
MAIT_CODE_REFLECTION_MODEL |
haiku |
Model used for reflection synthesis |
MAIT_CODE_LLM_TIMEOUT |
90 |
Timeout (seconds) for subprocess LLM calls |
MAIT_CODE_REFLECTION_BATCH_SIZE |
50 |
Default --batch-size for reflection |
MAIT_CODE_REFLECTION_NOVELTY_GATE |
3 |
Default --min-new for reflection |
MAIT_CODE_GIT_TIMEOUT |
5 |
Timeout (seconds) for git context probes |
Advanced scoring/dedup tuning knobs (MAIT_CODE_SCORE_WEIGHT_*, MAIT_CODE_HALF_LIFE_*, MAIT_CODE_DEDUP_*_THRESHOLD, MAIT_CODE_SCOPE_BOOST_*) directly affect retrieval quality — see the Memory guide for the full list, ranges, and the weight-sum constraint.
These knobs are defined once in src/mait_code/config.py; mait-code settings list prints their resolved values and the source of each (env, settings file, default, or derived), mait-code settings set <key> <value> edits one (validating, persisting, and running any follow-up), and bare mait-code settings edits them interactively. mait-code doctor validates them via its settings-values check.
Features:
setup_logging()— call once per entry point; idempotent, configures themait_codelogger hierarchy@log_invocation(name=...)— decorator that logs command name, parsed arguments, duration, and exit status- Sensitive parameters (
content,query,what,prompt,message) are automatically truncated to 80 chars TimedRotatingFileHandler— rotates at midnight, keepslog-backup-countdays (default 14)
Log format: JSON Lines — one object per line, written to mait-code.jsonl.
Core fields are ts (epoch seconds), level, logger (with the mait_code.
prefix stripped), msg, tool and pid; see the schema
table for the full list.
{"ts": 1772029381.412, "level": "info", "logger": "tools.memory.cli", "msg": "invoked: mc-tool-memory", "tool": "mc-tool-memory", "pid": 48120, "event": "invoked", "args": {"query": "dark mo...", "limit": 10, "mode": "hybrid"}}
{"ts": 1772029381.598, "level": "debug", "logger": "tools.memory.search", "msg": "Vector search: 3 results", "tool": "mc-tool-memory", "pid": 48120}
{"ts": 1772029381.834, "level": "info", "logger": "invocation", "msg": "completed: mc-tool-memory", "tool": "mc-tool-memory", "pid": 48120, "event": "completed", "duration_ms": 422}
Identity System¶
Four files compose the companion's identity:
- Soul Document — Values and personality (stable, rarely changes)
- User Context — Who the user is, their stack, preferences (updates occasionally)
- Communication Style — How responses are shaped: length, attention markers, shaping rules (stable, rarely changes)
- MEMORY.md — Accumulated knowledge (updates frequently)
The first three are seeded from templates/ on install (the communication style from templates/communication_styles/default.md) and never overwritten. All four are referenced via @ imports in config/CLAUDE.md and loaded into every Claude Code session.
Migration System¶
Schema changes are managed via forward-only migrations in src/mait_code/tools/memory/migrate.py. Each migration has a version number, description, and body (SQL list or callable). The schema_version table tracks which migrations have been applied.
Current migrations:
memory_entriestable with indexes- FTS5 virtual table for full-text search
- FTS sync triggers (insert/update/delete)
- Vec0 virtual table for vector search (1536-dim, superseded by migration 7)
memory_entitiestable for entity trackingmemory_relationshipstable for entity relationships- Recreate vec0 with 768 dimensions (default for local provider), add vec delete trigger
- Add
scope,project,branchcolumns tomemory_entries; rebuild FTS with new columns; add scope/project indexes - Create
reflection_watermarktable for idempotent reflection - Relabel extraction-sourced
insightentries asdecision(one-time; guarded to run only before reflection has ever run, so genuine reflection insights are never touched) - Add the supersession columns (
superseded_by,superseded_at) tomemory_entries - Remap entity and relationship types onto the canonical vocabularies, folding legacy values
- Add the
reviewed_atanchor that review resurfacing decays from
Adding a new migration:
- Append a tuple to
MIGRATIONSwith the next version number - Include SQL statements or a callable that receives
conn ensure_schema()runs automatically on every connection open
Data Directory¶
~/.claude/mait-code-data/
├── soul_document.md # Companion identity
├── user_context.md # User profile
├── communication_style.md # Response shaping
├── memory/
│ ├── MEMORY.md # Curated facts (loaded every session)
│ ├── memory.db # SQLite FTS5 + vec0 + entities database
│ ├── observations/ # Raw JSONL session extractions
│ │ ├── YYYY-MM-DD.jsonl
│ │ └── cursors.json # Byte offset tracking per transcript
│ └── reflections/ # Reserved for synthesised insights (created on install)
├── models/ # Cached embedding models (local provider only)
├── reminders.db # Reminder database
├── board.db # Cross-project kanban board database
├── inbox.db # Quick-capture inbox database
├── dashboard.toml # Start-page tile layout
├── bridge.json # Bridge channel config (may hold a token)
├── bridge-state.json # Per-machine drain watermark — never sync
└── project-aliases.json # Project slug alias map
Rotating log files do not live in the data directory — they go to the XDG state dir (
~/.local/state/mait-code/mait-code.jsonlby default, structured JSON Lines), configurable viaMAIT_CODE_LOG_FILE. Nor does the settings file:$XDG_CONFIG_HOME/mait-code/settings.tomlis the single source of truth for everyMAIT_CODE_*knob and sits outside the synced directory by design.
bridge-state.jsonandmemory/observations/cursors.jsondescribe how far this machine has read. See Multi-machine sync for what to commit and what to leave behind.
Key Technical Decisions¶
| Decision | Rationale |
|---|---|
| uv over pip/poetry | Fastest resolver, built-in project management, uv run eliminates venv activation |
| SQLite + FTS5 + sqlite-vec | Zero infrastructure, single file, portable, keyword + vector search in one DB |
| JSONL for observations | Append-only, merge-friendly for git sync, one object per line |
| Hooks over background services | No daemons to manage, reactive model fits Claude Code's architecture |
| CLI tools + skills over MCP | No process overhead, preprocessing injects results before Claude sees the skill, simpler debugging |
| Symlinks over file copying | Updates propagate automatically via git pull, no re-install needed |
| Exponential decay scoring | Recent memories surface naturally, old ones fade unless high importance |
| Dedup via FTS5 + SequenceMatcher | Fast candidate narrowing, precise similarity comparison, no duplicates |
| Async observation hook | PreCompact extraction runs in background, no conversation latency |
| Entity tables over separate graph DB | Entities live in memory.db alongside memories — single file, recursive CTEs for future traversal |
| truststore for SSL | Injects OS trust store into Python's ssl module — corporate proxy CAs (e.g. Netskope) are trusted automatically without manual cert management |
| fastembed over sentence-transformers | ONNX Runtime only (~80 MB), no PyTorch (~2 GB); ~300 MB RAM at runtime |
| nomic-embed-text-v1.5 @ 768 dims | Full-quality representation; 8192 token context; MTEB ~62.4; negligible storage cost at expected scale |
| Configurable embedding providers | Local (fastembed) for personal use, AWS Bedrock for corporate environments where HuggingFace is blocked; deployment-time decision, reindex to migrate |
| Hybrid search (FTS5 + vector) | Keywords catch exact matches, vectors catch semantic similarity; graceful degradation to FTS-only if embeddings unavailable |
| File-based rotating logs | No stdout/stderr interference with hook JSON; configurable via env vars; TimedRotatingFileHandler rotates at midnight and keeps log-backup-count days |
| Watermark table for reflection idempotency | Separate table over reflected_at column — atomic batch tracking, no feedback loops, clean separation of concerns |
| urllib.request over httpx/requests for web fetch | Zero new HTTP dependency tree; truststore.inject_into_ssl() patches the stdlib SSL context that urllib.request uses; system proxy env vars (HTTP_PROXY/HTTPS_PROXY) respected automatically |
| markdownify for HTML-to-markdown | Lightweight (~600 lines), single purpose, only brings beautifulsoup4; full readability extraction (trafilatura) is overkill — Claude can ignore boilerplate itself |