@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.
Files changed (141) hide show
  1. package/CHANGELOG.md +296 -0
  2. package/Dockerfile +5 -1
  3. package/README.md +152 -77
  4. package/package.json +7 -4
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/cli/dist/hub-cli.js +532 -261
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/cli.ts +36 -1
  9. package/packages/cli/src/subscriptions.ts +49 -0
  10. package/packages/codex/package.json +1 -1
  11. package/packages/codex/src/sessions.ts +10 -6
  12. package/packages/codex/src/spoke.ts +2 -1
  13. package/packages/node/package.json +1 -1
  14. package/packages/preset/package.json +1 -1
  15. package/packages/protocol/package.json +1 -1
  16. package/packages/protocol/schema/app-delivery/v1.schema.json +7 -5
  17. package/packages/protocol/schema/app-front/v1.schema.json +410 -0
  18. package/packages/protocol/src/app-front.ts +293 -0
  19. package/packages/protocol/src/concierge.ts +10 -1
  20. package/packages/protocol/src/context-cards.ts +59 -0
  21. package/packages/protocol/src/credentials.ts +39 -2
  22. package/packages/protocol/src/index.ts +69 -11
  23. package/packages/runtime-openshell/package.json +1 -1
  24. package/packages/runtime-sdk/package.json +1 -1
  25. package/packages/sdk/package.json +1 -1
  26. package/packages/sdk/src/app-front.ts +524 -0
  27. package/packages/sdk/src/client.ts +37 -4
  28. package/packages/sdk/src/envelope.ts +9 -2
  29. package/packages/sdk/src/errors.ts +1 -0
  30. package/packages/sdk/src/index.ts +1 -0
  31. package/packages/sdk/src/types.ts +39 -1
  32. package/packages/spoke-sdk/package.json +1 -1
  33. package/packages/spoke-sdk/src/delivery.ts +17 -1
  34. package/packages/spoke-sdk/src/harness-spoke.ts +4 -3
  35. package/packages/voice-client/README.md +146 -0
  36. package/packages/voice-client/src/vad/LICENSE +21 -0
  37. package/src/central/agent/acting-routes.ts +17 -3
  38. package/src/central/agent/acting.ts +12 -3
  39. package/src/central/agent/actor.ts +6 -1
  40. package/src/central/agent/app-decisions.ts +146 -0
  41. package/src/central/agent/app-tokens.ts +9 -3
  42. package/src/central/agent/audit.ts +3 -0
  43. package/src/central/agent/connect-routes.ts +64 -0
  44. package/src/central/agent/connect.ts +868 -0
  45. package/src/central/agent/conversation.ts +625 -0
  46. package/src/central/agent/decisions.ts +71 -6
  47. package/src/central/agent/delivery.ts +3 -0
  48. package/src/central/agent/directory.ts +5 -0
  49. package/src/central/agent/errors.ts +2 -0
  50. package/src/central/agent/grant-requests.ts +46 -9
  51. package/src/central/agent/grants.ts +13 -2
  52. package/src/central/agent/message-limits.ts +2 -2
  53. package/src/central/agent/message-routes.ts +13 -4
  54. package/src/central/agent/read-routes.ts +16 -2
  55. package/src/central/agent/routes.ts +108 -6
  56. package/src/central/agent/speech-routes.ts +316 -0
  57. package/src/central/agent/subscription-routes.ts +174 -0
  58. package/src/central/agent/subscriptions.ts +980 -0
  59. package/src/central/agent-data.ts +43 -1
  60. package/src/central/backend/memory-counters.ts +2 -2
  61. package/src/central/backend/types.ts +5 -2
  62. package/src/central/backend/valkey-counters.ts +5 -4
  63. package/src/central/client-address.ts +16 -0
  64. package/src/central/concierge/board-render.ts +52 -1
  65. package/src/central/concierge/board-routes.ts +25 -2
  66. package/src/central/concierge/board.ts +11 -1
  67. package/src/central/concierge/context.ts +29 -8
  68. package/src/central/concierge/grant-request.ts +216 -0
  69. package/src/central/concierge/prompt.ts +49 -2
  70. package/src/central/concierge/session-routes.ts +3 -0
  71. package/src/central/config.ts +64 -3
  72. package/src/central/context-cards/budget.ts +121 -0
  73. package/src/central/context-cards/changes.ts +29 -0
  74. package/src/central/context-cards/config.ts +145 -0
  75. package/src/central/context-cards/engine.ts +379 -0
  76. package/src/central/context-cards/inputs.ts +111 -0
  77. package/src/central/context-cards/pickup.ts +134 -0
  78. package/src/central/context-cards/registry.ts +89 -0
  79. package/src/central/context-cards/routes.ts +37 -0
  80. package/src/central/context-cards/service.ts +367 -0
  81. package/src/central/context-cards/store.ts +67 -0
  82. package/src/central/context-cards/types.ts +88 -0
  83. package/src/central/fetcher.ts +10 -2
  84. package/src/central/front-relays.ts +423 -0
  85. package/src/central/http.ts +516 -56
  86. package/src/central/human-activity.ts +453 -0
  87. package/src/central/llm/chat.ts +133 -0
  88. package/src/central/registry.ts +162 -9
  89. package/src/central/speech/service.ts +9 -0
  90. package/src/central/spoke-ws.ts +13 -0
  91. package/src/central/store.ts +9 -12
  92. package/src/config.ts +30 -13
  93. package/src/index.ts +45 -43
  94. package/src/rpc.ts +33 -4
  95. package/src/spoke/concierge-agent.ts +1 -1
  96. package/src/spoke/conversation.ts +6 -75
  97. package/src/spoke/deliver.ts +18 -8
  98. package/src/spoke/hub-rpc.ts +7 -2
  99. package/src/spoke/hub-tool-subscriptions.ts +82 -0
  100. package/src/spoke/hub-tool-title.ts +89 -0
  101. package/src/spoke/hub-tool.ts +56 -18
  102. package/src/spoke/session-context.ts +10 -2
  103. package/src/spoke/spoke.ts +209 -238
  104. package/src/spoke/summary.ts +36 -1
  105. package/src/spoke/taskboard.ts +5 -6
  106. package/src/spoke/voice-front.ts +323 -30
  107. package/src/spoke/who-teams.ts +51 -2
  108. package/src/tui-messaging.ts +24 -10
  109. package/src/tui.ts +6 -6
  110. package/web/dist/assets/{ArtifactPage-BKISN-Qj.js → ArtifactPage-Bz992ZX6.js} +1 -1
  111. package/web/dist/assets/{ArtifactPane-CKOiAUNH.js → ArtifactPane-BJZNixJC.js} +1 -1
  112. package/web/dist/assets/ArtifactWorkbench-Dti3VP07.js +6 -0
  113. package/web/dist/assets/{Diagram-vdpmp3xu.css → Diagram-7y8kg3og.css} +1 -1
  114. package/web/dist/assets/Diagram-B4HdUxw0.js +1 -0
  115. package/web/dist/assets/{Library-CcjZXsfF.js → Library-CpzD2HDW.js} +2 -2
  116. package/web/dist/assets/Lightbox-BlbivCkd.js +1 -0
  117. package/web/dist/assets/MediaImage-Ch3ryIdR.js +4 -0
  118. package/web/dist/assets/ProfilesDestination-DlQeogea.js +1 -0
  119. package/web/dist/assets/{TeamsPage-CDyjrK-I.js → TeamsPage-4xdWCaI-.js} +1 -1
  120. package/web/dist/assets/{Watchtower-DYwoUWME.js → Watchtower-CpQdIRns.js} +2 -2
  121. package/web/dist/assets/{artifacts-DU9uRgs1.js → artifacts-But87Jfo.js} +1 -1
  122. package/web/dist/assets/index-BC0O1gfR.css +1 -0
  123. package/web/dist/assets/index-BS6lA6fN.js +33 -0
  124. package/web/dist/assets/{live-ycZ_aQZO.js → live-CVUFIdUX.js} +2 -2
  125. package/web/dist/assets/{pane-layout-0RrLHGDr.js → pane-layout-BW0RdX5a.js} +1 -1
  126. package/web/dist/assets/{renderer2d-BIwbgRoX.js → renderer2d-IaJiloPi.js} +1 -1
  127. package/web/dist/assets/{renderer3d-C1a7eYGQ.js → renderer3d-ZiD1uKGW.js} +1 -1
  128. package/web/dist/assets/{safe-area-DHhPy61Q.js → safe-area-C7IAlkgY.js} +1 -1
  129. package/web/dist/assets/{source-BWlvaODa.js → source-C-De8L3s.js} +1 -1
  130. package/web/dist/assets/toast-CXTwTvDw.js +9 -0
  131. package/web/dist/assets/vad-worklet-BfxJhrXy.js +27 -0
  132. package/web/dist/index.html +6 -6
  133. package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +0 -6
  134. package/web/dist/assets/Diagram-azw2B_QH.js +0 -1
  135. package/web/dist/assets/Lightbox-Du56MXGX.js +0 -1
  136. package/web/dist/assets/MediaImage-Ds_JUx3a.js +0 -4
  137. package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +0 -1
  138. package/web/dist/assets/index-C3ci5UtY.css +0 -1
  139. package/web/dist/assets/index-LNMZCqgE.js +0 -36
  140. package/web/dist/assets/toast-Cuu2K0hx.js +0 -1
  141. /package/{web/src/voice → packages/voice-client/src}/turn/LICENSE +0 -0
