@try-works/dsh-righthand 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,241 @@
1
+ /**
2
+ * events-tools — rh_events_* over ctx.storageDomain.
3
+ * Reminders and the free-slot query. The agent IS the scheduler:
4
+ * rh_events_due is the check the agent runs each turn — an event whose
5
+ * time has come is returned once (pending → notified), so a missed run
6
+ * cannot go quiet without a record. Repeating events are one-off here;
7
+ * a real scheduler is the documented Cloudflare cron escalation.
8
+ * @module dsh-righthand/events-tools
9
+ */
10
+
11
+ import type { Context } from '@deepseek-ai/cordis'
12
+ import { defineTool } from '@deepseek-ai/dsh-tools'
13
+ import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
14
+ import type { Domain } from '@deepseek-ai/dsh-storage-domain'
15
+ import { z } from 'zod'
16
+
17
+ export const name = 'righthand-events'
18
+ export const inject = ['tools', 'storageDomain']
19
+
20
+ export interface Event {
21
+ id: string
22
+ title: string
23
+ detail: string
24
+ at: string
25
+ state: 'pending' | 'notified' | 'cancelled',
26
+ createdAt: string
27
+ }
28
+
29
+ export const eventsDomain = defineDomain({
30
+ name: 'righthand_events',
31
+ version: 1,
32
+ tables: {
33
+ events: domainTable(z.object({
34
+ id: z.string(),
35
+ title: z.string(),
36
+ detail: z.string(),
37
+ at: z.string(),
38
+ state: z.enum(['pending', 'notified', 'cancelled']),
39
+ createdAt: z.string(),
40
+ })),
41
+ },
42
+ })
43
+
44
+ export type EventsDomain = Domain<typeof eventsDomain>
45
+
46
+ function newId(): string {
47
+ return 'e-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6)
48
+ }
49
+
50
+ /**
51
+ * Free slots: gaps of at least durationMinutes between pending events at or
52
+ * after now, within working hours 09:00–17:00, over the next horizonHours.
53
+ * Pure and exported for tests.
54
+ */
55
+ export function freeSlots(events: readonly Event[], now: Date, durationMinutes: number, horizonHours = 24): string[] {
56
+ const horizonEnd = now.getTime() + horizonHours * 3600000
57
+ const busy = events
58
+ .filter(e => e.state === 'pending')
59
+ .map(e => new Date(e.at).getTime())
60
+ .filter(t => Number.isFinite(t) && t >= now.getTime() && t <= horizonEnd)
61
+ .sort((a, b) => a - b)
62
+ const slots: string[] = []
63
+ let cursor = now.getTime()
64
+ const dayStart = (t: number): number => {
65
+ const d = new Date(t)
66
+ d.setHours(9, 0, 0, 0)
67
+ return d.getTime()
68
+ }
69
+ const dayEnd = (t: number): number => {
70
+ const d = new Date(t)
71
+ d.setHours(17, 0, 0, 0)
72
+ return d.getTime()
73
+ }
74
+ const need = durationMinutes * 60000
75
+ for (const b of busy) {
76
+ let from = Math.max(cursor, dayStart(cursor))
77
+ if (from < b && b - from >= need && from + need <= dayEnd(from) && from + need <= horizonEnd) {
78
+ slots.push(new Date(from).toISOString())
79
+ }
80
+ cursor = Math.max(b, dayEnd(cursor) + 1)
81
+ if (cursor > horizonEnd) break
82
+ }
83
+ return slots
84
+ }
85
+
86
+ export function apply(ctx: Context): void {
87
+ let domain: EventsDomain | undefined
88
+ let ready: Promise<void> | undefined
89
+
90
+ ctx.effect(() => {
91
+ ready = (async () => {
92
+ domain = await ctx.storageDomain.open(eventsDomain)
93
+ })()
94
+ return () => { /* domain facility owns the domain */ }
95
+ })
96
+
97
+ const ensure = async (): Promise<EventsDomain> => {
98
+ if (domain === undefined) { await ready; }
99
+ if (domain === undefined) throw new Error('righthand events are not open (storageDomain not mounted)')
100
+ return domain
101
+ }
102
+
103
+ const all = async (): Promise<Event[]> => {
104
+ const d = await ensure()
105
+ return [...d.table('events').entries()].map(([, e]) => e)
106
+ }
107
+
108
+ ctx.tools.register(defineTool({
109
+ name: 'rh_events_create',
110
+ description: 'Schedule a reminder at an ISO datetime. One-off: the agent checks rh_events_due each turn and the event is delivered exactly once (pending to notified), so a missed run is never silently dropped.',
111
+ parameters: {
112
+ title: { type: 'string', required: true, description: 'What to be reminded of.' },
113
+ at: { type: 'string', required: true, description: 'ISO datetime when it is due, e.g. 2026-08-31T09:00:00Z.' },
114
+ detail: { type: 'string', description: 'Optional longer note.' },
115
+ },
116
+ output: {
117
+ schema: {
118
+ type: 'object',
119
+ additionalProperties: false,
120
+ properties: {
121
+ id: { type: 'string', required: true },
122
+ title: { type: 'string', required: true },
123
+ at: { type: 'string', required: true },
124
+ },
125
+ },
126
+ render: (_args, value) => [{ type: 'text', text: 'event ' + value.id + ': ' + value.title + ' at ' + value.at }],
127
+ },
128
+ async execute(args) {
129
+ const d = await ensure()
130
+ const at = new Date(args.at)
131
+ if (Number.isNaN(at.getTime())) throw new Error('at must be a valid ISO datetime')
132
+ const event: Event = {
133
+ id: newId(),
134
+ title: args.title.trim(),
135
+ detail: args.detail ?? '',
136
+ at: at.toISOString(),
137
+ state: 'pending',
138
+ createdAt: new Date().toISOString(),
139
+ }
140
+ if (event.title === '') throw new Error('an event needs a title')
141
+ await d.table('events').put(event.id, event)
142
+ return { id: event.id, title: event.title, at: event.at }
143
+ },
144
+ }))
145
+
146
+ ctx.tools.register(defineTool({
147
+ name: 'rh_events_due',
148
+ description: 'The pending events whose time has come (at <= now). Each is delivered exactly once: returned events are marked notified. This is the check to run every turn — the agent is the scheduler.',
149
+ parameters: {},
150
+ output: {
151
+ schema: {
152
+ type: 'object',
153
+ additionalProperties: false,
154
+ properties: {
155
+ due: { type: 'array', required: true, items: { type: 'json' } },
156
+ },
157
+ },
158
+ render: (_args, value) => [{ type: 'text', text: value.due.length === 0 ? '(nothing due)' : value.due.map((e: any) => e.title + ' at ' + e.at).join(' / ') }],
159
+ },
160
+ async execute() {
161
+ const d = await ensure()
162
+ const now = Date.now()
163
+ const due: Event[] = []
164
+ for (const e of [...d.table('events').entries()].map(([, v]) => v)) {
165
+ if (e.state === 'pending' && new Date(e.at).getTime() <= now) {
166
+ const notified: Event = { ...e, state: 'notified' }
167
+ await d.table('events').put(e.id, notified)
168
+ due.push(notified)
169
+ }
170
+ }
171
+ due.sort((a, b) => a.at.localeCompare(b.at))
172
+ return { due: due.map(e => ({ id: e.id, title: e.title, detail: e.detail, at: e.at })) as any[] }
173
+ },
174
+ }))
175
+
176
+ ctx.tools.register(defineTool({
177
+ name: 'rh_events_list',
178
+ description: 'List events: upcoming pending ones first, then notified and cancelled.',
179
+ parameters: {
180
+ state: { type: 'string', description: 'Filter: pending | notified | cancelled. Omit for all.' },
181
+ },
182
+ output: {
183
+ schema: { type: 'array', items: { type: 'json' } },
184
+ render: (_args, value) => [{ type: 'text', text: value.length === 0 ? '(no events)' : (value as any[]).map((e: any) => e.at + ' ' + e.title + ' [' + e.state + ']').join(' / ') }],
185
+ },
186
+ async execute(args) {
187
+ const events = await all()
188
+ const wanted = args.state ?? null
189
+ const filtered = wanted ? events.filter(e => e.state === wanted) : events
190
+ const rank = { pending: 0, notified: 1, cancelled: 2 } as Record<string, number>
191
+ filtered.sort((a, b) => (rank[a.state] - rank[b.state]) || a.at.localeCompare(b.at))
192
+ return filtered.map(e => ({ id: e.id, title: e.title, detail: e.detail, at: e.at, state: e.state })) as any[]
193
+ },
194
+ }))
195
+
196
+ ctx.tools.register(defineTool({
197
+ name: 'rh_events_free',
198
+ description: 'When is there nothing booked: the next free slots of at least durationMinutes within working hours (09:00-17:00) over the next horizonHours.',
199
+ parameters: {
200
+ durationMinutes: { type: 'integer', required: true, description: 'How long the free block must be.' },
201
+ horizonHours: { type: 'integer', description: 'How far ahead to look (default 24).' },
202
+ },
203
+ output: {
204
+ schema: { type: 'array', items: { type: 'string' } },
205
+ render: (_args, value) => [{ type: 'text', text: value.length === 0 ? '(no free slot)' : (value as string[]).join(' / ') }],
206
+ },
207
+ async execute(args) {
208
+ const events = await all()
209
+ return freeSlots(events, new Date(), args.durationMinutes, args.horizonHours ?? 24)
210
+ },
211
+ }))
212
+
213
+ ctx.tools.register(defineTool({
214
+ name: 'rh_events_cancel',
215
+ description: 'Cancel an event by id. Returns whether it existed. Cancelled events stay in the record — a cancellation is news too.',
216
+ parameters: {
217
+ id: { type: 'string', required: true, description: 'The event id.' },
218
+ },
219
+ output: {
220
+ schema: {
221
+ type: 'object',
222
+ additionalProperties: false,
223
+ properties: {
224
+ id: { type: 'string', required: true },
225
+ existed: { type: 'boolean', required: true },
226
+ },
227
+ },
228
+ render: (_args, value) => [{ type: 'text', text: 'cancelled ' + value.id + ': ' + value.existed }],
229
+ },
230
+ async execute(args) {
231
+ const d = await ensure()
232
+ const e = d.table('events').get(args.id)
233
+ if (e === undefined) return { id: args.id, existed: false }
234
+ await d.table('events').put(args.id, { ...e, state: 'cancelled' })
235
+ return { id: args.id, existed: true }
236
+ },
237
+ }))
238
+ }
239
+
240
+
241
+
package/src/index.ts CHANGED
@@ -8,8 +8,10 @@
8
8
  * The package's plugin entry (name/inject/apply) mounts the tool families
