@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,293 @@
|
|
|
1
|
+
// The app-front contract (AF0, docs/plans/2026-11-08-app-front-contracts.md; schema
|
|
2
|
+
// packages/protocol/schema/app-front/v1.schema.json): what an external app (a server-side BFF) uses to make Hub its
|
|
3
|
+
// AI layer — connect with consent (AF1), speech (AF2), a conversation stream (AF3), answering its own sessions'
|
|
4
|
+
// confirm-tier Decisions (AF4) and the voice-client transport (AF5/AF6). Pure: names, units, limits, codes and the
|
|
5
|
+
// few rules both the hub and apps need (redirect URIs, the scope parameter, PKCE shapes, token formats). Nothing
|
|
6
|
+
// here is specific to any one app (Hub decision kvtvodrn); user answers in Hub decisions w6lfbfaq and nixct956.
|
|
7
|
+
|
|
8
|
+
/** Version of the app-front contract and its schema; additive changes keep 1. */
|
|
9
|
+
export const APP_FRONT_VERSION = 1
|
|
10
|
+
|
|
11
|
+
// ---------- scopes and features ----------
|
|
12
|
+
|
|
13
|
+
/** The scopes the app front adds to APP_SCOPES (src/central/agent/app-tokens.ts), each shipped by its AF task. */
|
|
14
|
+
export const APP_FRONT_SCOPES = ["speech:use", "conversation:read", "conversation:write", "decisions:answer"] as const
|
|
15
|
+
export type AppFrontScope = (typeof APP_FRONT_SCOPES)[number]
|
|
16
|
+
|
|
17
|
+
/** `/.well-known/hub` `features` flags: a hub lists one only once the task that ships it is merged. */
|
|
18
|
+
export const APP_FRONT_FEATURES = ["app-connect", "app-speech", "app-conversation", "app-decisions"] as const
|
|
19
|
+
export type AppFrontFeature = (typeof APP_FRONT_FEATURES)[number]
|
|
20
|
+
|
|
21
|
+
/** The task that ships each flag, and the scope that flag brings (app-connect brings none: it is the flow). */
|
|
22
|
+
export const APP_FRONT_FEATURE_TASK: Readonly<Record<AppFrontFeature, { readonly task: string; readonly scopes: readonly AppFrontScope[] }>> = {
|
|
23
|
+
"app-connect": { task: "AF1", scopes: [] },
|
|
24
|
+
"app-speech": { task: "AF2", scopes: ["speech:use"] },
|
|
25
|
+
"app-conversation": { task: "AF3", scopes: ["conversation:read", "conversation:write"] },
|
|
26
|
+
"app-decisions": { task: "AF4", scopes: ["decisions:answer"] },
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What this source tree ships. Each AF task flips its flag in the same commit that ships it (the schema test then
|
|
31
|
+
* expects the hub to advertise it and its routes to answer); until then the feature is contract-only.
|
|
32
|
+
*/
|
|
33
|
+
export const APP_FRONT_SHIPPED: Readonly<Record<AppFrontFeature, boolean>> = {
|
|
34
|
+
"app-connect": true,
|
|
35
|
+
"app-speech": true,
|
|
36
|
+
"app-conversation": true,
|
|
37
|
+
"app-decisions": true,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------- connect (AF1): OAuth 2.1-style authorization code + PKCE ----------
|
|
41
|
+
|
|
42
|
+
/** Endpoints of the connect flow. `/connect/*` and the metadata are top level (never behind the console gate). */
|
|
43
|
+
export const CONNECT_PATHS = {
|
|
44
|
+
/** RFC 8414 authorization server metadata (public). */
|
|
45
|
+
metadata: "/.well-known/oauth-authorization-server",
|
|
46
|
+
/** GET, browser: validates the request, opens a flow, redirects to the consent page. */
|
|
47
|
+
authorize: "/connect/authorize",
|
|
48
|
+
/** The console's consent page (signed-in user). */
|
|
49
|
+
consentPage: "/connect/consent/:flow",
|
|
50
|
+
/** POST, app server (form-encoded, client authentication): code → tokens, refresh → tokens. */
|
|
51
|
+
token: "/connect/token",
|
|
52
|
+
/** POST, app server (RFC 7009): ends the connection the token belongs to. */
|
|
53
|
+
revoke: "/connect/revoke",
|
|
54
|
+
/** Console routes behind the gate (cookie, same origin): read, approve, deny a flow. */
|
|
55
|
+
flow: "/api/connect/flows/:flow",
|
|
56
|
+
flowApprove: "/api/connect/flows/:flow/approve",
|
|
57
|
+
flowDeny: "/api/connect/flows/:flow/deny",
|
|
58
|
+
/** /api/agent, user actors only: registered clients (admins write) and the user's connections. */
|
|
59
|
+
clients: "/api/agent/app-clients",
|
|
60
|
+
client: "/api/agent/app-clients/:id",
|
|
61
|
+
clientSecret: "/api/agent/app-clients/:id/secret",
|
|
62
|
+
connections: "/api/agent/connections",
|
|
63
|
+
connection: "/api/agent/connections/:id",
|
|
64
|
+
} as const
|
|
65
|
+
|
|
66
|
+
/** Token and ID prefixes ("hc": hub connect). Hand-made app tokens keep `hat_` (unchanged, never expire). */
|
|
67
|
+
export const CONNECT_PREFIXES = {
|
|
68
|
+
clientID: "hcl_",
|
|
69
|
+
clientSecret: "hcs_",
|
|
70
|
+
flow: "hcf_",
|
|
71
|
+
code: "hco_",
|
|
72
|
+
access: "hca_",
|
|
73
|
+
refresh: "hcr_",
|
|
74
|
+
} as const
|
|
75
|
+
|
|
76
|
+
/** Anchored formats; secrets are 32 random bytes in base64url (43 characters). Connection IDs are 16 hex. */
|
|
77
|
+
export const CONNECT_TOKEN_PATTERNS = {
|
|
78
|
+
clientID: /^hcl_[0-9a-f]{16}$/,
|
|
79
|
+
clientSecret: /^hcs_[A-Za-z0-9_-]{43}$/,
|
|
80
|
+
flow: /^hcf_[0-9a-f]{24}$/,
|
|
81
|
+
code: /^hco_[A-Za-z0-9_-]{43}$/,
|
|
82
|
+
access: /^hca_([0-9a-f]{16})_[A-Za-z0-9_-]{43}$/,
|
|
83
|
+
refresh: /^hcr_([0-9a-f]{16})_[A-Za-z0-9_-]{43}$/,
|
|
84
|
+
connectionID: /^[0-9a-f]{16}$/,
|
|
85
|
+
} as const
|
|
86
|
+
|
|
87
|
+
/** Lifetimes (ms). */
|
|
88
|
+
export const ACCESS_TOKEN_TTL_MS = 60 * 60_000
|
|
89
|
+
/** Sliding: every refresh issues a refresh token valid this long from then; unused this long, the connection lapses. */
|
|
90
|
+
export const REFRESH_TOKEN_IDLE_MS = 30 * 24 * 60 * 60_000
|
|
91
|
+
export const AUTHORIZATION_CODE_TTL_MS = 60_000
|
|
92
|
+
/**
|
|
93
|
+
* After a refresh, presenting the just-rotated refresh token again this long answers the same new pair (a lost answer
|
|
94
|
+
* can be retried); after it, that is reuse and revokes the connection (Hub decision nixct956 3).
|
|
95
|
+
*/
|
|
96
|
+
export const REFRESH_GRACE_MS = 30_000
|
|
97
|
+
/** From authorize to approve/deny (like CLI_LOGIN_TTL_MS). */
|
|
98
|
+
export const CONNECT_FLOW_TTL_MS = 10 * 60_000
|
|
99
|
+
|
|
100
|
+
export const CONNECT_LIMITS = {
|
|
101
|
+
/** Hub-wide (admin-registered) clients per hub. */
|
|
102
|
+
clients: 50,
|
|
103
|
+
/** Personal clients per user (decision nixct956 5). */
|
|
104
|
+
personalClientsPerUser: 10,
|
|
105
|
+
redirectURIsPerClient: 10,
|
|
106
|
+
clientNameMax: 100,
|
|
107
|
+
/** `state` is opaque to the hub and echoed back. */
|
|
108
|
+
stateMax: 1024,
|
|
109
|
+
/** Connections per user (one per client). */
|
|
110
|
+
connectionsPerUser: 50,
|
|
111
|
+
/** Authorize requests per client and client address a minute (an open flow is cheap but bounded). */
|
|
112
|
+
authorizePerMinute: 20,
|
|
113
|
+
/** Authenticated token-endpoint requests per client and minute. */
|
|
114
|
+
tokenPerMinute: 60,
|
|
115
|
+
/**
|
|
116
|
+
* Failed client authentications per client and client address a minute; beyond, that address's failures get 429
|
|
117
|
+
* (a caller with the right secret is never refused for them).
|
|
118
|
+
*/
|
|
119
|
+
failedClientAuthPerMinute: 10,
|
|
120
|
+
} as const
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Who may use a registered client (Hub decision nixct956 5): `hub` (hub-wide, admin-registered: every user of the hub
|
|
124
|
+
* may connect) or `user` (personal: only the user who registered it sees and uses it).
|
|
125
|
+
*/
|
|
126
|
+
export const CLIENT_AUDIENCES = ["hub", "user"] as const
|
|
127
|
+
export type ClientAudience = (typeof CLIENT_AUDIENCES)[number]
|
|
128
|
+
|
|
129
|
+
export const CODE_CHALLENGE_METHODS = ["S256"] as const
|
|
130
|
+
export const RESPONSE_TYPES = ["code"] as const
|
|
131
|
+
export const GRANT_TYPES = ["authorization_code", "refresh_token"] as const
|
|
132
|
+
export const TOKEN_ENDPOINT_AUTH_METHODS = ["client_secret_basic", "client_secret_post"] as const
|
|
133
|
+
|
|
134
|
+
/** RFC 6749 §4.1.2.1 errors the authorize endpoint redirects with (only once client_id and redirect_uri are valid). */
|
|
135
|
+
export const AUTHORIZE_ERRORS = ["invalid_request", "unauthorized_client", "access_denied", "unsupported_response_type", "invalid_scope", "server_error", "temporarily_unavailable"] as const
|
|
136
|
+
/** RFC 6749 §5.2 errors of the token endpoint, with their HTTP status (`temporarily_unavailable` 429: rate limit). */
|
|
137
|
+
export const TOKEN_ERROR_STATUS = {
|
|
138
|
+
invalid_request: 400,
|
|
139
|
+
invalid_client: 401,
|
|
140
|
+
invalid_grant: 400,
|
|
141
|
+
unauthorized_client: 400,
|
|
142
|
+
unsupported_grant_type: 400,
|
|
143
|
+
invalid_scope: 400,
|
|
144
|
+
temporarily_unavailable: 429,
|
|
145
|
+
} as const
|
|
146
|
+
export type TokenError = keyof typeof TOKEN_ERROR_STATUS
|
|
147
|
+
|
|
148
|
+
// ---------- speech (AF2) ----------
|
|
149
|
+
|
|
150
|
+
export const SPEECH_PATHS = { transcribe: "/api/agent/speech/transcribe", speak: "/api/agent/speech/speak" } as const
|
|
151
|
+
|
|
152
|
+
/** Per app credential (a connection, or a hand-made token): 429 rate_limited with retryAfter beyond these. */
|
|
153
|
+
export const SPEECH_RATE_LIMITS = {
|
|
154
|
+
transcribe: { perMinute: 20, perHour: 300 },
|
|
155
|
+
speak: { perMinute: 60, perHour: 1000, charsPerHour: 100_000 },
|
|
156
|
+
} as const
|
|
157
|
+
|
|
158
|
+
// ---------- conversation (AF3) ----------
|
|
159
|
+
|
|
160
|
+
export const CONVERSATION_PATHS = {
|
|
161
|
+
stream: "/api/agent/sessions/:addr/stream",
|
|
162
|
+
conversation: "/api/agent/sessions/:addr/conversation",
|
|
163
|
+
utterance: "/api/agent/sessions/:addr/utterance",
|
|
164
|
+
interrupt: "/api/agent/sessions/:addr/interrupt",
|
|
165
|
+
/** Typed turns: the existing act prompt, also admitted with conversation:write. */
|
|
166
|
+
turn: "/api/agent/sessions/:addr/prompt",
|
|
167
|
+
/** Re-read after a reset: the existing log, also admitted with conversation:read for the app's sessions. */
|
|
168
|
+
log: "/api/agent/sessions/:addr/log",
|
|
169
|
+
} as const
|
|
170
|
+
|
|
171
|
+
export const APP_STREAM_FRAME_TYPES = ["ready", "delta", "say", "heard", "status", "conversation", "reset", "end"] as const
|
|
172
|
+
export type AppStreamFrameType = (typeof APP_STREAM_FRAME_TYPES)[number]
|
|
173
|
+
|
|
174
|
+
/** Why the hub ended an app stream (the last frame, `end`). */
|
|
175
|
+
export const APP_STREAM_END_REASONS = ["token_expired", "revoked", "grant", "gone", "slow"] as const
|
|
176
|
+
export type AppStreamEndReason = (typeof APP_STREAM_END_REASONS)[number]
|
|
177
|
+
|
|
178
|
+
/** What a `reset` frame asks the app to re-read. */
|
|
179
|
+
export const APP_STREAM_REFRESH = ["log", "status", "conversation", "decisions"] as const
|
|
180
|
+
|
|
181
|
+
export const APP_STREAM_LIMITS = {
|
|
182
|
+
heartbeatMs: 20_000,
|
|
183
|
+
/** Draining: `retry:` sent before the stream ends. */
|
|
184
|
+
retryMs: 2_000,
|
|
185
|
+
/** How often an open stream re-checks its credential, grant and session (and ends with `end`). */
|
|
186
|
+
recheckMs: 30_000,
|
|
187
|
+
/** Unsent bytes a slow reader may hold before the hub ends the stream (`end` reason slow). */
|
|
188
|
+
bufferMaxBytes: 1 << 20,
|
|
189
|
+
/** Concurrent streams per app credential, and per user over all apps (429 rate_limited beyond). */
|
|
190
|
+
streamsPerApp: 4,
|
|
191
|
+
streamsPerUser: 20,
|
|
192
|
+
} as const
|
|
193
|
+
|
|
194
|
+
// ---------- decisions (AF4) ----------
|
|
195
|
+
|
|
196
|
+
export const DECISION_PATHS = { list: "/api/agent/decisions", answer: "/api/agent/decisions/:id/answer" } as const
|
|
197
|
+
/** The only tier an app answers (decision w6lfbfaq 1). */
|
|
198
|
+
export const APP_ANSWERABLE_TIERS = ["confirm"] as const
|
|
199
|
+
|
|
200
|
+
// ---------- voice-client transport (AF5 interface, AF6 app adapter) ----------
|
|
201
|
+
|
|
202
|
+
/** What the voice loop hears about its session (the app stream's frames it uses). */
|
|
203
|
+
export type VoiceEvent =
|
|
204
|
+
| { readonly type: "say"; readonly sessionID: string; readonly messageID: string; readonly ordinal: number; readonly seq?: number; readonly text: string }
|
|
205
|
+
| { readonly type: "heard"; readonly sessionID: string; readonly inboxID: string }
|
|
206
|
+
| { readonly type: "conversation"; readonly sessionID: string; readonly on: boolean; readonly front?: boolean; readonly reason?: "front_failed" }
|
|
207
|
+
| { readonly type: "status"; readonly sessionID: string; readonly state: "idle" | "working" | "blocked" | "error" }
|
|
208
|
+
| { readonly type: "reset" }
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The transport the voice loop (packages/voice-client) talks through. The console implements it with its cookie
|
|
212
|
+
* routes; an app implements it against its own server, which calls the AF2/AF3 routes with its token. The package
|
|
213
|
+
* never holds a hub token and never calls the hub itself.
|
|
214
|
+
*/
|
|
215
|
+
export interface VoiceTransport {
|
|
216
|
+
/** Conversation mode on/off for the session (front: the voice front). */
|
|
217
|
+
conversation(on: boolean, options?: { readonly front?: boolean }): Promise<{ readonly on: boolean; readonly front?: boolean }>
|
|
218
|
+
/** A final transcript, to the session (or its front). */
|
|
219
|
+
utterance(text: string): Promise<{ readonly inboxID: string }>
|
|
220
|
+
/** Barge-in: stops the front (or the session's turn). */
|
|
221
|
+
interrupt(): Promise<void>
|
|
222
|
+
transcribe(audio: Blob, options?: { readonly signal?: AbortSignal }): Promise<{ readonly text: string }>
|
|
223
|
+
/** audio/mpeg, streamed when the provider streams. */
|
|
224
|
+
speak(text: string, options?: { readonly signal?: AbortSignal }): Promise<ReadableStream<Uint8Array>>
|
|
225
|
+
/** Subscribes to the session's voice events; returns the unsubscribe. */
|
|
226
|
+
events(listener: (event: VoiceEvent) => void): () => void
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---------- rules ----------
|
|
230
|
+
|
|
231
|
+
export const REDIRECT_URI_MAX = 2048
|
|
232
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "[::1]", "localhost"])
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Why `uri` cannot be a registered redirect URI, or undefined: an absolute https URL (http only on a loopback host),
|
|
236
|
+
* at most REDIRECT_URI_MAX characters, without user info, fragment, wildcard or surrounding whitespace.
|
|
237
|
+
*/
|
|
238
|
+
export function redirectUriProblem(uri: unknown): string | undefined {
|
|
239
|
+
if (typeof uri !== "string" || uri === "") return "a redirect URI is a non-empty string"
|
|
240
|
+
if (uri.length > REDIRECT_URI_MAX) return `a redirect URI is at most ${REDIRECT_URI_MAX} characters`
|
|
241
|
+
if (uri.trim() !== uri || /\s/.test(uri)) return "a redirect URI has no whitespace"
|
|
242
|
+
if (uri.includes("*")) return "a redirect URI has no wildcard"
|
|
243
|
+
let url: URL
|
|
244
|
+
try {
|
|
245
|
+
url = new URL(uri)
|
|
246
|
+
} catch {
|
|
247
|
+
return "a redirect URI is an absolute URL"
|
|
248
|
+
}
|
|
249
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return "a redirect URI uses https (http only on a loopback host)"
|
|
250
|
+
if (url.protocol === "http:" && !LOOPBACK_HOSTS.has(url.hostname)) return "http is allowed only on a loopback host (127.0.0.1, [::1], localhost)"
|
|
251
|
+
if (url.username || url.password) return "a redirect URI has no user info"
|
|
252
|
+
if (uri.includes("#")) return "a redirect URI has no fragment"
|
|
253
|
+
return undefined
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Exact string comparison against the registered URIs (no normalisation of any kind). */
|
|
257
|
+
export function redirectUriMatches(registered: readonly string[], uri: unknown): boolean {
|
|
258
|
+
return typeof uri === "string" && registered.includes(uri)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The OAuth `scope` parameter: scopes separated by single spaces, each known; duplicates dropped, order kept. */
|
|
262
|
+
export function parseScopeParam(value: unknown, known: readonly string[]): { scopes: string[] } | { problem: string } {
|
|
263
|
+
if (typeof value !== "string" || value === "") return { problem: "scope lists at least one scope" }
|
|
264
|
+
const parts = value.split(" ")
|
|
265
|
+
if (parts.some((part) => part === "")) return { problem: "scope separates scopes with single spaces" }
|
|
266
|
+
const unknown = parts.find((part) => !known.includes(part))
|
|
267
|
+
if (unknown !== undefined) return { problem: `unknown scope: ${unknown.slice(0, 64)}` }
|
|
268
|
+
return { scopes: [...new Set(parts)] }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const VERIFIER = /^[A-Za-z0-9._~-]{43,128}$/
|
|
272
|
+
const CHALLENGE = /^[A-Za-z0-9_-]{43}$/
|
|
273
|
+
|
|
274
|
+
/** RFC 7636 §4.1: 43–128 unreserved characters. */
|
|
275
|
+
export function pkceVerifierProblem(verifier: unknown): string | undefined {
|
|
276
|
+
return typeof verifier === "string" && VERIFIER.test(verifier) ? undefined : "code_verifier is 43–128 characters of A-Z a-z 0-9 - . _ ~"
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** S256 only: the challenge is base64url(SHA-256(verifier)) without padding (43 characters). */
|
|
280
|
+
export function pkceChallengeProblem(challenge: unknown, method: unknown): string | undefined {
|
|
281
|
+
if (method !== "S256") return "code_challenge_method must be S256"
|
|
282
|
+
return typeof challenge === "string" && CHALLENGE.test(challenge) ? undefined : "code_challenge is 43 base64url characters"
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** A connected app's access or refresh token, with the connection it belongs to; anything else undefined. */
|
|
286
|
+
export function parseConnectToken(token: unknown): { kind: "access" | "refresh"; connectionID: string } | undefined {
|
|
287
|
+
if (typeof token !== "string") return undefined
|
|
288
|
+
const access = CONNECT_TOKEN_PATTERNS.access.exec(token)
|
|
289
|
+
if (access) return { kind: "access", connectionID: access[1]! }
|
|
290
|
+
const refresh = CONNECT_TOKEN_PATTERNS.refresh.exec(token)
|
|
291
|
+
if (refresh) return { kind: "refresh", connectionID: refresh[1]! }
|
|
292
|
+
return undefined
|
|
293
|
+
}
|
|
@@ -101,8 +101,10 @@ export const CONCIERGE_LIMITS = {
|
|
|
101
101
|
/**
|
|
102
102
|
* The keys of the parts a session context carries. The spoke appends instructions then notes to the session's system
|
|
103
103
|
* prompt (stable between turns) and sends the board after the conversation (it changes: V-01, the prompt cache).
|
|
104
|
+
* "frontBoard" (voice front P2, sent only to spokes announcing "conciergeFront"): the compact board for the
|
|
105
|
+
* Concierge's voice front; never part of the Concierge's own prompt.
|
|
104
106
|
*/
|
|
105
|
-
export const SESSION_CONTEXT_KEYS = ["instructions", "board", "notes"] as const
|
|
107
|
+
export const SESSION_CONTEXT_KEYS = ["instructions", "board", "notes", "frontBoard"] as const
|
|
106
108
|
export type SessionContextKey = (typeof SESSION_CONTEXT_KEYS)[number]
|
|
107
109
|
|
|
108
110
|
export const SESSION_CONTEXT_LIMITS = { partChars: 40_000, denyGlobs: 50, globChars: 100, allowTools: 50 } as const
|
|
@@ -272,6 +274,11 @@ export interface ConciergeBoardSession {
|
|
|
272
274
|
readonly project?: string
|
|
273
275
|
/** Machine name (sanitised label). */
|
|
274
276
|
readonly machine?: string
|
|
277
|
+
/**
|
|
278
|
+
* (Concierge grant requests) The verbs an active @@concierge grant allows in this session's project folder (the
|
|
279
|
+
* acting engine's grant lookup for the current Concierge; [] = none). Absent: unknown (no Concierge, no folder).
|
|
280
|
+
*/
|
|
281
|
+
readonly projectGrant?: readonly string[]
|
|
275
282
|
readonly state: ConciergeBoardState
|
|
276
283
|
/** When the current state began, as far as the hub knows (activity, last result, or lastSeen when offline). */
|
|
277
284
|
readonly since?: number
|
|
@@ -283,6 +290,8 @@ export interface ConciergeBoardSession {
|
|
|
283
290
|
readonly intent?: { readonly text: string; readonly source: IntentSource }
|
|
284
291
|
/** The non-placeholder title (at most 80 characters), untrusted; only when there is no intent. */
|
|
285
292
|
readonly title?: string
|
|
293
|
+
/** The primary context card's headline (at most 90 characters), untrusted: where the work stands (docs/context-cards.md). */
|
|
294
|
+
readonly pickup?: string
|
|
286
295
|
readonly lastResult?: { readonly at: number; readonly outcome: ConciergeResultOutcome }
|
|
287
296
|
readonly reasons: readonly WatchReason[]
|
|
288
297
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Context cards (decision 9604b0a1, docs/context-cards.md): short, auto-updated cards beside a session that help
|
|
2
|
+
// the user and the Concierge pick its work back up. Each card comes from a PROVIDER (the hub's registry, e.g.
|
|
3
|
+
// `pickup`, `changes`); one provider is primary: its headline is the session list's second line. Shared by the
|
|
4
|
+
// hub (src/central/context-cards) and the console (web/src/core/context-cards-core.ts).
|
|
5
|
+
|
|
6
|
+
/** What makes a provider run: a turn ended, the session went blocked, the user came back, the leader's sweep, a refresh. */
|
|
7
|
+
export const CONTEXT_CARD_TRIGGERS = ["turn-end", "blocked", "user-return", "sweep", "manual"] as const
|
|
8
|
+
export type ContextCardTrigger = (typeof CONTEXT_CARD_TRIGGERS)[number]
|
|
9
|
+
|
|
10
|
+
/** Bounds of a stored card (characters, code points). */
|
|
11
|
+
export const CONTEXT_CARD_LIMITS = {
|
|
12
|
+
title: 60,
|
|
13
|
+
headline: 90,
|
|
14
|
+
body: 400,
|
|
15
|
+
fields: 6,
|
|
16
|
+
fieldLabel: 24,
|
|
17
|
+
fieldValue: 120,
|
|
18
|
+
model: 100,
|
|
19
|
+
} as const
|
|
20
|
+
|
|
21
|
+
export interface ContextCardField {
|
|
22
|
+
readonly label: string
|
|
23
|
+
readonly value: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ContextCard {
|
|
27
|
+
/** The provider's id (`pickup`, `changes`, …). */
|
|
28
|
+
readonly provider: string
|
|
29
|
+
readonly sessionID: string
|
|
30
|
+
/** The card's title ("Pick up where you left off"). */
|
|
31
|
+
readonly title: string
|
|
32
|
+
/** One line (≤ 90 characters): the primary card's is the session list's second line. */
|
|
33
|
+
readonly headline: string
|
|
34
|
+
/** Markdown-lite text (≤ 400 characters; may be empty). Untrusted: render as text. */
|
|
35
|
+
readonly body: string
|
|
36
|
+
readonly fields?: readonly ContextCardField[]
|
|
37
|
+
/** Epoch ms. */
|
|
38
|
+
readonly generatedAt: number
|
|
39
|
+
/** The model that wrote it (model providers only). */
|
|
40
|
+
readonly model?: string
|
|
41
|
+
/** Hash of the inputs it was made from (unchanged inputs are not regenerated). */
|
|
42
|
+
readonly inputsHash: string
|
|
43
|
+
/** The provider's version when it was made. */
|
|
44
|
+
readonly version: number
|
|
45
|
+
readonly trigger: ContextCardTrigger
|
|
46
|
+
/** The inputs it was made from (an offline spoke's weaker inputs never replace a richer card). */
|
|
47
|
+
readonly basis?: readonly string[]
|
|
48
|
+
/** The primary card (its headline is the list's second line). */
|
|
49
|
+
readonly primary?: true
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A provider as clients see it. */
|
|
53
|
+
export interface ContextCardProviderInfo {
|
|
54
|
+
readonly id: string
|
|
55
|
+
readonly label: string
|
|
56
|
+
readonly primary: boolean
|
|
57
|
+
/** It calls the context card model (needs it configured, counts against the daily budget). */
|
|
58
|
+
readonly usesModel: boolean
|
|
59
|
+
}
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
// Literal credentials in free text (tokens, keys, private keys): found, or replaced with "[redacted: <kind>]". Voice
|
|
2
2
|
// recordings scrub every text field with it; spoken and pushed failure reasons and voice-front wakes do too.
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* A private key block of any type (PEM, OpenSSH, encrypted, PGP): BEGIN through END; without an END (a cut text), the
|
|
6
|
+
* header, the base64 chunks after it on the same line (after spaces or a literal "\n", as in JSON or env strings) and
|
|
7
|
+
* the key-material lines after it. Prose after a bare header ("… KEY----- and …") is kept.
|
|
8
|
+
*/
|
|
9
|
+
const PRIVATE_KEY = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----(?:[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----|(?:(?:[ \t]+|\\n)[A-Za-z0-9+/=]{8,})*(?:\r?\n[A-Za-z0-9+/=:,. -]*)*)/
|
|
10
|
+
|
|
4
11
|
/** Distinctive credential formats; prose about secrets ("set API_KEY=…") is not matched. */
|
|
5
12
|
export const CREDENTIALS: readonly (readonly [string, RegExp])[] = [
|
|
6
|
-
["private key",
|
|
13
|
+
["private key", PRIVATE_KEY],
|
|
7
14
|
["hub app token", /\bhat_[0-9a-f]{16}_[A-Za-z0-9_-]{43,}/],
|
|
8
15
|
["github token", /\bgh[pousr]_[A-Za-z0-9]{36,}/],
|
|
9
16
|
["github token", /\bgithub_pat_[A-Za-z0-9_]{22,}/],
|
|
@@ -15,6 +22,31 @@ export const CREDENTIALS: readonly (readonly [string, RegExp])[] = [
|
|
|
15
22
|
["bearer token", /\b[Bb]earer\s+[A-Za-z0-9._~+/=-]{20,}/],
|
|
16
23
|
]
|
|
17
24
|
|
|
25
|
+
/** Names whose value is a secret: ending in password, passwd, pwd, passphrase, secret, token, api key, or AWS key names. */
|
|
26
|
+
const NAME = String.raw`[A-Za-z0-9_.-]*(?:password|passwd|pwd|passphrase|secret|token|api[_-]?key|secret_?access_?key|access_?key_?id)`
|
|
27
|
+
/** Between a name and its value: an optional closing quote, then = or :. */
|
|
28
|
+
const SEP = String.raw`["']?\s*[:=]\s*`
|
|
29
|
+
/** Placeholder values in docs and prose ("API_KEY=your-key-here", "<token>", "…", "xxx", "changeme") are not secrets. */
|
|
30
|
+
const PLACEHOLDER = String.raw`(?!(?:your[-_ ]|<|\.\.\.|…|x{3,}(?![A-Za-z0-9])|\*{3,}|changeme|example|placeholder)[^\s"']*(?:["'\s&;,<>]|$))`
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Secrets by context, redacted by redactCredentials after CREDENTIALS (the name stays, the value goes):
|
|
34
|
+
* aws secret key: a 40-character key after aws_secret_access_key / aws_secret_key / secret_access_key (any case)
|
|
35
|
+
* url password: scheme://user:<password>@host (the user may be empty; the password may hold "/"; a port is not one)
|
|
36
|
+
* authorization: the credentials after Authorization: Basic | Bearer | Token | Digest
|
|
37
|
+
* secret: <name>=value, <name>: value and "<name>": "value" (NAME, any case, e.g. GITHUB_TOKEN, client_secret,
|
|
38
|
+
* OPENAI_API_KEY, apiKey, SecretAccessKey, SessionToken, AccessKeyId); a quoted value goes whole
|
|
39
|
+
* (spaces, commas, semicolons included), an unquoted one runs to whitespace, a quote or & ; , < >;
|
|
40
|
+
* placeholders (your-…, <…>, …, xxx, ***, changeme, example, placeholder) are left alone
|
|
41
|
+
*/
|
|
42
|
+
const CONTEXTUAL: readonly (readonly [string, RegExp, string])[] = [
|
|
43
|
+
["aws secret key", /\b((?:aws_)?secret(?:_access)?_key["']?\s*[:=]\s*["']?)(?!\[redacted)[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=])/gi, "$1[redacted: aws secret key]"],
|
|
44
|
+
["url password", /\b([a-z][a-z0-9+.-]*:\/\/[^\s:@/]*:)(?!\[redacted)(?!\d+(?:[/?#\s]|$))[^\s@]+(@)/gi, "$1[redacted: url password]$2"],
|
|
45
|
+
["authorization", /\b(authorization["']?\s*[:=]\s*["']?(?:basic|bearer|token|digest)\s+)(?!\[redacted)[^\s"',;]+/gi, "$1[redacted: authorization]"],
|
|
46
|
+
["secret", new RegExp(String.raw`\b(${NAME}${SEP})(["'])(?!\[redacted)${PLACEHOLDER}(?:(?!\2)[^\\\n]|\\.)*\2`, "gi"), "$1$2[redacted: secret]$2"],
|
|
47
|
+
["secret", new RegExp(String.raw`\b(${NAME}${SEP})(?!["'\[])${PLACEHOLDER}(?=[^\s"'&;,<>]*[A-Za-z0-9])[^\s"'&;,<>]+`, "gi"), "$1[redacted: secret]"],
|
|
48
|
+
]
|
|
49
|
+
|
|
18
50
|
/** The kind of literal credential in a text, or undefined. */
|
|
19
51
|
export function credentialFinding(text: string): string | undefined {
|
|
20
52
|
for (const [kind, pattern] of CREDENTIALS) if (pattern.test(text)) return kind
|
|
@@ -22,7 +54,7 @@ export function credentialFinding(text: string): string | undefined {
|
|
|
22
54
|
}
|
|
23
55
|
|
|
24
56
|
/**
|
|
25
|
-
* (REC-01) Replaces every literal credential in a text with `[redacted: <kind>]` (the CREDENTIALS table, applied
|
|
57
|
+
* (REC-01) Replaces every literal credential (CREDENTIALS, then the CONTEXTUAL secrets: names kept, values replaced) in a text with `[redacted: <kind>]` (the CREDENTIALS table, applied
|
|
26
58
|
* globally) and lists the kinds removed, each once, in table order. Voice recordings scrub every text field with it.
|
|
27
59
|
*/
|
|
28
60
|
export function redactCredentials(text: string): { text: string; kinds: string[] } {
|
|
@@ -34,5 +66,10 @@ export function redactCredentials(text: string): { text: string; kinds: string[]
|
|
|
34
66
|
if (next !== out && !kinds.includes(kind)) kinds.push(kind)
|
|
35
67
|
out = next
|
|
36
68
|
}
|
|
69
|
+
for (const [kind, pattern, replacement] of CONTEXTUAL) {
|
|
70
|
+
const next = out.replace(pattern, replacement)
|
|
71
|
+
if (next !== out && !kinds.includes(kind)) kinds.push(kind)
|
|
72
|
+
out = next
|
|
73
|
+
}
|
|
37
74
|
return { text: out, kinds }
|
|
38
75
|
}
|