wikicommit-search
Search the wiki's pages by keyword, expanding synonyms and searching each configured language. Use this whenever someone asks what the wiki says about something, wants to find a page, asks whether a t
Files1
View on GitHubSKILL.md.claude/skills/wikicommit-search/SKILL.md
GitHubwikicommit-search
A keyword-search skill that performs FTS5 trigram full-text search over .wikicommit/entity/. All index-building and query logic is delegated to the shared script .wikicommit/scripts/search_index.py; this skill is responsible for building the queries, merging per-language results, and formatting them for display.
Usage
/wikicommit-search <query> [--lang <lang>] [--no-expand]
<query>: search keywords (multiple words separated by spaces are allowed)--lang <lang>: restrict to pages in the given language (ISO 639-1) (optional). This turns off Step 0's fan-out across languages — but not Step 0.5's rendering into<lang>; see both--no-expand: skip Step 0.5's synonym expansion and search the query words themselves (optional). Independent of--lang: it suppresses invented synonyms, not other languages
The two flags are separate axes and can be combined. --lang narrows which languages are searched; --no-expand narrows which words are searched for.
Processing Flow
Step 0: Determine Target Languages
If --lang <lang> was given, the language list is just [<lang>] — skip the rest of this step. Do not detect the query's language and do not add any other language: the user narrowed the search deliberately, and this Skill should not widen it back. --lang is therefore how a user opts out of the fan-out across languages.
What it does not turn off is Step 0.5's rendering: when <lang> is not the language <query> is written in, the concepts are still translated into <lang> before being searched. Searching one language's words inside another language's pages returns nothing by construction, so skipping the translation here would make --lang a guaranteed-empty search rather than a narrower one (/wikicommit-search 認証フロー --lang en has to look for Authentication Flow). Step 3's "Also searched" line reports those translations as usual.
Otherwise, search every language, translating the query into each — a query written in one language reaches pages written in another only when a proper noun or Latin-script term happens to be shared (認証フロー never matches a page titled Authentication Flow):
Read
.wikicommit/config.ymland gettranslation.primary_langandtranslation.targets. If.wikicommit/config.ymldoesn't exist, display an error, guide the user to run/wikicommit-init, and stop.Have the LLM determine the language of
<query>.List the language directories that actually exist under
.wikicommit/entity/(its immediate subdirectories, excludingassets/).Build the list of languages to search, in this order, deduplicating any repeated language: the language of
<query>, thenprimary_lang, then each language intargets, then any remaining directory from step 3.Step 3 exists because every query carries a
--lang, so the search covers a closed set of languages: a wiki can hold pages in a languageconfig.ymlnever mentions (/wikicommit-translate <page> --lang enwrites.wikicommit/entity/en/whatevertargetssays), and without step 3 those pages would be unreachable. Configured languages come first so that they win the dedup priority in Step 2.3.
Step 0.5: Extract, Expand and Translate Keywords
FTS5 matches literal text, so a search finds nothing when the wiki writes the same idea in different words than the user typed — the user asks for 子ども手当, the page says 児童手当, and the result is zero hits even though the wiki covers the topic. Expanding the query with the LLM's own vocabulary knowledge closes that gap.
- Split
<query>into the distinct concepts it is asking about (usually one per word or phrase; a multi-word proper noun is one concept). Do this even under--no-expandand even for a single-language run:search_index.pysplits a positional query on whitespace and treats each piece as a phrase, so a full sentence in a language without whitespace word boundaries (ja,zh) becomes one verbatim phrase that structurally cannot match page body text. - Unless
--no-expandwas given, produce for each concept the original term plus its expansions under the rules below. - For each language from Step 0, render every concept in that language: the language of
<query>uses the terms as-is, and any other language gets each term translated individually (the LLM translates on the fly — no dedicated translation API or library). Do not translate<query>as one sentence and pass that. One concept becomes one--expandgroup per language.
Do not add expansions to the query string instead of grouping them. FTS5 AND-s adjacent phrases, so an appended synonym makes the search narrower — pages would have to contain every wording at once, and the hit that motivated the expansion disappears. Only the --expand grouping OR-s them.
Expansion rules. trigram search matches substrings, so inflected forms and longer compounds containing the term (エンジニア → ソフトウェアエンジニア) are already reached for free; spending expansion slots there only adds noise. Expand only where the vocabulary genuinely differs:
- Expand: synonyms (
児童手当/子ども手当), hypernyms and general terms (Claude Code/AIコーディングツール), abbreviation–full-form pairs (LLM/大規模言語モデル), cross-language equivalents (vibe coding/バイブコーディング), orthographic variants (サーバ/サーバー). - Do not expand: inflected forms and word endings, compounds already reachable as a substring, or merely related terms whose meaning sits somewhere else (a term that "gets discussed alongside" the original is not a synonym).
- Limits: at most 2–3 expansions per original term, and roughly 5 expanded terms across the whole search. Left unbounded this widens without end.
- Never produce an expansion shorter than 3 characters — the trigram tokenizer cannot form a token from it, so it can never match.
Step 1: Run the Query, Once Per Language
Run one query per language from Step 0, always passing that language as --lang. Run them sequentially, one language at a time — never in parallel: search_index.py query rebuilds the index whenever it is missing or stale, so concurrent calls after a wiki change would each rebuild it — the swap is atomic, so nothing breaks, but the work is done once per language instead of once (the same reason wikicommit-synthesize gives for its own per-language loop).
--limit: always --limit 10, per language, regardless of how many languages are in the list. Step 2.3 merges and trims to 10. Splitting the budget across languages (5 each, as wikicommit-ask does for its grounding set) would shrink the result list on the wikis this feature is for: in a fully translated two-language wiki, both queries return the same ten pages in different languages, dedup collapses them to one set, and the user ends up with five results where a single unfiltered --limit 10 query used to give ten. wikicommit-ask splits because every extra hit costs LLM context; this Skill only prints lines, so the local index is free to return more.
With expansion (the default), pass one --expand per concept, using that language's rendering from Step 0.5, each group's terms joined by |:
python .wikicommit/scripts/search_index.py query \
--expand "$(cat <<'EOF'
<concept 1 term in this language>|<expansion>|<expansion>
EOF
)" \
--expand "$(cat <<'EOF'
<concept 2 term in this language>|<expansion>
EOF
)" --lang <lang> --limit 10
With --no-expand, use the positional form instead, with that language's rendering of the concepts joined by spaces (for the query's own language that is the query's own words):
python .wikicommit/scripts/search_index.py query "$(cat <<'EOF'
<concepts in this language, space-separated>
EOF
)" --lang <lang> --limit 10
Pass every search term through a quote-delimited heredoc, not a plain double-quote embedding — <query> is free-form user text with no upstream validation, expansions and translations are LLM output derived from it, and command substitution (`, $(...)) is evaluated by the shell at the point this command line is assembled regardless of which program the resulting argument is ultimately handed to; scoping the downstream program to a local read-only script does not by itself contain that.
Never pass both forms at once — the script rejects that combination rather than guessing which semantics you meant.
On exit code 1 (failure, with an ERROR: line printed), display that error message as-is to the user and stop (e.g. SQLite doesn't support trigram, or .wikicommit/entity/ doesn't exist). Stop on the first such failure rather than continuing with the remaining languages — the cause is the environment, not the query, so the next language would fail the same way.
Step 2: Parse the Output
Parse each language's stdout separately, keeping track of which language each line came from:
MATCH: <path> | title=<title> | type=<type> | lang=<lang> | review_status=<review_status>line → information for one hit- The indented line immediately after (
<snippet>) → that hit's snippet SUMMARY: query=<what was searched>, hits=<N>line → that language's hit count, not the number Step 3 reports (Step 2.3 computes that one). With--no-expandthe value is the raw query in double quotes (query="児童手当"); with expansion it is the FTS5 expression actually run (query=("子ども手当" OR "児童手当") AND ("手続き")), which reflects any term the script dropped and is what Step 3 shows the userWARNING:line(s), zero or more —search_index.pydetects terms too short for the trigram tokenizer to ever match. Four forms, which mean different things:WARNING: query term "<term>" has <N> character(s); ... cannot match anything→--no-expandpath; that term kills the whole search. Collect these to explain an unexpectedhits=0in Step 3WARNING: expand term "<term>" ... so it was dropped — its group still matches via: <terms>→ the concept survives through a longer synonym. Do not surface this to the user: nothing is wrong, and the expansion did exactly what it is forWARNING: expand group "<a>|<b>" has no term of at least 3 character(s); ... no longer narrows the search→ that entire concept was dropped, so the results are broader than asked for. Surface this in Step 3 — in both thehits=0and thehits>= 1 branch, since results that silently answer a narrower question than the user asked are exactly what this warning exists to flagWARNING: no usable --expand term remains; ... cannot match anything→ every concept was dropped, so the search ran with no terms andhits=0is guaranteed regardless of what the wiki contains. Surface this in Step 3 instead of the per-group message, whose "no longer narrows the search" wording is the opposite of what happened here
Record the language alongside every warning you keep. A short-term warning usually applies to one language's rendering of a concept — a translation can land under three characters where the original did not — and reporting it without saying which language would suggest the whole search was broken when only one leg of it was.
Step 2.3: Merge Results Across Languages
Skip this step if only one language was searched; its hits are the result list as-is.
- Combine every language's hits into a single list.
- Collapse language variants of the same page into one hit. Two or more hits that share the same
typeandslugare the same page in different languages (linked viatranslated_from). Neither field is printed as such:typecomes from theMATCH:line, and theslugis the<path>'s file name with.mdremoved (.wikicommit/entity/ja/DefinedTerm/auth-flow.md→auth-flow). Keep exactly one, by this priority: the language of<query>>primary_lang> the order the language appears intargets. Remember which languages the suppressed variants were in — Step 3 shows them, so that a hit's other-language versions are visible instead of silently disappearing. - Sort the surviving hits roughly by the bm25 order
search_index.pyreturned (already ranked within each language) and keep the top 10. A naive cross-language score comparison is accepted as an approximation here: corpus size and trigram distribution differ per language, so a strict comparison isn't available. This is the same approximationwikicommit-askdocuments.
The merged count is what Step 3 reports as <hits> — not the sum of the per-language hits= values, which double-counts translated pages.
Step 2.5: Look Up Each Hit's sources
For each merged hit's <path> (skip this step entirely if there are no hits left after Step 2.3), read the page and extract its frontmatter. search_index.py's own index doesn't carry sources — it isn't full-text-searchable structured data, so extending the FTS5 schema for it isn't worth it when each hit's path is already in hand. Format a sources line per hit:
- Ordinary page with a non-empty
sourceslist: format each entry and join with,:type: path→ thepathvalue as-is (e.g.,raw/paper-2024.pdf)type: url/type: wikicommit→ theurlvalue with a leadinghttps://orhttp://stripped (e.g.,simonwillison.net/2025/May/1/not-vibe-coding/— full URLs would dominate the result listing's width; the scheme carries no information the reader needs to recognize the source)type: manual→manual (author: <author>)
- Translation page (
translated_frompresent; these have nosourcesof their own): read the parent page attranslated_fromand format itssourcesthe same way as above, prefixed withtranslated from <translated_from>:— this is more informative than a bare "this is a translation" note, and the parent page'ssourcesare exactly what a reader chasing provenance actually wants. If the parent page itself is missing (already acheck_translation_status.pyMISSING_SOURCEcase elsewhere), fall back totranslated from <translated_from> (parent page not found). - Synthesized page (
derived_frompresent; these also have nosourcesof their own): format asderived from: <path1>, <path2>, ...using each entry'spath. - Anything else with an empty or missing
sources(a malformed page that isn't a translation/synthesis/index page —index.mdpages don't reach this step at all, sincesearch_index.py'sbuildalready excludes them from the index):sources: (none).
Step 3: Display Results
<hits> throughout this step is the merged count from Step 2.3.
If there are no hits:
No matching pages
If any surfaceable WARNING: line was parsed in Step 2 (the query term, expand group and no usable --expand term forms, not the expand term one), append a supplementary note naming the offending term(s) and the language whose query produced them (translate the explanation into the query's language if it isn't English). Naming the language matters once more than one is searched: a term can be long enough in the original and too short once translated, and "no matching pages" would otherwise look like a gap in the wiki rather than a query that structurally could not match in that language:
No matching pages
⚠️ The query term(s) "<term1>" (en), "<term2>" (ja) are shorter than 3 characters. Trigram search can't match terms under 3 characters — try a more specific term (e.g. 児童手当 instead of 児童).
Display this and stop.
If <hits> is 1 or more, number the hits 1, 2, 3, ... in merged order and format them, appending the sources line from Step 2.5. Append ⚠️ Unreviewed at the end of the line for any hit with review_status: pending (omit it for reviewed). If Step 2.3 suppressed other-language versions of a hit, append (also in: <lang>, ...) so the reader can see the page exists in those languages too — collapsing the duplicates keeps the list readable, but hiding that they existed would make the wiki look thinner than it is.
If an expand group warning was parsed in Step 2, say so here too, on a line under the header, naming the dropped concept. These results answer a broader question than the user asked — one of their concepts is not constraining the search at all, so pages unrelated to it are in the list — and that is invisible from the hits themselves. Reporting it only when hits=0 would leave the more common case (some other concept still matches, so results do come back) silently wrong:
⚠️ The concept "<a>|<b>" was dropped: every wording is shorter than 3 characters, which trigram search can never match. The results below are not narrowed by it.
Say which words were actually searched — list, on a line under the header, every term that is not one of the user's own words: the expansions Step 0.5 added and the translations it produced for the other languages. A person reading these results is entitled to know that a word they never typed is why a hit is here; silently searching for something else and presenting the results as theirs is the wrong default for a Skill whose whole output is read by a human (wikicommit-ask makes the opposite call because there the search is an internal step and the answer carries its own grounding notes). Omit the line only when nothing was in fact added — --no-expand together with a single language that is the query's own (under --lang <other-lang> the translations still have to be listed) — and omit any term the Step 2 warnings say was dropped — it did not contribute:
Search results: "<query>" (<hits> hits)
Also searched: 子ども手当 (ja), child allowance, child benefit (en)
1. <title> (<type>, <lang>) ⚠️ Unreviewed
<path>
<snippet>
sources: simonwillison.net/2025/May/1/not-vibe-coding/, arxiv.org/html/2510.17842v1
2. <title> (<type>, <lang>) (also in: en)
<path>
<snippet>
sources: translated from .wikicommit/entity/ja/DefinedTerm/vibe-coding.md: raw/paper-2024.pdf
Notes
- Do not commit or create a PR against
mainor any branch - Do not write to
.wikicommit/schema/ - This skill itself has no side effects. However,
search_index.pyautomatically rebuilds the index file (.wikicommit/.cache/search_index.sqlite3, not tracked by Git) when it doesn't exist or no longer matches the pages (a page added, edited or removed since it was built — it then prints aNOTE:line)