feat(plugins): let a plugin answer place searches from its own index

TREK searches its own place index and OpenStreetMap. Neither carries a rating,
so "the best rated hotel around here" has never been answerable, and an operator
with a better index for their region had no way to reach the search box at all.
The searchProvider hook is that way in: a plugin is handed the query, a limit,
the language and the coordinate the search is biased toward, and answers with
places in the same shape the core search returns, plus a rating.

It sits beside the core search rather than inside it. The plugin runtime already
imports PlacesModule, which imports MapsModule, so asking the providers from the
maps service would close a module cycle — the same reason the other fourteen
contribution controllers stand next to what they contribute to. The client asks
both at once and merges, at the single point every caller already goes through,
so all nine search surfaces get plugin hits without knowing the hook exists.

A plugin index may never hold up the list: the client stops waiting at two and a
half seconds and the host gives a provider two, which is also why the budget is
the shortest of any hook here. With no such plugin installed the route answers
without reaching the runtime at all.

Every hit is normalized host-side and shared by the REST route and the MCP tool,
so a cap that holds for the browser holds for the assistant: coordinates are
range-checked, strings capped, the rating clamped to 0..5, a website must be
http or https, and ids are namespaced so they can never collide with an OSM one.

search_places_via_plugins is the MCP half, because what the user can see the
assistant has to be able to see; search_place now points at it.
This commit is contained in:
Maurice
2026-09-10 19:33:55 +02:00
committed by Maurice
parent a00cc889ed
commit a9907e2f10
51 changed files with 971 additions and 12 deletions
+43
View File
@@ -94,6 +94,49 @@ module.exports = {
}
```
## Answer place searches from your own index
**Needs:** `hook:search-provider` (+ `http:outbound:<host>` and a matching `egress` when
the index is somebody else's server)
TREK searches its own place index and OpenStreetMap. This hook adds a third one, and
its hits are drawn into the same list the user is already looking at.
`rating` is the reason to reach for it: open data carries no ratings at all, so a
question like "the best rated hotel around here" cannot be answered from OpenStreetMap.
Whatever your index knows, this is where it reaches the search list.
```js
module.exports = {
hooks: {
searchProvider: {
async search({ query, limit, lang, near }, ctx) {
const res = await fetch(
`https://your-index.example/search?q=${encodeURIComponent(query)}&n=${limit}`,
)
const { results } = await res.json()
return results.map(r => ({
id: r.ref, // namespaced host-side to plugin:<yourId>:<ref>
name: r.title,
lat: r.lat,
lng: r.lon,
address: r.address,
rating: r.stars, // 0..5, clamped host-side
website: r.url, // http/https only
}))
},
},
},
}
```
Called for an explicit search, never per keystroke, so an index with rate limits is
safe here. You get two seconds to answer and at most 20 of your places are kept: a
person is waiting on the list, and the app stops waiting at two and a half seconds
whatever happens, so a provider that misses that window is left out of the list rather
than holding it up. `near` is the coordinate the search is biased toward,
and it is what tells the four places called "Hase-dera" apart — use it when you have it.
## Raise validation warnings on a trip
**Needs:** `hook:trip-warning-provider`