9
9
  * and the packaged skill as child fibers; each child waits on its own
10
10
  * services, so a
11
- * profile without `storageDomain` (e.g. headless) simply gets the other
12
- * seven tools instead of failing the boot.
11
+ * profile without `storageDomain` (e.g. headless) simply gets the
12
+ * store/task/events families dormant and the other twenty-three tools
13
+ * (secrets, settings, exec, text, weather, places, files, notify) instead
14
+ * of failing the boot.
13
15
  *
14
16
  * Modules (each individually mountable via `export * as ...`):
15
17
  * - store-tools: rh_store_put/get/delete/list over ctx.storageDomain
@@ -17,7 +19,10 @@
17
19
  * - task-tools: rh_task_create/list/next/update/delete over ctx.storageDomain
18
20
  * - text-tools: rh_text_summarise/extract/classify/translate over ctx.llm
19
21
  * - weather-tools: rh_weather_forecast/air over Open-Meteo (keyless, SSRF-guarded)
22
+ * - places-tools: rh_places_geocode/address/elevation/nearby (Nominatim + Open-Meteo, keyless)
20
23
  * - files-tools: rh_files_put/get/list/share/delete over Cloudflare R2
24
+ * - events-tools: rh_events_create/due/list/free/cancel over ctx.storageDomain
25
+ * - notify-tools: rh_notify_send over ntfy.sh (keyless)
21
26
  * - exec-tools: rh_run / rh_run_bg over ctx.subprocess + ctx.jobs
