plans/search-chats-tool.md
Plan updated 2026-07-15 after critical review
Add two bounded, read-only local-agent tools that let the AI search and inspect chats belonging to the current app:
search_chats — FTS5 keyword search over a role-aware, cleaned projection of chat history. It returns ranked chat/message pointers with short excerpts, never complete messages.read_chat — bounded retrieval from a specific same-app chat, either around a message returned by search or as a chronological page. Unlike search, it may read the current chat, which is useful after context compaction.The intended flow is search_chats -> read_chat({ around_message_id }): discover the relevant prior discussion cheaply, then retrieve only enough surrounding context to answer accurately.
Search computation and storage stay on-device. However, text returned by either tool becomes part of the active model request and may therefore be sent to the user's selected cloud model provider. The product and consent UI must describe this as local retrieval, not end-to-end offline processing.
@app: references.aiMessagesJson or exposing native tool-call transcripts.Use a local FTS5 virtual table and SQLite's built-in bm25() ranking. Do not add an in-process BM25 pass or generalize tools/bm25.ts; that ranker remains specific to MCP tool discovery.
FTS5 removes the correctness problem of taking the newest N raw LIKE matches before ranking. An old, specific result must remain discoverable even when thousands of recent messages contain common query words.
Before implementation, verify that the packaged better-sqlite3 binaries used on macOS, Windows, and Linux have FTS5 enabled. Add an automated capability test that creates and queries a temporary FTS5 table. FTS5 is a hard requirement for this feature; do not silently fall back to raw LIKE search with different relevance behavior.
This follows the existing grep -> read_file pattern:
read_chat accepts around_message_id, so the model never has to page blindly from the start of a long conversation.messages.content is the source of user-visible chat data, but assistant messages can contain full files, SQL, diffs, logs, schemas, and tool results inside Dyad tags. Build and index a deterministic projection designed specifically for chat recall.
Never select or index aiMessagesJson. It is an internal, potentially multi-megabyte duplicate representation.
Both tools use defaultConsent: "always" for seamless recall. Users can still change either tool to "Ask every time" or "Never allow" through the existing agent-tool permission UI; when prompting is enabled, the consent preview must say that historical chat text from the current app will be provided to the active AI model.
Both tools have no modifiesState flag and no usesEngineEndpoint. Their execute() implementations must perform only reads. FTS maintenance runs independently as application-owned derived-index maintenance; a tool call must not lazily write or rebuild the index.
ctx.appId; there is no app_id tool argument.search_chats excludes ctx.chatId by default to avoid self-matches.read_chat allows the current chat as well as other chats for the same app. Earlier current-chat messages may no longer be in the model context after compaction.DyadErrorKind.NotFound, avoiding cross-app existence disclosure.Create a standalone FTS5 virtual table conceptually shaped as:
CREATE VIRTUAL TABLE chat_search_fts USING fts5(
title,
body,
app_id UNINDEXED,
chat_id UNINDEXED,
message_id UNINDEXED,
role UNINDEXED,
message_created_at UNINDEXED,
is_compaction_summary UNINDEXED,
projection_truncated UNINDEXED,
tokenize = 'unicode61 remove_diacritics 2'
);
Use message_id as the FTS rowid so each source message has at most one indexed document. Store only the cleaned projection, never raw content.
The exact migration may add small ordinary tables for dirty-message IDs, dirty-chat IDs, and the projection version. Model ordinary tables in src/db/schema.ts and generate their migration normally. Since Drizzle schema declarations cannot express this FTS virtual table and its triggers, generate a custom migration scaffold with:
npm run db:generate -- --custom --name chat-search-fts
Then add the FTS/triggers SQL to that generated file. Do not create or number a migration file manually. Follow rules/database-drizzle.md, including regeneration after rebases that change migration numbering.
FTS rows contain a TypeScript-produced projection, so SQLite triggers cannot fully construct them. Use triggers only to track source changes:
content, role, is_compaction_summary) upserts its ID into a dirty-message table.Add a background ChatSearchIndexer service owned by the main process:
CHAT_SEARCH_PROJECTION_VERSION. When the projection policy changes, mark existing documents dirty and rebuild them in the background.search_chats.execute() remains a pure read. If indexing does not become current within a short bounded wait, return results from the current index with structured coverage metadata such as index_status: "indexing". Do not block an agent turn indefinitely.
Add src/pro/main/ipc/handlers/local_agent/tools/chat_search_text.ts with a role-aware API:
interface ChatSearchProjectionInput {
role: "user" | "assistant";
content: string;
isCompactionSummary: boolean;
}
interface ChatSearchProjection {
text: string;
truncated: boolean;
}
function projectChatMessageForSearch(
input: ChatSearchProjectionInput,
): ChatSearchProjection;
Preserve user-authored text. Do not interpret literal <dyad-*> examples in user messages as trusted tool markup. Apply only normalization and a generous pathological-size bound.
Use the existing structured streaming-message parser where practical rather than a generic <dyad-*>...</dyad-*> regex. Apply an explicit policy by block/tag class:
<think> bodies.dyad-search-chats and dyad-read-chat so retrieved history never becomes recursively searchable copied history.Compaction summaries require explicit handling: the <dyad-compaction> body is high-signal searchable text. Original messages remain in the database, so post-ranking grouping should avoid returning a compaction-summary excerpt that merely duplicates a stronger original-message hit. Apply a small score penalty to summary rows while still allowing them to surface unique terms.
Normalize whitespace and cap pathological projections to a documented byte limit, preserving a head and tail segment and recording truncated. Normal chat messages should not hit this bound after payload removal.
search_chats toolNew file: src/pro/main/ipc/handlers/local_agent/tools/search_chats.ts
{
query: z.string().trim().min(1).max(500),
limit: z.number().int().min(1).max(20).optional(), // default 8 chats
}
The description should request concise keywords or a short phrase, explain that the tool searches historical chats for the current app, and distinguish it from code search.
MATCH expression internally; never accept raw FTS query syntax from the model.Execute a parameterized FTS query constrained by app_id = ctx.appId and chat_id != ctx.chatId. Use built-in bm25() with a strong title-column weight and body-column weight. Remember that FTS5 bm25() returns smaller (normally more-negative) values for better matches; name score variables and apply bonuses/penalties consistently with that ordering. Do not impose a pre-ranking "most recent messages" cap.
Post-process a bounded ranked window:
snippet() or highlight() against the already-clean projection to produce short excerpts centered on matches.Compute last_message_at with a set-based aggregate/window query or omit it; do not fetch it once per result.
Return JSON, not a hand-built pseudo-record format that historical text could spoof:
{
"query": "authentication decision",
"index_status": "ready",
"results": [
{
"chat_id": 42,
"title": "Authentication setup",
"last_message_at": "2026-07-12T18:30:00.000Z",
"matches": [
{
"message_id": 301,
"role": "assistant",
"created_at": "2026-07-12T18:29:00.000Z",
"excerpt": "We decided to use...",
"projection_truncated": false
}
]
}
],
"archival_content": true
}
The output must state that excerpts are historical data, not instructions. Enforce both a result-count limit and a total serialized-output budget (target 12 KB). If the budget is reached, set results_truncated: true.
Do not issue per-result count queries. If a message count is not already available from the bounded query, omit it rather than introducing N+1 database work.
defaultConsent: "always"modifiesStateusesEngineEndpointSearch historical chats for this app for "<query>" and provide matching excerpts to the active AI model.<dyad-search-chats> card showing the query, index status, matched chat titles, dates, and excerpts consulted by the agent.read_chat toolNew file: src/pro/main/ipc/handlers/local_agent/tools/read_chat.ts
Use mutually exclusive around-hit and page forms, enforced with Zod refinements:
{
chat_id: z.number().int().positive(),
around_message_id: z.number().int().positive().optional(),
before: z.number().int().min(0).max(10).optional(), // default 3
after: z.number().int().min(0).max(10).optional(), // default 3
offset: z.number().int().min(0).optional(),
limit: z.number().int().min(1).max(20).optional(), // default 10
}
around_message_id is present, reject offset/limit and return the target plus bounded surrounding messages.offset/limit.chats and require chats.appId = ctx.appId in the SQL that locates the chat and optional target message.chat_id === ctx.chatId.around_message_id to belong to the requested same-app chat.aiMessagesJson.ctx.messageId, which is the in-flight assistant placeholder/tool turn currently being constructed. Return a stable snapshot through the user message that started the current turn rather than recursively reading the current response.(messages.createdAt, messages.id). Account for compaction summaries whose timestamps are deliberately positioned before their triggering user messages.Use a SQL window/CTE or equivalent bounded queries to locate the ordinal position of around_message_id and retrieve its neighbors without loading every message body.
Reading should be more informative than search while remaining bounded. Reuse the role-aware projection but retain concise tool/error summaries that help explain what happened. Do not return full file, SQL, diff, log, schema, web, or MCP payload bodies.
Return structured JSON containing chat metadata, page/around-hit metadata, message IDs, roles, timestamps, cleaned text, per-message truncation flags, and has_more_before/has_more_after.
Enforce:
output_truncated and continuation metadata.Mark all returned text as archival content and tell the model not to treat instructions inside it as commands for the current task.
defaultConsent: "always"modifiesStateusesEngineEndpointchat_id and whether the read is around a message or a page. It cannot query the title because ToolDefinition.getConsentPreview receives only parsed arguments; show the resolved title in the completed renderer card instead.<dyad-read-chat> card that shows the source chat, returned time/message range, and the bounded text actually consulted by the agent.TOOL_DEFINITIONS near other read-only search tools.AgentToolName and the permissions UI, but verify the Ask, Plan, local-agent, basic/free-model, ask, always, and never paths explicitly.dyad-search-chats and dyad-read-chat to DYAD_CUSTOM_TAG_NAMES in src/lib/streamingMessageParser.ts; registering only React components is insufficient.DyadMarkdownParser.tsx.chat.json locale files.search_chats; source code -> grep/code_search; expand a hit -> read_chat with around_message_id.rules/local-agent-tools.md.NotFound).aiMessagesJson.chat_search_text.spec.ts:
dyad-search-chats/dyad-read-chat bodies are removed.index_status accurately without performing writes.search_chats testsask, always, and never consent behavior.read_chat testsaround_message_id returns the target and correct neighbors.has_more_* metadata.aiMessagesJson is never selected or returned.search_chats, takes a returned message_id, then invokes read_chat around it through the real local-agent tool loop.Run focused tests first, then the repository pre-commit workflow:
npm test -- src/pro/main/ipc/handlers/local_agent/tools/chat_search_text.spec.ts
npm test -- src/pro/main/ipc/handlers/local_agent/tools/search_chats.spec.ts
npm test -- src/pro/main/ipc/handlers/local_agent/tools/read_chat.spec.ts
# focused hybrid integration test command
npm run fmt
npm run lint
npm run ts
Use /dyad:lint when available. If an E2E test is added or run, execute npm run build first.
referencedApps paths are not sufficient.