@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.
- package/CHANGELOG.md +320 -0
- package/Dockerfile +5 -1
- package/README.md +154 -77
- package/package.json +7 -4
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +534 -261
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/cli.ts +36 -1
- package/packages/cli/src/subscriptions.ts +49 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/sessions.ts +10 -6
- package/packages/codex/src/spoke.ts +2 -1
- package/packages/node/package.json +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/schema/app-delivery/v1.schema.json +7 -5
- package/packages/protocol/schema/app-front/v1.schema.json +410 -0
- package/packages/protocol/src/app-front.ts +293 -0
- package/packages/protocol/src/concierge.ts +10 -1
- package/packages/protocol/src/context-cards.ts +59 -0
- package/packages/protocol/src/credentials.ts +39 -2
- package/packages/protocol/src/index.ts +81 -11
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/app-front.ts +524 -0
- package/packages/sdk/src/client.ts +37 -4
- package/packages/sdk/src/envelope.ts +9 -2
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/index.ts +1 -0
- package/packages/sdk/src/types.ts +39 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/delivery.ts +17 -1
- package/packages/spoke-sdk/src/harness-spoke.ts +4 -3
- package/packages/voice-client/README.md +146 -0
- package/packages/voice-client/src/vad/LICENSE +21 -0
- package/src/central/agent/acting-routes.ts +17 -3
- package/src/central/agent/acting.ts +12 -3
- package/src/central/agent/actor.ts +6 -1
- package/src/central/agent/app-decisions.ts +146 -0
- package/src/central/agent/app-tokens.ts +9 -3
- package/src/central/agent/audit.ts +3 -0
- package/src/central/agent/connect-routes.ts +64 -0
- package/src/central/agent/connect.ts +868 -0
- package/src/central/agent/conversation.ts +625 -0
- package/src/central/agent/decisions.ts +71 -6
- package/src/central/agent/delivery.ts +3 -0
- package/src/central/agent/directory.ts +5 -0
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/grant-requests.ts +46 -9
- package/src/central/agent/grants.ts +13 -2
- package/src/central/agent/message-limits.ts +2 -2
- package/src/central/agent/message-routes.ts +13 -4
- package/src/central/agent/read-routes.ts +16 -2
- package/src/central/agent/routes.ts +108 -6
- package/src/central/agent/speech-routes.ts +316 -0
- package/src/central/agent/subscription-routes.ts +174 -0
- package/src/central/agent/subscriptions.ts +980 -0
- package/src/central/agent-data.ts +43 -1
- package/src/central/backend/memory-counters.ts +2 -2
- package/src/central/backend/types.ts +5 -2
- package/src/central/backend/valkey-counters.ts +5 -4
- package/src/central/client-address.ts +16 -0
- package/src/central/concierge/board-render.ts +52 -1
- package/src/central/concierge/board-routes.ts +25 -2
- package/src/central/concierge/board.ts +11 -1
- package/src/central/concierge/context.ts +29 -8
- package/src/central/concierge/grant-request.ts +216 -0
- package/src/central/concierge/prompt.ts +49 -2
- package/src/central/concierge/session-routes.ts +3 -0
- package/src/central/config.ts +64 -3
- package/src/central/context-cards/budget.ts +121 -0
- package/src/central/context-cards/changes.ts +29 -0
- package/src/central/context-cards/config.ts +145 -0
- package/src/central/context-cards/engine.ts +379 -0
- package/src/central/context-cards/inputs.ts +111 -0
- package/src/central/context-cards/pickup.ts +134 -0
- package/src/central/context-cards/registry.ts +89 -0
- package/src/central/context-cards/routes.ts +37 -0
- package/src/central/context-cards/service.ts +367 -0
- package/src/central/context-cards/store.ts +67 -0
- package/src/central/context-cards/types.ts +88 -0
- package/src/central/fetcher.ts +10 -2
- package/src/central/front-relays.ts +423 -0
- package/src/central/http.ts +547 -58
- package/src/central/human-activity.ts +453 -0
- package/src/central/llm/chat.ts +133 -0
- package/src/central/registry.ts +162 -9
- package/src/central/speech/service.ts +9 -0
- package/src/central/spoke-ws.ts +13 -0
- package/src/central/store.ts +9 -12
- package/src/config.ts +30 -13
- package/src/index.ts +45 -43
- package/src/rpc.ts +33 -4
- package/src/spoke/concierge-agent.ts +1 -1
- package/src/spoke/conversation.ts +6 -75
- package/src/spoke/deliver.ts +18 -8
- package/src/spoke/hub-rpc.ts +7 -2
- package/src/spoke/hub-tool-subscriptions.ts +82 -0
- package/src/spoke/hub-tool-title.ts +89 -0
- package/src/spoke/hub-tool.ts +56 -18
- package/src/spoke/session-context.ts +10 -2
- package/src/spoke/spoke.ts +227 -237
- package/src/spoke/summary.ts +36 -1
- package/src/spoke/taskboard.ts +5 -6
- package/src/spoke/voice-front.ts +323 -30
- package/src/spoke/who-teams.ts +51 -2
- package/src/tui-messaging.ts +24 -10
- package/src/tui.ts +6 -6
- package/web/dist/assets/{ArtifactPage-BKISN-Qj.js → ArtifactPage-CsPjam8D.js} +1 -1
- package/web/dist/assets/{ArtifactPane-CKOiAUNH.js → ArtifactPane-B9weO8L6.js} +1 -1
- package/web/dist/assets/ArtifactWorkbench-DzKhMs3l.js +6 -0
- package/web/dist/assets/{Diagram-vdpmp3xu.css → Diagram-7y8kg3og.css} +1 -1
- package/web/dist/assets/Diagram-CuRIffka.js +1 -0
- package/web/dist/assets/{Library-CcjZXsfF.js → Library-rv5DGtrm.js} +2 -2
- package/web/dist/assets/Lightbox-BlbivCkd.js +1 -0
- package/web/dist/assets/MediaImage-Ch3ryIdR.js +4 -0
- package/web/dist/assets/ProfilesDestination-DUuQ9jmd.js +1 -0
- package/web/dist/assets/{TeamsPage-CDyjrK-I.js → TeamsPage-D_17MkvN.js} +1 -1
- package/web/dist/assets/{Watchtower-DYwoUWME.js → Watchtower-DlrNOJ7X.js} +2 -2
- package/web/dist/assets/{artifacts-DU9uRgs1.js → artifacts-But87Jfo.js} +1 -1
- package/web/dist/assets/index-bX8B6NLo.js +33 -0
- package/web/dist/assets/index-rW6LKQj1.css +1 -0
- package/web/dist/assets/{live-ycZ_aQZO.js → live-BGLA-peO.js} +2 -2
- package/web/dist/assets/{pane-layout-0RrLHGDr.js → pane-layout-BW0RdX5a.js} +1 -1
- package/web/dist/assets/{renderer2d-BIwbgRoX.js → renderer2d-DyANQ1WS.js} +1 -1
- package/web/dist/assets/{renderer3d-C1a7eYGQ.js → renderer3d-vYYKFAJq.js} +1 -1
- package/web/dist/assets/{safe-area-DHhPy61Q.js → safe-area-B2Bk__FK.js} +1 -1
- package/web/dist/assets/{source-BWlvaODa.js → source-C-De8L3s.js} +1 -1
- package/web/dist/assets/toast-CXTwTvDw.js +9 -0
- package/web/dist/assets/vad-worklet-BfxJhrXy.js +27 -0
- package/web/dist/index.html +6 -6
- package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +0 -6
- package/web/dist/assets/Diagram-azw2B_QH.js +0 -1
- package/web/dist/assets/Lightbox-Du56MXGX.js +0 -1
- package/web/dist/assets/MediaImage-Ds_JUx3a.js +0 -4
- package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +0 -1
- package/web/dist/assets/index-C3ci5UtY.css +0 -1
- package/web/dist/assets/index-LNMZCqgE.js +0 -36
- package/web/dist/assets/toast-Cuu2K0hx.js +0 -1
- /package/{web/src/voice → packages/voice-client/src}/turn/LICENSE +0 -0
|
@@ -0,0 +1,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 =
|
|
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}`
|
package/src/central/config.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|