22
27
  * - guard-tools: tools/pre-execute policy (config.rules)
23
28
  * - skills: the packaged dsh-righthand skill (ctx.skills)
@@ -33,7 +38,10 @@ import * as guardTools from './guard-tools.ts'
33
38
  import * as taskTools from './task-tools.ts'
34
39
  import * as textTools from './text-tools.ts'
35
40
  import * as weatherTools from './weather-tools.ts'
41
+ import * as placesTools from './places-tools.ts'
36
42
  import * as filesTools from './files-tools.ts'
43
+ import * as eventsTools from './events-tools.ts'
44
+ import * as notifyTools from './notify-tools.ts'
37
45
  import * as righthandSkills from './skills.ts'
38
46
  import type { GuardRule } from './guard-tools.ts'
39
47
 
@@ -61,20 +69,27 @@ export function apply(ctx: Context, config: RighthandConfig = {}): void {
61
69
  ctx.plugin(taskTools)
62
70
  ctx.plugin(textTools)
63
71
  ctx.plugin(weatherTools)
72
+ ctx.plugin(placesTools)
64
73
  ctx.plugin(filesTools)
74
+ ctx.plugin(eventsTools)
75
+ ctx.plugin(notifyTools)
65
76
  ctx.plugin(guardTools, { rules: config.rules ?? [] })
66
77
  ctx.plugin(righthandSkills)
67
78
  }
