Skip to content

feat(search): per-message role/tool granularity (Phase 5) - #14

Merged
drewburchfield merged 3 commits into
mainfrom
feat/phase5-per-message-role
Jun 3, 2026
Merged

feat(search): per-message role/tool granularity (Phase 5)#14
drewburchfield merged 3 commits into
mainfrom
feat/phase5-per-message-role

Conversation

@drewburchfield

@drewburchfield drewburchfield commented Jun 3, 2026

Copy link
Copy Markdown
Owner

Phase 5: per-message role granularity

Adds the ability to filter search to a message role (user / assistant / system / tool) or a specific tool — the piece deferred in Phase 2. This is also the foundation for the conversation "state" model behind the observability direction.

How it works

  • New message_fts table: one row per message, tagged role / tool_name / seq, alongside the existing conversation_fts. Default search stays on conversation_fts (so there's no search blackout on upgrade); role/tool search uses message_fts, which the background reindex (user_version → 3) populates.
  • Indexer emits per-message records (text/thinking under the message's role; each tool_use/tool_result as a tool row with tool_name).
  • DatabaseManager.searchConversationsByRole returns the best-matching message per conversation with role/tool filters (flat FTS MATCH + JS dedupe — bm25 can't run inside a windowed subquery). message_fts is cleaned on removeFile; roles added to the facets enum.
  • SearchService routes role/tool queries to the per-message path.
  • Exposed on REST (/api/search + /api/facets), MCP (search_conversations role/tool params, list_facets.roles), and the Web UI (Message-role dropdown).

Dogfood (live, against the real corpus, mid-reindex)

Role filtering discriminates correctly across all four surfaces for cleanupPeriodDays:

  • REST: user=1, assistant=3, tool=19, no-filter=90 (term lives mostly in tool output — settings files read via tools, exactly right)
  • MCP stdio (real server process): user=1, assistant=2, tool=24, facets.roles present
  • Web UI: role dropdown populated [user,assistant,system,tool], request path user=2/assistant=6/tool=36/none=90
  • No blackout: server served in 2s while message_fts built in the background

Also includes a dogfood-found MCP fix: reading a conversation resource whose transcript file is unreadable now returns a clear "Transcript file unavailable" error (not "Conversation not found"), with a docs note on transcript access.

Tests: 329 passing (DB role/tool search, REST role e2e, MCP role filter, facets roles, transcript-error case).

Tradeoff

message_fts duplicates message content, so the index roughly doubles. Accepted to keep default search blackout-free; a future option is to consolidate onto per-message FTS as the single store.


Open in Devin Review

…able

Found via real stdio dogfood: reading a conversation resource when the JSONL
file isn't accessible (e.g. server running without ~/.claude mounted) returned
a misleading 'Conversation not found'. Distinguish the two cases and document
the transcript-access requirement. Adds a regression test.
Adds a message_fts table (one row per message, tagged role/tool_name/seq)
alongside conversation_fts, so search can filter to a message role
(user/assistant/system/tool) or a specific tool. Default search still uses
conversation_fts (no blackout); role/tool search uses message_fts, which the
background reindex (user_version 3) populates.

- Indexer emits per-message records (text/thinking under the message role;
  tool_use/tool_result as 'tool' rows with tool_name).
- DatabaseManager.searchConversationsByRole (best message per conversation,
  role/tool filters); message_fts cleaned on removeFile; roles facet enum.
- SearchService routes role/tool queries to the per-message path.
- REST /api/search + GET /api/facets, MCP search_conversations, and the web
  UI all expose role (and tool via API/MCP).
- Tests: DB role/tool search, REST role e2e, MCP role filter, facets roles.

Cost: larger index (message_fts duplicates content); flagged as the tradeoff.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View 4 additional findings in Devin Review.

Open in Devin Review

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 removeConversation() does not delete from message_fts, leaving orphaned rows

removeFile() at src/analytics/data/DatabaseManager.js:809 was correctly updated to delete from the new message_fts table, but the parallel removeConversation() method at lines 781-788 was not. When a conversation is removed via removeConversation(), its rows in message_fts are left behind as orphans. While the JOIN in searchConversationsByRole prevents these orphaned rows from appearing in search results, they still leak storage and violate the cleanup contract that removeFile correctly implements.

(Refers to lines 782-786)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

removeFile already cleaned message_fts; removeConversation didn't, leaving
orphan rows. Mirror the cleanup and add a regression test.
@drewburchfield

Copy link
Copy Markdown
Owner Author

Fixed: removeConversation() now also deletes from message_fts, matching removeFile(). Added a regression test (removeConversation cleans up message_fts (no orphans)).

@drewburchfield
drewburchfield merged commit 479af3e into main Jun 3, 2026
2 checks passed
@drewburchfield
drewburchfield deleted the feat/phase5-per-message-role branch June 3, 2026 13:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant