@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,134 @@
1
+ // The `pickup` context card (primary): "Pick up where you left off", a short summary the user (and the Concierge)
2
+ // reads to get back into a session: what you were doing / last asked, what the agent did since, where it stands,
3
+ // and the next step or what it waits on. Written by the context card model from a bounded, sanitised, redacted
4
+ // view of the session (title unless a placeholder, intent, state, blockers, the newest transcript rows: tool rows
5
+ // as names only). Its one-line headline is the session list's second line.
6
+ import { redactCredentials } from "../../../packages/protocol/src/credentials.ts"
7
+ import { isPlaceholderTitle } from "../agent/names"
8
+ import type { CardDraft, CardInputs, ContextCardProvider, TranscriptRow } from "./types"
9
+
10
+ /** The prompt's user message stays within this many characters. */
11
+ export const PICKUP_PROMPT_MAX = 8000
12
+ /** A transcript row in the prompt is cut to this many characters. */
13
+ const PROMPT_ROW_MAX = 1200
14
+ /** Room for reasoning models, which spend output tokens before they answer (the answer itself is ~150 tokens). */
15
+ export const PICKUP_MAX_TOKENS = 800
16
+
17
+ export const PICKUP_SYSTEM = [
18
+ "You write a short pick-up note for one AI coding session so its user (or their assistant) can get back into it fast.",
19
+ "Read the session facts and transcript excerpt. Be terse and factual; never invent work that is not in the excerpt.",
20
+ "Answer ONE JSON object only:",
21
+ '{"headline": "<= 90 characters: where the work stands now, e.g. \\"Login loop fixed; waiting for your review\\"",',
22
+ ' "summary": "<= 300 characters: what the user was doing or last asked, what the agent did since, and the current state",',
23
+ ' "next": "<= 120 characters: the next step, or what the session is waiting on (omit if nothing)"}',
24
+ "Write to the user as \"you\". No greetings, no markdown headings, no secrets.",
25
+ ].join("\n")
26
+
27
+ const LABEL: Record<Exclude<TranscriptRow["kind"], "tool">, string> = { user: "User", assistant: "Agent", error: "Error", message: "Message" }
28
+
29
+ /** Transcript rows as prompt lines, oldest first; consecutive tool rows fold into one "Tools: a, b" line. */
30
+ function lines(rows: readonly TranscriptRow[]): string[] {
31
+ const out: string[] = []
32
+ let tools: string[] = []
33
+ const flush = () => {
34
+ if (tools.length) out.push(`Tools: ${tools.join(", ")}`)
35
+ tools = []
36
+ }
37
+ for (const row of rows) {
38
+ if (row.kind === "tool") {
39
+ if (row.name) tools.push(row.state === "failed" ? `${row.name} (failed)` : row.name)
40
+ continue
41
+ }
42
+ flush()
43
+ if (row.text) out.push(`${LABEL[row.kind]}: ${clip(row.text, PROMPT_ROW_MAX)}`)
44
+ }
45
+ flush()
46
+ return out
47
+ }
48
+
49
+ /** Credentials redacted, THEN cut: a secret straddling the limit never leaks a part. */
50
+ const clip = (text: string, max: number) => redactCredentials(text).text.slice(0, max)
51
+
52
+ export function buildPickupPrompt(inputs: CardInputs): { system: string; user: string } {
53
+ const head: string[] = []
54
+ if (!isPlaceholderTitle(inputs.title)) head.push(`Title: ${clip(inputs.title!, 200)}`)
55
+ if (inputs.intent) head.push(`Intent: ${clip(inputs.intent, 280)}`)
56
+ if (inputs.state) head.push(`State: ${clip(inputs.state, 20)}`)
57
+ if (inputs.blockers?.length) head.push(`Waiting on: ${clip(inputs.blockers.join("; "), 1000)}`)
58
+ const header = `${head.join("\n")}\n\nTranscript (oldest first):\n`
59
+ // Newest lines first until the budget is used, then back in order.
60
+ const kept: string[] = []
61
+ let used = header.length
62
+ for (const line of lines(inputs.transcript ?? []).reverse()) {
63
+ const clean = line
64
+ if (used + clean.length + 1 > PICKUP_PROMPT_MAX) break
65
+ kept.push(clean)
66
+ used += clean.length + 1
67
+ }
68
+ return { system: PICKUP_SYSTEM, user: `${header}${kept.reverse().join("\n")}`.slice(0, PICKUP_PROMPT_MAX) }
69
+ }
70
+
71
+ /** The model's answer as a draft: JSON {headline, summary, next?} (fenced or not); plain text: its first line. */
72
+ export function parsePickup(text: string): CardDraft | undefined {
73
+ const raw = text.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "").trim()
74
+ if (!raw) return undefined
75
+ const start = raw.indexOf("{")
76
+ const end = raw.lastIndexOf("}")
77
+ if (start !== -1 && end > start) {
78
+ let parsed: unknown
79
+ try {
80
+ parsed = JSON.parse(raw.slice(start, end + 1))
81
+ } catch {
82
+ parsed = undefined
83
+ }
84
+ if (parsed !== undefined) {
85
+ const answer = (typeof parsed === "object" && parsed !== null ? parsed : {}) as Record<string, unknown>
86
+ const str = (v: unknown) => (typeof v === "string" ? v.trim() : "")
87
+ const headline = str(answer.headline)
88
+ if (!headline) return undefined
89
+ const summary = str(answer.summary)
90
+ const next = str(answer.next)
91
+ return { headline, body: [summary, next ? `Next: ${next}` : ""].filter(Boolean).join("\n\n") }
92
+ }
93
+ }
94
+ // JSON the model cut or broke: its headline field when it can be read, else nothing (never the raw JSON).
95
+ if (/^[[{]/.test(raw)) {
96
+ const headline = /"headline"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(raw)?.[1]
97
+ if (headline === undefined) return undefined
98
+ let text: string
99
+ try {
100
+ text = JSON.parse(`"${headline}"`) as string
101
+ } catch {
102
+ return undefined
103
+ }
104
+ return text.trim() ? { headline: text.trim(), body: "" } : undefined
105
+ }
106
+ const first = raw.split("\n").map((l) => l.trim()).find(Boolean)
107
+ return first && !/[{}"]/.test(first.slice(0, 2)) ? { headline: first, body: "" } : undefined
108
+ }
109
+
110
+ export function pickupProvider(options: { readonly minIntervalMs?: number } = {}): ContextCardProvider {
111
+ return {
112
+ id: "pickup",
113
+ label: "Pick up where you left off",
114
+ version: 1,
115
+ primary: true,
116
+ usesModel: true,
117
+ triggers: ["turn-end", "blocked", "user-return", "sweep"],
118
+ minIntervalMs: options.minIntervalMs ?? 5 * 60_000,
119
+ needs: ["status", "intent", "transcript"],
120
+ async generate({ inputs, chat }) {
121
+ if (!chat) return undefined
122
+ if (!inputs.transcript?.length && !inputs.intent) return undefined
123
+ const { system, user } = buildPickupPrompt(inputs)
124
+ const answer = await chat.complete({ system, user, maxTokens: PICKUP_MAX_TOKENS, json: true })
125
+ const draft = parsePickup(answer.text)
126
+ if (!draft) return undefined
127
+ const fields = [
128
+ ...(inputs.state ? [{ label: "State", value: inputs.state }] : []),
129
+ ...(inputs.blockers?.length ? [{ label: "Waiting on", value: inputs.blockers.join("; ") }] : []),
130
+ ]
131
+ return { ...draft, ...(fields.length ? { fields } : {}), model: answer.model }
132
+ },
133
+ }
134
+ }
@@ -0,0 +1,89 @@
1
+ // The context card provider registry and the bounding of a provider's draft into a stored card. Card text is
2
+ // untrusted (a model or a session wrote it): sanitised (control, ANSI, bidi, zero-width characters go), literal
3
+ // credentials redacted, every field cut to CONTEXT_CARD_LIMITS. A draft without a headline is no card.
4
+ import { redactCredentials } from "../../../packages/protocol/src/credentials.ts"
5
+ import { CONTEXT_CARD_LIMITS, type ContextCard, type ContextCardField, type ContextCardProviderInfo, type ContextCardTrigger } from "../../protocol"
6
+ import { sanitizeText } from "../agent/envelope"
7
+ import type { CardDraft, ContextCardProvider } from "./types"
8
+
9
+ const PROVIDER_ID = /^[a-z][a-z0-9-]{1,31}$/
10
+
11
+ export class CardRegistry {
12
+ private readonly providers = new Map<string, ContextCardProvider>()
13
+
14
+ /** Adds a provider; throws on a bad or taken id, or a second primary. */
15
+ register(provider: ContextCardProvider): void {
16
+ if (!PROVIDER_ID.test(provider.id)) throw new Error(`Context card provider id ${JSON.stringify(provider.id)} must be 2–32 lowercase letters, digits or -`)
17
+ if (this.providers.has(provider.id)) throw new Error(`Context card provider ${provider.id} is registered already`)
18
+ if (provider.primary && this.primary()) throw new Error(`Context card provider ${provider.id}: ${this.primary()!.id} is the primary already`)
19
+ this.providers.set(provider.id, provider)
20
+ }
21
+
22
+ list(): ContextCardProvider[] {
23
+ return [...this.providers.values()]
24
+ }
25
+
26
+ get(id: string): ContextCardProvider | undefined {
27
+ return this.providers.get(id)
28
+ }
29
+
30
+ primary(): ContextCardProvider | undefined {
31
+ return this.list().find((p) => p.primary)
32
+ }
33
+
34
+ /** What clients see of the providers. */
35
+ info(): ContextCardProviderInfo[] {
36
+ return this.list().map((p) => ({ id: p.id, label: p.label, primary: p.primary === true, usesModel: p.usesModel === true }))
37
+ }
38
+ }
39
+
40
+ /** Sanitised, credentials redacted, cut to `max` code points. `lines`: newlines kept (runs of blank lines folded). */
41
+ export function cardText(input: unknown, max: number, lines = false): string {
42
+ if (typeof input !== "string") return ""
43
+ // Redacted on the whole (bounded) text BEFORE the cut, so a secret straddling the limit never leaks a part.
44
+ let text = redactCredentials(sanitizeText(input.slice(0, 1024 * 1024))).text
45
+ text = lines ? text.replace(/[ \t]+/g, " ").replace(/ *\n */g, "\n").replace(/\n{3,}/g, "\n\n").trim() : text.replace(/\s+/g, " ").trim()
46
+ return Array.from(text).slice(0, max).join("").trimEnd()
47
+ }
48
+
49
+ function boundFields(fields: unknown): ContextCardField[] | undefined {
50
+ if (!Array.isArray(fields)) return undefined
51
+ const out: ContextCardField[] = []
52
+ for (const field of fields) {
53
+ if (out.length >= CONTEXT_CARD_LIMITS.fields) break
54
+ if (!field || typeof field !== "object") continue
55
+ const label = cardText((field as { label?: unknown }).label, CONTEXT_CARD_LIMITS.fieldLabel)
56
+ const value = cardText((field as { value?: unknown }).value, CONTEXT_CARD_LIMITS.fieldValue)
57
+ if (label && value) out.push({ label, value })
58
+ }
59
+ return out.length ? out : undefined
60
+ }
61
+
62
+ /** A provider's draft as a stored card (undefined when it has no headline). */
63
+ export function boundCard(
64
+ provider: Pick<ContextCardProvider, "id" | "label" | "version" | "primary">,
65
+ sessionID: string,
66
+ draft: CardDraft | null | undefined,
67
+ meta: { readonly generatedAt: number; readonly inputsHash: string; readonly trigger: ContextCardTrigger; readonly basis?: readonly string[] },
68
+ ): ContextCard | undefined {
69
+ if (!draft || typeof draft !== "object") return undefined
70
+ const headline = cardText(draft.headline, CONTEXT_CARD_LIMITS.headline)
71
+ if (!headline) return undefined
72
+ const fields = boundFields(draft.fields)
73
+ const model = cardText(draft.model, CONTEXT_CARD_LIMITS.model)
74
+ return {
75
+ provider: provider.id,
76
+ sessionID,
77
+ title: cardText(draft.title, CONTEXT_CARD_LIMITS.title) || cardText(provider.label, CONTEXT_CARD_LIMITS.title),
78
+ headline,
79
+ body: cardText(draft.body, CONTEXT_CARD_LIMITS.body, true),
80
+ ...(fields ? { fields } : {}),
81
+ generatedAt: meta.generatedAt,
82
+ ...(model ? { model } : {}),
83
+ inputsHash: meta.inputsHash,
84
+ version: provider.version,
85
+ trigger: meta.trigger,
86
+ ...(meta.basis ? { basis: [...meta.basis] } : {}),
87
+ ...(provider.primary ? { primary: true as const } : {}),
88
+ }
89
+ }
@@ -0,0 +1,37 @@
1
+ // Context card agent route (docs/context-cards.md "API"):
2
+ //
3
+ // GET /api/agent/sessions/:addr/context-cards → {sessionID, cards: ContextCard[], providers}
4
+ // users; app tokens holding sessions:read; session tokens (the session read rules, team-scoped when the hub
5
+ // has team discovery), exactly like the intent read. Another user's session is 404 not_found.
6
+ //
7
+ // None while cards are off for the user. The Concierge reads a session's cards here (and the primary headline rides its board). Card text is untrusted.
8
+ import type { Directory, DirectoryEntry } from "../agent/directory"
9
+ import type { AgentActor } from "../agent/actor"
10
+ import { AGENT_PREFIX, type AgentRoute } from "../agent/routes"
11
+ import type { TeamDiscovery } from "../teams/discovery"
12
+ import type { ContextCardService } from "./service"
13
+
14
+ export interface ContextCardRoutesDeps {
15
+ readonly cards: Pick<ContextCardService, "list" | "registry">
16
+ readonly directory: Directory
17
+ readonly discovery?: TeamDiscovery
18
+ }
19
+
20
+ export function contextCardRoutes(deps: ContextCardRoutesDeps): AgentRoute[] {
21
+ async function resolve(userID: string, actor: AgentActor, addr: string): Promise<DirectoryEntry> {
22
+ if (deps.discovery) return deps.discovery.resolveOne(await deps.discovery.census(userID, actor), addr)
23
+ return deps.directory.resolveOne(userID, addr)
24
+ }
25
+ return [
26
+ {
27
+ method: "GET",
28
+ path: `${AGENT_PREFIX}/sessions/:addr/context-cards`,
29
+ allow: { session: true, scope: "sessions:read" },
30
+ handle: async ({ user, actor, params }) => {
31
+ const entry = await resolve(user.id, actor, params.addr!)
32
+ const cards = await deps.cards.list(user.id, entry.sessionID)
33
+ return Response.json({ sessionID: entry.sessionID, cards, providers: deps.cards.registry.info() }, { headers: { "cache-control": "no-store" } })
34
+ },
35
+ },
36
+ ]
37
+ }
@@ -0,0 +1,367 @@
1
+ // The context card service (docs/context-cards.md): the registry with the built-in providers (pickup, changes),
2
+ // the store, the daily budget, the engine, the human activity gate, the per-user setting, the model, and the
3
+ // triggers http.ts feeds it:
4
+ // - journal appends (cardTriggerOf): a root session's turn ended (working → idle | error) → "turn-end";
5
+ // the tree went blocked (state blocked, or a blocker opened) → "blocked"
6
+ // - human turns and read marks (the user opened the session): human activity; after RETURN_GAP_MS without any
7
+ // (or the first time this process sees one) → "user-return"
8
+ // - the leader's sweep every SWEEP_MS: "sweep" for every session with recent human activity; and every
9
+ // PRUNE_EVERY_MS cards older than CARD_RETENTION_MS are dropped (a deleted session's cards go at once: forget)
10
+ // Automatic triggers run on the "context-cards" leader only: other pods forward them (forward, a leader kick)
11
+ // with the human activity time, so the leader's gate knows it.
12
+ //
13
+ // Opt-in (user decision 2026-11-08): cards are OFF for every user until that user turns them on in Settings; nothing
14
+ // is gathered or sent to a model for a user who has not, even with a model configured. HUB_CONTEXT_CARDS=off (or
15
+ // contextCards.enabled false) is the hub-wide kill switch over every user's choice.
16
+ //
17
+ // Privacy: while cards are off for a user (their setting or the kill switch) every read (console, primary headlines,
18
+ // agent API, Concierge board) answers none and no card is stored or sent; turning them off deletes their stored cards.
19
+ //
20
+ // Human activity: the engine takes an injectable predicate (EngineDeps.humanActive). The hub gives the persisted
21
+ // per-user activity (deps.lastHumanAt, src/central/human-activity.ts `last`, root sessions): active when the user
22
+ // touched the session within HUMAN_ACTIVE_MS. Without it, the in-memory HumanActivity below (bounded) decides. The
23
+ // in-memory map still tells a return after RETURN_GAP_MS ("user-return") and lists the sweep's recent sessions; in the
24
+ // hub it is fed from the same human touches (prompts typed or sent, Voice, answers, explicit opens).
25
+ import type { ContextCard, ContextCardProviderInfo, ContextCardTrigger } from "../../protocol"
26
+ import { realTimers } from "../backend/memory-bus"
27
+ import type { StateStore, Timers } from "../backend/types"
28
+ import type { AgentEvent, EventInput } from "../agent/events"
29
+ import { OpenAICompatibleChat, type ChatClient } from "../llm/chat"
30
+ import { DailyBudget, DEFAULT_BUDGET, type BudgetUsage } from "./budget"
31
+ import { changesProvider } from "./changes"
32
+ import { resolveCardModel, type ContextCardsConfig, type KeySources } from "./config"
33
+ import { ContextCardEngine, type CardModel, type RunOutcome } from "./engine"
34
+ import { gatherInputs, type InputDeps } from "./inputs"
35
+ import { pickupProvider } from "./pickup"
36
+ import { CardRegistry } from "./registry"
37
+ import { ContextCardStore } from "./store"
38
+
39
+ /** A session the user touched within this long is "recently active" (automatic cards, the sweep). */
40
+ export const HUMAN_ACTIVE_MS = 24 * 3_600_000
41
+ /** A human touch after this long without one is a return ("user-return"). */
42
+ export const RETURN_GAP_MS = 30 * 60_000
43
+ export const SWEEP_MS = 30 * 60_000
44
+ export const PRUNE_EVERY_MS = 6 * 3_600_000
45
+ export const CARD_RETENTION_MS = 30 * 86_400_000
46
+ const ACTIVITY_MAX = 10_000
47
+
48
+ export type AutoTrigger = Exclude<ContextCardTrigger, "manual">
49
+
50
+ /** The card trigger a journal event is, on which (root) session; undefined for most events. */
51
+ export function cardTriggerOf(event: Pick<AgentEvent, "type" | "subject" | "data"> | null | undefined): { sessionID: string; trigger: "turn-end" | "blocked" } | undefined {
52
+ if (typeof event !== "object" || event === null) return undefined
53
+ const subject = event.subject ?? {}
54
+ const data = event.data ?? {}
55
+ const root = subject.rootID ?? subject.sessionID
56
+ if (typeof root !== "string" || !root) return undefined
57
+ if (event.type === "blocker.opened") return { sessionID: root, trigger: "blocked" }
58
+ if (event.type !== "session.changed" || subject.sessionID !== root) return undefined
59
+ if (data.state === "blocked") return { sessionID: root, trigger: "blocked" }
60
+ if ((data.state === "idle" || data.state === "error") && data.prev === "working") return { sessionID: root, trigger: "turn-end" }
61
+ return undefined
62
+ }
63
+
64
+ /** When the user last acted in each session (human turns, read marks), in memory, bounded. */
65
+ export class HumanActivity {
66
+ private readonly now: () => number
67
+ private readonly max: number
68
+ /** `user\nsession` → at (insertion order = age). */
69
+ private readonly last = new Map<string, number>()
70
+
71
+ constructor(options: { readonly now?: () => number; readonly max?: number } = {}) {
72
+ this.now = options.now ?? Date.now
73
+ this.max = options.max ?? ACTIVITY_MAX
74
+ }
75
+
76
+ /** The user acted now; `returned` after RETURN_GAP_MS without activity (or none known). */
77
+ touch(userID: string, sessionID: string): { returned: boolean } {
78
+ const at = this.now()
79
+ const before = this.last.get(`${userID}\n${sessionID}`)
80
+ this.note(userID, sessionID, at)
81
+ return { returned: before === undefined || at - before > RETURN_GAP_MS }
82
+ }
83
+
84
+ /** Records activity seen elsewhere (another pod's forward). */
85
+ note(userID: string, sessionID: string, at: number): void {
86
+ const key = `${userID}\n${sessionID}`
87
+ if ((this.last.get(key) ?? 0) > at) return
88
+ this.last.delete(key)
89
+ this.last.set(key, at)
90
+ if (this.last.size > this.max) this.last.delete(this.last.keys().next().value!)
91
+ }
92
+
93
+ lastAt(userID: string, sessionID: string): number | undefined {
94
+ return this.last.get(`${userID}\n${sessionID}`)
95
+ }
96
+
97
+ active(userID: string, sessionID: string): boolean {
98
+ const at = this.lastAt(userID, sessionID)
99
+ return at !== undefined && this.now() - at <= HUMAN_ACTIVE_MS
100
+ }
101
+
102
+ *recent(): Iterable<{ userID: string; sessionID: string }> {
103
+ const now = this.now()
104
+ for (const [key, at] of this.last) {
105
+ if (now - at > HUMAN_ACTIVE_MS) continue
106
+ const [userID, sessionID] = key.split("\n") as [string, string]
107
+ yield { userID, sessionID }
108
+ }
109
+ }
110
+
111
+ forget(userID: string, sessionID: string): void {
112
+ this.last.delete(`${userID}\n${sessionID}`)
113
+ }
114
+ }
115
+
116
+ export const CARD_SETTINGS = "context-card-settings"
117
+
118
+ /** The per-user setting (StateStore `context-card-settings`, key "settings"): {enabled}; unset is off (opt-in). */
119
+ export class CardSettings {
120
+ constructor(private readonly deps: { readonly state: StateStore }) {}
121
+
122
+ async stored(userID: string): Promise<boolean | undefined> {
123
+ const value = (await this.deps.state.get<{ enabled?: unknown }>(userID, CARD_SETTINGS, "settings"))?.value
124
+ return typeof value?.enabled === "boolean" ? value.enabled : undefined
125
+ }
126
+
127
+ async enabled(userID: string, fallback: boolean): Promise<boolean> {
128
+ return (await this.stored(userID)) ?? fallback
129
+ }
130
+
131
+ /** The user turned context cards on (opt-in: never by default). */
132
+ async optedIn(userID: string): Promise<boolean> {
133
+ return (await this.stored(userID)) === true
134
+ }
135
+
136
+ async set(userID: string, enabled: boolean): Promise<void> {
137
+ await this.deps.state.put(userID, CARD_SETTINGS, "settings", { enabled })
138
+ }
139
+ }
140
+
141
+ /** The journal event of a new card: its provider, version and primary flag; never the text. */
142
+ export const contextCardEvent = (card: ContextCard): EventInput => ({
143
+ type: "session.contextCard",
144
+ family: "session",
145
+ subject: { sessionID: card.sessionID },
146
+ data: { provider: card.provider, version: card.version, primary: card.primary === true },
147
+ })
148
+
149
+ export interface ContextCardsView {
150
+ readonly cards: ContextCard[]
151
+ readonly providers: ContextCardProviderInfo[]
152
+ /** Cards are on for this user (hub kill switch and their setting). */
153
+ readonly enabled: boolean
154
+ /** A model is configured (model providers can run). */
155
+ readonly configured: boolean
156
+ }
157
+
158
+ export interface ContextCardSettingsView {
159
+ /** The user's effective setting. */
160
+ readonly enabled: boolean
161
+ /** The hub allows cards (HUB_CONTEXT_CARDS / contextCards.enabled). */
162
+ readonly available: boolean
163
+ readonly configured: boolean
164
+ readonly model?: { readonly provider: string; readonly model: string; readonly keySource: string }
165
+ readonly usage: BudgetUsage
166
+ }
167
+
168
+ export interface ContextCardServiceDeps {
169
+ readonly state: StateStore
170
+ readonly config: ContextCardsConfig
171
+ /** The speech keys (read per call: settings changes apply at once). */
172
+ readonly keySources: () => KeySources
173
+ /** Overrides the model client (tests). */
174
+ readonly chat?: ChatClient
175
+ readonly fetch?: typeof globalThis.fetch
176
+ readonly inputs: InputDeps
177
+ readonly isLeader: () => boolean
178
+ /** Another pod's automatic trigger goes to the leader (a leader kick). */
179
+ readonly forward?: (userID: string, sessionID: string, trigger: AutoTrigger, humanAt?: number) => void
180
+ readonly onCard: (userID: string, card: ContextCard) => void
181
+ readonly autoName?: (userID: string, sessionID: string) => Promise<void>
182
+ /** The session still exists (not deleted) right before a card is stored. */
183
+ readonly exists?: (userID: string, sessionID: string) => Promise<boolean>
184
+ /** The live title, re-checked right before an auto name. */
185
+ readonly title?: (userID: string, sessionID: string) => string | undefined
186
+ /** When the user last touched the (root) session, persisted (the automatic cards' gate); in memory without it. */
187
+ readonly lastHumanAt?: (userID: string, sessionID: string) => Promise<number | undefined> | number | undefined
188
+ readonly users?: () => Promise<Iterable<string>>
189
+ readonly now?: () => number
190
+ readonly timers?: Timers
191
+ readonly debounceMs?: number
192
+ readonly log?: (message: string) => void
193
+ }
194
+
195
+ export class ContextCardService {
196
+ readonly registry = new CardRegistry()
197
+ readonly store: ContextCardStore
198
+ readonly budget: DailyBudget
199
+ readonly engine: ContextCardEngine
200
+ readonly activity: HumanActivity
201
+ readonly settings: CardSettings
202
+ private readonly now: () => number
203
+ private readonly timers: Timers
204
+ private timer: unknown
205
+ private lastPrune = 0
206
+ private client: { key: string; baseURL: string; chat: ChatClient } | undefined
207
+
208
+ constructor(private readonly deps: ContextCardServiceDeps) {
209
+ this.now = deps.now ?? Date.now
210
+ this.timers = deps.timers ?? realTimers
211
+ this.registry.register(pickupProvider())
212
+ this.registry.register(changesProvider())
213
+ this.store = new ContextCardStore({ state: deps.state })
214
+ this.budget = new DailyBudget({ state: deps.state, now: this.now, limits: { ...DEFAULT_BUDGET, ...deps.config.budget } })
215
+ this.activity = new HumanActivity({ now: this.now })
216
+ this.settings = new CardSettings({ state: deps.state })
217
+ const lastHumanAt = deps.lastHumanAt
218
+ this.engine = new ContextCardEngine({
219
+ registry: this.registry,
220
+ store: this.store,
221
+ budget: this.budget,
222
+ inputs: (userID, sessionID, needs) => gatherInputs(deps.inputs, userID, sessionID, needs),
223
+ humanActive: lastHumanAt
224
+ ? async (userID, sessionID) => this.now() - ((await lastHumanAt(userID, sessionID)) ?? Number.NEGATIVE_INFINITY) <= HUMAN_ACTIVE_MS
225
+ : (userID, sessionID) => this.activity.active(userID, sessionID),
226
+ enabled: (userID) => this.enabled(userID),
227
+ model: () => this.model(),
228
+ isLeader: deps.isLeader,
229
+ onCard: deps.onCard,
230
+ ...(deps.autoName ? { autoName: deps.autoName } : {}),
231
+ ...(deps.exists ? { exists: deps.exists } : {}),
232
+ ...(deps.title ? { title: deps.title } : {}),
233
+ recent: () => this.activity.recent(),
234
+ now: this.now,
235
+ timers: this.timers,
236
+ ...(deps.debounceMs !== undefined ? { debounceMs: deps.debounceMs } : {}),
237
+ ...(deps.log ? { log: deps.log } : {}),
238
+ })
239
+ }
240
+
241
+ /** The hub-wide kill switch (HUB_CONTEXT_CARDS=off, contextCards.enabled false). */
242
+ available(): boolean {
243
+ return this.deps.config.enabled !== false
244
+ }
245
+
246
+ private resolved() {
247
+ return resolveCardModel(this.deps.config, this.deps.keySources())
248
+ }
249
+
250
+ /** The model, when one is configured (an injected client counts). */
251
+ model(): CardModel | undefined {
252
+ if (!this.available()) return undefined
253
+ const resolved = this.resolved()
254
+ if (this.deps.chat) return { client: this.deps.chat, model: resolved?.model ?? "test" }
255
+ if (!resolved) return undefined
256
+ if (this.client?.key !== resolved.apiKey || this.client.baseURL !== resolved.baseURL) {
257
+ const apiKey = resolved.apiKey
258
+ this.client = { key: apiKey, baseURL: resolved.baseURL, chat: new OpenAICompatibleChat({ baseURL: resolved.baseURL, apiKey: async () => apiKey, ...(this.deps.fetch ? { fetch: this.deps.fetch } : {}) }) }
259
+ }
260
+ return { client: this.client.chat, model: resolved.model }
261
+ }
262
+
263
+ configured(): boolean {
264
+ return this.model() !== undefined
265
+ }
266
+
267
+ /** Cards on for a user: the hub allows them and the user opted in (off by default, model or not). */
268
+ async enabled(userID: string): Promise<boolean> {
269
+ return this.available() && (await this.settings.optedIn(userID))
270
+ }
271
+
272
+ /** An automatic trigger: run here on the leader, else forwarded to it. */
273
+ trigger(userID: string, sessionID: string, trigger: AutoTrigger): void {
274
+ if (this.deps.isLeader()) this.engine.trigger(userID, sessionID, trigger)
275
+ else this.deps.forward?.(userID, sessionID, trigger, this.activity.lastAt(userID, sessionID))
276
+ }
277
+
278
+ /** A forwarded trigger, on the leader. */
279
+ forwarded(userID: string, sessionID: string, trigger: AutoTrigger, humanAt?: number): void {
280
+ if (humanAt !== undefined) this.activity.note(userID, sessionID, humanAt)
281
+ this.engine.trigger(userID, sessionID, trigger)
282
+ }
283
+
284
+ /** A journal append (the AgentEvents onAppend path). Never throws. */
285
+ onEvent(userID: string, event: Pick<AgentEvent, "type" | "subject" | "data">): void {
286
+ const found = cardTriggerOf(event)
287
+ if (found) this.trigger(userID, found.sessionID, found.trigger)
288
+ }
289
+
290
+ /** The user acted in a session (a human turn, a read mark): after a gap, its user-return cards run. */
291
+ human(userID: string, sessionID: string): void {
292
+ if (this.activity.touch(userID, sessionID).returned) this.trigger(userID, sessionID, "user-return")
293
+ }
294
+
295
+ async forget(userID: string, sessionID: string): Promise<void> {
296
+ this.activity.forget(userID, sessionID)
297
+ await this.engine.forget(userID, sessionID)
298
+ }
299
+
300
+ async view(userID: string, sessionID: string): Promise<ContextCardsView> {
301
+ const enabled = await this.enabled(userID)
302
+ return { cards: enabled ? await this.store.list(userID, sessionID) : [], providers: this.registry.info(), enabled, configured: this.configured() }
303
+ }
304
+
305
+ /** A session's cards, none while cards are off for the user (kill switch or their setting): every read goes here. */
306
+ async list(userID: string, sessionID: string): Promise<ContextCard[]> {
307
+ return (await this.enabled(userID)) ? this.store.list(userID, sessionID) : []
308
+ }
309
+
310
+ /** A manual refresh: deduped, rate limited, unchanged inputs skipped unless forced. */
311
+ refresh(userID: string, sessionID: string, provider: string, options: { readonly force?: boolean } = {}): Promise<RunOutcome> {
312
+ return this.engine.refresh(userID, sessionID, provider, options)
313
+ }
314
+
315
+ /** The primary card of each session (the list's second line, the Concierge board); none while cards are off. */
316
+ async primaries(userID: string, sessionIDs: Iterable<string>): Promise<Map<string, ContextCard>> {
317
+ return (await this.enabled(userID)) ? this.store.primaries(userID, sessionIDs) : new Map()
318
+ }
319
+
320
+ async settingsView(userID: string): Promise<ContextCardSettingsView> {
321
+ const resolved = this.available() ? this.resolved() : undefined
322
+ return {
323
+ enabled: await this.enabled(userID),
324
+ available: this.available(),
325
+ configured: this.configured(),
326
+ ...(resolved ? { model: { provider: resolved.provider, model: resolved.model, keySource: resolved.keySource } } : {}),
327
+ usage: await this.budget.usage(userID),
328
+ }
329
+ }
330
+
331
+ /** Turning cards off deletes the user's stored cards (privacy): turning them on again starts fresh. */
332
+ async setEnabled(userID: string, enabled: boolean): Promise<ContextCardSettingsView> {
333
+ await this.settings.set(userID, enabled)
334
+ if (!enabled) await this.store.clear(userID)
335
+ return this.settingsView(userID)
336
+ }
337
+
338
+ /** The leader's loop: the sweep every SWEEP_MS, retention every PRUNE_EVERY_MS. */
339
+ start(): void {
340
+ if (this.timer !== undefined) return
341
+ this.timer = this.timers.set(() => {
342
+ this.timer = undefined
343
+ void this.tick().finally(() => this.start())
344
+ }, SWEEP_MS)
345
+ }
346
+
347
+ stop(): void {
348
+ if (this.timer !== undefined) this.timers.clear(this.timer)
349
+ this.timer = undefined
350
+ }
351
+
352
+ /** One sweep (and retention when due). */
353
+ async tick(): Promise<void> {
354
+ this.engine.sweep()
355
+ if (this.now() - this.lastPrune < PRUNE_EVERY_MS || !this.deps.users) return
356
+ this.lastPrune = this.now()
357
+ try {
358
+ for (const userID of await this.deps.users()) await this.store.prune(userID, this.now() - CARD_RETENTION_MS)
359
+ } catch (cause) {
360
+ ;(this.deps.log ?? console.error)(`[hub] context card retention failed: ${(cause as Error).message}`)
361
+ }
362
+ }
363
+
364
+ settled(): Promise<void> {
365
+ return this.engine.settled()
366
+ }
367
+ }