@@ -0,0 +1,145 @@
1
+ // Context cards configuration (docs/context-cards.md "Configuration"):
2
+ //
3
+ // hub.json "contextCards": {
4
+ // "enabled": true, // false (or env HUB_CONTEXT_CARDS=off): the hub-wide kill switch
5
+ // "model": { // the light model the hub calls (all optional)
6
+ // "provider": "openrouter", // or "openai-compatible" (needs baseURL)
7
+ // "model": "anthropic/claude-haiku-4.5", // default on OpenRouter
8
+ // "baseURL": "https://…/v1", // https, or http on a private host
9
+ // "apiKey": "…" // else OpenRouter reuses the speech OpenRouter key
10
+ // },
11
+ // "budget": { "calls": 200, "tokens": 400000 } // per user per UTC day
12
+ // }
13
+ //
14
+ // The speech key is reused only for OpenRouter at OpenRouter's own base URL (a key never follows the settings to
15
+ // another endpoint, as in speech/settings.ts). A file other users can read has its apiKey ignored (as speech's).
16
+ import { OPENROUTER_BASE_URL } from "../speech/openrouter"
17
+ import { isPrivateHost, type StoredSpeech } from "../speech/settings"
18
+
19
+ export const CARD_MODEL_DEFAULT = "anthropic/claude-haiku-4.5"
20
+ export const CARD_PROVIDERS = ["openrouter", "openai-compatible"] as const
21
+ export type CardProviderName = (typeof CARD_PROVIDERS)[number]
22
+
23
+ export interface ContextCardsConfig {
24
+ readonly enabled?: boolean
25
+ readonly model?: {
26
+ readonly provider?: CardProviderName
27
+ readonly model?: string
28
+ readonly baseURL?: string
29
+ readonly apiKey?: string
30
+ }
31
+ readonly budget?: { readonly calls?: number; readonly tokens?: number }
32
+ }
33
+
34
+ /** The model the hub calls, with its key (never sent to clients). */
35
+ export interface ResolvedCardModel {
36
+ readonly provider: CardProviderName
37
+ readonly model: string
38
+ readonly baseURL: string
39
+ readonly apiKey: string
40
+ /** config: contextCards.model.apiKey; speech: the speech OpenRouter key (saved, hub.json or env). */
41
+ readonly keySource: "config" | "speech"
42
+ }
43
+
44
+ const KEYS = new Set(["enabled", "model", "budget"])
45
+ const MODEL_KEYS = new Set(["provider", "model", "baseURL", "apiKey"])
46
+ const BUDGET_KEYS = new Set(["calls", "tokens"])
47
+ const MAX_CHARS = 500
48
+ const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
49
+
50
+ /** https, or http on a private host; no user info. */
51
+ function validBaseURL(raw: string): boolean {
52
+ try {
53
+ const url = new URL(raw)
54
+ if (url.username || url.password) return false
55
+ return url.protocol === "https:" || (url.protocol === "http:" && isPrivateHost(url.hostname))
56
+ } catch {
57
+ return false
58
+ }
59
+ }
60
+
61
+ const text = (value: unknown, name: string, fail: (message: string) => never): string => {
62
+ if (typeof value !== "string" || !value.trim() || value.length > MAX_CHARS) fail(`contextCards.${name} must be a non-empty string`)
63
+ return (value as string).trim()
64
+ }
65
+
66
+ /** hub.json `contextCards`, validated (fail throws); `shared`: the file is readable by others (its key is dropped). */
67
+ export function readContextCards(raw: unknown, file: string, warnings: string[], fail: (message: string) => never, shared: boolean): ContextCardsConfig {
68
+ if (!isObj(raw)) fail("contextCards must be an object")
69
+ const input = raw as Record<string, unknown>
70
+ for (const key of Object.keys(input)) if (!KEYS.has(key)) warnings.push(`${file}: unknown key "contextCards.${key}" ignored`)
71
+ const out: { -readonly [K in keyof ContextCardsConfig]: ContextCardsConfig[K] } = {}
72
+ if (input.enabled !== undefined) {
73
+ if (typeof input.enabled !== "boolean") fail("contextCards.enabled must be true or false")
74
+ out.enabled = input.enabled as boolean
75
+ }
76
+ if (input.model !== undefined) {
77
+ if (!isObj(input.model)) fail("contextCards.model must be an object")
78
+ const m = input.model as Record<string, unknown>
79
+ for (const key of Object.keys(m)) if (!MODEL_KEYS.has(key)) warnings.push(`${file}: unknown key "contextCards.model.${key}" ignored`)
80
+ const model: { provider?: CardProviderName; model?: string; baseURL?: string; apiKey?: string } = {}
81
+ if (m.provider !== undefined) {
82
+ if (!CARD_PROVIDERS.includes(m.provider as CardProviderName)) fail(`contextCards.model.provider must be one of ${CARD_PROVIDERS.join(", ")}`)
83
+ model.provider = m.provider as CardProviderName
84
+ }
85
+ if (m.model !== undefined) model.model = text(m.model, "model.model", fail)
86
+ if (m.baseURL !== undefined) {
87
+ const url = text(m.baseURL, "model.baseURL", fail).replace(/\/+$/, "")
88
+ if (!validBaseURL(url)) fail("contextCards.model.baseURL must be https (or http on a private host)")
89
+ model.baseURL = url
90
+ }
91
+ if (m.apiKey !== undefined) {
92
+ const key = text(m.apiKey, "model.apiKey", fail)
93
+ if (shared) warnings.push(`${file} is readable by other users, so its contextCards.model.apiKey is ignored. Fix it with: chmod 600 ${file}`)
94
+ else model.apiKey = key
95
+ }
96
+ out.model = model
97
+ }
98
+ if (input.budget !== undefined) {
99
+ if (!isObj(input.budget)) fail("contextCards.budget must be an object")
100
+ const b = input.budget as Record<string, unknown>
101
+ const budget: { calls?: number; tokens?: number } = {}
102
+ for (const key of Object.keys(b)) {
103
+ if (!BUDGET_KEYS.has(key)) {
104
+ warnings.push(`${file}: unknown key "contextCards.budget.${key}" ignored`)
105
+ continue
106
+ }
107
+ const n = b[key]
108
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 0) fail(`contextCards.budget.${key} must be a whole number ≥ 0`)
109
+ budget[key as "calls" | "tokens"] = n as number
110
+ }
111
+ out.budget = budget
112
+ }
113
+ return out
114
+ }
115
+
116
+ export interface KeySources {
117
+ /** OPENROUTER_API_KEY. */
118
+ readonly envKey?: string
119
+ /** Speech settings layers, highest first (the saved server default, then hub.json's). */
120
+ readonly speech?: readonly StoredSpeech[]
121
+ }
122
+
123
+ /** The speech settings' OpenRouter key (highest layer first), else the env's. */
124
+ function speechKey(sources: KeySources): string | undefined {
125
+ for (const layer of sources.speech ?? []) {
126
+ for (const direction of [layer.stt, layer.tts]) {
127
+ if (direction?.apiKey && (direction.provider ?? "openrouter") === "openrouter" && (direction.baseURL === undefined || direction.baseURL.replace(/\/+$/, "") === OPENROUTER_BASE_URL)) return direction.apiKey
128
+ }
129
+ }
130
+ return sources.envKey || undefined
131
+ }
132
+
133
+ /** The configured model, or undefined (off, or no usable key / base URL). */
134
+ export function resolveCardModel(config: ContextCardsConfig, sources: KeySources): ResolvedCardModel | undefined {
135
+ if (config.enabled === false) return undefined
136
+ const provider = config.model?.provider ?? "openrouter"
137
+ const openrouter = provider === "openrouter"
138
+ const model = config.model?.model ?? (openrouter ? CARD_MODEL_DEFAULT : undefined)
139
+ const baseURL = config.model?.baseURL ?? (openrouter ? OPENROUTER_BASE_URL : undefined)
140
+ if (!model || !baseURL) return undefined
141
+ if (config.model?.apiKey) return { provider, model, baseURL, apiKey: config.model.apiKey, keySource: "config" }
142
+ if (!openrouter || baseURL !== OPENROUTER_BASE_URL) return undefined
143
+ const apiKey = speechKey(sources)
144
+ return apiKey ? { provider, model, baseURL, apiKey, keySource: "speech" } : undefined
145
+ }
@@ -0,0 +1,379 @@
1
+ // The context card engine (decision 9604b0a1; docs/context-cards.md). Triggers come in per user and session
2
+ // (turn-end, blocked, user-return from http.ts's wiring; sweep from the leader's timer; manual from a refresh) and
3
+ // run each provider that lists the trigger, through these gates, in order:
4
+ //
5
+ // unknown provider → skipped "unknown"
6
+ // the kill switch / the user's setting (enabled) → "disabled"
7
+ // automatic only: recent human activity in the session (humanActive, injectable) → "inactive"
8
+ // automatic only: at most one run per provider and session per minIntervalMs → "throttled" (the last trigger is
9
+ // kept and runs once the interval is over)
10
+ // model providers: a model configured → "no-model"; then the inputs are gathered (only the provider's needs)
11
+ // automatic only: inputs (and provider version) unchanged since the stored card → "unchanged"
12
+ // model providers: the user's failure backoff → "backoff"; the hub-wide circuit breaker → "circuit"
13
+ // not forced: strictly weaker inputs than the stored card's (e.g. an offline spoke: no transcript) → "weaker"
14
+ // model providers: each call RESERVES one call in the user's daily budget first (atomic) → "budget"; the tokens it
15
+ // reports are added after it, also when it failed (a failed or timed-out call still counts)
16
+ // generate() → bounded (registry.boundCard) → the session still exists (exists) → "gone" → stored
17
+ // (ContextCardStore) → onCard (SSE + journal)
18
+ //
19
+ // Manual refreshes (refresh()): one run per card at a time (concurrent requests share it), at most one per card per
20
+ // REFRESH_CARD_MS and REFRESH_USER_MAX per user per minute ("rate_limited" with retryAfterMs), and unchanged inputs
21
+ // are skipped unless forced. run(…, "manual") is the forced, unlimited form (tests, internal).
22
+ //
23
+ // Automatic triggers are debounced (debounceMs, the latest wins) and only run where isLeader() holds (one pod
24
+ // generates: http.ts elects "context-cards"). A manual refresh runs at once on the pod that took the request. A run
25
+ // never throws: failures are a `failed` outcome (logged without text). The primary provider's run on a session
26
+ // whose title is a placeholder (agent/names.ts) asks the existing autoName op, at most every AUTO_NAME_EVERY_MS
27
+ // per session; real titles are never touched.
28
+ import { createHash } from "node:crypto"
29
+ import type { ContextCard, ContextCardTrigger } from "../../protocol"
30
+ import { realTimers } from "../backend/memory-bus"
31
+ import type { Timers } from "../backend/types"
32
+ import { isPlaceholderTitle } from "../agent/names"
33
+ import { ChatError, type ChatClient } from "../llm/chat"
34
+ import type { CardBudget } from "./budget"
35
+ import { boundCard, type CardRegistry } from "./registry"
36
+ import type { ContextCardStore } from "./store"
37
+ import type { CardChat, CardInputs, ContextCardProvider, InputNeed } from "./types"
38
+
39
+ export const DEBOUNCE_MS = 15_000
40
+ /** Manual refreshes: at most one per card this often, and this many per user per minute. */
41
+ export const REFRESH_CARD_MS = 30_000
42
+ export const REFRESH_USER_MAX = 10
43
+ /** Consecutive model failures before a user backs off (BACKOFF_BASE_MS doubling, at most BACKOFF_MAX_MS). */
44
+ export const BACKOFF_AFTER = 3
45
+ export const BACKOFF_BASE_MS = 60_000
46
+ export const BACKOFF_MAX_MS = 3_600_000
47
+ /**
48
+ * The hub-wide breaker opens when PROVIDER-level errors (ChatError kind "provider": 401/403/408/429/5xx, network)
49
+ * came from at least CIRCUIT_USERS distinct users within CIRCUIT_WINDOW_MS, and pauses every model call for
50
+ * CIRCUIT_MS. One user can never open it alone, and content errors never count.
51
+ */
52
+ export const CIRCUIT_USERS = 3
53
+ export const CIRCUIT_WINDOW_MS = 5 * 60_000
54
+ export const CIRCUIT_MS = 5 * 60_000
55
+
56
+ /** The budget refused a model call (the run is skipped "budget"). */
57
+ class BudgetRefused extends Error {}
58
+ /** A placeholder-titled session's auto name is asked at most this often. */
59
+ export const AUTO_NAME_EVERY_MS = 30 * 60_000
60
+ /** In-memory throttle entries kept (oldest dropped first). */
61
+ const TRACKED = 10_000
62
+
63
+ export interface CardModel {
64
+ readonly client: ChatClient
65
+ readonly model: string
66
+ }
67
+
68
+ export interface EngineDeps {
69
+ readonly registry: CardRegistry
70
+ readonly store: ContextCardStore
71
+ readonly budget: CardBudget
72
+ /** Gathers the inputs a provider needs (./inputs.ts). */
73
+ readonly inputs: (userID: string, sessionID: string, needs: readonly InputNeed[]) => Promise<CardInputs>
74
+ /**
75
+ * Whether the user was recently active in the session (automatic triggers need it): the persisted per-user
76
+ * activity (src/central/human-activity.ts) in the hub, an in-memory map without it.
77
+ */
78
+ readonly humanActive: (userID: string, sessionID: string) => boolean | Promise<boolean>
79
+ /** The hub's kill switch and the user's setting. */
80
+ readonly enabled: (userID: string) => Promise<boolean>
81
+ /** The configured model, else undefined (model providers are skipped). */
82
+ readonly model: () => CardModel | undefined
83
+ /** Whether this pod generates automatic cards (the "context-cards" leader). */
84
+ readonly isLeader: () => boolean
85
+ readonly onCard: (userID: string, card: ContextCard) => void
86
+ /** The autoName op for a placeholder-titled session (best effort). */
87
+ readonly autoName?: (userID: string, sessionID: string) => Promise<void>
88
+ /** Whether the session still exists right before a card is stored (a deleted session gets none). */
89
+ readonly exists?: (userID: string, sessionID: string) => Promise<boolean>
90
+ /** The session's live title, re-checked right before an auto name (a title the user just set wins). */
91
+ readonly title?: (userID: string, sessionID: string) => string | undefined
92
+ /** Sessions the sweep looks at (recently active ones). */
93
+ readonly recent?: () => Iterable<{ readonly userID: string; readonly sessionID: string }>
94
+ readonly now?: () => number
95
+ readonly timers?: Timers
96
+ readonly debounceMs?: number
97
+ readonly log?: (message: string) => void
98
+ }
99
+
100
+ export type SkipReason = "unknown" | "disabled" | "inactive" | "throttled" | "no-model" | "unchanged" | "budget" | "empty" | "gone" | "backoff" | "circuit" | "weaker" | "rate_limited"
101
+
102
+ export type RunOutcome =
103
+ | { readonly status: "generated"; readonly card: ContextCard }
104
+ | { readonly status: "skipped"; readonly reason: SkipReason; readonly retryAfterMs?: number }
105
+ | { readonly status: "failed"; readonly error: string }
106
+
107
+ const keyOf = (userID: string, sessionID: string, provider: string) => `${userID}\n${sessionID}\n${provider}`
108
+
109
+ /** Hash of a provider's inputs and version (unchanged → no regeneration). */
110
+ export function inputsHash(provider: Pick<ContextCardProvider, "id" | "version">, inputs: CardInputs): string {
111
+ return createHash("sha256").update(JSON.stringify([provider.id, provider.version, inputs])).digest("hex").slice(0, 24)
112
+ }
113
+
114
+ /** The needs a run actually had (an offline spoke leaves the transcript and changes out). */
115
+ function basisOf(needs: readonly InputNeed[], inputs: CardInputs): InputNeed[] {
116
+ return needs.filter((need) => {
117
+ switch (need) {
118
+ case "status":
119
+ return inputs.state !== undefined
120
+ case "intent":
121
+ return !!inputs.intent
122
+ case "transcript":
123
+ return !!inputs.transcript?.length
124
+ case "changes":
125
+ return inputs.changes !== undefined
126
+ }
127
+ })
128
+ }
129
+
130
+ /** `now` is a strict subset of `before`. */
131
+ const weaker = (now: readonly string[], before: readonly string[]) => now.length < before.length && now.every((n) => before.includes(n))
132
+
133
+ /** Sets `key` as the newest entry of a bounded, insertion-ordered map. */
134
+ function remember<K, V>(map: Map<K, V>, key: K, value: V): void {
135
+ map.delete(key)
136
+ map.set(key, value)
137
+ if (map.size > TRACKED) map.delete(map.keys().next().value!)
138
+ }
139
+
140
+ export class ContextCardEngine {
141
+ private readonly now: () => number
142
+ private readonly timers: Timers
143
+ private readonly debounceMs: number
144
+ private readonly log: (message: string) => void
145
+ /** Pending automatic runs: key → timer and the latest trigger. */
146
+ private readonly pending = new Map<string, { handle: unknown; trigger: ContextCardTrigger }>()
147
+ /** key → when its last automatic-eligible run started generating. */
148
+ private readonly lastRun = new Map<string, number>()
149
+ /** `user\nsession` → when its auto name was last asked. */
150
+ private readonly named = new Map<string, number>()
151
+ /** `user\nsession` → forget() count (a run that started before a forget does not store). */
152
+ private readonly epochs = new Map<string, number>()
153
+ private readonly running = new Set<Promise<unknown>>()
154
+ /** Manual refreshes in flight, per card. */
155
+ private readonly inflight = new Map<string, Promise<RunOutcome>>()
156
+ /** Manual refreshes: per card the last, per user the times within the last minute. */
157
+ private readonly lastRefresh = new Map<string, number>()
158
+ private readonly userRefreshes = new Map<string, number[]>()
159
+ /** Model failures per user: consecutive count and the backoff's end (only that user's success clears it). */
160
+ private readonly failures = new Map<string, { count: number; until: number }>()
161
+ /** Model attempts in flight per user (a user with a pending failure gets one at a time). */
162
+ private readonly attempts = new Map<string, number>()
163
+ /** Provider-level failures per user (last time), for the hub-wide breaker; and the breaker's end. */
164
+ private readonly providerFailures = new Map<string, number>()
165
+ private circuitUntil = 0
166
+
167
+ constructor(private readonly deps: EngineDeps) {
168
+ this.now = deps.now ?? Date.now
169
+ this.timers = deps.timers ?? realTimers
170
+ this.debounceMs = deps.debounceMs ?? DEBOUNCE_MS
171
+ this.log = deps.log ?? ((message) => console.error(message))
172
+ }
173
+
174
+ /** An automatic trigger: each provider listing it runs after the debounce (on the leader only). */
175
+ trigger(userID: string, sessionID: string, trigger: Exclude<ContextCardTrigger, "manual">): void {
176
+ if (!this.deps.isLeader()) return
177
+ for (const provider of this.deps.registry.list()) {
178
+ if (provider.triggers.includes(trigger)) this.schedule(keyOf(userID, sessionID, provider.id), userID, sessionID, provider.id, trigger, this.debounceMs)
179
+ }
180
+ }
181
+
182
+ /** The sweep: a `sweep` trigger for each recently active session; answers how many. */
183
+ sweep(): number {
184
+ if (!this.deps.isLeader()) return 0
185
+ let n = 0
186
+ for (const { userID, sessionID } of this.deps.recent?.() ?? []) {
187
+ this.trigger(userID, sessionID, "sweep")
188
+ n++
189
+ }
190
+ return n
191
+ }
192
+
193
+ /** Waits for every run in flight (tests, drain). */
194
+ async settled(): Promise<void> {
195
+ while (this.running.size) await Promise.allSettled([...this.running])
196
+ }
197
+
198
+ /** Drops a session's cards and pending runs (session deleted). */
199
+ async forget(userID: string, sessionID: string): Promise<void> {
200
+ const prefix = `${userID}\n${sessionID}\n`
201
+ for (const [key, entry] of [...this.pending]) {
202
+ if (!key.startsWith(prefix)) continue
203
+ this.timers.clear(entry.handle)
204
+ this.pending.delete(key)
205
+ }
206
+ const session = `${userID}\n${sessionID}`
207
+ remember(this.epochs, session, (this.epochs.get(session) ?? 0) + 1)
208
+ await this.deps.store.forget(userID, sessionID)
209
+ }
210
+
211
+ /** A user's manual refresh: deduped per card, rate limited, unchanged inputs skipped unless forced. Never throws. */
212
+ refresh(userID: string, sessionID: string, providerID: string, options: { readonly force?: boolean } = {}): Promise<RunOutcome> {
213
+ const key = keyOf(userID, sessionID, providerID)
214
+ const shared = this.inflight.get(key)
215
+ if (shared) return shared
216
+ const now = this.now()
217
+ const last = this.lastRefresh.get(key)
218
+ if (last !== undefined && now - last < REFRESH_CARD_MS) return Promise.resolve({ status: "skipped", reason: "rate_limited", retryAfterMs: last + REFRESH_CARD_MS - now })
219
+ const recent = (this.userRefreshes.get(userID) ?? []).filter((at) => now - at < 60_000)
220
+ if (recent.length >= REFRESH_USER_MAX) return Promise.resolve({ status: "skipped", reason: "rate_limited", retryAfterMs: recent[0]! + 60_000 - now })
221
+ remember(this.userRefreshes, userID, [...recent, now])
222
+ remember(this.lastRefresh, key, now)
223
+ const run = this.run(userID, sessionID, providerID, "manual", { force: options.force === true }).finally(() => this.inflight.delete(key))
224
+ this.inflight.set(key, run)
225
+ return run
226
+ }
227
+
228
+ /** Runs one provider for a session now, through the gates; never throws. A manual run is forced unless told. */
229
+ run(userID: string, sessionID: string, providerID: string, trigger: ContextCardTrigger, options: { readonly force?: boolean } = {}): Promise<RunOutcome> {
230
+ const force = trigger === "manual" && options.force !== false
231
+ const run = this.attempt(userID, sessionID, providerID, trigger, force).catch((cause: unknown): RunOutcome => {
232
+ if (cause instanceof BudgetRefused) return { status: "skipped", reason: "budget" }
233
+ const error = cause instanceof Error ? cause.message : String(cause)
234
+ this.log(`[hub] context card ${providerID} failed: ${error.slice(0, 200)}`)
235
+ return { status: "failed", error }
236
+ })
237
+ this.running.add(run)
238
+ void run.finally(() => this.running.delete(run))
239
+ return run
240
+ }
241
+
242
+ private schedule(key: string, userID: string, sessionID: string, providerID: string, trigger: ContextCardTrigger, delayMs: number): void {
243
+ const existing = this.pending.get(key)
244
+ if (existing) {
245
+ existing.trigger = trigger
246
+ return
247
+ }
248
+ const entry = {
249
+ trigger,
250
+ handle: this.timers.set(() => {
251
+ this.pending.delete(key)
252
+ if (this.deps.isLeader()) void this.run(userID, sessionID, providerID, entry.trigger)
253
+ }, delayMs),
254
+ }
255
+ this.pending.set(key, entry)
256
+ }
257
+
258
+ private async attempt(userID: string, sessionID: string, providerID: string, trigger: ContextCardTrigger, force: boolean): Promise<RunOutcome> {
259
+ const provider = this.deps.registry.get(providerID)
260
+ if (!provider) return { status: "skipped", reason: "unknown" }
261
+ if (!(await this.deps.enabled(userID))) return { status: "skipped", reason: "disabled" }
262
+ const manual = trigger === "manual"
263
+ const key = keyOf(userID, sessionID, provider.id)
264
+ if (!manual) {
265
+ if (!(await this.deps.humanActive(userID, sessionID))) return { status: "skipped", reason: "inactive" }
266
+ const last = this.lastRun.get(key)
267
+ const wait = last === undefined ? 0 : last + provider.minIntervalMs - this.now()
268
+ if (wait > 0) {
269
+ this.schedule(key, userID, sessionID, provider.id, trigger, wait)
270
+ return { status: "skipped", reason: "throttled" }
271
+ }
272
+ }
273
+ const model = provider.usesModel ? this.deps.model() : undefined
274
+ if (provider.usesModel && !model) return { status: "skipped", reason: "no-model" }
275
+ if (model) {
276
+ if (this.now() < this.circuitUntil) return { status: "skipped", reason: "circuit" }
277
+ if (this.now() < (this.failures.get(userID)?.until ?? 0)) return { status: "skipped", reason: "backoff" }
278
+ }
279
+ const session = `${userID}\n${sessionID}`
280
+ const epoch = this.epochs.get(session) ?? 0
281
+ const inputs = await this.deps.inputs(userID, sessionID, provider.needs)
282
+ const hash = inputsHash(provider, inputs)
283
+ const basis = basisOf(provider.needs, inputs)
284
+ if (!force) {
285
+ const stored = await this.deps.store.get(userID, sessionID, provider.id)
286
+ if (stored?.inputsHash === hash && stored.version === provider.version) return { status: "skipped", reason: "unchanged" }
287
+ if (stored?.basis && weaker(basis, stored.basis)) return { status: "skipped", reason: "weaker" }
288
+ }
289
+ if (model) {
290
+ // Checked and counted with no await between: concurrent attempts see each other. After a failure that is not
291
+ // cleared yet, a user's attempts run one at a time (a burst cannot fail many calls at once).
292
+ if (this.now() < this.circuitUntil) return { status: "skipped", reason: "circuit" }
293
+ const failing = this.failures.get(userID)
294
+ if (failing && (this.now() < failing.until || (this.attempts.get(userID) ?? 0) > 0)) return { status: "skipped", reason: "backoff" }
295
+ this.attempts.set(userID, (this.attempts.get(userID) ?? 0) + 1)
296
+ }
297
+ remember(this.lastRun, key, this.now())
298
+ let draft
299
+ try {
300
+ draft = await provider.generate({ userID, sessionID, trigger, inputs, now: this.now(), ...(model ? { chat: this.chat(userID, model) } : {}) })
301
+ } catch (cause) {
302
+ if (model && !(cause instanceof BudgetRefused)) this.failed(userID, cause)
303
+ throw cause
304
+ } finally {
305
+ if (model) {
306
+ const left = (this.attempts.get(userID) ?? 1) - 1
307
+ if (left > 0) this.attempts.set(userID, left)
308
+ else this.attempts.delete(userID)
309
+ }
310
+ }
311
+ if (model) this.succeeded(userID)
312
+ const card = boundCard(provider, sessionID, draft, { generatedAt: this.now(), inputsHash: hash, trigger, basis })
313
+ if (!card) return { status: "skipped", reason: "empty" }
314
+ if ((this.epochs.get(session) ?? 0) !== epoch) return { status: "skipped", reason: "gone" }
315
+ if (this.deps.exists && !(await this.deps.exists(userID, sessionID))) return { status: "skipped", reason: "gone" }
316
+ // The user may have turned cards off during the run (privacy): store and send nothing, before and after the write.
317
+ if (!(await this.deps.enabled(userID))) return { status: "skipped", reason: "disabled" }
318
+ await this.deps.store.put(userID, card)
319
+ if (!(await this.deps.enabled(userID))) {
320
+ await this.deps.store.forget(userID, sessionID)
321
+ return { status: "skipped", reason: "disabled" }
322
+ }
323
+ this.deps.onCard(userID, card)
324
+ if (provider.primary) this.maybeName(userID, sessionID, inputs)
325
+ return { status: "generated", card }
326
+ }
327
+
328
+ /** The model for one run: each call reserves its place in the user's budget first; its tokens are added after. */
329
+ private chat(userID: string, model: CardModel): CardChat {
330
+ return {
331
+ model: model.model,
332
+ complete: async (request) => {
333
+ if (!(await this.deps.budget.reserve(userID)).ok) throw new BudgetRefused("budget")
334
+ try {
335
+ const result = await model.client.complete({ ...request, model: model.model })
336
+ await this.deps.budget.settle(userID, result.usage.inputTokens + result.usage.outputTokens)
337
+ return result
338
+ } catch (cause) {
339
+ const usage = cause instanceof ChatError ? cause.usage : undefined
340
+ if (usage) await this.deps.budget.settle(userID, usage.inputTokens + usage.outputTokens).catch(() => {})
341
+ throw cause
342
+ }
343
+ },
344
+ }
345
+ }
346
+
347
+ private failed(userID: string, cause: unknown): void {
348
+ const now = this.now()
349
+ const count = (this.failures.get(userID)?.count ?? 0) + 1
350
+ const until = count >= BACKOFF_AFTER ? now + Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** (count - BACKOFF_AFTER)) : 0
351
+ remember(this.failures, userID, { count, until })
352
+ if (!(cause instanceof ChatError) || cause.kind !== "provider") return
353
+ remember(this.providerFailures, userID, now)
354
+ for (const [user, at] of this.providerFailures) if (now - at > CIRCUIT_WINDOW_MS) this.providerFailures.delete(user)
355
+ if (this.providerFailures.size >= CIRCUIT_USERS) {
356
+ this.providerFailures.clear()
357
+ this.circuitUntil = now + CIRCUIT_MS
358
+ this.log("[hub] context cards: the model keeps failing; paused for 5 minutes")
359
+ }
360
+ }
361
+
362
+ /** Clears this user's failures only; the hub's record only when this user is not among its recent failures. */
363
+ private succeeded(userID: string): void {
364
+ this.failures.delete(userID)
365
+ if (!this.providerFailures.has(userID)) this.providerFailures.clear()
366
+ }
367
+
368
+ /** A placeholder title (known from the status input): ask the autoName op, throttled per session. */
369
+ private maybeName(userID: string, sessionID: string, inputs: CardInputs): void {
370
+ if (!this.deps.autoName || !("title" in inputs) || !isPlaceholderTitle(inputs.title)) return
371
+ // Re-checked now: the user may have named it while the card was being made.
372
+ if (this.deps.title && !isPlaceholderTitle(this.deps.title(userID, sessionID))) return
373
+ const session = `${userID}\n${sessionID}`
374
+ const last = this.named.get(session)
375
+ if (last !== undefined && this.now() - last < AUTO_NAME_EVERY_MS) return
376
+ remember(this.named, session, this.now())
377
+ void this.deps.autoName(userID, sessionID).catch((cause: unknown) => this.log(`[hub] context card auto name failed: ${(cause as Error).message}`))
378
+ }
379
+ }
@@ -0,0 +1,111 @@
1
+ // The context card inputs gatherer: only what a provider needs, each piece best effort (an offline spoke or a
2
+ // failed read leaves it out; the provider decides what it can do without).
3
+ // status: the live session (registry): title, state (the session's own status entry), open blockers' summaries
4
+ // intent: the session's effective intent text (CON-02)
5
+ // transcript: the newest timeline rows from the spoke (op "timeline", order desc): user, assistant, tool and error
6
+ // rows, oldest first, sanitised and cut (reasoning, retries and hub messages are left out; tool rows
7
+ // keep only the tool's name and state, never their input or output)
8
+ // changes: the spoke summary's working-tree changes and branch
9
+ import { redactCredentials } from "../../../packages/protocol/src/credentials.ts"
10
+ import { sanitizeText } from "../agent/envelope"
11
+ import type { CardInputs, ChangesInput, InputNeed, TranscriptRow } from "./types"
12
+
13
+ /** Timeline rows asked for. */
14
+ export const TRANSCRIPT_ROWS = 40
15
+ /** A row's text is cut to this many characters. */
16
+ export const ROW_TEXT_MAX = 1500
17
+ const BLOCKERS_MAX = 5
18
+ const BLOCKER_MAX = 200
19
+ const NAME_MAX = 60
20
+
21
+ export interface InputSession {
22
+ readonly title?: string
23
+ readonly status?: ReadonlyArray<{ readonly id: string; readonly state: string; readonly blockers?: ReadonlyArray<{ readonly summary?: string }> }>
24
+ }
25
+
26
+ export interface InputDeps {
27
+ readonly session: (userID: string, sessionID: string) => InputSession | undefined
28
+ readonly intent: (userID: string, sessionID: string) => Promise<string | undefined>
29
+ /** The newest timeline rows (TRANSCRIPT_ROWS). */
30
+ readonly timeline: (userID: string, sessionID: string) => Promise<readonly unknown[]>
31
+ /** The spoke's summary (GET /api/sessions/:id/summary's body). */
32
+ readonly summary: (userID: string, sessionID: string) => Promise<unknown>
33
+ }
34
+
35
+ const rec = (v: unknown): Record<string, unknown> => (typeof v === "object" && v !== null ? (v as Record<string, unknown>) : {})
36
+ const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : 0)
37
+ /** Raw text redacted at most (bounds the work on a hostile row); far beyond any cut, so redaction always runs first. */
38
+ const REDACT_MAX = 1024 * 1024
39
+ /** Sanitised and credentials redacted first, then cut: a secret straddling the limit never leaks a part. */
40
+ const cut = (text: string, max: number) =>
41
+ redactCredentials(sanitizeText(text.slice(0, REDACT_MAX))).text.replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n").trim().slice(0, max)
42
+
43
+ const KINDS: ReadonlySet<string> = new Set(["user", "assistant", "tool", "error"])
44
+
45
+ /** Timeline items → transcript rows, oldest first (by `pos` when every row has one). */
46
+ export function transcriptRows(items: readonly unknown[]): TranscriptRow[] {
47
+ const rows = items.map(rec).filter((item) => typeof item.kind === "string" && KINDS.has(item.kind))
48
+ if (rows.every((item) => typeof item.pos === "number")) rows.sort((a, b) => num(a.pos) - num(b.pos))
49
+ const out: TranscriptRow[] = []
50
+ for (const item of rows) {
51
+ const kind = item.kind as TranscriptRow["kind"]
52
+ if (kind === "tool") {
53
+ const name = typeof item.name === "string" ? cut(item.name, NAME_MAX) : ""
54
+ if (name) out.push({ kind, name, ...(typeof item.state === "string" ? { state: cut(item.state, 16) } : {}) })
55
+ continue
56
+ }
57
+ const text = typeof item.text === "string" ? cut(item.text, ROW_TEXT_MAX) : ""
58
+ if (text) out.push({ kind, text })
59
+ }
60
+ return out
61
+ }
62
+
63
+ /** The spoke summary's changes (undefined for anything but an object). */
64
+ export function changesInput(summary: unknown): ChangesInput | undefined {
65
+ if (typeof summary !== "object" || summary === null) return undefined
66
+ const s = rec(summary)
67
+ const c = rec(s.changes)
68
+ const files = (Array.isArray(c.files) ? c.files : [])
69
+ .map(rec)
70
+ .filter((f) => typeof f.file === "string" && f.file)
71
+ .map((f) => ({ file: String(f.file), additions: num(f.additions), deletions: num(f.deletions) }))
72
+ return {
73
+ ...(typeof s.branch === "string" && s.branch ? { branch: s.branch } : {}),
74
+ files,
75
+ totalFiles: num(c.totalFiles) || files.length,
76
+ additions: num(c.additions),
77
+ deletions: num(c.deletions),
78
+ }
79
+ }
80
+
81
+ const quietly = async <T>(read: () => Promise<T>): Promise<T | undefined> => {
82
+ try {
83
+ return await read()
84
+ } catch {
85
+ return undefined
86
+ }
87
+ }
88
+
89
+ export async function gatherInputs(deps: InputDeps, userID: string, sessionID: string, needs: readonly InputNeed[]): Promise<CardInputs> {
90
+ const want = new Set(needs)
91
+ const live = want.has("status") ? deps.session(userID, sessionID) : undefined
92
+ const own = live?.status?.find((s) => s.id === sessionID) ?? live?.status?.[0]
93
+ const blockers = (own?.blockers ?? [])
94
+ .map((b) => (typeof b.summary === "string" ? cut(b.summary, BLOCKER_MAX).replace(/\s+/g, " ") : ""))
95
+ .filter(Boolean)
96
+ .slice(0, BLOCKERS_MAX)
97
+ const [intent, timeline, summary] = await Promise.all([
98
+ want.has("intent") ? quietly(() => deps.intent(userID, sessionID)) : undefined,
99
+ want.has("transcript") ? quietly(() => deps.timeline(userID, sessionID)) : undefined,
100
+ want.has("changes") ? quietly(() => deps.summary(userID, sessionID)) : undefined,
101
+ ])
102
+ const changes = summary === undefined ? undefined : changesInput(summary)
103
+ return {
104
+ sessionID,
105
+ ...(live?.title !== undefined ? { title: live.title } : {}),
106
+ ...(own ? { state: own.state, blockers } : {}),
107
+ ...(intent ? { intent } : {}),
108
+ ...(timeline ? { transcript: transcriptRows(timeline) } : {}),
109
+ ...(changes ? { changes } : {}),
110
+ }
111
+ }