@huynle/hub 0.10.0 → 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 +78 -0
- package/README.md +20 -4
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +625 -395
- 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/index.ts +13 -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 +135 -24
- 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/src/spoke/spoke.ts +19 -0
- package/web/dist/assets/{ArtifactPage-Bz992ZX6.js → ArtifactPage-C5MMqXLN.js} +1 -1
- package/web/dist/assets/{ArtifactPane-BJZNixJC.js → ArtifactPane-CXSCmhyJ.js} +1 -1
- package/web/dist/assets/{ArtifactWorkbench-Dti3VP07.js → ArtifactWorkbench-S0RxNGWy.js} +1 -1
- package/web/dist/assets/{Diagram-B4HdUxw0.js → Diagram-BGmyRb-R.js} +1 -1
- package/web/dist/assets/{Library-CpzD2HDW.js → Library-CFmMbXEa.js} +2 -2
- package/web/dist/assets/{ProfilesDestination-DlQeogea.js → ProfilesDestination-7jpOc2HM.js} +1 -1
- package/web/dist/assets/{TeamsPage-4xdWCaI-.js → TeamsPage-C1NGrUR-.js} +1 -1
- package/web/dist/assets/{Watchtower-CpQdIRns.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-BC0O1gfR.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-IaJiloPi.js → renderer2d-B2Nh39UN.js} +1 -1
- package/web/dist/assets/{renderer3d-ZiD1uKGW.js → renderer3d-CTYbljHH.js} +1 -1
- package/web/dist/assets/{safe-area-C7IAlkgY.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-BS6lA6fN.js +0 -33
- package/web/dist/assets/live-CVUFIdUX.js +0 -5
|
@@ -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)
|
|
@@ -249,6 +249,7 @@ export function teamRoutes(deps: TeamRoutesDeps): AgentRoute[] {
|
|
|
249
249
|
allow: MANAGE,
|
|
250
250
|
async handle(context) {
|
|
251
251
|
const body = await bodyOf(context.request)
|
|
252
|
+
if (body.hopLimit !== undefined && context.actor.kind !== "user") throw new AgentError("forbidden", "Only the user may set a team's hop limit")
|
|
252
253
|
const key = idempotencyKeyOf(context.request)
|
|
253
254
|
const { team, created } = await teams.create(context.user.id, body, key === undefined ? {} : { idempotencyKey: key })
|
|
254
255
|
if (created) await changed(context, "team.create", team.id, teamCreatedEvent(team))
|
|
@@ -292,6 +293,7 @@ export function teamRoutes(deps: TeamRoutesDeps): AgentRoute[] {
|
|
|
292
293
|
allow: MANAGE,
|
|
293
294
|
async handle(context) {
|
|
294
295
|
const { expectedRevision, publish, ...patch } = await bodyOf(context.request)
|
|
296
|
+
if (patch.hopLimit !== undefined && context.actor.kind !== "user") throw new AgentError("forbidden", "Only the user may set a team's hop limit")
|
|
295
297
|
if (publish !== undefined && typeof publish !== "boolean") throw new AgentError("invalid", "publish must be true or false")
|
|
296
298
|
const revision = expected(expectedRevision)
|
|
297
299
|
const { id } = await managed(context)
|
|
@@ -47,7 +47,7 @@ export interface TeamSeatRecord extends TeamStaffing {
|
|
|
47
47
|
readonly teamVersion: number
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
export type DeliveryTeams = ReadonlyMap<string, Pick<TeamDefinition, "mode" | "edges" | "roles" | "state">>
|
|
50
|
+
export type DeliveryTeams = ReadonlyMap<string, Pick<TeamDefinition, "mode" | "edges" | "roles" | "state" | "hopLimit">>
|
|
51
51
|
|
|
52
52
|
export interface DeliveryContext {
|
|
53
53
|
/** Each asked session's current seats (empty when it holds none). */
|
|
@@ -199,11 +199,11 @@ export class TeamSeatStore {
|
|
|
199
199
|
/** Seats of several sessions and the teams they are in: teamDeliveryVerdict's inputs (HWA-34). */
|
|
200
200
|
async deliveryContext(userID: string, sessionIDs: readonly string[]): Promise<DeliveryContext> {
|
|
201
201
|
const seats = await this.seatsBySession(userID, sessionIDs)
|
|
202
|
-
const teams = new Map<string, Pick<TeamDefinition, "mode" | "edges" | "roles" | "state">>()
|
|
202
|
+
const teams = new Map<string, Pick<TeamDefinition, "mode" | "edges" | "roles" | "state" | "hopLimit">>()
|
|
203
203
|
for (const teamID of new Set([...seats.values()].flat().map((s) => s.teamID))) {
|
|
204
204
|
try {
|
|
205
205
|
const team = await this.teams.get(userID, teamID)
|
|
206
|
-
if (team) teams.set(teamID, { mode: team.mode, edges: team.edges, roles: team.roles, state: team.state })
|
|
206
|
+
if (team) teams.set(teamID, { mode: team.mode, edges: team.edges, roles: team.roles, state: team.state, ...(team.hopLimit !== undefined ? { hopLimit: team.hopLimit } : {}) })
|
|
207
207
|
} catch {
|
|
208
208
|
// A team this build cannot read takes no part in delivery (its seats are then ignored by the verdict).
|
|
209
209
|
}
|
|
@@ -75,6 +75,7 @@ import { readSeatsDoc, type SeatsDoc, TEAM_SEATS } from "./collections"
|
|
|
75
75
|
import { teamStaffedEvent, teamUnstaffedEvent } from "./events"
|
|
76
76
|
import type { TeamSeatRecord, TeamSeatStore } from "./seats"
|
|
77
77
|
import type { Team, TeamStore } from "./store"
|
|
78
|
+
import type { TeamHopStalls } from "./hop-stalls"
|
|
78
79
|
import { TEAM_STORE_LIMITS, type TeamRoleSpec } from "./validation"
|
|
79
80
|
|
|
80
81
|
/** One aggregate per team (key: team ID): workspace link, managed members, launch requests, Idempotency-Keys. */
|
|
@@ -144,6 +145,8 @@ export interface SeatProfileStatus {
|
|
|
144
145
|
export interface RosterSeat {
|
|
145
146
|
readonly staffing: TeamSeatRecord
|
|
146
147
|
readonly presence: SessionPresence
|
|
148
|
+
/** An agent-only message to this seat hit the team's hop limit; only the user may renew. */
|
|
149
|
+
readonly stalled?: "hop cap"
|
|
147
150
|
readonly memberID?: string
|
|
148
151
|
readonly supervisorMemberID?: string
|
|
149
152
|
readonly profile?: SeatProfileStatus
|
|
@@ -208,6 +211,7 @@ export interface TeamStaffingDeps {
|
|
|
208
211
|
readonly members: MembershipStore
|
|
209
212
|
readonly supervision: SupervisionStore
|
|
210
213
|
readonly presence: SessionPresenceLookup
|
|
214
|
+
readonly hopStalls?: Pick<TeamHopStalls, "stalled">
|
|
211
215
|
/** The session's project directory now (grant scope); undefined when no spoke reports it. */
|
|
212
216
|
readonly sessionDirectory: (userID: string, sessionID: string) => string | undefined
|
|
213
217
|
readonly launches: Pick<LaunchEngine, "accept" | "view">
|
|
@@ -454,6 +458,7 @@ export class TeamStaffingService {
|
|
|
454
458
|
seats.push({
|
|
455
459
|
staffing,
|
|
456
460
|
presence: presence.get(staffing.sessionID)!,
|
|
461
|
+
...(await this.deps.hopStalls?.stalled(userID, teamID, staffing.sessionID) ? { stalled: "hop cap" as const } : {}),
|
|
457
462
|
...(memberID !== undefined ? { memberID } : {}),
|
|
458
463
|
...(supervisorMemberID !== undefined ? { supervisorMemberID } : {}),
|
|
459
464
|
...(profile ? { profile } : {}),
|
|
@@ -132,6 +132,7 @@ export type PinnedVersions = (userID: string, teamID: string) => Promise<Readonl
|
|
|
132
132
|
|
|
133
133
|
export interface TeamStoreDeps {
|
|
134
134
|
readonly state: StateStore
|
|
135
|
+
readonly globalHopLimit?: number
|
|
135
136
|
readonly now?: Clock
|
|
136
137
|
readonly newID?: (kind: IdKind) => string
|
|
137
138
|
readonly limits?: Partial<TeamStoreLimits>
|
|
@@ -256,12 +257,14 @@ export class TeamStore {
|
|
|
256
257
|
private readonly now: Clock
|
|
257
258
|
private readonly newID: (kind: IdKind) => string
|
|
258
259
|
private readonly pinned?: PinnedVersions
|
|
260
|
+
private readonly globalHopLimit: number
|
|
259
261
|
|
|
260
262
|
constructor(deps: TeamStoreDeps) {
|
|
261
263
|
this.state = deps.state
|
|
262
264
|
this.now = deps.now ?? Date.now
|
|
263
265
|
this.newID = deps.newID ?? ((kind) => `${ID_PREFIXES[kind]}_${randomBytes(12).toString("hex")}`)
|
|
264
266
|
this.pinned = deps.pinnedVersions
|
|
267
|
+
this.globalHopLimit = deps.globalHopLimit ?? 8
|
|
265
268
|
this.limits = {
|
|
266
269
|
teamsPerUser: deps.limits?.teamsPerUser ?? TEAM_LIMITS.teamsPerUser,
|
|
267
270
|
versionsRetained: deps.limits?.versionsRetained ?? TEAM_STORE_LIMITS.versionsRetained,
|
|
@@ -277,7 +280,7 @@ export class TeamStore {
|
|
|
277
280
|
* another body is a conflict.
|
|
278
281
|
*/
|
|
279
282
|
async create(userID: string, input: unknown, options: { readonly idempotencyKey?: string } = {}): Promise<{ team: Team; created: boolean }> {
|
|
280
|
-
const spec = teamSpecOf(input)
|
|
283
|
+
const spec = teamSpecOf(input, this.globalHopLimit)
|
|
281
284
|
const key = options.idempotencyKey
|
|
282
285
|
if (key === undefined) return { team: await this.insert(userID, this.newID("team"), spec), created: true }
|
|
283
286
|
if (!IDEMPOTENCY_KEY.test(key)) throw new AgentError("invalid", "Idempotency-Key must be 1–200 of A-Z a-z 0-9 _ . : -")
|
|
@@ -365,7 +368,7 @@ export class TeamStore {
|
|
|
365
368
|
const expected = checkExpected(expectedRevision)
|
|
366
369
|
return this.mutate(userID, id, expected, (record) => {
|
|
367
370
|
this.active(record)
|
|
368
|
-
const spec = teamPatchOf(record.draft?.spec ?? record.spec, patch)
|
|
371
|
+
const spec = teamPatchOf(record.draft?.spec ?? record.spec, patch, this.globalHopLimit)
|
|
369
372
|
return { ...record, draft: { baseVersion: record.version, spec, digest: specDigest(spec), savedAt: this.now() } }
|
|
370
373
|
})
|
|
371
374
|
}
|
|
@@ -63,6 +63,7 @@ export interface TeamSpec {
|
|
|
63
63
|
readonly title: string
|
|
64
64
|
readonly description?: string
|
|
65
65
|
readonly mode: TeamMode
|
|
66
|
+
readonly hopLimit?: number
|
|
66
67
|
readonly roles: readonly TeamRoleSpec[]
|
|
67
68
|
readonly entryRole: string
|
|
68
69
|
readonly edges: readonly TeamEdge[]
|
|
@@ -82,13 +83,13 @@ export interface TeamDiff {
|
|
|
82
83
|
readonly projects: { readonly added: readonly string[]; readonly removed: readonly string[] }
|
|
83
84
|
}
|
|
84
85
|
|
|
85
|
-
export const SPEC_FIELDS: ReadonlySet<string> = new Set(["name", "title", "description", "mode", "roles", "entryRole", "edges", "tags", "metadata", "projects"])
|
|
86
|
+
export const SPEC_FIELDS: ReadonlySet<string> = new Set(["name", "title", "description", "mode", "hopLimit", "roles", "entryRole", "edges", "tags", "metadata", "projects"])
|
|
86
87
|
const ROLE_FIELDS = new Set(["name", "label", "description", "externalContact", "responsibilities", "profile", "prefs", "minMembers", "maxMembers"])
|
|
87
88
|
const EDGE_FIELDS = new Set(["from", "to", "kinds", "direction"])
|
|
88
89
|
const PROFILE_FIELDS = new Set(["profileID", "version"])
|
|
89
90
|
const PREFS_FIELDS = new Set(["harness", "model", "machineID"])
|
|
90
91
|
const REQUIRED = new Set(["title", "mode", "roles", "entryRole"])
|
|
91
|
-
const SCALARS = ["title", "description", "mode", "entryRole"] as const
|
|
92
|
+
const SCALARS = ["title", "description", "mode", "entryRole", "hopLimit"] as const
|
|
92
93
|
|
|
93
94
|
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
94
95
|
const fail = (message: string): never => {
|
|
@@ -157,13 +158,14 @@ function normalizedRole(r: Record<string, unknown>): TeamRoleSpec {
|
|
|
157
158
|
}
|
|
158
159
|
|
|
159
160
|
/** A validated, normalized team spec; throws AgentError invalid. */
|
|
160
|
-
export function teamSpecOf(input: unknown): TeamSpec {
|
|
161
|
+
export function teamSpecOf(input: unknown, globalHopLimit = 8): TeamSpec {
|
|
161
162
|
if (!isObj(input)) return fail("body must be an object")
|
|
162
163
|
onlyFields(input, SPEC_FIELDS, "")
|
|
163
164
|
if (Array.isArray(input.roles)) for (const role of input.roles) if (isObj(role)) onlyFields(role, ROLE_FIELDS, `role ${printable(String(role.name))}: `)
|
|
164
165
|
if (Array.isArray(input.edges)) for (const edge of input.edges) if (isObj(edge)) onlyFields(edge, EDGE_FIELDS, "edge: ")
|
|
165
166
|
const problem = teamInputProblem(input)
|
|
166
167
|
if (problem) fail(problem)
|
|
168
|
+
if (input.hopLimit !== undefined && (input.hopLimit as number) > globalHopLimit) fail(`hopLimit must not exceed the global agents.hopLimit (${globalHopLimit})`)
|
|
167
169
|
const roles = input.roles as Record<string, unknown>[]
|
|
168
170
|
for (const role of roles) {
|
|
169
171
|
const p = roleProblem(role)
|
|
@@ -193,6 +195,7 @@ export function teamSpecOf(input: unknown): TeamSpec {
|
|
|
193
195
|
title: input.title as string,
|
|
194
196
|
...(input.description !== undefined ? { description: input.description as string } : {}),
|
|
195
197
|
mode: input.mode as TeamMode,
|
|
198
|
+
...(input.hopLimit !== undefined ? { hopLimit: input.hopLimit as number } : {}),
|
|
196
199
|
roles: roles.map(normalizedRole),
|
|
197
200
|
entryRole: input.entryRole as string,
|
|
198
201
|
edges,
|
|
@@ -206,7 +209,7 @@ export function teamSpecOf(input: unknown): TeamSpec {
|
|
|
206
209
|
* The spec after a patch: fields replace the base's (roles and edges wholesale), null clears an optional one, the
|
|
207
210
|
* name never changes (it is the `#team` address), and the result is validated as a whole.
|
|
208
211
|
*/
|
|
209
|
-
export function teamPatchOf(base: TeamSpec, patch: unknown): TeamSpec {
|
|
212
|
+
export function teamPatchOf(base: TeamSpec, patch: unknown, globalHopLimit = 8): TeamSpec {
|
|
210
213
|
if (!isObj(patch)) return fail("body must be an object")
|
|
211
214
|
onlyFields(patch, SPEC_FIELDS, "")
|
|
212
215
|
const keys = Object.keys(patch)
|
|
@@ -221,7 +224,7 @@ export function teamPatchOf(base: TeamSpec, patch: unknown): TeamSpec {
|
|
|
221
224
|
else delete next[key]
|
|
222
225
|
} else if (value !== undefined) next[key] = value
|
|
223
226
|
}
|
|
224
|
-
return teamSpecOf(next)
|
|
227
|
+
return teamSpecOf(next, globalHopLimit)
|
|
225
228
|
}
|
|
226
229
|
|
|
227
230
|
/** sha256 of the spec's canonical JSON (key order never matters). */
|
package/src/spoke/spoke.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
type SessionTokensRequest,
|
|
29
29
|
type ActTokensRequest,
|
|
30
30
|
type CreateRequest,
|
|
31
|
+
type ModelSelection,
|
|
31
32
|
type SessionModelPrefs,
|
|
32
33
|
type SpokeRequest,
|
|
33
34
|
type TerminalInfo,
|
|
@@ -1419,6 +1420,8 @@ export class Spoke {
|
|
|
1419
1420
|
else this.modelPrefs.delete(req.sessionID)
|
|
1420
1421
|
return this.modelPrefsResult(req.sessionID)
|
|
1421
1422
|
}
|
|
1423
|
+
case "setModel":
|
|
1424
|
+
return this.setSessionModel(req.sessionID, req.model)
|
|
1422
1425
|
case "timeline": {
|
|
1423
1426
|
// A first page reads OpenCode (it shows the newest state); following pages reuse those rows briefly. A
|
|
1424
1427
|
// staged revert hides its message and everything after it (OpenCode keeps them until it is committed).
|
|
@@ -1940,6 +1943,22 @@ export class Spoke {
|
|
|
1940
1943
|
await this.host.switchModel(sessionID, model)
|
|
1941
1944
|
}
|
|
1942
1945
|
|
|
1946
|
+
/**
|
|
1947
|
+
* (feature "setModel") The session's own model, as the console picked it: OpenCode's session.switchModel stores it
|
|
1948
|
+
* on the session, so every later turn runs on it whoever prompts (the console, the TUI, a voice relay). Only a
|
|
1949
|
+
* model (and variant) of this OpenCode's catalog; no variant: the model's default.
|
|
1950
|
+
*/
|
|
1951
|
+
private async setSessionModel(sessionID: string, model: ModelSelection): Promise<ResultFor["setModel"]> {
|
|
1952
|
+
const catalog = (await this.host.listModels?.()) ?? []
|
|
1953
|
+
const known = catalog.find((value: any) => value?.providerID === model.providerID && (typeof value.id === "string" && value.id ? value.id : value.modelID) === model.modelID) as any
|
|
1954
|
+
const variants: string[] = Array.isArray(known?.variants) ? known.variants.flatMap((v: any) => (typeof v?.id === "string" ? [v.id] : [])) : []
|
|
1955
|
+
const variant = model.variant && model.variant !== "default" ? model.variant : undefined
|
|
1956
|
+
if (!known || (variant && !variants.includes(variant))) throw new Error(`Unknown model ${model.providerID}/${model.modelID}${variant ? `#${variant}` : ""} for this session`)
|
|
1957
|
+
const ref: ModelRef = { providerID: model.providerID, id: model.modelID, ...(variant ? { variant } : {}) }
|
|
1958
|
+
await this.switchModel(sessionID, ref)
|
|
1959
|
+
return { model: sessionModel({ chosen: ref, models: catalog })! }
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1943
1962
|
/** Enables or disables voice for a session this process reported (mode "session" only). */
|
|
1944
1963
|
private expose(sessionID: string, on: boolean): ResultFor["expose"] {
|
|
1945
1964
|
if (!this.tracker.has(sessionID)) throw new Error("Unknown session")
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{B as e,I as t,S as n,i as r,k as i,r as a}from"./ui-CFB4wQfF.js";import{o}from"./artifacts-
|
|
1
|
+
import{B as e,I as t,S as n,i as r,k as i,r as a}from"./ui-CFB4wQfF.js";import{o}from"./artifacts-DlQEYmDl.js";import{it as s,rt as c}from"./index-Da8SKXMw.js";import{t as l}from"./ArtifactWorkbench-S0RxNGWy.js";var u=i(`<div class="feature-page av-page">`),d=i(`<a class="icon-btn av-back"href=/artifacts aria-label="All artifacts"title="All artifacts">`);function f(i){return(()=>{var f=u();return n(f,e(l,{get id(){return i.id},layout:`page`,get lead(){return[e(t,{get when(){return c()},get children(){return e(r,{icon:`menu`,label:`Open sidebar`,onClick:s})}}),(()=>{var t=d();return n(t,e(a,{name:`back`})),t})()]},get actions(){return e(r,{class:`av-wide`,icon:`split`,label:`Open in pane`,onClick:()=>o(i.id)})}})),f})()}export{f as default};
|