@huynle/hub 0.9.2 → 0.10.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 +296 -0
- package/Dockerfile +5 -1
- package/README.md +152 -77
- package/package.json +7 -4
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +532 -261
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/cli.ts +36 -1
- package/packages/cli/src/subscriptions.ts +49 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/sessions.ts +10 -6
- package/packages/codex/src/spoke.ts +2 -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/schema/app-delivery/v1.schema.json +7 -5
- package/packages/protocol/schema/app-front/v1.schema.json +410 -0
- package/packages/protocol/src/app-front.ts +293 -0
- package/packages/protocol/src/concierge.ts +10 -1
- package/packages/protocol/src/context-cards.ts +59 -0
- package/packages/protocol/src/credentials.ts +39 -2
- package/packages/protocol/src/index.ts +69 -11
- 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/app-front.ts +524 -0
- package/packages/sdk/src/client.ts +37 -4
- package/packages/sdk/src/envelope.ts +9 -2
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/index.ts +1 -0
- package/packages/sdk/src/types.ts +39 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/delivery.ts +17 -1
- package/packages/spoke-sdk/src/harness-spoke.ts +4 -3
- package/packages/voice-client/README.md +146 -0
- package/packages/voice-client/src/vad/LICENSE +21 -0
- package/src/central/agent/acting-routes.ts +17 -3
- package/src/central/agent/acting.ts +12 -3
- package/src/central/agent/actor.ts +6 -1
- package/src/central/agent/app-decisions.ts +146 -0
- package/src/central/agent/app-tokens.ts +9 -3
- package/src/central/agent/audit.ts +3 -0
- package/src/central/agent/connect-routes.ts +64 -0
- package/src/central/agent/connect.ts +868 -0
- package/src/central/agent/conversation.ts +625 -0
- package/src/central/agent/decisions.ts +71 -6
- package/src/central/agent/delivery.ts +3 -0
- package/src/central/agent/directory.ts +5 -0
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/grant-requests.ts +46 -9
- package/src/central/agent/grants.ts +13 -2
- package/src/central/agent/message-limits.ts +2 -2
- package/src/central/agent/message-routes.ts +13 -4
- package/src/central/agent/read-routes.ts +16 -2
- package/src/central/agent/routes.ts +108 -6
- package/src/central/agent/speech-routes.ts +316 -0
- package/src/central/agent/subscription-routes.ts +174 -0
- package/src/central/agent/subscriptions.ts +980 -0
- package/src/central/agent-data.ts +43 -1
- package/src/central/backend/memory-counters.ts +2 -2
- package/src/central/backend/types.ts +5 -2
- package/src/central/backend/valkey-counters.ts +5 -4
- package/src/central/client-address.ts +16 -0
- package/src/central/concierge/board-render.ts +52 -1
- package/src/central/concierge/board-routes.ts +25 -2
- package/src/central/concierge/board.ts +11 -1
- package/src/central/concierge/context.ts +29 -8
- package/src/central/concierge/grant-request.ts +216 -0
- package/src/central/concierge/prompt.ts +49 -2
- package/src/central/concierge/session-routes.ts +3 -0
- package/src/central/config.ts +64 -3
- package/src/central/context-cards/budget.ts +121 -0
- package/src/central/context-cards/changes.ts +29 -0
- package/src/central/context-cards/config.ts +145 -0
- package/src/central/context-cards/engine.ts +379 -0
- package/src/central/context-cards/inputs.ts +111 -0
- package/src/central/context-cards/pickup.ts +134 -0
- package/src/central/context-cards/registry.ts +89 -0
- package/src/central/context-cards/routes.ts +37 -0
- package/src/central/context-cards/service.ts +367 -0
- package/src/central/context-cards/store.ts +67 -0
- package/src/central/context-cards/types.ts +88 -0
- package/src/central/fetcher.ts +10 -2
- package/src/central/front-relays.ts +423 -0
- package/src/central/http.ts +516 -56
- package/src/central/human-activity.ts +453 -0
- package/src/central/llm/chat.ts +133 -0
- package/src/central/registry.ts +162 -9
- package/src/central/speech/service.ts +9 -0
- package/src/central/spoke-ws.ts +13 -0
- package/src/central/store.ts +9 -12
- package/src/config.ts +30 -13
- package/src/index.ts +45 -43
- package/src/rpc.ts +33 -4
- package/src/spoke/concierge-agent.ts +1 -1
- package/src/spoke/conversation.ts +6 -75
- package/src/spoke/deliver.ts +18 -8
- package/src/spoke/hub-rpc.ts +7 -2
- package/src/spoke/hub-tool-subscriptions.ts +82 -0
- package/src/spoke/hub-tool-title.ts +89 -0
- package/src/spoke/hub-tool.ts +56 -18
- package/src/spoke/session-context.ts +10 -2
- package/src/spoke/spoke.ts +209 -238
- package/src/spoke/summary.ts +36 -1
- package/src/spoke/taskboard.ts +5 -6
- package/src/spoke/voice-front.ts +323 -30
- package/src/spoke/who-teams.ts +51 -2
- package/src/tui-messaging.ts +24 -10
- package/src/tui.ts +6 -6
- package/web/dist/assets/{ArtifactPage-BKISN-Qj.js → ArtifactPage-Bz992ZX6.js} +1 -1
- package/web/dist/assets/{ArtifactPane-CKOiAUNH.js → ArtifactPane-BJZNixJC.js} +1 -1
- package/web/dist/assets/ArtifactWorkbench-Dti3VP07.js +6 -0
- package/web/dist/assets/{Diagram-vdpmp3xu.css → Diagram-7y8kg3og.css} +1 -1
- package/web/dist/assets/Diagram-B4HdUxw0.js +1 -0
- package/web/dist/assets/{Library-CcjZXsfF.js → Library-CpzD2HDW.js} +2 -2
- package/web/dist/assets/Lightbox-BlbivCkd.js +1 -0
- package/web/dist/assets/MediaImage-Ch3ryIdR.js +4 -0
- package/web/dist/assets/ProfilesDestination-DlQeogea.js +1 -0
- package/web/dist/assets/{TeamsPage-CDyjrK-I.js → TeamsPage-4xdWCaI-.js} +1 -1
- package/web/dist/assets/{Watchtower-DYwoUWME.js → Watchtower-CpQdIRns.js} +2 -2
- package/web/dist/assets/{artifacts-DU9uRgs1.js → artifacts-But87Jfo.js} +1 -1
- package/web/dist/assets/index-BC0O1gfR.css +1 -0
- package/web/dist/assets/index-BS6lA6fN.js +33 -0
- package/web/dist/assets/{live-ycZ_aQZO.js → live-CVUFIdUX.js} +2 -2
- package/web/dist/assets/{pane-layout-0RrLHGDr.js → pane-layout-BW0RdX5a.js} +1 -1
- package/web/dist/assets/{renderer2d-BIwbgRoX.js → renderer2d-IaJiloPi.js} +1 -1
- package/web/dist/assets/{renderer3d-C1a7eYGQ.js → renderer3d-ZiD1uKGW.js} +1 -1
- package/web/dist/assets/{safe-area-DHhPy61Q.js → safe-area-C7IAlkgY.js} +1 -1
- package/web/dist/assets/{source-BWlvaODa.js → source-C-De8L3s.js} +1 -1
- package/web/dist/assets/toast-CXTwTvDw.js +9 -0
- package/web/dist/assets/vad-worklet-BfxJhrXy.js +27 -0
- package/web/dist/index.html +6 -6
- package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +0 -6
- package/web/dist/assets/Diagram-azw2B_QH.js +0 -1
- package/web/dist/assets/Lightbox-Du56MXGX.js +0 -1
- package/web/dist/assets/MediaImage-Ds_JUx3a.js +0 -4
- package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +0 -1
- package/web/dist/assets/index-C3ci5UtY.css +0 -1
- package/web/dist/assets/index-LNMZCqgE.js +0 -36
- package/web/dist/assets/toast-Cuu2K0hx.js +0 -1
- /package/{web/src/voice → packages/voice-client/src}/turn/LICENSE +0 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Context card storage: StateStore collection `context-cards`, tenant = hub user, key = session ID, one record per
|
|
2
|
+
// session holding the newest card of each provider ({cards: {[provider]: ContextCard}}). One key per session keeps
|
|
3
|
+
// a session's cards one read (the Details rail, the agent API) and its removal one delete (session deleted).
|
|
4
|
+
// Writes are version-checked (updateVersioned), so two providers finishing at once both land.
|
|
5
|
+
import type { ContextCard } from "../../protocol"
|
|
6
|
+
import type { StateStore } from "../backend/types"
|
|
7
|
+
import { updateVersioned } from "../agent/versioned"
|
|
8
|
+
|
|
9
|
+
export const CONTEXT_CARDS = "context-cards"
|
|
10
|
+
|
|
11
|
+
interface StoredCards {
|
|
12
|
+
readonly cards: Readonly<Record<string, ContextCard>>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const cardsOf = (record: StoredCards | undefined): ContextCard[] => Object.values(record?.cards ?? {})
|
|
16
|
+
const newest = (record: StoredCards | undefined) => Math.max(0, ...cardsOf(record).map((c) => c.generatedAt))
|
|
17
|
+
|
|
18
|
+
export class ContextCardStore {
|
|
19
|
+
constructor(private readonly deps: { readonly state: StateStore }) {}
|
|
20
|
+
|
|
21
|
+
/** Stores (replaces) the card of its provider. */
|
|
22
|
+
async put(userID: string, card: ContextCard): Promise<void> {
|
|
23
|
+
await updateVersioned<StoredCards>(this.deps.state, userID, CONTEXT_CARDS, card.sessionID, (current) => ({ cards: { ...current?.cards, [card.provider]: card } }))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A session's cards, primary first, then by provider id. */
|
|
27
|
+
async list(userID: string, sessionID: string): Promise<ContextCard[]> {
|
|
28
|
+
const record = (await this.deps.state.get<StoredCards>(userID, CONTEXT_CARDS, sessionID))?.value
|
|
29
|
+
return cardsOf(record).sort((a, b) => Number(b.primary === true) - Number(a.primary === true) || a.provider.localeCompare(b.provider))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async get(userID: string, sessionID: string, provider: string): Promise<ContextCard | undefined> {
|
|
33
|
+
return (await this.deps.state.get<StoredCards>(userID, CONTEXT_CARDS, sessionID))?.value?.cards[provider]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The primary card of each of these sessions that has one (one read each, concurrently). */
|
|
37
|
+
async primaries(userID: string, sessionIDs: Iterable<string>): Promise<Map<string, ContextCard>> {
|
|
38
|
+
const ids = [...new Set(sessionIDs)]
|
|
39
|
+
const records = await Promise.all(ids.map((id) => this.deps.state.get<StoredCards>(userID, CONTEXT_CARDS, id)))
|
|
40
|
+
const out = new Map<string, ContextCard>()
|
|
41
|
+
records.forEach((record, i) => {
|
|
42
|
+
const primary = cardsOf(record?.value).find((c) => c.primary)
|
|
43
|
+
if (primary) out.set(ids[i]!, primary)
|
|
44
|
+
})
|
|
45
|
+
return out
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Drops a session's cards. */
|
|
49
|
+
async forget(userID: string, sessionID: string): Promise<void> {
|
|
50
|
+
await this.deps.state.delete(userID, CONTEXT_CARDS, sessionID)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Drops every card of a user (they turned cards off). */
|
|
54
|
+
async clear(userID: string): Promise<void> {
|
|
55
|
+
for (const { key } of await this.deps.state.list<StoredCards>(userID, CONTEXT_CARDS)) await this.deps.state.delete(userID, CONTEXT_CARDS, key)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Drops the records whose newest card is older than `before`; answers how many. */
|
|
59
|
+
async prune(userID: string, before: number): Promise<number> {
|
|
60
|
+
let dropped = 0
|
|
61
|
+
for (const { key, value, version } of await this.deps.state.list<StoredCards>(userID, CONTEXT_CARDS)) {
|
|
62
|
+
if (newest(value) >= before) continue
|
|
63
|
+
if ((await this.deps.state.delete(userID, CONTEXT_CARDS, key, { ifVersion: version })).ok) dropped++
|
|
64
|
+
}
|
|
65
|
+
return dropped
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Context card provider contract (docs/context-cards.md "Writing a provider"). A provider declares what makes it
|
|
2
|
+
// run (triggers), how often at most (minIntervalMs), what it reads (needs) and generate(), which answers a draft
|
|
3
|
+
// card (or undefined: nothing to show / skip). The engine (./engine.ts) gathers the inputs, hashes them, checks
|
|
4
|
+
// the gates (kill switch, setting, human activity, throttle, budget), calls generate() and bounds the result.
|
|
5
|
+
import type { ContextCardField, ContextCardTrigger } from "../../protocol"
|
|
6
|
+
import type { ChatRequest, ChatResult } from "../llm/chat"
|
|
7
|
+
|
|
8
|
+
/** Inputs a provider may need; the engine fetches only these (a spoke round trip each for transcript and changes). */
|
|
9
|
+
export type InputNeed = "transcript" | "status" | "intent" | "changes"
|
|
10
|
+
|
|
11
|
+
/** One transcript row, already sanitised and bounded by the gatherer (./inputs.ts). */
|
|
12
|
+
export interface TranscriptRow {
|
|
13
|
+
readonly kind: "user" | "assistant" | "tool" | "error" | "message"
|
|
14
|
+
readonly text?: string
|
|
15
|
+
/** Tool rows: the tool's name. */
|
|
16
|
+
readonly name?: string
|
|
17
|
+
/** Tool rows: running | done | failed. */
|
|
18
|
+
readonly state?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ChangesInput {
|
|
22
|
+
readonly branch?: string
|
|
23
|
+
readonly files: readonly { readonly file: string; readonly additions: number; readonly deletions: number }[]
|
|
24
|
+
readonly totalFiles: number
|
|
25
|
+
readonly additions: number
|
|
26
|
+
readonly deletions: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What the engine gathered for a run (only the needs a provider declared are present). */
|
|
30
|
+
export interface CardInputs {
|
|
31
|
+
readonly sessionID: string
|
|
32
|
+
/** status: the live title (may be a placeholder). */
|
|
33
|
+
readonly title?: string
|
|
34
|
+
/** status: idle | working | blocked | error. */
|
|
35
|
+
readonly state?: string
|
|
36
|
+
/** status: open blockers' one-line summaries. */
|
|
37
|
+
readonly blockers?: readonly string[]
|
|
38
|
+
/** intent: the session's effective intent text. */
|
|
39
|
+
readonly intent?: string
|
|
40
|
+
/** transcript: the newest rows, oldest first. */
|
|
41
|
+
readonly transcript?: readonly TranscriptRow[]
|
|
42
|
+
readonly changes?: ChangesInput
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What generate() answers; the engine stamps the rest and bounds every field. */
|
|
46
|
+
export interface CardDraft {
|
|
47
|
+
/** Default: the provider's label. */
|
|
48
|
+
readonly title?: string
|
|
49
|
+
readonly headline: string
|
|
50
|
+
readonly body?: string
|
|
51
|
+
readonly fields?: readonly ContextCardField[]
|
|
52
|
+
/** The model that wrote it. */
|
|
53
|
+
readonly model?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The model as a provider sees it: usage is counted against the user's daily budget by the engine. */
|
|
57
|
+
export interface CardChat {
|
|
58
|
+
readonly model: string
|
|
59
|
+
complete(request: Omit<ChatRequest, "model">): Promise<ChatResult>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface GenerateContext {
|
|
63
|
+
readonly userID: string
|
|
64
|
+
readonly sessionID: string
|
|
65
|
+
readonly trigger: ContextCardTrigger
|
|
66
|
+
readonly inputs: CardInputs
|
|
67
|
+
/** Present for providers with usesModel (the engine skips them when no model is configured). */
|
|
68
|
+
readonly chat?: CardChat
|
|
69
|
+
readonly now: number
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ContextCardProvider {
|
|
73
|
+
/** Lowercase id, 2–32 characters (`pickup`). */
|
|
74
|
+
readonly id: string
|
|
75
|
+
readonly label: string
|
|
76
|
+
/** Bump when the output changes: cards of an older version are regenerated even with unchanged inputs. */
|
|
77
|
+
readonly version: number
|
|
78
|
+
/** The primary card (at most one provider): its headline is the session list's second line. */
|
|
79
|
+
readonly primary?: boolean
|
|
80
|
+
/** It calls the model (needs one configured; counts against the daily budget). */
|
|
81
|
+
readonly usesModel?: boolean
|
|
82
|
+
/** Automatic triggers it runs on ("manual" refreshes always may). */
|
|
83
|
+
readonly triggers: readonly ContextCardTrigger[]
|
|
84
|
+
/** At most one automatic run per session this often (later triggers run once the interval is over). */
|
|
85
|
+
readonly minIntervalMs: number
|
|
86
|
+
readonly needs: readonly InputNeed[]
|
|
87
|
+
generate(context: GenerateContext): Promise<CardDraft | undefined>
|
|
88
|
+
}
|
package/src/central/fetcher.ts
CHANGED
|
@@ -139,13 +139,21 @@ export function isDeniedAddress(address: string, extra: readonly Cidr[] = []): b
|
|
|
139
139
|
return false
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/** Whether `address` (a plain IPv4/IPv6 address, IPv4-mapped IPv6 read as IPv4) lies in one of `blocks`. */
|
|
143
|
+
export function inCidrs(address: string, blocks: readonly Cidr[]): boolean {
|
|
144
|
+
const raw = address.trim().replace(/^\[(.*)\]$/, "$1")
|
|
145
|
+
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(raw)
|
|
146
|
+
const ip = parseIP(mapped ? mapped[1]! : raw)
|
|
147
|
+
return ip !== undefined && blocks.some((block) => contains(block, ip))
|
|
148
|
+
}
|
|
149
|
+
|
|
142
150
|
/** HUB_FETCH_DENY_CIDRS: a comma list of CIDRs or bare addresses (v4 or v6). Throws on any invalid entry. */
|
|
143
|
-
export function parseDenyCidrs(text: string | undefined): Cidr[] {
|
|
151
|
+
export function parseDenyCidrs(text: string | undefined, setting = "HUB_FETCH_DENY_CIDRS"): Cidr[] {
|
|
144
152
|
const out: Cidr[] = []
|
|
145
153
|
for (const raw of (text ?? "").split(",")) {
|
|
146
154
|
const entry = raw.trim()
|
|
147
155
|
if (!entry) continue
|
|
148
|
-
const invalid = () => new Error(
|
|
156
|
+
const invalid = () => new Error(`${setting}: ${JSON.stringify(entry)} is not an IPv4 or IPv6 CIDR (e.g. 10.42.0.0/16, fd00::/8)`)
|
|
149
157
|
const match = /^([^/\s]+)(?:\/(\d{1,3}))?$/.exec(entry)
|
|
150
158
|
if (!match) throw invalid()
|
|
151
159
|
const ip = parseIP(match[1]!)
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
// (voice front P2, contract "P2: Concierge mode", decision el64mka7) The hub's side of a Concierge front's
|
|
2
|
+
// relay_to_session, as the Registry uses it:
|
|
3
|
+
// - Utterance bindings. Each utterance the hub steers into a front is recorded (inbox ID, device, time), and the
|
|
4
|
+
// front's latest delivered input (session.inbox.delivered from its linking process: the user's utterances and the
|
|
5
|
+
// spoke's wakes alike). A relay is allowed only while that latest input is a recorded utterance, at most
|
|
6
|
+
// RELAY_WINDOW_MS old, with fewer than RELAYS_PER_UTTERANCE relays made for it. A wake (or anything else) delivered
|
|
7
|
+
// after the utterance ends its relays: a front never relays on its own initiative.
|
|
8
|
+
// - Relayed sessions. A session the front relayed to is tracked for RELAY_TRACK_MS after the latest relay: its root's
|
|
9
|
+
// turn ends become updates for the front (frontUpdate), with only text written after the relay (no stale news). While
|
|
10
|
+
// a steered relay is not delivered yet, a turn end is held and folded into the next one (a held "finished" report is
|
|
11
|
+
// superseded; failures and interruptions kept), capped at RELAY_HOLD_MS: one report per job, as the session-mode
|
|
12
|
+
// front's fix C.
|
|
13
|
+
import type { Timers } from "./backend/types"
|
|
14
|
+
import { redactCredentials } from "../../packages/protocol/src/credentials.ts"
|
|
15
|
+
|
|
16
|
+
export const RELAYS_PER_UTTERANCE = 2
|
|
17
|
+
export const RELAY_WINDOW_MS = 120_000
|
|
18
|
+
export const RELAY_TRACK_MS = 30 * 60_000
|
|
19
|
+
export const RELAY_HOLD_MS = 60_000
|
|
20
|
+
/** The audit reason of a relayed prompt (the user's own, through the Concierge front). */
|
|
21
|
+
export const RELAY_REASON = "Relayed by voice from you (Concierge front)"
|
|
22
|
+
/** (guard tuning) The audit reason of a relay the front confirmed after the hub's soft question refusal. */
|
|
23
|
+
export const RELAY_REASON_CONFIRMED = "Relayed by voice from you (Concierge front), confirmed after a question check"
|
|
24
|
+
/** A relayed session's final text in an update, at most. */
|
|
25
|
+
const TEXT_MAX = 1500
|
|
26
|
+
/** Utterances remembered per front. */
|
|
27
|
+
const UTTERANCES_KEPT = 10
|
|
28
|
+
const FRONTS_KEPT = 1000
|
|
29
|
+
const TARGETS_KEPT = 1000
|
|
30
|
+
|
|
31
|
+
export class RelayRefused extends Error {
|
|
32
|
+
/** The device of the utterance the refused relay answered, when known (for the audit). */
|
|
33
|
+
constructor(
|
|
34
|
+
message: string,
|
|
35
|
+
readonly deviceID?: string,
|
|
36
|
+
) {
|
|
37
|
+
super(message)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** (hardening) The plain marker every relayed message starts with. */
|
|
42
|
+
export const RELAY_MARKER = "(by voice, via the Concierge, for you)"
|
|
43
|
+
|
|
44
|
+
/** (hands-on 2026-11-09) What the front reads when the hub refuses a status question as a relay. */
|
|
45
|
+
export const STATUS_QUESTION_REFUSAL =
|
|
46
|
+
"That's a question about the session, not a request for it: answer the user from the board in a sentence or two, or pass it to the Concierge with voice_main_send. Nothing was sent"
|
|
47
|
+
/**
|
|
48
|
+
* (guard tuning) The refusal of a question that also has a work verb ("did hub run the tests?" may be "have hub run
|
|
49
|
+
* the tests"): the front may retry once with confirm.
|
|
50
|
+
*/
|
|
51
|
+
export const STATUS_QUESTION_SOFT_REFUSAL = `${STATUS_QUESTION_REFUSAL}. If the user did ask for work to be done there, you may retry ONCE with confirm: true, after saying aloud exactly what you will send, for example "Sending 'update the changelog' to docs."`
|
|
52
|
+
|
|
53
|
+
/** Verbs that ask for work (imperatives, and the verb after a modal or "should"); "check" is special (check on = status). */
|
|
54
|
+
const WORK_VERBS: ReadonlySet<string> = new Set(
|
|
55
|
+
("tell ask have make get let send run rerun fix create add update review commit merge push pull squash rebase release deploy " +
|
|
56
|
+
"restart start stop retry rename delete remove refactor implement install bump prepare draft edit move write lint summarize " +
|
|
57
|
+
"summarise investigate debug keep continue open close undo revert build test scale clean sync generate document look check " +
|
|
58
|
+
"finish change reply answer post publish upload download copy split format rewrite optimize optimise migrate pin unpin tag").split(" "),
|
|
59
|
+
)
|
|
60
|
+
/** After "can/could you" (or "we"): verbs that ask the assistant for information, not work in a session. */
|
|
61
|
+
const INFO_VERBS: ReadonlySet<string> = new Set(["check", "see", "find", "tell", "let", "show", "give", "remind", "look", "hear", "say", "know", "confirm"])
|
|
62
|
+
/** Words that open a question about a session. */
|
|
63
|
+
const QUESTION_START = /^(?:did|has|is|are|was|were|does|do|what|where|when|why|who|whose|which|how|any)\b/
|
|
64
|
+
const LEADING_FILLER = /^(?:ok(?:ay)?|so|hey|hi|uh|um|er|erm|please|and|alright|right|well|also|now|then|oh|yeah|yes|concierge)\b[\s,.!]*/
|
|
65
|
+
/** Status phrases anywhere (a question about a session, if no request was found first). */
|
|
66
|
+
const STATUS_PHRASE = /\b(?:check(?:ing)?\s+(?:in\s+)?(?:up\s+)?on|how(?:'s|\s+is|\s+are)|status|any\s+(?:news|update)|news\s+from|hear(?:d)?\s+from|what\s+happened|where\s+is\b.*\bat\b|is\b.*\b(?:done|finished|waiting|stuck|ready)|working\s+on|doing|up\s+to)\b/
|
|
67
|
+
/** A delegation inside a question ("how about you tell hub to run the tests"). */
|
|
68
|
+
const DELEGATION = /\b(?:tell|ask)\s+(?!me\b|us\b|you\b)[^?]*?\bto\b|\b(?:have|get|make)\s+(?!you\b|we\b|me\b|us\b|i\b|it\b|the\b|any\b)\w+(?:\s+\w+){0,2}\s+(?:to\s+)?\w+/
|
|
69
|
+
|
|
70
|
+
const normalise = (words: string) =>
|
|
71
|
+
words
|
|
72
|
+
.normalize("NFKC")
|
|
73
|
+
.replace(/\p{Cf}/gu, "")
|
|
74
|
+
.replace(/[\u2018\u2019]/g, "'")
|
|
75
|
+
.toLowerCase()
|
|
76
|
+
.replace(/\s+/g, " ")
|
|
77
|
+
.trim()
|
|
78
|
+
|
|
79
|
+
const stripFillers = (text: string) => {
|
|
80
|
+
let t = text
|
|
81
|
+
for (let i = 0; i < 6; i++) {
|
|
82
|
+
const next = t.replace(LEADING_FILLER, "")
|
|
83
|
+
if (next === t) break
|
|
84
|
+
t = next
|
|
85
|
+
}
|
|
86
|
+
return t
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const firstWord = (text: string) => /^[a-z']+/.exec(text)?.[0] ?? ""
|
|
90
|
+
|
|
91
|
+
/** A request at the start of `text`: an imperative, a modal request, "<subject> should …", "keep …". */
|
|
92
|
+
function requestAt(text: string): boolean {
|
|
93
|
+
const t = stripFillers(text)
|
|
94
|
+
const w = firstWord(t)
|
|
95
|
+
// "check on X", "check in on X": status; any other "check …" (check in X whether…, check the logs) is work.
|
|
96
|
+
if (w === "check") return !/^check(?:ing)?\s+(?:in\s+)?(?:up\s+)?on\b/.test(t)
|
|
97
|
+
// "have you / have the tests …" is a question; "have hub fix …" a request.
|
|
98
|
+
if (w === "have") return !/^have\s+(?:you|we|i|they|the|it|any)\b/.test(t)
|
|
99
|
+
if (w === "tell" || w === "ask") return !/^(?:tell|ask)\s+(?:me|us)\b/.test(t)
|
|
100
|
+
if (WORK_VERBS.has(w)) return true
|
|
101
|
+
// Modal requests: "can hub merge …", "could billing lead review …", "can you get hub to …", "can we squash …".
|
|
102
|
+
const modal = /^(?:can|could|would|will|should)\s+(.*)$/.exec(t)
|
|
103
|
+
if (modal) {
|
|
104
|
+
const rest = modal[1]!
|
|
105
|
+
const self = /^(?:you|we|u)\s+(?:please\s+)?([a-z']+)/.exec(rest)
|
|
106
|
+
if (self) return !INFO_VERBS.has(self[1]!) || /^(?:you|we|u)\s+(?:please\s+)?(?:tell|ask)\s+(?!me\b|us\b)/.test(rest)
|
|
107
|
+
// Another subject: a request unless it only asks about a state ("will hub be done").
|
|
108
|
+
return !/^(?:i|it|they|there)\b/.test(rest) && !/\bbe\s+(?:done|finished|ready|waiting|stuck)\b/.test(rest)
|
|
109
|
+
}
|
|
110
|
+
// "<subject> should|needs to|must|has to <verb>" (not "be"): "billing lead should add a currency field".
|
|
111
|
+
if (/^[a-z' ]{1,40}?\b(?:should|needs?\s+to|must|has\s+to|ought\s+to)\s+(?!be\b)[a-z]/.test(t)) return true
|
|
112
|
+
return false
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* (guard tuning, verify round 7) Whether what the user said is a request FOR a session (relay it) or a question ABOUT
|
|
117
|
+
* one (refuse it: the front answers from the board or asks the Concierge). Deterministic and cheap; it works on the
|
|
118
|
+
* user's own words (normalised: NFKC, no format characters, lower case), with or without a "?". In order:
|
|
119
|
+
* 1. A question start (did/has/is/are/was/does/do, what/where/when/why/who/which/how, any) is a question, unless a
|
|
120
|
+
* delegation follows ("how about you tell hub to …"). "have" is a question only before you/we/the/it/any.
|
|
121
|
+
* 2. A request: an imperative or a modal request at the start, also after fillers ("ok so"), a vocative ("hub, merge
|
|
122
|
+
* …") or an "in X," / "in X …" prefix ("in hub can we squash …"); a modal ("can/could/would/will/should") with any
|
|
123
|
+
* subject and any verb; "can/could you (get/have/make/tell/ask) …" and any work verb after "you/we"; "<subject>
|
|
124
|
+
* should|needs to|must … <verb>"; "keep working on".
|
|
125
|
+
* 3. Otherwise a status phrase (check on, check in on, how's, status, any news, what happened, where is … at, is …
|
|
126
|
+
* done/waiting/stuck, working on, doing) or a final "?" makes it a question; anything else is a request.
|
|
127
|
+
* `workVerb`: a work verb appears (such a question may be a request after all: the soft refusal allows a confirm).
|
|
128
|
+
*/
|
|
129
|
+
export function classifyRelay(words: string): { readonly kind: "request" | "question"; readonly workVerb: boolean } {
|
|
130
|
+
const text = stripFillers(normalise(words))
|
|
131
|
+
// Not a work verb: "tell me", "ask us", "have you/the …" (an auxiliary), "check" (check on is status).
|
|
132
|
+
const notWork = (w: string, next: string) => w === "check" || ((w === "tell" || w === "ask") && /^(?:me|us)$/.test(next)) || (w === "have" && /^(?:you|we|i|they|the|it|any)$/.test(next))
|
|
133
|
+
const workVerb = (text.match(/[a-z']+/g) ?? []).some((w, i, all) => WORK_VERBS.has(w) && !notWork(w, all[i + 1] ?? ""))
|
|
134
|
+
const question = (kind: "request" | "question") => ({ kind, workVerb })
|
|
135
|
+
// A negative imperative ("do not delete …", "don't push …", "never force push …") is a request, never a "do …?".
|
|
136
|
+
if (/^(?:do\s+not|don't|dont|never)\s+[a-z]/.test(text)) return question("request")
|
|
137
|
+
if (QUESTION_START.test(text) || /^have\s+(?:you|we|i|they|the|it|any)\b/.test(text)) {
|
|
138
|
+
return question(DELEGATION.test(text.replace(QUESTION_START, "")) ? "request" : "question")
|
|
139
|
+
}
|
|
140
|
+
const clauses = [text]
|
|
141
|
+
// A vocative or an "in X," prefix: the request follows the first comma.
|
|
142
|
+
const comma = text.indexOf(",")
|
|
143
|
+
if (comma > 0 && text.slice(0, comma).split(" ").length <= 5) clauses.push(text.slice(comma + 1).trim())
|
|
144
|
+
// "in hub can we squash …": the request after the place.
|
|
145
|
+
const place = /^in\s+(.+)$/.exec(text)
|
|
146
|
+
if (place) {
|
|
147
|
+
const words = place[1]!.split(" ")
|
|
148
|
+
for (let i = 1; i < Math.min(words.length, 6); i++) clauses.push(words.slice(i).join(" "))
|
|
149
|
+
}
|
|
150
|
+
if (clauses.some((c) => requestAt(c) && !QUESTION_START.test(stripFillers(c)))) return question("request")
|
|
151
|
+
return question(STATUS_PHRASE.test(text) || /[??]\s*$/.test(text) ? "question" : "request")
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* (hands-on 2026-11-09, tuned after verify round 7) The refusal for a relay of a question about a session, else
|
|
156
|
+
* undefined: STATUS_QUESTION_REFUSAL, or STATUS_QUESTION_SOFT_REFUSAL when the question also has a work verb (the
|
|
157
|
+
* front may retry once with confirm after saying aloud what it sends).
|
|
158
|
+
*/
|
|
159
|
+
export function relayQuestionProblem(words: string): string | undefined {
|
|
160
|
+
const { kind, workVerb } = classifyRelay(words)
|
|
161
|
+
if (kind === "request") return undefined
|
|
162
|
+
return workVerb ? STATUS_QUESTION_SOFT_REFUSAL : STATUS_QUESTION_REFUSAL
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A line that starts (after spaces, zero-width or other format characters, in any Unicode compatibility form such as
|
|
167
|
+
* fullwidth "[hub]") with "[hub]" gets "(hub)" instead: relayed text never poses as a hub envelope. Such a line is
|
|
168
|
+
* kept in its NFKC form, without format characters; every other line is unchanged.
|
|
169
|
+
*/
|
|
170
|
+
const neutralize = (text: string) =>
|
|
171
|
+
text
|
|
172
|
+
.split("\n")
|
|
173
|
+
.map((line) => {
|
|
174
|
+
const plain = line.normalize("NFKC").replace(/\p{Cf}/gu, "")
|
|
175
|
+
const m = /^(\s*)\[hub\]/i.exec(plain)
|
|
176
|
+
return m ? `${m[1]}(hub)${plain.slice(m[0].length)}` : line
|
|
177
|
+
})
|
|
178
|
+
.join("\n")
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* (hardening, verify rounds 4 and 5) The message a relay delivers, built by the HUB from the user's own words only
|
|
182
|
+
* (the utterance the relay answers; for a redirect, the same device's earlier one first, then "Then: …"). The front's
|
|
183
|
+
* text is never delivered, not even as a summary: board text that steers the front cannot put words into another
|
|
184
|
+
* session, and the front cannot restate (or invert) the request. The message starts with RELAY_MARKER.
|
|
185
|
+
*/
|
|
186
|
+
export function relayMessage(input: { readonly words: string; readonly previous?: string }): { readonly message: string } {
|
|
187
|
+
const words = neutralize(input.words.trim())
|
|
188
|
+
const previous = input.previous?.trim() ? neutralize(input.previous.trim()) : undefined
|
|
189
|
+
return { message: `${RELAY_MARKER} ${previous ? `${previous}\nThen: ${words}` : words}` }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
interface Utterance {
|
|
193
|
+
readonly at: number
|
|
194
|
+
readonly deviceID?: string
|
|
195
|
+
/** What the user said (the hub's own record: the relay delivers these words). */
|
|
196
|
+
readonly text: string
|
|
197
|
+
relays: number
|
|
198
|
+
/** (guard tuning) The session a soft question refusal named: one confirmed retry to it is allowed. */
|
|
199
|
+
softTarget?: string
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
interface Update {
|
|
203
|
+
readonly text: string
|
|
204
|
+
readonly finished?: boolean
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
interface Target {
|
|
208
|
+
readonly mainID: string
|
|
209
|
+
readonly title: string
|
|
210
|
+
at: number
|
|
211
|
+
/** Steered relays not delivered yet: inbox ID → when relayed. */
|
|
212
|
+
readonly inflight: Map<string, number>
|
|
213
|
+
/** Root text written since the latest relay (or the turn's start after it). */
|
|
214
|
+
text?: string
|
|
215
|
+
deferred: Update[]
|
|
216
|
+
hold?: unknown
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface RelayUpdate {
|
|
220
|
+
readonly mainID: string
|
|
221
|
+
readonly text: string
|
|
222
|
+
readonly finished?: boolean
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export interface FrontRelaysDeps {
|
|
226
|
+
readonly now: () => number
|
|
227
|
+
readonly timers: Timers
|
|
228
|
+
/** Sends an update to the front of `mainID` (frontUpdate through its linking process). */
|
|
229
|
+
readonly send: (update: RelayUpdate) => void
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const trim = (text: string) => {
|
|
233
|
+
const t = text.replace(/\s+/g, " ").trim()
|
|
234
|
+
return t.length > TEXT_MAX ? `${t.slice(0, TEXT_MAX - 1)}…` : t
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function bound<K, V>(map: Map<K, V>, limit: number): void {
|
|
238
|
+
while (map.size > limit) map.delete(map.keys().next().value!)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export class FrontRelays {
|
|
242
|
+
/** Front → its recent utterances by inbox ID. */
|
|
243
|
+
private readonly utterances = new Map<string, Map<string, Utterance>>()
|
|
244
|
+
/** Front → the inbox ID of the latest input delivered to it. */
|
|
245
|
+
private readonly latest = new Map<string, string>()
|
|
246
|
+
private readonly targets = new Map<string, Target>()
|
|
247
|
+
/** Inbox items delivered lately (a delivery can be seen before the relay that made it is recorded). */
|
|
248
|
+
private readonly deliveredIDs = new Map<string, true>()
|
|
249
|
+
|
|
250
|
+
constructor(private readonly deps: FrontRelaysDeps) {}
|
|
251
|
+
|
|
252
|
+
/** The hub steered an utterance of the user's (from `deviceID`) into `frontID`. */
|
|
253
|
+
utterance(frontID: string, inboxID: string, deviceID?: string, text = ""): void {
|
|
254
|
+
const list = this.utterances.get(frontID) ?? new Map<string, Utterance>()
|
|
255
|
+
list.set(inboxID, { at: this.deps.now(), ...(deviceID ? { deviceID } : {}), text, relays: 0 })
|
|
256
|
+
bound(list, UTTERANCES_KEPT)
|
|
257
|
+
this.utterances.delete(frontID)
|
|
258
|
+
this.utterances.set(frontID, list)
|
|
259
|
+
bound(this.utterances, FRONTS_KEPT)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** An inbox item was delivered to `frontID` (its latest input now). */
|
|
263
|
+
frontDelivered(frontID: string, inboxID: string): void {
|
|
264
|
+
this.latest.delete(frontID)
|
|
265
|
+
this.latest.set(frontID, inboxID)
|
|
266
|
+
bound(this.latest, FRONTS_KEPT)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Takes one relay of the utterance the front is answering; RelayRefused when its latest input is no utterance of
|
|
271
|
+
* the user's, the utterance is too old, or it made RELAYS_PER_UTTERANCE relays already. `release` gives it back
|
|
272
|
+
* (the relay was refused for its target). `words`: what the user said; `previous`: the utterance before it (within
|
|
273
|
+
* the window, from the same device), for a redirect ("no, the other one").
|
|
274
|
+
*/
|
|
275
|
+
take(frontID: string): {
|
|
276
|
+
readonly deviceID?: string
|
|
277
|
+
readonly words: string
|
|
278
|
+
readonly previous?: string
|
|
279
|
+
/** The session the hub's soft question refusal of this utterance named, if any (one confirmed retry). */
|
|
280
|
+
readonly softTarget?: string
|
|
281
|
+
/** A soft question refusal for `targetID`: one retry with confirm may follow, for this utterance only. */
|
|
282
|
+
markSoft(targetID: string): void
|
|
283
|
+
/** The confirmed retry is used (once). */
|
|
284
|
+
useConfirm(): void
|
|
285
|
+
release(): void
|
|
286
|
+
} {
|
|
287
|
+
const inboxID = this.latest.get(frontID)
|
|
288
|
+
const list = this.utterances.get(frontID)
|
|
289
|
+
const utterance = inboxID === undefined ? undefined : list?.get(inboxID)
|
|
290
|
+
if (!utterance) throw new RelayRefused("relay_to_session is only for what the user just said: the front's latest input is not the user's speech")
|
|
291
|
+
if (this.deps.now() - utterance.at > RELAY_WINDOW_MS) throw new RelayRefused("The user's request is too old to relay; ask them again", utterance.deviceID)
|
|
292
|
+
if (utterance.relays >= RELAYS_PER_UTTERANCE) throw new RelayRefused(`No more relays for this request: at most ${RELAYS_PER_UTTERANCE} per thing the user says`, utterance.deviceID)
|
|
293
|
+
utterance.relays++
|
|
294
|
+
const order = [...list!.entries()]
|
|
295
|
+
const before = order[order.findIndex(([id]) => id === inboxID) - 1]?.[1]
|
|
296
|
+
// (round 5) Only the same device's earlier words: another device in the conversation may be another speaker.
|
|
297
|
+
const sameDevice = before?.deviceID !== undefined && before.deviceID === utterance.deviceID
|
|
298
|
+
const previous = before && sameDevice && this.deps.now() - before.at <= RELAY_WINDOW_MS && before.text ? before.text : undefined
|
|
299
|
+
let released = false
|
|
300
|
+
return {
|
|
301
|
+
...(utterance.deviceID ? { deviceID: utterance.deviceID } : {}),
|
|
302
|
+
words: utterance.text,
|
|
303
|
+
previous,
|
|
304
|
+
...(utterance.softTarget !== undefined ? { softTarget: utterance.softTarget } : {}),
|
|
305
|
+
markSoft: (targetID) => void (utterance.softTarget = targetID),
|
|
306
|
+
useConfirm: () => void (utterance.softTarget = undefined),
|
|
307
|
+
release: () => {
|
|
308
|
+
if (!released) utterance.relays--
|
|
309
|
+
released = true
|
|
310
|
+
},
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** A relay reached `targetID` (a root session): its results go to the front of `mainID` from now on. */
|
|
315
|
+
relayed(mainID: string, targetID: string, title: string, inboxID?: string): void {
|
|
316
|
+
const now = this.deps.now()
|
|
317
|
+
const known = this.targets.get(targetID)
|
|
318
|
+
const target: Target = known && known.mainID === mainID ? known : { mainID, title, at: now, inflight: new Map(), deferred: [] }
|
|
319
|
+
if (known && known !== target) this.drop(targetID)
|
|
320
|
+
target.at = now
|
|
321
|
+
target.text = undefined
|
|
322
|
+
if (inboxID !== undefined && !this.deliveredIDs.has(inboxID)) target.inflight.set(inboxID, now)
|
|
323
|
+
this.targets.delete(targetID)
|
|
324
|
+
this.targets.set(targetID, target)
|
|
325
|
+
bound(this.targets, TARGETS_KEPT)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** The front of `mainID` is gone (off, failed, replaced): its relayed sessions are no longer tracked. */
|
|
329
|
+
forget(mainID: string, frontID?: string): void {
|
|
330
|
+
for (const [id, target] of [...this.targets]) if (target.mainID === mainID) this.drop(id)
|
|
331
|
+
if (frontID !== undefined) {
|
|
332
|
+
this.utterances.delete(frontID)
|
|
333
|
+
this.latest.delete(frontID)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** The tracked target a session belongs to (its root), if still tracked; the title it is announced with. */
|
|
338
|
+
target(rootID: string): { readonly mainID: string; readonly title: string } | undefined {
|
|
339
|
+
const target = this.targets.get(rootID)
|
|
340
|
+
if (!target) return undefined
|
|
341
|
+
if (this.deps.now() - target.at > RELAY_TRACK_MS) {
|
|
342
|
+
this.drop(rootID)
|
|
343
|
+
return undefined
|
|
344
|
+
}
|
|
345
|
+
return target
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** An event of a tracked target's ROOT session (from its owning process). */
|
|
349
|
+
observe(rootID: string, type: string, data: Record<string, unknown>): void {
|
|
350
|
+
if (type === "session.inbox.delivered" && typeof data.inboxID === "string") {
|
|
351
|
+
this.deliveredIDs.set(data.inboxID, true)
|
|
352
|
+
bound(this.deliveredIDs, 500)
|
|
353
|
+
}
|
|
354
|
+
if (!this.target(rootID)) return
|
|
355
|
+
const target = this.targets.get(rootID)!
|
|
356
|
+
switch (type) {
|
|
357
|
+
case "session.execution.started":
|
|
358
|
+
target.text = undefined
|
|
359
|
+
return
|
|
360
|
+
case "session.text.ended":
|
|
361
|
+
if (typeof data.text === "string" && data.text.trim()) target.text = trim(data.text)
|
|
362
|
+
return
|
|
363
|
+
case "session.inbox.delivered":
|
|
364
|
+
if (typeof data.inboxID === "string") target.inflight.delete(data.inboxID)
|
|
365
|
+
return
|
|
366
|
+
case "session.execution.succeeded":
|
|
367
|
+
case "session.execution.failed":
|
|
368
|
+
case "session.execution.interrupted": {
|
|
369
|
+
const reply = target.text ? ` Its final reply: ${target.text}` : ""
|
|
370
|
+
target.text = undefined
|
|
371
|
+
if (type === "session.execution.succeeded") return this.ended(rootID, target, { text: `${target.title} finished its turn.${reply}`, finished: true })
|
|
372
|
+
if (type === "session.execution.interrupted") return this.ended(rootID, target, { text: `${target.title}'s turn was interrupted.${reply}` })
|
|
373
|
+
const message = (data.error as { message?: unknown } | undefined)?.message
|
|
374
|
+
const reason = trim(redactCredentials(typeof message === "string" && message.trim() ? message : "unknown error").text).replace(/[.!]+$/, "")
|
|
375
|
+
return this.ended(rootID, target, { text: `${target.title}'s turn failed (already announced to the user; don't say it failed again): ${reason}.${reply}` })
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
stop(): void {
|
|
381
|
+
for (const target of this.targets.values()) if (target.hold !== undefined) this.deps.timers.clear(target.hold)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
private drop(targetID: string): void {
|
|
385
|
+
const target = this.targets.get(targetID)
|
|
386
|
+
if (target?.hold !== undefined) this.deps.timers.clear(target.hold)
|
|
387
|
+
this.targets.delete(targetID)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Relays older than the hold cap count as delivered (a lost event never holds later reports). */
|
|
391
|
+
private prune(target: Target): void {
|
|
392
|
+
const cutoff = this.deps.now() - RELAY_HOLD_MS
|
|
393
|
+
for (const [id, at] of target.inflight) if (at <= cutoff) target.inflight.delete(id)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private ended(rootID: string, target: Target, update: Update): void {
|
|
397
|
+
this.prune(target)
|
|
398
|
+
if (target.inflight.size) {
|
|
399
|
+
target.deferred = [...target.deferred.filter((u) => !u.finished || !update.finished), update]
|
|
400
|
+
target.hold ??= this.deps.timers.set(() => {
|
|
401
|
+
target.hold = undefined
|
|
402
|
+
if (this.targets.get(rootID) !== target) return
|
|
403
|
+
this.prune(target)
|
|
404
|
+
const held = target.deferred.splice(0)
|
|
405
|
+
if (target.inflight.size) held.push({ text: `A later request you relayed to ${target.title} is still running or waiting; its result comes in a later update.` })
|
|
406
|
+
for (const u of held) this.send(target, u)
|
|
407
|
+
}, RELAY_HOLD_MS)
|
|
408
|
+
return
|
|
409
|
+
}
|
|
410
|
+
if (target.hold !== undefined) this.deps.timers.clear(target.hold)
|
|
411
|
+
target.hold = undefined
|
|
412
|
+
const held = target.deferred.splice(0).filter((u) => !u.finished || !update.finished)
|
|
413
|
+
for (const u of [...held, update]) this.send(target, u)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
private send(target: Target, update: Update): void {
|
|
417
|
+
try {
|
|
418
|
+
this.deps.send({ mainID: target.mainID, text: update.text, ...(update.finished ? { finished: true } : {}) })
|
|
419
|
+
} catch (cause) {
|
|
420
|
+
console.error(`[hub] front update failed: ${(cause as Error).message}`)
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|