@try-works/dsh-righthand 0.1.6 → 0.1.8
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 +11 -4
- package/lib/events-tools.d.ts +44 -0
- package/lib/events-tools.d.ts.map +1 -0
- package/lib/events-tools.js +219 -0
- package/lib/events-tools.js.map +1 -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 +16 -3
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +17 -3
- package/lib/index.js.map +1 -1
- package/lib/notify-tools.d.ts +18 -0
- package/lib/notify-tools.d.ts.map +1 -0
- package/lib/notify-tools.js +52 -0
- package/lib/notify-tools.js.map +1 -0
- package/lib/secrets-tools.d.ts +4 -0
- package/lib/secrets-tools.d.ts.map +1 -1
- package/lib/secrets-tools.js +3 -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 +6 -0
- package/lib/weather-tools.d.ts.map +1 -1
- package/lib/weather-tools.js +1 -5
- package/lib/weather-tools.js.map +1 -1
- package/package.json +1 -1
- package/skills/dsh-righthand/SKILL.md +27 -3
- package/src/events-tools.ts +241 -0
- package/src/files-tools.ts +294 -0
- package/src/index.ts +19 -3
- package/src/notify-tools.ts +62 -0
- package/src/secrets-tools.ts +3 -1
- package/src/sigv4.ts +122 -0
- package/src/weather-tools.ts +8 -2
|
@@ -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
|
+
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* files-tools — rh_files_* over Cloudflare R2 (the user's own account).
|
|
3
|
+
* The first family on an actual Cloudflare primitive. S3-compatible API,
|
|
4
|
+
* signed with SigV4 (src/sigv4.ts, pinned to the AWS test vector).
|
|
5
|
+
*
|
|
6
|
+
* Credentials resolve at call time from the credential provider
|
|
7
|
+
* (R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY); accountId and bucket come
|
|
8
|
+
* from the righthand settings namespace. The client is a pure factory so
|
|
9
|
+
* tests drive it with a stubbed fetch.
|
|
10
|
+
* @module dsh-righthand/files-tools
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
14
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
15
|
+
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
16
|
+
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
17
|
+
import { signRequest, presignGet } from './sigv4.ts'
|
|
18
|
+
|
|
19
|
+
export const name = 'righthand-files'
|
|
20
|
+
export const inject = ['tools', 'credentials', 'settings']
|
|
21
|
+
|
|
22
|
+
export const R2_ACCESS_KEY_REF = 'R2_ACCESS_KEY_ID'
|
|
23
|
+
export const R2_SECRET_KEY_REF = 'R2_SECRET_ACCESS_KEY'
|
|
24
|
+
|
|
25
|
+
export interface FilesConfig {
|
|
26
|
+
/** Bucket name; defaults to the righthand settings defaultR2Bucket. */
|
|
27
|
+
bucket?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface R2Options {
|
|
31
|
+
accountId: string
|
|
32
|
+
accessKeyId: string
|
|
33
|
+
secretAccessKey: string
|
|
34
|
+
bucket: string
|
|
35
|
+
fetchImpl?: typeof fetch
|
|
36
|
+
now?: () => Date
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function amzDate(d: Date): string {
|
|
40
|
+
return d.toISOString().replace(/[:-]|\.\d{3}/g, '')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ListEntry {
|
|
44
|
+
key: string
|
|
45
|
+
size: number
|
|
46
|
+
lastModified: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Minimal ListObjectsV2 XML parse: Key / Size / LastModified triples. */
|
|
50
|
+
function parseListXml(xml: string): ListEntry[] {
|
|
51
|
+
const out: ListEntry[] = []
|
|
52
|
+
const contents = xml.split('<Contents>').slice(1)
|
|
53
|
+
for (const block of contents) {
|
|
54
|
+
const grab = (tag: string): string => {
|
|
55
|
+
const a = block.indexOf('<' + tag + '>')
|
|
56
|
+
const b = block.indexOf('</' + tag + '>')
|
|
57
|
+
return a < 0 || b < 0 ? '' : block.slice(a + tag.length + 2, b)
|
|
58
|
+
}
|
|
59
|
+
const key = grab('Key')
|
|
60
|
+
if (key === '') continue
|
|
61
|
+
out.push({ key, size: Number(grab('Size') ?? 0), lastModified: grab('LastModified') })
|
|
62
|
+
}
|
|
63
|
+
return out
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The R2 client factory. Pure over its options; every call signs fresh.
|
|
68
|
+
*/
|
|
69
|
+
export function createR2Client(opts: R2Options) {
|
|
70
|
+
const fetchImpl = opts.fetchImpl ?? fetch
|
|
71
|
+
const now = opts.now ?? (() => new Date())
|
|
72
|
+
const base = 'https://' + opts.accountId + '.r2.cloudflarestorage.com/' + opts.bucket
|
|
73
|
+
|
|
74
|
+
async function request(method: string, key: string, payload: string, extraHeaders: Record<string, string> = {}): Promise<Response> {
|
|
75
|
+
const url = base + '/' + key
|
|
76
|
+
const parsed = new URL(url)
|
|
77
|
+
const signed = signRequest({
|
|
78
|
+
method,
|
|
79
|
+
url,
|
|
80
|
+
headers: { host: parsed.host, ...extraHeaders },
|
|
81
|
+
payload,
|
|
82
|
+
accessKeyId: opts.accessKeyId,
|
|
83
|
+
secretAccessKey: opts.secretAccessKey,
|
|
84
|
+
region: 'auto',
|
|
85
|
+
amzDate: amzDate(now()),
|
|
86
|
+
})
|
|
87
|
+
return fetchImpl(url, { method, headers: signed.headers, body: payload === '' ? undefined : payload })
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
async put(key: string, content: string, contentType?: string): Promise<{ key: string; size: number }> {
|
|
92
|
+
const res = await request('PUT', key, content, contentType ? { 'content-type': contentType } : {})
|
|
93
|
+
if (!res.ok) throw new Error('R2 PUT failed: HTTP ' + res.status)
|
|
94
|
+
return { key, size: Buffer.byteLength(content) }
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
async get(key: string): Promise<{ found: boolean; content: string; contentType: string }> {
|
|
98
|
+
const res = await request('GET', key, '')
|
|
99
|
+
if (res.status === 404) return { found: false, content: '', contentType: '' }
|
|
100
|
+
if (!res.ok) throw new Error('R2 GET failed: HTTP ' + res.status)
|
|
101
|
+
return { found: true, content: await res.text(), contentType: res.headers.get('content-type') ?? '' }
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
async list(prefix?: string, maxKeys?: number): Promise<ListEntry[]> {
|
|
105
|
+
const q = '?list-type=2' + (prefix ? '&prefix=' + encodeURIComponent(prefix) : '') + (maxKeys ? '&max-keys=' + maxKeys : '')
|
|
106
|
+
const url = base + '/' + q
|
|
107
|
+
const parsed = new URL(url)
|
|
108
|
+
const signed = signRequest({
|
|
109
|
+
method: 'GET',
|
|
110
|
+
url,
|
|
111
|
+
headers: { host: parsed.host },
|
|
112
|
+
payload: '',
|
|
113
|
+
accessKeyId: opts.accessKeyId,
|
|
114
|
+
secretAccessKey: opts.secretAccessKey,
|
|
115
|
+
region: 'auto',
|
|
116
|
+
amzDate: amzDate(now()),
|
|
117
|
+
})
|
|
118
|
+
const res = await fetchImpl(url, { method: 'GET', headers: signed.headers })
|
|
119
|
+
if (!res.ok) throw new Error('R2 list failed: HTTP ' + res.status)
|
|
120
|
+
return parseListXml(await res.text())
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
share(key: string, expiresSeconds: number): string {
|
|
124
|
+
return presignGet({
|
|
125
|
+
url: base + '/' + key,
|
|
126
|
+
accessKeyId: opts.accessKeyId,
|
|
127
|
+
secretAccessKey: opts.secretAccessKey,
|
|
128
|
+
region: 'auto',
|
|
129
|
+
amzDate: amzDate(now()),
|
|
130
|
+
expiresSeconds,
|
|
131
|
+
})
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
async delete(key: string): Promise<{ key: string; existed: boolean }> {
|
|
135
|
+
const res = await request('DELETE', key, '')
|
|
136
|
+
if (res.status === 404) return { key, existed: false }
|
|
137
|
+
if (!res.ok) throw new Error('R2 DELETE failed: HTTP ' + res.status)
|
|
138
|
+
return { key, existed: true }
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
export function apply(ctx: Context, config: FilesConfig = {}): void {
|
|
145
|
+
const ns = settingsNamespace('righthand')
|
|
146
|
+
|
|
147
|
+
/** Resolve accountId + credentials + bucket at call time. */
|
|
148
|
+
async function client(): Promise<ReturnType<typeof createR2Client>> {
|
|
149
|
+
const settings = ctx.settings.get(ns) as { accountId?: string; defaultR2Bucket?: string } | undefined
|
|
150
|
+
const accountId = settings?.accountId ?? ''
|
|
151
|
+
if (accountId === '') throw new Error('righthand accountId is not set (rh_settings_set { accountId })')
|
|
152
|
+
const bucket = config.bucket ?? settings?.defaultR2Bucket ?? ''
|
|
153
|
+
if (bucket === '') throw new Error('R2 bucket is not set (plugin config bucket or settings defaultR2Bucket)')
|
|
154
|
+
const access = await ctx.credentials.resolve(credentialRef(R2_ACCESS_KEY_REF))
|
|
155
|
+
const secret = await ctx.credentials.resolve(credentialRef(R2_SECRET_KEY_REF))
|
|
156
|
+
if (access === undefined || secret === undefined) {
|
|
157
|
+
throw new Error('R2 credentials are not set (rh_credential_set R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY)')
|
|
158
|
+
}
|
|
159
|
+
return createR2Client({ accountId, accessKeyId: access.value, secretAccessKey: secret.value, bucket })
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
ctx.tools.register(defineTool({
|
|
163
|
+
name: 'rh_files_put',
|
|
164
|
+
description: 'Store a file in the R2 bucket and return its key. Content is text (reports, CSV, transcripts, code); content type defaults to text/plain.',
|
|
165
|
+
parameters: {
|
|
166
|
+
key: { type: 'string', required: true, description: 'Object key, e.g. reports/2026-08-30.csv.' },
|
|
167
|
+
content: { type: 'string', required: true, description: 'The full text content to store.' },
|
|
168
|
+
contentType: { type: 'string', description: 'Content type, e.g. text/csv. Defaults to text/plain.' },
|
|
169
|
+
},
|
|
170
|
+
output: {
|
|
171
|
+
schema: {
|
|
172
|
+
type: 'object',
|
|
173
|
+
additionalProperties: false,
|
|
174
|
+
properties: {
|
|
175
|
+
key: { type: 'string', required: true },
|
|
176
|
+
size: { type: 'integer', required: true },
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
render: (_args, value) => [{ type: 'text', text: 'stored ' + value.key + ' (' + value.size + ' bytes)' }],
|
|
180
|
+
},
|
|
181
|
+
async execute(args) {
|
|
182
|
+
const c = await client()
|
|
183
|
+
return c.put(args.key, args.content, args.contentType)
|
|
184
|
+
},
|
|
185
|
+
}))
|
|
186
|
+
|
|
187
|
+
ctx.tools.register(defineTool({
|
|
188
|
+
name: 'rh_files_get',
|
|
189
|
+
description: 'Read a stored file back by its key. found: false means the key is absent, not an error.',
|
|
190
|
+
parameters: {
|
|
191
|
+
key: { type: 'string', required: true, description: 'The object key to read.' },
|
|
192
|
+
},
|
|
193
|
+
output: {
|
|
194
|
+
schema: {
|
|
195
|
+
type: 'object',
|
|
196
|
+
additionalProperties: false,
|
|
197
|
+
properties: {
|
|
198
|
+
found: { type: 'boolean', required: true },
|
|
199
|
+
key: { type: 'string', required: true },
|
|
200
|
+
content: { type: 'string' },
|
|
201
|
+
contentType: { type: 'string' },
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
render: (_args, value) => [{ type: 'text', text: value.found && typeof value.content === 'string' ? value.content : '(absent)' }],
|
|
205
|
+
},
|
|
206
|
+
async execute(args) {
|
|
207
|
+
const c = await client()
|
|
208
|
+
const out = await c.get(args.key)
|
|
209
|
+
return {
|
|
210
|
+
key: args.key,
|
|
211
|
+
found: out.found,
|
|
212
|
+
...out.found ? { content: out.content, contentType: out.contentType } : {},
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
}))
|
|
216
|
+
|
|
217
|
+
ctx.tools.register(defineTool({
|
|
218
|
+
name: 'rh_files_list',
|
|
219
|
+
description: 'List objects in the bucket, optionally by key prefix. Newest first is not guaranteed by S3; entries carry lastModified.',
|
|
220
|
+
parameters: {
|
|
221
|
+
prefix: { type: 'string', description: 'Only keys starting with this, e.g. reports/.' },
|
|
222
|
+
maxKeys: { type: 'integer', description: 'Max entries (default 100).' },
|
|
223
|
+
},
|
|
224
|
+
output: {
|
|
225
|
+
schema: {
|
|
226
|
+
type: 'object',
|
|
227
|
+
additionalProperties: false,
|
|
228
|
+
properties: {
|
|
229
|
+
entries: { type: 'array', required: true, items: { type: 'json' } },
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
render: (_args, value) => [{ type: 'text', text: value.entries.length + ' objects' }],
|
|
233
|
+
},
|
|
234
|
+
async execute(args) {
|
|
235
|
+
const c = await client()
|
|
236
|
+
const entries = await c.list(args.prefix, args.maxKeys)
|
|
237
|
+
return { entries: entries as any[] }
|
|
238
|
+
},
|
|
239
|
+
}))
|
|
240
|
+
|
|
241
|
+
ctx.tools.register(defineTool({
|
|
242
|
+
name: 'rh_files_share',
|
|
243
|
+
description: 'Get a presigned download URL for a key, valid for the given minutes (default 60, max 10080). Anyone with the URL can read the object for that window.',
|
|
244
|
+
parameters: {
|
|
245
|
+
key: { type: 'string', required: true, description: 'The object key to share.' },
|
|
246
|
+
minutes: { type: 'integer', description: 'Validity in minutes (default 60, max 10080 = 7 days).' },
|
|
247
|
+
},
|
|
248
|
+
output: {
|
|
249
|
+
schema: {
|
|
250
|
+
type: 'object',
|
|
251
|
+
additionalProperties: false,
|
|
252
|
+
properties: {
|
|
253
|
+
key: { type: 'string', required: true },
|
|
254
|
+
url: { type: 'string', required: true },
|
|
255
|
+
expiresAt: { type: 'string', required: true },
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
render: (_args, value) => [{ type: 'text', text: value.url }],
|
|
259
|
+
},
|
|
260
|
+
async execute(args) {
|
|
261
|
+
const minutes = Math.min(Math.max(args.minutes ?? 60, 1), 10080)
|
|
262
|
+
const c = await client()
|
|
263
|
+
const url = c.share(args.key, minutes * 60)
|
|
264
|
+
return { key: args.key, url, expiresAt: new Date(Date.now() + minutes * 60000).toISOString() }
|
|
265
|
+
},
|
|
266
|
+
}))
|
|
267
|
+
|
|
268
|
+
ctx.tools.register(defineTool({
|
|
269
|
+
name: 'rh_files_delete',
|
|
270
|
+
description: 'Delete an object by key. Returns whether it existed.',
|
|
271
|
+
parameters: {
|
|
272
|
+
key: { type: 'string', required: true, description: 'The object key to delete.' },
|
|
273
|
+
},
|
|
274
|
+
output: {
|
|
275
|
+
schema: {
|
|
276
|
+
type: 'object',
|
|
277
|
+
additionalProperties: false,
|
|
278
|
+
properties: {
|
|
279
|
+
key: { type: 'string', required: true },
|
|
280
|
+
existed: { type: 'boolean', required: true },
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
render: (_args, value) => [{ type: 'text', text: 'deleted ' + value.key + ': ' + value.existed }],
|
|
284
|
+
},
|
|
285
|
+
async execute(args) {
|
|
286
|
+
const c = await client()
|
|
287
|
+
return c.delete(args.key)
|
|
288
|
+
},
|
|
289
|
+
}))
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
|
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
|
|
12
|
-
*
|
|
11
|
+
* profile without `storageDomain` (e.g. headless) simply gets the
|
|
12
|
+
* store/task/events families dormant and the other nineteen tools
|
|
13
|
+
* (secrets, settings, exec, text, weather, files, notify) instead of
|
|
14
|
+
* 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,6 +19,9 @@
|
|
|
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
|
+
* - files-tools: rh_files_put/get/list/share/delete over Cloudflare R2
|
|
23
|
+
* - events-tools: rh_events_create/due/list/free/cancel over ctx.storageDomain
|
|
24
|
+
* - notify-tools: rh_notify_send over ntfy.sh (keyless)
|
|
20
25
|
* - exec-tools: rh_run / rh_run_bg over ctx.subprocess + ctx.jobs
|
|
21
26
|
* - guard-tools: tools/pre-execute policy (config.rules)
|
|
22
27
|
* - skills: the packaged dsh-righthand skill (ctx.skills)
|
|
@@ -32,6 +37,9 @@ import * as guardTools from './guard-tools.ts'
|
|
|
32
37
|
import * as taskTools from './task-tools.ts'
|
|
33
38
|
import * as textTools from './text-tools.ts'
|
|
34
39
|
import * as weatherTools from './weather-tools.ts'
|
|
40
|
+
import * as filesTools from './files-tools.ts'
|
|
41
|
+
import * as eventsTools from './events-tools.ts'
|
|
42
|
+
import * as notifyTools from './notify-tools.ts'
|
|
35
43
|
import * as righthandSkills from './skills.ts'
|
|
36
44
|
import type { GuardRule } from './guard-tools.ts'
|
|
37
45
|
|
|
@@ -59,16 +67,24 @@ export function apply(ctx: Context, config: RighthandConfig = {}): void {
|
|
|
59
67
|
ctx.plugin(taskTools)
|
|
60
68
|
ctx.plugin(textTools)
|
|
61
69
|
ctx.plugin(weatherTools)
|
|
70
|
+
ctx.plugin(filesTools)
|
|
71
|
+
ctx.plugin(eventsTools)
|
|
72
|
+
ctx.plugin(notifyTools)
|
|
62
73
|
ctx.plugin(guardTools, { rules: config.rules ?? [] })
|
|
63
74
|
ctx.plugin(righthandSkills)
|
|
64
75
|
}
|
|
65
76
|
|
|
66
77
|
// Individual modules stay importable for selective mounting.
|
|
67
|
-
export { storeTools, secretsTools, execTools, taskTools, textTools, weatherTools, guardTools, righthandSkills }
|
|
78
|
+
export { storeTools, secretsTools, execTools, taskTools, textTools, weatherTools, filesTools, eventsTools, notifyTools, guardTools, righthandSkills }
|
|
68
79
|
export { SKILL_NAME, SKILL_DESCRIPTION, skillDirectory, skillBody } from './skills.ts'
|
|
69
80
|
export type { GuardRule } from './guard-tools.ts'
|
|
70
81
|
export { guardFactsFor } from './guard-tools.ts'
|
|
71
82
|
export type { GuardFacts } from './guard-tools.ts'
|
|
72
83
|
export { isBlockedIP, guardedFetch, normalizeForecast, normalizeAir } from './weather-tools.ts'
|
|
84
|
+
export { createR2Client, R2_ACCESS_KEY_REF, R2_SECRET_KEY_REF } from './files-tools.ts'
|
|
85
|
+
export { signRequest, presignGet } from './sigv4.ts'
|
|
86
|
+
export { freeSlots } from './events-tools.ts'
|
|
87
|
+
export type { Event } from './events-tools.ts'
|
|
88
|
+
export type { R2Options, ListEntry } from './files-tools.ts'
|
|
73
89
|
export type { WeatherConfig, ForecastOut, AirOut } from './weather-tools.ts'
|
|
74
90
|
|
|
@@ -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
|
+
|
package/src/secrets-tools.ts
CHANGED
|
@@ -27,6 +27,8 @@ export const righthandSettingsSchema = z.object({
|
|
|
27
27
|
accountId: z.string().default(''),
|
|
28
28
|
defaultScriptPrefix: z.string().default('rh-'),
|
|
29
29
|
defaultZone: z.string().default(''),
|
|
30
|
+
defaultR2Bucket: z.string().default(''),
|
|
31
|
+
defaultNotifyTopic: z.string().default(''),
|
|
30
32
|
})
|
|
31
33
|
|
|
32
34
|
/** Register the settings namespace + the secret/credential tools. */
|
|
@@ -119,7 +121,7 @@ export function apply(ctx: Context): void {
|
|
|
119
121
|
},
|
|
120
122
|
async execute() {
|
|
121
123
|
const v = scope.get()
|
|
122
|
-
return { accountId: v.accountId, defaultScriptPrefix: v.defaultScriptPrefix, defaultZone: v.defaultZone }
|
|
124
|
+
return { accountId: v.accountId, defaultScriptPrefix: v.defaultScriptPrefix, defaultZone: v.defaultZone, defaultR2Bucket: v.defaultR2Bucket, defaultNotifyTopic: v.defaultNotifyTopic }
|
|
123
125
|
},
|
|
124
126
|
}))
|
|
125
127
|
|