Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d371267
Add an in-process embedding engine to the inference patch
senadaruc Oct 14, 2026
b3de4d2
Add conversation memory's encrypted store and engine
senadaruc Oct 14, 2026
8c035ac
Read memory sources from their local stores on this Mac
senadaruc Oct 14, 2026
26f7d96
Let suggestions use the earlier messages of the conversation
senadaruc Oct 14, 2026
6b758b0
Add Settings -> Memory and start memory with the app
senadaruc Oct 14, 2026
a5f2ec1
Document conversation memory
senadaruc Oct 14, 2026
621d5d6
Require a mail reply's whole quote header to be the thread's people
senadaruc Oct 14, 2026
366e4e5
Make an ambiguous expression explicit and give memory's main-actor cl…
senadaruc Oct 14, 2026
71c448f
Split expressions that older compilers type-check too slowly
senadaruc Oct 14, 2026
38e24f1
Move the memory poll loop body out of its Task closure
senadaruc Oct 14, 2026
5c929ac
Keep deleted memory out of caches and stale writes, and starts in order
senadaruc Oct 14, 2026
2c72d5f
Make the memory readers reconcile, retry and survive bad input
senadaruc Oct 14, 2026
b9df9fa
Accept only an unambiguous client quote header, and close cache races
senadaruc Oct 14, 2026
9a86353
Give mail no conversation scope from its body
senadaruc Oct 14, 2026
4860099
Give mail replies their thread, subject, addressee and compose column
senadaruc Oct 14, 2026
37a6c5c
Add the rules and memory search answers are drafted from
senadaruc Oct 14, 2026
188dcbf
Offer drafted answers on a card at the caret
senadaruc Oct 14, 2026
9213c41
Document answers and mail reply context
senadaruc Oct 14, 2026
ea75a18
Keep mail reply bodies, read each quote once, and condense for Apple too
senadaruc Oct 14, 2026
07afad3
Drop cached answers when memory changes, and fix review findings
senadaruc Oct 14, 2026
535a4fd
Fix the memory issues #872's review found in #871's files
senadaruc Oct 14, 2026
4db66ef
Merge #871's latest memory fixes
senadaruc Oct 14, 2026
0de7769
Never download an online-only cloud file again because we gave it back
senadaruc Oct 17, 2026
4cc9f62
Merge #871's cloud-drive eviction fix
senadaruc Oct 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -365,11 +365,18 @@ jobs:

for code_path in "${nested_code[@]}"; do
if [[ -e "${code_path}" ]]; then
# The app itself keeps its resource-access entitlements (Calendars). The array is
# expanded as ${a[@]+"${a[@]}"} because bash 3.2 treats an empty one as unbound under set -u.
entitlement_args=()
if [[ "${code_path}" == "${app_path}" ]]; then
entitlement_args=(--entitlements Cotabby/Cotabby.entitlements)
fi
codesign \
--force \
--options runtime \
--timestamp \
--sign "${signing_identity}" \
${entitlement_args[@]+"${entitlement_args[@]}"} \
"${code_path}"
fi
done
Expand Down
45 changes: 45 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,51 @@ Do not put raw screenshots, unbounded OCR dumps, or noisy AX tree text directly
Normalize, bound, and mark unavailable states explicitly. Screen Recording permission is separate
from Accessibility and Input Monitoring.

## Conversation Memory

Conversation memory is an optional, off-by-default, on-device index of the user's chat, mail,
calendar and document history:

- `MemoryEngineController` (Services/Memory): on/off, the pinned embedding model download, the
Keychain key, and the running `MemoryEngine`.
- `MemoryEngine`: the ingest pipeline (exclusions, retention, `MailQuoteStripper`,
`MemoryTextScrubber`), background indexing under `EmbeddingSchedulePolicy`, and the scoped search.
- `MemoryStore` / `MemoryVault` / `FlatVectorIndex`: the encrypted SQLite store, AES-GCM sealing
with HMAC lookup tags, and the exact half-precision vector scan.
- `EmbeddingCore`: the llama.cpp embedding engine from the CotabbyInference patch.
- `MemoryHistorySync` and `Readers/`: one read-only reader per source (`MemoryHistoryReading`).
- `MemoryRetriever` + `ConversationScopeResolver` (`SuggestionMemoryProviding`): the earlier
messages of the focused conversation for the suggestion pipeline, from cache, never waiting.
- `MemoryControlModel` and `UI/Settings/Panes/Memory`: the Settings → Memory pane.

Keep its privacy rules: memory never reaches the endpoint engine (retriever, request factory and
router each refuse it); a search is scoped to the conversation the window names, then to
conversations with exactly the same people, and an unknown or ambiguous title returns nothing.
A mail gets no conversation scope: its subject does not say who it is for, and nothing in the body
(the quoted header included) can be trusted to, since the other person shapes it. Mail scope waits
for the compose window's own To/Cc fields. Groups carry a per-group participant marker so they
never widen into a 1:1 chat. A mail conversation is one thread with one audience (`MailAudience`),
identified by addresses only.
Mail is the user's own only when it is in a Sent mailbox. A sync is cancelled and awaited before
anything is deleted (`MemoryHistorySync.cancelSync`). Reader fixtures must stay synthetic.

