@huynle/hub 0.9.1 → 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 (143) hide show
  1. package/CHANGELOG.md +309 -0
  2. package/Dockerfile +5 -1
  3. package/README.md +153 -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/webhooks.ts +5 -2
  60. package/src/central/agent-data.ts +43 -1
  61. package/src/central/backend/memory-counters.ts +2 -2
  62. package/src/central/backend/types.ts +5 -2
  63. package/src/central/backend/valkey-counters.ts +5 -4
  64. package/src/central/client-address.ts +16 -0
  65. package/src/central/concierge/board-render.ts +52 -1
  66. package/src/central/concierge/board-routes.ts +25 -2
  67. package/src/central/concierge/board.ts +11 -1
  68. package/src/central/concierge/context.ts +29 -8
  69. package/src/central/concierge/grant-request.ts +216 -0
  70. package/src/central/concierge/prompt.ts +49 -2
  71. package/src/central/concierge/session-routes.ts +3 -0
  72. package/src/central/config.ts +88 -5
  73. package/src/central/context-cards/budget.ts +121 -0
  74. package/src/central/context-cards/changes.ts +29 -0
  75. package/src/central/context-cards/config.ts +145 -0
  76. package/src/central/context-cards/engine.ts +379 -0
  77. package/src/central/context-cards/inputs.ts +111 -0
  78. package/src/central/context-cards/pickup.ts +134 -0
  79. package/src/central/context-cards/registry.ts +89 -0
  80. package/src/central/context-cards/routes.ts +37 -0
  81. package/src/central/context-cards/service.ts +367 -0
  82. package/src/central/context-cards/store.ts +67 -0
  83. package/src/central/context-cards/types.ts +88 -0
  84. package/src/central/fetcher.ts +10 -2
  85. package/src/central/front-relays.ts +423 -0
  86. package/src/central/http.ts +517 -56
  87. package/src/central/human-activity.ts +453 -0
  88. package/src/central/llm/chat.ts +133 -0
  89. package/src/central/push-target.ts +11 -2
  90. package/src/central/registry.ts +162 -9
  91. package/src/central/speech/service.ts +9 -0
  92. package/src/central/spoke-ws.ts +13 -0
  93. package/src/central/store.ts +9 -12
  94. package/src/config.ts +30 -13
  95. package/src/index.ts +45 -43
  96. package/src/rpc.ts +33 -4
  97. package/src/spoke/concierge-agent.ts +1 -1
  98. package/src/spoke/conversation.ts +6 -75
  99. package/src/spoke/deliver.ts +18 -8
  100. package/src/spoke/hub-rpc.ts +7 -2
  101. package/src/spoke/hub-tool-subscriptions.ts +82 -0
  102. package/src/spoke/hub-tool-title.ts +89 -0
  103. package/src/spoke/hub-tool.ts +56 -18
  104. package/src/spoke/session-context.ts +10 -2
  105. package/src/spoke/spoke.ts +209 -238
  106. package/src/spoke/summary.ts +36 -1
  107. package/src/spoke/taskboard.ts +5 -6
  108. package/src/spoke/voice-front.ts +323 -30
  109. package/src/spoke/who-teams.ts +51 -2
  110. package/src/tui-messaging.ts +24 -10
  111. package/src/tui.ts +6 -6
  112. package/web/dist/assets/{ArtifactPage-BKISN-Qj.js → ArtifactPage-Bz992ZX6.js} +1 -1
  113. package/web/dist/assets/{ArtifactPane-CKOiAUNH.js → ArtifactPane-BJZNixJC.js} +1 -1
  114. package/web/dist/assets/ArtifactWorkbench-Dti3VP07.js +6 -0
  115. package/web/dist/assets/{Diagram-vdpmp3xu.css → Diagram-7y8kg3og.css} +1 -1
  116. package/web/dist/assets/Diagram-B4HdUxw0.js +1 -0
  117. package/web/dist/assets/{Library-CcjZXsfF.js → Library-CpzD2HDW.js} +2 -2
  118. package/web/dist/assets/Lightbox-BlbivCkd.js +1 -0
  119. package/web/dist/assets/MediaImage-Ch3ryIdR.js +4 -0
  120. package/web/dist/assets/ProfilesDestination-DlQeogea.js +1 -0
  121. package/web/dist/assets/{TeamsPage-CDyjrK-I.js → TeamsPage-4xdWCaI-.js} +1 -1
  122. package/web/dist/assets/{Watchtower-DYwoUWME.js → Watchtower-CpQdIRns.js} +2 -2
  123. package/web/dist/assets/{artifacts-DU9uRgs1.js → artifacts-But87Jfo.js} +1 -1
  124. package/web/dist/assets/index-BC0O1gfR.css +1 -0
  125. package/web/dist/assets/index-BS6lA6fN.js +33 -0
  126. package/web/dist/assets/{live-ycZ_aQZO.js → live-CVUFIdUX.js} +2 -2
  127. package/web/dist/assets/{pane-layout-0RrLHGDr.js → pane-layout-BW0RdX5a.js} +1 -1
  128. package/web/dist/assets/{renderer2d-BIwbgRoX.js → renderer2d-IaJiloPi.js} +1 -1
  129. package/web/dist/assets/{renderer3d-C1a7eYGQ.js → renderer3d-ZiD1uKGW.js} +1 -1
  130. package/web/dist/assets/{safe-area-DHhPy61Q.js → safe-area-C7IAlkgY.js} +1 -1
  131. package/web/dist/assets/{source-BWlvaODa.js → source-C-De8L3s.js} +1 -1
  132. package/web/dist/assets/toast-CXTwTvDw.js +9 -0
  133. package/web/dist/assets/vad-worklet-BfxJhrXy.js +27 -0
  134. package/web/dist/index.html +6 -6
  135. package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +0 -6
  136. package/web/dist/assets/Diagram-azw2B_QH.js +0 -1
  137. package/web/dist/assets/Lightbox-Du56MXGX.js +0 -1
  138. package/web/dist/assets/MediaImage-Ds_JUx3a.js +0 -4
  139. package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +0 -1
  140. package/web/dist/assets/index-C3ci5UtY.css +0 -1
  141. package/web/dist/assets/index-LNMZCqgE.js +0 -36
  142. package/web/dist/assets/toast-Cuu2K0hx.js +0 -1
  143. /package/{web/src/voice → packages/voice-client/src}/turn/LICENSE +0 -0
@@ -0,0 +1,216 @@
1
+ // Concierge grant requests (plan o9zx7u68 follow-up; the user decided 2026-11-08): the Concierge may ASK the user for
2
+ // a grant in a project it cannot act in, through the existing grant-request path (agent/grant-requests.ts
3
+ // requestManaged): a tap Decision to @me (never armable, never by voice); the user's approval creates the grant AS
4
+ // THE USER, holder @@concierge (the managed holder only this path and Settings may name). It never widens a grant
5
+ // itself: each request is a new, separate grant the user approves or denies.
6
+ //
7
+ // POST /api/agent/concierge/grant-requests {projects, verbs, permissions?, never?, hours?, userAsked?, why}
8
+ // the user's CURRENT Concierge session (act token) → 201 {decision, message}; anyone else 404, the user 403.
9
+ //
10
+ // Clamps (the hub's, whatever the model asks):
11
+ // - holder: always @@concierge (no holder field).
12
+ // - verbs: approve, deny, answer; prompt and stop only with userAsked: true (the user asked for them explicitly).
13
+ // - expiry: hours, 8 by default, at most 24 (whole minutes, at least one).
14
+ // - projects: explicit, each one a project the Concierge watches now: a watched folder itself, or its name as the
15
+ // board shows it (a name two watched folders share is invalid, naming both). No wildcards, no parents.
16
+ // - permissions: action[:glob] allow patterns; a wildcard action ("*", "re*d") is invalid.
17
+ // - never: the default list (shell:git push*, shell:rm *), the Settings grant's never rules, the last @@concierge
18
+ // grant's (any state) and the asked ones, once each, in that order. allowAlways is always false.
19
+ // - maxActionsPerHour: 60.
20
+ // - one open request for the user's Concierge (all its sessions share the key "concierge"); after the user denies
21
+ // one, no new request for 24 hours (rate_limited); GRANT_REQUESTS_PER_HOUR an hour.
22
+ import type { ConciergeSettings } from "../../../packages/protocol/src/concierge"
23
+ import { CONCIERGE_ROLE } from "../../../packages/protocol/src/concierge"
24
+ import { type AgentActor, canActAsSession, type SessionActor } from "../agent/actor"
25
+ import { boundedJson } from "../agent/body"
26
+ import { type Decision, WHY_MAX } from "../agent/decisions"
27
+ import { sanitizeLabel } from "../agent/envelope"
28
+ import { AgentError } from "../agent/errors"
29
+ import type { GrantRequests } from "../agent/grant-requests"
30
+ import type { Grants } from "../agent/grants"
31
+ import type { Message } from "../agent/messages"
32
+ import { canonicalProject, formatPattern, GRANT_PROJECTS_MAX, parsePattern } from "../agent/policy"
33
+ import type { AgentRoute } from "../agent/routes"
34
+ import { resolveWatchlist } from "./watchlist"
35
+ import { type ConciergeIdentity, watchFacts, type WatchFactDeps, WatchlistStore } from "./watchlist-routes"
36
+ import path from "node:path"
37
+
38
+ const HOUR_MS = 3_600_000
39
+ export const CONCIERGE_GRANT_HOLDER = `@@${CONCIERGE_ROLE}`
40
+ /** The never rules every Concierge grant request carries (the Settings preset's). */
41
+ export const CONCIERGE_GRANT_DEFAULT_NEVER: readonly string[] = ["shell:git push*", "shell:rm *"]
42
+ export const CONCIERGE_GRANT_REQUEST_LIMITS = { hoursDefault: 8, hoursMax: 24, maxActionsPerHour: 60, denialCooldownMs: 24 * HOUR_MS } as const
43
+ /** The request record's key: one for all the user's Concierge sessions. */
44
+ export const CONCIERGE_GRANT_REQUESTER = "concierge"
45
+ /** Verbs the Concierge may request; prompt and stop only when the user asked for them. */
46
+ export const CONCIERGE_REQUEST_VERBS = ["approve", "deny", "answer"] as const
47
+ export const CONCIERGE_ASKED_VERBS = ["prompt", "stop"] as const
48
+ const FIELDS: ReadonlySet<string> = new Set(["projects", "verbs", "permissions", "never", "hours", "userAsked", "why"])
49
+ const PATH = "/api/agent/concierge/grant-requests"
50
+ const NO_STORE = { "cache-control": "no-store" }
51
+
52
+ /** A project folder the Concierge watches now, with the names the board shows for it. */
53
+ export interface WatchedProject {
54
+ readonly directory: string
55
+ readonly labels: readonly string[]
56
+ }
57
+
58
+ export interface ConciergeGrantRequestDeps {
59
+ readonly requests: Pick<GrantRequests, "requestManaged">
60
+ readonly grants: Pick<Grants, "list">
61
+ /** The user's Concierge settings (the Settings grant's never rules). */
62
+ readonly settings: (userID: string) => Promise<Pick<ConciergeSettings, "grant">>
63
+ /** The project folders of the watched sessions now. */
64
+ readonly watched: (userID: string) => Promise<readonly WatchedProject[]>
65
+ }
66
+
67
+ const invalid = (message: string, hint?: string) => new AgentError("invalid", message, hint ? { hint } : {})
68
+ const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
69
+
70
+ function strings(value: unknown, what: string, options: { readonly required?: boolean } = {}): string[] {
71
+ if (value === undefined && !options.required) return []
72
+ if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) throw invalid(`${what} is a list of texts`)
73
+ if (options.required && value.length === 0) throw invalid(`${what} lists at least one`)
74
+ return value as string[]
75
+ }
76
+
77
+ /** A pattern as grants store it (bash: → shell:); invalid otherwise. */
78
+ function pattern(text: string, what: string, options: { readonly noWildcardAction?: boolean } = {}): string {
79
+ const parsed = parsePattern(text)
80
+ if (!parsed) throw invalid(`${what}: ${sanitizeLabel(text, 80)} is not an action[:glob] pattern`)
81
+ if (options.noWildcardAction && /[*?[\]]/.test(parsed.action)) throw invalid(`${what}: ${sanitizeLabel(text, 80)} names every action: name the action (read, edit, …)`)
82
+ return formatPattern(parsed)
83
+ }
84
+
85
+ function verbsOf(raw: unknown, userAsked: boolean): string[] {
86
+ const verbs: string[] = []
87
+ for (const verb of strings(raw, "verbs", { required: true })) {
88
+ const asked = (CONCIERGE_ASKED_VERBS as readonly string[]).includes(verb)
89
+ if (!asked && !(CONCIERGE_REQUEST_VERBS as readonly string[]).includes(verb)) throw invalid(`verbs: the Concierge may request ${[...CONCIERGE_REQUEST_VERBS, ...CONCIERGE_ASKED_VERBS].join(", ")}, not ${sanitizeLabel(verb, 40)}`)
90
+ if (asked && !userAsked) throw invalid(`verbs: ${verb} only when the user asked for it (userAsked: true)`)
91
+ if (!verbs.includes(verb)) verbs.push(verb)
92
+ }
93
+ return verbs
94
+ }
95
+
96
+ function expiresOf(hours: unknown): number {
97
+ if (hours === undefined) return CONCIERGE_GRANT_REQUEST_LIMITS.hoursDefault * 3600
98
+ const max = CONCIERGE_GRANT_REQUEST_LIMITS.hoursMax
99
+ if (typeof hours !== "number" || !Number.isFinite(hours) || hours <= 0 || hours > max) throw invalid(`hours is a number above 0, at most ${max}`)
100
+ const minutes = Math.round(hours * 60)
101
+ if (minutes < 1) throw invalid("hours is at least one minute (1/60)")
102
+ return minutes * 60
103
+ }
104
+
105
+ const slug = (text: string) => text.trim().toLowerCase()
106
+
107
+ /** The watched folders the asked projects name (see the header). */
108
+ function projectsOf(raw: unknown, watched: readonly WatchedProject[]): string[] {
109
+ const asked = strings(raw, "projects", { required: true })
110
+ if (asked.length > GRANT_PROJECTS_MAX) throw invalid(`projects lists at most ${GRANT_PROJECTS_MAX}`)
111
+ const folders = new Map<string, Set<string>>()
112
+ for (const w of watched) {
113
+ const dir = canonicalProject(w.directory)
114
+ if (dir === undefined) continue
115
+ const names = folders.get(dir) ?? new Set<string>()
116
+ for (const label of [...w.labels, path.posix.basename(dir)]) if (label) names.add(slug(label))
117
+ folders.set(dir, names)
118
+ }
119
+ const out: string[] = []
120
+ for (const item of asked) {
121
+ const text = item.trim()
122
+ if (!text) throw invalid("projects: an empty name")
123
+ if (/[*?]/.test(text)) throw invalid(`projects: ${sanitizeLabel(text, 80)} is a wildcard: name each project`)
124
+ let dir: string | undefined
125
+ const canonical = canonicalProject(text)
126
+ if (canonical !== undefined) {
127
+ if (folders.has(canonical)) dir = canonical
128
+ } else {
129
+ const named = [...folders].filter(([, names]) => names.has(slug(text))).map(([d]) => d)
130
+ if (named.length > 1) throw invalid(`projects: ${sanitizeLabel(text, 80)} names ${named.length} watched folders (${named.join(", ")}): name the folder`)
131
+ dir = named[0]
132
+ }
133
+ if (dir === undefined) throw invalid(`projects: ${sanitizeLabel(text, 80)} is not a project you watch`, "Request only projects of watched sessions (concierge_watch first)")
134
+ if (!out.includes(dir)) out.push(dir)
135
+ }
136
+ return out
137
+ }
138
+
139
+ export class ConciergeGrantRequests {
140
+ constructor(private readonly deps: ConciergeGrantRequestDeps) {}
141
+
142
+ /** Opens the Decision for the Concierge's request. Throws invalid or rate_limited. */
143
+ async request(userID: string, actor: SessionActor, body: unknown): Promise<{ decision: Decision; message: Message }> {
144
+ if (!isObject(body)) throw invalid("A grant request is {projects, verbs, permissions?, never?, hours?, userAsked?, why}")
145
+ for (const key of Object.keys(body)) if (!FIELDS.has(key)) throw invalid(`Unknown grant request field ${sanitizeLabel(key, 40)}`, key === "holder" ? "The holder is always @@concierge" : undefined)
146
+ const why = typeof body.why === "string" && body.why.length <= WHY_MAX * 4 ? sanitizeLabel(body.why, WHY_MAX) : ""
147
+ if (!why) throw invalid(`why is the reason the user reads (1–${WHY_MAX} characters)`)
148
+ if (body.userAsked !== undefined && typeof body.userAsked !== "boolean") throw invalid("userAsked is true or false")
149
+ const verbs = verbsOf(body.verbs, body.userAsked === true)
150
+ const expires = expiresOf(body.hours)
151
+ const allow = [...new Set(strings(body.permissions, "permissions").map((p) => pattern(p, "permissions", { noWildcardAction: true })))]
152
+ const askedNever = strings(body.never, "never").map((p) => pattern(p, "never"))
153
+ const projects = projectsOf(body.projects, await this.deps.watched(userID))
154
+ const never = await this.neverRules(userID, askedNever)
155
+ const grant = {
156
+ holder: CONCIERGE_GRANT_HOLDER,
157
+ scope: { projects },
158
+ allow: verbs,
159
+ permissions: { allow, never },
160
+ allowAlways: false,
161
+ maxActionsPerHour: CONCIERGE_GRANT_REQUEST_LIMITS.maxActionsPerHour,
162
+ expires,
163
+ }
164
+ return this.deps.requests.requestManaged(userID, actor, { grant, why }, { key: CONCIERGE_GRANT_REQUESTER, cooldownMs: CONCIERGE_GRANT_REQUEST_LIMITS.denialCooldownMs })
165
+ }
166
+
167
+ /** The default list, the Settings grant's, the last @@concierge grant's and the asked ones, once each. */
168
+ private async neverRules(userID: string, asked: readonly string[]): Promise<string[]> {
169
+ const [settings, grants] = await Promise.all([this.deps.settings(userID), this.deps.grants.list(userID)])
170
+ const last = grants.find((g) => g.holder === CONCIERGE_GRANT_HOLDER)
171
+ const out: string[] = []
172
+ for (const rule of [...CONCIERGE_GRANT_DEFAULT_NEVER, ...(settings.grant?.permissions.never ?? []), ...(last?.permissions.never ?? []), ...asked]) {
173
+ const parsed = parsePattern(rule)
174
+ const text = parsed ? formatPattern(parsed) : undefined
175
+ if (text !== undefined && !out.includes(text)) out.push(text)
176
+ }
177
+ return out
178
+ }
179
+ }
180
+
181
+ /** The project folders of the user's watched sessions now (each watched root's project, else its folder), with its slug. */
182
+ export async function watchedProjects(deps: WatchFactDeps & { readonly state: ConstructorParameters<typeof WatchlistStore>[0] }, userID: string, hidden: readonly string[]): Promise<WatchedProject[]> {
183
+ const [config, facts] = await Promise.all([new WatchlistStore(deps.state).get(userID), watchFacts(deps, userID, hidden)])
184
+ const byID = new Map(facts.sessions.map((f) => [f.sessionID, f]))
185
+ const out: WatchedProject[] = []
186
+ for (const entry of resolveWatchlist(config, facts).entries) {
187
+ const fact = byID.get(entry.sessionID)
188
+ const directory = fact?.project ?? fact?.directory
189
+ if (directory) out.push({ directory, labels: fact?.projectSlug ? [fact.projectSlug] : [] })
190
+ }
191
+ return out
192
+ }
193
+
194
+ /** The route (see the header): the user's current Concierge only. */
195
+ export function conciergeGrantRequestRoutes(deps: { readonly requests: ConciergeGrantRequests; readonly identity: ConciergeIdentity }): AgentRoute[] {
196
+ return [
197
+ {
198
+ method: "POST",
199
+ path: PATH,
200
+ allow: { session: true, app: true },
201
+ handle: async ({ actor, request, user }) => {
202
+ await admitCurrent(deps.identity, actor, user.id)
203
+ return Response.json(await deps.requests.request(user.id, actor as SessionActor, await boundedJson(request)), { status: 201, headers: NO_STORE })
204
+ },
205
+ },
206
+ ]
207
+ }
208
+
209
+ async function admitCurrent(identity: ConciergeIdentity, actor: AgentActor, userID: string): Promise<void> {
210
+ if (actor.kind === "user") throw new AgentError("forbidden", "Only the Concierge requests its grant: change it in Settings → Concierge")
211
+ if (actor.kind === "session" && canActAsSession(actor) && actor.userID === userID) {
212
+ const current = await identity.current(userID)
213
+ if (current.sessionID !== undefined && current.sessionID === actor.sessionID) return
214
+ }
215
+ throw new AgentError("not_found", "Not found")
216
+ }
@@ -3,7 +3,7 @@
3
3
  // text lives here (src/central/concierge/**) and nowhere else (contract open question 1).
