@huynle/hub 0.10.1 → 0.11.0
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 +54 -0
- package/README.md +17 -3
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +622 -394
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/grants-mcp.ts +65 -0
- package/packages/cli/src/mcp-operations.ts +49 -0
- package/packages/cli/src/mcp.ts +6 -0
- package/packages/cli/src/subscriptions-mcp.ts +76 -0
- package/packages/cli/src/teams-mcp.ts +5 -3
- package/packages/codex/package.json +1 -1
- package/packages/node/package.json +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/teams.ts +4 -0
- package/packages/protocol/src/voice-recording.ts +39 -2
- package/packages/runtime-openshell/image/entry.sh +3 -1
- package/packages/runtime-openshell/image/start.sh +3 -1
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/client.ts +13 -0
- package/packages/sdk/src/teams.ts +2 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/agent/decisions.ts +7 -1
- package/src/central/agent/delivery.ts +39 -9
- package/src/central/agent/grant-routes.ts +14 -1
- package/src/central/agent/grants.ts +23 -0
- package/src/central/agent/launch-engine.ts +3 -3
- package/src/central/agent/message-limits.ts +3 -0
- package/src/central/agent/routes.ts +11 -0
- package/src/central/agent/subscriptions.ts +3 -1
- package/src/central/config.ts +9 -2
- package/src/central/http.ts +102 -20
- package/src/central/recording/export.ts +57 -3
- package/src/central/recording/index.ts +234 -16
- package/src/central/recording/ingest.ts +11 -2
- package/src/central/recording/routes.ts +61 -7
- package/src/central/recording/schema.ts +66 -2
- package/src/central/recording/settings.ts +19 -5
- package/src/central/recording/store.ts +19 -0
- package/src/central/recording/taps.ts +45 -1
- package/src/central/speech/openai.ts +4 -4
- package/src/central/speech/openrouter.ts +4 -4
- package/src/central/speech/provider.ts +32 -1
- package/src/central/speech/service.ts +6 -5
- package/src/central/teams/hop-stalls.ts +72 -0
- package/src/central/teams/policy.ts +10 -0
- package/src/central/teams/routes.ts +2 -0
- package/src/central/teams/seats.ts +3 -3
- package/src/central/teams/staffing.ts +5 -0
- package/src/central/teams/store.ts +5 -2
- package/src/central/teams/validation.ts +8 -5
- package/web/dist/assets/{ArtifactPage-CsPjam8D.js → ArtifactPage-C5MMqXLN.js} +1 -1
- package/web/dist/assets/{ArtifactPane-B9weO8L6.js → ArtifactPane-CXSCmhyJ.js} +1 -1
- package/web/dist/assets/{ArtifactWorkbench-DzKhMs3l.js → ArtifactWorkbench-S0RxNGWy.js} +1 -1
- package/web/dist/assets/{Diagram-CuRIffka.js → Diagram-BGmyRb-R.js} +1 -1
- package/web/dist/assets/{Library-rv5DGtrm.js → Library-CFmMbXEa.js} +2 -2
- package/web/dist/assets/{ProfilesDestination-DUuQ9jmd.js → ProfilesDestination-7jpOc2HM.js} +1 -1
- package/web/dist/assets/{TeamsPage-D_17MkvN.js → TeamsPage-C1NGrUR-.js} +1 -1
- package/web/dist/assets/{Watchtower-DlrNOJ7X.js → Watchtower-DnvlJaU0.js} +2 -2
- package/web/dist/assets/{artifacts-But87Jfo.js → artifacts-DlQEYmDl.js} +1 -1
- package/web/dist/assets/index-Da8SKXMw.js +33 -0
- package/web/dist/assets/{index-rW6LKQj1.css → index-Dyq-jBeE.css} +1 -1
- package/web/dist/assets/live-BmqLASSo.js +5 -0
- package/web/dist/assets/{pane-layout-BW0RdX5a.js → pane-layout-BAuu_pA3.js} +1 -1
- package/web/dist/assets/{renderer2d-DyANQ1WS.js → renderer2d-B2Nh39UN.js} +1 -1
- package/web/dist/assets/{renderer3d-vYYKFAJq.js → renderer3d-CTYbljHH.js} +1 -1
- package/web/dist/assets/{safe-area-B2Bk__FK.js → safe-area-CkhEclGo.js} +1 -1
- package/web/dist/assets/{silero-worker-e802lB5T.js → silero-worker-CC2etKS3.js} +1 -1
- package/web/dist/assets/{source-C-De8L3s.js → source-GYayxUxS.js} +1 -1
- package/web/dist/assets/{toast-CXTwTvDw.js → toast-q818mjUF.js} +1 -1
- package/web/dist/index.html +5 -5
- package/web/dist/assets/index-bX8B6NLo.js +0 -33
- package/web/dist/assets/live-BGLA-peO.js +0 -5
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
// is a console route with access "user": the user's cookie or device token only (the hub refuses session, app and
|
|
3
3
|
// act tokens before these run), so no agent, not even the Concierge, can read or export a recording, and there is
|
|
4
4
|
// no admin route. Every lookup is by (user.id, id); another user's, an unknown and a deleted ID answer 404 alike.
|
|
5
|
-
import { RECORDING_AUDIO_TYPE, RECORDING_FEATURES, RECORDING_LIMITS, type RecordingClient } from "../../protocol"
|
|
5
|
+
import { RECORDING_AUDIO_TYPE, RECORDING_FEATURES, RECORDING_LIMITS, RECORDING_REPLY_TYPE, type RecordingClient } from "../../protocol"
|
|
6
6
|
import { readBounded } from "../agent/body"
|
|
7
7
|
import { checkConsoleBatch } from "./schema"
|
|
8
|
-
import { audioKey, blobTenant, isSegmentNumber, summaryOf, type AudioEntry } from "./store"
|
|
8
|
+
import { audioKey, blobTenant, clipKey, type ExtraEntry, isSegmentNumber, replyKey, summaryOf, type AudioEntry } from "./store"
|
|
9
9
|
import { exportEntries } from "./export"
|
|
10
10
|
import type { RecordingService, StartInput } from "./index"
|
|
11
11
|
import { zipStream } from "./zip"
|
|
@@ -169,6 +169,25 @@ export function recordingRoutes(service: RecordingService, options: { readonly v
|
|
|
169
169
|
return n
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
/** A clip's or reply's number: 1 to the recording's limit. */
|
|
173
|
+
const extraNumber = (raw: string | undefined, max: number, what: string): number => {
|
|
174
|
+
const n = raw && /^\d{1,6}$/.test(raw) ? Number(raw) : 0
|
|
175
|
+
if (n < 1 || n > max) throw new BadInput(`${what} must be a number 1–${max}`)
|
|
176
|
+
return n
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** GET of a stored extra (a clip or a reply's audio): owner-only, private, as a download. */
|
|
180
|
+
const extraResponse = async (userID: string, id: string, key: string, type: string, file: string): Promise<Response> => {
|
|
181
|
+
const record = await service.visible(userID, id)
|
|
182
|
+
if (!record) return notFound()
|
|
183
|
+
const entry = await service.store.indexEntry<ExtraEntry>(userID, record.id, key)
|
|
184
|
+
const blob = entry ? await service.store.blobs.get(blobTenant(userID), entry.hash) : undefined
|
|
185
|
+
if (!blob) return notFound()
|
|
186
|
+
return new Response(blob.body, {
|
|
187
|
+
headers: { ...PRIVATE, "content-type": type, "content-length": String(blob.bytes), "cross-origin-resource-policy": "same-origin", "content-disposition": `attachment; filename="${record.id}-${file}"` },
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
|
|
172
191
|
return [
|
|
173
192
|
route("GET", "/api/settings/recording", async ({ user }) =>
|
|
174
193
|
Response.json({ settings: await service.settings.get(user.id), usage: await service.usage(user.id), limits: { retentionDays: L.retentionMs / 86_400_000, userBytes: L.userBytes, recordingBytes: L.recordingBytes } }, { headers: NO_STORE }),
|
|
@@ -176,11 +195,14 @@ export function recordingRoutes(service: RecordingService, options: { readonly v
|
|
|
176
195
|
route("PUT", "/api/settings/recording", async ({ request, user, device }) => {
|
|
177
196
|
const input = await jsonOf(request)
|
|
178
197
|
if (input instanceof Response) return input
|
|
179
|
-
only(input, ["enabled", "expectedRevision"])
|
|
180
|
-
|
|
181
|
-
|
|
198
|
+
only(input, ["enabled", "developer", "expectedRevision"])
|
|
199
|
+
const flag = (v: unknown) => v === undefined || typeof v === "boolean"
|
|
200
|
+
if (!flag(input.enabled) || !flag(input.developer) || (input.enabled === undefined && input.developer === undefined) || !Number.isSafeInteger(input.expectedRevision) || (input.expectedRevision as number) < 0) {
|
|
201
|
+
throw new BadInput("Expected {enabled?: boolean, developer?: boolean, expectedRevision: number}")
|
|
182
202
|
}
|
|
183
|
-
|
|
203
|
+
if (input.enabled === false && input.developer === true) throw new BadInput("Developer mode needs recording on")
|
|
204
|
+
const change = { ...(input.enabled !== undefined ? { enabled: input.enabled as boolean } : {}), ...(input.developer !== undefined ? { developer: input.developer as boolean } : {}) }
|
|
205
|
+
const result = await service.setSettings(user.id, device.id, change, input.expectedRevision as number)
|
|
184
206
|
if (!result.ok) return fail(409, "The setting changed meanwhile; reload it", "conflict", { currentRevision: result.currentRevision })
|
|
185
207
|
return Response.json({ settings: result.settings }, { headers: NO_STORE })
|
|
186
208
|
}),
|
|
@@ -199,7 +221,10 @@ export function recordingRoutes(service: RecordingService, options: { readonly v
|
|
|
199
221
|
return fail(429, `At most ${L.recordingsStored} recordings are kept; delete some in Settings → Voice`, "limit", { limit: L.recordingsStored })
|
|
200
222
|
}
|
|
201
223
|
const record = started.record
|
|
202
|
-
return Response.json(
|
|
224
|
+
return Response.json(
|
|
225
|
+
{ recording: summaryOf(record), audio: record.audio, hubAt: options.now(), features: [...RECORDING_FEATURES], ...(record.developer ? { developer: true } : {}) },
|
|
226
|
+
{ status: started.kind === "created" ? 201 : 200, headers: NO_STORE },
|
|
227
|
+
)
|
|
203
228
|
}),
|
|
204
229
|
route("GET", "/api/recordings", async ({ url, user }) => {
|
|
205
230
|
const limitRaw = url.searchParams.get("limit")
|
|
@@ -235,6 +260,7 @@ export function recordingRoutes(service: RecordingService, options: { readonly v
|
|
|
235
260
|
return after ? Response.json({ recording: summaryOf(after) }, { headers: NO_STORE }) : notFound()
|
|
236
261
|
}),
|
|
237
262
|
route("POST", "/api/recordings/:id/events", async ({ request, params, user, device }) => {
|
|
263
|
+
const startedAt = options.now()
|
|
238
264
|
const appendable = await service.appendable(user.id, device.id, params.id!)
|
|
239
265
|
if (appendable.kind === "not_found") return notFound()
|
|
240
266
|
if (appendable.kind === "ended") return fail(409, "This recording has ended", "recording_ended", { reason: appendable.reason })
|
|
@@ -244,6 +270,7 @@ export function recordingRoutes(service: RecordingService, options: { readonly v
|
|
|
244
270
|
const checked = checkConsoleBatch(input.events)
|
|
245
271
|
if (!checked.ok) return checked.limit ? fail(413, checked.problem, "limit") : fail(400, checked.problem, "invalid", checked.seq !== undefined ? { seq: checked.seq } : {})
|
|
246
272
|
const appended = await service.appendConsole(user.id, appendable.record, checked.events)
|
|
273
|
+
void service.requestLine(user.id, device.id, appendable.record.id, { route: "events", method: "POST", status: appended.kind === "ok" ? 200 : appended.kind === "limit" ? 413 : 404, ms: options.now() - startedAt }).catch(() => undefined)
|
|
247
274
|
if (appended.kind === "not_found") return notFound()
|
|
248
275
|
if (appended.kind === "limit") return fail(413, "This recording reached its event limit and has ended", "limit")
|
|
249
276
|
return Response.json({ through: appended.through, hubAt: options.now(), audio: appended.audio }, { headers: NO_STORE })
|
|
@@ -275,6 +302,33 @@ export function recordingRoutes(service: RecordingService, options: { readonly v
|
|
|
275
302
|
headers: { ...PRIVATE, "content-type": RECORDING_AUDIO_TYPE, "content-length": String(blob.bytes), "cross-origin-resource-policy": "same-origin", "content-disposition": `attachment; filename="${record.id}-${n}.wav"` },
|
|
276
303
|
})
|
|
277
304
|
}),
|
|
305
|
+
// Developer mode: audio clips around near-misses and marks (uploaded by the recording's device) and reply audio.
|
|
306
|
+
route("PUT", "/api/recordings/:id/clips/:n", async ({ request, params, user, device }) => {
|
|
307
|
+
const appendable = await service.appendable(user.id, device.id, params.id!)
|
|
308
|
+
if (appendable.kind === "not_found") return notFound()
|
|
309
|
+
const n = extraNumber(params.n, service.devLimits.clipsPerRecording, "clip")
|
|
310
|
+
if (appendable.kind === "ended") return fail(409, "This recording has ended", "recording_ended", { reason: appendable.reason })
|
|
311
|
+
if (!appendable.record.developer) return fail(409, "This recording does not keep Developer mode's extras", "developer_off")
|
|
312
|
+
const type = request.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase()
|
|
313
|
+
if (type !== RECORDING_AUDIO_TYPE) return fail(415, `Clips are ${RECORDING_AUDIO_TYPE}`, "unsupported")
|
|
314
|
+
const bytes = await bytesOf(request, service.devLimits.clipBytes)
|
|
315
|
+
if (bytes instanceof Response) return bytes
|
|
316
|
+
if (!bytes.length) throw new BadInput("Empty clip")
|
|
317
|
+
const stored = await service.storeExtra(user.id, appendable.record, "clip", n, bytes)
|
|
318
|
+
if (stored.kind === "cap") return fail(413, "Developer mode's share of the storage is full: the clip is not saved", "limit")
|
|
319
|
+
if (stored.kind === "gone") return notFound()
|
|
320
|
+
if (stored.kind === "off") return fail(409, "This recording does not keep Developer mode's extras", "developer_off")
|
|
321
|
+
if (stored.kind === "failed") return fail(503, "The clip could not be stored; retry", "unavailable")
|
|
322
|
+
return Response.json({ clip: { n, bytes: stored.entry.bytes, hash: stored.entry.hash } }, { status: stored.fresh ? 201 : 200, headers: NO_STORE })
|
|
323
|
+
}),
|
|
324
|
+
route("GET", "/api/recordings/:id/clips/:n", async ({ params, user }) => {
|
|
325
|
+
const n = extraNumber(params.n, service.devLimits.clipsPerRecording, "clip")
|
|
326
|
+
return extraResponse(user.id, params.id!, clipKey(n), RECORDING_AUDIO_TYPE, `clip-${n}.wav`)
|
|
327
|
+
}),
|
|
328
|
+
route("GET", "/api/recordings/:id/replies/:n", async ({ params, user }) => {
|
|
329
|
+
const n = extraNumber(params.n, service.devLimits.repliesPerRecording, "reply")
|
|
330
|
+
return extraResponse(user.id, params.id!, replyKey(n), RECORDING_REPLY_TYPE, `reply-${n}.mp3`)
|
|
331
|
+
}),
|
|
278
332
|
route("GET", "/api/recordings/:id/export", async ({ params, user }) => ((await service.visible(user.id, params.id!)) ? exportOf(user.id, params.id!) : notFound())),
|
|
279
333
|
route("DELETE", "/api/recordings/:id", async ({ params, user }) => ((await service.deleteOne(user.id, params.id!)) ? new Response(null, { status: 204 }) : notFound())),
|
|
280
334
|
]
|
|
@@ -32,6 +32,8 @@ const object = (fields: Fields): FieldSpec => ({ t: "object", fields })
|
|
|
32
32
|
const L = RECORDING_LIMITS
|
|
33
33
|
const ref = object({ messageID: id, ordinal: int, "seq?": int, i: int })
|
|
34
34
|
const AUDIO_RESULTS = ["stored", "skipped-cap", "failed", "off"]
|
|
35
|
+
/** A device label (enumerateDevices, a track's label): ✂ like any text. */
|
|
36
|
+
const DEVICE_LABEL = 120
|
|
35
37
|
|
|
36
38
|
/** The console kinds (REC-02 emits exactly these fields). */
|
|
37
39
|
export const CONSOLE_FIELDS: Readonly<Record<ConsoleEventKind, Fields>> = {
|
|
@@ -42,13 +44,25 @@ export const CONSOLE_FIELDS: Readonly<Record<ConsoleEventKind, Fields>> = {
|
|
|
42
44
|
"segment.closed": { segment: int, bytes: int, durationMs: num, speechMs: num, forced: bool },
|
|
43
45
|
"stt.sent": { segment: int, bytes: int },
|
|
44
46
|
// `text` is optional: a failed request has none (failed names why).
|
|
45
|
-
"
|
|
47
|
+
// Feature "capture.v2" adds junkReason (junkTranscript's reason), held (the text held at that moment) and speechMs.
|
|
48
|
+
"stt.received": {
|
|
49
|
+
segment: int,
|
|
50
|
+
"text?": text(L.textChars),
|
|
51
|
+
"junk?": yes,
|
|
52
|
+
"empty?": yes,
|
|
53
|
+
"failed?": text(L.messageChars),
|
|
54
|
+
"audio?": oneOf(...AUDIO_RESULTS),
|
|
55
|
+
"junkReason?": label,
|
|
56
|
+
"held?": text(L.utteranceChars),
|
|
57
|
+
"speechMs?": num,
|
|
58
|
+
},
|
|
46
59
|
"utterance.held": { text: text(L.utteranceChars) },
|
|
47
60
|
"utterance.merged": { segments: list(int, 1000), pauseMs: num },
|
|
48
61
|
"utterance.sent": { text: text(L.utteranceChars), segments: list(int, 1000), reason: oneOf("finished", "hold", "max", "command", "typed", "smart", "smart-fallback"), holdMs: num },
|
|
49
62
|
"utterance.accepted": { inboxID: id },
|
|
50
63
|
"utterance.failed": { "status?": int, message: text(L.messageChars) },
|
|
51
|
-
"
|
|
64
|
+
// "mark": feature "capture.v2".
|
|
65
|
+
"utterance.command": { command: oneOf("pause", "interrupt", "mark"), text: text(L.textChars) },
|
|
52
66
|
heard: { inboxID: id },
|
|
53
67
|
// The contract's `seq?` (the say's seq) collides with the envelope's `seq`: a flat event carries only the latter.
|
|
54
68
|
"say.received": { messageID: id, ordinal: int, chars: int, sentences: int, muted: bool },
|
|
@@ -70,6 +84,56 @@ export const CONSOLE_FIELDS: Readonly<Record<ConsoleEventKind, Fields>> = {
|
|
|
70
84
|
"turn.timing": { "speechEnd?": num, "transcript?": num, "accepted?": num, "firstSay?": num, "firstByte?": num, firstAudio: num },
|
|
71
85
|
// Feature "turn.predict" (Smart Turn): a score (none when the model failed or was slow) and what it decided.
|
|
72
86
|
"turn.predict": { "p?": num, ms: num, silenceMs: num, decision: oneOf("send", "hold", "fallback", "timer-fallback"), "error?": text(L.messageChars) },
|
|
87
|
+
// Feature "capture.v2": what the detector almost took for speech, its 10 s summary, a slow STT answer, a bookmark.
|
|
88
|
+
"vad.near": {
|
|
89
|
+
ms: num,
|
|
90
|
+
"voicedMs?": num,
|
|
91
|
+
"peakProb?": num,
|
|
92
|
+
"peakDb?": num,
|
|
93
|
+
threshold: num,
|
|
94
|
+
echoGuard: bool,
|
|
95
|
+
mode: oneOf("silero", "energy"),
|
|
96
|
+
reason: oneOf("short-onset", "below-threshold", "voiced-ratio"),
|
|
97
|
+
},
|
|
98
|
+
"vad.summary": {
|
|
99
|
+
frames: int,
|
|
100
|
+
"maxProb?": num,
|
|
101
|
+
"p90Prob?": num,
|
|
102
|
+
"maxDb?": num,
|
|
103
|
+
"noiseFloorDb?": num,
|
|
104
|
+
framesAboveStart: int,
|
|
105
|
+
framesAboveSpeakingStart: int,
|
|
106
|
+
msEchoGuard: num,
|
|
107
|
+
"nearDropped?": int,
|
|
108
|
+
mode: oneOf("silero", "energy"),
|
|
109
|
+
sensitivity: oneOf("low", "normal", "high"),
|
|
110
|
+
},
|
|
111
|
+
"stt.slow": { segment: int, ms: num },
|
|
112
|
+
mark: { via: oneOf("voice", "button"), "note?": text(L.textChars) },
|
|
113
|
+
// Feature "dev.v1" (Developer mode; stored only in a developer recording, see ingest).
|
|
114
|
+
"vad.trace": {
|
|
115
|
+
reason: oneOf("speech.start", "speech.end", "vad.near"),
|
|
116
|
+
mode: oneOf("silero", "energy"),
|
|
117
|
+
frameMs: num,
|
|
118
|
+
// p100: probabilities × 100; db: dBFS, both rounded to integers. `guard`: one "0"/"1" per frame (echo guard up).
|
|
119
|
+
scale: oneOf("p100", "db"),
|
|
120
|
+
values: list(int, 128),
|
|
121
|
+
threshold: int,
|
|
122
|
+
guard: { t: "str", max: 128 },
|
|
123
|
+
},
|
|
124
|
+
"audio.clip": { clip: int, for: object({ kind: oneOf("vad.near", "mark"), seq: int }), preMs: num, postMs: num, bytes: int },
|
|
125
|
+
"device.info": {
|
|
126
|
+
reason: oneOf("start", "change"),
|
|
127
|
+
"outputs?": list(object({ kind: label, "label?": text(DEVICE_LABEL) }), 20),
|
|
128
|
+
"inputs?": list(object({ kind: label, "label?": text(DEVICE_LABEL) }), 20),
|
|
129
|
+
"track?": object({ "echoCancellation?": bool, "noiseSuppression?": bool, "autoGainControl?": bool, "sampleRate?": num, "channelCount?": num, "label?": text(DEVICE_LABEL) }),
|
|
130
|
+
"network?": object({ "effectiveType?": label, "rtt?": num, "downlink?": num, "saveData?": bool }),
|
|
131
|
+
"visibility?": label,
|
|
132
|
+
"wakeLock?": bool,
|
|
133
|
+
"battery?": object({ "level?": num, "charging?": bool }),
|
|
134
|
+
},
|
|
135
|
+
"resource.timing": { route: oneOf("transcribe", "speak", "events", "utterance", "recording"), n: int, medianMs: num, maxMs: num, "medianTtfbMs?": num, "bytes?": int },
|
|
136
|
+
"console.log": { level: oneOf("error", "warn", "unhandled"), message: text(L.messageChars), "count?": int, "source?": label },
|
|
73
137
|
"decision.voice": { decisionID: id, option: id, outcome: oneOf("answered", "refused", "failed") },
|
|
74
138
|
"clock.sync": { t0: num, t1: num, hubAt: num },
|
|
75
139
|
error: { where: oneOf("stt", "tts", "send", "mic", "upload", "audio"), message: text(L.messageChars) },
|
|
@@ -7,7 +7,15 @@ export const SETTINGS_COLLECTION = "voice-recording-settings"
|
|
|
7
7
|
const KEY = "settings"
|
|
8
8
|
export const DEFAULT_SETTINGS: RecordingSettings = { enabled: false, revision: 0 }
|
|
9
9
|
|
|
10
|
-
export type SettingsWrite =
|
|
10
|
+
export type SettingsWrite =
|
|
11
|
+
| { readonly ok: true; readonly settings: RecordingSettings; readonly changed: boolean; readonly developerChanged: boolean }
|
|
12
|
+
| { readonly ok: false; readonly currentRevision: number }
|
|
13
|
+
|
|
14
|
+
/** A PUT's change: either or both (never enabled: false with developer: true, the route refuses it). */
|
|
15
|
+
export interface SettingsChange {
|
|
16
|
+
readonly enabled?: boolean
|
|
17
|
+
readonly developer?: boolean
|
|
18
|
+
}
|
|
11
19
|
|
|
12
20
|
export class RecordingSettingsStore {
|
|
13
21
|
constructor(
|
|
@@ -19,15 +27,21 @@ export class RecordingSettingsStore {
|
|
|
19
27
|
return (await this.state.get<RecordingSettings>(userID, SETTINGS_COLLECTION, KEY))?.value ?? DEFAULT_SETTINGS
|
|
20
28
|
}
|
|
21
29
|
|
|
22
|
-
/**
|
|
23
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Sets `enabled` and/or `developer` when the stored revision is `expectedRevision`; a stale one answers the current
|
|
32
|
+
* revision. Developer mode implies recording: developer on turns recording on, recording off turns developer off;
|
|
33
|
+
* a change of `enabled` alone (an older console) keeps developer as it is.
|
|
34
|
+
*/
|
|
35
|
+
async set(userID: string, change: SettingsChange, expectedRevision: number): Promise<SettingsWrite> {
|
|
24
36
|
for (let attempt = 0; attempt < 8; attempt++) {
|
|
25
37
|
const stored = await this.state.get<RecordingSettings>(userID, SETTINGS_COLLECTION, KEY)
|
|
26
38
|
const current = stored?.value ?? DEFAULT_SETTINGS
|
|
27
39
|
if (current.revision !== expectedRevision) return { ok: false, currentRevision: current.revision }
|
|
28
|
-
const
|
|
40
|
+
const developer = change.developer ?? (change.enabled === false ? false : current.developer === true)
|
|
41
|
+
const enabled = developer || (change.enabled ?? current.enabled)
|
|
42
|
+
const next: RecordingSettings = { enabled, ...(current.developer !== undefined || developer ? { developer } : {}), revision: current.revision + 1, updatedAt: this.now() }
|
|
29
43
|
const written = await this.state.put(userID, SETTINGS_COLLECTION, KEY, next, { ifVersion: stored?.version ?? 0 })
|
|
30
|
-
if (written.ok) return { ok: true, settings: next, changed: current.enabled !== enabled }
|
|
44
|
+
if (written.ok) return { ok: true, settings: next, changed: current.enabled !== enabled, developerChanged: (current.developer === true) !== developer }
|
|
31
45
|
}
|
|
32
46
|
const current = await this.get(userID)
|
|
33
47
|
return { ok: false, currentRevision: current.revision }
|
|
@@ -81,6 +81,19 @@ export interface AudioEntry {
|
|
|
81
81
|
readonly redacted?: true
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/** Developer mode: an audio clip (k) or a reply sentence's audio (r), numbered per recording. */
|
|
85
|
+
export interface ExtraEntry {
|
|
86
|
+
readonly hash: string
|
|
87
|
+
readonly bytes: number
|
|
88
|
+
readonly at: number
|
|
89
|
+
/** Replies: the sentence's ref. */
|
|
90
|
+
readonly ref?: { readonly messageID: string; readonly ordinal: number; readonly seq?: number; readonly i: number }
|
|
91
|
+
}
|
|
92
|
+
export const clipKey = (n: number) => `k${String(n).padStart(6, "0")}`
|
|
93
|
+
export const replyKey = (n: number) => `r${String(n).padStart(6, "0")}`
|
|
94
|
+
/** Index keys of event pages (c…, h…); a… are segments, k… clips, r… reply audio. */
|
|
95
|
+
export const isPageKey = (key: string) => key.startsWith("c") || key.startsWith("h")
|
|
96
|
+
|
|
84
97
|
export const consoleKey = (firstSeq: number) => `c${String(firstSeq).padStart(10, "0")}`
|
|
85
98
|
export const hubKey = (podTag: string, n: number) => `h${podTag}-${String(n).padStart(8, "0")}`
|
|
86
99
|
export const audioKey = (n: number) => `a${String(n).padStart(6, "0")}`
|
|
@@ -116,6 +129,9 @@ export function summaryOf(r: RecordingRecord): RecordingSummary {
|
|
|
116
129
|
events: r.events,
|
|
117
130
|
audio: r.audio,
|
|
118
131
|
redactions: r.redactions,
|
|
132
|
+
...(r.developer ? { developer: true as const } : {}),
|
|
133
|
+
...(r.clips ? { clips: r.clips } : {}),
|
|
134
|
+
...(r.replies ? { replies: r.replies } : {}),
|
|
119
135
|
}
|
|
120
136
|
}
|
|
121
137
|
|
|
@@ -274,6 +290,8 @@ export interface StartFacts {
|
|
|
274
290
|
readonly anchor: RecordingAnchor
|
|
275
291
|
readonly audio: boolean
|
|
276
292
|
readonly at: number
|
|
293
|
+
/** Developer mode was on at start. */
|
|
294
|
+
readonly developer?: boolean
|
|
277
295
|
}
|
|
278
296
|
|
|
279
297
|
export function newRecord(facts: StartFacts, speech: SpeechFacts): RecordingRecord {
|
|
@@ -303,6 +321,7 @@ export function newRecord(facts: StartFacts, speech: SpeechFacts): RecordingReco
|
|
|
303
321
|
lastEventAt: facts.at,
|
|
304
322
|
capNoted: [],
|
|
305
323
|
skipped: { cap: 0, failed: 0 },
|
|
324
|
+
...(facts.developer ? { developer: true as const } : {}),
|
|
306
325
|
...speech,
|
|
307
326
|
}
|
|
308
327
|
}
|
|
@@ -10,6 +10,25 @@ export interface TapSink {
|
|
|
10
10
|
hub(userID: string, sessionID: string, kind: HubEventKind, fields: Record<string, unknown>): void
|
|
11
11
|
/** False when the user surely has no active recording (a fresh, empty cache); true when unsure. */
|
|
12
12
|
mayRecord(userID: string): boolean
|
|
13
|
+
/** (Developer mode) A turn ended: its timings, for the session's developer recordings (turn.detail). */
|
|
14
|
+
turnDetail?(userID: string, sessionID: string, detail: TurnTimings): void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A turn's timings (ms from its start), for Developer mode's turn.detail. Tool names only, never their input. */
|
|
18
|
+
export interface TurnTimings {
|
|
19
|
+
readonly outcome: "succeeded" | "failed" | "interrupted"
|
|
20
|
+
readonly ms: number
|
|
21
|
+
readonly firstTokenMs?: number
|
|
22
|
+
readonly tools: ReadonlyArray<{ readonly callID: string; readonly tool?: string; readonly startMs: number; readonly endMs?: number; readonly state: "called" | "success" | "failed" }>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Tool calls kept per turn in turn.detail. */
|
|
26
|
+
const TURN_TOOLS_MAX = 50
|
|
27
|
+
|
|
28
|
+
interface TurnTrack {
|
|
29
|
+
readonly at: number
|
|
30
|
+
firstTokenMs?: number
|
|
31
|
+
readonly tools: Map<string, { callID: string; tool?: string; startMs: number; endMs?: number; state: "called" | "success" | "failed" }>
|
|
13
32
|
}
|
|
14
33
|
|
|
15
34
|
const OUTCOMES: Record<string, "succeeded" | "failed" | "interrupted"> = {
|
|
@@ -34,8 +53,16 @@ export class RegistryTaps implements RecordingTap {
|
|
|
34
53
|
private readonly firstToken = new Set<string>()
|
|
35
54
|
/** Tool names by call ID (session.tool.input.started carries the name, the later events do not). */
|
|
36
55
|
private readonly tools = new Map<string, string>()
|
|
56
|
+
/** (Developer mode) The running turn of each root session: its start, first token and tool calls. */
|
|
57
|
+
private readonly turns = new Map<string, TurnTrack>()
|
|
58
|
+
private readonly now: () => number
|
|
37
59
|
|
|
38
|
-
constructor(
|
|
60
|
+
constructor(
|
|
61
|
+
private readonly sink: TapSink,
|
|
62
|
+
now: () => number = Date.now,
|
|
63
|
+
) {
|
|
64
|
+
this.now = now
|
|
65
|
+
}
|
|
39
66
|
|
|
40
67
|
wants(tenant: string, sessionID: string, type: string): boolean {
|
|
41
68
|
if (type === "session.text.delta") return this.firstToken.has(sessionID) && this.sink.mayRecord(tenant)
|
|
@@ -47,6 +74,7 @@ export class RegistryTaps implements RecordingTap {
|
|
|
47
74
|
if (type === "session.execution.started") {
|
|
48
75
|
this.firstToken.add(sessionID)
|
|
49
76
|
if (this.firstToken.size > 1000) this.firstToken.delete(this.firstToken.values().next().value as string)
|
|
77
|
+
if (this.sink.turnDetail) remember(this.turns, sessionID, { at: this.now(), tools: new Map() })
|
|
50
78
|
this.sink.hub(tenant, sessionID, "turn.started", {})
|
|
51
79
|
return
|
|
52
80
|
}
|
|
@@ -54,10 +82,17 @@ export class RegistryTaps implements RecordingTap {
|
|
|
54
82
|
if (outcome) {
|
|
55
83
|
this.firstToken.delete(sessionID)
|
|
56
84
|
this.sink.hub(tenant, sessionID, "turn.ended", { outcome })
|
|
85
|
+
const track = this.turns.get(sessionID)
|
|
86
|
+
if (track) {
|
|
87
|
+
this.turns.delete(sessionID)
|
|
88
|
+
this.sink.turnDetail?.(tenant, sessionID, { outcome, ms: this.now() - track.at, ...(track.firstTokenMs !== undefined ? { firstTokenMs: track.firstTokenMs } : {}), tools: [...track.tools.values()] })
|
|
89
|
+
}
|
|
57
90
|
return
|
|
58
91
|
}
|
|
59
92
|
if (type === "session.text.delta") {
|
|
60
93
|
if (!this.firstToken.delete(sessionID)) return
|
|
94
|
+
const track = this.turns.get(sessionID)
|
|
95
|
+
if (track) track.firstTokenMs = this.now() - track.at
|
|
61
96
|
const { assistantMessageID: messageID, ordinal } = data
|
|
62
97
|
this.sink.hub(tenant, sessionID, "first.token", { ...(typeof messageID === "string" ? { messageID } : {}), ...(typeof ordinal === "number" ? { ordinal } : {}) })
|
|
63
98
|
return
|
|
@@ -71,6 +106,15 @@ export class RegistryTaps implements RecordingTap {
|
|
|
71
106
|
if (!state || !callID) return
|
|
72
107
|
const tool = this.tools.get(callID)
|
|
73
108
|
if (state !== "called") this.tools.delete(callID)
|
|
109
|
+
const track = this.turns.get(sessionID)
|
|
110
|
+
if (track) {
|
|
111
|
+
const ms = this.now() - track.at
|
|
112
|
+
const call = track.tools.get(callID)
|
|
113
|
+
if (call) {
|
|
114
|
+
call.state = state
|
|
115
|
+
if (state !== "called") call.endMs = ms
|
|
116
|
+
} else if (track.tools.size < TURN_TOOLS_MAX) track.tools.set(callID, { callID, ...(tool ? { tool } : {}), startMs: ms, ...(state !== "called" ? { endMs: ms } : {}), state })
|
|
117
|
+
}
|
|
74
118
|
// The summary only (summarizeToolInput on the spoke): never the input itself, never the output or error.
|
|
75
119
|
const summary = state === "called" && typeof data.summary === "string" ? data.summary : undefined
|
|
76
120
|
this.sink.hub(tenant, sessionID, "tool", { callID, ...(tool ? { tool } : {}), ...(summary !== undefined ? { summary } : {}), state })
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// OpenAI-compatible speech endpoints (OpenAI, Groq, local whisper/kokoro servers, ...):
|
|
2
2
|
// STT as multipart POST {baseURL}/audio/transcriptions (file, model, language?), TTS as
|
|
3
3
|
// POST {baseURL}/audio/speech {model, input, voice, response_format: "mp3", speed?}.
|
|
4
|
-
import { audioBody, audioMime, collect, SpeechError, transcriptText, upstreamDetail, wantsVerbose, withSpeedRetry, withVerboseRetry, type SpeechProvider } from "./provider"
|
|
4
|
+
import { audioBody, audioMime, collect, SpeechError, timedTranscription, transcriptText, upstreamDetail, wantsVerbose, withSpeedRetry, withVerboseRetry, type SpeechProvider, type TranscribeStats } from "./provider"
|
|
5
5
|
|
|
6
6
|
export interface OpenAICompatibleOptions {
|
|
7
7
|
/** Base URL including the version path, e.g. https://api.openai.com/v1 (no trailing slash). */
|
|
@@ -25,7 +25,7 @@ export class OpenAICompatible implements SpeechProvider {
|
|
|
25
25
|
this.verbose = wantsVerbose(options.sttModel)
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
async transcribe(audio: Uint8Array, format: string, signal?: AbortSignal): Promise<string> {
|
|
28
|
+
async transcribe(audio: Uint8Array, format: string, signal?: AbortSignal, stats?: TranscribeStats): Promise<string> {
|
|
29
29
|
const headers = await this.headers()
|
|
30
30
|
const send = (verbose: boolean) => {
|
|
31
31
|
const form = new FormData()
|
|
@@ -36,9 +36,9 @@ export class OpenAICompatible implements SpeechProvider {
|
|
|
36
36
|
if (verbose) form.append("response_format", "verbose_json")
|
|
37
37
|
return this.fetch(`${this.options.baseURL}/audio/transcriptions`, { method: "POST", headers, body: form, signal })
|
|
38
38
|
}
|
|
39
|
-
const response = await withVerboseRetry(send, this.verbose, () => (this.verbose = false))
|
|
39
|
+
const { response, json } = await timedTranscription(() => withVerboseRetry(send, this.verbose, () => (this.verbose = false)), stats)
|
|
40
40
|
if (!response.ok) throw await failure("transcription", response)
|
|
41
|
-
return transcriptText(await
|
|
41
|
+
return transcriptText(await json())
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
async speak(text: string, speed?: number, signal?: AbortSignal): Promise<Uint8Array<ArrayBuffer>> {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// OpenRouter speech-to-text (/audio/transcriptions as OpenAI-style multipart: the audio goes up as binary,
|
|
2
2
|
// not as base64 JSON, a third smaller and no encoding) and text-to-speech (/audio/speech).
|
|
3
|
-
import { audioBody, audioMime, collect, SpeechError, transcriptText, upstreamDetail, wantsVerbose, withSpeedRetry, withVerboseRetry, type SpeechProvider } from "./provider"
|
|
3
|
+
import { audioBody, audioMime, collect, SpeechError, timedTranscription, transcriptText, upstreamDetail, wantsVerbose, withSpeedRetry, withVerboseRetry, type SpeechProvider, type TranscribeStats } from "./provider"
|
|
4
4
|
|
|
5
5
|
export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
|
6
6
|
|
|
@@ -33,7 +33,7 @@ export class OpenRouter implements SpeechProvider {
|
|
|
33
33
|
this.verbose = wantsVerbose(options.sttModel)
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
async transcribe(audio: Uint8Array, format: string, signal?: AbortSignal): Promise<string> {
|
|
36
|
+
async transcribe(audio: Uint8Array, format: string, signal?: AbortSignal, stats?: TranscribeStats): Promise<string> {
|
|
37
37
|
const { sttModel: model, language } = this.options
|
|
38
38
|
// OpenRouter documents multipart file/model/language (up to 25 MB; an utterance is at most ~2 MB of WAV), and
|
|
39
39
|
// response_format verbose_json (segments with Whisper's no_speech_prob) for OpenAI-compatible providers.
|
|
@@ -45,9 +45,9 @@ export class OpenRouter implements SpeechProvider {
|
|
|
45
45
|
if (verbose) form.append("response_format", "verbose_json")
|
|
46
46
|
return this.post("/audio/transcriptions", form, signal)
|
|
47
47
|
}
|
|
48
|
-
const response = await withVerboseRetry(send, this.verbose, () => (this.verbose = false))
|
|
48
|
+
const { response, json } = await timedTranscription(() => withVerboseRetry(send, this.verbose, () => (this.verbose = false)), stats)
|
|
49
49
|
if (!response.ok) throw await failure("transcription", response)
|
|
50
|
-
return transcriptText(await
|
|
50
|
+
return transcriptText(await json())
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
async speak(text: string, speed?: number, signal?: AbortSignal): Promise<Uint8Array<ArrayBuffer>> {
|
|
@@ -1,15 +1,46 @@
|
|
|
1
1
|
// Speech provider interface shared by OpenRouter and OpenAI-compatible endpoints, the typed upstream
|
|
2
2
|
// error (mapped to 502 by the hub), audio format detection, and the speed-retry rule.
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* (Recording) What a transcription's upstream request looked like, filled in by the provider when asked: the HTTP
|
|
6
|
+
* status, the response's bytes and the time to its headers (ms, from the first request: a verbose retry included).
|
|
7
|
+
*/
|
|
8
|
+
export interface TranscribeStats {
|
|
9
|
+
status?: number
|
|
10
|
+
bytes?: number
|
|
11
|
+
headersMs?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
4
14
|
/** Speech-to-text and text-to-speech against one configured provider. */
|
|
5
15
|
export interface SpeechProvider {
|
|
6
|
-
transcribe(audio: Uint8Array, format: string, signal?: AbortSignal): Promise<string>
|
|
16
|
+
transcribe(audio: Uint8Array, format: string, signal?: AbortSignal, stats?: TranscribeStats): Promise<string>
|
|
7
17
|
/** MP3 audio. `speed` is optional; providers that reject it get one retry without it. */
|
|
8
18
|
speak(text: string, speed?: number, signal?: AbortSignal): Promise<Uint8Array<ArrayBuffer>>
|
|
9
19
|
/** The same MP3 as a stream, passed through as the provider sends it (errors are thrown before any byte). */
|
|
10
20
|
speakStream(text: string, speed?: number, signal?: AbortSignal): Promise<ReadableStream<Uint8Array>>
|
|
11
21
|
}
|
|
12
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Sends a transcription (`send`, with its retries) noting the status and the time to the headers in `stats`, then
|
|
25
|
+
* reads the JSON body noting its size. A failed status is returned as is (the caller turns it into its error).
|
|
26
|
+
*/
|
|
27
|
+
export async function timedTranscription(send: () => Promise<Response>, stats: TranscribeStats | undefined): Promise<{ response: Response; json: () => Promise<unknown> }> {
|
|
28
|
+
const started = performance.now()
|
|
29
|
+
const response = await send()
|
|
30
|
+
if (stats) {
|
|
31
|
+
stats.status = response.status
|
|
32
|
+
stats.headersMs = Math.round(performance.now() - started)
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
response,
|
|
36
|
+
json: async () => {
|
|
37
|
+
const raw = new Uint8Array(await response.arrayBuffer())
|
|
38
|
+
if (stats) stats.bytes = raw.byteLength
|
|
39
|
+
return JSON.parse(new TextDecoder().decode(raw))
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
13
44
|
/** A successful upstream response's body (an empty stream when it has none). */
|
|
14
45
|
export function audioBody(response: Response): ReadableStream<Uint8Array> {
|
|
15
46
|
return response.body ?? new ReadableStream({ start: (controller) => controller.close() })
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { OpenAICompatible } from "./openai"
|
|
4
4
|
import { OpenRouter } from "./openrouter"
|
|
5
5
|
import { speechModelList, withCurrent, type SpeechModelInfo, type SpeechModelList } from "./models"
|
|
6
|
-
import { SpeechError, upstreamDetail, type SpeechProvider } from "./provider"
|
|
6
|
+
import { SpeechError, upstreamDetail, type SpeechProvider, type TranscribeStats } from "./provider"
|
|
7
7
|
import {
|
|
8
8
|
lowerLayers,
|
|
9
9
|
resolveStt,
|
|
@@ -34,7 +34,8 @@ export interface SpeechSettingsStore {
|
|
|
34
34
|
|
|
35
35
|
/** Speech for one scope: a user (their settings over the default) or, without a user, the server default. */
|
|
36
36
|
export interface ScopedSpeech {
|
|
37
|
-
|
|
37
|
+
/** `stats`: the upstream request's status, bytes and time to headers (recording mode). */
|
|
38
|
+
transcribe(audio: Uint8Array, format: string, stats?: TranscribeStats): Promise<string>
|
|
38
39
|
speak(text: string): Promise<Uint8Array<ArrayBuffer>>
|
|
39
40
|
/** The audio as the provider streams it; `signal` aborts the upstream request (the phone moved on). */
|
|
40
41
|
speakStream(text: string, signal?: AbortSignal): Promise<ReadableStream<Uint8Array>>
|
|
@@ -94,14 +95,14 @@ export class SpeechService {
|
|
|
94
95
|
/** Speech with a user's effective settings (undefined: the server default). */
|
|
95
96
|
forUser(userID: string | undefined): ScopedSpeech {
|
|
96
97
|
return {
|
|
97
|
-
transcribe: (audio, format) => this.transcribe(audio, format, undefined, userID),
|
|
98
|
+
transcribe: (audio, format, stats) => this.transcribe(audio, format, undefined, userID, stats),
|
|
98
99
|
speak: (text) => this.speak(text, undefined, userID),
|
|
99
100
|
speakStream: (text, signal) => this.speakStream(text, signal, userID),
|
|
100
101
|
}
|
|
101
102
|
}
|
|
102
103
|
|
|
103
|
-
transcribe(audio: Uint8Array, format: string, signal?: AbortSignal, userID?: string): Promise<string> {
|
|
104
|
-
return this.provider("stt", userID).transcribe(audio, format, signal)
|
|
104
|
+
transcribe(audio: Uint8Array, format: string, signal?: AbortSignal, userID?: string, stats?: TranscribeStats): Promise<string> {
|
|
105
|
+
return this.provider("stt", userID).transcribe(audio, format, signal, stats)
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
speak(text: string, signal?: AbortSignal, userID?: string): Promise<Uint8Array<ArrayBuffer>> {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// One persistent hop-cap stall per team. A reservation prevents simultaneous sends from opening two Decisions.
|
|
2
|
+
import type { StateStore } from "../backend/types"
|
|
3
|
+
import { updateVersioned } from "../agent/versioned"
|
|
4
|
+
|
|
5
|
+
export const TEAM_HOP_STALLS = "team-hop-stalls"
|
|
6
|
+
/** A hub that crashed after reserving, but before opening its Decision, may retry after this long. */
|
|
7
|
+
export const TEAM_HOP_CLAIM_STALE_MS = 5 * 60_000
|
|
8
|
+
|
|
9
|
+
interface Stall {
|
|
10
|
+
readonly sender: string
|
|
11
|
+
readonly recipient: string
|
|
12
|
+
readonly limit: number
|
|
13
|
+
readonly decisionID?: string
|
|
14
|
+
readonly at: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class TeamHopStalls {
|
|
18
|
+
constructor(private readonly deps: {
|
|
19
|
+
readonly state: StateStore
|
|
20
|
+
readonly seats: (userID: string, teamID: string) => Promise<readonly string[]>
|
|
21
|
+
readonly open: (userID: string, teamID: string, sender: string, recipient: string, limit: number) => Promise<string>
|
|
22
|
+
readonly notice?: (userID: string, teamID: string, decisionID: string) => Promise<void>
|
|
23
|
+
readonly close?: (userID: string, decisionID: string) => Promise<void>
|
|
24
|
+
readonly reset: (userID: string, sessionID: string) => Promise<void>
|
|
25
|
+
readonly now?: () => number
|
|
26
|
+
}) {}
|
|
27
|
+
|
|
28
|
+
async hit(userID: string, teamID: string, sender: string, recipient: string, limit: number): Promise<boolean> {
|
|
29
|
+
const previous = await this.deps.state.get<Stall>(userID, TEAM_HOP_STALLS, teamID)
|
|
30
|
+
if (previous && previous.value.decisionID === undefined && (this.deps.now ?? Date.now)() - previous.value.at >= TEAM_HOP_CLAIM_STALE_MS) {
|
|
31
|
+
// CAS: never erase an open Decision or a replacement claim made by another caller.
|
|
32
|
+
await this.deps.state.delete(userID, TEAM_HOP_STALLS, teamID, { ifVersion: previous.version })
|
|
33
|
+
}
|
|
34
|
+
const claimed = await this.deps.state.put<Stall>(userID, TEAM_HOP_STALLS, teamID, { sender, recipient, limit, at: (this.deps.now ?? Date.now)() }, { ifVersion: 0 })
|
|
35
|
+
if (!claimed.ok) return false
|
|
36
|
+
try {
|
|
37
|
+
const decisionID = await this.deps.open(userID, teamID, sender, recipient, limit)
|
|
38
|
+
await updateVersioned<Stall>(this.deps.state, userID, TEAM_HOP_STALLS, teamID, (current) => current?.sender === sender && current.recipient === recipient && current.decisionID === undefined ? { ...current, decisionID } : undefined)
|
|
39
|
+
try {
|
|
40
|
+
await this.deps.notice?.(userID, teamID, decisionID)
|
|
41
|
+
} catch (cause) {
|
|
42
|
+
// The Decision was opened and persisted; never erase it because a spoke refused a quiet notice.
|
|
43
|
+
console.error(`[hub] team hop-cap entry notice failed: ${cause instanceof Error ? cause.message : String(cause)}`)
|
|
44
|
+
}
|
|
45
|
+
return true
|
|
46
|
+
} catch (cause) {
|
|
47
|
+
await this.deps.state.delete(userID, TEAM_HOP_STALLS, teamID, { ifVersion: claimed.version })
|
|
48
|
+
throw cause
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async stalled(userID: string, teamID: string, sessionID: string): Promise<(Stall & { readonly decisionID: string }) | undefined> {
|
|
53
|
+
const stall = (await this.deps.state.get<Stall>(userID, TEAM_HOP_STALLS, teamID))?.value
|
|
54
|
+
if (!stall?.decisionID || stall.recipient !== sessionID) return undefined
|
|
55
|
+
return stall as Stall & { readonly decisionID: string }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async renew(userID: string, teamID: string, decisionID: string): Promise<void> {
|
|
59
|
+
const entry = await this.deps.state.get<Stall>(userID, TEAM_HOP_STALLS, teamID)
|
|
60
|
+
if (!entry || entry.value.decisionID !== decisionID) throw new Error("No matching team hop-cap Decision")
|
|
61
|
+
for (const sessionID of new Set(await this.deps.seats(userID, teamID))) await this.deps.reset(userID, sessionID)
|
|
62
|
+
await this.deps.state.delete(userID, TEAM_HOP_STALLS, teamID, { ifVersion: entry.version })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async clearForHuman(userID: string, sessionID: string): Promise<void> {
|
|
66
|
+
for (const { key, value, version } of await this.deps.state.list<Stall>(userID, TEAM_HOP_STALLS)) {
|
|
67
|
+
if (value.recipient !== sessionID && value.sender !== sessionID) continue
|
|
68
|
+
if (!(await this.deps.state.delete(userID, TEAM_HOP_STALLS, key, { ifVersion: version })).ok) continue
|
|
69
|
+
if (value.decisionID) await this.deps.close?.(userID, value.decisionID)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -109,6 +109,16 @@ const sessionParty = (entry: DirectoryEntry): Party => ({ kind: "session", sessi
|
|
|
109
109
|
export class TeamPolicy {
|
|
110
110
|
constructor(private readonly deps: TeamPolicyDeps) {}
|
|
111
111
|
|
|
112
|
+
/** Shared active seat(s) only; cross-team and non-team sends use the global limit. */
|
|
113
|
+
async hopContext(userID: string, from: Party, to: Party, explicitTeamID?: string): Promise<{ teamID: string; limit?: number } | undefined> {
|
|
114
|
+
if (from.kind !== "session" || to.kind !== "session") return undefined
|
|
115
|
+
const ctx = await this.context(userID, [from.sessionID, to.sessionID])
|
|
116
|
+
const shared = seatsIn(ctx, from.sessionID).map((s) => s.teamID).filter((id) => (explicitTeamID === undefined || explicitTeamID === id) && seatsIn(ctx, to.sessionID).some((s) => s.teamID === id) && ctx.teams.get(id)?.state === "active").sort()
|
|
117
|
+
const teamID = shared[0]
|
|
118
|
+
if (!teamID) return undefined
|
|
119
|
+
return { teamID, ...(ctx.teams.get(teamID)?.hopLimit !== undefined ? { limit: ctx.teams.get(teamID)!.hopLimit } : {}) }
|
|
120
|
+
}
|
|
121
|
+
|
|
112
122
|
/** Starts a send from `from` to `addr` (see the header). */
|
|
113
123
|
async prepare(userID: string, from: Party, addr: string): Promise<TeamSend> {
|
|
114
124
|
const resolved = await this.resolve(userID, from, addr)
|