@huynle/hub 0.9.2 → 0.10.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.
Files changed (141) hide show
  1. package/CHANGELOG.md +320 -0
  2. package/Dockerfile +5 -1
  3. package/README.md +154 -77
  4. package/package.json +7 -4
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/cli/dist/hub-cli.js +534 -261
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/cli.ts +36 -1
  9. package/packages/cli/src/subscriptions.ts +49 -0
  10. package/packages/codex/package.json +1 -1
  11. package/packages/codex/src/sessions.ts +10 -6
  12. package/packages/codex/src/spoke.ts +2 -1
  13. package/packages/node/package.json +1 -1
  14. package/packages/preset/package.json +1 -1
  15. package/packages/protocol/package.json +1 -1
  16. package/packages/protocol/schema/app-delivery/v1.schema.json +7 -5
  17. package/packages/protocol/schema/app-front/v1.schema.json +410 -0
  18. package/packages/protocol/src/app-front.ts +293 -0
  19. package/packages/protocol/src/concierge.ts +10 -1
  20. package/packages/protocol/src/context-cards.ts +59 -0
  21. package/packages/protocol/src/credentials.ts +39 -2
  22. package/packages/protocol/src/index.ts +81 -11
  23. package/packages/runtime-openshell/package.json +1 -1
  24. package/packages/runtime-sdk/package.json +1 -1
  25. package/packages/sdk/package.json +1 -1
  26. package/packages/sdk/src/app-front.ts +524 -0
  27. package/packages/sdk/src/client.ts +37 -4
  28. package/packages/sdk/src/envelope.ts +9 -2
  29. package/packages/sdk/src/errors.ts +1 -0
  30. package/packages/sdk/src/index.ts +1 -0
  31. package/packages/sdk/src/types.ts +39 -1
  32. package/packages/spoke-sdk/package.json +1 -1
  33. package/packages/spoke-sdk/src/delivery.ts +17 -1
  34. package/packages/spoke-sdk/src/harness-spoke.ts +4 -3
  35. package/packages/voice-client/README.md +146 -0
  36. package/packages/voice-client/src/vad/LICENSE +21 -0
  37. package/src/central/agent/acting-routes.ts +17 -3
  38. package/src/central/agent/acting.ts +12 -3
  39. package/src/central/agent/actor.ts +6 -1
  40. package/src/central/agent/app-decisions.ts +146 -0
  41. package/src/central/agent/app-tokens.ts +9 -3
  42. package/src/central/agent/audit.ts +3 -0
  43. package/src/central/agent/connect-routes.ts +64 -0
  44. package/src/central/agent/connect.ts +868 -0
  45. package/src/central/agent/conversation.ts +625 -0
  46. package/src/central/agent/decisions.ts +71 -6
  47. package/src/central/agent/delivery.ts +3 -0
  48. package/src/central/agent/directory.ts +5 -0
  49. package/src/central/agent/errors.ts +2 -0
  50. package/src/central/agent/grant-requests.ts +46 -9
  51. package/src/central/agent/grants.ts +13 -2
  52. package/src/central/agent/message-limits.ts +2 -2
  53. package/src/central/agent/message-routes.ts +13 -4
  54. package/src/central/agent/read-routes.ts +16 -2
  55. package/src/central/agent/routes.ts +108 -6
  56. package/src/central/agent/speech-routes.ts +316 -0
  57. package/src/central/agent/subscription-routes.ts +174 -0
  58. package/src/central/agent/subscriptions.ts +980 -0
  59. package/src/central/agent-data.ts +43 -1
  60. package/src/central/backend/memory-counters.ts +2 -2
  61. package/src/central/backend/types.ts +5 -2
  62. package/src/central/backend/valkey-counters.ts +5 -4
  63. package/src/central/client-address.ts +16 -0
  64. package/src/central/concierge/board-render.ts +52 -1
  65. package/src/central/concierge/board-routes.ts +25 -2
  66. package/src/central/concierge/board.ts +11 -1
  67. package/src/central/concierge/context.ts +29 -8
  68. package/src/central/concierge/grant-request.ts +216 -0
  69. package/src/central/concierge/prompt.ts +49 -2
  70. package/src/central/concierge/session-routes.ts +3 -0
  71. package/src/central/config.ts +64 -3
  72. package/src/central/context-cards/budget.ts +121 -0
  73. package/src/central/context-cards/changes.ts +29 -0
  74. package/src/central/context-cards/config.ts +145 -0
  75. package/src/central/context-cards/engine.ts +379 -0
  76. package/src/central/context-cards/inputs.ts +111 -0
  77. package/src/central/context-cards/pickup.ts +134 -0
  78. package/src/central/context-cards/registry.ts +89 -0
  79. package/src/central/context-cards/routes.ts +37 -0
  80. package/src/central/context-cards/service.ts +367 -0
  81. package/src/central/context-cards/store.ts +67 -0
  82. package/src/central/context-cards/types.ts +88 -0
  83. package/src/central/fetcher.ts +10 -2
  84. package/src/central/front-relays.ts +423 -0
  85. package/src/central/http.ts +547 -58
  86. package/src/central/human-activity.ts +453 -0
  87. package/src/central/llm/chat.ts +133 -0
  88. package/src/central/registry.ts +162 -9
  89. package/src/central/speech/service.ts +9 -0
  90. package/src/central/spoke-ws.ts +13 -0
  91. package/src/central/store.ts +9 -12
  92. package/src/config.ts +30 -13
  93. package/src/index.ts +45 -43
  94. package/src/rpc.ts +33 -4
  95. package/src/spoke/concierge-agent.ts +1 -1
  96. package/src/spoke/conversation.ts +6 -75
  97. package/src/spoke/deliver.ts +18 -8
  98. package/src/spoke/hub-rpc.ts +7 -2
  99. package/src/spoke/hub-tool-subscriptions.ts +82 -0
  100. package/src/spoke/hub-tool-title.ts +89 -0
  101. package/src/spoke/hub-tool.ts +56 -18
  102. package/src/spoke/session-context.ts +10 -2
  103. package/src/spoke/spoke.ts +227 -237
  104. package/src/spoke/summary.ts +36 -1
  105. package/src/spoke/taskboard.ts +5 -6
  106. package/src/spoke/voice-front.ts +323 -30
  107. package/src/spoke/who-teams.ts +51 -2
  108. package/src/tui-messaging.ts +24 -10
  109. package/src/tui.ts +6 -6
  110. package/web/dist/assets/{ArtifactPage-BKISN-Qj.js → ArtifactPage-CsPjam8D.js} +1 -1
  111. package/web/dist/assets/{ArtifactPane-CKOiAUNH.js → ArtifactPane-B9weO8L6.js} +1 -1
  112. package/web/dist/assets/ArtifactWorkbench-DzKhMs3l.js +6 -0
  113. package/web/dist/assets/{Diagram-vdpmp3xu.css → Diagram-7y8kg3og.css} +1 -1
  114. package/web/dist/assets/Diagram-CuRIffka.js +1 -0
  115. package/web/dist/assets/{Library-CcjZXsfF.js → Library-rv5DGtrm.js} +2 -2
  116. package/web/dist/assets/Lightbox-BlbivCkd.js +1 -0
  117. package/web/dist/assets/MediaImage-Ch3ryIdR.js +4 -0
  118. package/web/dist/assets/ProfilesDestination-DUuQ9jmd.js +1 -0
  119. package/web/dist/assets/{TeamsPage-CDyjrK-I.js → TeamsPage-D_17MkvN.js} +1 -1
  120. package/web/dist/assets/{Watchtower-DYwoUWME.js → Watchtower-DlrNOJ7X.js} +2 -2
  121. package/web/dist/assets/{artifacts-DU9uRgs1.js → artifacts-But87Jfo.js} +1 -1
  122. package/web/dist/assets/index-bX8B6NLo.js +33 -0
  123. package/web/dist/assets/index-rW6LKQj1.css +1 -0
  124. package/web/dist/assets/{live-ycZ_aQZO.js → live-BGLA-peO.js} +2 -2
  125. package/web/dist/assets/{pane-layout-0RrLHGDr.js → pane-layout-BW0RdX5a.js} +1 -1
  126. package/web/dist/assets/{renderer2d-BIwbgRoX.js → renderer2d-DyANQ1WS.js} +1 -1
  127. package/web/dist/assets/{renderer3d-C1a7eYGQ.js → renderer3d-vYYKFAJq.js} +1 -1
  128. package/web/dist/assets/{safe-area-DHhPy61Q.js → safe-area-B2Bk__FK.js} +1 -1
  129. package/web/dist/assets/{source-BWlvaODa.js → source-C-De8L3s.js} +1 -1
  130. package/web/dist/assets/toast-CXTwTvDw.js +9 -0
  131. package/web/dist/assets/vad-worklet-BfxJhrXy.js +27 -0
  132. package/web/dist/index.html +6 -6
  133. package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +0 -6
  134. package/web/dist/assets/Diagram-azw2B_QH.js +0 -1
  135. package/web/dist/assets/Lightbox-Du56MXGX.js +0 -1
  136. package/web/dist/assets/MediaImage-Ds_JUx3a.js +0 -4
  137. package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +0 -1
  138. package/web/dist/assets/index-C3ci5UtY.css +0 -1
  139. package/web/dist/assets/index-LNMZCqgE.js +0 -36
  140. package/web/dist/assets/toast-Cuu2K0hx.js +0 -1
  141. /package/{web/src/voice → packages/voice-client/src}/turn/LICENSE +0 -0
