@huynle/hub 0.0.0-stage → 0.6.1
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/CHANGELOG.md +269 -0
- package/Dockerfile +51 -0
- package/LICENSE +21 -0
- package/README.md +1029 -2
- package/bin/hub.ts +20 -0
- package/docker/hub-deps.ts +42 -0
- package/package.json +85 -4
- package/rpc.ts +2 -0
- package/scripts/hub-local.sh +28 -0
- package/scripts/io.github.huynle.hub.plist +42 -0
- package/server.ts +3 -0
- package/src/central/assets.ts +74 -0
- package/src/central/auth.ts +271 -0
- package/src/central/config.ts +310 -0
- package/src/central/http.ts +1247 -0
- package/src/central/main.ts +136 -0
- package/src/central/oidc.ts +295 -0
- package/src/central/push-target.ts +97 -0
- package/src/central/push.ts +285 -0
- package/src/central/registry.ts +1153 -0
- package/src/central/speech/models.ts +89 -0
- package/src/central/speech/openai.ts +66 -0
- package/src/central/speech/openrouter.ts +71 -0
- package/src/central/speech/provider.ts +74 -0
- package/src/central/speech/service.ts +168 -0
- package/src/central/speech/settings.ts +375 -0
- package/src/central/spoke-ws.ts +300 -0
- package/src/central/store.ts +653 -0
- package/src/config.ts +170 -0
- package/src/env.ts +63 -0
- package/src/index.ts +236 -0
- package/src/key.ts +17 -0
- package/src/loopback.ts +41 -0
- package/src/migrate.ts +105 -0
- package/src/openrouter.ts +3 -0
- package/src/options.ts +33 -0
- package/src/protocol.ts +650 -0
- package/src/reply.ts +27 -0
- package/src/rpc.ts +98 -0
- package/src/setup.ts +124 -0
- package/src/speakable.ts +35 -0
- package/src/spoke/client.ts +272 -0
- package/src/spoke/conversation.ts +93 -0
- package/src/spoke/enroll.ts +79 -0
- package/src/spoke/herdr.ts +346 -0
- package/src/spoke/listing.ts +108 -0
- package/src/spoke/localhub.ts +287 -0
- package/src/spoke/locations.ts +34 -0
- package/src/spoke/own-server.ts +32 -0
- package/src/spoke/presence.ts +45 -0
- package/src/spoke/singleton.ts +103 -0
- package/src/spoke/spoke.ts +1212 -0
- package/src/spoke/summary.ts +117 -0
- package/src/spoke/timeline.ts +93 -0
- package/src/spoke/tracker.ts +345 -0
- package/src/spoke/watch.ts +78 -0
- package/src/status.ts +232 -0
- package/src/tui-commands.ts +53 -0
- package/src/tui-forms.ts +77 -0
- package/src/tui-presence.ts +75 -0
- package/src/tui.ts +479 -0
- package/src/turns.ts +94 -0
- package/src/voice-command.ts +65 -0
- package/tui.ts +2 -0
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/index-DClBvfLL.css +1 -0
- package/web/dist/assets/index-Djo_yF0a.js +12 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/icon-maskable-512.png +0 -0
- package/web/dist/icon.svg +18 -0
- package/web/dist/index.html +25 -0
- package/web/dist/manifest.webmanifest +18 -0
- package/web/dist/sw.js +151 -0
- package/web/dist/vad-worklet.js +27 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// Web push (VAPID) for paired devices: hand-offs from the terminal, questions, permissions, finished and
|
|
2
|
+
// failed runs, and (conversation mode, nobody watching) what the agent said. Payloads carry only {kind,
|
|
3
|
+
// sessionID, title, body, requestID?}: no tokens, no transcript beyond one spoken text, shortened. Each device picks the kinds it wants, may set quiet hours, and users mute sessions.
|
|
4
|
+
import webpush from "web-push"
|
|
5
|
+
import { type PushPrefs, type PushTarget, type QuietHours, type Store } from "./store"
|
|
6
|
+
|
|
7
|
+
export { DEFAULT_PUSH_PREFS, type PushPrefs } from "./store"
|
|
8
|
+
|
|
9
|
+
/** What happened, as the registry and the spoke socket report it. */
|
|
10
|
+
export type PushKind = "handoff" | "blocked" | "finished" | "say"
|
|
11
|
+
/** What a blocked or finished notification is about. */
|
|
12
|
+
export type PushDetail = "permission" | "form" | "succeeded" | "failed"
|
|
13
|
+
/** The precise notification kind; one per device toggle. */
|
|
14
|
+
export type NotificationKind = keyof PushPrefs
|
|
15
|
+
|
|
16
|
+
/** Extra facts for one notification. */
|
|
17
|
+
export interface PushExtra {
|
|
18
|
+
/** permission: the request ID, so the notification can answer it. */
|
|
19
|
+
readonly requestID?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PushPayload {
|
|
23
|
+
readonly kind: NotificationKind | "test"
|
|
24
|
+
readonly sessionID: string
|
|
25
|
+
readonly title: string
|
|
26
|
+
readonly body: string
|
|
27
|
+
readonly requestID?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The device toggle that decides an alert. */
|
|
31
|
+
export function pushKindFor(kind: PushKind, detail?: PushDetail): NotificationKind {
|
|
32
|
+
switch (kind) {
|
|
33
|
+
case "blocked":
|
|
34
|
+
return detail === "form" ? "question" : "permission"
|
|
35
|
+
case "finished":
|
|
36
|
+
return detail === "failed" ? "failed" : "finished"
|
|
37
|
+
default:
|
|
38
|
+
return kind
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const minutes = (clock: string): number => Number(clock.slice(0, 2)) * 60 + Number(clock.slice(3, 5))
|
|
43
|
+
|
|
44
|
+
/** True when `at` falls in the quiet hours (local time of their zone); never for an empty or invalid range. */
|
|
45
|
+
export function inQuietHours(quiet: QuietHours, at: number): boolean {
|
|
46
|
+
let parts: Intl.DateTimeFormatPart[]
|
|
47
|
+
try {
|
|
48
|
+
parts = new Intl.DateTimeFormat("en-GB", { timeZone: quiet.timeZone, hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).formatToParts(at)
|
|
49
|
+
} catch {
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
const hour = Number(parts.find((p) => p.type === "hour")?.value)
|
|
53
|
+
const minute = Number(parts.find((p) => p.type === "minute")?.value)
|
|
54
|
+
if (!Number.isFinite(hour) || !Number.isFinite(minute)) return false
|
|
55
|
+
const now = (hour % 24) * 60 + minute
|
|
56
|
+
const start = minutes(quiet.start)
|
|
57
|
+
const end = minutes(quiet.end)
|
|
58
|
+
if (start === end) return false
|
|
59
|
+
return start < end ? now >= start && now < end : now >= start || now < end
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface PushSendOptions {
|
|
63
|
+
readonly TTL: number
|
|
64
|
+
readonly urgency: "high" | "normal"
|
|
65
|
+
readonly timeout: number
|
|
66
|
+
readonly vapidDetails: { readonly subject: string; readonly publicKey: string; readonly privateKey: string }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Delivers one message; rejects with an error carrying `statusCode` when the push service refuses it. */
|
|
70
|
+
export type PushSender = (target: PushTarget, payload: string, options: PushSendOptions) => Promise<unknown>
|
|
71
|
+
|
|
72
|
+
export const PUSH_COOLDOWN_MS = 30_000
|
|
73
|
+
/** Spoken replies are pushed more often than other kinds. */
|
|
74
|
+
export const SAY_COOLDOWN_MS = 10_000
|
|
75
|
+
/** Longest spoken text carried in a say notification body. */
|
|
76
|
+
export const SAY_MAX_CHARS = 180
|
|
77
|
+
export const PUSH_TTL_SECONDS = 300
|
|
78
|
+
/** Questions, permissions and run results stay deliverable for an hour (a phone in doze picks them up later). */
|
|
79
|
+
export const URGENT_TTL_SECONDS = 3600
|
|
80
|
+
/** One test notification per device in this window. */
|
|
81
|
+
export const TEST_COOLDOWN_MS = 10_000
|
|
82
|
+
const SEND_TIMEOUT_MS = 10_000
|
|
83
|
+
const MAX_NAME = 60
|
|
84
|
+
|
|
85
|
+
const webPushSender: PushSender = (target, payload, options) =>
|
|
86
|
+
webpush.sendNotification(target, payload, { ...options, vapidDetails: { ...options.vapidDetails } })
|
|
87
|
+
|
|
88
|
+
export interface PushOptions {
|
|
89
|
+
readonly store: Store
|
|
90
|
+
/** VAPID subject: a mailto: or https: URL. */
|
|
91
|
+
readonly subject: string
|
|
92
|
+
readonly sender?: PushSender
|
|
93
|
+
readonly now?: () => number
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function clip(text: string, max: number): string {
|
|
97
|
+
const clean = text.replace(/\s+/g, " ").trim()
|
|
98
|
+
return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const shorten = (title: string): string => clip(title, MAX_NAME) || "Session"
|
|
102
|
+
|
|
103
|
+
export function pushPayload(
|
|
104
|
+
kind: PushKind,
|
|
105
|
+
sessionID: string,
|
|
106
|
+
sessionTitle: string,
|
|
107
|
+
detail?: PushDetail,
|
|
108
|
+
text?: string,
|
|
109
|
+
extra: PushExtra = {},
|
|
110
|
+
): PushPayload {
|
|
111
|
+
const name = shorten(sessionTitle)
|
|
112
|
+
const precise = pushKindFor(kind, detail)
|
|
113
|
+
switch (precise) {
|
|
114
|
+
case "say":
|
|
115
|
+
return { kind: precise, sessionID, title: name, body: clip(text ?? "", SAY_MAX_CHARS) || "New reply. Tap to listen." }
|
|
116
|
+
case "handoff":
|
|
117
|
+
return { kind: precise, sessionID, title: name, body: "Sent from the terminal. Tap to talk." }
|
|
118
|
+
case "question":
|
|
119
|
+
return { kind: precise, sessionID, title: `${name} has a question`, body: "Tap to see it." }
|
|
120
|
+
case "permission":
|
|
121
|
+
// Never the request's summary (it may be a command line); the service worker fetches it from the hub.
|
|
122
|
+
return {
|
|
123
|
+
kind: precise,
|
|
124
|
+
sessionID,
|
|
125
|
+
title: `${name} needs permission`,
|
|
126
|
+
body: "Tap to review and answer.",
|
|
127
|
+
...(extra.requestID ? { requestID: extra.requestID } : {}),
|
|
128
|
+
}
|
|
129
|
+
case "failed":
|
|
130
|
+
return { kind: precise, sessionID, title: `${name} failed`, body: "Tap to open the session." }
|
|
131
|
+
case "finished":
|
|
132
|
+
return { kind: precise, sessionID, title: `${name} finished`, body: "Tap to open the session." }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** web-push options per notification kind: urgent kinds wake a dozing phone and wait longer for it. */
|
|
137
|
+
export function sendOptionsFor(kind: NotificationKind | "test"): { TTL: number; urgency: "high" | "normal" } {
|
|
138
|
+
switch (kind) {
|
|
139
|
+
case "question":
|
|
140
|
+
case "permission":
|
|
141
|
+
case "failed":
|
|
142
|
+
return { TTL: URGENT_TTL_SECONDS, urgency: "high" }
|
|
143
|
+
case "finished":
|
|
144
|
+
return { TTL: URGENT_TTL_SECONDS, urgency: "normal" }
|
|
145
|
+
default:
|
|
146
|
+
return { TTL: PUSH_TTL_SECONDS, urgency: "high" }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Result of a test notification. */
|
|
151
|
+
export type PushTestResult =
|
|
152
|
+
| { readonly sent: number; readonly failed: ReadonlyArray<{ readonly host: string; readonly status?: number }> }
|
|
153
|
+
| { readonly limited: true; readonly retryAfter: number }
|
|
154
|
+
|
|
155
|
+
const hostOf = (endpoint: string): string => {
|
|
156
|
+
try {
|
|
157
|
+
return new URL(endpoint).host
|
|
158
|
+
} catch {
|
|
159
|
+
return "invalid endpoint"
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const statusOf = (cause: unknown): number | undefined => {
|
|
164
|
+
const code = (cause as { statusCode?: unknown } | null)?.statusCode
|
|
165
|
+
return typeof code === "number" ? code : undefined
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export class Push {
|
|
169
|
+
private readonly store: Store
|
|
170
|
+
private readonly subject: string
|
|
171
|
+
private readonly sender: PushSender
|
|
172
|
+
private readonly now: () => number
|
|
173
|
+
/** `session kind` → when it was last sent and how long it cools down. */
|
|
174
|
+
private readonly lastSent = new Map<string, { at: number; cooldown: number }>()
|
|
175
|
+
|
|
176
|
+
constructor(options: PushOptions) {
|
|
177
|
+
this.store = options.store
|
|
178
|
+
this.subject = options.subject
|
|
179
|
+
this.sender = options.sender ?? webPushSender
|
|
180
|
+
this.now = options.now ?? Date.now
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The VAPID public key (applicationServerKey); generated and persisted on first use. */
|
|
184
|
+
publicKey(): string {
|
|
185
|
+
return this.keys().publicKey
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Notifies every subscribed device that wants this kind of notification — only devices of `ownerID`
|
|
190
|
+
* (the session's owner) when given — unless the session is muted by the device's user or the device is
|
|
191
|
+
* in its quiet hours (hand-offs, an explicit request from the terminal, go through both). Returns the
|
|
192
|
+
* number of successful deliveries.
|
|
193
|
+
*/
|
|
194
|
+
async notify(
|
|
195
|
+
kind: PushKind,
|
|
196
|
+
sessionID: string,
|
|
197
|
+
title: string,
|
|
198
|
+
detail?: PushDetail,
|
|
199
|
+
body?: string,
|
|
200
|
+
ownerID?: string,
|
|
201
|
+
extra?: PushExtra,
|
|
202
|
+
): Promise<number> {
|
|
203
|
+
const precise = pushKindFor(kind, detail)
|
|
204
|
+
if (!this.cool(`${sessionID}\n${precise}`, precise === "say" ? SAY_COOLDOWN_MS : PUSH_COOLDOWN_MS)) return 0
|
|
205
|
+
const owners = new Map(this.store.devices().map((d) => [d.id, d.ownerID]))
|
|
206
|
+
const at = this.now()
|
|
207
|
+
const muted = new Map<string, boolean>()
|
|
208
|
+
const isMuted = (userID: string | undefined) => {
|
|
209
|
+
if (userID === undefined) return false
|
|
210
|
+
if (!muted.has(userID)) muted.set(userID, this.store.mutedSessions(userID).includes(sessionID))
|
|
211
|
+
return muted.get(userID)!
|
|
212
|
+
}
|
|
213
|
+
const targets = this.store.pushSubscriptions().filter((s) => {
|
|
214
|
+
const owner = owners.get(s.deviceID)
|
|
215
|
+
if (ownerID !== undefined && owner !== ownerID) return false
|
|
216
|
+
if (!this.store.pushPrefs(s.deviceID)[precise]) return false
|
|
217
|
+
if (precise === "handoff") return true
|
|
218
|
+
const quiet = this.store.quietHours(s.deviceID)
|
|
219
|
+
return !isMuted(owner) && !(quiet && inQuietHours(quiet, at))
|
|
220
|
+
})
|
|
221
|
+
if (targets.length === 0) return 0
|
|
222
|
+
const payload = pushPayload(kind, sessionID, title, detail, body, extra)
|
|
223
|
+
const { delivered } = await this.send(targets, payload)
|
|
224
|
+
return delivered
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Sends a test notification to `deviceID`'s own subscriptions, whatever its prefs; one per TEST_COOLDOWN_MS. */
|
|
228
|
+
async test(deviceID: string): Promise<PushTestResult> {
|
|
229
|
+
const key = `test\n${deviceID}`
|
|
230
|
+
const last = this.lastSent.get(key)
|
|
231
|
+
if (last && this.now() - last.at < last.cooldown) {
|
|
232
|
+
return { limited: true, retryAfter: Math.ceil((last.cooldown - (this.now() - last.at)) / 1000) }
|
|
233
|
+
}
|
|
234
|
+
this.cool(key, TEST_COOLDOWN_MS)
|
|
235
|
+
const targets = this.store.pushSubscriptions().filter((s) => s.deviceID === deviceID)
|
|
236
|
+
const payload: PushPayload = { kind: "test", sessionID: "", title: "Hub", body: "Test notification: notifications work on this device." }
|
|
237
|
+
const { delivered, failed } = await this.send(targets, payload)
|
|
238
|
+
return { sent: delivered, failed }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private async send(
|
|
242
|
+
targets: readonly { endpoint: string; keys: PushTarget["keys"] }[],
|
|
243
|
+
payload: PushPayload,
|
|
244
|
+
): Promise<{ delivered: number; failed: Array<{ host: string; status?: number }> }> {
|
|
245
|
+
const keys = this.keys()
|
|
246
|
+
const options: PushSendOptions = {
|
|
247
|
+
...sendOptionsFor(payload.kind),
|
|
248
|
+
timeout: SEND_TIMEOUT_MS,
|
|
249
|
+
vapidDetails: { subject: this.subject, publicKey: keys.publicKey, privateKey: keys.privateKey },
|
|
250
|
+
}
|
|
251
|
+
const raw = JSON.stringify(payload)
|
|
252
|
+
const results = await Promise.allSettled(
|
|
253
|
+
targets.map((target) => this.sender({ endpoint: target.endpoint, keys: target.keys }, raw, options)),
|
|
254
|
+
)
|
|
255
|
+
let delivered = 0
|
|
256
|
+
const failed: Array<{ host: string; status?: number }> = []
|
|
257
|
+
results.forEach((result, index) => {
|
|
258
|
+
if (result.status === "fulfilled") return void delivered++
|
|
259
|
+
const status = statusOf(result.reason)
|
|
260
|
+
const endpoint = targets[index]!.endpoint
|
|
261
|
+
failed.push({ host: hostOf(endpoint), ...(status !== undefined ? { status } : {}) })
|
|
262
|
+
if (status === 404 || status === 410) this.store.dropPushSubscription(endpoint)
|
|
263
|
+
else console.error(`[hub] push to ${hostOf(endpoint)} failed: ${status ?? (result.reason as Error)?.message}`)
|
|
264
|
+
})
|
|
265
|
+
return { delivered, failed }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** True (and records the send) when (session, kind) is outside its cooldown. */
|
|
269
|
+
private cool(key: string, cooldown: number): boolean {
|
|
270
|
+
const t = this.now()
|
|
271
|
+
for (const [k, sent] of this.lastSent) if (t - sent.at >= sent.cooldown) this.lastSent.delete(k)
|
|
272
|
+
if (this.lastSent.has(key)) return false
|
|
273
|
+
this.lastSent.set(key, { at: t, cooldown })
|
|
274
|
+
return true
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private keys() {
|
|
278
|
+
const existing = this.store.vapid()
|
|
279
|
+
if (existing) return existing
|
|
280
|
+
const keys = webpush.generateVAPIDKeys()
|
|
281
|
+
this.store.setVapid(keys)
|
|
282
|
+
return keys
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|