Answers (`App/Coordinators/Answers`, `Services/Answers`, `Support/Answers`) draft a reply to a
question someone sent the user from memory and offer it on a card at the caret, only in mail apps
and chat apps with an enabled memory source (`IncomingMessageResolver.answersIn`). The sender never
chooses the scope: a quoted mail is data only and never names a remembered conversation
(`IncomingMessageResolver.quotedIncoming`), other conversations are opt-in per source and off by
default, Calendar never answers from its events, and anything ambiguous gives no answer. The
question and remembered text are untrusted input: keep `AnswerGroundingPolicy`'s gates and guards
(relevance, named-subject match,
unsupported numbers and names, secret scrubbing, copied passages from other conversations,
instruction-like text), quote everything in the prompt as data, attribute every fact on the card, draft
on-device only (`AnswerDraftEngine` never uses the endpoint), keep availability facts to busy and
free, and never insert without the user's click or Tab. `MailReplyContext` (Support/Context) reads a
mail reply's quoted thread, subject and addressee from the trailing text; keep it off the endpoint.
Any decision that reads a mail quote header goes through the one strict reader, `MailQuoteHeader`
(via `QuotedReplyParser.quotedMessage`), and it never chooses a scope; never add a second header
parser that decides anything.

## Runtime And Prompting

Runtime generation is split by responsibility:
Expand Down
112 changes: 111 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,112 @@ because the rest of a field is often a quoted thread. History shapes suggestions
`TypingHistoryPhraseEngine` answers from `TypingHistoryPhrasePredictor` when history confidently
knows how a phrase ends. Both are on-device only: the provider returns nothing
for the endpoint, the request factory drops examples for it, and the router refuses to send any
request that still carries them.
request that still carries them. Examples come from the same kind of app
(`TypingHistoryIndex.mayExemplify`): an email takes only earlier email, and terminal or code-editor
text never stands as the style of prose.

A mail reply is the one surface whose most relevant context sits after the caret: the quoted
thread being answered. `MailReplyContext` (over `QuotedReplyParser`) reads it from the field's
trailing text for mail apps only: the prompt carries up to 1500 characters of it, condensed to
"Sender: message" per quoted message; the thread's subject names the surface (Outlook's inline
reply sits in a window titled after the mailbox, which never names a conversation for memory, and
the quoted subject, written by the sender, never chooses memory's scope either); the newest
message's sender becomes a "To:" line right above the draft, so a
salutation addresses them rather than the user; and the screen excerpt keeps only the compose
column. Everything read from the quoted thread stays off the endpoint, like the rest of the
trailing text. The header that decides anything (the subject, the addressee, and for answers the
message being answered and whether a reply is still empty) is read only through one strict reader,
`MailQuoteHeader`; a looser reader only reformats the condensed thread. Neither ever chooses a
memory scope: memory gives mail none.

### Conversation memory

Conversation memory (Settings → Memory) is off by default, and each of its sources is opt-in. It
remembers the user's history from the local stores of WhatsApp, Apple Mail, Outlook (New Outlook's
`HxStore.hxd` and the classic database), the Microsoft Teams cache (Chromium IndexedDB), Calendar
(EventKit), a folder of notes, and documents in iCloud Drive, Google Drive and OneDrive. It uses no
account APIs and sends nothing anywhere; its only own network use is the one-time embedding model
download. Reading an online-only cloud document makes the drive's app download it (and evict it
again afterwards). The mail and chat stores need Full Disk Access; Calendar needs the Calendars
permission and the app's Calendars entitlement.

~~~text
MemoryHistorySync (15 min on AC, on demand)
-> Readers/ (one read-only reader per source, cursor-paged)
-> MemoryEngine.ingest (exclusions, retention, mail quote stripping, secret scrubbing)
-> MemoryStore (SQLite; text, names, titles, cursors and vectors sealed by MemoryVault)
-> background indexing (EmbeddingSchedulePolicy: small deltas anytime, backlogs only plugged in,
idle, cool, outside Low Power Mode) -> EmbeddingCore (llama.cpp, Qwen3-Embedding-0.6B)
-> FlatVectorIndex (half-precision rows, exact Accelerate scan after filtering)

SuggestionCoordinator.memorySnippets(for:)
-> MemoryRetriever (cache first; a stale query starts a background lookup, never waits)
-> ConversationScopeResolver (window title -> conversation within the app's sources; mail never
gets one)
-> MemoryEngine.search (this conversation, then exactly the same people; vector + keyword, RRF)
-> MemorySnippetFormatter -> "Earlier in this conversation:" prompt section
~~~