68
79
 
69
80
  // Individual modules stay importable for selective mounting.
70
- export { storeTools, secretsTools, execTools, taskTools, textTools, weatherTools, filesTools, guardTools, righthandSkills }
81
+ export { storeTools, secretsTools, execTools, taskTools, textTools, weatherTools, placesTools, filesTools, eventsTools, notifyTools, guardTools, righthandSkills }
71
82
  export { SKILL_NAME, SKILL_DESCRIPTION, skillDirectory, skillBody } from './skills.ts'
72
83
  export type { GuardRule } from './guard-tools.ts'
73
84
  export { guardFactsFor } from './guard-tools.ts'
74
85
  export type { GuardFacts } from './guard-tools.ts'
75
86
  export { isBlockedIP, guardedFetch, normalizeForecast, normalizeAir } from './weather-tools.ts'
87
+ export { normalizePlace, normalizePlaces, normalizeElevation, distanceKm, nearbyViewbox } from './places-tools.ts'
76
88
  export { createR2Client, R2_ACCESS_KEY_REF, R2_SECRET_KEY_REF } from './files-tools.ts'
77
89
  export { signRequest, presignGet } from './sigv4.ts'
90
+ export { freeSlots } from './events-tools.ts'
91
+ export type { Event } from './events-tools.ts'
78
92
  export type { R2Options, ListEntry } from './files-tools.ts'
79
93
  export type { WeatherConfig, ForecastOut, AirOut } from './weather-tools.ts'
94
+ export type { PlacesConfig, PlaceOut, ElevationOut } from './places-tools.ts'
80
95
 
