@huynle/hub 0.9.2 → 0.10.1

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 +320 -0
  2. package/Dockerfile +5 -1
  3. package/README.md +154 -77
  4. package/package.json +7 -4
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/cli/dist/hub-cli.js +534 -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 +81 -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 +547 -58
  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 +227 -237
  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-CsPjam8D.js} +1 -1
  111. package/web/dist/assets/{ArtifactPane-CKOiAUNH.js → ArtifactPane-B9weO8L6.js} +1 -1
  112. package/web/dist/assets/ArtifactWorkbench-DzKhMs3l.js +6 -0
  113. package/web/dist/assets/{Diagram-vdpmp3xu.css → Diagram-7y8kg3og.css} +1 -1
  114. package/web/dist/assets/Diagram-CuRIffka.js +1 -0
  115. package/web/dist/assets/{Library-CcjZXsfF.js → Library-rv5DGtrm.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-DUuQ9jmd.js +1 -0
  119. package/web/dist/assets/{TeamsPage-CDyjrK-I.js → TeamsPage-D_17MkvN.js} +1 -1
  120. package/web/dist/assets/{Watchtower-DYwoUWME.js → Watchtower-DlrNOJ7X.js} +2 -2
  121. package/web/dist/assets/{artifacts-DU9uRgs1.js → artifacts-But87Jfo.js} +1 -1
  122. package/web/dist/assets/index-bX8B6NLo.js +33 -0
  123. package/web/dist/assets/index-rW6LKQj1.css +1 -0
  124. package/web/dist/assets/{live-ycZ_aQZO.js → live-BGLA-peO.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-DyANQ1WS.js} +1 -1
  127. package/web/dist/assets/{renderer3d-C1a7eYGQ.js → renderer3d-vYYKFAJq.js} +1 -1
  128. package/web/dist/assets/{safe-area-DHhPy61Q.js → safe-area-B2Bk__FK.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,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"
@@ -73,6 +76,8 @@ export interface CentralConfig {
73
76
  readonly webhooksAllowPrivate?: true
74
77
  /** hub.json `agents.webhooks.allowPrivateHosts`: exact hostnames whose https webhook URLs may resolve privately. */
75
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
76
81
  /**
77
82
  * HUB_RELEASE_BASE: where /install.sh and /install.ps1 tell machines to download hubctl (default: this version's
78
83
  * hub-releases GitHub release). https, or plain http on loopback or a private-network IP address.
@@ -84,6 +89,13 @@ export interface CentralConfig {
84
89
  readonly remoteFetch?: boolean
85
90
  /** HUB_FETCH_DENY_CIDRS: CIDRs the hub's outbound fetcher refuses on top of its built-in ranges (validated). */
86
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[]
87
99
  }
88
100
 
89
101
  /**
@@ -128,6 +140,8 @@ export interface HubAgents {
128
140
  readonly never?: readonly string[]
129
141
  readonly launches?: HubLaunches
130
142
  readonly webhooks?: HubWebhooks
143
+ /** (AF2) Per-credential app speech quotas: any subset of SPEECH_RATE_LIMITS. */
144
+ readonly speechQuotas?: SpeechQuotaOverrides
131
145
  }
132
146
 
133
147
  /** hub.json `agents.webhooks` (decision znbg8xa5 #8). */
@@ -148,13 +162,16 @@ export interface HubFile {
148
162
  readonly auth?: HubAuth
149
163
  readonly agents?: HubAgents
150
164
  readonly cluster?: HubCluster
165
+ readonly contextCards?: ContextCardsConfig
151
166
  }
152
167
 
153
168
  type Env = Readonly<Record<string, string | undefined>>
154
169
 
155
- 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"])
156
171
  const CLUSTER_KEYS = ["database", "valkey"] as const
157
- const AGENTS_KEYS = new Set(["never", "launches", "webhooks"])
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
158
175
  const WEBHOOK_KEYS = new Set(["allowPrivate", "allowPrivateHosts"])
159
176
  const PRIVATE_HOSTS_MAX = 50
160
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])?$/
@@ -251,6 +268,7 @@ export function readHubFile(file: string, home: string = homedir()): { file?: Hu
251
268
  if (raw.auth !== undefined) hub.auth = readHubAuth(raw.auth, file, warnings, fail)
252
269
  if (raw.agents !== undefined) hub.agents = readHubAgents(raw.agents, file, warnings, fail)
253
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)
254
272
  if (raw.speech !== undefined) {
255
273
  if (!isObj(raw.speech)) fail("speech must be an object with stt and/or tts")
256
274
  const speech = { ...(raw.speech as Record<string, unknown>) }
@@ -335,7 +353,33 @@ function readHubAgents(raw: unknown, file: string, warnings: string[], fail: (me
335
353
  const launches = input.launches === undefined ? undefined : readHubLaunches(input.launches, file, warnings, fail)
336
354
  const never = input.never === undefined ? undefined : readNever(input.never, fail)
337
355
  const webhooks = input.webhooks === undefined ? undefined : readHubWebhooks(input.webhooks, file, warnings, fail)
338
- 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
339
383
  }
340
384
 
341
385
  function readHubWebhooks(raw: unknown, file: string, warnings: string[], fail: (message: string) => never): HubWebhooks {
@@ -537,6 +581,8 @@ export function centralConfig(rawEnv: Env = process.env, home: string = homedir(
537
581
  const remoteFetch = remoteFetchSetting(env).enabled ? "on" : "off"
538
582
  parseDenyCidrs(env.HUB_FETCH_DENY_CIDRS) // throws on an invalid entry: a startup error
539
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)
540
586
  return {
541
587
  host,
542
588
  port,
@@ -558,13 +604,28 @@ export function centralConfig(rawEnv: Env = process.env, home: string = homedir(
558
604
  ...(file.agents?.launches ? { launchCaps: { ...file.agents.launches } } : {}),
559
605
  ...(file.agents?.webhooks?.allowPrivate === true ? { webhooksAllowPrivate: true as const } : {}),
560
606
  ...(file.agents?.webhooks?.allowPrivateHosts?.length ? { webhooksPrivateHosts: [...file.agents.webhooks.allowPrivateHosts] } : {}),
607
+ ...(file.agents?.speechQuotas ? { speechQuotas: file.agents.speechQuotas } : {}),
561
608
  ...(releaseBase ? { releaseBase } : {}),
562
609
  ...(cluster ? { cluster } : {}),
563
610
  ...(remoteFetch === "off" ? { remoteFetch: false } : {}),
564
611
  ...(fetchDenyCidrs.length ? { fetchDenyCidrs } : {}),
612
+ ...contextCardsSetting(env.HUB_CONTEXT_CARDS, file.contextCards),
613
+ ...(trustedProxies.length ? { trustedProxies } : {}),
565
614
  }
566
615
  }
567
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
+
568
629
  export interface LoadOptions {
569
630
  /** Where one-time migration notices go (default console). */
570
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
+ }