The store's key is a 256-bit Keychain item that never syncs, per app identity. Message contents,
names, titles, ids and cursors are sealed and lookups use HMAC tags; timestamps, sources and the
settings file (never-remember lists included) are not encrypted. A search never widens past the
conversation the focused window names, except to conversations with exactly the same people; an
unknown or ambiguous title returns nothing rather than a guess. Mail gets no conversation scope: a
subject does not say who a mail is for, and the body (the quoted original's header included) is
shaped by the other person, so no audience read from it can be trusted; the engine never resolves
a title in a mail source either. Mail scope waits for the compose window's own To/Cc fields, read
through Accessibility. Groups (Teams,
WhatsApp) carry a per-group marker, so they never have exactly the people of a 1:1 chat or of
another group, and titles match whole, never in part. Mail threads are joined by headers a sender
controls, so each audience within a thread is its own conversation (`MailAudience`), mail people are
addresses (never display names or Reply-To), and only Mail's Sent mailbox or Outlook's outgoing flag
makes a message the user's own. Under
`-cotabby-debug`, `llm-io.jsonl` records full prompts, memory lines included, as it does typing
history. Like typing history, memory is on-device
only: the retriever returns nothing for the endpoint engine, the request factory drops it, and the
router refuses a request that still carries it. The memory engine loads its own embedding model
next to the completion model; CotabbyInference's process-wide llama.cpp backend is shared through
a reference-counted guard so unloading one never frees it under the other.

### Answers

Answer Questions (Settings → Memory → Answers) is off by default and needs conversation memory. When
the user opens an empty reply (or one holding only its salutation) to a message that asks them
something, `AnswerCoordinator` drafts an answer from memory and offers it on a card at the caret:

~~~text
FocusTrackingModel.$snapshot -> AnswerCoordinator (debounced; Cotabby on for the app; reply awaiting its body)
-> IncomingMessageResolver (the quoted original after the caret, or the newest incoming messages
of the chat Cotabby identified) -> QuestionDetector (English and Turkish)
-> MemoryEngine.answerSearch (the conversation being answered, plus any conversation of the
sources the user turned "use for answers" on for, none by default; never the question itself)
+ AvailabilityFacts for a scheduling question (EventKit, free and busy only)
-> AnswerGroundingPolicy gates -> AnswerDraftEngine (Apple Intelligence, else the local model)
-> AnswerGroundingPolicy guards -> AnswerCardController (click, or Tab in an empty reply, pastes
at the caret via SuggestionInserter.pasteAtCaret; Esc or typing dismisses)
~~~

Answers run only in mail apps (Mail, Outlook) and chat apps whose memory source is on (Teams,
WhatsApp), never in a browser page or another app's field. The sender of a message never chooses
the conversation an answer treats as its own: a quoted mail is data only (what is asked, by whom)
and never names a remembered conversation, so all of its answer's facts come from opted-in sources
(each marked as another conversation's) or busy/free times; a chat must be the one conversation
the window names; mail without its quote below the caret gets no answer; and anything ambiguous
gives no answer. Other conversations are opt-in per source and off by default; Calendar is never an answer
source (only busy and free times, read live).

The question is written by someone else, so it is treated as untrusted text: a message that reads
as instructions to an assistant ("ignore previous instructions", "list all the facts") gets no
answer, remembered text that does never reaches the prompt, and Apple Intelligence reads the
question, every fact and every name and title quoted, clipped and labeled as data, and the draft
must hold up on its own. A
draft is shown only when the facts are relevant enough and mention what the question names, every
number and name in it is in the facts or the question, it uses something from the facts, it holds
nothing the secret scrubber would remove, and it does not reproduce a long passage of a message
from another conversation. The card says which message it answers and lists every fact the model
was given with its sender, conversation, date and app, marking those from other conversations. Availability facts state only when the user is busy and free, never what
an event is or who is in it. Drafting runs on this Mac only (never the endpoint), and nothing is
inserted without a click or Tab.

## Presentation and Sibling Features

Expand Down Expand Up @@ -586,6 +691,11 @@ policies, prompt utilities, normalization, and layout logic should receive focus
| Permission loop or lost grant | PermissionManager, PermissionGuidanceController, app identity |
| Settings/onboarding window issue | SettingsCoordinator, WelcomeCoordinator |
| Settings persistence or domain mismatch | SuggestionSettingsModel, SuggestionSettingsData, SuggestionSettingsStore |
| Memory not used in a conversation | MemoryRetriever, ConversationScopeResolver, MemoryEngine.search |
| A memory source does not sync | MemoryHistorySync, the source's reader in Services/Memory/Readers |
| Memory indexing never runs | EmbeddingSchedulePolicy, MemoryEngine indexing loop, EmbeddingCore |
| No answer card in a reply | AnswerCoordinator (`answers` log category), IncomingMessageResolver, AnswerGroundingPolicy |
| Mail reply prompt misses its thread | MailReplyContext, QuotedReplyParser, SuggestionRequestFactory |

When a change crosses several rows, keep ownership at these boundaries rather than teaching one
coordinator to perform every step itself.
Loading
Loading