Files
amethyst/.claude/skills/searchable-events/SKILL.md
Vitor PamplonaandClaude 9c34a3bcc0 quartz: close hint-provider and NIP-50 indexing gaps across event kinds
An audit of all 429 concrete Event classes found 184 that carried pubkey /
event / address references in public tags without exposing them through
PubKeyHintProvider / EventHintProvider / AddressHintProvider (or exposed only
some), and 72 that held human-written text without indexing it.

Hint providers
- New shared helpers: zap splits (TagArray.zapSplitHints/zapSplitPubKeys, now
  used by text notes, comments, long-form, chat messages, zap goals,
  classifieds, public messages, music tracks), NIP-71 video (P/E/A once on
  RegularVideoEvent/AddressableVideoEvent), NIP-90 (new DvmRequestEvent /
  DvmResponseEvent bases for all request/result/feedback kinds), chess
  (OpponentTag on BaseChessEvent), trusted lists (on TrustedListEvent).
- Providers added or completed for gift wraps (recipient relay), 10040
  trust-provider lists (service key + card relay), voice replies, threads,
  polls, pictures, torrents, user status, NIP-29 group kinds, NIP-51 lists
  with `a` pointers, mute-list threads, live-activity goals, zap receipts
  (P sender, zap-request author), `q` tags on chat/git status/classifieds/
  public messages, the experimental and Buzz kinds, and more.
- RelayUrlNormalizer.normalizeHintOrNull: hint parsers take a relay only
  when the slot holds a ws(s):// URL. normalizeOrNull turns any bare word
  into wss://<word>/, so a pubkey or label in a relay slot became a fake
  relay in the hint index and broadcast set. Applied to every new or newly
  wired parser, including the shared ATag.parseAsHint (a bare host there no
  longer counts as a hint, matching PTag/ETag).
- New linkedAddressIds() only return well-formed kind:pubkey:d ids.
- DraftWrapEvent is deliberately NOT a provider: drafts arrive from the
  private-storage relay, which would otherwise be recorded as a hint for the
  public events a draft references.

Bugs fixed
- NIP-22 RootAddressTag/ReplyAddressTag.parseAsHint required a 64-char
  value, so no `A`/`a` address ever produced a hint.
- ZapSplitSetupSerializer dropped the empty relay slot, writing
  ["zap", pk, weight]; the parser then read weight 0 and discarded the
  split. The slot is kept now and the legacy 3-slot shape still parses.
- EmojiPackEvent (30030) indexed its NIP-44 ciphertext `content`; it now
  indexes the public emoji shortcodes.
