@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
|
@@ -63,11 +63,51 @@ export interface AppToken {
|
|
|
63
63
|
[field: string]: unknown
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* (AF1) A registered app client of this user (hub-wide when an admin registered it with audience "hub", else
|
|
68
|
+
* personal). Only the sha256 of its secret is stored. Unique per user by id (hcl_…); IDs are hub-unique.
|
|
69
|
+
*/
|
|
70
|
+
export interface AppClientRecord {
|
|
71
|
+
id: string
|
|
72
|
+
secretHash: string
|
|
73
|
+
name?: string
|
|
74
|
+
redirectURIs?: string[]
|
|
75
|
+
scopes?: string[]
|
|
76
|
+
audience?: "hub" | "user"
|
|
77
|
+
created?: number
|
|
78
|
+
updated?: number
|
|
79
|
+
disabled?: true
|
|
80
|
+
[field: string]: unknown
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* (AF1) This user's connection to an app client: its scopes and its token family (sha256 hashes only). `previous`
|
|
85
|
+
* is the 30 s refresh grace: the just-rotated refresh token's hash, its access token's hash and the new pair sealed
|
|
86
|
+
* with a key derived from that refresh token. Unique per user by id (16 hex, the app's tokenID).
|
|
87
|
+
*/
|
|
88
|
+
export interface ConnectionRecord {
|
|
89
|
+
id: string
|
|
90
|
+
clientID: string
|
|
91
|
+
scopes?: string[]
|
|
92
|
+
created?: number
|
|
93
|
+
lastUsed?: number
|
|
94
|
+
accessHash?: string
|
|
95
|
+
accessExpiresAt?: number
|
|
96
|
+
refreshHash?: string
|
|
97
|
+
refreshExpiresAt?: number
|
|
98
|
+
previous?: { readonly refreshHash: string; readonly accessHash: string; readonly until: number; readonly accessUntil: number; readonly sealed: string }
|
|
99
|
+
[field: string]: unknown
|
|
100
|
+
}
|
|
101
|
+
|
|
66
102
|
export interface AgentData {
|
|
67
103
|
names?: AgentName[]
|
|
68
104
|
roles?: AgentRole[]
|
|
69
105
|
sessions?: AgentSession[]
|
|
70
106
|
appTokens?: AppToken[]
|
|
107
|
+
/** (AF1) Registered app clients (connect-app). */
|
|
108
|
+
appClients?: AppClientRecord[]
|
|
109
|
+
/** (AF1) Connections of this user to app clients. */
|
|
110
|
+
connections?: ConnectionRecord[]
|
|
71
111
|
/** Last holder generation handed out per roleKey(role, project). */
|
|
72
112
|
generations?: Record<string, number>
|
|
73
113
|
/** The user's agent kill switch (agent/acting-switch.ts): every agent action answers paused. */
|
|
@@ -89,11 +129,13 @@ const LEGACY_ROLE_KEY = "\u0000"
|
|
|
89
129
|
type Entry = Record<string, unknown>
|
|
90
130
|
|
|
91
131
|
/** Each list: the string fields an entry needs, and its identity (entries with the same one are duplicates). */
|
|
92
|
-
const LISTS: Record<"names" | "roles" | "sessions" | "appTokens", { required: readonly string[]; identity: (e: Entry) => string }> = {
|
|
132
|
+
const LISTS: Record<"names" | "roles" | "sessions" | "appTokens" | "appClients" | "connections", { required: readonly string[]; identity: (e: Entry) => string }> = {
|
|
93
133
|
names: { required: ["name", "sessionID"], identity: (e) => e.name as string },
|
|
94
134
|
roles: { required: ["role", "sessionID"], identity: (e) => `${roleKey(e.role as string, typeof e.project === "string" ? e.project : undefined)}\u0000${e.sessionID}` },
|
|
95
135
|
sessions: { required: ["sessionID"], identity: (e) => e.sessionID as string },
|
|
96
136
|
appTokens: { required: ["id", "tokenHash"], identity: (e) => e.id as string },
|
|
137
|
+
appClients: { required: ["id", "secretHash"], identity: (e) => e.id as string },
|
|
138
|
+
connections: { required: ["id", "clientID"], identity: (e) => e.id as string },
|
|
97
139
|
}
|
|
98
140
|
|
|
99
141
|
const isPlain = (v: unknown): v is Entry => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
@@ -46,9 +46,9 @@ export class MemoryCounters implements Counters {
|
|
|
46
46
|
this.counts = new ExpiringMap(this.now)
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
async incr(key: string, ttlMs: number): Promise<number> {
|
|
49
|
+
async incr(key: string, ttlMs: number, by = 1): Promise<number> {
|
|
50
50
|
const current = this.counts.get(key)
|
|
51
|
-
const count = (current?.count ?? 0) +
|
|
51
|
+
const count = (current?.count ?? 0) + by
|
|
52
52
|
this.counts.set(key, { count, expiresAt: current?.expiresAt ?? this.now() + ttlMs })
|
|
53
53
|
return count
|
|
54
54
|
}
|
|
@@ -143,8 +143,11 @@ export interface EventLog {
|
|
|
143
143
|
|
|
144
144
|
/** Fixed-window counters (rate limits, budgets): INCR + PEXPIRE NX in a cluster. */
|
|
145
145
|
export interface Counters {
|
|
146
|
-
/**
|
|
147
|
-
|
|
146
|
+
/**
|
|
147
|
+
* Adds `by` (default one; a non-zero whole number, negative to undo an increment) and returns the count; the first
|
|
148
|
+
* increment of a window starts its `ttlMs`, later ones never extend it.
|
|
149
|
+
*/
|
|
150
|
+
incr(key: string, ttlMs: number, by?: number): Promise<number>
|
|
148
151
|
/** The current count (0 when unset or expired). */
|
|
149
152
|
get(key: string): Promise<number>
|
|
150
153
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
// Counters in Valkey: fixed windows as
|
|
1
|
+
// Counters in Valkey: fixed windows as INCRBY + PEXPIRE NX in one Lua script, so the first increment of a window
|
|
2
2
|
// starts its expiry and later ones never extend it, atomically. Keys go under a prefix (default "ctr:").
|
|
3
3
|
// Expiry is server-side, so it follows the server's clock, not an injected one.
|
|
4
4
|
import type { Counters } from "./types"
|
|
5
5
|
import { ValkeyConnection, type ValkeySource } from "./valkey-client"
|
|
6
6
|
|
|
7
|
-
const INCR_WINDOW = `local n = redis.call('
|
|
7
|
+
const INCR_WINDOW = `local n = redis.call('INCRBY', KEYS[1], ARGV[2])
|
|
8
8
|
redis.call('PEXPIRE', KEYS[1], ARGV[1], 'NX')
|
|
9
9
|
return n`
|
|
10
10
|
|
|
@@ -22,9 +22,10 @@ export class ValkeyCounters implements Counters {
|
|
|
22
22
|
this.prefix = options.prefix ?? "ctr:"
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
async incr(key: string, ttlMs: number): Promise<number> {
|
|
25
|
+
async incr(key: string, ttlMs: number, by = 1): Promise<number> {
|
|
26
26
|
if (!(Number.isInteger(ttlMs) && ttlMs > 0)) throw new Error("Counters.incr: ttl must be a positive whole number of milliseconds")
|
|
27
|
-
|
|
27
|
+
if (!(Number.isInteger(by) && by !== 0)) throw new Error("Counters.incr: by must be a non-zero whole number")
|
|
28
|
+
return Number(await this.valkey.send("EVAL", [INCR_WINDOW, "1", this.prefix + key, String(ttlMs), String(by)]))
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
async get(key: string): Promise<number> {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// The client address of a request, for per-address rate limits (connect-app, AF1). The TCP peer, unless the peer is
|
|
2
|
+
// a trusted reverse proxy (HUB_TRUSTED_PROXIES): then the right-most X-Forwarded-For entry that is not itself a
|
|
3
|
+
// trusted proxy (the address the outermost trusted proxy saw). Without trusted proxies the header is never read, so
|
|
4
|
+
// a client cannot pick its own address. Only for limits: never for authentication or authorization.
|
|
5
|
+
import { type Cidr, inCidrs } from "./fetcher"
|
|
6
|
+
|
|
7
|
+
export function clientAddress(request: Request, peer: string | undefined, trusted: readonly Cidr[]): string {
|
|
8
|
+
const direct = (peer ?? "").trim() || "unknown"
|
|
9
|
+
if (!trusted.length || !inCidrs(direct, trusted)) return direct
|
|
10
|
+
const hops = (request.headers.get("x-forwarded-for") ?? "")
|
|
11
|
+
.split(",")
|
|
12
|
+
.map((hop) => hop.trim())
|
|
13
|
+
.filter(Boolean)
|
|
14
|
+
for (let i = hops.length - 1; i >= 0; i--) if (!inCidrs(hops[i]!, trusted)) return hops[i]!.slice(0, 64)
|
|
15
|
+
return hops[0]?.slice(0, 64) ?? direct
|
|
16
|
+
}
|
|
@@ -24,8 +24,12 @@
|
|
|
24
24
|
// surrogates are removed from all of the text.
|
|
25
25
|
import { CONCIERGE_BOARD_LIMITS, INTENT_MAX, type ConciergeBoard, type ConciergeBoardDecision, type ConciergeBoardSection, type ConciergeBoardSession } from "../../../packages/protocol/src/concierge"
|
|
26
26
|
import { isHubSessionID } from "../../../packages/protocol/src/harness-events"
|
|
27
|
+
import { CONTEXT_CARD_LIMITS } from "../../../packages/protocol/src/context-cards"
|
|
27
28
|
import { sanitizeLabel } from "../agent/envelope"
|
|
28
29
|
|
|
30
|
+
/** (Concierge grant requests) The verbs a board may name for a project's grant (anything else is dropped). */
|
|
31
|
+
export const PROJECT_GRANT_VERBS: readonly string[] = ["approve", "deny", "answer", "prompt", "stop"]
|
|
32
|
+
|
|
29
33
|
const MIN = 60_000
|
|
30
34
|
/** An age line when the board is at least this old. */
|
|
31
35
|
const AGE_AFTER_MS = MIN
|
|
@@ -129,7 +133,11 @@ const cutLine = (line: string) => cutUnits(line, LINE_MAX)
|
|
|
129
133
|
|
|
130
134
|
function sessionLine(s: ConciergeBoardSession, at: number): string {
|
|
131
135
|
const parts: string[] = [s.name ? `${s.sessionID} ${frame(s.name, LABEL_CHARS)}` : s.sessionID]
|
|
132
|
-
|
|
136
|
+
// (Concierge grant requests) Whether a @@concierge grant covers the project (the hub's lookup), with its verbs.
|
|
137
|
+
const verbs = s.projectGrant?.filter((v) => PROJECT_GRANT_VERBS.includes(v))
|
|
138
|
+
const covered = verbs === undefined ? "" : verbs.length ? ` inGrant=yes (${verbs.join(", ")})` : " inGrant=no"
|
|
139
|
+
if (s.project) parts.push(`project ${frame(s.project, LABEL_CHARS)}${covered}`)
|
|
140
|
+
else if (covered) parts.push(`project${covered}`)
|
|
133
141
|
if (s.machine) parts.push(`on ${frame(s.machine, LABEL_CHARS)}`)
|
|
134
142
|
parts.push(s.since !== undefined ? `${s.state} ${span(at - s.since)}` : s.state)
|
|
135
143
|
if (!s.blockers.reported) parts.push("blockers not reported")
|
|
@@ -144,6 +152,7 @@ function sessionLine(s: ConciergeBoardSession, at: number): string {
|
|
|
144
152
|
if (s.intent) parts.push(`intent (${s.intent.source}) ${frame(s.intent.text, INTENT_MAX)}`)
|
|
145
153
|
// (CON-11) "no intent": the title shows only because no intent is set (one the user cleared reads the same).
|
|
146
154
|
else if (s.title) parts.push(`no intent · title ${frame(s.title, CONCIERGE_BOARD_LIMITS.titleChars)}`)
|
|
155
|
+
if (s.pickup) parts.push(`pick up ${frame(s.pickup, CONTEXT_CARD_LIMITS.headline)}`)
|
|
147
156
|
parts.push(`watching: ${s.reasons.join(", ")}`)
|
|
148
157
|
return cutLine(`- ${parts.join(" · ")}`)
|
|
149
158
|
}
|
|
@@ -211,3 +220,45 @@ export function renderBoard(board: ConciergeBoard, options: RenderOptions = {}):
|
|
|
211
220
|
}
|
|
212
221
|
return cutUnits(text, max).replace(LONE_SURROGATE, "")
|
|
213
222
|
}
|
|
223
|
+
|
|
224
|
+
/** (P2) The compact board for the Concierge's voice front, at most this many characters. */
|
|
225
|
+
export const FRONT_BOARD_MAX = 6_000
|
|
226
|
+
/** Intent shown on the compact board (characters). */
|
|
227
|
+
const FRONT_INTENT_CHARS = 100
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* (P2 Concierge front, contract "P2: Concierge mode") The compact board for the front's note: the watched sessions
|
|
231
|
+
* with ID, @name, title (or intent), project, machine, state and what they wait for, then the machines. Enough to pick
|
|
232
|
+
* a relay target and answer simple status questions; no Decision IDs, watch reasons, grant facts or results (those
|
|
233
|
+
* are the Concierge's). Untrusted text is framed as on the full board. Lines past FRONT_BOARD_MAX are dropped from the
|
|
234
|
+
* end (the lowest priority) and counted.
|
|
235
|
+
*/
|
|
236
|
+
export function renderFrontBoard(board: ConciergeBoard): string {
|
|
237
|
+
const machines =
|
|
238
|
+
board.machines.status !== "ok"
|
|
239
|
+
? "Machines: unavailable"
|
|
240
|
+
: cutLine(`Machines: ${board.machines.items.length ? board.machines.items.map((m) => `${frame(m.name, 40)} ${m.online ? "online" : "offline"}`).join(", ") : "none"}`)
|
|
241
|
+
if (board.sessions.status !== "ok") return [`The user's watched sessions: ${unavailable(board.sessions)}`, machines].join("\n")
|
|
242
|
+
const items = board.sessions.items.filter((s) => isHubSessionID(s.sessionID))
|
|
243
|
+
if (!items.length) return ["The user's watched sessions: none", machines].join("\n")
|
|
244
|
+
const total = Math.max(board.counts.watched, items.length)
|
|
245
|
+
const head = `The user's watched sessions (${total}${total > items.length ? `, ${items.length} shown` : ""}):`
|
|
246
|
+
const lines = items.map((s) => {
|
|
247
|
+
const parts = [s.name ? `${s.sessionID} ${frame(s.name, LABEL_CHARS)}` : s.sessionID]
|
|
248
|
+
if (s.title) parts.push(frame(s.title, CONCIERGE_BOARD_LIMITS.titleChars))
|
|
249
|
+
else if (s.intent) parts.push(`intent ${frame(s.intent.text, FRONT_INTENT_CHARS)}`)
|
|
250
|
+
if (s.project) parts.push(`project ${frame(s.project, LABEL_CHARS)}`)
|
|
251
|
+
if (s.machine) parts.push(`on ${frame(s.machine, LABEL_CHARS)}`)
|
|
252
|
+
parts.push(s.since !== undefined ? `${s.state} ${span(board.at - s.since)}` : s.state)
|
|
253
|
+
if (s.blockers.reported && s.blockers.count > 0) parts.push(`waits for the user: ${s.blockers.kinds.length ? s.blockers.kinds.map((k) => sanitizeLabel(k, 40)).join(", ") : String(s.blockers.count)}`)
|
|
254
|
+
return cutLine(`- ${parts.join(" · ")}`)
|
|
255
|
+
})
|
|
256
|
+
const compose = (shown: string[]) => [head, ...shown, ...(shown.length < lines.length ? [`(${lines.length - shown.length} more not shown)`] : []), machines].join("\n")
|
|
257
|
+
let shown = lines
|
|
258
|
+
let text = compose(shown)
|
|
259
|
+
while (text.length > FRONT_BOARD_MAX && shown.length > 0) {
|
|
260
|
+
shown = shown.slice(0, -1)
|
|
261
|
+
text = compose(shown)
|
|
262
|
+
}
|
|
263
|
+
return cutUnits(text, FRONT_BOARD_MAX).replace(LONE_SURROGATE, "")
|
|
264
|
+
}
|
|
@@ -52,6 +52,8 @@ export interface BoardRouteDeps {
|
|
|
52
52
|
/** Launched sessions' labels (the latest launch wins), for label filters. */
|
|
53
53
|
readonly labels?: (userID: string) => Promise<ReadonlyMap<string, Readonly<Record<string, string>>>>
|
|
54
54
|
readonly intents?: Pick<SessionIntents, "many">
|
|
55
|
+
/** Context cards: the shown roots' primary card headlines (docs/context-cards.md). */
|
|
56
|
+
readonly cards?: { primaries(userID: string, sessionIDs: Iterable<string>): Promise<ReadonlyMap<string, { readonly headline: string }>> }
|
|
55
57
|
readonly results?: Pick<ResultTracker, "many">
|
|
56
58
|
readonly identity?: ConciergeIdentity
|
|
57
59
|
/** (CON-06) The Decision armed now by the current Concierge; a failure shows none armed. */
|
|
@@ -64,6 +66,12 @@ export interface BoardRouteDeps {
|
|
|
64
66
|
* tier, Acting.coverage). Absent, or no current Concierge: every permission reads inGrant=no, auto=no.
|
|
65
67
|
*/
|
|
66
68
|
readonly coverage?: (userID: string, conciergeSessionID: string, sessionID: string, requestID: string) => Promise<{ readonly inGrant: boolean; readonly auto: boolean }>
|
|
69
|
+
/**
|
|
70
|
+
* (Concierge grant requests) The verbs an active grant the current Concierge holds allows in a folder (Grants.usable
|
|
71
|
+
* per verb): the board's project-level inGrant, so the Concierge knows where it may offer to request one. Absent,
|
|
72
|
+
* no current Concierge or a failure: not shown.
|
|
73
|
+
*/
|
|
74
|
+
readonly projectGrant?: (userID: string, conciergeSessionID: string, directory: string) => Promise<readonly string[]>
|
|
67
75
|
readonly now?: Clock
|
|
68
76
|
}
|
|
69
77
|
|
|
@@ -126,11 +134,13 @@ export async function conciergeBoard(deps: BoardRouteDeps, revisions: BoardRevis
|
|
|
126
134
|
const machineName = new Map(ownMachines.map((m) => [m.id, m.name]))
|
|
127
135
|
const slugs = new Map(facts.sessions.filter((f) => f.projectSlug !== undefined).map((f) => [f.sessionID, f.projectSlug!]))
|
|
128
136
|
const coverage = await coverageOf(deps, userID, live)
|
|
129
|
-
const
|
|
137
|
+
const projectGrants = await projectGrantsOf(deps, userID, live)
|
|
138
|
+
const [intents, results, decisions, armed, primaries] = await Promise.all([
|
|
130
139
|
deps.intents ? deps.intents.many(userID, shown) : new Map(),
|
|
131
140
|
deps.results ? deps.results.many(userID, shown) : new Map(),
|
|
132
141
|
open,
|
|
133
142
|
deps.armed ? deps.armed(userID).catch((cause: unknown) => (console.error(`[hub] concierge board: armed failed: ${(cause as Error).message}`), undefined)) : undefined,
|
|
143
|
+
deps.cards ? deps.cards.primaries(userID, shown).catch((cause: unknown) => (console.error(`[hub] concierge board: context cards failed: ${(cause as Error).message}`), undefined)) : undefined,
|
|
134
144
|
])
|
|
135
145
|
const rootFacts = new Map<string, BoardRoot>()
|
|
136
146
|
for (const id of shown) {
|
|
@@ -144,7 +154,8 @@ export async function conciergeBoard(deps: BoardRouteDeps, revisions: BoardRevis
|
|
|
144
154
|
if (reported) {
|
|
145
155
|
const s = reported.session
|
|
146
156
|
const blockers = s.blockers.map((b) => ({ kind: b.kind, id: b.id, ...(b.action !== undefined ? { action: b.action } : {}), ...(b.kind === "permission" ? { inGrant: coverage.get(b.id)?.inGrant === true, auto: coverage.get(b.id)?.auto === true } : {}) }))
|
|
147
|
-
|
|
157
|
+
const projectGrant = projectGrants.get(s.project ?? s.directory)
|
|
158
|
+
rootFacts.set(id, { ...common, machine: reported.machine, state: STATES.has(s.state) ? (s.state as ConciergeBoardState) : "idle", online: true, title: s.title, blockers, ...(projectGrant !== undefined ? { projectGrant } : {}) })
|
|
148
159
|
continue
|
|
149
160
|
}
|
|
150
161
|
const machine = record?.machineID !== undefined ? machineName.get(record.machineID) : undefined
|
|
@@ -168,6 +179,7 @@ export async function conciergeBoard(deps: BoardRouteDeps, revisions: BoardRevis
|
|
|
168
179
|
rootOf,
|
|
169
180
|
intents,
|
|
170
181
|
results,
|
|
182
|
+
...(primaries?.size ? { cards: new Map([...primaries].map(([id, card]) => [id, card.headline])) } : {}),
|
|
171
183
|
decisions: !deps.decisions ? { status: "unsupported", feature: "decisions" } : decisions.ok ? (decisions.list as BoardDecisionInput[]) : { status: "failed" },
|
|
172
184
|
hidden,
|
|
173
185
|
machines,
|
|
@@ -203,6 +215,17 @@ async function coverageOf(deps: BoardRouteDeps, userID: string, live: ReadonlyMa
|
|
|
203
215
|
return out
|
|
204
216
|
}
|
|
205
217
|
|
|
218
|
+
/** (Concierge grant requests) The verbs the current Concierge's grants allow in each shown live session's project folder. */
|
|
219
|
+
async function projectGrantsOf(deps: BoardRouteDeps, userID: string, live: ReadonlyMap<string, { session: CatalogSession }>): Promise<Map<string, readonly string[]>> {
|
|
220
|
+
const out = new Map<string, readonly string[]>()
|
|
221
|
+
if (!deps.projectGrant || !deps.identity) return out
|
|
222
|
+
const conciergeID = await deps.identity.current(userID).then((c) => c.sessionID, () => undefined)
|
|
223
|
+
if (conciergeID === undefined) return out
|
|
224
|
+
const folders = new Set([...live.values()].map(({ session }) => session.project ?? session.directory).filter((d): d is string => typeof d === "string" && d.length > 0))
|
|
225
|
+
await Promise.all([...folders].map((dir) => deps.projectGrant!(userID, conciergeID, dir).then((verbs) => void out.set(dir, verbs), () => undefined)))
|
|
226
|
+
return out
|
|
227
|
+
}
|
|
228
|
+
|
|
206
229
|
/** Muted sessions named on the line (the rest counted). */
|
|
207
230
|
const MUTED_SHOWN = 20
|
|
208
231
|
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
// digest} (a SHA-256 of the content without `at`), version-checked, so it changes only when the content changes and
|
|
20
20
|
// concurrent renders of one change bump it once. Nothing else is written.
|
|
21
21
|
import { createHash } from "node:crypto"
|
|
22
|
+
import { CONTEXT_CARD_LIMITS } from "../../../packages/protocol/src/context-cards"
|
|
22
23
|
import {
|
|
23
24
|
CONCIERGE_BOARD_LIMITS,
|
|
24
25
|
CONCIERGE_BOARD_PENDING_MAX,
|
|
@@ -39,7 +40,7 @@ import { partyLabel } from "../agent/envelope"
|
|
|
39
40
|
import { isPlaceholderTitle } from "../agent/names"
|
|
40
41
|
import { updateVersioned } from "../agent/versioned"
|
|
41
42
|
import type { StateStore } from "../backend/types"
|
|
42
|
-
import { boardText } from "./board-render"
|
|
43
|
+
import { boardText, PROJECT_GRANT_VERBS } from "./board-render"
|
|
43
44
|
|
|
44
45
|
export const BOARD_COLLECTION = "concierge-board"
|
|
45
46
|
const REVISION_KEY = "revision"
|
|
@@ -63,8 +64,12 @@ export interface BoardRoot {
|
|
|
63
64
|
readonly title?: string
|
|
64
65
|
/** Open blockers in the tree when this hub's catalog reports it; absent: not reported. Summaries are never read. */
|
|
65
66
|
readonly blockers?: readonly { readonly kind: string; readonly id?: string; readonly action?: string; readonly inGrant?: boolean; readonly auto?: boolean }[]
|
|
67
|
+
/** (Concierge grant requests) The verbs a @@concierge grant allows in its project folder; absent: unknown. */
|
|
68
|
+
readonly projectGrant?: readonly string[]
|
|
66
69
|
}
|
|
67
70
|
|
|
71
|
+
const grantVerbs = (verbs: readonly string[]) => PROJECT_GRANT_VERBS.filter((v) => verbs.includes(v))
|
|
72
|
+
|
|
68
73
|
/** The Decision fields the board reads (never question, options, recommendation or answer). */
|
|
69
74
|
export type BoardDecisionInput = Pick<Decision, "id" | "from" | "about" | "urgency" | "tier" | "at" | "deadline" | "state">
|
|
70
75
|
|
|
@@ -88,6 +93,8 @@ export interface BoardInput {
|
|
|
88
93
|
readonly intents: ReadonlyMap<string, SessionIntent>
|
|
89
94
|
/** Last results of the shown roots. */
|
|
90
95
|
readonly results: ReadonlyMap<string, LastResult>
|
|
96
|
+
/** Primary context card headlines of the shown roots (docs/context-cards.md). */
|
|
97
|
+
readonly cards?: ReadonlyMap<string, string>
|
|
91
98
|
/** The user's open Decisions, or why there are none to show. */
|
|
92
99
|
readonly decisions: readonly BoardDecisionInput[] | { readonly status: "unsupported"; readonly feature: string } | { readonly status: "failed" }
|
|
93
100
|
/** The Concierge's own sessions (current and previous). */
|
|
@@ -136,6 +143,7 @@ function sessionItem(entry: WatchlistEntry, input: BoardInput, decisions: readon
|
|
|
136
143
|
const intentText = stored ? label(stored.text, INTENT_MAX) : undefined
|
|
137
144
|
const title = intentText === undefined && r && !isPlaceholderTitle(r.title) ? label(r.title, CONCIERGE_BOARD_LIMITS.titleChars) : undefined
|
|
138
145
|
const blockers = r?.blockers
|
|
146
|
+
const pickup = label(input.cards?.get(entry.sessionID), CONTEXT_CARD_LIMITS.headline)
|
|
139
147
|
const name = label(r?.name, LABEL_CHARS)
|
|
140
148
|
const project = label(r?.project, LABEL_CHARS)
|
|
141
149
|
const machine = label(r?.machine, LABEL_CHARS)
|
|
@@ -144,12 +152,14 @@ function sessionItem(entry: WatchlistEntry, input: BoardInput, decisions: readon
|
|
|
144
152
|
...(name ? { name } : {}),
|
|
145
153
|
...(project ? { project } : {}),
|
|
146
154
|
...(machine ? { machine } : {}),
|
|
155
|
+
...(r?.projectGrant !== undefined ? { projectGrant: grantVerbs(r.projectGrant) } : {}),
|
|
147
156
|
state,
|
|
148
157
|
...(since !== undefined ? { since } : {}),
|
|
149
158
|
blockers: blockers ? { reported: true, count: blockers.length, kinds: [...new Set(blockers.map((b) => (BLOCKER_KIND.test(b.kind) ? b.kind : "other")))].sort(), ...pendingField(blockers) } : { reported: false },
|
|
150
159
|
decisions,
|
|
151
160
|
...(intentText !== undefined && stored ? { intent: { text: intentText, source: stored.source } } : {}),
|
|
152
161
|
...(title !== undefined ? { title } : {}),
|
|
162
|
+
...(pickup !== undefined ? { pickup } : {}),
|
|
153
163
|
...(result ? { lastResult: { at: result.at, outcome: result.outcome } } : {}),
|
|
154
164
|
reasons: [...entry.reasons],
|
|
155
165
|
}
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import { CONCIERGE_TOOLS, SESSION_CONTEXT_FEATURE, SESSION_CONTEXT_LIMITS, type SessionContextRequest, type SessionContextResult } from "../../../packages/protocol/src/concierge"
|
|
22
22
|
import type { Clock, Timers } from "../backend/types"
|
|
23
23
|
import type { NotesStore } from "./notes"
|
|
24
|
-
import { conciergeInstructions,
|
|
24
|
+
import { conciergeInstructions, grantLines, type GrantLineInput, NOTES_HEADER, type RequestedGrantInput, RETIRED_INSTRUCTIONS } from "./prompt"
|
|
25
25
|
import type { SettingsStore } from "./settings"
|
|
26
26
|
|
|
27
27
|
export { CONCIERGE_TOOLS } from "../../../packages/protocol/src/concierge"
|
|
@@ -46,14 +46,22 @@ export interface ContextGrant {
|
|
|
46
46
|
readonly permissions?: { readonly allow: readonly string[]; readonly never: readonly string[] }
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/** (P2) The spoke feature that takes the frontBoard part (the Concierge's voice front). */
|
|
50
|
+
export const CONCIERGE_FRONT_FEATURE = "conciergeFront"
|
|
51
|
+
|
|
49
52
|
export interface ContextDeps {
|
|
50
|
-
readonly registry: { sessionTargets(sessionID: string): string[]; supports(connID: string, feature: typeof SESSION_CONTEXT_FEATURE): boolean; connTenant(connID: string): string | undefined }
|
|
53
|
+
readonly registry: { sessionTargets(sessionID: string): string[]; supports(connID: string, feature: typeof SESSION_CONTEXT_FEATURE | typeof CONCIERGE_FRONT_FEATURE): boolean; connTenant(connID: string): string | undefined }
|
|
51
54
|
readonly send: (connID: string, req: SessionContextRequest) => Promise<SessionContextResult>
|
|
52
|
-
/**
|
|
53
|
-
|
|
55
|
+
/**
|
|
56
|
+
* The rendered board of the user (renderBoard); (P2) with `front`, also the compact board for the voice front
|
|
57
|
+
* (renderFrontBoard), sent as the frontBoard part to spokes that take it.
|
|
58
|
+
*/
|
|
59
|
+
readonly board: (userID: string) => Promise<string | { readonly rendered: string; readonly front?: string }>
|
|
54
60
|
readonly notes: Pick<NotesStore, "get">
|
|
55
61
|
readonly settings: Pick<SettingsStore, "get">
|
|
56
62
|
readonly grant: (userID: string, grantID: string) => Promise<ContextGrant | undefined>
|
|
63
|
+
/** (Concierge grant requests) The user's @@concierge grants: the active ones besides the Settings grant are named. */
|
|
64
|
+
readonly requested?: (userID: string) => Promise<readonly ContextGrant[]>
|
|
57
65
|
/** (CON-09a) Speech is set up for the user (the instructions mention voice and voice confirm). Absent: false. */
|
|
58
66
|
readonly voice?: (userID: string) => boolean
|
|
59
67
|
readonly now?: Clock
|
|
@@ -105,13 +113,26 @@ export class ContextPusher {
|
|
|
105
113
|
return { state: "ended", id: grant.id, at: grant.revokedAt ?? grant.expiresAt }
|
|
106
114
|
}
|
|
107
115
|
|
|
108
|
-
|
|
109
|
-
|
|
116
|
+
/** (Concierge grant requests) The active requested grants (not the Settings grant); a failed read names none. */
|
|
117
|
+
private async requestedGrants(userID: string, settingsGrantID: string | undefined): Promise<RequestedGrantInput[]> {
|
|
118
|
+
if (!this.deps.requested) return []
|
|
119
|
+
const grants = await this.deps.requested(userID).catch(() => [] as readonly ContextGrant[])
|
|
120
|
+
const active = grants.filter((g) => g.state === "active" && g.id !== settingsGrantID)
|
|
121
|
+
return active.map((g) => ({ id: g.id, allow: g.allow, projects: g.scope.projects, expiresAt: g.expiresAt }))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private async build(userID: string, sessionID: string, connID: string): Promise<Body> {
|
|
125
|
+
const [settings, notes, boards] = await Promise.all([this.deps.settings.get(userID), this.deps.notes.get(userID), this.deps.board(userID)])
|
|
126
|
+
const board = typeof boards === "string" ? boards : boards.rendered
|
|
127
|
+
const front = typeof boards === "string" ? undefined : boards.front
|
|
110
128
|
const grant = await this.grantInput(userID, settings.grantID)
|
|
129
|
+
const requested = await this.requestedGrants(userID, settings.grantID)
|
|
111
130
|
const parts: SessionContextRequest["parts"][number][] = [
|
|
112
131
|
{ key: "instructions", text: cut(conciergeInstructions({ voice: this.deps.voice?.(userID) === true, timeZone: settings.timeZone })) },
|
|
113
|
-
{ key: "board", text: cut(`${
|
|
132
|
+
{ key: "board", text: cut(`${grantLines(grant, requested, settings.timeZone)}\n${board}`) },
|
|
114
133
|
...(notes.text ? [{ key: "notes" as const, text: cut(`${NOTES_HEADER}\n${notes.text}`) }] : []),
|
|
134
|
+
// (P2) Only a spoke that takes it: an older one refuses an unknown part key.
|
|
135
|
+
...(front !== undefined && this.deps.registry.supports(connID, CONCIERGE_FRONT_FEATURE) ? [{ key: "frontBoard" as const, text: cut(front) }] : []),
|
|
115
136
|
]
|
|
116
137
|
return { op: "sessionContext", sessionID, parts, tools: { allow: [...CONCIERGE_TOOLS] }, lockAgent: true }
|
|
117
138
|
}
|
|
@@ -137,7 +158,7 @@ export class ContextPusher {
|
|
|
137
158
|
try {
|
|
138
159
|
const connID = this.target(userID, sessionID)
|
|
139
160
|
if (connID === undefined) return false
|
|
140
|
-
const body = await this.build(userID, sessionID)
|
|
161
|
+
const body = await this.build(userID, sessionID, connID)
|
|
141
162
|
const digest = JSON.stringify(body)
|
|
142
163
|
const key = `${userID}\n${sessionID}`
|
|
143
164
|
const known = this.sent.get(key)
|