@try-works/dsh-righthand 0.1.18 → 0.1.20

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.
Files changed (63) hide show
  1. package/lib/cards.d.ts +16 -0
  2. package/lib/cards.d.ts.map +1 -0
  3. package/lib/cards.js +18 -0
  4. package/lib/cards.js.map +1 -0
  5. package/lib/client/index.d.ts +14 -0
  6. package/lib/client/index.d.ts.map +1 -0
  7. package/lib/client/index.js +118 -0
  8. package/lib/client/index.js.map +1 -0
  9. package/lib/client.js +373 -0
  10. package/lib/client.js.map +1 -0
  11. package/lib/events-tools.d.ts.map +1 -1
  12. package/lib/events-tools.js +11 -0
  13. package/lib/events-tools.js.map +1 -1
  14. package/lib/exec-tools.d.ts.map +1 -1
  15. package/lib/exec-tools.js +9 -0
  16. package/lib/exec-tools.js.map +1 -1
  17. package/lib/files-tools.d.ts.map +1 -1
  18. package/lib/files-tools.js +11 -0
  19. package/lib/files-tools.js.map +1 -1
  20. package/lib/gui.d.ts +12 -0
  21. package/lib/gui.d.ts.map +1 -0
  22. package/lib/gui.js +160 -0
  23. package/lib/gui.js.map +1 -0
  24. package/lib/index.d.ts +3 -1
  25. package/lib/index.d.ts.map +1 -1
  26. package/lib/index.js +4 -1
  27. package/lib/index.js.map +1 -1
  28. package/lib/notify-tools.d.ts.map +1 -1
  29. package/lib/notify-tools.js +3 -0
  30. package/lib/notify-tools.js.map +1 -1
  31. package/lib/places-tools.d.ts.map +1 -1
  32. package/lib/places-tools.js +9 -0
  33. package/lib/places-tools.js.map +1 -1
  34. package/lib/secrets-tools.d.ts.map +1 -1
  35. package/lib/secrets-tools.js +11 -0
  36. package/lib/secrets-tools.js.map +1 -1
  37. package/lib/store-tools.d.ts.map +1 -1
  38. package/lib/store-tools.js +8 -0
  39. package/lib/store-tools.js.map +1 -1
  40. package/lib/task-tools.d.ts.map +1 -1
  41. package/lib/task-tools.js +11 -0
  42. package/lib/task-tools.js.map +1 -1
  43. package/lib/text-tools.d.ts.map +1 -1
  44. package/lib/text-tools.js +9 -0
  45. package/lib/text-tools.js.map +1 -1
  46. package/lib/weather-tools.d.ts.map +1 -1
  47. package/lib/weather-tools.js +5 -0
  48. package/lib/weather-tools.js.map +1 -1
  49. package/package.json +24 -3
  50. package/src/cards.ts +23 -0
  51. package/src/client/index.ts +184 -0
  52. package/src/events-tools.ts +11 -0
  53. package/src/exec-tools.ts +10 -0
  54. package/src/files-tools.ts +11 -0
  55. package/src/gui.ts +157 -0
  56. package/src/index.ts +4 -1
  57. package/src/notify-tools.ts +3 -0
  58. package/src/places-tools.ts +9 -0
  59. package/src/secrets-tools.ts +11 -0
  60. package/src/store-tools.ts +9 -0
  61. package/src/task-tools.ts +11 -0
  62. package/src/text-tools.ts +9 -0
  63. package/src/weather-tools.ts +5 -0
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Righthand panel client half: a collapsible right-side panel inside the
3
+ * DSH Web GUI showing the plugin's live state - settings, store keys,
4
+ * tasks and events - and mutating it through the /righthand/* routes the
5
+ * host half (src/gui.ts) registers. Same-plugin GUI: no separate package.
6
+ */
7
+
8
+ import {
9
+ createElement as h,
10
+ useEffect,
11
+ useState,
12
+ type CSSProperties,
13
+ } from 'react'
14
+ import { createPortal } from 'react-dom'
15
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
16
+ import type {} from '@deepseek-ai/dsh-client-ui-slots'
17
+ import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
18
+
19
+ /** Required services: the slot registry (client runtime). */
20
+ export const inject = ['slots']
21
+
22
+ const C = {
23
+ bg: '#15171a',
24
+ panel: '#1b1e23',
25
+ row: '#23272e',
26
+ border: '#2d323a',
27
+ text: '#e6e8ec',
28
+ dim: '#9aa3ad',
29
+ accent: '#3b82f6',
30
+ green: '#3fbf6e',
31
+ red: '#e5484d',
32
+ amber: '#d9a13b',
33
+ }
34
+
35
+ interface State {
36
+ settings: Record<string, unknown>
37
+ storeKeys: string[]
38
+ tasks: any[]
39
+ events: any[]
40
+ }
41
+
42
+ const EMPTY: State = { settings: {}, storeKeys: [], tasks: [], events: [] }
43
+
44
+ function fmt(s: unknown, n: number): string {
45
+ try { const t = JSON.stringify(s); return t === undefined ? '' : (t.length > n ? t.slice(0, n) + '...' : t) }
46
+ catch { return String(s) }
47
+ }
48
+
49
+ function Row(props: { label: string; value: string; tone?: string }): ReturnType<typeof h> {
50
+ return h('div', { style: { display: 'flex', gap: 8, justifyContent: 'space-between', padding: '4px 0', borderBottom: '1px solid ' + C.border, fontSize: 12 } },
51
+ h('span', { style: { color: C.dim, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, props.label),
52
+ h('span', { style: { color: props.tone ?? C.text, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '55%' } }, props.value),
53
+ )
54
+ }
55
+
56
+ function Btn(props: { label: string; onClick: () => void; tone?: string; danger?: boolean }): ReturnType<typeof h> {
57
+ const base: CSSProperties = {
58
+ background: C.row, color: props.danger ? C.red : (props.tone ?? C.text),
59
+ border: '1px solid ' + C.border, borderRadius: 6, padding: '3px 10px',
60
+ fontSize: 11, cursor: 'pointer', whiteSpace: 'nowrap'
61
+ }
62
+ return h('button', { style: base, onClick: props.onClick }, props.label)
63
+ }
64
+
65
+ function Section(props: { title: string; children?: any }): ReturnType<typeof h> {
66
+ return h('div', { style: { marginTop: 14 } },
67
+ h('div', { style: { fontSize: 11, letterSpacing: '0.12em', textTransform: 'uppercase', color: C.dim, marginBottom: 6 } }, props.title),
68
+ props.children,
69
+ )
70
+ }
71
+
72
+ function RighthandPanel(): ReturnType<typeof h> {
73
+ const [collapsed, setCollapsed] = useState(false)
74
+ const [state, setState] = useState<State>(EMPTY)
75
+ const [error, setError] = useState('')
76
+ const [patch, setPatch] = useState('{}')
77
+ const [newKey, setNewKey] = useState('')
78
+ const [newValue, setNewValue] = useState('')
79
+
80
+ const load = (): void => {
81
+ fetch('/righthand/state').then(r => r.json()).then((d: State) => { setState(d); setError('') })
82
+ .catch((e: Error) => setError(String(e)))
83
+ }
84
+ useEffect(() => { load() }, [])
85
+
86
+ const post = (path: string, body: unknown, then: () => void): void => {
87
+ fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
88
+ .then(r => r.json()).then((d: any) => { if (!d.ok && d.error) setError(String(d.error)); then() })
89
+ .catch((e: Error) => setError(String(e)))
90
+ }
91
+
92
+ const railStyle: CSSProperties = {
93
+ position: 'fixed', right: 0, top: 0, bottom: 0, width: 44, background: C.panel,
94
+ borderLeft: '1px solid ' + C.border, display: 'flex', flexDirection: 'column',
95
+ alignItems: 'center', paddingTop: 12, gap: 10, zIndex: 9998
96
+ }
97
+ const panelStyle: CSSProperties = {
98
+ position: 'fixed', right: 0, top: 0, bottom: 0, width: 340, background: C.bg,
99
+ borderLeft: '1px solid ' + C.border, padding: 12, overflowY: 'auto', zIndex: 9998,
100
+ fontFamily: 'ui-sans-serif, system-ui, sans-serif', color: C.text, fontSize: 13
101
+ }
102
+
103
+ const toggle = h('button', {
104
+ style: { background: 'none', border: 'none', color: C.dim, fontSize: 18, cursor: 'pointer' },
105
+ onClick: () => setCollapsed(!collapsed),
106
+ }, collapsed ? '<' : '>')
107
+
108
+ const refreshBtn = h(Btn, { label: 'Refresh', onClick: load })
109
+
110
+ if (collapsed) {
111
+ return createPortal(h('div', { style: railStyle, 'data-dsh-righthand-panel': 'collapsed' },
112
+ toggle,
113
+ h('span', { style: { color: C.green, fontSize: 10, width: 8, height: 8, borderRadius: '50%', background: C.green } }),
114
+ h('span', { style: { color: C.dim, fontSize: 11, writingMode: 'vertical-rl', letterSpacing: '0.12em', userSelect: 'none' } }, 'Righthand'),
115
+ ), document.body)
116
+ }
117
+
118
+ return createPortal(h('div', { style: panelStyle, 'data-dsh-righthand-panel': 'expanded' },
119
+ h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 } },
120
+ h('span', { style: { fontWeight: 600, letterSpacing: '0.05em' } }, 'Righthand'),
121
+ h('span', { style: { display: 'flex', gap: 6 } }, refreshBtn, toggle),
122
+ ),
123
+ error !== '' ? h('div', { style: { color: C.red, fontSize: 11, marginBottom: 8 } }, error) : null,
124
+
125
+ h(Section, { title: 'Settings' },
126
+ Object.entries(state.settings).map(([k, v]) => h(Row, { key: k, label: k, value: fmt(v, 90) })),
127
+ h('div', { style: { display: 'flex', gap: 6, marginTop: 8 } },
128
+ h('input', {
129
+ value: patch, onChange: (e: any) => setPatch(e.target.value),
130
+ style: { flex: 1, background: C.row, color: C.text, border: '1px solid ' + C.border, borderRadius: 6, padding: '3px 8px', fontSize: 11, fontFamily: 'monospace' },
131
+ }),
132
+ h(Btn, { label: 'Patch', onClick: () => {
133
+ try { const p = JSON.parse(patch); post('/righthand/settings/set', { patch: p }, load) }
134
+ catch { setError('patch must be JSON') }
135
+ } }),
136
+ ),
137
+ ),
138
+
139
+ h(Section, { title: 'Store (' + state.storeKeys.length + ')' },
140
+ state.storeKeys.length === 0 ? h('div', { style: { color: C.dim, fontSize: 12 } }, '(empty)') : null,
141
+ state.storeKeys.map(k => h('div', { key: k, style: { display: 'flex', gap: 6, alignItems: 'center', padding: '2px 0' } },
142
+ h('span', { style: { flex: 1, fontFamily: 'monospace', fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, k),
143
+ h(Btn, { label: 'Delete', danger: true, onClick: () => post('/righthand/store/delete', { key: k }, load) }),
144
+ )),
145
+ h('div', { style: { display: 'flex', gap: 6, marginTop: 8 } },
146
+ h('input', { placeholder: 'key', value: newKey, onChange: (e: any) => setNewKey(e.target.value), style: { flex: 1, background: C.row, color: C.text, border: '1px solid ' + C.border, borderRadius: 6, padding: '3px 8px', fontSize: 11, fontFamily: 'monospace' } }),
147
+ h('input', { placeholder: 'json value', value: newValue, onChange: (e: any) => setNewValue(e.target.value), style: { flex: 1, background: C.row, color: C.text, border: '1px solid ' + C.border, borderRadius: 6, padding: '3px 8px', fontSize: 11, fontFamily: 'monospace' } }),
148
+ h(Btn, { label: 'Put', onClick: () => {
149
+ if (newKey === '') { setError('key required'); return }
150
+ try { const v = JSON.parse(newValue === '' ? 'null' : newValue); post('/righthand/store/put', { key: newKey, value: v }, () => { load(); setNewKey(''); setNewValue('') }) }
151
+ catch { setError('value must be JSON') }
152
+ } }),
153
+ ),
154
+ ),
155
+
156
+ h(Section, { title: 'Tasks (' + state.tasks.length + ')' },
157
+ state.tasks.length === 0 ? h('div', { style: { color: C.dim, fontSize: 12 } }, '(none)') : null,
158
+ state.tasks.map((t: any) => h('div', { key: t.id, style: { display: 'flex', gap: 6, alignItems: 'center', padding: '2px 0' } },
159
+ h('span', { style: { flex: 1, fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, (t.state === 'done' ? '[x] ' : t.state === 'failed' ? '[!] ' : '[ ] ') + t.title),
160
+ h(Btn, { label: 'Done', tone: C.green, onClick: () => post('/righthand/task/update', { id: t.id, state: 'done' }, load) }),
161
+ h(Btn, { label: 'Fail', danger: true, onClick: () => post('/righthand/task/update', { id: t.id, state: 'failed' }, load) }),
162
+ )),
163
+ ),
164
+
165
+ h(Section, { title: 'Events (' + state.events.length + ')' },
166
+ state.events.length === 0 ? h('div', { style: { color: C.dim, fontSize: 12 } }, '(none)') : null,
167
+ state.events.map((e: any) => h('div', { key: e.id, style: { display: 'flex', gap: 6, alignItems: 'center', padding: '2px 0' } },
168
+ h('span', { style: { flex: 1, fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, e.title + ' @ ' + e.at + ' [' + e.state + ']'),
169
+ e.state === 'pending' ? h(Btn, { label: 'Cancel', danger: true, onClick: () => post('/righthand/event/cancel', { id: e.id }, load) }) : null,
170
+ )),
171
+ ),
172
+ ), document.body)
173
+ }
174
+
175
+ /**
176
+ * Client plugin body: register the panel into the frame-wide overlay slot.
177
+ */
178
+ export function apply(ctx: ClientContext): void {
179
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({
180
+ name: 'shell.overlay',
181
+ id: 'righthand-panel',
182
+ order: 60,
183
+ }, RighthandPanel))
184
+ }
@@ -13,6 +13,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
13
13
  import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
14
14
  import type { Domain } from '@deepseek-ai/dsh-storage-domain'
15
15
  import { z } from 'zod'
16
+ import { genCall, genResult } from './cards.ts'
16
17
 
17
18
  export const name = 'righthand-events'
18
19
  export const inject = ['tools', 'storageDomain']
@@ -134,6 +135,8 @@ export function apply(ctx: Context): void {
134
135
  },
135
136
  render: (_args, value) => [{ type: 'text', text: 'event ' + value.id + ': ' + value.title + ' at ' + value.at }],
136
137
  },
138
+ presentCall: (args: any) => genCall('Reminder: ' + args.title + ' at ' + args.at, 'edit'),
139
+ presentResult: (_args, result) => genResult(result),
137
140
  async execute(args) {
138
141
  const d = await ensure()
139
142
  const at = new Date(args.at)
@@ -166,6 +169,8 @@ export function apply(ctx: Context): void {
166
169
  },
167
170
  render: (_args, value) => [{ type: 'text', text: value.due.length === 0 ? '(nothing due)' : value.due.map((e: any) => e.title + ' at ' + e.at).join(' / ') }],
168
171
  },
172
+ presentCall: () => genCall('Due events', 'read'),
173
+ presentResult: (_args, result) => genResult(result),
169
174
  async execute() {
170
175
  const d = await ensure()
171
176
  const now = Date.now()
@@ -192,6 +197,8 @@ export function apply(ctx: Context): void {
192
197
  schema: { type: 'array', items: { type: 'json' } },
193
198
  render: (_args, value) => [{ type: 'text', text: value.length === 0 ? '(no events)' : (value as any[]).map((e: any) => e.at + ' ' + e.title + ' [' + e.state + ']').join(' / ') }],
194
199
  },
200
+ presentCall: () => genCall('Events', 'read'),
201
+ presentResult: (_args, result) => genResult(result),
195
202
  async execute(args) {
196
203
  const events = await all()
197
204
  const wanted = args.state ?? null
@@ -213,6 +220,8 @@ export function apply(ctx: Context): void {
213
220
  schema: { type: 'array', items: { type: 'string' } },
214
221
  render: (_args, value) => [{ type: 'text', text: value.length === 0 ? '(no free slot)' : (value as string[]).join(' / ') }],
215
222
  },
223
+ presentCall: (args: any) => genCall('Free slots (' + args.durationMinutes + 'm)', 'read'),
224
+ presentResult: (_args, result) => genResult(result),
216
225
  async execute(args) {
217
226
  const events = await all()
218
227
  return freeSlots(events, new Date(), args.durationMinutes, args.horizonHours ?? 24)
@@ -236,6 +245,8 @@ export function apply(ctx: Context): void {
236
245
  },
237
246
  render: (_args, value) => [{ type: 'text', text: 'cancelled ' + value.id + ': ' + value.existed }],
238
247
  },
248
+ presentCall: (args: any) => genCall('Cancel event ' + args.id, 'edit'),
249
+ presentResult: (_args, result) => genResult(result),
239
250
  async execute(args) {
240
251
  const d = await ensure()
241
252
  const e = d.table('events').get(args.id)
package/src/exec-tools.ts CHANGED
@@ -9,7 +9,9 @@
9
9
 
10
10
  import type { Context } from '@deepseek-ai/cordis'
11
11
  import { defineTool } from '@deepseek-ai/dsh-tools'
12
+ import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools'
12
13
  import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
14
+ import { genCall, genResult, textOf } from './cards.ts'
13
15
  import type {} from '@deepseek-ai/dsh-jobs'
14
16
 
15
17
  export const name = 'righthand-exec'
@@ -41,6 +43,12 @@ export function apply(ctx: Context): void {
41
43
  },
42
44
  },
43
45
  render: (_args, value) => [{ type: 'text', text: `exit ${value.exitCode ?? ('signal ' + value.signal)}; stdout=${value.stdout.length}b stderr=${value.stderr.length}b` }],
46
+ presentationMeta: (_args, value) => ({ exitCode: value.exitCode ?? null, signal: value.signal ?? null }),
47
+ },
48
+ presentCall: (args: any): ToolCallView => ({ card: 'terminal', title: args.argv.join(' '), description: 'run one command (bounded collect)' }),
49
+ presentResult: (_args, result): ToolResultView => {
50
+ const meta = result.meta as any
51
+ return { card: 'terminal', output: textOf(result.content) + '\n' + (meta?.exitCode ?? ''), exitCode: meta?.exitCode ?? undefined, signal: meta?.signal ?? undefined }
44
52
  },
45
53
  async execute(args, exec) {
46
54
  const maxBytes = args.maxOutputBytes ?? 4096
@@ -89,6 +97,8 @@ export function apply(ctx: Context): void {
89
97
  },
90
98
  render: (_args, value) => [{ type: 'text', text: `started background job ${value.jobId} (${value.label})` }],
91
99
  },
100
+ presentCall: (args: any): ToolCallView => genCall('Background job: ' + (args.label ?? args.argv[0]), 'execute'),
101
+ presentResult: (_args, result): ToolResultView => genResult(result),
92
102
  async execute(args, exec) {
93
103
  const label = args.label ?? args.argv.join(' ')
94
104
  const jobId = ctx.jobs.start({
@@ -15,6 +15,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
15
15
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
16
16
  import { settingsNamespace } from '@deepseek-ai/dsh-settings'
17
17
  import { signRequest, presignGet } from './sigv4.ts'
18
+ import { genCall, genResult } from './cards.ts'
18
19
 
19
20
  export const name = 'righthand-files'
20
21
  export const inject = ['tools', 'credentials', 'settings']
@@ -178,6 +179,8 @@ export function apply(ctx: Context, config: FilesConfig = {}): void {
178
179
  },
179
180
  render: (_args, value) => [{ type: 'text', text: 'stored ' + value.key + ' (' + value.size + ' bytes)' }],
180
181
  },
182
+ presentCall: (args: any) => genCall('Upload ' + args.key, 'edit'),
183
+ presentResult: (_args, result) => genResult(result),
181
184
  async execute(args) {
182
185
  const c = await client()
183
186
  return c.put(args.key, args.content, args.contentType)
@@ -203,6 +206,8 @@ export function apply(ctx: Context, config: FilesConfig = {}): void {
203
206
  },
204
207
  render: (_args, value) => [{ type: 'text', text: value.found && typeof value.content === 'string' ? value.content : '(absent)' }],
205
208
  },
209
+ presentCall: (args: any) => genCall('Read object ' + args.key, 'read'),
210
+ presentResult: (_args, result) => genResult(result),
206
211
  async execute(args) {
207
212
  const c = await client()
208
213
  const out = await c.get(args.key)
@@ -231,6 +236,8 @@ export function apply(ctx: Context, config: FilesConfig = {}): void {
231
236
  },
232
237
  render: (_args, value) => [{ type: 'text', text: value.entries.length + ' objects' }],
233
238
  },
239
+ presentCall: () => genCall('List bucket', 'read'),
240
+ presentResult: (_args, result) => genResult(result),
234
241
  async execute(args) {
235
242
  const c = await client()
236
243
  const entries = await c.list(args.prefix, args.maxKeys)
@@ -257,6 +264,8 @@ export function apply(ctx: Context, config: FilesConfig = {}): void {
257
264
  },
258
265
  render: (_args, value) => [{ type: 'text', text: value.url }],
259
266
  },
267
+ presentCall: (args: any) => genCall('Share ' + args.key + ' for ' + (args.minutes ?? 60) + ' min', 'execute'),
268
+ presentResult: (_args, result) => genResult(result),
260
269
  async execute(args) {
261
270
  const minutes = Math.min(Math.max(args.minutes ?? 60, 1), 10080)
262
271
  const c = await client()
@@ -282,6 +291,8 @@ export function apply(ctx: Context, config: FilesConfig = {}): void {
282
291
  },
283
292
  render: (_args, value) => [{ type: 'text', text: 'deleted ' + value.key + ': ' + value.existed }],
284
293
  },
294
+ presentCall: (args: any) => genCall('Delete object ' + args.key, 'delete'),
295
+ presentResult: (_args, result) => genResult(result),
285
296
  async execute(args) {
286
297
  const c = await client()
287
298
  return c.delete(args.key)
package/src/gui.ts ADDED
@@ -0,0 +1,157 @@
1
+ /**
2
+ * gui.ts - the righthand GUI host half: HTTP routes under /righthand/* on
3
+ * the DSH web server that read and mutate the same domains the tools use.
4
+ * The client half (src/client/index.ts) renders these as a panel inside the
5
+ * DSH Web GUI. Mounts only when a webServer exists (the Web surface);
6
+ * elsewhere the child fiber simply does not start.
7
+ */
8
+
9
+ import type { Context } from '@deepseek-ai/cordis'
10
+ import type { IncomingMessage, ServerResponse } from 'node:http'
11
+ import { settingsNamespace } from '@deepseek-ai/dsh-settings'
12
+ import { storeDomain } from './store-tools.ts'
13
+ import { taskDomain } from './task-tools.ts'
14
+ import { eventsDomain } from './events-tools.ts'
15
+ // Type-only: activates the cordis Context merge for ctx.webServer.
16
+ import type {} from '@deepseek-ai/dsh-host-webserver'
17
+
18
+ export const name = 'righthand-gui'
19
+ export const inject = ['webServer', 'storageDomain', 'settings']
20
+
21
+ const ns = settingsNamespace('righthand')
22
+
23
+ function json(res: ServerResponse, code: number, body: unknown): void {
24
+ const text = JSON.stringify(body)
25
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
26
+ res.end(text)
27
+ }
28
+
29
+ function readBody(req: IncomingMessage): Promise<string> {
30
+ return new Promise((resolve, reject) => {
31
+ const chunks: Buffer[] = []
32
+ let bytes = 0
33
+ req.on('data', (c: Buffer) => {
34
+ bytes += c.length
35
+ if (bytes > 65536) { reject(new Error('body too large')); req.destroy() }
36
+ else chunks.push(c)
37
+ })
38
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
39
+ req.on('error', reject)
40
+ })
41
+ }
42
+
43
+ export function apply(ctx: Context): void {
44
+ let domains: { store: any; tasks: any; events: any } | undefined
45
+ let ready: Promise<void> | undefined
46
+
47
+ ctx.effect(() => {
48
+ ready = (async () => {
49
+ const [store, tasks, events] = await Promise.all([
50
+ ctx.storageDomain.open(storeDomain),
51
+ ctx.storageDomain.open(taskDomain),
52
+ ctx.storageDomain.open(eventsDomain),
53
+ ])
54
+ domains = { store, tasks, events }
55
+ })()
56
+ return () => { }
57
+ })
58
+
59
+ const ensure = async (): Promise<NonNullable<typeof domains>> => {
60
+ if (domains === undefined) { await ready }
61
+ if (domains === undefined) throw new Error('righthand domains not open')
62
+ return domains
63
+ }
64
+
65
+ const getSettings = () => {
66
+ const v = ctx.settings.get(ns) as Record<string, unknown> | undefined
67
+ return v ?? {}
68
+ }
69
+
70
+ const disposers: Array<() => void> = []
71
+ ctx.effect(() => () => { for (const d of disposers.splice(0)) d() })
72
+
73
+ const route = (path: string, handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>): void => {
74
+ disposers.push(ctx.webServer.register({ kind: 'exact', path, handler }))
75
+ }
76
+
77
+ route('/righthand/state', async (_req, res) => {
78
+ try {
79
+ const d = await ensure()
80
+ const storeKeys = [...d.store.table('rows').keys()]
81
+ const tasks = [...d.tasks.table('tasks').entries()]
82
+ const events = [...d.events.table('events').entries()]
83
+ json(res, 200, {
84
+ settings: getSettings(),
85
+ storeKeys,
86
+ tasks: tasks.map(([id, row]) => ({ id, ...row })),
87
+ events: events.map(([id, row]) => ({ id, ...row })),
88
+ })
89
+ } catch (e) {
90
+ json(res, 500, { error: String(e) })
91
+ }
92
+ })
93
+
94
+ route('/righthand/settings/set', async (req, res) => {
95
+ try {
96
+ const body = JSON.parse(await readBody(req))
97
+ await ctx.settings.update(ns, body.patch)
98
+ json(res, 200, { ok: true })
99
+ } catch (e) {
100
+ json(res, 400, { error: String(e) })
101
+ }
102
+ })
103
+
104
+ route('/righthand/store/put', async (req, res) => {
105
+ try {
106
+ const d = await ensure()
107
+ const body = JSON.parse(await readBody(req))
108
+ if (typeof body.key !== 'string' || body.key === '') throw new Error('key required')
109
+ await d.store.table('rows').put(body.key, { value: body.value ?? null, updatedAt: new Date().toISOString() })
110
+ const writes = d.store.global.get()
111
+ await d.store.global.set({ writes: writes.writes + 1 })
112
+ json(res, 200, { ok: true })
113
+ } catch (e) {
114
+ json(res, 400, { error: String(e) })
115
+ }
116
+ })
117
+
118
+ route('/righthand/store/delete', async (req, res) => {
119
+ try {
120
+ const d = await ensure()
121
+ const body = JSON.parse(await readBody(req))
122
+ const existed = await d.store.table('rows').delete(body.key)
123
+ json(res, 200, { ok: true, existed })
124
+ } catch (e) {
125
+ json(res, 400, { error: String(e) })
126
+ }
127
+ })
128
+
129
+ route('/righthand/task/update', async (req, res) => {
130
+ try {
131
+ const d = await ensure()
132
+ const body = JSON.parse(await readBody(req))
133
+ const row = await d.tasks.table('tasks').get(body.id)
134
+ if (row === undefined) { json(res, 404, { error: 'task not found' }); return }
135
+ const next: any = { ...row }
136
+ if (typeof body.state === 'string') next.state = body.state
137
+ if (body.result !== undefined) next.result = body.result
138
+ await d.tasks.table('tasks').put(body.id, next)
139
+ json(res, 200, { ok: true })
140
+ } catch (e) {
141
+ json(res, 400, { error: String(e) })
142
+ }
143
+ })
144
+
145
+ route('/righthand/event/cancel', async (req, res) => {
146
+ try {
147
+ const d = await ensure()
148
+ const body = JSON.parse(await readBody(req))
149
+ const row = await d.events.table('events').get(body.id)
150
+ if (row === undefined) { json(res, 404, { error: 'event not found' }); return }
151
+ await d.events.table('events').put(body.id, { ...row, state: 'cancelled' })
152
+ json(res, 200, { ok: true })
153
+ } catch (e) {
154
+ json(res, 400, { error: String(e) })
155
+ }
156
+ })
157
+ }
package/src/index.ts CHANGED
@@ -25,6 +25,7 @@
25
25
  * - notify-tools: rh_notify_send over ntfy.sh (keyless)
26
26
  * - exec-tools: rh_run / rh_run_bg over ctx.subprocess + ctx.jobs
27
27
  * - guard-tools: tools/pre-execute policy (config.rules)
28
+ * - gui: /righthand/* web routes + the client panel (src/client)
28
29
  * - skills: the packaged dsh-righthand skill (ctx.skills)
29
30
  *
30
31
  * @module @try-works/dsh-righthand
@@ -42,6 +43,7 @@ import * as placesTools from './places-tools.ts'
42
43
  import * as filesTools from './files-tools.ts'
43
44
  import * as eventsTools from './events-tools.ts'
44
45
  import * as notifyTools from './notify-tools.ts'
46
+ import * as guiTools from './gui.ts'
45
47
  import * as righthandSkills from './skills.ts'
46
48
  import type { GuardRule } from './guard-tools.ts'
47
49
 
@@ -73,12 +75,13 @@ export function apply(ctx: Context, config: RighthandConfig = {}): void {
73
75
  ctx.plugin(filesTools)
74
76
  ctx.plugin(eventsTools)
75
77
  ctx.plugin(notifyTools)
78
+ ctx.plugin(guiTools)
76
79
  ctx.plugin(guardTools, { rules: config.rules ?? [] })
77
80
  ctx.plugin(righthandSkills)
78
81
  }
79
82
 
80
83
  // Individual modules stay importable for selective mounting.
81
- export { storeTools, secretsTools, execTools, taskTools, textTools, weatherTools, placesTools, filesTools, eventsTools, notifyTools, guardTools, righthandSkills }
84
+ export { storeTools, secretsTools, execTools, taskTools, textTools, weatherTools, placesTools, filesTools, eventsTools, notifyTools, guiTools, guardTools, righthandSkills }
82
85
  export { SKILL_NAME, SKILL_DESCRIPTION, skillDirectory, skillBody } from './skills.ts'
83
86
  export type { GuardRule } from './guard-tools.ts'
84
87
  export { guardFactsFor } from './guard-tools.ts'
@@ -10,6 +10,7 @@ import type { Context } from '@deepseek-ai/cordis'
10
10
  import { defineTool } from '@deepseek-ai/dsh-tools'
11
11
  import { settingsNamespace } from '@deepseek-ai/dsh-settings'
12
12
  import { guardedFetch } from './weather-tools.ts'
13
+ import { genCall, genResult } from './cards.ts'
13
14
 
14
15
  export const name = 'righthand-notify'
15
16
  export const inject = ['tools', 'settings']
@@ -45,6 +46,8 @@ export function apply(ctx: Context, config: NotifyConfig = {}): void {
45
46
  },
46
47
  render: (_args, value) => [{ type: 'text', text: 'notified ' + value.topic }],
47
48
  },
49
+ presentCall: (args: any) => genCall('Notify: ' + args.message.slice(0, 60), 'execute'),
50
+ presentResult: (_args, result) => genResult(result),
48
51
  async execute(args) {
49
52
  const settings = ctx.settings.get(ns) as { defaultNotifyTopic?: string } | undefined
50
53
  const topic = args.topic ?? config.topic ?? settings?.defaultNotifyTopic ?? ''
@@ -12,6 +12,7 @@
12
12
  import type { Context } from '@deepseek-ai/cordis'
13
13
  import { defineTool } from '@deepseek-ai/dsh-tools'
14
14
  import { guardedFetch } from './weather-tools.ts'
15
+ import { genCall, genResult } from './cards.ts'
15
16
 
16
17
  export const name = 'righthand-places'
17
18
  export const inject = ['tools']
@@ -122,6 +123,8 @@ export function apply(ctx: Context, config: PlacesConfig = {}): void {
122
123
  return [{ type: 'text', text }]
123
124
  },
124
125
  },
126
+ presentCall: (args: any) => genCall('Places: ' + args.query, 'search'),
127
+ presentResult: (_args, result) => genResult(result),
125
128
  async execute(args) {
126
129
  const limit = Math.min(Math.max(1, Math.round(args.limit ?? 5)), 20)
127
130
  const qs = new URLSearchParams({ q: args.query, format: 'jsonv2', addressdetails: '1', limit: String(limit) })
@@ -153,6 +156,8 @@ export function apply(ctx: Context, config: PlacesConfig = {}): void {
153
156
  },
154
157
  render: (_args, value) => [{ type: 'text', text: value.displayName }],
155
158
  },
159
+ presentCall: (args: any) => genCall('Reverse geocode ' + args.latitude + ', ' + args.longitude, 'fetch'),
160
+ presentResult: (_args, result) => genResult(result),
156
161
  async execute(args) {
157
162
  const qs = new URLSearchParams({ lat: String(args.latitude), lon: String(args.longitude), format: 'jsonv2', addressdetails: '1' })
158
163
  const body = await guardedFetch(cfg.nominatimUrl + '/reverse?' + qs.toString(), { init: { headers: nomHeaders } })
@@ -181,6 +186,8 @@ export function apply(ctx: Context, config: PlacesConfig = {}): void {
181
186
  },
182
187
  render: (_args, value) => [{ type: 'text', text: value.elevation + ' m' }],
183
188
  },
189
+ presentCall: (args: any) => genCall('Elevation at ' + args.latitude + ', ' + args.longitude, 'fetch'),
190
+ presentResult: (_args, result) => genResult(result),
184
191
  async execute(args) {
185
192
  const qs = new URLSearchParams({ latitude: String(args.latitude), longitude: String(args.longitude) })
186
193
  const body = await guardedFetch(cfg.elevationUrl + '?' + qs.toString())
@@ -207,6 +214,8 @@ export function apply(ctx: Context, config: PlacesConfig = {}): void {
207
214
  return [{ type: 'text', text }]
208
215
  },
209
216
  },
217
+ presentCall: (args: any) => genCall('Nearby: ' + args.query + ' within ' + (args.radiusKm ?? 1) + ' km', 'search'),
218
+ presentResult: (_args, result) => genResult(result),
210
219
  async execute(args) {
211
220
  const radius = Math.min(Math.max(0.1, Number(args.radiusKm ?? 1)), 20)
212
221
  const limit = Math.min(Math.max(1, Math.round(args.limit ?? 5)), 20)
@@ -11,6 +11,7 @@ import type { Context } from '@deepseek-ai/cordis'
11
11
  import { defineTool } from '@deepseek-ai/dsh-tools'
12
12
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
13
13
  import { settingsNamespace } from '@deepseek-ai/dsh-settings'
14
+ import { genCall, genResult } from './cards.ts'
14
15
  import z from '@deepseek-ai/schemastery'
15
16
 
16
17
  export const name = 'righthand-secrets'
@@ -56,6 +57,8 @@ export function apply(ctx: Context): void {
56
57
  },
57
58
  render: (_args, value) => [{ type: 'text', text: `${value.ref}: configured=${value.configured}${value.source ? ' (source=' + value.source + ')' : ''}, writable=${value.writable}` }],
58
59
  },
60
+ presentCall: (args: any) => genCall('Check credential ' + args.ref, 'read'),
61
+ presentResult: (_args, result) => genResult(result),
59
62
  async execute(args) {
60
63
  const ref = credentialRef(args.ref)
61
64
  const info = await ctx.credentials.describe(ref)
@@ -81,6 +84,8 @@ export function apply(ctx: Context): void {
81
84
  },
82
85
  render: (_args, value) => [{ type: 'text', text: `stored credential ${value.ref} (value not echoed)` }],
83
86
  },
87
+ presentCall: (args: any) => genCall('Store credential ' + args.ref, 'edit'),
88
+ presentResult: (_args, result) => genResult(result),
84
89
  async execute(args) {
85
90
  if (args.value.length === 0) throw new Error('credential value must be non-empty')
86
91
  await ctx.credentials.set(credentialRef(args.ref), args.value)
@@ -105,6 +110,8 @@ export function apply(ctx: Context): void {
105
110
  },
106
111
  render: (_args, value) => [{ type: 'text', text: `removed credential ${value.ref}` }],
107
112
  },
113
+ presentCall: (args: any) => genCall('Remove credential ' + args.ref, 'delete'),
114
+ presentResult: (_args, result) => genResult(result),
108
115
  async execute(args) {
109
116
  await ctx.credentials.unset(credentialRef(args.ref))
110
117
  return { ref: args.ref, removed: true }
@@ -119,6 +126,8 @@ export function apply(ctx: Context): void {
119
126
  schema: { type: 'object', additionalProperties: true },
120
127
  render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
121
128
  },
129
+ presentCall: () => genCall('righthand settings', 'read'),
130
+ presentResult: (_args, result) => genResult(result),
122
131
  async execute() {
123
132
  const v = scope.get()
124
133
  return { accountId: v.accountId, defaultScriptPrefix: v.defaultScriptPrefix, defaultZone: v.defaultZone, defaultR2Bucket: v.defaultR2Bucket, defaultNotifyTopic: v.defaultNotifyTopic }
@@ -141,6 +150,8 @@ export function apply(ctx: Context): void {
141
150
  },
142
151
  render: () => [{ type: 'text', text: 'settings updated' }],
143
152
  },
153
+ presentCall: (args: any) => genCall('Patch righthand settings (' + Object.keys(args.patch).join(', ') + ')', 'edit'),
154
+ presentResult: (_args, result) => genResult(result),
144
155
  async execute(args) {
145
156
  await scope.update(args.patch as Partial<RighthandSettings>)
146
157
  return { applied: true }
@@ -8,6 +8,7 @@
8
8
 
9
9
  import type { Context } from '@deepseek-ai/cordis'
10
10
  import { defineTool } from '@deepseek-ai/dsh-tools'
11
+ import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools'
11
12
  import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
12
13
  import type { Domain } from '@deepseek-ai/dsh-storage-domain'
13
14
  import { z } from 'zod'
@@ -110,6 +111,8 @@ export function apply(ctx: Context): void {
110
111
  },
111
112
  render: (_args, value) => [{ type: 'text', text: `stored ${value.key} (writes=${value.writes})` }],
112
113
  },
114
+ presentCall: (args: any): ToolCallView => ({ card: 'generic', title: 'Store ' + args.key, kind: 'edit' }),
115
+ presentResult: (_args, result): ToolResultView => ({ card: 'generic', content: result.content }),
113
116
  async execute(args) {
114
117
  const s = await ensure()
115
118
  const row = await s.put(args.key, args.value)
@@ -136,6 +139,8 @@ export function apply(ctx: Context): void {
136
139
  },
137
140
  render: (_args, value) => [{ type: 'text', text: value.found ? `${value.key} = ${JSON.stringify(value.value)}` : `${value.key} (absent)` }],
138
141
  },
142
+ presentCall: (args: any): ToolCallView => ({ card: 'generic', title: 'Read ' + args.key, kind: 'read' }),
143
+ presentResult: (_args, result): ToolResultView => ({ card: 'generic', content: result.content }),
139
144
  async execute(args) {
140
145
  const s = await ensure()
141
146
  const row = await s.get(args.key)
@@ -161,6 +166,8 @@ export function apply(ctx: Context): void {
161
166
  },
162
167
  render: (_args, value) => [{ type: 'text', text: `deleted ${value.key}: ${value.existed}` }],
163
168
  },
169
+ presentCall: (args: any): ToolCallView => ({ card: 'generic', title: 'Delete ' + args.key, kind: 'delete' }),
170
+ presentResult: (_args, result): ToolResultView => ({ card: 'generic', content: result.content }),
164
171
  async execute(args) {
165
172
  const s = await ensure()
166
173
  const existed = await s.delete(args.key)
@@ -176,6 +183,8 @@ export function apply(ctx: Context): void {
176
183
  schema: { type: 'array', items: { type: 'string' } },
177
184
  render: (_args, keys) => [{ type: 'text', text: keys.length === 0 ? '(empty)' : keys.join('\n') }],
178
185
  },
186
+ presentCall: (): ToolCallView => ({ card: 'generic', title: 'List store keys', kind: 'read' }),
187
+ presentResult: (_args, result): ToolResultView => ({ card: 'generic', content: result.content }),
179
188
  async execute() {
180
189
  const s = await ensure()
181
190
  return s.list()