@@ -0,0 +1,62 @@
1
+ /**
2
+ * notify-tools — rh_notify_send over ntfy.sh (keyless).
3
+ * Interrupt yourself on your own devices: publish to a topic; any phone
4
+ * subscribed to that topic gets it. The topic name is the only secret —
5
+ * choose one nobody can guess, like the digest of a random string.
6
+ * @module dsh-righthand/notify-tools
7
+ */
8
+
9
+ import type { Context } from '@deepseek-ai/cordis'
10
+ import { defineTool } from '@deepseek-ai/dsh-tools'
11
+ import { settingsNamespace } from '@deepseek-ai/dsh-settings'
12
+ import { guardedFetch } from './weather-tools.ts'
13
+
14
+ export const name = 'righthand-notify'
15
+ export const inject = ['tools', 'settings']
16
+
17
+ export interface NotifyConfig {
18
+ /** ntfy server base URL; default https://ntfy.sh (keyless publish). */
19
+ baseUrl?: string
20
+ /** Topic to publish to; defaults to the righthand settings defaultNotifyTopic. */
21
+ topic?: string
22
+ }
23
+
24
+ export function apply(ctx: Context, config: NotifyConfig = {}): void {
25
+ const ns = settingsNamespace('righthand')
26
+ const baseUrl = config.baseUrl ?? 'https://ntfy.sh'
27
+
28
+ ctx.tools.register(defineTool({
29
+ name: 'rh_notify_send',
30
+ description: 'Interrupt yourself on your own devices: publish a notification to a ntfy.sh topic (keyless). Any device subscribed to that topic receives it. The topic name is the only secret — use an unguessable one. Defaults: priority 1 and auto-delete after 24h.',
31
+ parameters: {
32
+ message: { type: 'string', required: true, description: 'The notification body.' },
33
+ title: { type: 'string', description: 'Notification title.' },
34
+ topic: { type: 'string', description: 'Topic to publish to; defaults to the settings defaultNotifyTopic.' },
35
+ priority: { type: 'integer', description: '1 (low/default) to 5 (urgent). Default 1.' },
36
+ },
37
+ output: {
38
+ schema: {
39
+ type: 'object',
40
+ additionalProperties: false,
41
+ properties: {
42
+ topic: { type: 'string', required: true },
43
+ sent: { type: 'boolean', required: true },
44
+ },
45
+ },
46
+ render: (_args, value) => [{ type: 'text', text: 'notified ' + value.topic }],
47
+ },
48
+ async execute(args) {
49
+ const settings = ctx.settings.get(ns) as { defaultNotifyTopic?: string } | undefined
50
+ const topic = args.topic ?? config.topic ?? settings?.defaultNotifyTopic ?? ''
51
+ if (topic === '') throw new Error('notify topic is not set (arg, plugin config, or settings defaultNotifyTopic)')
52
+ const priority = Math.min(Math.max(args.priority ?? 1, 1), 5)
53
+ const headers: Record<string, string> = { 'Priority': String(priority), 'X-TTL': '24h' }
54
+ if (args.title !== undefined) headers['Title'] = args.title
55
+ await guardedFetch(baseUrl + '/' + encodeURIComponent(topic), {
56
+ init: { method: 'POST', body: args.message, headers },
57
+ })
58
+ return { topic, sent: true }
59
+ },
60
+ }))
61
+ }
62
+
@@ -0,0 +1,227 @@
1
+ /**
2
+ * places-tools - the second keyless data-adapter family (after weather).
3
+ * Geocoding, reverse geocoding and nearby search over Nominatim
4
+ * (OpenStreetMap, keyless), elevation over Open-Meteo (keyless).
5
+ *
6
+ * Every request goes through guardedFetch (the SSRF checklist from
7
+ * blueprint/data-adapter); Nominatim requests carry a User-Agent per its
8
+ * usage policy (max 1 req/s - agent-paced calls only).
9
+ * @module dsh-righthand/places-tools
10
+ */
11
+
12
+ import type { Context } from '@deepseek-ai/cordis'
13
+ import { defineTool } from '@deepseek-ai/dsh-tools'
14
+ import { guardedFetch } from './weather-tools.ts'
15
+
16
+ export const name = 'righthand-places'
17
+ export const inject = ['tools']
18
+
19
+ export interface PlacesConfig {
20
+ /** Nominatim endpoint; default https://nominatim.openstreetmap.org (keyless). */
21
+ nominatimUrl?: string
22
+ /** Open-Meteo elevation endpoint; default https://api.open-meteo.com/v1/elevation (keyless). */
23
+ elevationUrl?: string
24
+ /** User-Agent sent with Nominatim requests (its usage policy requires one). */
25
+ userAgent?: string
26
+ }
27
+
28
+ const DEFAULTS: Required<PlacesConfig> = {
29
+ nominatimUrl: 'https://nominatim.openstreetmap.org',
30
+ elevationUrl: 'https://api.open-meteo.com/v1/elevation',
31
+ userAgent: 'dsh-righthand (agent data adapter)',
32
+ }
33
+
34
+ /** The stable place shape callers see - the wire JSON is Nominatim's. */
35
+ export interface PlaceOut {
36
+ name: string
37
+ lat: number
38
+ lon: number
39
+ displayName: string
40
+ category: string
41
+ type: string
42
+ address: Record<string, string>
43
+ }
44
+
45
+ /** Normalize one Nominatim result (search or reverse) to PlaceOut. */
46
+ export function normalizePlace(raw: any): PlaceOut {
47
+ const address: Record<string, string> = {}
48
+ if (raw !== null && typeof raw === 'object' && raw.address !== null && typeof raw.address === 'object') {
49
+ for (const [k, v] of Object.entries(raw.address)) {
50
+ if (typeof v === 'string') address[k] = v
51
+ }
52
+ }
53
+ return {
54
+ name: typeof raw.name === 'string' ? raw.name : '',
55
+ lat: Number(raw.lat ?? 0),
56
+ lon: Number(raw.lon ?? 0),
57
+ displayName: typeof raw.display_name === 'string' ? raw.display_name : '',
58
+ category: typeof raw.category === 'string' ? raw.category : '',
59
+ type: typeof raw.type === 'string' ? raw.type : '',
60
+ address,
61
+ }
62
+ }
63
+
64
+ export function normalizePlaces(raw: any): PlaceOut[] {
65
+ if (!Array.isArray(raw)) return []
66
+ return raw.map(normalizePlace)
67
+ }
68
+
69
+ /** Normalize the Open-Meteo elevation response { elevation: [8.0] }. */
70
+ export interface ElevationOut {
71
+ elevation: number
72
+ }
73
+
74
+ export function normalizeElevation(raw: any): ElevationOut {
75
+ const arr = Array.isArray(raw.elevation) ? raw.elevation : []
76
+ return { elevation: Number(arr[0] ?? 0) }
77
+ }
78
+
79
+ /** Haversine distance in km between two lat/lon pairs. */
80
+ export function distanceKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
81
+ const rad = Math.PI / 180
82
+ const dLat = (lat2 - lat1) * rad
83
+ const dLon = (lon2 - lon1) * rad
84
+ const sLat = Math.sin(dLat / 2)
85
+ const sLon = Math.sin(dLon / 2)
86
+ const a = sLat * sLat + Math.cos(lat1 * rad) * Math.cos(lat2 * rad) * sLon * sLon
87
+ return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
88
+ }
89
+
90
+ /**
91
+ * The Nominatim viewbox for a radius around a point, as
92
+ * left,top,right,bottom (lon,lat pairs) - the order the API expects.
93
+ */
94
+ export function nearbyViewbox(latitude: number, longitude: number, radiusKm: number): string {
95
+ const dy = radiusKm / 110.57
96
+ const cos = Math.cos(latitude * Math.PI / 180)
97
+ const dx = radiusKm / (111.32 * (cos === 0 ? 1 : cos))
98
+ const left = longitude - dx
99
+ const top = latitude + dy
100
+ const right = longitude + dx
101
+ const bottom = latitude - dy
102
+ return [left, top, right, bottom].map(v => v.toFixed(6)).join(',')
103
+ }
104
+
105
+ export function apply(ctx: Context, config: PlacesConfig = {}): void {
106
+ const cfg: Required<PlacesConfig> = { ...DEFAULTS, ...config }
107
+
108
+ const nomHeaders = { 'User-Agent': cfg.userAgent }
109
+
110
+ ctx.tools.register(defineTool({
111
+ name: 'rh_places_geocode',
112
+ description: 'Geocode a place query with Nominatim (OpenStreetMap, keyless): text in, ranked places out. Covers search too. Respects the 1 req/s public usage policy - agent-paced calls only.',
113
+ parameters: {
114
+ query: { type: 'string', required: true, description: 'Free-text place query, e.g. "big ben london" or "Eiffel Tower".' },
115
+ limit: { type: 'integer', description: 'Max results, default 5 (max 20).' },
116
+ },
117
+ output: {
118
+ schema: { type: 'array', items: { type: 'json' } },
119
+ render: (_args, value) => {
120
+ const arr = value as any[]
121
+ const text = arr.length === 0 ? '(no places found)' : arr.length + ' place(s): ' + arr.map(p => p.name !== '' ? p.name : p.displayName).join('; ')
122
+ return [{ type: 'text', text }]
123
+ },
124
+ },
125
+ async execute(args) {
126
+ const limit = Math.min(Math.max(1, Math.round(args.limit ?? 5)), 20)
127
+ const qs = new URLSearchParams({ q: args.query, format: 'jsonv2', addressdetails: '1', limit: String(limit) })
128
+ const body = await guardedFetch(cfg.nominatimUrl + '/search?' + qs.toString(), { init: { headers: nomHeaders } })
129
+ return normalizePlaces(JSON.parse(body)) as any[]
130
+ },
131
+ }))
132
+
133
+ ctx.tools.register(defineTool({
134
+ name: 'rh_places_address',
135
+ description: 'Reverse geocode a lat/lon with Nominatim (OpenStreetMap, keyless): coordinates in, the closest address out.',
136
+ parameters: {
137
+ latitude: { type: 'number', required: true, description: 'Latitude.' },
138
+ longitude: { type: 'number', required: true, description: 'Longitude.' },
139
+ },
140
+ output: {
141
+ schema: {
142
+ type: 'object',
143
+ additionalProperties: false,
144
+ properties: {
145
+ name: { type: 'string', required: true },
146
+ lat: { type: 'number', required: true },
147
+ lon: { type: 'number', required: true },
148
+ displayName: { type: 'string', required: true },
149
+ category: { type: 'string', required: true },
150
+ type: { type: 'string', required: true },
151
+ address: { type: 'json', required: true },
152
+ },
153
+ },
154
+ render: (_args, value) => [{ type: 'text', text: value.displayName }],
155
+ },
156
+ async execute(args) {
157
+ const qs = new URLSearchParams({ lat: String(args.latitude), lon: String(args.longitude), format: 'jsonv2', addressdetails: '1' })
158
+ const body = await guardedFetch(cfg.nominatimUrl + '/reverse?' + qs.toString(), { init: { headers: nomHeaders } })
159
+ const raw = JSON.parse(body)
160
+ if (raw.error) throw new Error('Nominatim: ' + raw.error)
161
+ return normalizePlace(raw) as any
162
+ },
163
+ }))
164
+
165
+ ctx.tools.register(defineTool({
166
+ name: 'rh_places_elevation',
167
+ description: 'Elevation at a lat/lon from Open-Meteo (keyless), in metres.',
168
+ parameters: {
169
+ latitude: { type: 'number', required: true, description: 'Latitude.' },
170
+ longitude: { type: 'number', required: true, description: 'Longitude.' },
171
+ },
172
+ output: {
173
+ schema: {
174
+ type: 'object',
175
+ additionalProperties: false,
176
+ properties: {
177
+ latitude: { type: 'number', required: true },
178
+ longitude: { type: 'number', required: true },
179
+ elevation: { type: 'number', required: true },
180
+ },
181
+ },
182
+ render: (_args, value) => [{ type: 'text', text: value.elevation + ' m' }],
183
+ },
184
+ async execute(args) {
185
+ const qs = new URLSearchParams({ latitude: String(args.latitude), longitude: String(args.longitude) })
186
+ const body = await guardedFetch(cfg.elevationUrl + '?' + qs.toString())
187
+ const out = normalizeElevation(JSON.parse(body))
188
+ return { latitude: args.latitude, longitude: args.longitude, elevation: out.elevation }
189
+ },
190
+ }))
191
+
192
+ ctx.tools.register(defineTool({
193
+ name: 'rh_places_nearby',
194
+ description: 'Places matching a query within radiusKm of a point (Nominatim bounded search, keyless): results sorted nearest first with distanceKm attached.',
195
+ parameters: {
196
+ query: { type: 'string', required: true, description: 'What to look for, e.g. "cafe" or "pharmacy".' },
197
+ latitude: { type: 'number', required: true, description: 'Centre latitude.' },
198
+ longitude: { type: 'number', required: true, description: 'Centre longitude.' },
199
+ radiusKm: { type: 'number', description: 'Search radius, default 1 (min 0.1, max 20).' },
200
+ limit: { type: 'integer', description: 'Max results, default 5 (max 20).' },
201
+ },
202
+ output: {
203
+ schema: { type: 'array', items: { type: 'json' } },
204
+ render: (_args, value) => {
205
+ const arr = value as any[]
206
+ const text = arr.length === 0 ? '(no nearby places)' : arr.map((p: any) => (p.name !== '' ? p.name : p.displayName) + ' (' + p.distanceKm.toFixed(1) + ' km)').join('; ')
207
+ return [{ type: 'text', text }]
208
+ },
209
+ },
210
+ async execute(args) {
211
+ const radius = Math.min(Math.max(0.1, Number(args.radiusKm ?? 1)), 20)
212
+ const limit = Math.min(Math.max(1, Math.round(args.limit ?? 5)), 20)
213
+ const qs = new URLSearchParams({
214
+ q: args.query,
215
+ viewbox: nearbyViewbox(args.latitude, args.longitude, radius),
216
+ bounded: '1',
217
+ format: 'jsonv2',
218
+ addressdetails: '1',
219
+ limit: String(limit),
220
+ })
221
+ const body = await guardedFetch(cfg.nominatimUrl + '/search?' + qs.toString(), { init: { headers: nomHeaders } })
222
+ return normalizePlaces(JSON.parse(body))
223
+ .map(p => ({ ...p, distanceKm: distanceKm(args.latitude, args.longitude, p.lat, p.lon) }))
224
+ .sort((a, b) => a.distanceKm - b.distanceKm) as any[]
225
+ },
226
+ }))
227
+ }
@@ -28,6 +28,7 @@ export const righthandSettingsSchema = z.object({
28
28
  defaultScriptPrefix: z.string().default('rh-'),
29
29
  defaultZone: z.string().default(''),
30
30
  defaultR2Bucket: z.string().default(''),
31
+ defaultNotifyTopic: z.string().default(''),
31
32
  })
