@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
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
|
2
|
+
<title>Hub</title>
|
|
3
|
+
<rect width="512" height="512" rx="112" fill="#141413"/>
|
|
4
|
+
<g transform="translate(0 20.7)">
|
|
5
|
+
<g stroke="#e8e6e1" stroke-width="22" stroke-linecap="round">
|
|
6
|
+
<line x1="256" y1="256" x2="256" y2="90"/>
|
|
7
|
+
<line x1="256" y1="256" x2="399.8" y2="339"/>
|
|
8
|
+
<line x1="256" y1="256" x2="112.2" y2="339"/>
|
|
9
|
+
</g>
|
|
10
|
+
<g fill="#e8e6e1">
|
|
11
|
+
<circle cx="256" cy="90" r="40"/>
|
|
12
|
+
<circle cx="399.8" cy="339" r="40"/>
|
|
13
|
+
<circle cx="112.2" cy="339" r="40"/>
|
|
14
|
+
</g>
|
|
15
|
+
<circle cx="256" cy="256" r="71" fill="#141413"/>
|
|
16
|
+
<circle cx="256" cy="256" r="60" fill="#fab283"/>
|
|
17
|
+
</g>
|
|
18
|
+
</svg>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
|
|
6
|
+
<meta name="theme-color" content="#141413" />
|
|
7
|
+
<meta name="color-scheme" content="dark" />
|
|
8
|
+
<meta name="mobile-web-app-capable" content="yes" />
|
|
9
|
+
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
10
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
11
|
+
<meta name="apple-mobile-web-app-title" content="Hub" />
|
|
12
|
+
<title>Hub</title>
|
|
13
|
+
<link rel="manifest" href="/manifest.webmanifest" />
|
|
14
|
+
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48" />
|
|
15
|
+
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
|
16
|
+
<link rel="icon" href="/icon-192.png" type="image/png" sizes="192x192" />
|
|
17
|
+
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
|
18
|
+
<script type="module" crossorigin src="/assets/index-Djo_yF0a.js"></script>
|
|
19
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DClBvfLL.css">
|
|
20
|
+
</head>
|
|
21
|
+
<body>
|
|
22
|
+
<div id="root"></div>
|
|
23
|
+
<noscript>Hub needs JavaScript.</noscript>
|
|
24
|
+
</body>
|
|
25
|
+
</html>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Hub",
|
|
3
|
+
"short_name": "Hub",
|
|
4
|
+
"description": "The hub for your AI agent sessions: follow and steer them, answer permission requests, talk to them with Voice, and get notified when they need you.",
|
|
5
|
+
"id": "/",
|
|
6
|
+
"start_url": "/",
|
|
7
|
+
"scope": "/",
|
|
8
|
+
"display": "standalone",
|
|
9
|
+
"orientation": "portrait",
|
|
10
|
+
"theme_color": "#141413",
|
|
11
|
+
"background_color": "#141413",
|
|
12
|
+
"icons": [
|
|
13
|
+
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
|
14
|
+
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
|
15
|
+
{ "src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
|
|
16
|
+
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }
|
|
17
|
+
]
|
|
18
|
+
}
|
package/web/dist/sw.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Hub service worker: shows push notifications, answers permissions from them, keeps the push
|
|
2
|
+
// subscription known to the hub, and handles the actions of the page's "Voice mode on" notification.
|
|
3
|
+
// No offline caching: every request goes to the network (the API must never be served stale).
|
|
4
|
+
// Requests from here are same-origin with the device cookie (credentials: "same-origin"); POSTs carry the
|
|
5
|
+
// Origin header browsers add to non-GET requests, which the hub's CSRF check accepts.
|
|
6
|
+
self.addEventListener("install", () => self.skipWaiting())
|
|
7
|
+
self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()))
|
|
8
|
+
|
|
9
|
+
/** Kinds that wait for the user: stay on screen until handled. */
|
|
10
|
+
const STICKY = new Set(["question", "permission", "blocked"])
|
|
11
|
+
const VIBRATE = { question: [200, 100, 200], permission: [200, 100, 200], blocked: [200, 100, 200], failed: [300], test: [100] }
|
|
12
|
+
|
|
13
|
+
const sessionURL = (sessionID) => (sessionID ? `/s/${encodeURIComponent(sessionID)}` : "/")
|
|
14
|
+
|
|
15
|
+
async function postJSON(path, body) {
|
|
16
|
+
return fetch(path, {
|
|
17
|
+
method: "POST",
|
|
18
|
+
credentials: "same-origin",
|
|
19
|
+
headers: { "content-type": "application/json" },
|
|
20
|
+
body: JSON.stringify(body),
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The pending permission's summary (e.g. "bash: bun test"), fetched from the hub, never carried by the push. */
|
|
25
|
+
async function permissionSummary(sessionID, requestID) {
|
|
26
|
+
try {
|
|
27
|
+
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionID)}`, { credentials: "same-origin" })
|
|
28
|
+
if (!res.ok) return undefined
|
|
29
|
+
const session = await res.json()
|
|
30
|
+
const blocker = (session.blockers || []).find((b) => b && b.kind === "permission" && b.id === requestID)
|
|
31
|
+
return blocker ? String(blocker.summary || "a tool call") : undefined
|
|
32
|
+
} catch {
|
|
33
|
+
return undefined
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
self.addEventListener("push", (event) => {
|
|
38
|
+
let data = {}
|
|
39
|
+
try {
|
|
40
|
+
data = event.data ? event.data.json() : {}
|
|
41
|
+
} catch {
|
|
42
|
+
data = { title: "Hub", body: event.data ? event.data.text() : "" }
|
|
43
|
+
}
|
|
44
|
+
const sessionID = typeof data.sessionID === "string" ? data.sessionID : ""
|
|
45
|
+
const kind = typeof data.kind === "string" ? data.kind : "update"
|
|
46
|
+
const requestID = typeof data.requestID === "string" ? data.requestID : ""
|
|
47
|
+
event.waitUntil(
|
|
48
|
+
(async () => {
|
|
49
|
+
let body = data.body || ""
|
|
50
|
+
let actions = [{ action: "open", title: "Open" }]
|
|
51
|
+
// Answer from the notification only when the hub confirms the request is pending and we can show what it is.
|
|
52
|
+
if (kind === "permission" && sessionID && requestID) {
|
|
53
|
+
const summary = await permissionSummary(sessionID, requestID)
|
|
54
|
+
if (summary) {
|
|
55
|
+
body = summary.length > 140 ? `${summary.slice(0, 139)}…` : summary
|
|
56
|
+
actions = [
|
|
57
|
+
{ action: "allow-once", title: "Allow once" },
|
|
58
|
+
{ action: "reject", title: "Reject" },
|
|
59
|
+
]
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
await self.registration.showNotification(data.title || "Hub", {
|
|
63
|
+
body,
|
|
64
|
+
tag: `${sessionID}:${kind}`,
|
|
65
|
+
renotify: kind !== "finished",
|
|
66
|
+
requireInteraction: STICKY.has(kind),
|
|
67
|
+
vibrate: VIBRATE[kind] || [100],
|
|
68
|
+
icon: "/icon-192.png",
|
|
69
|
+
badge: "/icon-192.png",
|
|
70
|
+
timestamp: Date.now(),
|
|
71
|
+
actions,
|
|
72
|
+
data: { url: sessionURL(sessionID), sessionID, kind, requestID },
|
|
73
|
+
})
|
|
74
|
+
})(),
|
|
75
|
+
)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
async function focusOrOpen(url) {
|
|
79
|
+
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true })
|
|
80
|
+
const client = windows.find((c) => new URL(c.url).origin === self.location.origin)
|
|
81
|
+
if (client) {
|
|
82
|
+
await client.focus()
|
|
83
|
+
// The page routes in place (keeps its SSE connection and audio unlock).
|
|
84
|
+
if (client.url !== url) client.postMessage({ type: "navigate", url })
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
await self.clients.openWindow(url)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Ends Voice mode: tells an open page, or (no page) turns conversation mode off at the hub itself. */
|
|
91
|
+
async function endVoice(sessionID) {
|
|
92
|
+
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true })
|
|
93
|
+
const pages = windows.filter((c) => new URL(c.url).origin === self.location.origin)
|
|
94
|
+
if (pages.length) {
|
|
95
|
+
for (const page of pages) page.postMessage({ type: "voice-end", sessionID })
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
if (!sessionID) return
|
|
99
|
+
const res = await postJSON(`/api/sessions/${encodeURIComponent(sessionID)}/conversation`, { on: false }).catch(() => undefined)
|
|
100
|
+
if (!res || !res.ok) await self.clients.openWindow(sessionURL(sessionID))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
self.addEventListener("notificationclick", (event) => {
|
|
104
|
+
const note = event.notification
|
|
105
|
+
const data = note.data || {}
|
|
106
|
+
const url = new URL(data.url || "/", self.location.origin).href
|
|
107
|
+
note.close()
|
|
108
|
+
event.waitUntil(
|
|
109
|
+
(async () => {
|
|
110
|
+
// The ongoing "Voice mode on" notification (shown by the page): End or Open.
|
|
111
|
+
if (data.kind === "voice") {
|
|
112
|
+
if (event.action === "end") return endVoice(data.sessionID)
|
|
113
|
+
return focusOrOpen(url)
|
|
114
|
+
}
|
|
115
|
+
if ((event.action === "allow-once" || event.action === "reject") && data.sessionID && data.requestID) {
|
|
116
|
+
const decision = event.action === "allow-once" ? "once" : "reject"
|
|
117
|
+
const res = await postJSON(`/api/sessions/${encodeURIComponent(data.sessionID)}/permission`, { requestID: data.requestID, decision }).catch(() => undefined)
|
|
118
|
+
if (res && res.ok) return
|
|
119
|
+
// Not answered (signed out, already answered, hub down): show the session instead.
|
|
120
|
+
return focusOrOpen(url)
|
|
121
|
+
}
|
|
122
|
+
return focusOrOpen(url)
|
|
123
|
+
})(),
|
|
124
|
+
)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
function urlBase64ToBytes(base64url) {
|
|
128
|
+
const pad = "=".repeat((4 - (base64url.length % 4)) % 4)
|
|
129
|
+
const raw = atob((base64url + pad).replace(/-/g, "+").replace(/_/g, "/"))
|
|
130
|
+
return Uint8Array.from(raw, (c) => c.charCodeAt(0))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// The browser replaced (or expired) the subscription: subscribe again and tell the hub, dropping the old one.
|
|
134
|
+
self.addEventListener("pushsubscriptionchange", (event) => {
|
|
135
|
+
event.waitUntil(
|
|
136
|
+
(async () => {
|
|
137
|
+
const old = event.oldSubscription
|
|
138
|
+
let sub = event.newSubscription
|
|
139
|
+
if (!sub) {
|
|
140
|
+
const res = await fetch("/api/push/key", { credentials: "same-origin" })
|
|
141
|
+
if (!res.ok) return
|
|
142
|
+
const { publicKey } = await res.json()
|
|
143
|
+
sub = await self.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToBytes(publicKey) })
|
|
144
|
+
}
|
|
145
|
+
await postJSON("/api/push/subscribe", { subscription: sub.toJSON(), ...(old ? { oldEndpoint: old.endpoint } : {}) })
|
|
146
|
+
})().catch(() => {}),
|
|
147
|
+
)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
// Pass-through fetch handler (keeps the app installable); never caches.
|
|
151
|
+
self.addEventListener("fetch", () => {})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// AudioWorklet for conversation mode: batches 128-sample render quanta into ~20 ms mono frames and posts
|
|
2
|
+
// them to the page, where web/conversation-core.js runs the VAD. Loaded with audioWorklet.addModule().
|
|
3
|
+
class VadTap extends AudioWorkletProcessor {
|
|
4
|
+
constructor() {
|
|
5
|
+
super()
|
|
6
|
+
this.size = Math.max(128, Math.round(sampleRate * 0.02))
|
|
7
|
+
this.buffer = new Float32Array(this.size)
|
|
8
|
+
this.filled = 0
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
process(inputs) {
|
|
12
|
+
const channel = inputs[0]?.[0]
|
|
13
|
+
if (channel) {
|
|
14
|
+
for (let i = 0; i < channel.length; i++) {
|
|
15
|
+
this.buffer[this.filled++] = channel[i]
|
|
16
|
+
if (this.filled === this.size) {
|
|
17
|
+
this.port.postMessage(this.buffer, [this.buffer.buffer])
|
|
18
|
+
this.buffer = new Float32Array(this.size)
|
|
19
|
+
this.filled = 0
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return true
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
registerProcessor("vad-tap", VadTap)
|