4
4
  import { zonedStamp } from "./board-render"
5
5
 
6
- export const CONCIERGE_PROMPT_VERSION = 8
6
+ export const CONCIERGE_PROMPT_VERSION = 9
7
7
 
8
8
  /** Facts about the user the instructions depend on (hub-known, never model text). */
9
9
  export interface InstructionFacts {
@@ -25,7 +25,7 @@ export interface InstructionFacts {
25
25
  * project folder name is fine, full paths are not; a refused act is said with the hub's reason. (FX-07, v7) An
26
26
  * action's why is read by the user in Activity: plain words, never board fields or codes (inGrant, auto, IDs).
27
27
  * (R-03, v8) Only an approve escalates (also with no grant); the other verbs outside the grant are proposed; an
28
- * approve recommends only with recommend: true.
28
+ * approve recommends only with recommend: true. (v9) Grant requests: GRANT_REQUEST_RULES (the block at the end).
29
29
  */
30
30
  export function conciergeInstructions(facts: InstructionFacts): string {
31
31
  const { voice } = facts
@@ -76,6 +76,8 @@ export function conciergeInstructions(facts: InstructionFacts): string {
76
76
  "- Every action is audited as yours, and the user reads its why in Activity. Give a short why for each, in plain words: never board fields or codes (inGrant, auto, revisions, IDs).",
77
77
  '- A proposal\'s why is your own reason, in your words. Never say the user asked for it unless they did in this conversation; when they only asked you to propose, say so ("You asked me to propose this").',
78
78
  "",
79
+ ...GRANT_REQUEST_RULES,
80
+ "",
79
81
  "Rules:",
80
82
  voice
81
83
  ? "- Never act on a Decision yourself: the user answers Decisions, by tap, or by voice on their own device. Their spoken yes or no is matched on their device, never by you: never claim a Decision was answered unless the hub says so."
@@ -132,3 +134,48 @@ export const NOTES_HEADER = "Your notes (written by you, the Concierge; they may
132
134
  * still exists on the machine): no board, no notes, no tools. It only says so.
133
135
  */
134
136
  export const RETIRED_INSTRUCTIONS = "This Concierge session was retired: the hub replaced it with a new Concierge session. You have no tools, no board and no authority here. In one short sentence, tell the user to open the Concierge from the Concierge button instead."
137
+
138
+ // ---- (Concierge grant requests, v9) asking the user for a grant ------------------------------------------------
139
+ // The rules for concierge_request_grant (concierge/grant-request.ts: the hub's clamps) and the line naming the grants
140
+ // the user approved on the Concierge's request (after the grant line). Kept in one block (merge isolation).
141
+
142
+ /** (v9) When and how the Concierge asks the user for a grant. */
143
+ export const GRANT_REQUEST_RULES: readonly string[] = [
144
+ "Asking the user for a grant (hub tool verb concierge_request_grant: projects = the watched projects by the name your sessions show, verbs, permissions, never, hours, userAsked, why):",
145
+ "- Offer it only when the user asks you to help or act in a project no grant of yours covers (its sessions show project inGrant=no), or after you had to propose or escalate there several times. Offer it in one sentence first (for example \"Shall I ask you for a grant to approve reads and edits in toy-truck for 8 hours?\") and request it only after the user says yes.",
146
+ "- Ask for the least: only the projects, verbs and permissions needed, for a short time (8 hours by default, at most 24). Verbs approve, deny and answer; prompt or stop only when the user explicitly asked for them (then userAsked: true). You never ask for more than the user agreed to, and never for 'always'; the hub adds the user's never rules.",
147
+ "- After the user denied a grant request, do not ask again that day (the hub refuses it too). One request at a time; when the hub refuses a request, tell the user its reason and do not retry.",
148
+ "- A grant request is a Decision that waits for the user's tap on the card in the Concierge panel: say so. It cannot be armed. Never say you have the grant until the hub says it was created or your sessions show inGrant=yes.",
149
+ ]
150
+
151
+ /** An active grant the user approved on the Concierge's request (not the Settings grant). */
152
+ export interface RequestedGrantInput {
153
+ readonly id: string
154
+ readonly allow: readonly string[]
155
+ readonly projects: readonly string[]
156
+ readonly expiresAt: number
157
+ }
158
+
159
+ const requestedList = (grants: readonly RequestedGrantInput[], timeZone?: string) => {
160
+ const when = (at: number) => (timeZone !== undefined ? zonedStamp(at, timeZone) : new Date(at).toISOString())
161
+ return grants.map((g) => `${g.id}: ${g.allow.join(", ")} in ${g.projects.length} project(s), until ${when(g.expiresAt)}`).join("; ")
162
+ }
163
+
164
+ /** (v9) The line after the grant line naming the requested grants (hub-written); undefined when there are none. */
165
+ export function requestedGrantsLine(grants: readonly RequestedGrantInput[], timeZone?: string): string | undefined {
166
+ if (!grants.length) return undefined
167
+ return `Grants the user approved at your request: ${requestedList(grants, timeZone)}. Your sessions' project inGrant shows where your grants apply.`
168
+ }
169
+
170
+ /**
171
+ * (hardening, verify round 4) The grant lines heading the board: the Settings grant's line, then the requested
172
+ * grants. Without an active Settings grant but with requested ones, one line names both (never a bare "Grant: none"
173
+ * or "every action is a Decision" next to grants that do apply).
174
+ */
175
+ export function grantLines(grant: GrantLineInput, requested: readonly RequestedGrantInput[], timeZone?: string): string {
176
+ if (!requested.length) return grantLine(grant, timeZone)
177
+ if (grant.state === "active") return `${grantLine(grant, timeZone)}\n${requestedGrantsLine(requested, timeZone)}`
178
+ const when = (at: number) => (timeZone !== undefined ? zonedStamp(at, timeZone) : new Date(at).toISOString())
179
+ const settings = grant.state === "none" ? "Grant: none from Settings" : `Grant ${grant.id} from Settings ended ${when(grant.at)}`
180
+ return `${settings}; approved at your request: ${requestedList(requested, timeZone)}. Your sessions' project inGrant shows where these grants apply. Outside them, an approve you try becomes a Decision for the user; propose stop, deny, prompt or answer with concierge_propose.`
181
+ }
@@ -43,6 +43,9 @@ export const CONCIERGE_SESSION_ROUTES: readonly { readonly method: string; reado
43
43
  { method: "POST", path: `${C}/arm` },
44
44
  // CON-13 (tiers.ts): a pending form's schema (fields, options), so its answers fit the form.
45
45
  { method: "GET", path: `${C}/sessions/:addr/forms/:form` },
46
+ // (Concierge grant requests, grant-request.ts) The Concierge asks the user for a scoped grant: a tap Decision, the
47
+ // hub's clamps. The generic POST /api/agent/grants/requests stays closed to it.
48
+ { method: "POST", path: `${C}/grant-requests` },
46
49
  ]
47
50
 
48
51
  const key = (method: string, path: string) => `${method} ${path}`
@@ -7,8 +7,11 @@ import path from "node:path"
7
7
  import { hubEnv } from "../env"
8
8
  import { isLoopbackHost } from "../loopback"
9
9
  import { formatPattern, parsePattern } from "./agent/policy"
10
+ import type { SpeechQuotaOverrides } from "./agent/speech-routes"
11
+ import { SPEECH_RATE_LIMITS } from "../protocol"
10
12
  import { APP_DIR, configBase, legacyPaths, migrateFirst, migrateStateDir, resolveHubStateDir, stateLegacyPaths } from "../migrate"
11
13
  import { updateSpeech, type StoredSpeech } from "./speech/settings"
14
+ import { readContextCards, type ContextCardsConfig } from "./context-cards/config"
12
15
  import { checkReleaseBase } from "./install/render"
13
16
  import { cspMode, type CspMode } from "./csp"
14
17
  import { parseDenyCidrs } from "./fetcher"
@@ -71,6 +74,10 @@ export interface CentralConfig {
71
74
  readonly launchCaps?: HubLaunches
72
75
  /** hub.json `agents.webhooks.allowPrivate`: webhooks may target http, loopback, private and local hosts (admin opt-in). */
73
76
  readonly webhooksAllowPrivate?: true
77
+ /** hub.json `agents.webhooks.allowPrivateHosts`: exact hostnames whose https webhook URLs may resolve privately. */
78
+ readonly webhooksPrivateHosts?: readonly string[]
79
+ /** (AF2) hub.json `agents.speechQuotas`: per-credential app speech quotas over SPEECH_RATE_LIMITS (only those set). */
80
+ readonly speechQuotas?: SpeechQuotaOverrides
74
81
  /**
75
82
  * HUB_RELEASE_BASE: where /install.sh and /install.ps1 tell machines to download hubctl (default: this version's
76
83
  * hub-releases GitHub release). https, or plain http on loopback or a private-network IP address.
@@ -82,6 +89,13 @@ export interface CentralConfig {
82
89
  readonly remoteFetch?: boolean
83
90
  /** HUB_FETCH_DENY_CIDRS: CIDRs the hub's outbound fetcher refuses on top of its built-in ranges (validated). */
84
91
  readonly fetchDenyCidrs?: readonly string[]
92
+ /** hub.json `contextCards` (context-cards/config.ts); HUB_CONTEXT_CARDS on/off sets enabled (off: the kill switch). */
93
+ readonly contextCards?: ContextCardsConfig
94
+ /**
95
+ * HUB_TRUSTED_PROXIES: reverse proxies (CIDRs or addresses, validated) whose X-Forwarded-For names the client for
96
+ * per-address rate limits (connect-app). Unset: the TCP peer is the client; X-Forwarded-For is never read.
97
+ */
98
+ readonly trustedProxies?: readonly string[]
85
99
  }
86
100
 
87
101
  /**
@@ -126,12 +140,16 @@ export interface HubAgents {
126
140
  readonly never?: readonly string[]
127
141
  readonly launches?: HubLaunches
128
142
  readonly webhooks?: HubWebhooks
143
+ /** (AF2) Per-credential app speech quotas: any subset of SPEECH_RATE_LIMITS. */
144
+ readonly speechQuotas?: SpeechQuotaOverrides
129
145
  }
130
146
 
131
147
  /** hub.json `agents.webhooks` (decision znbg8xa5 #8). */
132
148
  export interface HubWebhooks {
133
149
  /** Lets webhook URLs use http and private or local targets (self-hosted receivers); off by default (SSRF). */
134
150
  readonly allowPrivate?: boolean
151
+ /** Exact hostnames whose https webhook URLs may resolve to private addresses (a LAN-hosted receiver), nothing else. */
152
+ readonly allowPrivateHosts?: readonly string[]
135
153
  }
136
154
 
137
155
  /** hub.json, validated. */
@@ -144,14 +162,19 @@ export interface HubFile {
144
162
  readonly auth?: HubAuth
145
163
  readonly agents?: HubAgents
146
164
  readonly cluster?: HubCluster
165
+ readonly contextCards?: ContextCardsConfig
147
166
  }
148
167
 
149
168
  type Env = Readonly<Record<string, string | undefined>>
150
169
 
151
- const HUB_KEYS = new Set(["host", "port", "publicURL", "stateDir", "speech", "auth", "agents", "cluster"])
170
+ const HUB_KEYS = new Set(["host", "port", "publicURL", "stateDir", "speech", "auth", "agents", "cluster", "contextCards"])
152
171
  const CLUSTER_KEYS = ["database", "valkey"] as const
153
- const AGENTS_KEYS = new Set(["never", "launches", "webhooks"])
154
- const WEBHOOK_KEYS = new Set(["allowPrivate"])
172
+ const AGENTS_KEYS = new Set(["never", "launches", "webhooks", "speechQuotas"])
173
+ /** The largest speech quota hub.json may set (counts and characters). */
174
+ const SPEECH_QUOTA_MAX = 1_000_000_000
175
+ const WEBHOOK_KEYS = new Set(["allowPrivate", "allowPrivateHosts"])
176
+ const PRIVATE_HOSTS_MAX = 50
177
+ const HOSTNAME = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/
155
178
  const LAUNCH_CAP_KEYS = ["maxQueued", "maxActive", "perHour", "lostAfterMinutes"] as const
156
179
  /** The longest lostAfterMinutes (a day): far below setTimeout's 32-bit limit. */
157
180
  const LOST_AFTER_MAX = 1440
@@ -245,6 +268,7 @@ export function readHubFile(file: string, home: string = homedir()): { file?: Hu
245
268
  if (raw.auth !== undefined) hub.auth = readHubAuth(raw.auth, file, warnings, fail)
246
269
  if (raw.agents !== undefined) hub.agents = readHubAgents(raw.agents, file, warnings, fail)
247
270
  if (raw.cluster !== undefined) hub.cluster = readHubCluster(raw.cluster, file, warnings, fail)
271
+ if (raw.contextCards !== undefined) hub.contextCards = readContextCards(raw.contextCards, file, warnings, fail, (statSync(file).mode & 0o044) !== 0)
248
272
  if (raw.speech !== undefined) {
249
273
  if (!isObj(raw.speech)) fail("speech must be an object with stt and/or tts")
250
274
  const speech = { ...(raw.speech as Record<string, unknown>) }
@@ -329,7 +353,33 @@ function readHubAgents(raw: unknown, file: string, warnings: string[], fail: (me
329
353
  const launches = input.launches === undefined ? undefined : readHubLaunches(input.launches, file, warnings, fail)
330
354
  const never = input.never === undefined ? undefined : readNever(input.never, fail)
331
355
  const webhooks = input.webhooks === undefined ? undefined : readHubWebhooks(input.webhooks, file, warnings, fail)
332
- return { ...(never ? { never } : {}), ...(launches ? { launches } : {}), ...(webhooks ? { webhooks } : {}) }
356
+ const speechQuotas = input.speechQuotas === undefined ? undefined : readSpeechQuotas(input.speechQuotas, file, warnings, fail)
357
+ return { ...(never ? { never } : {}), ...(launches ? { launches } : {}), ...(webhooks ? { webhooks } : {}), ...(speechQuotas ? { speechQuotas } : {}) }
358
+ }
359
+
360
+ /** (AF2) `agents.speechQuotas`: {transcribe?: {perMinute?, perHour?}, speak?: {perMinute?, perHour?, charsPerHour?}}. */
361
+ function readSpeechQuotas(raw: unknown, file: string, warnings: string[], fail: (message: string) => never): SpeechQuotaOverrides {
362
+ if (!isObj(raw)) fail("agents.speechQuotas must be an object with transcribe and/or speak")
363
+ const out: Record<string, Record<string, number>> = {}
364
+ for (const [kind, section] of Object.entries(raw as Record<string, unknown>)) {
365
+ if (!Object.hasOwn(SPEECH_RATE_LIMITS, kind)) {
366
+ warnings.push(`${file}: unknown key "agents.speechQuotas.${kind}" ignored`)
367
+ continue
368
+ }
369
+ if (!isObj(section)) fail(`agents.speechQuotas.${kind} must be an object`)
370
+ const known = Object.keys(SPEECH_RATE_LIMITS[kind as keyof typeof SPEECH_RATE_LIMITS])
371
+ const quotas: Record<string, number> = {}
372
+ for (const [name, value] of Object.entries(section as Record<string, unknown>)) {
373
+ if (!known.includes(name) || !Object.hasOwn(section as object, name)) {
374
+ warnings.push(`${file}: unknown key "agents.speechQuotas.${kind}.${name}" ignored`)
375
+ continue
376
+ }
377
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > SPEECH_QUOTA_MAX) fail(`agents.speechQuotas.${kind}.${name} must be a whole number from 1 to ${SPEECH_QUOTA_MAX}`)
378
+ quotas[name] = value as number
379
+ }
380
+ out[kind] = quotas
381
+ }
382
+ return out as SpeechQuotaOverrides
333
383
  }
334
384
 
335
385
  function readHubWebhooks(raw: unknown, file: string, warnings: string[], fail: (message: string) => never): HubWebhooks {
@@ -337,7 +387,22 @@ function readHubWebhooks(raw: unknown, file: string, warnings: string[], fail: (
337
387
  const input = raw as Record<string, unknown>
338
388
  for (const key of Object.keys(input)) if (!WEBHOOK_KEYS.has(key)) warnings.push(`${file}: unknown key "agents.webhooks.${key}" ignored`)
339
389
  if (input.allowPrivate !== undefined && typeof input.allowPrivate !== "boolean") fail("agents.webhooks.allowPrivate must be true or false")
340
- return input.allowPrivate === undefined ? {} : { allowPrivate: input.allowPrivate as boolean }
390
+ const hosts = input.allowPrivateHosts === undefined ? undefined : readPrivateHosts(input.allowPrivateHosts, fail)
391
+ return { ...(input.allowPrivate === undefined ? {} : { allowPrivate: input.allowPrivate as boolean }), ...(hosts ? { allowPrivateHosts: hosts } : {}) }
392
+ }
393
+
394
+ /** Plain DNS hostnames with a dot (no IPs, wildcards, schemes, ports or paths), lowercase, trailing dot dropped, deduplicated. */
395
+ function readPrivateHosts(raw: unknown, fail: (message: string) => never): string[] {
396
+ if (!Array.isArray(raw)) fail("agents.webhooks.allowPrivateHosts must be a list of hostnames")
397
+ const list = raw as unknown[]
398
+ if (list.length > PRIVATE_HOSTS_MAX) fail(`agents.webhooks.allowPrivateHosts holds at most ${PRIVATE_HOSTS_MAX} hostnames`)
399
+ const out: string[] = []
400
+ for (const item of list) {
401
+ const host = typeof item === "string" ? item.trim().toLowerCase().replace(/\.$/, "") : ""
402
+ if (!HOSTNAME.test(host) || /^[0-9.]+$/.test(host)) fail(`agents.webhooks.allowPrivateHosts: ${JSON.stringify(item)} is not a hostname (no IPs, wildcards, schemes, ports or paths)`)
403
+ if (!out.includes(host)) out.push(host)
404
+ }
405
+ return out
341
406
  }
342
407
 
343
408
  function readHubLaunches(raw: unknown, file: string, warnings: string[], fail: (message: string) => never): HubLaunches {
@@ -516,6 +581,8 @@ export function centralConfig(rawEnv: Env = process.env, home: string = homedir(
516
581
  const remoteFetch = remoteFetchSetting(env).enabled ? "on" : "off"
517
582
  parseDenyCidrs(env.HUB_FETCH_DENY_CIDRS) // throws on an invalid entry: a startup error
518
583
  const fetchDenyCidrs = (env.HUB_FETCH_DENY_CIDRS ?? "").split(",").map((entry) => entry.trim()).filter(Boolean)
584
+ parseDenyCidrs(env.HUB_TRUSTED_PROXIES, "HUB_TRUSTED_PROXIES")
585
+ const trustedProxies = (env.HUB_TRUSTED_PROXIES ?? "").split(",").map((entry) => entry.trim()).filter(Boolean)
519
586
  return {
520
587
  host,
521
588
  port,
@@ -536,13 +603,29 @@ export function centralConfig(rawEnv: Env = process.env, home: string = homedir(
536
603
  agentsNever: [...(file.agents?.never ?? [])],
537
604
  ...(file.agents?.launches ? { launchCaps: { ...file.agents.launches } } : {}),
538
605
  ...(file.agents?.webhooks?.allowPrivate === true ? { webhooksAllowPrivate: true as const } : {}),
606
+ ...(file.agents?.webhooks?.allowPrivateHosts?.length ? { webhooksPrivateHosts: [...file.agents.webhooks.allowPrivateHosts] } : {}),
607
+ ...(file.agents?.speechQuotas ? { speechQuotas: file.agents.speechQuotas } : {}),
539
608
  ...(releaseBase ? { releaseBase } : {}),
540
609
  ...(cluster ? { cluster } : {}),
541
610
  ...(remoteFetch === "off" ? { remoteFetch: false } : {}),
542
611
  ...(fetchDenyCidrs.length ? { fetchDenyCidrs } : {}),
612
+ ...contextCardsSetting(env.HUB_CONTEXT_CARDS, file.contextCards),
613
+ ...(trustedProxies.length ? { trustedProxies } : {}),
543
614
  }
544
615
  }
545
616
 
617
+ /**
618
+ * hub.json `contextCards` with HUB_CONTEXT_CARDS over its `enabled`: on/true/1/yes or off/false/0/no (any case; off is
619
+ * the hub-wide kill switch). Unset or empty leaves the file alone; any other value throws (startup stops).
620
+ */
621
+ function contextCardsSetting(raw: string | undefined, file: ContextCardsConfig | undefined): { contextCards?: ContextCardsConfig } {
622
+ const value = raw?.trim().toLowerCase()
623
+ if (value === undefined || value === "") return file ? { contextCards: file } : {}
624
+ if (/^(on|true|1|yes)$/.test(value)) return { contextCards: { ...file, enabled: true } }
625
+ if (/^(off|false|0|no)$/.test(value)) return { contextCards: { ...file, enabled: false } }
626
+ throw new Error(`HUB_CONTEXT_CARDS must be on/true/1/yes or off/false/0/no, got ${JSON.stringify(raw)}`)
627
+ }
628
+
546
629
  export interface LoadOptions {
547
630
  /** Where one-time migration notices go (default console). */
548
631
  readonly log?: (message: string) => void
@@ -0,0 +1,121 @@
1
+ // The context cards' per-user daily budget (decision 9604b0a1: "implement the entitlements hook for real"): model
2
+ // calls and tokens per user per UTC day, in the StateStore collection `context-card-usage` (key = the day,
3
+ // "2026-11-08"; older days are dropped on write). reserve() BEFORE a model call checks both limits and counts the
4
+ // call in one version-checked write (serialised per user in this process, so concurrent runs cannot all pass a
5
+ // check first); settle() after it adds the tokens the call reported, also when it failed. A failed or timed-out call
6
+ // keeps its reservation: attempts count. Manual refreshes count too: the budget is the cost ceiling.
7
+ import type { StateStore } from "../backend/types"
8
+ import { updateVersioned } from "../agent/versioned"
9
+
10
+ export const CONTEXT_CARD_USAGE = "context-card-usage"
11
+
12
+ export interface BudgetLimits {
13
+ /** Model calls per user per UTC day. */
14
+ readonly calls: number
15
+ /** Input + output tokens per user per UTC day. */
16
+ readonly tokens: number
17
+ }
18
+
19
+ /** Defaults: ~200 pick-up summaries a day at ≈ 2k tokens each. */
20
+ export const DEFAULT_BUDGET: BudgetLimits = { calls: 200, tokens: 400_000 }
21
+
22
+ export type BudgetCheck = { readonly ok: true } | { readonly ok: false; readonly reason: string }
23
+
24
+ export interface CardBudget {
25
+ /** Counts one call if both limits allow it (atomically); refused otherwise. */
26
+ reserve(userID: string): Promise<BudgetCheck>
27
+ /** Adds the tokens a reserved call used. */
28
+ settle(userID: string, tokens: number): Promise<void>
29
+ }
30
+
31
+ export interface BudgetUsage {
32
+ readonly day: string
33
+ readonly calls: number
34
+ readonly tokens: number
35
+ readonly limits: BudgetLimits
36
+ }
37
+
38
+ interface DayUsage {
39
+ readonly calls: number
40
+ readonly tokens: number
41
+ }
42
+
43
+ const dayOf = (at: number) => new Date(at).toISOString().slice(0, 10)
44
+
45
+ export class DailyBudget implements CardBudget {
46
+ private readonly now: () => number
47
+
48
+ constructor(private readonly deps: { readonly state: StateStore; readonly now?: () => number; readonly limits: BudgetLimits | (() => BudgetLimits) }) {
49
+ this.now = deps.now ?? Date.now
50
+ }
51
+
52
+ private limits(): BudgetLimits {
53
+ return typeof this.deps.limits === "function" ? this.deps.limits() : this.deps.limits
54
+ }
55
+
56
+ async usage(userID: string): Promise<BudgetUsage> {
57
+ const day = dayOf(this.now())
58
+ const value = (await this.deps.state.get<DayUsage>(userID, CONTEXT_CARD_USAGE, day))?.value
59
+ return { day, calls: value?.calls ?? 0, tokens: value?.tokens ?? 0, limits: this.limits() }
60
+ }
61
+
62
+ async check(userID: string): Promise<BudgetCheck> {
63
+ const { calls, tokens, limits } = await this.usage(userID)
64
+ if (calls >= limits.calls) return { ok: false, reason: "Daily context card budget used up (calls)" }
65
+ if (tokens >= limits.tokens) return { ok: false, reason: "Daily context card budget used up (tokens)" }
66
+ return { ok: true }
67
+ }
68
+
69
+ /** Per-user chain: this process's reservations run one at a time (the versioned write covers other pods). */
70
+ private readonly chains = new Map<string, Promise<unknown>>()
71
+
72
+ private serial<T>(userID: string, run: () => Promise<T>): Promise<T> {
73
+ const next = (this.chains.get(userID) ?? Promise.resolve()).then(run, run)
74
+ const tail = next.catch(() => undefined)
75
+ this.chains.set(userID, tail)
76
+ void tail.then(() => {
77
+ if (this.chains.get(userID) === tail) this.chains.delete(userID)
78
+ })
79
+ return next
80
+ }
81
+
82
+ reserve(userID: string): Promise<BudgetCheck> {
83
+ return this.serial(userID, async () => {
84
+ const limits = this.limits()
85
+ let refused: BudgetCheck | undefined
86
+ await this.update(userID, (current) => {
87
+ refused = undefined
88
+ if ((current?.calls ?? 0) >= limits.calls) refused = { ok: false, reason: "Daily context card budget used up (calls)" }
89
+ else if ((current?.tokens ?? 0) >= limits.tokens) refused = { ok: false, reason: "Daily context card budget used up (tokens)" }
90
+ return refused ? undefined : { calls: (current?.calls ?? 0) + 1, tokens: current?.tokens ?? 0 }
91
+ })
92
+ return refused ?? { ok: true }
93
+ })
94
+ }
95
+
96
+ settle(userID: string, tokens: number): Promise<void> {
97
+ if (!(tokens > 0)) return Promise.resolve()
98
+ return this.serial(userID, () => this.update(userID, (current) => ({ calls: current?.calls ?? 0, tokens: (current?.tokens ?? 0) + tokens })))
99
+ }
100
+
101
+ /** One version-checked change of today's record; a new day's first write drops the older days. */
102
+ private async update(userID: string, change: (current: DayUsage | undefined) => DayUsage | undefined): Promise<void> {
103
+ const day = dayOf(this.now())
104
+ let fresh = false
105
+ const written = await updateVersioned<DayUsage>(this.deps.state, userID, CONTEXT_CARD_USAGE, day, (current) => {
106
+ fresh = current === undefined
107
+ return change(current)
108
+ })
109
+ if (written && fresh) {
110
+ for (const entry of await this.deps.state.list(userID, CONTEXT_CARD_USAGE)) {
111
+ if (entry.key !== day) await this.deps.state.delete(userID, CONTEXT_CARD_USAGE, entry.key).catch(() => {})
112
+ }
113
+ }
114
+ }
115
+
116
+ /** Adds usage directly (tests, imports); reserve/settle are the call path. */
117
+ record(userID: string, usage: { readonly calls: number; readonly tokens: number }): Promise<void> {
118
+ return this.serial(userID, () => this.update(userID, (current) => ({ calls: (current?.calls ?? 0) + usage.calls, tokens: (current?.tokens ?? 0) + usage.tokens })))
119
+ }
120
+
121
+ }
@@ -0,0 +1,29 @@
1
+ // The `changes` context card: the session's working-tree changes from the spoke summary (files, +/−, branch),
2
+ // deterministic (no model, no budget). It proves the registry's modularity and is cheap to keep fresh.
3
+ import type { CardDraft, ContextCardProvider } from "./types"
4
+
5
+ /** Files listed as fields (the most-changed first, as the spoke lists them). */
6
+ const FILES_SHOWN = 4
7
+
8
+ export function changesProvider(): ContextCardProvider {
9
+ return {
10
+ id: "changes",
11
+ label: "Changes",
12
+ version: 1,
13
+ usesModel: false,
14
+ triggers: ["turn-end", "user-return", "sweep"],
15
+ minIntervalMs: 60_000,
16
+ needs: ["changes"],
17
+ async generate({ inputs }): Promise<CardDraft | undefined> {
18
+ const c = inputs.changes
19
+ if (!c) return undefined
20
+ if (!c.totalFiles && !c.files.length) return { headline: c.branch ? `No changes on ${c.branch}` : "No changes", body: "" }
21
+ const n = c.totalFiles || c.files.length
22
+ return {
23
+ headline: `${n} file${n === 1 ? "" : "s"} changed · +${c.additions} −${c.deletions}${c.branch ? ` on ${c.branch}` : ""}`,
24
+ body: "",
25
+ fields: [...(c.branch ? [{ label: "Branch", value: c.branch }] : []), ...c.files.slice(0, FILES_SHOWN).map((f) => ({ label: f.file, value: `+${f.additions} −${f.deletions}` }))],
26
+ }
27
+ },
28
+ }
29
+ }