import { PluginController, PluginHook } from './host/rpc-kit/decorators'; import { PluginRuntimeService } from './plugin-runtime.service'; /** What a notification channel is handed for one message. */ export interface HookChannelMessage { event: string; title: string; body: string; url?: string; tripName?: string; } /** One MCP tool call, as the plugin that published the tool receives it. */ export interface HookMcpToolCall { /** The plugin-local tool name, without the `plugin__` advertisement prefix. */ name: string; /** Whatever the assistant passed. Validated against the declared schema first. */ args: unknown; } /** * What a search provider is asked to look for. * * `near` is the same bias the core search uses, and it is what decides which of the * four places called "Hase-dera" the answer is about. Optional, because a search from * a trip with no places yet has nothing to bias toward. */ export interface HookSearchRequest { query: string; limit: number; /** The caller's language tag, for providers that return localized names. */ lang?: string; near?: { lat: number; lng: number }; /** Category search within a Roadtrip search rectangle. Older hosts omit these fields. */ category?: string; bounds?: { south: number; west: number; north: number; east: number }; } /** * What a POI category provider is asked for (#1781): the places of ONE of its own * declared categories inside the map area the user is looking at. * * `category` is the plugin-local id from `capabilities.poiCategories`, never a pill * key, and the host only ever sends an id the manifest declared. `bounds` is the * viewport after the host narrowed it to a search window, so a provider never has to * answer for a whole continent. */ export interface HookPoiRequest { category: string; bounds: { south: number; west: number; north: number; east: number }; lang?: string; limit: number; } /** The waypoint request a route provider is asked to solve. */ export interface HookRouteRequest { tripId: number; dayId: number | null; profile: string; waypoints: unknown[]; } /** * Every host-to-plugin hook call, in one place. * * The hooks the consent screen offers (one per HOOK_PERMISSION key, 18 of them now) * used to be invoked straight from the controllers, with the fn name and the timeout * written out at each call site. That * made three things impossible to check: that a granted `hook:*` permission actually * has a consumer (a dead grant on the consent screen looks exactly like a live one), * that the fn name the host asks for matches the one the SDK documents, and that two * call sites for the same hook agree on the budget. * * Each method here carries a `@PluginHook` declaration, which the registry validates * against HOOK_PERMISSION at boot and, with requireTotalCoverage on, fails the app for * any hook that no longer has a consumer. The declaration and the invocation are the * same method on purpose, so the two cannot drift apart. * * Timeouts are the plugin's budget to answer. They are deliberately not uniform: * a name lookup is cheap, a route solve over a full day is not, and a notification * channel is talking to somebody else's API. */ @PluginController() export class PluginHooks { constructor(private readonly runtime: PluginRuntimeService) {} /** The plugins that declared `hook`, in manifest order; with `fn`, only those whose hook also has that function. */ providersOf(hook: string, fn?: string): string[] { return this.runtime.providersOf(hook, fn); } @PluginHook('photoProvider', { permission: 'hook:photo-provider', fn: 'search', timeoutMs: 5000 }) searchPhotos(pluginId: string, query: string, page: number, limit: number, userId: number): Promise { return this.runtime.invokeHook(pluginId, 'photoProvider', 'search', [query, { page, limit }], userId, 5000); } @PluginHook('photoProvider', { permission: 'hook:photo-provider', fn: 'getById', timeoutMs: 5000 }) getPhoto(pluginId: string, photoId: string, userId: number): Promise { return this.runtime.invokeHook(pluginId, 'photoProvider', 'getById', [photoId], userId, 5000); } // A display name is a one-field read, so it gets a shorter leash than the feed it // labels; the caller falls back to the plugin id when it times out. @PluginHook('calendarSource', { permission: 'hook:calendar-source', fn: 'getName', timeoutMs: 3000 }) calendarName(pluginId: string, userId: number): Promise { return this.runtime.invokeHook(pluginId, 'calendarSource', 'getName', [], userId, 3000); } @PluginHook('calendarSource', { permission: 'hook:calendar-source', fn: 'getEvents', timeoutMs: 5000 }) calendarEvents(pluginId: string, userId: number, start: string, end: string): Promise { return this.runtime.invokeHook(pluginId, 'calendarSource', 'getEvents', [userId, start, end], userId, 5000); } @PluginHook('placeDetailProvider', { permission: 'hook:place-detail-provider', fn: 'getDetails', timeoutMs: 5000 }) placeDetails(pluginId: string, placeId: number, userId: number): Promise { return this.runtime.invokeHook(pluginId, 'placeDetailProvider', 'getDetails', [placeId], userId, 5000); } /** * A short leash, because a person is waiting on a list. * * The core search and this one run side by side, so the wait is the slower of the * two rather than their sum, and the client stops waiting at two and a half seconds * whatever happens. Two seconds is therefore the whole of what a provider can * usefully spend: past it the list is already drawn and its answer is discarded, so * a longer budget would only hold an IPC call open for nothing. */ @PluginHook('searchProvider', { permission: 'hook:search-provider', fn: 'search', timeoutMs: 2000 }) searchPlaces(pluginId: string, request: HookSearchRequest, userId: number): Promise { return this.runtime.invokeHook(pluginId, 'searchProvider', 'search', [request], userId, 2000); } /** * The same question asked while it is still being typed (#2221), and the shortest * leash of any hook here. * * Only for a provider that implements `suggest`, which says its index can take a * request per keystroke. The typed-ahead list waits for it beside the core * suggestions, which arrive in a few hundred milliseconds, and the next keystroke * replaces the question anyway, so an answer later than this is one nobody reads. */ @PluginHook('searchProvider', { permission: 'hook:search-provider', fn: 'suggest', timeoutMs: 800 }) suggestPlaces(pluginId: string, request: HookSearchRequest, userId: number): Promise { return this.runtime.invokeHook(pluginId, 'searchProvider', 'suggest', [request], userId, 800); } /** * A chip in the explore pill, answered by the plugin that declared it. * * Longer than the search budget because nobody is typing: the person picked a * category and waits for markers, the way they wait for the core categories, whose * Overpass path is allowed far more. It is still well short of that, because a * provider behind an external API that has not answered in eight seconds is not * going to, and the chip should show its error dot rather than spin. */ @PluginHook('poiCategoryProvider', { permission: 'hook:poi-category-provider', fn: 'getPois', timeoutMs: 8000 }) categoryPois(pluginId: string, request: HookPoiRequest, userId: number): Promise { return this.runtime.invokeHook(pluginId, 'poiCategoryProvider', 'getPois', [request], userId, 8000); } @PluginHook('warningProvider', { permission: 'hook:trip-warning-provider', fn: 'getWarnings', timeoutMs: 5000 }) tripWarnings(pluginId: string, tripId: number, userId: number): Promise { return this.runtime.invokeHook(pluginId, 'warningProvider', 'getWarnings', [tripId], userId, 5000); } @PluginHook('tableContributor', { permission: 'hook:table-contributor', fn: 'getContributions', timeoutMs: 5000 }) tableContributions(pluginId: string, view: string, tripId: number, userId: number): Promise { return this.runtime.invokeHook(pluginId, 'tableContributor', 'getContributions', [view, tripId], userId, 5000); } @PluginHook('mapMarkerProvider', { permission: 'hook:map-marker-provider', fn: 'getMarkers', timeoutMs: 5000 }) mapMarkers(pluginId: string, tripId: number, userId: number): Promise { return this.runtime.invokeHook(pluginId, 'mapMarkerProvider', 'getMarkers', [tripId], userId, 5000); } @PluginHook('mapLayerProvider', { permission: 'hook:map-layer-provider', fn: 'getLayers', timeoutMs: 5000 }) mapLayers(pluginId: string, tripId: number, userId: number): Promise { return this.runtime.invokeHook(pluginId, 'mapLayerProvider', 'getLayers', [tripId], userId, 5000); } // A route solve runs an external routing engine over a whole day of waypoints, so it // gets four times the budget of a plain read. The caller falls back to straight lines. @PluginHook('routeProvider', { permission: 'hook:route-provider', fn: 'getRoute', timeoutMs: 20_000 }) route(pluginId: string, request: HookRouteRequest, userId: number): Promise { return this.runtime.invokeHook(pluginId, 'routeProvider', 'getRoute', [request], userId, 20_000); } @PluginHook('dayScheduleProvider', { permission: 'hook:day-schedule-provider', fn: 'getSchedule', timeoutMs: 5000 }) daySchedule(pluginId: string, tripId: number, userId: number): Promise { return this.runtime.invokeHook(pluginId, 'dayScheduleProvider', 'getSchedule', [tripId], userId, 5000); } @PluginHook('dayTintProvider', { permission: 'hook:day-tint-provider', fn: 'getDayTints', timeoutMs: 5000 }) dayTints(pluginId: string, tripId: number, userId: number): Promise { return this.runtime.invokeHook(pluginId, 'dayTintProvider', 'getDayTints', [tripId], userId, 5000); } @PluginHook('pdfSectionProvider', { permission: 'hook:pdf-section-provider', fn: 'getSections', timeoutMs: 5000 }) pdfSections(pluginId: string, tripId: number, userId: number): Promise { return this.runtime.invokeHook(pluginId, 'pdfSectionProvider', 'getSections', [tripId], userId, 5000); } @PluginHook('atlasLayerProvider', { permission: 'hook:atlas-layer-provider', fn: 'getLayers', timeoutMs: 5000 }) atlasLayers(pluginId: string, userId: number): Promise { return this.runtime.invokeHook(pluginId, 'atlasLayerProvider', 'getLayers', [], userId, 5000); } @PluginHook('journalEntryProvider', { permission: 'hook:journal-entry-provider', fn: 'getRows', timeoutMs: 5000 }) journalRows(pluginId: string, entryId: number, userId: number): Promise { return this.runtime.invokeHook(pluginId, 'journalEntryProvider', 'getRows', [entryId], userId, 5000); } @PluginHook('tripCardProvider', { permission: 'hook:trip-card-provider', fn: 'getCards', timeoutMs: 5000 }) tripCards(pluginId: string, tripIds: number[], userId: number): Promise { return this.runtime.invokeHook(pluginId, 'tripCardProvider', 'getCards', [tripIds], userId, 5000); } /** * Both notification-channel calls run with NO acting user: a notification is * host-initiated for an arbitrary recipient, so the hook gets that recipient's own * decrypted config as an argument rather than the right to read anything as them. * The budget is the longest of the read hooks because the channel is talking to a * third-party API (Telegram, ntfy, a webhook) rather than to TREK. */ @PluginHook('notificationChannel', { permission: 'hook:notification-channel', fn: 'send', timeoutMs: 8000 }) sendNotification(pluginId: string, message: HookChannelMessage, userSettings: unknown): Promise { return this.runtime.invokeHook(pluginId, 'notificationChannel', 'send', [message, userSettings], undefined, 8000); } @PluginHook('notificationChannel', { permission: 'hook:notification-channel', fn: 'test', timeoutMs: 8000 }) testNotification(pluginId: string, userSettings: unknown): Promise { return this.runtime.invokeHook(pluginId, 'notificationChannel', 'test', [userSettings], undefined, 8000); } /** * One MCP tool call the assistant made, dispatched into the plugin that * published the tool. * * The longest budget of any read hook. Every other one backs a render that a * user is waiting on, and falls back to something reasonable when it times * out; this one backs a chat turn, and the plugin is likely talking to a * third-party API of its own. Still well under the supervisor's 30s default, * which no hook uses: 30s of a blocked MCP request is not a budget, it is a * hung client. * * One fn for every tool, because tool names are runtime data and * hookContracts() is keyed by (hook, fn). The name is checked against the * advertised set before we get here. */ @PluginHook('mcpToolProvider', { permission: 'mcp:tools', fn: 'callTool', timeoutMs: 15_000 }) callMcpTool(pluginId: string, call: HookMcpToolCall, userId: number): Promise { return this.runtime.invokeHook(pluginId, 'mcpToolProvider', 'callTool', [call], userId, 15_000); } }