feat(search): filter /search by tags (people, projects, topics)#3940
Conversation
Add an optional `tags` filter to the unified /search endpoint and the MCP search-content tool, so an AI agent can pull every screen + audio capture carrying a given tag (e.g. person:ada, project:atlas). Tags are the existing junction-table labels written via POST /tags/:type/:id, and search results already return them; this closes the loop by letting you filter on them. - `tags` is comma-separated; multiple tags AND together (intersection). - Backed by vision_tags / audio_tags, so content types without tag tables (input, accessibility, memory) return nothing when a tag filter is set. Memories keep their own tag filter via GET /memories?tags=. - count_search_results agrees with the filtered result set so pagination totals stay correct. - Public search() / count_search_results() signatures kept stable via search_with_tags() / count_search_results_with_tags() siblings, so the ~60 existing callers are untouched. Namespaced tags (person:, project:, topic:) become a universal, AI-authored link primitive: two captures sharing a tag are connected. That is the foundation for an Obsidian-style graph over captures, openable to the AI with no rigid per-entity schema. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make tags a coherent cross-store link the AI can actually use end to end, not just a screen/audio filter. - `GET /search?content_type=memory&tags=...` now filters memories by their JSON tags (exact membership, AND across multiple tags), via new list_memories/count_memories `tags_all` params. Previously the AI could tag a memory (update-memory) but had no way to retrieve memories by tag through search. content_type=all still never returns memories. - One string namespace now spans three stores: vision_tags / audio_tags (screen + audio) and memories.tags (memory). The same `person:ada` on a frame and on a memory links them. - MCP: add-tags / update-memory / search-content descriptions now teach the namespaced convention (person:/project:/topic:), how to retrieve by tag, and that frames are pruned so durable links belong on memories. - screenpipe-api SKILL.md (shipped + repo copies): concise Tags section. - Tests: add test_tag_filter_audio_and_cross_modal (audio + cross-modal all + exact-not-substring + input-gated + count) and test_memory_filter_by_tags (exact AND, no-substring, FTS compose, all-excludes-memory, count). 37 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…mes) Adds tests/tag_filter_bench.rs (#[ignore], run with --ignored) that seeds a 200k-frame / 60k-audio / 50k-memory DB with rare tags on the OLDEST rows (adversarial vs ORDER BY timestamp DESC LIMIT) and dumps EXPLAIN QUERY PLAN plus timings. Findings: screen/audio tag filtering drives off the tag indexes (tags.name UNIQUE + idx_vision_tags_tag_id) then PK-looks-up frames, so it never scans all frames — the tag filter is ~7 ms vs ~127 ms for unfiltered OCR search at 200k frames (17x faster, since it narrows to the tagged set). Audio ~1 ms, all ~8 ms, counts ~7-12 ms. Memory is the one linear path (memories.tags is unindexed JSON → full scan + correlated json_each, ~16 ms @ 50k); fine at realistic counts, junction table if memories ever hit millions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diarization eval resultsSource:
DER, VAD FA, VAD FN, boundary err: lower is better. Continuity: higher is better, 1.0 = same hyp cluster across all silence gaps. Composed workday rows and Pipeline replay matrixSource: generated
The no-secret CI matrix runs local diarization under Parakeet/Whisper engine labels across live/background and mic/system device profiles. Real Deepgram/screenpipe-cloud smoke can be run locally with Transcription qualitySource: LibriSpeech test-clean (CC-BY-4.0) · per-model utterance cap · normalized lowercased word-level Levenshtein
WER + CER on read-aloud speech. Per-model utterance caps keep wall time bounded — tiny/parakeet at 50, the heavier large-v3-turbo-quantized at 20. See README for normalization rules. |
What
Makes tags a coherent cross-store link that an AI agent can use end to end. Two pieces:
tagsfilter on/search(and the MCPsearch-contenttool): pull every item carrying given tags, e.g.?tags=person:ada,project:atlas.content_type=memory), so the AI can retrieve memories by tag. Before this, the AI could add tags to a memory (update-memory) but had no way to get them back by tag.The model (how the AI links a person to memories / a timeframe)
One string namespace spans three stores. The same tag on different items connects them.
flowchart LR subgraph "one tag namespace" direction LR T["person:ada"] end F["screen frame<br/>vision_tags"] --- T A["audio chunk<br/>audio_tags"] --- T M["memory<br/>memories.tags"] --- T T --> Q1["GET /search?tags=person:ada<br/>(screen + audio)"] T --> Q2["GET /search?content_type=memory&tags=person:ada<br/>(facts)"]POST /tags/vision/{id}or/tags/audio/{id}. To a memory:tagsfield onPOST /memories/PUT /memories/{id}.person:ada.person:adaand query withstart_time/end_time, or rely on the memory'screated_at+frame_idprovenance.content_type=all&tags=person:ada) + one for facts (content_type=memory&tags=person:ada).Behavior
tagsis comma-separated. Multiple tags AND together (intersection). Matching is exact membership, not substring (person:adadoes not matchperson:adam).inputandaccessibilityhave no tag table and return nothing when a tag filter is set.content_type=allunions tagged screen + audio (it never includes memory, tagged or not, same as today).count_search_resultsagrees with the filtered set, so paginationtotalstays correct.tagsbehaves exactly as before. Strictly opt-in.Implementation
search()/count_search_results()signatures kept stable (~60 callers untouched) viasearch_with_tags()/count_search_results_with_tags()siblings; the route handler calls those.json_each+HAVING COUNT(DISTINCT) = json_array_lengthover the junction tables. Memory matching: the same exact-AND shape over thememories.tagsJSON array, threaded through newlist_memories/count_memoriestags_allparams (the publicGET /memories?tags=keeps its existing single-substring filter).add-tags,update-memory,search-contentdescriptions updated to teach namespaced tags and retrieval.screenpipe-apiSKILL.md (shipped + repo copies) gains a concise Tags section.Tests
cargo test -p screenpipe-db --test db— 37 pass, including:test_search_filter_by_tags— single / shared / AND / unknown / no-filter / count.test_tag_filter_audio_and_cross_modal— audio filter,content_type=allcross-modal union, exact-not-substring, input gated, count.test_memory_filter_by_tags— memory exact AND, no-substring, FTS compose, all-excludes-memory, count.cargo clippyclean (no new warnings).Performance
Benchmarked on the search hot path with
tests/tag_filter_bench.rs(ignored; 200k frames / 200k vision_tags / 60k audio / 50k memories, in-memory). Adversarial case: tags are rare and on the oldest rows, fightingORDER BY timestamp DESC LIMIT.EXPLAIN QUERY PLANconfirms screen/audio filtering drives off the tag indexes (tags.nameUNIQUE +idx_vision_tags_tag_id) and PK-looks-up frames, so it never scans all frames:tags=person:adatags=person:adatags=person:adatags=person:adaThe tag filter is faster than unfiltered search because it narrows to the tagged set instead of scanning everything. The one linear path is memory:
memories.tagsis unindexed JSON, so the filter is a full scan + correlatedjson_each(~0.3 us/row, ~16 ms @ 50k → ~160 ms @ 500k). Fine at realistic memory counts; if memories ever reach millions, add amemory_tagsjunction table mirroringvision_tags. No-tag queries are unaffected (thejson_array_length(?)=0 OR ...guard short-circuits).Not in this PR
A graph-traversal endpoint (co-occurring tags for a given tag). Note
/connectionsis already taken by integrations, so it would need a name like/graphor/related. Natural follow-up.