@try-works/dsh-righthand 0.1.5 → 0.1.7
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.
- package/README.md +5 -0
- package/lib/files-tools.d.ts +55 -0
- package/lib/files-tools.d.ts.map +1 -0
- package/lib/files-tools.js +259 -0
- package/lib/files-tools.js.map +1 -0
- package/lib/index.d.ts +10 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +10 -1
- package/lib/index.js.map +1 -1
- package/lib/secrets-tools.d.ts +2 -0
- package/lib/secrets-tools.d.ts.map +1 -1
- package/lib/secrets-tools.js +2 -1
- package/lib/secrets-tools.js.map +1 -1
- package/lib/sigv4.d.ts +37 -0
- package/lib/sigv4.d.ts.map +1 -0
- package/lib/sigv4.js +94 -0
- package/lib/sigv4.js.map +1 -0
- package/lib/weather-tools.d.ts +68 -0
- package/lib/weather-tools.d.ts.map +1 -0
- package/lib/weather-tools.js +208 -0
- package/lib/weather-tools.js.map +1 -0
- package/package.json +1 -1
- package/skills/dsh-righthand/SKILL.md +17 -1
- package/src/files-tools.ts +294 -0
- package/src/index.ts +12 -1
- package/src/secrets-tools.ts +2 -1
- package/src/sigv4.ts +122 -0
- package/src/weather-tools.ts +236 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* weather-tools — the worked example of the keyless data-adapter recipe
|
|
3
|
+
* (blueprint/data-adapter). Two tools over Open-Meteo, a keyless API:
|
|
4
|
+
* rh_weather_forecast — current conditions + 3-day min/max
|
|
5
|
+
* rh_weather_air — PM2.5 / PM10 / European AQI
|
|
6
|
+
*
|
|
7
|
+
* Every request goes through guardedFetch, the SSRF checklist from Mu's
|
|
8
|
+
* safefetch: public destinations only (loopback, private, link-local incl.
|
|
9
|
+
* the metadata address, multicast and unspecified are refused), every
|
|
10
|
+
* redirect hop revalidated, response size and time capped.
|
|
11
|
+
* @module dsh-righthand/weather-tools
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
15
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
16
|
+
import { lookup } from 'node:dns/promises'
|
|
17
|
+
import { isIP } from 'node:net'
|
|
18
|
+
|
|
19
|
+
export const name = 'righthand-weather'
|
|
20
|
+
export const inject = ['tools']
|
|
21
|
+
|
|
22
|
+
export interface WeatherConfig {
|
|
23
|
+
/** Endpoint URLs; defaults are Open-Meteo (keyless). Tests override these. */
|
|
24
|
+
forecastUrl?: string
|
|
25
|
+
airUrl?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const DEFAULTS: Required<WeatherConfig> = {
|
|
29
|
+
forecastUrl: 'https://api.open-meteo.com/v1/forecast',
|
|
30
|
+
airUrl: 'https://air-quality-api.open-meteo.com/v1/air-quality',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const MAX_BODY_BYTES = 2 * 1024 * 1024
|
|
34
|
+
const MAX_REDIRECTS = 5
|
|
35
|
+
const FETCH_TIMEOUT_MS = 10000
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether an IP must not be reached: the Mu safefetch set — loopback,
|
|
39
|
+
* RFC1918 private, ULA, link-local (169.254/16, fe80::/10 — which includes
|
|
40
|
+
* the 169.254.169.254 metadata address), multicast, and unspecified.
|
|
41
|
+
*/
|
|
42
|
+
export function isBlockedIP(ip: string): boolean {
|
|
43
|
+
if (ip === '') return true
|
|
44
|
+
if (isIP(ip) === 4) {
|
|
45
|
+
const [a, b] = ip.split('.').map(Number)
|
|
46
|
+
if (a === 0 || a === 10 || a === 127) return true
|
|
47
|
+
if (a === 169 && b === 254) return true
|
|
48
|
+
if (a === 172 && b >= 16 && b <= 31) return true
|
|
49
|
+
if (a === 192 && b === 168) return true
|
|
50
|
+
if (a >= 224 && a <= 239) return true
|
|
51
|
+
return false
|
|
52
|
+
}
|
|
53
|
+
if (isIP(ip) === 6) {
|
|
54
|
+
const lower = ip.toLowerCase()
|
|
55
|
+
if (lower === '::' || lower === '::1') return true
|
|
56
|
+
if (lower.startsWith('fc') || lower.startsWith('fd')) return true
|
|
57
|
+
if (lower.startsWith('fe8') || lower.startsWith('fe9') || lower.startsWith('fea') || lower.startsWith('feb')) return true
|
|
58
|
+
if (lower.startsWith('ff')) return true
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
return true
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Fetch one URL with the SSRF checklist. `fetchImpl` and `resolve` are
|
|
66
|
+
* injectable for tests; production uses the globals.
|
|
67
|
+
*/
|
|
68
|
+
export async function guardedFetch(
|
|
69
|
+
url: string,
|
|
70
|
+
deps: { fetchImpl?: typeof fetch; resolve?: (host: string) => Promise<string[]> } = {},
|
|
71
|
+
): Promise<string> {
|
|
72
|
+
const fetchImpl = deps.fetchImpl ?? fetch
|
|
73
|
+
const resolve = deps.resolve ?? (async (host: string) => (await lookup(host, { all: true })).map(a => a.address))
|
|
74
|
+
|
|
75
|
+
let current = url
|
|
76
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
77
|
+
const parsed = new URL(current)
|
|
78
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') throw new Error('blocked: non-http(s) URL')
|
|
79
|
+
const ips = await resolve(parsed.hostname)
|
|
80
|
+
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' })
|
|
82
|
+
if (res.status >= 300 && res.status < 400) {
|
|
83
|
+
const next = res.headers.get('location')
|
|
84
|
+
if (next === null) throw new Error('blocked: redirect without location')
|
|
85
|
+
current = new URL(next, current).toString()
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
if (!res.ok) throw new Error('HTTP ' + res.status + ' for ' + current)
|
|
89
|
+
const reader = res.body?.getReader()
|
|
90
|
+
if (reader === undefined) return ''
|
|
91
|
+
const chunks: Uint8Array[] = []
|
|
92
|
+
let bytes = 0
|
|
93
|
+
for (;;) {
|
|
94
|
+
const { done, value } = await reader.read()
|
|
95
|
+
if (done) break
|
|
96
|
+
bytes += value.length
|
|
97
|
+
if (bytes > MAX_BODY_BYTES) throw new Error('response exceeds ' + MAX_BODY_BYTES + ' bytes')
|
|
98
|
+
chunks.push(value)
|
|
99
|
+
}
|
|
100
|
+
return Buffer.concat(chunks).toString('utf8')
|
|
101
|
+
}
|
|
102
|
+
throw new Error('too many redirects')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Normalize the Open-Meteo forecast response into a stable digest shape:
|
|
107
|
+
* what changed is the wire JSON; what callers see is this.
|
|
108
|
+
*/
|
|
109
|
+
export interface ForecastOut {
|
|
110
|
+
latitude: number
|
|
111
|
+
longitude: number
|
|
112
|
+
timezone: string
|
|
113
|
+
current: { time: string; temperature: number; windspeed: number; winddirection: number; weathercode: number } | null
|
|
114
|
+
daily: { time: string[]; temperatureMax: number[]; temperatureMin: number[] }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function normalizeForecast(json: any): ForecastOut {
|
|
118
|
+
const cw = json.current_weather
|
|
119
|
+
return {
|
|
120
|
+
latitude: Number(json.latitude ?? 0),
|
|
121
|
+
longitude: Number(json.longitude ?? 0),
|
|
122
|
+
timezone: json.timezone ?? '',
|
|
123
|
+
current: cw ? {
|
|
124
|
+
time: String(cw.time ?? ''),
|
|
125
|
+
temperature: Number(cw.temperature ?? 0),
|
|
126
|
+
windspeed: Number(cw.windspeed ?? 0),
|
|
127
|
+
winddirection: Number(cw.winddirection ?? 0),
|
|
128
|
+
weathercode: Number(cw.weathercode ?? 0),
|
|
129
|
+
} : null,
|
|
130
|
+
daily: {
|
|
131
|
+
time: (json.daily?.time ?? []).map(String),
|
|
132
|
+
temperatureMax: (json.daily?.temperature_2m_max ?? []).map(Number),
|
|
133
|
+
temperatureMin: (json.daily?.temperature_2m_min ?? []).map(Number),
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Normalize the Open-Meteo air-quality response. */
|
|
139
|
+
export interface AirOut {
|
|
140
|
+
latitude: number
|
|
141
|
+
longitude: number
|
|
142
|
+
time: string
|
|
143
|
+
pm2_5: number | null
|
|
144
|
+
pm10: number | null
|
|
145
|
+
aqi: number | null
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function normalizeAir(json: any): AirOut {
|
|
149
|
+
const c = json.current ?? {}
|
|
150
|
+
const num = (v: unknown): number | null => (v === undefined || v === null ? null : Number(v))
|
|
151
|
+
return {
|
|
152
|
+
latitude: Number(json.latitude ?? 0),
|
|
153
|
+
longitude: Number(json.longitude ?? 0),
|
|
154
|
+
time: String(c.time ?? ''),
|
|
155
|
+
pm2_5: num(c.pm2_5),
|
|
156
|
+
pm10: num(c.pm10),
|
|
157
|
+
aqi: num(c.european_aqi),
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function apply(ctx: Context, config: WeatherConfig = {}): void {
|
|
162
|
+
const cfg: Required<WeatherConfig> = { ...DEFAULTS, ...config }
|
|
163
|
+
|
|
164
|
+
ctx.tools.register(defineTool({
|
|
165
|
+
name: 'rh_weather_forecast',
|
|
166
|
+
description: 'Weather forecast for a location from Open-Meteo (keyless): current conditions plus 3 days of min/max temperatures.',
|
|
167
|
+
parameters: {
|
|
168
|
+
latitude: { type: 'number', required: true, description: 'Latitude, e.g. 51.5074 for London.' },
|
|
169
|
+
longitude: { type: 'number', required: true, description: 'Longitude, e.g. -0.1278 for London.' },
|
|
170
|
+
},
|
|
171
|
+
output: {
|
|
172
|
+
schema: {
|
|
173
|
+
type: 'object',
|
|
174
|
+
additionalProperties: false,
|
|
175
|
+
properties: {
|
|
176
|
+
latitude: { type: 'number', required: true },
|
|
177
|
+
longitude: { type: 'number', required: true },
|
|
178
|
+
timezone: { type: 'string', required: true },
|
|
179
|
+
current: { type: 'json' },
|
|
180
|
+
daily: { type: 'json', required: true },
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
render: (_args, value) => { const c = value.current as any; return [{ type: 'text', text: c ? (value.timezone + ': ' + c.temperature + ' C, wind ' + c.windspeed + ' km/h') : value.timezone + ': current conditions unavailable' }] },
|
|
184
|
+
},
|
|
185
|
+
async execute(args) {
|
|
186
|
+
const url = cfg.forecastUrl
|
|
187
|
+
+ '?latitude=' + args.latitude
|
|
188
|
+
+ '&longitude=' + args.longitude
|
|
189
|
+
+ '¤t_weather=true&daily=temperature_2m_max,temperature_2m_min&forecast_days=3'
|
|
190
|
+
const body = await guardedFetch(url)
|
|
191
|
+
return normalizeForecast(JSON.parse(body))
|
|
192
|
+
},
|
|
193
|
+
}))
|
|
194
|
+
|
|
195
|
+
ctx.tools.register(defineTool({
|
|
196
|
+
name: 'rh_weather_air',
|
|
197
|
+
description: 'Air quality at a location from Open-Meteo (keyless): PM2.5, PM10 and the European AQI.',
|
|
198
|
+
parameters: {
|
|
199
|
+
latitude: { type: 'number', required: true, description: 'Latitude.' },
|
|
200
|
+
longitude: { type: 'number', required: true, description: 'Longitude.' },
|
|
201
|
+
},
|
|
202
|
+
output: {
|
|
203
|
+
schema: {
|
|
204
|
+
type: 'object',
|
|
205
|
+
additionalProperties: false,
|
|
206
|
+
properties: {
|
|
207
|
+
latitude: { type: 'number', required: true },
|
|
208
|
+
longitude: { type: 'number', required: true },
|
|
209
|
+
time: { type: 'string', required: true },
|
|
210
|
+
pm2_5: { type: 'number' },
|
|
211
|
+
pm10: { type: 'number' },
|
|
212
|
+
aqi: { type: 'number' },
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
render: (_args, value) => [{ type: 'text', text: 'PM2.5 ' + value.pm2_5 + ', PM10 ' + value.pm10 + ', AQI ' + value.aqi }],
|
|
216
|
+
},
|
|
217
|
+
async execute(args) {
|
|
218
|
+
const url = cfg.airUrl
|
|
219
|
+
+ '?latitude=' + args.latitude
|
|
220
|
+
+ '&longitude=' + args.longitude
|
|
221
|
+
+ '¤t=pm2_5,pm10,european_aqi'
|
|
222
|
+
const body = await guardedFetch(url)
|
|
223
|
+
const out = normalizeAir(JSON.parse(body))
|
|
224
|
+
return {
|
|
225
|
+
latitude: out.latitude,
|
|
226
|
+
longitude: out.longitude,
|
|
227
|
+
time: out.time,
|
|
228
|
+
...out.pm2_5 !== null ? { pm2_5: out.pm2_5 } : {},
|
|
229
|
+
...out.pm10 !== null ? { pm10: out.pm10 } : {},
|
|
230
|
+
...out.aqi !== null ? { aqi: out.aqi } : {},
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
}))
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|