lib/migrate_search_index.py), the write hook, the redact
hook, GET /api/search/history, the three-group client, and six smoketest
gates. Live numbers from the dev corpus — 24,098 lines indexed from 1,632 rooms in
4.1 s; a query answers in 3 ms in the database and 17 ms round-trip.
Two things moved while building, both noted in place below: a head-slice snippet
hid the very match it was there to show (§5), and the index hook reached for the wrong
database handle and the safety try swallowed it (§6).Today the Chats field narrows the chat list and nothing more (v751). The ask is WeChat's: one field that searches everything you have, and answers in three named groups — group chats, chat histories, friends. Two of those three the app can already answer in a keystroke. The third has never existed, and it is the whole engineering story: the app has never searched what was said.
The groups are not three filters over one pile; they are three different kinds of thing, and each already has a row that knows how to draw itself and what to do when tapped. That is the rule the whole feature hangs on: a result looks exactly like the row it stands for, and does exactly what that row does.
| Group | What is in it | The row says | Tapping it | Source |
|---|---|---|---|---|
| 1 · Friends 联系人 | people and personas | face · name, with the hit highlighted | opens the profile | /api/friends?q= |
| 2 · Group chats 群聊 | rooms that are not a 1-on-1 — a panel of personas, or more than one human | the chat-list row · member count · and why it matched:「contains: 高玮丽」when the hit was a member rather than the name | opens the chat | client, ALL_ROOMS |
| 3 · Chat histories 聊天记录 | lines that were said, in any chat you can see | one row per chat, not per line: the newest matching line + date + 「62 matching messages」 | opens that chat | NEW endpoint |
Groups 1 and 3 are free. Group 2 is the question, and it is not close. Measured on the dev corpus, 2026-08-11:
| Amount | What it means | |
|---|---|---|
| Rooms on disk | 1,755 | each a directory with
state.json |
| Total room state | 233.5 MB | what a naive search must open |
| Naive full scan | 15.3 s | per query. Not a feature — a hang |
| Lines actually said | 30,066 | the visible conversation |
| Text worth searching | 3.15 MB | 1.3% of what you had to read to find it |
The 74× gap is history — the raw model messages, megaprompt and all —
which nobody searches and which dwarfs the transcript sitting beside it. So the text gets
pulled out once and kept where it can be asked a question: a msgs
table in app.db, backfilled once, appended as turns land. At 30k short rows a
query is single-digit milliseconds, and it grows with what was said rather than
with what the model was told.
The obvious reach is SQLite's full-text index. It is the wrong tool here, and the corpus says so rather than the argument. All three were built from the same 30,057 lines and asked the same two questions — a real two-character Chinese word (个人, which 2,434 lines genuinely contain) and an English one:
| Candidate | Build | On disk | CJK query | CJK hits | Latin hits | Verdict |
|---|---|---|---|---|---|---|
| plain table + LIKE | 0.1 s | 9.7 MB | 6–12 ms | 2,434 ✓ | 17 ✓ | correct |
| FTS5, default (unicode61) | 0.3 s | 15.7 MB | 0 ms | 0 ✗ | 17 ✓ | finds nothing |
| FTS5, trigram | 1.4 s | 34.1 MB | 0 ms | 0 ✗ | 17 ✓ | finds nothing |
includes), so one word cannot find one thing in the list and a different
thing in the history.A substring scan is linear in total text, so the honest question is not「is it fast」but 「how much room is there before it isn't」. Measured: 1.9 ms per MB of conversation. The whole 3.15 MB corpus answers in 6 ms; a single user's own rooms — the only scope a real query has — answer in under 1 ms. Ten times the text is ~60 ms and a hundred times is ~600 ms, so the ceiling is around 50–100× today's corpus, at which point the upgrade is a hand-rolled bigram column (every adjacent pair indexed as a token) — which is what CJK full-text setups do anyway, and can be added beside the same table without changing a single caller.
This is the part that can leak. Today every line a user sees passes through
one read path — replay(floor) then _hide_filter — and
that path is where all privacy is enforced — the floor exists only in the read
path; nothing is deleted underneath it. An index sits beside that path, so each
filter has to be carried across explicitly or it is simply gone:
| Filter | What it hides | Carried across by |
|---|---|---|
The floormember_see_from | everything said before you joined, when history was private | store each line's ts; drop candidates older than the caller's floor |
| Delete for everyone a redact event | a line the room retracted — replay renders a tombstone | store each line's lid; delete it from the index when the redact lands |
Delete for mehidden_msgs | a line one person put a lid on | same lid, filtered per caller — already a DB table |
The field and its behaviour are unchanged — the magnifier grows into the bar, the ‹ retracts it, the word is the tab's (v748–v752). What changes is what the list under it shows.
The results replace the chat list in place, under three headings. A chat opens the chat; a person opens their profile. Both are the end of the loop — entering a chat already sweeps the search (v751), and it should keep doing exactly that.
Results fill the left pane, and the right pane answers:
Which is not a new rule at all — it is the one the app already has: pressing a row in the left pane replaces the right pane. The search list is a left-pane list like any other, so it inherits that for free. On wide the search is not swept when a result is opened: the list stays beside the chat, and sweeping it would pull the other results out from under the hand that just picked one.
Unchanged from v748–v752 — the magnifier grows into the bar, the ‹ retracts it, the word belongs to the tab. One addition from the screenshot: an ✕ clear button at the right end of the field once there is something to clear, which empties the word without putting the bar away. The ‹ puts the bar away; the ✕ only empties it. Two different intentions, two different controls.
msgs(room_id, lid, ts, who, text) in
db.py, indexed on room_id.migrate_* that walks every room once and fills
it — ~15 s once, on the box, at deploy.(room_id, lid) so a re-persist cannot double it.GET /api/search/history?q= — scope to the caller's
rooms, apply the three filters, return chat + line + count.ALL_ROOMS restricted to
multi-party, histories from the new one. Each group capped with a「more」link, the hit
highlighted in coral, and a group row carrying「contains: name」when it matched
on a member.db — which is local to build_app();
module-level Room methods read the _DB alias the app publishes for exactly
this. So the hook was calling a module where an instance was wanted, and the
try/except that exists so a search index can never fail a user's turn
swallowed the error completely. Every gate passed, the backfill was full, the UI worked —
and nothing said after the deploy would ever have been findable. A safety net that
hides a defect is a defect, and the only thing that caught it was exercising the live
path directly: say a line, persist, look. Now verified end to end — a line indexes itself
on persist, a redact removes it, and a rehydrated room re-syncs whole without duplicating
it or resurrecting the redacted one.