32
33
 
33
34
  /** Register the settings namespace + the secret/credential tools. */
@@ -120,7 +121,7 @@ export function apply(ctx: Context): void {
120
121
  },
121
122
  async execute() {
122
123
  const v = scope.get()
123
- return { accountId: v.accountId, defaultScriptPrefix: v.defaultScriptPrefix, defaultZone: v.defaultZone, defaultR2Bucket: v.defaultR2Bucket }
124
+ return { accountId: v.accountId, defaultScriptPrefix: v.defaultScriptPrefix, defaultZone: v.defaultZone, defaultR2Bucket: v.defaultR2Bucket, defaultNotifyTopic: v.defaultNotifyTopic }
124
125
  },
125
126
  }))
126
127
 
@@ -65,9 +65,15 @@ export function isBlockedIP(ip: string): boolean {
65
65
  * Fetch one URL with the SSRF checklist. `fetchImpl` and `resolve` are
66
66
  * injectable for tests; production uses the globals.
67
67
  */
68
+ export interface GuardedFetchInit {
69
+ method?: string
70
+ body?: string
71
+ headers?: Record<string, string>
72
+ }
73
+
68
74
  export async function guardedFetch(
69
75
  url: string,
70
- deps: { fetchImpl?: typeof fetch; resolve?: (host: string) => Promise<string[]> } = {},
76
+ deps: { fetchImpl?: typeof fetch; resolve?: (host: string) => Promise<string[]>; init?: GuardedFetchInit } = {},
71
77
  ): Promise<string> {
72
78
  const fetchImpl = deps.fetchImpl ?? fetch
73
79
  const resolve = deps.resolve ?? (async (host: string) => (await lookup(host, { all: true })).map(a => a.address))
@@ -78,7 +84,7 @@ export async function guardedFetch(
78
84
  if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') throw new Error('blocked: non-http(s) URL')
79
85
  const ips = await resolve(parsed.hostname)
80
86
  if (ips.length === 0 || ips.some(isBlockedIP)) throw new Error('blocked: destination is not a public host')
81
- const res = await fetchImpl(current, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), redirect: 'manual' })
87
+ const res = await fetchImpl(current, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), redirect: 'manual', method: deps.init?.method ?? 'GET', body: deps.init?.body, headers: deps.init?.headers })
82
88
  if (res.status >= 300 && res.status < 400) {
83
89
  const next = res.headers.get('location')
84
90
  if (next === null) throw new Error('blocked: redirect without location')