- FeedDefinitionEvent (31890) and EncryptionKeyListEvent (10044) were not
  registered in EventFactory (31890's search text was unreachable).

NIP-50 indexing
- 42 kinds newly searchable (SearchableKinds.ALL, golden file, and
  searchable-kinds.md updated); indexableContent() extended on ~35 more
  (topics/labels, locations, subjects, segment titles, citation authors,
  option labels, file captions, ...). SearchFieldExtractor branches kept in
  sync; `t`/`location` stay with its funnel. Existing stores need
  IEventStore.reindexFullTextSearch() to pick these up.
- RenderableKindsTest lists the new kinds as searchable-but-not-shown.

Parsed-JSON caches added where the new hint/search paths decoded content on
every call (Buzz personas/agents/teams/summaries/system messages, NIP-15
marketplace, bid confirmations, code-snippet and geocache naddrs).

Left out on purpose: NIP-85 assertion subjects held only in `d` (linking
them would spread score-relay hints to every subject), AppDefinitionEvent's
over-inclusive indexing (policy), ReportEvent comments, NIP-19 citations in
Buzz forum/stream content.

Tests: ~110 new test classes; quartz jvmTest and commons jvmTest green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UrXSz9uRnNLVru3AvD3wSa
2026-10-06 15:07:05 +00:00

125 lines
7.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: searchable-events
description: The NIP-50 indexing surface of Quartz — the `SearchableEvent` interface, which event kinds are searchable, exactly what text each kind's `indexableContent()` contributes, how the SQLite/filesystem stores consume it, and the NIP-50 `SearchQuery` extension grammar plus `SearchRelayListEvent` (kind 10007). Use when making a kind searchable, changing what a kind indexes, diffing the searchable set at a Quartz version bump (external search engines mirror this table), debugging why an event is or isn't found by search, or working with search extensions (`include:spam`, `domain:`, …).
---
# Searchable Events — the NIP-50 indexing surface
## The contract
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableEvent.kt`:
```kotlin
interface SearchableEvent {
fun indexableContent(): String
}
```
One method; marker and extractor in one. An event kind is searchable **iff** its event class
implements this interface **and** the class is wired into `EventFactory` (the stores probe
searchability by kind through `EventFactory.create` — an unwired implementor is invisible).
Rules every implementation follows (keep them when adding one):
- **Plain text out.** Return the human-meaningful fields joined with `"\n"` (a handful of
metadata-ish kinds use `" "`); no markup stripping is performed — markdown/asciidoc content
goes in raw, JSON-content kinds (kind 0 metadata, marketplace stalls, channel info) **parse
first and join the extracted fields**, never the raw JSON.
- **Never throw, never null.** There is no defensive wrapper at any call site; a throw aborts
the insert transaction. Parsed-JSON implementations use `?.let { … } ?: ""`.
- **Only public data.** Encrypted content stays out (e.g. kind 30382 contact cards index only
the public petname/summary/topics, never the NIP-44 payload; kind 30030 emoji packs index the
public `emoji` shortcodes, never the NIP-44 private-tag ciphertext in `content`).
- Typical shapes: `content` alone (~57 kinds); `listOfNotNull(title(), content)`;
`listOfNotNull(title(), summary(), content)`; lists index `title() + description()`.
## The full kind table
**`references/searchable-kinds.md`** in this skill holds the authoritative table — every
implementor with its kind number, class, and the exact `indexableContent()` expression
(191 concrete classes / 192 kind values as of 2026-10; the kind set is exactly
`nip50Search/SearchableKinds.kt`'s `ALL`). Diff that file at a version bump to
answer "did the searchable set or any kind's indexed text change?".
Notables that surprise people:
- **Kind 9735 (zap receipt) indexes the embedded zap request's content**
(`zapRequest?.content.orEmpty()`) — receipts are searchable by the zapper's comment.
- **Kind 0 / 31990** index many profile fields space-joined (name, about, nip05, lud16,
website, picture URL, …).
- **Kind 30063** (`ReleaseArtifactSetEvent`) serves both NIP-51 release artifact sets and NIP-82
software releases: it indexes `title()\ndescription()`, plus the release notes in `content` only
for NIP-82 releases (NIP-51 `content` can be encrypted private items).
- Poll kinds (1068, 6969) append each option label on its own line.
## MANDATORY maintenance when you touch this surface
Adding `SearchableEvent` to a kind, removing it, or changing any `indexableContent()` body:
1. **Update `references/searchable-kinds.md`** in the same PR (external search engines — e.g.
the Vespa-backed store's `SearchExtractors` — mirror this table at pin bumps; a silent
change ships them stale search results).
2. **Remember existing databases don't reindex themselves.** Old rows keep their old (or
missing) FTS text until `IEventStore.reindexFullTextSearch()` runs — the KDoc on that method
is the contract. App-side, schedule the resumable overload after shipping such a change.
3. New implementors must be **registered in `EventFactory`** or the reindex scan and kind
pre-filter (`FullTextSearchModule.isSearchableKind`) will never see them, and listed in
`SearchableKinds.ALL` (`SearchableKindsTest` fails otherwise); any body change also moves
`jvmTest/resources/indexable-content.golden` (`IndexableContentGoldenTest`).
Eligibility policy: a kind becomes searchable when it carries human-authored, human-meaningful
text (titles, bodies, names, descriptions). Pure-machine kinds (reactions, follow lists, zaps
minus their comment, relay lists) stay out to keep the index small.
## How the stores consume it
**SQLite** (`nip01Core/store/sqlite/FullTextSearchModule.kt`):
`CREATE VIRTUAL TABLE event_fts USING fts5(content, content='', contentless_delete=1)` —
contentless, `rowid` = `event_headers.row_id`, an `AFTER DELETE` trigger keeps it in sync. On
insert (when FTS is on and not deferred): `if (event is SearchableEvent)` → bind
`event.indexableContent()` — the only method ever called. Tokenization is entirely SQLite's
default FTS5 `unicode61`; queries are always a bound `event_fts MATCH ?` (never concatenated),
ordered by bm25 `rank` then `created_at DESC`. Query-side semantics (relevance ordering,
extension stripping, FTS-off behavior, deferred catch-up) are rules STORE-S01…S06 in the
`event-store-semantics` skill.
**Filesystem store** (`jvmMain/.../store/fs/FsIndexer.kt` + `FsSearchTokenizer.kt`): tokenizes
`indexableContent()` itself, approximating `unicode61` (split on non-letter/digit, lowercase);
the same tokenizer runs on queries so drift cancels.
## NIP-50 client side
**`SearchQuery`** (`nip50Search/SearchQuery.kt`) — typed parse of the `search` filter string
into `terms` + `extensions`. A whitespace token is an extension iff it looks like
`lowercasekey:value` (the value not starting with `//`, so URLs stay free text); duplicate keys
keep the last; unknown extensions are preserved (`extension(key)`). Typed accessors:
`includeSpam`, `domain`, `language`, `sentiment`, `nsfw`. `stripExtensions()` /
`Filter.strippingSearchExtensions()` is the bridge the built-in stores use — unsupported
extensions are **ignored** (NIP-50), so an extensions-only search collapses to an unconstrained
query, never match-nothing. A server-side store that implements its own extensions
(`observer:`, `sort:rank`, …) receives the raw string (see the `IEventStore` KDoc) and should
parse with `SearchQuery.parse` so its syntax stays compatible with what clients send.
**`SearchRelayListEvent`** — **kind 10007**, the user's search-relay list (NIP-51-style, public
tags + NIP-44 private tags; *not* a `SearchableEvent` itself). Client consumption:
`commons/.../actions/SearchActions.kt`, bootstrap defaults in
`commons/.../account/AccountBootstrapEvents.kt`.
Don't confuse it with `commons/.../commons/search/SearchQuery.kt` — an app-level local-feed
query model (authors/kinds/hashtags/or-terms), unrelated to the NIP-50 wire string.
## Tests (executable spec)
- `commonTest/.../nip50Search/SearchQueryTest.kt` — the extension grammar, token by token.
- `commonTest/.../nip50Search/SearchableKindsTest.kt` — sweeps kinds 0–65535 through
`EventFactory`; the searchable set must equal `SearchableKinds.ALL`.
- `jvmTest/.../nip50Search/IndexableContentGoldenTest.kt` — each searchable kind's
`indexableContent()` for a tag-rich sample, pinned in `jvmTest/resources/indexable-content.golden`.
- `commonTest/.../store/sqlite/SearchTest.kt` — per-kind indexing (kind 0 profile fields,
40/41 channel JSON, 31924/30617), extension-token ignoring, reindex/resumable-reindex,
FTS cleanup on replaceable rotation.
- `commonTest/.../store/sqlite/SearchRelevanceOrderTest.kt` — bm25-before-recency ordering,
limit-after-score, multi-filter rank union.
- `commonTest/.../store/sqlite/NoFullTextSearchTest.kt` — FTS-off contract.
- `jvmTest/.../store/fs/FsSearchTest.kt` — tokenizer parity for the filesystem store.