@@ -0,0 +1,453 @@
1
+ // Session findability (Brain decisions iaspcry7, qqmworx1): per user and root session, the user's last action (a
2
+ // prompt, Voice, an answer) and last explicit open, plus a small histogram of human and agent activity, so the console
3
+ // can find "the sessions I work with" among hundreds (Touched by me, the sidebar's Working set, the activity meter).
4
+ //
5
+ // Feeds (wired in http.ts; every session ID here is a root session's):
6
+ // human(…, "prompt-web") a prompt the hub carried for the user (console Send, /api/agent prompt by the user); never
7
+ // a launch's first prompt (intent "first") or a Concierge proposal (intent false)
8
+ // human(…, "prompt-tui") a prompt typed in the terminal: Registry.onTerminalPrompt (exact: an OpenCode spoke marks
9
+ // the user items the hub carried, `carried`; other harnesses report the hub's prompts as
10
+ // origin "hub"; an older OpenCode spoke's user items are not counted, never guessed)
11
+ // human(…, "voice") a Voice utterance
12
+ // human(…, "answer") the user's permission or form answer on a card
13
+ // human(…, "view") an explicit open (POST /api/sessions/:id/read {open: true}): moves `last`, never counted,
14
+ // and never replaces an action as what the session shows
15
+ // agent(…, turn | tool) a turn started (session.execution.started) or a tool was called
16
+ //
17
+ // Storage: StateStore collection `human-activity` (tenant = user, key = root session ID):
18
+ // {a?, k?, v?, b: [[bucketStart, human, turns, tools], …], u}
19
+ // `a`/`k`: the last action and its kind; `v`: the last open; `b`: 30-minute buckets, the last 48 (24 h); `u`: the last
20
+ // update. What a session shows (at, kind): its last action while it is within a day of the latest touch, else the
21
+ // open. Records not updated for 30 days are left out of reads and deleted by a background sweep (at most hourly per
22
+ // user), and at most 2000 are kept per user.
23
+ //
24
+ // Writes are fire-and-forget: recording never waits on the store (a console prompt's request does not). Events are
25
+ // aggregated in memory per session and written at most once a minute per session by this process (an action of a
26
+ // session whose last write was not one, or the first touch, goes out at once); agent counts wait for the throttle
27
+ // (a timer). A write is a version-checked read-modify-write, so pods add their counts. A failed write (the store, or
28
+ // five version conflicts in a row) puts the activity back in the queue and backs the session off (30 s, doubling to
29
+ // 10 min). At most `inFlight` store operations are pending at once. A touch written out at once takes no buffer slot.
30
+ // lastAt (the context cards' gate) gives up on the store after readTimeoutMs: unknown, so not active.
31
+ import type { Actions, PromptInput } from "./actions"
32
+ import type { Clock, StateStore } from "./backend/types"
33
+
34
+ export const HUMAN_ACTIVITY_COLLECTION = "human-activity"
35
+
36
+ export const HUMAN_ACTIVITY_LIMITS = {
37
+ /** Histogram bucket width. */
38
+ bucketMs: 30 * 60_000,
39
+ /** Buckets kept per session (48 × 30 min = 24 h). */
40
+ buckets: 48,
41
+ /** At most one write per session per this long (the first action after an open excepted). */
42
+ writeEveryMs: 60_000,
43
+ /** How often the timer looks for buffered sessions due a write. */
44
+ flushEveryMs: 15_000,
45
+ /** Records not updated for this long are dropped. */
46
+ keepMs: 30 * 86_400_000,
47
+ /** Records kept per user (the least recently updated go first). */
48
+ perUser: 2000,
49
+ /** Sessions this process buffers or remembers (oldest dropped first). */
50
+ tracked: 10_000,
51
+ /** lastAt's store read gives up after this long (a hanging store never blocks the context cards' gate). */
52
+ readTimeoutMs: 2_000,
53
+ /** Store operations pending at once; past it, writes wait in the queue. */
54
+ inFlight: 100,
55
+ /** A user's records are swept at most this often. */
56
+ sweepEveryMs: 3_600_000,
57
+ /** An action keeps speaking for its session while it is within this long of the latest touch. */
58
+ actionShownMs: 86_400_000,
59
+ /** Backoff after a failed write (doubling per failure, up to maxRetryMs). */
60
+ retryMs: 30_000,
61
+ maxRetryMs: 600_000,
62
+ } as const
63
+
64
+ const L = HUMAN_ACTIVITY_LIMITS
65
+
66
+ export const HUMAN_KINDS = ["prompt-tui", "prompt-web", "voice", "answer", "view"] as const
67
+ export type HumanKind = (typeof HUMAN_KINDS)[number]
68
+ type ActionKind = Exclude<HumanKind, "view">
69
+
70
+ /** [bucket start (ms), human actions, agent turns, agent tool calls]. */
71
+ export type Bucket = [number, number, number, number]
72
+
73
+ /** What the console gets per session (GET /api/activity/mine, SSE `activity`). */
74
+ export interface ActivityEntry {
75
+ /** What the session shows: the last action (within a day of `last`), else the last open. */
76
+ at?: number
77
+ kind?: HumanKind
78
+ /** The latest touch of any kind (sorts and filters). */
79
+ last?: number
80
+ buckets: Bucket[]
81
+ }
82
+
83
+ interface ActivityRecord {
84
+ readonly a?: number
85
+ readonly k?: ActionKind
86
+ readonly v?: number
87
+ readonly b: Bucket[]
88
+ readonly u: number
89
+ }
90
+
91
+ interface Pending {
92
+ readonly userID: string
93
+ readonly sessionID: string
94
+ acted?: number
95
+ actKind?: ActionKind
96
+ viewed?: number
97
+ /** bucket start → [human, turns, tools] */
98
+ readonly deltas: Map<number, [number, number, number]>
99
+ }
100
+
101
+ export interface HumanActivityOptions {
102
+ readonly state: StateStore
103
+ readonly now?: Clock
104
+ /** How often the timer looks for due writes (default flushEveryMs). */
105
+ readonly flushEveryMs?: number
106
+ /** At most one write per session per this long (default writeEveryMs; tests shorten it). */
107
+ readonly writeEveryMs?: number
108
+ /** Sessions buffered at once (default tracked; tests shorten it). */
109
+ readonly tracked?: number
110
+ /** lastAt gives up on a store read after this long (default readTimeoutMs): unknown, not active. */
111
+ readonly readTimeoutMs?: number
112
+ /** A session's record changed (after the write): the owner's consoles get it. */
113
+ readonly onChange?: (userID: string, sessionID: string, entry: ActivityEntry) => void
114
+ }
115
+
116
+ const slotOf = (at: number) => Math.floor(at / L.bucketMs) * L.bucketMs
117
+ const isAction = (kind: HumanKind): kind is ActionKind => kind !== "view"
118
+
119
+ export class HumanActivity {
120
+ private readonly state: StateStore
121
+ private readonly now: Clock
122
+ private readonly onChange: HumanActivityOptions["onChange"]
123
+ private readonly flushEveryMs: number
124
+ private readonly writeEveryMs: number
125
+ private readonly tracked: number
126
+ private readonly readTimeoutMs: number
127
+ private readonly pending = new Map<string, Pending>()
128
+ /** `userID\nsessionID` → when this process last wrote it and whether that write held an action. */
129
+ private readonly written = new Map<string, { at: number; action: boolean }>()
130
+ /** `userID\nsessionID` → backoff after failed writes. */
131
+ private readonly backoff = new Map<string, { until: number; failures: number }>()
132
+ private readonly swept = new Map<string, number>()
133
+ private readonly running = new Set<Promise<unknown>>()
134
+ private readonly touchListeners = new Set<(userID: string, sessionID: string, kind: HumanKind) => void>()
135
+ private inFlight = 0
136
+ private timer: ReturnType<typeof setInterval> | undefined
137
+ private lastLogged = Number.NEGATIVE_INFINITY
138
+
139
+ constructor(options: HumanActivityOptions) {
140
+ this.state = options.state
141
+ this.now = options.now ?? Date.now
142
+ this.onChange = options.onChange
143
+ this.flushEveryMs = options.flushEveryMs ?? L.flushEveryMs
144
+ this.writeEveryMs = options.writeEveryMs ?? L.writeEveryMs
145
+ this.tracked = options.tracked ?? L.tracked
146
+ this.readTimeoutMs = options.readTimeoutMs ?? L.readTimeoutMs
147
+ }
148
+
149
+ /** The user touched the (root) session. Queued: never waits on the store, never throws. */
150
+ human(userID: string, sessionID: string, kind: HumanKind): void {
151
+ try {
152
+ const now = this.now()
153
+ const key = this.key(userID, sessionID)
154
+ const last = this.written.get(key)
155
+ const due = !last || now - last.at >= this.writeEveryMs || (isAction(kind) && !last.action)
156
+ // Written out at once (flush takes it out of the buffer before its first await): it needs no buffer slot.
157
+ const p = this.pendingOf(userID, sessionID, due && this.writable(key, now) ? "passing" : "human")
158
+ if (!p) return
159
+ if (isAction(kind)) {
160
+ p.acted = now
161
+ p.actKind = kind
162
+ this.count(p, now, 0)
163
+ } else p.viewed = now
164
+ if (due) this.track(this.flush(key))
165
+ for (const listener of [...this.touchListeners]) listener(userID, sessionID, kind)
166
+ } catch (cause) {
167
+ this.log(cause)
168
+ }
169
+ }
170
+
171
+ /** Listens to every human touch as it is recorded (the context cards' user-return). Returns an unsubscribe. */
172
+ onTouch(listener: (userID: string, sessionID: string, kind: HumanKind) => void): () => void {
173
+ this.touchListeners.add(listener)
174
+ return () => this.touchListeners.delete(listener)
175
+ }
176
+
177
+ /** When the user last touched the (root) session: buffered here, else persisted (`last`); undefined when never. */
178
+ async lastAt(userID: string, sessionID: string): Promise<number | undefined> {
179
+ const p = this.pending.get(this.key(userID, sessionID))
180
+ const buffered = Math.max(p?.acted ?? Number.NEGATIVE_INFINITY, p?.viewed ?? Number.NEGATIVE_INFINITY)
181
+ const stored = await this.storedRecord(userID, sessionID)
182
+ const last = stored && typeof stored.u === "number" && this.now() - stored.u <= L.keepMs ? entryOf(stored, this.now()).last : undefined
183
+ const at = Math.max(buffered, last ?? Number.NEGATIVE_INFINITY)
184
+ return Number.isFinite(at) ? at : undefined
185
+ }
186
+
187
+ /** The stored record, or undefined when the store fails or does not answer within readTimeoutMs (unknown). */
188
+ private async storedRecord(userID: string, sessionID: string): Promise<ActivityRecord | undefined> {
189
+ let timer: ReturnType<typeof setTimeout> | undefined
190
+ const late = new Promise<undefined>((resolve) => (timer = setTimeout(() => (this.log(new Error(`activity read of ${sessionID} took over ${this.readTimeoutMs} ms`)), resolve(undefined)), this.readTimeoutMs)))
191
+ try {
192
+ return await Promise.race([this.state.get<ActivityRecord>(userID, HUMAN_ACTIVITY_COLLECTION, sessionID).then((r) => r?.value), late])
193
+ } catch (cause) {
194
+ this.log(cause)
195
+ return undefined
196
+ } finally {
197
+ clearTimeout(timer)
198
+ }
199
+ }
200
+
201
+ /** The agent worked in the session: counted in memory, written by the throttle. */
202
+ agent(userID: string, sessionID: string, what: "turn" | "tool"): void {
203
+ const p = this.pendingOf(userID, sessionID)
204
+ if (p) this.count(p, this.now(), what === "turn" ? 1 : 2)
205
+ }
206
+
207
+ /** Waits until every queued write and sweep has finished (stop, tests). */
208
+ async settled(): Promise<void> {
209
+ while (this.running.size) await Promise.allSettled([...this.running])
210
+ }
211
+
212
+ /** Writes the buffered sessions whose throttle (and backoff) has passed, and waits for them. */
213
+ async flushDue(): Promise<void> {
214
+ const now = this.now()
215
+ for (const key of [...this.pending.keys()]) {
216
+ const last = this.written.get(key)
217
+ if (!last || now - last.at >= this.writeEveryMs) this.track(this.flush(key))
218
+ }
219
+ await this.settled()
220
+ }
221
+
222
+ /** Writes everything buffered (on stop), backoffs aside. */
223
+ async flushAll(): Promise<void> {
224
+ this.backoff.clear()
225
+ for (const key of [...this.pending.keys()]) this.track(this.flush(key))
226
+ await this.settled()
227
+ }
228
+
229
+ start(): void {
230
+ if (this.timer) return
231
+ this.timer = setInterval(() => void this.flushDue().catch((cause) => this.log(cause)), this.flushEveryMs)
232
+ ;(this.timer as { unref?: () => void }).unref?.()
233
+ }
234
+
235
+ /** Stops the timer and writes what is buffered, giving up after `timeoutMs` (a store that hangs never holds shutdown). */
236
+ async stop(timeoutMs = 5_000): Promise<void> {
237
+ if (this.timer) clearInterval(this.timer)
238
+ this.timer = undefined
239
+ let timer: ReturnType<typeof setTimeout> | undefined
240
+ const late = new Promise<void>((resolve) => (timer = setTimeout(() => (this.log(new Error(`not all activity written within ${timeoutMs} ms of stop`)), resolve()), timeoutMs)))
241
+ await Promise.race([this.flushAll().catch((cause) => this.log(cause)), late])
242
+ clearTimeout(timer)
243
+ }
244
+
245
+ /** The user's sessions touched within 30 days, with buckets of the last 24 h. Reads only: expired ones are swept later. */
246
+ async list(userID: string): Promise<Record<string, ActivityEntry>> {
247
+ const now = this.now()
248
+ const { live, stale } = await this.scan(userID, now)
249
+ if (stale) this.track(this.sweepThrottled(userID, now))
250
+ const out: Record<string, ActivityEntry> = {}
251
+ for (const { key, value } of live) out[key] = entryOf(value, now)
252
+ return out
253
+ }
254
+
255
+ /** Deletes the user's expired records, and the least recently updated past perUser. */
256
+ async sweep(userID: string): Promise<void> {
257
+ const { drop } = await this.scan(userID, this.now())
258
+ for (const { key, version } of drop) await this.state.delete(userID, HUMAN_ACTIVITY_COLLECTION, key, { ifVersion: version }).catch(() => {})
259
+ }
260
+
261
+ private async sweepThrottled(userID: string, now: number): Promise<void> {
262
+ const last = this.swept.get(userID)
263
+ if (last !== undefined && now - last < L.sweepEveryMs) return
264
+ remember(this.swept, userID, now, L.tracked)
265
+ await this.sweep(userID).catch((cause) => this.log(cause))
266
+ }
267
+
268
+ private async scan(userID: string, now: number): Promise<{ live: Array<{ key: string; value: ActivityRecord }>; drop: Array<{ key: string; version: number }>; stale: boolean }> {
269
+ const cutoff = now - L.keepMs
270
+ const live: Array<{ key: string; version: number; value: ActivityRecord }> = []
271
+ const drop: Array<{ key: string; version: number }> = []
272
+ for (const entry of await this.state.list<ActivityRecord>(userID, HUMAN_ACTIVITY_COLLECTION)) {
273
+ const u = entry.value?.u
274
+ if (typeof u === "number" && u >= cutoff && Array.isArray(entry.value.b)) live.push(entry)
275
+ else drop.push(entry)
276
+ }
277
+ live.sort((a, b) => b.value.u - a.value.u)
278
+ if (live.length > L.perUser) drop.push(...live.splice(L.perUser))
279
+ return { live, drop, stale: drop.length > 0 }
280
+ }
281
+
282
+ private track(work: Promise<unknown>): void {
283
+ const done: Promise<unknown> = work.catch((cause) => this.log(cause)).finally(() => this.running.delete(done))
284
+ this.running.add(done)
285
+ }
286
+
287
+ private key(userID: string, sessionID: string): string {
288
+ return `${userID}\n${sessionID}`
289
+ }
290
+
291
+ /**
292
+ * The session's buffer entry. When the buffer is full, a human touch that stays buffered takes the place of the
293
+ * oldest entry that holds only agent work (its counts are lost); agent work, or a human touch with no such entry, is
294
+ * dropped. A "passing" touch (written out at once, so it leaves the buffer right away) evicts nothing.
295
+ */
296
+ private pendingOf(userID: string, sessionID: string, mode: "agent" | "human" | "passing" = "agent"): Pending | undefined {
297
+ const key = this.key(userID, sessionID)
298
+ let p = this.pending.get(key)
299
+ if (!p) {
300
+ if (this.pending.size >= this.tracked && mode !== "passing") {
301
+ if (mode === "agent") return undefined
302
+ const agentOnly = [...this.pending].find(([, q]) => q.acted === undefined && q.viewed === undefined)
303
+ if (!agentOnly) return undefined
304
+ this.pending.delete(agentOnly[0])
305
+ }
306
+ p = { userID, sessionID, deltas: new Map() }
307
+ this.pending.set(key, p)
308
+ }
309
+ return p
310
+ }
311
+
312
+ private count(p: Pending, at: number, lane: 0 | 1 | 2): void {
313
+ const slot = slotOf(at)
314
+ const counts = p.deltas.get(slot) ?? [0, 0, 0]
315
+ counts[lane]++
316
+ p.deltas.set(slot, counts)
317
+ }
318
+
319
+ /** flush would write the session now (no backoff, the store not busy) rather than leave it queued. */
320
+ private writable(key: string, now: number): boolean {
321
+ const backoff = this.backoff.get(key)
322
+ return !(backoff && now < backoff.until) && this.inFlight < L.inFlight
323
+ }
324
+
325
+ /** Writes one session's buffered activity, merged into the stored record; on failure it goes back to the queue. */
326
+ private async flush(key: string): Promise<void> {
327
+ const p = this.pending.get(key)
328
+ if (!p) return
329
+ const now = this.now()
330
+ if (!this.writable(key, now)) return // backing off, or the store is busy: stays queued for the timer
331
+ this.pending.delete(key)
332
+ remember(this.written, key, { at: now, action: p.acted !== undefined || this.written.get(key)?.action === true }, L.tracked)
333
+ this.inFlight++
334
+ let wrote = false
335
+ try {
336
+ for (let attempt = 0; attempt < 5 && !wrote; attempt++) {
337
+ const stored = await this.state.get<ActivityRecord>(p.userID, HUMAN_ACTIVITY_COLLECTION, p.sessionID)
338
+ const next = merge(stored?.value, p, now)
339
+ const result = await this.state.put<ActivityRecord>(p.userID, HUMAN_ACTIVITY_COLLECTION, p.sessionID, next, { ifVersion: stored?.version ?? 0 })
340
+ if (!result.ok) continue
341
+ wrote = true
342
+ const written = this.written.get(key)
343
+ if (written && next.a !== undefined) written.action = true
344
+ this.backoff.delete(key)
345
+ this.onChange?.(p.userID, p.sessionID, entryOf(next, now))
346
+ }
347
+ if (!wrote) throw new Error(`version conflicts on ${p.sessionID}`)
348
+ } catch (cause) {
349
+ this.requeue(key, p, now)
350
+ this.log(cause)
351
+ } finally {
352
+ this.inFlight--
353
+ }
354
+ if (wrote) this.track(this.sweepThrottled(p.userID, now))
355
+ }
356
+
357
+ /** Puts a failed write's activity back (merged with what came meanwhile) and backs the session off. */
358
+ private requeue(key: string, failed: Pending, now: number): void {
359
+ this.written.delete(key)
360
+ const failures = (this.backoff.get(key)?.failures ?? 0) + 1
361
+ remember(this.backoff, key, { until: now + Math.min(L.maxRetryMs, L.retryMs * 2 ** (failures - 1)), failures }, L.tracked)
362
+ const p = this.pendingOf(failed.userID, failed.sessionID, failed.acted !== undefined || failed.viewed !== undefined ? "human" : "agent")
363
+ if (!p) return
364
+ if (failed.acted !== undefined && (p.acted === undefined || failed.acted > p.acted)) {
365
+ p.acted = failed.acted
366
+ p.actKind = failed.actKind
367
+ }
368
+ if (failed.viewed !== undefined && (p.viewed === undefined || failed.viewed > p.viewed)) p.viewed = failed.viewed
369
+ for (const [slot, [h, t, x]] of failed.deltas) {
370
+ const counts = p.deltas.get(slot) ?? [0, 0, 0]
371
+ counts[0] += h
372
+ counts[1] += t
373
+ counts[2] += x
374
+ p.deltas.set(slot, counts)
375
+ }
376
+ }
377
+
378
+ private log(cause: unknown): void {
379
+ const now = Date.now()
380
+ if (now - this.lastLogged < 60_000) return
381
+ this.lastLogged = now
382
+ console.error(`[hub] human activity: ${cause instanceof Error ? cause.message : String(cause)} (kept queued, retried later)`)
383
+ }
384
+ }
385
+
386
+ /** The stored record with a session's buffered activity added. */
387
+ function merge(stored: ActivityRecord | undefined, p: Pending, now: number): ActivityRecord {
388
+ const buckets = new Map<number, Bucket>()
389
+ for (const b of Array.isArray(stored?.b) ? stored.b : []) if (Array.isArray(b) && b.length === 4) buckets.set(b[0], [...b] as Bucket)
390
+ for (const [slot, [h, t, x]] of p.deltas) {
391
+ const b = buckets.get(slot) ?? [slot, 0, 0, 0]
392
+ b[1] += h
393
+ b[2] += t
394
+ b[3] += x
395
+ buckets.set(slot, b)
396
+ }
397
+ let a = stored?.a
398
+ let k = stored?.k
399
+ if (p.acted !== undefined && (a === undefined || p.acted >= a)) {
400
+ a = p.acted
401
+ k = p.actKind
402
+ }
403
+ const v = Math.max(stored?.v ?? Number.NEGATIVE_INFINITY, p.viewed ?? Number.NEGATIVE_INFINITY)
404
+ return { ...(a !== undefined && k ? { a, k } : {}), ...(Number.isFinite(v) ? { v } : {}), b: recent([...buckets.values()], now), u: now }
405
+ }
406
+
407
+ /** The buckets of the last 24 h, oldest first. */
408
+ function recent(buckets: Bucket[], now: number): Bucket[] {
409
+ const from = slotOf(now) - (L.buckets - 1) * L.bucketMs
410
+ return buckets.filter((b) => b[0] >= from).sort((a, b) => a[0] - b[0])
411
+ }
412
+
413
+ function entryOf(record: ActivityRecord, now: number): ActivityEntry {
414
+ const a = typeof record.a === "number" && record.k && (HUMAN_KINDS as readonly string[]).includes(record.k) ? record.a : undefined
415
+ const v = typeof record.v === "number" ? record.v : undefined
416
+ const last = Math.max(a ?? Number.NEGATIVE_INFINITY, v ?? Number.NEGATIVE_INFINITY)
417
+ const shown: { at: number; kind: HumanKind } | undefined =
418
+ a !== undefined && (v === undefined || last - a <= L.actionShownMs) ? { at: a, kind: record.k! } : v !== undefined ? { at: v, kind: "view" } : undefined
419
+ return { ...(shown ? { at: shown.at, kind: shown.kind, last } : {}), buckets: recent(record.b.map((b) => [...b] as Bucket), now) }
420
+ }
421
+
422
+ function remember<K, V>(map: Map<K, V>, key: K, value: V, max: number): void {
423
+ map.delete(key)
424
+ map.set(key, value)
425
+ if (map.size > max) map.delete(map.keys().next().value!)
426
+ }
427
+
428
+ export interface HumanActivityRouting {
429
+ /** The session's owner (its tenant); undefined when unknown. */
430
+ readonly tenant: (sessionID: string) => string | undefined
431
+ /** The root of the session's tree (activity is per root session). */
432
+ readonly root: (sessionID: string) => string
433
+ }
434
+
435
+ /**
436
+ * The shared action executor with "touched by me": the user's own prompts the hub carries (the console's Send, a
437
+ * user's /api/agent prompt) are prompt-web actions on the root session, recorded once the prompt reached the session;
438
+ * a launch's first prompt (intent "first") and a Concierge proposal the user only approved (intent false) are not.
439
+ * The spoke's report of the same prompt is marked carried (or origin hub), so it is never counted again.
440
+ */
441
+ export function withHumanActivity<A extends Pick<Actions, "prompt">>(actions: A, activity: Pick<HumanActivity, "human">, routing: HumanActivityRouting): A {
442
+ return {
443
+ ...actions,
444
+ async prompt(input: PromptInput) {
445
+ const result = await actions.prompt(input)
446
+ if (result.outcome === "done" && input.intent !== "first" && input.intent !== false) {
447
+ const userID = routing.tenant(input.sessionID)
448
+ if (userID !== undefined) activity.human(userID, routing.root(input.sessionID), "prompt-web")
449
+ }
450
+ return result
451
+ },
452
+ }
453
+ }
@@ -0,0 +1,133 @@
1
+ // The hub's own small model client (context cards, decision 9604b0a1): ONE OpenAI-compatible chat completion
2
+ // (POST {baseURL}/chat/completions), deterministic (temperature 0), bounded (max_tokens, a timeout), optionally
3
+ // asking for a JSON object. Provider-agnostic: OpenRouter and any OpenAI-compatible server work. The key is read
4
+ // per call (settings changes apply at once) and never appears in errors. fetch is injectable (tests never touch
5
+ // the network).
6
+
7
+ /**
8
+ * What failed: "provider" (the model service: 401/403/408/429/5xx, network, timeout, no key), "request" (another 4xx:
9
+ * this request), "content" (the answer: none, or cut by the token limit). Only provider errors trip the hub breaker.
10
+ */
11
+ export type ChatErrorKind = "provider" | "request" | "content"
12
+
13
+ const PROVIDER_STATUSES: ReadonlySet<number> = new Set([401, 403, 408, 429])
14
+
15
+ export class ChatError extends Error {
16
+ readonly kind: ChatErrorKind
17
+ constructor(
18
+ message: string,
19
+ readonly status?: number,
20
+ /** What the failed call used, when the upstream said (counted against the budget). */
21
+ readonly usage?: ChatUsage,
22
+ kind?: ChatErrorKind,
23
+ ) {
24
+ super(message)
25
+ this.name = "ChatError"
26
+ this.kind = kind ?? (status === undefined || status >= 500 || PROVIDER_STATUSES.has(status) ? "provider" : "request")
27
+ }
28
+ }
29
+
30
+ export interface ChatRequest {
31
+ readonly model: string
32
+ readonly system: string
33
+ readonly user: string
34
+ readonly maxTokens: number
35
+ /** Ask for a JSON object (response_format json_object). */
36
+ readonly json?: boolean
37
+ }
38
+
39
+ export interface ChatUsage {
40
+ readonly inputTokens: number
41
+ readonly outputTokens: number
42
+ }
43
+
44
+ export interface ChatResult {
45
+ readonly text: string
46
+ /** The model that answered (the upstream's, else the one asked for). */
47
+ readonly model: string
48
+ readonly usage: ChatUsage
49
+ }
50
+
51
+ /** What the context cards need of a model: one completion. Tests inject fakes. */
52
+ export interface ChatClient {
53
+ complete(request: ChatRequest, signal?: AbortSignal): Promise<ChatResult>
54
+ }
55
+
56
+ export interface OpenAICompatibleChatOptions {
57
+ readonly baseURL: string
58
+ readonly apiKey: () => Promise<string | undefined>
59
+ readonly fetch?: typeof globalThis.fetch
60
+ /** Per request (default 30 s). */
61
+ readonly timeoutMs?: number
62
+ }
63
+
64
+ export const CHAT_TIMEOUT_MS = 30_000
65
+ /** Upstream error detail kept in a ChatError (bounded; keys scrubbed). */
66
+ const DETAIL_MAX = 300
67
+
68
+ export class OpenAICompatibleChat implements ChatClient {
69
+ private readonly fetch: typeof globalThis.fetch
70
+ private readonly baseURL: string
71
+
72
+ constructor(private readonly options: OpenAICompatibleChatOptions) {
73
+ this.fetch = options.fetch ?? globalThis.fetch
74
+ this.baseURL = options.baseURL.replace(/\/+$/, "")
75
+ }
76
+
77
+ async complete(request: ChatRequest, signal?: AbortSignal): Promise<ChatResult> {
78
+ const key = await this.options.apiKey()
79
+ if (!key) throw new ChatError("No API key for the context card model")
80
+ const timeout = AbortSignal.timeout(this.options.timeoutMs ?? CHAT_TIMEOUT_MS)
81
+ const send = (json: boolean) => this.send(request, key, json, signal ? AbortSignal.any([signal, timeout]) : timeout)
82
+ let response = await send(request.json === true)
83
+ // Some OpenAI-compatible servers refuse response_format: once more without it (the prompt still asks for JSON).
84
+ if (request.json && response.status === 400) {
85
+ await response.text().catch(() => "")
86
+ response = await send(false)
87
+ }
88
+ if (!response.ok) {
89
+ const detail = await response.text().catch(() => "")
90
+ throw new ChatError(`Model request failed (${response.status}): ${scrub(detail, key)}`, response.status)
91
+ }
92
+ const json = (await response.json().catch(() => undefined)) as
93
+ | { model?: unknown; choices?: Array<{ message?: { content?: unknown }; finish_reason?: unknown }>; usage?: { prompt_tokens?: unknown; completion_tokens?: unknown } }
94
+ | undefined
95
+ const usage = { inputTokens: count(json?.usage?.prompt_tokens), outputTokens: count(json?.usage?.completion_tokens) }
96
+ const choice = json?.choices?.[0]
97
+ const content = choice?.message?.content
98
+ if (typeof content !== "string") throw new ChatError("The model answered without a message", undefined, usage, "content")
99
+ // A reasoning model can spend every token before it answers.
100
+ if (!content.trim() && choice?.finish_reason === "length") throw new ChatError("The model ran out of tokens before answering", undefined, usage, "content")
101
+ return { text: content, model: typeof json?.model === "string" && json.model ? json.model : request.model, usage }
102
+ }
103
+
104
+ private async send(request: ChatRequest, key: string, json: boolean, signal: AbortSignal): Promise<Response> {
105
+ const body = {
106
+ model: request.model,
107
+ messages: [
108
+ { role: "system", content: request.system },
109
+ { role: "user", content: request.user },
110
+ ],
111
+ max_tokens: request.maxTokens,
112
+ temperature: 0,
113
+ ...(json ? { response_format: { type: "json_object" } } : {}),
114
+ }
115
+ try {
116
+ return await this.fetch(`${this.baseURL}/chat/completions`, {
117
+ method: "POST",
118
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
119
+ body: JSON.stringify(body),
120
+ signal,
121
+ })
122
+ } catch (cause) {
123
+ throw new ChatError(`Model request failed: ${scrub(cause instanceof Error ? cause.message : String(cause), key)}`)
124
+ }
125
+ }
126
+ }
127
+
128
+ const count = (n: unknown) => (typeof n === "number" && Number.isFinite(n) && n > 0 ? Math.round(n) : 0)
129
+
130
+ /** An upstream detail without the key, on one line, bounded. */
131
+ function scrub(text: string, key: string): string {
132
+ return text.split(key).join("[key]").replace(/\s+/g, " ").trim().slice(0, DETAIL_MAX)
133
+ }