Skip to content

Latest commit

Β 

History

History
677 lines (572 loc) Β· 52.9 KB

ARCHITECTURE.md

File metadata and controls

677 lines (572 loc) Β· 52.9 KB

Architecture

What the package is made of and where the seams are: the map of what exists. Open work is in the backlog.

The one rule

Nothing here knows a messenger. A CLI's adapter translates its provider's objects into the domain types, and biome.json refuses any import of @mtcute/*, ws or an adapter directory under src/. What only one provider has travels in providerMetadata.

Responsibility and dependency boundaries

Follow the shared modularity standard. cli-base owns framework primitives; cli-core owns provider-neutral domain behavior and use cases; provider applications own vendor translation and account/resource binding.

Module Responsibility
Domain modules such as src/meetings/ and src/tasks/ Models, references, validation and capability ports; no transport runtime or SQL implementation
src/files/ Ordinary-file validation and native writer leases behind narrow ownership contracts; hosts supply archive layout (guide)
src/services/ Use-case orchestration over injected narrow ports; no provider SDK or concrete store imports
src/store/sqlite/ and store drivers Persistence adapters, SQL and transaction boundaries
src/cli/, src/mcp/, domain /cli and /mcp entries Transport parsing and result presentation; call shared policy rather than copying it
Provider application's composition root Choose account, bind store and provider ports, enforce lifetime and close-before-output

Persistence adapters are part of Core; vendor adapters are not. Keep this distinction when adding meeting providers. A service may depend on an exported store contract without importing its SQLite implementation. New CLI/MCP capabilities share validation, account-bound reader contracts and answer mapping. Split substantive orchestration, transport parsing and formatting into coherent modules; do not grow an existing handler into a second service layer. These boundaries are review rules; existing import restrictions and type checks enforce only the subset configured in the repository.

CLI design references

The command interface follows a shared adoption profile: POSIX utility conventions for utility syntax, GNU CLI conventions for common options, and CLIG for modern CLI design. The standard records our additional agent contract and deliberate differences. These references do not choose a particular resource hierarchy; stats is a project decision.

The shared program shell owns parsing and error routing; the renderer owns machine output; command discovery describes the tree; services own use cases and write guards enforce permissions. CLI and MCP are two adapters over those services. The MCP tools specification governs the latter; Agent Skills describes the portable agent guidance format. Agent-oriented design references and their applicability are linked from the standard, so a reader need not treat a project example as a formal standard.

The compliance audit distinguishes source evidence, isolated observations, intentional deviations and work still needed. This architecture page does not certify complete conformance or imply that planned command paths are already implemented.

Modules

Export Directory What it holds
. src/domain/, src/render/, src/resolve.ts, src/terminal/ the domain model (models.ts, types only), message locators, message rendering, name resolution that refuses rather than guesses, the secret prompt, the terminal QR code
./store src/store/ the SQLite seam and the shared message store
./sends src/sends/ the send guard: the permission levels, the read-only lock, the recipient list, the hourly limit, the journal (never the text), the send id
./speech src/speech/ the pinned catalogue, shared model directories, verified installer and installed-only bounded PCM recognizer; import alone loads no engine
./charts src/charts/ neutral chart data and a replaceable renderer interface; SVG rendering loads ECharts only on demand; a separate lazy PNG encoder uses resvg and bundled fonts
./models src/models/ purpose-specific text generation with typed image inputs, OpenAI-compatible and Anthropic adapters, strict options, injected key/consent resolvers; no configured provider means no call
./services src/services/ the use cases, once each, that commands and MCP tools call β€” see Services
./background src/background/ what any background process needs and no messenger: the lock per app and profile, whether a PID is alive and ours, the machine seam tests replace, systemd and launchd units β€” serve and server are built on it, each CLI's server stays its own (NEED-492 C)
./cli src/cli/, src/mcp/ the command skeleton, the shared commands and the MCP server
./sqlite-runtime src/sqlite-runtime.ts ensureSqlite, the first thing tg and max run: restarts the command with the bundled SQLite when the system's lacks FTS5 β€” see The store
./parity src/parity/ the command manifest, page and wording checks behind cli-core-parity, which compares tg and max
./testing src/kit/ the adapter kit: a fake adapter, the contract cases and their seed β€” see the adapter guide. src/testing/ is this repository's own test setup and is not published
./tasks, ./tasks/testing src/tasks/ open tasks: model, TaskStore port and service β€” see Open tasks
./meetings, /meetings/cli, /meetings/mcp, /meetings/testing src/meetings/ events and meetings, the provider and store ports, pull, import, commands and MCP reads β€” see Meetings and provider integration

The README's table lists what each export offers; this page does not repeat it.

The store

openCache (src/store/open.ts) opens node:sqlite under Node and bun:sqlite under Bun. Both imports are dynamic: a static import of the other runtime's module fails at load time, before anything can catch it. pnpm smoke:bun is what proves the Bun half.

Before it opens the file, openStore checks once per process that the runtime's SQLite has the full-text search the migrations need (assertStoreCapable), and refuses with what to install. The version number does not tell: official Node 22.0–22.15 has SQLite 3.46–3.49 without FTS5. CI runs the built package on Node 22.15.0 to see the refusal (scripts/check-old-node.mjs).

Where the runtime's SQLite can be swapped, ours from @wirecat/cli-core-sqlite (built in packages/sqlite, published by .github/workflows/sqlite.yml) takes its place (plan):

  • Bun on macOS always loads ours (bunDatabase, src/store/drivers/bun-sqlite.ts), once, before the first database: Bun uses the system's library there, which can be too old.
  • A Linux distribution's Node links the system's libsqlite3. ensureSqlite (src/sqlite-runtime.ts, exported as @wirecat/cli-core/sqlite-runtime) is the first thing tg and max run: when the system's SQLite fails the check above, it starts the command again with ours first on LD_LIBRARY_PATH, before anything is read or sent. CI runs it on Ubuntu's Node with SQLite 3.42 (scripts/check-sqlite-restart.mjs).
  • Official Node and Bun on Linux and Windows build SQLite in; nothing is swapped.

openStore (src/store/store.ts) is the one file for every messenger and account β€” tg's profiles and max-cli's bots write the same database, keyed by provider and account. Its path comes from storePath (src/store/path.ts), the only place that turns MESSAGING_STORE into a path. Without it the file is wirecat.db in the WireCat folder (~/.local/share/WireCat/, %LOCALAPPDATA%\WireCat\Data\; WIRECAT_STATE_DIR moves the folder, the models live in its models/). mcp config copies the variable into the entry it prints, so the server it starts opens the same file.

Every MessageStore method is async and is one whole operation: inside the SQLite store each write runs as one synchronous BEGIN IMMEDIATE transaction, with no await between BEGIN and COMMIT. The interface has no transaction(callback). The driver is synchronous, so an await inside a transaction would let the commit run before the awaited part; and since a method never yields mid-transaction, two calls on one store in serve or mcp cannot interleave inside one BEGIN (phase 1 plan, D3). A large write therefore blocks the event loop while it runs β€” keep writes in bounded batches.

One method is several transactions on purpose: replaceConversations (phase 3). A chat of 1M messages takes seconds to write, and every other process waits at most 5 s for the lock, so it writes a new build of the chat's links and conversations in transactions of ~250 ms with a 120 ms pause between them, makes the build current in one more, and drops older builds the same way. Each transaction runs synchronously to its COMMIT; the pause is between them. Readers see only conversation_state.current_build, so a half-written or failed build is never read. Without the pause the next BEGIN IMMEDIATE wins the lock again at once and a waiting process sees no gap (bench/disentangle/, plan phase 3, C3).

The user's agent links what the rules leave open (phase 4, plan). The CLI never calls a model (NEED-405); it hands the agent batches and stores its answers, in src/store/sqlite/batches.ts:

  • A batch is the earliest live message that needs the agent β€” no reply the messenger records, no current agent answer β€” and the next --size live messages, plus BATCH_CONTEXT (50) messages before it as context. The rules' current links come along as candidates. No table holds batches.
  • The batch id (batchId) names the chat, the first and last message to answer, and a hash of every live message between them. links add reads that span again and refuses the answer when the hash differs: a message added or deleted inside it means the agent answered a window that moved. Answering some of its messages leaves the id valid.
  • An answer is checked whole before anything is stored (saveAnswers): every message one the batch asks about, each parent in the batch and earlier than its message, no message twice, confidence 0–1, a model named. One refusal stores nothing. A message's new answer replaces its earlier one; the rows are message_links with source = 'agent', outside any build, so a rebuild keeps them. An answer goes stale when its message or parent changes after it was written, and the message needs the agent again.
  • The choice (choose, src/conversations/link.ts): the messenger's reply, then a fresh agent answer β€” including "starts a conversation", which drops the rule's parent β€” then the most confident rule. An answer naming a message the chat no longer holds leaves the choice to the rules.
  • Permission: conversations links has its own key, conversations.links (keyForCommand, src/sends/permissions.ts), so a profile read-only on messages can still link β€” the answers write only to the local store. batches next shows message text and is checked as messages.

Chunks and vectors (phase 5, store version 14, plan). Each build also writes chunks rows of type conversation, with their message range in chunk_messages: a conversation cut at message boundaries into pieces of at most CHUNK_CHARS (src/conversations/chunks.ts), each with its first and last message and the sha256 of its text. A message longer than a chunk is split into overlapping pieces (splitText), a chunk each, and since store version 21 such a chunk also keeps the stretch of the message it holds (text_start, text_end), so every reader below cuts the text the same way. The text is never stored. Chunks cascade with their conversation, so old builds' chunks go with them. chunk_vectors is keyed by model and that hash, with no chat: a rebuild writes new conversation rows, and a vector tied to them would be thrown away each time, while a chunk whose text did not change keeps its hash and finds its vector again. conversations embed reads a chunk's messages again (chunksToEmbed, src/store/sqlite/vectors.ts), rebuilds its text with chunkTextOf and embeds it only when the hash still matches; a chunk whose messages changed since the build waits for the next one. A search re-reads each meaning hit the same way (chunkFreshness) and marks it stale or drops it, and tombstone deletes the vectors of the chunks a deleted message was in (purgeVectorsOf); how fresh each chat is, is one query (readiness) behind conversations status and search's readiness. The same readiness picks what conversations build and embed without --chat, search conversations --catch-up and MCP conversations_refresh do: one service method, embeddings.refresh, bounded by chats and chunks. How the search over them works, end to end and measured: search-indexes.md.

The owner's tags (version 16) are one table, tags, for a chat, a person (an identities row) or a message β€” taggable_type and taggable_pk, so no foreign key. Triggers in the file drop a message's tags when it is deleted or tombstoned and a chat's when it is deleted, so a build that knows nothing of tags β€” 0.49.0 included β€” cannot leave one behind; identities are never deleted. tag: in a strict search compiles to one exact condition over the three (src/store/sqlite/lucene.ts), and tags commands and MCP tools write through services.tags with their own permission keys (tags.add, tags.remove).

The text of files (version 19) is attachment_texts, one row per attachment: what attachments extract read from the file messages download saved (attachments.local_path, written by the download since this version's package) β€” plain text, HTML, RTF, Word through mammoth, Word 97–2003 through word-extractor, a PDF's text layer through unpdf (dependencies of this package, loaded on first use: src/readers/pdf/, picked by src/readers/registry.ts), ODF/OOXML/EPUB through the built-in readers (src/attachments/documents.ts) β€” or what an agent wrote back after reading a scan (origin: extracted or agent; an extraction never replaces an agent's text). Its words are in attachment_words, contentless FTS5 with rowid = attachment pk, kept by triggers; a voice note's transcript is its attachment's text with extractor speech:<source>. content: (file text) and transcript: compile to exact conditions over it. A bare word (Predicate.implicit) also matches there and in file names: its leaf ORs those sets in (elsewhere), the query is driven by the union of the sets and ranked by message_words joined LEFT, with hits outside the message text tiered transcript β†’ file name β†’ file text (lucene.ts). No foreign key, so the purges of older builds still work; triggers erase the text when its message is tombstoned or deleted, or its attachment deleted (NEED-393 A, tested against 0.49.0). store reindex rebuilds the index. attachments list is:needs_text and attachments text set (MCP attachments_list, attachments_text_set) are how an agent finds a scan, reads it itself and writes the text back, all through services.attachments.

Remote agents receive retained bytes through bounded attachments show / attachments_show, with whole-file SHA256 and account/message binding. See attachment transfer.

Agents perform self-OCR by default. Explicit attachments extract --ocr selects the models.ocr gateway for bulk extraction; without it no model is called. Its typed image parts are local validated base64, never a URL to fetch. Scanned PDFs use the optional unpdf/@napi-rs/canvas renderer, at most20pages and bounded image bytes/ pixels; mixed PDFs keep text-layer pages local. The worker caps concurrency1–8, keeps page order, and writes complete text atomically to the same table/index. Extractor identity records OCR version/provider/model/endpoint hash; cache reuse requires that identity plus the file hash. Failed or cancelled OCR never overwrites good text. A provider rate limit stops later requests in the run, without retries. Needs-agent items include localPath; it is a filesystem reference, not remote artifact transport. OCR contract.

Searches (version 17) are one table, searches: every successful search messages and stats messages show run records its parameters as canonical JSON (searchRecordOf, src/services/search-record.ts) β€” never a message or a result β€” from MessagesService, so the command and the MCP tool both record. An identical unnamed run counts on its row (a unique index over command and parameters where the name is null); unnamed rows past the newest 1,000 are dropped. A named row is a saved search, which --saved re-parses on every run. --no-record (ServiceDeps.history: false) records nothing; a bot's search calls searchStore and is not recorded.

Where the queries live. src/store/store.ts holds the MessageStore interface and storeOver, a facade that opens the transaction and delegates. The SQL is in src/store/sqlite/, one module per kind of record β€” accounts, identities, chats, messages (writes), reads, search, ranges, sync (state and fetch leases), transcripts, conversations, tags, searches β€” as plain functions taking a StoreContext: the connection as the CacheDatabase seam and as Drizzle (orm), and the clock. Queries are Drizzle's builder, called synchronously (.get(), .all(), .run()); FTS MATCH, json_extract and the coalesce(excluded.…) upserts stay sql fragments. A write goes through write and a multi-query read through read (src/store/sqlite/transaction.ts), never Drizzle's transaction or a hand-written BEGIN: the outermost write recomputes what the triggers queued (stems, mentions, involvements) before it commits, and the nesting depth is kept per connection. A statement that runs for every saved message is .prepare()d once per store (identities.ts, messages.ts): built per call, Drizzle cost about a quarter of the load rate (results). src/store/search-plan.test.ts fails if search starts reading the text index once per message.

Opening a store also fills messages.normalized_text for rows stored before version 6, when at most 5,000 of them wait (BACKFILL_ON_OPEN, about 40 ms); a larger file keeps working and waits for the maintenance command that fills it in batches.

Two text indexes. messages_fts (trigram over text) answers substring search. message_words (version 12) holds the words of normalized_text for ranked search β€” contentless with delete support, kept by triggers that fire only when the normalized text, the sender or the chat really changed, with a scope column of c<chat_pk> and s<sender_identity_pk> tokens. Ask its vocabulary through message_words_vocab with col = 'normalized_text', and restrict a word query to that column, or the scope tokens come back as words. A file of at most BACKFILL_ON_OPEN messages is indexed by the migration; a larger one records in search_index_state the highest pk the batches must reach. fillSearchIndex (src/store/sqlite/search-index.ts) gets it there in batches of 5,000 β€” the normalized text first, then the words, then the typo vocabulary (search_terms, search_term_trigrams), then the words of messages stored since β€” from store migrate, store reindex, and up to 200 ms before each search messages. When everything is built it returns without taking the write lock. search messages ranks by it. src/store/sqlite/words.ts keeps knownTerms and termCandidates, which --discover uses for typo correction; src/store/search-plan.test.ts fails if a search reads an index once per message.

Stems (version 15). message_stems holds the Snowball stems of messages.text β€” stemmed before folding, by createStemmer (src/search/stem.ts) β€” in the same shape as message_words: contentless with delete, the same tokenizer, the same scope tokens. A text term or phrase matches the word index OR the stems (src/store/sqlite/lucene.ts), so no exact hit is lost to a different fold; exact: reads the words only. A stemmed query is driven by a materialized set of the messages its indexes name, read by key, and ranked by a materialized stems bm25 joined LEFT, exact forms first β€” joining the stems as the ranked table would drop exact-only hits, and joining it row by row was 80Γ— slower (bench/stemming). While the stems are building, or chosen by a newer tool, search and stats run a stemmed query on the word index alone and say so in query.stemming (applied: false); only stems waiting for the owner's store reindex refuse with index.not-ready (searchStemming, src/services/messages-search.ts). serve fills building stems a slice at a time (stemFills). A default the store saved carries origin: "default" and the defaults' version, so a newer default replaces it and an older build leaves it. SQL cannot stem, so the triggers only queue the message in message_stems_pending, and JS writes the stems (src/store/sqlite/stems.ts): every store write empties up to 500 queued messages before its COMMIT, and fillStems stems the messages up to the watermark and then the queue β€” on open for a small file, in store migrate, store reindex, and inside the same 200 ms before a search. The analyzer column of the row records the Snowball version and stemmer choices that built it; the first fill claims an unbuilt row. The choices are store-wide (store_settings, written by config set search_stemmers.cyrillic|latin), since every profile, tg and MAX share one index. Ready means filled to the watermark, an empty queue, and the analyzer this binary would build β€” an older binary runs the triggers and never drains, so a flag alone would lie. Stems built by other choices are written by nobody and rebuilt only by store migrate or store reindex, never by a search, so two tools with different settings or Snowball versions cannot rebuild each other's index in turn; a row a newer Snowball built is refused with "upgrade this tool". The first claim and every rebuild also save the choices, so a tool with another default sees a setting and refuses as "unknown" instead of rebuilding. latin may name several stemmers: each Latin stemmer gives a stem sequence, and the distinct ones are stored apart by STEM_SEPARATOR so a phrase never spans two; a query is the OR of its phrases.

Notes and links (version 25, plan). MessageStore.notes (src/store/sqlite/notes.ts) holds the owner's records, which belong to no account: note_folders (an id; the path is each computer's config), notes (from a file or written here) with note_revisions, entities, and links β€” every connection, both ends a typed reference (src/domain/references.ts). A link whose written target names no one yet keeps target_folded; creating, renaming or aliasing a person resolves it in the same write (resolvePersonLinks, called from identities.ts, private-people.ts, person-links.ts). KnowledgeStore and contacts notes read and write these tables. Builds before 25 may still write the old ones, so openStore copies what is missing on every open (src/store/sqlite/notes-copy.ts, behind notesToCopy).

Notes' search indexes (version 26). The same words and stems as messages β€” note_words, note_stems, same tokenizer and the store's stemmer choices β€” and note_chunks, whose hashes find their vectors in chunk_vectors beside the conversations'. Triggers on notes only queue the note in note_index_pending; drainNoteIndex (src/store/sqlite/note-index.ts) writes words, stems and chunks in JS before every notes search, chunksToEmbed and nearest, and in store migrate and store reindex. Notes are few, so a change of stemmer choices queues them all again at the next drain instead of waiting for a reindex. searchNotes (src/store/sqlite/note-search.ts) compiles the same parsed query as messages over the fields a note has (text, exact, body, tag, date, in) and refuses the rest; searchNotesQuery (src/services/notes-search.ts) is the service a search notes command calls.

One search group (plan, STANDARD.md "Search hierarchy"). src/cli/messenger/search-command.ts mounts search all|messages|mail|notes|conversations (and topics where the messenger has forum topics); bot search messages sits under bot. Each leaf calls the service the old command called, and the MCP tools are named after the leaves (src/mcp/tools/search-tools.ts). searchAll (src/services/search-all.ts) runs messages (every messenger account, never mail), mail and notes in turn and merges their lists by reciprocal rank, so no resource's own scores are compared with another's. A resource that cannot answer the query β€” a field it lacks, or nothing stored β€” is listed in skipped with the reason instead of failing the search. Its messages take the same server step as search messages (--backend, default both, through messages.searchAll); mail and notes are always the local store's.

searchAllIncludingMeetings (src/services/search-all-meetings.ts) is a separate opt-in service. It delegates the legacy search unchanged, then blends its ordered result with one explicit meeting account's prefix matches. Meeting hits carry their numeric source identities and ref, the meeting:<accountId>/<meetingId> reference of formatMeetingReference that memo takes as evidence. It resolves the account through storedAccount and never registers one. Because MeetingStore.search pages meetings before matching their rows, the service visits candidate meetings serially and keeps a source cursor inside a meeting's hit list. Sparse pages can report unknown hasMore at the scan limit. The candidate bound does not bound the store's raw hit allocation, and offsets do not give a snapshot. search all --source and the search_all tool's source reach it through messages.searchAllWithMeetings, which never lets the meeting account default to the messenger's own: it reads meetings only from the one meeting account the list names (of several a bare zoom names, the one that holds meetings), and otherwise answers skipped for meetings. No meeting reference resolver or vector index is introduced.

Server search beside the archive. messages.search with backend: both|server first runs searchServer (src/services/server-search.ts): it turns the resolved query into at most three server queries (required words, one chat, one sender, dates), calls the optional MessageSearch capability under a time bound, looks up which hits the store already held, and saves the rest with via: "search" β€” never markRange, so ranges and coverage stay what messages sync proved. The strict local query then runs once over everything; server restricts it to the returned messages (QueryExecution.only). A page that arrives after the bound is dropped unsaved. The step is not wrapped by stored, because it must look before it saves.

Drizzle is bundled, not installed. drizzle-orm is a development dependency. pnpm build runs scripts/bundle-drizzle.ts, which writes the Drizzle modules the store uses into dist/store/sqlite/drizzle/: loaded from node_modules, Drizzle costs Node about 200 ms per process, bundled about 6 ms. So:

  • Import Drizzle only through src/store/sqlite/drizzle/ β€” core.ts for the query builder and schema functions (add a name there when you need one), node.ts and bun.ts for the drivers. Anywhere else, biome.json refuses drizzle-orm (noRestrictedImports, biome.json:40); the folder itself and tests are exempt (:84). A direct import passes the tests and crashes tg and max at runtime, where drizzle-orm is not installed.
  • The Node and Bun drivers are separate bundle entries and are loaded by dynamic import(): each imports its own runtime's SQLite at the top of its file, so loading one under the other runtime fails.
  • scripts/check-dist.ts refuses a dist that still imports drizzle-orm and reads a row through the bundle. CI runs it under Node (pnpm check:dist) and under Bun (bun scripts/check-dist.ts).

Snowball is vendored, not installed. src/search/snowball/ holds the official Snowball 3.1.1 JavaScript for Russian, Spanish and English, generated by bin/snowball-update (BSD-3-Clause, also in THIRD_PARTY_NOTICES). Never edit it by hand; bin/snowball-update --check proves it matches the source. tsc does not emit JavaScript it did not compile, so pnpm build copies it with scripts/copy-snowball.ts, and scripts/check-dist.ts stems a word from dist. Use it only through createStemmer (src/search/stem.ts): it picks the stemmer by the script of a word, stems, then folds with normalize(), and its identity names what built an index.

Migrations

One migration, store version 1, creates every table in docs/storage/schema.md, in the file wirecat.db. It is one folder under drizzle/: the SQL pnpm db:generate wrote from src/store/sqlite/schema.ts, then the FTS5 indexes, their triggers, the WITHOUT ROWID search-term tables and the seed rows, which Drizzle cannot model. pnpm db:bundle copies it into src/store/sqlite/migrations.generated.ts, and src/store/sqlite/manifest.ts numbers it. Our runner (migrate) applies migrations under BEGIN IMMEDIATE; Drizzle's own migrator is not used.

The schema before this one lived in messages.db and is not converted: the new file has a new name so that a build still installed never opens it. Every migration after the first is forward-only, additive, numbered, and never edited once it reached anyone's file β€” a test refuses a generated rebuild of a base table. min_compatible lets an older CLI keep using a file a newer one migrated; a migration that drops what an older build reads raises it, and that build then refuses the file and asks to be upgraded. It is 1.

⚠ Announce a migration number before writing it. Several sessions work in this repository at once, and two of them taking the same number is a conflict no rebase fixes. The next free number lives in COORDINATION.md; take it by editing that line in a PR of its own, merged before the migration.

Adding a migration

  1. Take the number as the paragraph above says, and wait for that PR to merge.
  2. Change the spec first, scripts/schema/spec.mjs, and pnpm schema:render for the page. Then change src/store/sqlite/schema.ts to match, until pnpm schema:check passes (CI runs it), and run pnpm db:generate --name version-<n>-<what>. It writes drizzle/<timestamp>_version-<n>-<what>/migration.sql.
  3. Read the SQL before anything else. For a constraint change (a new NOT NULL, a changed default, a foreign key), drizzle-kit rebuilds the table: CREATE TABLE __new_…, copy, DROP TABLE, RENAME. A build already installed breaks on that, and on messages the DROP also removes the full-text triggers. Choose a change drizzle-kit can express as ALTER TABLE … ADD β€” a new column is nullable or has a default β€” or a new table. The test "never rebuild a base table" (src/store/sqlite/manifest.test.ts:14-19, :47) refuses the rest.
  4. Triggers, FTS5 tables and data fills go in a custom migration, since schema.ts holds neither triggers nor FTS: pnpm db:generate --custom --name version-<n>-<what>, then write the SQL into the empty file. Put --> statement-breakpoint between statements β€” the bundle splits on nothing else (scripts/bundle-migrations.ts:20), so a trigger body with ; inside stays whole. Generate it right after step 2: folders apply in name order, which is their timestamp.
  5. Add a row to MANIFEST (src/store/sqlite/manifest.ts) for each new folder, with the same version. Two folders with one version β€” the generated one and its custom one, as version 6 β€” apply as one migration and write one schema_migrations row. Versions run on without a gap (manifest.test.ts:38-45).
  6. minCompatible stays where it is β€” 1 today β€” for an additive change. Raising it locks every older build out of the file: ask the owner first; it is a major version of this package, and tg-cli and max-cli ship their upgrade the same day, as with version 6.
  7. pnpm db:bundle after every db:generate and every edit of a migration.sql. It rewrites src/store/sqlite/migrations.generated.ts; pnpm build does not, and the test "are bundled exactly as drizzle-kit wrote them" (manifest.test.ts:22) fails until you run it.
  8. Test the upgrade: a src/store/version-<n>.test.ts opens a file at version nβˆ’1, migrates it and reads what the new version added. src/store/upgrade.test.ts already opens a store 0.218.0 made, at version 1, with every migration and keeps its rows. src/store/sqlite/schema.test.ts opens a new store and compares every table and column with schema.md, and compiles every trigger: change the page with the schema.
  9. CHANGELOG: an entry under ## Unreleased that names the store version, as "Chat members in the store (store version 7)" does.

A migration is frozen once released: fix a mistake with the next version, never by editing a folder. upgrade.test.ts pins version 1 by hash, and bin/regen-initial-migration refuses once a v* tag holds it.

Open tasks

Tasks waiting on the owner belong to src/tasks/ (exported as ./tasks), which knows no messenger and no SQLite; biome.json holds it to that and lets the rest of the package import only its entry points. The store implements its TaskStore as store.tasks, in the tasks table (store version 20, src/store/sqlite/tasks.ts), so backup, restore and export carry tasks with the messages, and a task joins its message in one query. A task holds a locator, never the message text; kind, state and origin are checked in code, as chats.kind is.

A bug, feature or chore has no message behind it, so it never reaches the package: store.tasks.create (src/store/sqlite/work-tasks.ts) writes it into a project the owner names, keyed <project key>-<n>, with no package id, and the package's reads skip rows without one. edit, assign, details, find and events work on any task β€” priority, parent, description, assignees and the verdict the package does not carry β€” and each change of status, due date, priority or assignee is a task_events row.

The command skeleton

run() (src/cli/program.ts) never throws; it returns an exit code. It lifts a profile given as the first word, resolves settings (flag β†’ environment β†’ file β†’ default, src/cli/settings.ts), records runs (src/runs/; the runs command is src/cli/runs/) and closes what a command holds when --timeout ends it (src/cli/deadline.ts).

Optional ProgramDefinition.configure, prepare and onFailure let a consumer add root options, provide legacy command context and settle its own recording before the shared fallback. Help/version exits skip failure settlement. The lifecycle contract describes the order and failure behavior.

A CLI describes its messenger once β€” a Messenger (src/cli/messenger/context.ts) with a connect that returns a MessengerAdapter (src/ports/messenger.ts) β€” and gets the shared commands, one file per resource in src/cli/messenger/. Two wrappers sit between a command and the adapter: observed.ts times each call into the run record, stored.ts saves what was read. A new adapter method is optional and reached with capability(); the wrappers pass through any method they do not name. How to write an adapter for a new messenger, and test it with the contract cases, is the adapter guide.

The MCP server (src/mcp/) holds one connection for minutes and runs one call at a time; each tool lives in src/mcp/tools/<resource>.ts and answers what the command's --json prints.

mcp --http (src/mcp/http/, CLI-58) serves the same factory over Streamable HTTP on 127.0.0.1 behind the owner's tunnel. oauth.ts is a one-owner OAuth server β€” a token needs the code printed in the terminal, and only token hashes reach the disk; serve.ts checks the Host, answers the login routes, requires a bearer token for /mcp, and sends 2025-era requests to per-session servers bound to their OAuth client, because a stateless server forgets the form capability a client declared in initialize. Writes over HTTP follow the profile's permissions exactly as over stdio; no form is forced (NEED-774, which replaced NEED-593).

Services

Five layers, each calling only the ones below it: the domain (src/domain/), the adapters (each CLI's own, behind MessengerAdapter), the ports (port.ts, the store), the services (src/services/) and the interface (the commands and the MCP tools). biome.json refuses an import of commander or of anything under src/cli/ from src/services/, src/sends/, src/replies/, src/attachments/, src/moderation/, src/models/, src/analysis/, src/domain/ and src/ports/, and of a command file from src/mcp/: what they share with a command lives below it, and the command imports it. A service sees the messenger as MessengerTraits (src/ports/traits.ts), the fields it reads; the CLI's Messenger adds how commands connect and resolve settings. The app's identity is src/ports/app.ts, error shapes and reasons src/errors/, run records and log keys src/runs/. It also refuses a table module under src/store/sqlite/ from src/services/, src/sends/, src/mcp/ and src/cli/: they reach the store through src/store/store.ts, which re-exports what they need.

A service is a plain object made by a factory over ServiceDeps (src/services/deps.ts): the messenger, offline, and a connection, a store and an account that are each opened on first use β€” so a read from the store never connects. A service reads and writes through accountStore(deps): the store with the running account bound (store.forAccount(key)), where no call takes an account and a row id of another account β€” an attachment, a mail thread, a meeting β€” is not found. A read across accounts on purpose takes the whole store from crossAccount(deps, reason). chats, conversations and attachments work this way; the other services still pair deps.store() with deps.account(). servicesFor(deps) hands out messages, chats, people, inbox and archive. A command gets them from withServices on its context, which closes what was opened; an MCP tool builds them over the session's connection with onlineDeps, or over the store with storedDeps. The services callback can also borrow the held connection for hearing. It fetches recordings on that connection and releases it before local recognition; closing is awaited once, and store cleanup still runs if closing rejects. Review's optional enrich hook runs after admin lookups and before unanswered filtering, so transcript text can supply a voice question without changing the archived message text. MCP retains session-owned connections. Either way a command and its tool run the same method, and so answer the same error for the same input. Each caller still parses its own input, so an error names --since in a command and since in a tool.

A messenger that pushes its history reads it from the store. Messenger.history: "store" sets ServiceDeps.reads, and every read that --offline answers from the store β€” chats list|show, messages list|context, contacts list|show β€” answers from it for that messenger without --offline, and never connects; writes still do. serve keeps the store filled, so such a read warns on stderr when no serve holds the profile, and a chat with nothing stored is not_found. inbox and review read the stored chats and messages through storeReader, the same code over an InboxReader, so their --json is the online one; messages sync refuses for now. The MCP chat resource reads the store too, and so does an MCP tool with a served body: it gets the services over the store and reaches the session's connection only through connect, for transcription.

A CLI replaces a use case, not a command. Messenger.services is an Override: it gets the shared services and returns the ones it changes, and can call the shared method inside its own:

services: (base) => ({
  messages: { ...base.messages, list: (chat, window) => maxList(base.messages, chat, window) },
}),

Commands and MCP tools both see the replacement. To add a subcommand, a CLI calls addCommand on the command a factory returns.

Saving what a read answered and timing each call stay decorators on the adapter (stored.ts, observed.ts), stacked by connected() in context.ts, so no service can forget to save.

Who consumes it

  • tg-cli β€” the whole skeleton, the store, the guard and the MCP server; its adapter is under its own src/telegram/. To try an unreleased change: bin/try-messaging in a tg-cli checkout beside this one, never a committed file: path.
  • max-cli β€” both its personal account and its bots. The personal account is a Messenger (src/messenger.ts) whose connect returns maxAdapter (src/adapter/max-adapter.ts) over max's own protocol client, and it uses the shared commands, services, store and guard; its own cache is gone. max keeps its protocol, session, max serve, the Bot API slice and its own MCP server (MaxSession, src/mcp/server.ts), whose tools run the shared services through withShared (src/mcp/shared.ts).

Both pin an exact version; a change here reaches them through a release and a bump in each.

Package upgrades

upgradePackage (src/services/package-upgrade.ts) owns the explicit upgrade decision and invokes installer/latest/install and optional host lifecycle ports. upgradeCommand renders its outcome; consumers bind their existing update environment and server restart policy. No installer runs on check/no-op paths, and a failed install or callback never retries. The upgrade plan describes the common result and consumer adoption.

Agent conversation linking over MCP

conversations_batches_status reports messages, batches, characters and a token estimate before text is fetched; conversations_batches_next returns the next window or { batch: null }. conversations_links_add stores batch answers atomically, conversations_links_clear drops them, and conversations_build rebuilds one stored chat without inference. The last three write only locally under conversations.links: readonly/deny hides them, ask refuses with the config key. The link-conversations prompt reads the same shipped skill as the CLI; its cost/consent gate is per chat. These tools call services.conversations; inference in this MCP loop belongs to the owner's agent. The explicit CLI build --analyze path can call a configured provider with consent; see AI providers.

Consumer parity: planned for both CLIs on their next SDK adoption. Source-level shared schema and synthetic MCP proof are tested here; installed consumer parity is not claimed before publication.

Bounded preparation after history fetch

Per-profile search_catch_up defaults to false; messages sync can override it with --catch-up or --no-catch-up. The fetched chat alone is prepared with500chunks,10,000messages and30seconds by default. Explicit budgets have hard caps. Invalid permissions/bounds refuse before connection; missing local e5 keeps graph-only progress, without downloading a model or using a remote provider. Fetched history persists even when preparation stops; the result reports a separate prepared state.

Cancellation checks run through input reads, linking, chunk cutting and staged publication. A failed staged build leaves the previous published generation available. Completed builds remain valid if later vector preparation stops. Edits and successful rebuilds prune affected vector hashes, verifying current text across shared users. Shared-use checks are bounded; an inconclusive hash is kept rather than removing another account's valid cache. Deletion safeguards remain in force.

Interior archive gap repair

store gaps plan <chat> uses recorded inclusive coverage ranges, with versioned account/chat/order identity and a fingerprint. Unknown archive edges remain separate from interior gaps; message-id holes and quiet periods alone do not establish missing history. Planning is local.

store gaps repair <chat> explicitly fetches up to five gaps and500messages within30seconds by default. --max-gaps, --limit, --repair-time, --page-size and --pause set bounds. Optional --fingerprint refuses a stale plan before connecting. A per-account/chat lease prevents concurrent repairs, while every page persists through the existing ingestion path. Repeated timestamp pages, partial responses and inaccessible history stay pending; nothing is deleted by absence. Re-running plans the remaining ranges. --background uses store jobs show/list/cancel and captures the plan's fingerprint. MCP offers matching plan/repair and profile-scoped job metadata tools.

Repair requires store.gaps.repair write permission and message read access. The optional Fetching.beforeInclusive adapter declaration describes native timestamp cursors; it does not change regular fetch behavior.

Repair accepts the same catch-up overrides and budgets as fetch. One preparation follows the repair, within its remaining overall time budget; history completeness and preparation progress stay separate.

Adapter settings compatibility

settingsFor(app, extension) owns common settings resolution. An optional resolve hook receives resolved common settings, ordered personal/bot/profile/default layers and fromLayers; it returns adapter values and their sources without rereading configuration. profile and defaults extend the generated strict schema. An optional schema validates both reads and writes when an existing consumer needs stricter scope rules or unchanged errors; it must produce the common configuration shape, including profiles. sourcePaths opts into full configuration paths, including legacy permission provenance. parseDuration preserves a consumer's duration syntax and diagnostics. Without these hooks, existing source labels, validation and duration parsing stay unchanged.

Administrator statistics

The shared adminStatistics service implements unanswered, selected-identity response times, known-join newcomer help and stored discussion reports. admin-statistics.ts in the SQLite seam uses compiled query selection and bounded context within one read snapshot. Question selection is separate from reply context through a captured cutoff. Membership stays distinguish joinedAt and firstSeenAt. Reports and their saved runs reuse existing CLI/MCP discovery and evidence paths; a separate versioned selection binds query, options and cutoff. See the user guide.

Retention and counter observations

Migration 24 adds membership_batches, membership_batch_members and message_counter_observations. Only explicit remote reads/updates record observations. Legacy rows are not backfilled with observation times. Batch membership points to identities and separate stays; partial lists cannot close stays. Definite known rejoining dates split stays. Invalid, duplicate and out-of-order roster observations are rejected atomically.

Retention uses SQL lookup of the first batch within a checkpoint's 24-hour tolerance, then bounded cohort aggregation (10,000 stays, ten checkpoints, 8 MiB fingerprint, 64 KiB evidence). One read transaction pins membership and activity. Cohort selections freeze cutoff and options; existing message-evidence CLI/MCP paths page through member evidence.

Counter timestamps are independent by field and tied to the exact observed value. A legacy writer can still use schema 6 statements; differing legacy values have unknown freshness. Counter-only updates preserve other message fields and ignore tombstones. An older response cannot replace a newer authoritative observation. Rankings/evidence expose these states and fingerprint observation changes.

The optional adapter fetchCounters returns explicit counter observations; Messenger.counterFields declares static support for a connection-free preview. The shared service resolves a bounded query or exact locator selection before connecting and guards stats.messages.counters.refresh as a local write. It closes stalled connections on abort and records partial results without message actions. CLI commands and the existing three-tool MCP frontend share this service. See statistics guide.

Meeting integration capabilities

The meetings module (src/meetings/, once @wirecat/cli-meetings 0.4.0) supplies optional ports alongside the unchanged base meeting store. Native event and participant association writes check the selected account, live target and optional previous value in one transaction. Participant association moves only one identity and records its manual audit and account observation; it never merges entire people from display names.

createMeetingContextService reuses existing notes, knowledge and memory stores with canonical meeting references. It validates account ownership and evidence on preview and again before apply. Writes require explicit application; owner memories are confirmed as in memo. Memo already provides meeting notes, tags and memory commands, so providers reuse these records instead of creating another knowledge system.

searchHits pages inside one occurrence with an account/query-bound continuation and stored text byte budgets. Each term matches a literal prefix or a configured shared Snowball stem variant; English, Spanish, Russian and disabled stemming follow existing store settings. Tokens longer than 1024 characters match literally. indexSearch explicitly maintains a bounded account queue batch; read pages do not drain queues. Write transactions keep the bounded shared involvement queue current. Coverage reports pending indexing and unknown archive completeness. Append receipts count new occurrences, inserted/replayed revisions and superseded revisions in the same transaction. Their legacy full meeting snapshot still hydrates retained history; compact CLI receipts do not change that store limitation.

openLocalSpeechRecognizer uses an already installed speech model, bounded mono PCM windows and bounded text. It never downloads a model and loads the existing engine only when explicitly opened. Cancellation occurs between synchronous inference windows. Model provenance identifies the pinned registry configuration; importing the descriptor requires no installed files.

Shared result projection

The CLI's fieldsOf and projectFields delegate to cli-base's shared parser and projector. Payload-relative dotted paths traverse objects and arrays; items.ref selects each list row's reference. Item/list envelopes, coverage, continuation, missing data and execution metadata and operation identifiers stay intact. Parent selections retain an entire subtree. Parsed and direct paths reject unsafe or empty selections, more than 128 paths, or paths longer than 256 characters. The runtime peer and development pin require cli-base 0.31.1 for this shared behavior. The messenger runner retains its own write and resource lifetime policy; no store schema is involved.

File and folder catalog

MessageStore.catalog stores provider-neutral file/folder metadata and parent keys in the existing documents.metadata.catalog namespace. Providers supply account-scoped external IDs and optional observed actors; metadata-only entries have no text. Upsert preserves live text, content hashes, revisions and other extracted metadata. Content readers preserve the catalog namespace when updating metadata. Stable document:<id> references reuse tags, notes and relationships; Google folder hierarchies remain distinct from notes collections. Reconciliation marks absent entries inactive without deleting annotations. Reads use explicit account IDs with bounded pages and metadata; provider adapters own hierarchy navigation. No migration or provider dependency is added.

Meeting read batches

Base owns bounded input, registered-handler preparation and correlated serial execution. Core's src/cli/meeting-batch.ts reuses the pure grammar from the independent meetings module and the standard envelope/formatter. src/services/meeting-batch.ts binds the existing meeting search, index and passage operations to one host-selected account. The batch performs one bounded index pass; single searches retain their existing refresh policy. No provider or shell dispatch is introduced.