@huynle/hub 0.7.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/Dockerfile +7 -2
  3. package/README.md +153 -24
  4. package/package.json +5 -3
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/daemon.ts +8 -1
  7. package/packages/claude-code/src/launcher.ts +29 -9
  8. package/packages/claude-code/src/profile.ts +173 -0
  9. package/packages/claude-code/src/sessions.ts +22 -4
  10. package/packages/cli/dist/hub-cli.js +6474 -3470
  11. package/packages/cli/package.json +1 -1
  12. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  13. package/packages/cli/src/artifacts-mcp.ts +160 -0
  14. package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
  15. package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
  16. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  17. package/packages/cli/src/cli.ts +95 -13
  18. package/packages/cli/src/format.ts +48 -10
  19. package/packages/cli/src/http.ts +1 -0
  20. package/packages/cli/src/intents.ts +23 -0
  21. package/packages/cli/src/login.ts +20 -6
  22. package/packages/cli/src/mcp.ts +380 -9
  23. package/packages/cli/src/messaging.ts +3 -1
  24. package/packages/cli/src/setup/commands.ts +3 -2
  25. package/packages/cli/src/setup/runtime.ts +3 -2
  26. package/packages/cli/src/team-intake-mcp.ts +158 -0
  27. package/packages/cli/src/teams-mcp.ts +403 -0
  28. package/packages/cli/src/watchlist.ts +135 -0
  29. package/packages/codex/package.json +1 -1
  30. package/packages/codex/src/profile.ts +163 -0
  31. package/packages/codex/src/sessions.ts +12 -0
  32. package/packages/codex/src/spoke.ts +19 -1
  33. package/packages/node/package.json +1 -1
  34. package/packages/node/src/config.ts +96 -2
  35. package/packages/node/src/opencode-warm.ts +325 -0
  36. package/packages/node/src/run.ts +27 -1
  37. package/packages/preset/package.json +1 -1
  38. package/packages/preset/src/profile.ts +34 -0
  39. package/packages/preset/src/spoke.ts +4 -0
  40. package/packages/protocol/package.json +1 -1
  41. package/packages/protocol/src/artifacts.ts +142 -0
  42. package/packages/protocol/src/concierge.ts +366 -0
  43. package/packages/protocol/src/contracts.ts +104 -0
  44. package/packages/protocol/src/graph.ts +70 -0
  45. package/packages/protocol/src/hub-workspace.ts +201 -0
  46. package/packages/protocol/src/index.ts +169 -6
  47. package/packages/protocol/src/launch.ts +22 -0
  48. package/packages/protocol/src/profiles.ts +301 -0
  49. package/packages/protocol/src/teams.ts +204 -0
  50. package/packages/runtime-openshell/package.json +1 -1
  51. package/packages/runtime-sdk/package.json +1 -1
  52. package/packages/sdk/package.json +1 -1
  53. package/packages/sdk/src/address.ts +50 -2
  54. package/packages/sdk/src/artifact-jobs.ts +194 -0
  55. package/packages/sdk/src/artifacts.ts +187 -0
  56. package/packages/sdk/src/client.ts +226 -5
  57. package/packages/sdk/src/concierge.ts +88 -0
  58. package/packages/sdk/src/errors.ts +3 -1
  59. package/packages/sdk/src/index.ts +5 -0
  60. package/packages/sdk/src/team-intake.ts +122 -0
  61. package/packages/sdk/src/teams.ts +346 -0
  62. package/packages/sdk/src/types.ts +548 -1
  63. package/packages/spoke-sdk/package.json +1 -1
  64. package/packages/spoke-sdk/src/index.ts +1 -0
  65. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  66. package/src/central/actions.ts +7 -0
  67. package/src/central/agent/acting-routes.ts +2 -1
  68. package/src/central/agent/acting-switch.ts +2 -1
  69. package/src/central/agent/acting.ts +215 -10
  70. package/src/central/agent/app-tokens.ts +3 -2
  71. package/src/central/agent/audit.ts +17 -0
  72. package/src/central/agent/body.ts +163 -0
  73. package/src/central/agent/decisions.ts +69 -2
  74. package/src/central/agent/delivery.ts +95 -15
  75. package/src/central/agent/directory-routes.ts +37 -7
  76. package/src/central/agent/directory.ts +70 -14
  77. package/src/central/agent/errors.ts +5 -0
  78. package/src/central/agent/events-routes.ts +21 -4
  79. package/src/central/agent/events.ts +11 -3
  80. package/src/central/agent/form-check.ts +85 -0
  81. package/src/central/agent/grant-requests.ts +2 -1
  82. package/src/central/agent/grant-routes.ts +4 -3
  83. package/src/central/agent/grants.ts +29 -2
  84. package/src/central/agent/idempotency.ts +133 -0
  85. package/src/central/agent/intent-routes.ts +116 -0
  86. package/src/central/agent/intents.ts +505 -0
  87. package/src/central/agent/interaction-graph-routes.ts +212 -0
  88. package/src/central/agent/interaction-graph.ts +865 -0
  89. package/src/central/agent/launch-engine.ts +16 -2
  90. package/src/central/agent/launch-routes.ts +6 -1
  91. package/src/central/agent/launch-spec.ts +14 -1
  92. package/src/central/agent/message-routes.ts +2 -1
  93. package/src/central/agent/messages.ts +27 -2
  94. package/src/central/agent/names.ts +12 -2
  95. package/src/central/agent/read-routes.ts +86 -19
  96. package/src/central/agent/routes.ts +176 -5
  97. package/src/central/agent/webhook-routes.ts +2 -1
  98. package/src/central/agent-data.ts +4 -0
  99. package/src/central/artifacts/access.ts +44 -0
  100. package/src/central/artifacts/blob-store.ts +113 -0
  101. package/src/central/artifacts/blob-support.ts +98 -0
  102. package/src/central/artifacts/events.ts +27 -0
  103. package/src/central/artifacts/host-caps.ts +162 -0
  104. package/src/central/artifacts/host.ts +315 -0
  105. package/src/central/artifacts/idempotency.ts +109 -0
  106. package/src/central/artifacts/jobs-routes.ts +773 -0
  107. package/src/central/artifacts/jobs.ts +344 -0
  108. package/src/central/artifacts/maintenance.ts +103 -0
  109. package/src/central/artifacts/routes.ts +360 -0
  110. package/src/central/artifacts/store.ts +776 -0
  111. package/src/central/artifacts/workspaces.ts +31 -0
  112. package/src/central/backend/file-blobs.ts +377 -0
  113. package/src/central/backend/index.ts +12 -3
  114. package/src/central/backend/memory-blobs.ts +116 -0
  115. package/src/central/backend/postgres-blobs.ts +513 -0
  116. package/src/central/backend/postgres-migrate.ts +33 -0
  117. package/src/central/backend/postgres-state.ts +19 -0
  118. package/src/central/backend/types.ts +8 -0
  119. package/src/central/concierge/activity.ts +142 -0
  120. package/src/central/concierge/arm.ts +63 -0
  121. package/src/central/concierge/board-render.ts +190 -0
  122. package/src/central/concierge/board-results.ts +169 -0
  123. package/src/central/concierge/board-routes.ts +339 -0
  124. package/src/central/concierge/board.ts +249 -0
  125. package/src/central/concierge/context.ts +221 -0
  126. package/src/central/concierge/entitlements.ts +13 -0
  127. package/src/central/concierge/guard.ts +120 -0
  128. package/src/central/concierge/inbound.ts +89 -0
  129. package/src/central/concierge/index.ts +84 -0
  130. package/src/central/concierge/notes.ts +41 -0
  131. package/src/central/concierge/notifier.ts +986 -0
  132. package/src/central/concierge/prompt.ts +130 -0
  133. package/src/central/concierge/proposals.ts +262 -0
  134. package/src/central/concierge/push-remap.ts +49 -0
  135. package/src/central/concierge/routes.ts +62 -0
  136. package/src/central/concierge/schedule.ts +113 -0
  137. package/src/central/concierge/service.ts +243 -0
  138. package/src/central/concierge/session-routes.ts +69 -0
  139. package/src/central/concierge/settings.ts +217 -0
  140. package/src/central/concierge/supervisor.ts +740 -0
  141. package/src/central/concierge/tiers.ts +260 -0
  142. package/src/central/concierge/watchlist-routes.ts +277 -0
  143. package/src/central/concierge/watchlist.ts +273 -0
  144. package/src/central/config.ts +7 -1
  145. package/src/central/http.ts +633 -50
  146. package/src/central/mcp-http.ts +10 -21
  147. package/src/central/profiles/apply.ts +333 -0
  148. package/src/central/profiles/content.ts +174 -0
  149. package/src/central/profiles/events.ts +40 -0
  150. package/src/central/profiles/idempotency.ts +14 -0
  151. package/src/central/profiles/import.ts +139 -0
  152. package/src/central/profiles/routes.ts +356 -0
  153. package/src/central/profiles/store.ts +597 -0
  154. package/src/central/profiles/usage-ports.ts +120 -0
  155. package/src/central/profiles/usage.ts +364 -0
  156. package/src/central/push.ts +7 -0
  157. package/src/central/registry.ts +43 -6
  158. package/src/central/store.ts +4 -0
  159. package/src/central/teams/collections.ts +34 -0
  160. package/src/central/teams/discovery.ts +346 -0
  161. package/src/central/teams/draft-edits.ts +46 -0
  162. package/src/central/teams/events.ts +46 -0
  163. package/src/central/teams/intake-routes.ts +100 -0
  164. package/src/central/teams/intake.ts +772 -0
  165. package/src/central/teams/policy.ts +231 -0
  166. package/src/central/teams/routes.ts +516 -0
  167. package/src/central/teams/seats.ts +272 -0
  168. package/src/central/teams/staffing.ts +888 -0
  169. package/src/central/teams/store.ts +667 -0
  170. package/src/central/teams/validation.ts +256 -0
  171. package/src/central/workspaces/board.ts +408 -0
  172. package/src/central/workspaces/checkpoints.ts +316 -0
  173. package/src/central/workspaces/coordinator.ts +841 -0
  174. package/src/central/workspaces/events.ts +85 -0
  175. package/src/central/workspaces/lifecycle.ts +93 -0
  176. package/src/central/workspaces/master.ts +490 -0
  177. package/src/central/workspaces/members.ts +632 -0
  178. package/src/central/workspaces/routes.ts +354 -0
  179. package/src/central/workspaces/sidechats-routes.ts +410 -0
  180. package/src/central/workspaces/sidechats.ts +482 -0
  181. package/src/central/workspaces/store.ts +571 -0
  182. package/src/central/workspaces/supervision.ts +355 -0
  183. package/src/index.ts +97 -15
  184. package/src/spoke/concierge-agent.ts +33 -0
  185. package/src/spoke/deliver.ts +22 -4
  186. package/src/spoke/fork-revert.ts +142 -0
  187. package/src/spoke/hub-rpc.ts +41 -5
  188. package/src/spoke/hub-tool.ts +383 -18
  189. package/src/spoke/listing.ts +47 -4
  190. package/src/spoke/profile-apply.ts +298 -0
  191. package/src/spoke/session-context.ts +176 -0
  192. package/src/spoke/spoke.ts +319 -20
  193. package/src/spoke/timeline.ts +6 -1
  194. package/src/spoke/tracker.ts +23 -1
  195. package/src/spoke/who-teams.ts +81 -0
  196. package/src/tui-messaging.ts +32 -18
  197. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
  198. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
  199. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  200. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
  201. package/web/dist/assets/Library-CtxRqTjo.js +2 -0
  202. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  203. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
  204. package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
  205. package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
  206. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  207. package/web/dist/assets/index-DaH5GUdV.js +31 -0
  208. package/web/dist/assets/index-WWuKfWxL.css +1 -0
  209. package/web/dist/assets/live-BUofsQpz.css +1 -0
  210. package/web/dist/assets/live-YyTjE47g.js +5 -0
  211. package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
  212. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  213. package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
  214. package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
  215. package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
  216. package/web/dist/assets/source-CvUHTc3O.js +1 -0
  217. package/web/dist/assets/ui-C9Igxzjm.js +1 -0
  218. package/web/dist/index.html +5 -2
  219. package/web/dist/sw.js +22 -2
  220. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  221. package/web/dist/assets/index-FUJbj_Du.js +0 -20
@@ -0,0 +1,597 @@
1
+ // The versioned profile registry (HWA-14; contract docs/plans/2026-10-30-hub-workspaces-artifacts-contracts.md,
2
+ // packages/protocol/src/profiles.ts). Per user (the StateStore userID is the tenant key; another user's records
3
+ // are simply absent):
4
+ //
5
+ // profiles key prof_… the AgentProfile plus its internal version history (number, digest,
6
+ // bytes, createdAt), never more than versionsPerProfile entries
7
+ // profile-index key "names" one aggregate {name → {id, at}}: unique names and the count bound
8
+ // profile-version.<prof_…> key "<n>" one immutable AgentProfileVersion per collection (one small file each
9
+ // in the file store), written once with ifVersion 0
10
+ // profile-assignments key <sessionID> ProfileAssignment pins (HWA-15 applies them)
11
+ //
12
+ // Concurrency: every mutation names expectedRevision; a mismatch is 409 conflict with currentRevision. A new
13
+ // version races for its number through the version slot (ifVersion 0): the first writer wins and the record is
14
+ // never overwritten. A slot left by a writer that stopped before updating the profile is adopted (healed) by the
15
+ // next writer, which then gets conflict and retries with the new revision. Retention drops the oldest versions
16
+ // no session pins once a profile holds more than versionsPerProfile; when every older version is pinned, a new
17
+ // one is refused (rate_limited). Archiving is reversible; an archived profile takes no versions or new pins.
18
+ // Bounded: profiles per user, versions per profile, bytes of retained versions per user (bytesPerUser).
19
+ import { randomBytes } from "node:crypto"
20
+ import {
21
+ type AgentProfile,
22
+ type AgentProfileVersion,
23
+ DESCRIPTION_MAX,
24
+ isExpectedRevision,
25
+ isLabel,
26
+ isText,
27
+ LIST_LIMIT_DEFAULT,
28
+ LIST_LIMIT_MAX,
29
+ metadataProblem,
30
+ type Page,
31
+ PROFILE_APPLY_LIMITS,
32
+ PROFILE_LIMITS,
33
+ type ProfileAck,
34
+ type ProfileAssignment,
35
+ type ProfileVersionContent,
36
+ profileInputProblem,
37
+ profileProjectsProblem,
38
+ RECORD_STATES,
39
+ type RecordState,
40
+ tagsProblem,
41
+ } from "../../protocol"
42
+ import type { Clock, StateStore } from "../backend/types"
43
+ import { AgentError } from "../agent/errors"
44
+ import { updateVersioned } from "../agent/versioned"
45
+ import { checkContent, contentBytes, contentDigest, credentialFinding, ProfileContentError } from "./content"
46
+
47
+ export const PROFILES = "profiles"
48
+ export const PROFILE_INDEX = "profile-index"
49
+ export const PROFILE_ASSIGNMENTS = "profile-assignments"
50
+ export const versionCollection = (profileID: string): string => `profile-version.${profileID}`
51
+
52
+ export interface ProfileStoreLimits {
53
+ readonly profilesPerUser: number
54
+ readonly versionsPerProfile: number
55
+ readonly bytesPerUser: number
56
+ readonly reservationMs: number
57
+ }
58
+
59
+ export const PROFILE_STORE_LIMITS: ProfileStoreLimits = {
60
+ profilesPerUser: PROFILE_LIMITS.profilesPerUser,
61
+ versionsPerProfile: PROFILE_LIMITS.versionsPerProfile,
62
+ /** Serialized bytes of all retained versions of one user (extension of the frozen limits). */
63
+ bytesPerUser: 32 * 1024 * 1024,
64
+ /** A name reservation whose profile never got written is reclaimed after this long. */
65
+ reservationMs: 60_000,
66
+ }
67
+
68
+ /** One retained version as the history shows it (never its content). */
69
+ export interface VersionEntry {
70
+ readonly version: number
71
+ readonly digest: string
72
+ readonly bytes: number
73
+ readonly createdAt: number
74
+ /** (RP-08) Cached counts of the version's content (absent on entries written before; summaries() reads those). */
75
+ readonly skills?: number
76
+ readonly commands?: number
77
+ readonly instructions?: boolean
78
+ }
79
+
80
+ /** (RP-08) The latest version of a listed profile, without reading it (list rows). */
81
+ export interface LatestSummary {
82
+ readonly version: number
83
+ readonly skills: number
84
+ readonly commands: number
85
+ readonly instructions: boolean
86
+ }
87
+
88
+ const countsOf = (content: ProfileVersionContent) => ({ skills: content.skills.length, commands: content.commands.length, instructions: content.instructions !== undefined })
89
+
90
+ interface ProfileRecord extends AgentProfile {
91
+ readonly createdAt: number
92
+ readonly history: readonly VersionEntry[]
93
+ }
94
+
95
+ interface NameIndex {
96
+ readonly v: 1
97
+ readonly names: Readonly<Record<string, { readonly id: string; readonly at: number }>>
98
+ }
99
+
100
+ /** The acknowledgment recorded when a pin is stored (HWA-15 plugs in the spoke application). */
101
+ export type AckFor = (userID: string, assignment: ProfileAssignment, version: AgentProfileVersion) => Promise<ProfileAck>
102
+
103
+ export interface ProfileStoreOptions {
104
+ readonly state: StateStore
105
+ readonly now?: Clock
106
+ readonly limits?: Partial<ProfileStoreLimits>
107
+ /** Without it a pin is recorded as `unsupported` (this hub does not apply profiles yet), never as applied. */
108
+ readonly ack?: AckFor
109
+ readonly newID?: () => string
110
+ }
111
+
112
+ export interface CreateResult {
113
+ readonly profile: AgentProfile
114
+ readonly version: AgentProfileVersion
115
+ }
116
+
117
+ export interface VersionInput {
118
+ readonly expectedRevision?: unknown
119
+ readonly content?: unknown
120
+ /** Explicit rollback: copy this retained version into a new one. */
121
+ readonly fromVersion?: unknown
122
+ }
123
+
124
+ export interface ProfilePatch {
125
+ readonly expectedRevision?: unknown
126
+ readonly title?: unknown
127
+ readonly description?: unknown
128
+ readonly tags?: unknown
129
+ readonly state?: unknown
130
+ /** (RP-08) Replaces the metadata; null clears it. */
131
+ readonly metadata?: unknown
132
+ /** (RP-08) Replaces the projects; null clears them. */
133
+ readonly projects?: unknown
134
+ }
135
+
136
+ export interface AssignInput {
137
+ readonly profileID?: unknown
138
+ readonly version?: unknown
139
+ readonly expectedRevision?: unknown
140
+ }
141
+
142
+ export interface ListOptions {
143
+ readonly state?: string
144
+ readonly limit?: number
145
+ readonly cursor?: string
146
+ }
147
+
148
+ const CREATE_FIELDS = new Set(["name", "title", "description", "tags", "metadata", "projects", "content"])
149
+ const PATCH_FIELDS = new Set(["expectedRevision", "title", "description", "tags", "state", "metadata", "projects"])
150
+
151
+ const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
152
+
153
+ export const conflict = (message: string, currentRevision: number, extra: Record<string, unknown> = {}): AgentError =>
154
+ new AgentError("conflict", message, { extra: { ...extra, currentRevision }, hint: "Read the current record and retry with its revision" })
155
+
156
+ /** A refused content body as the agent error envelope. */
157
+ export function contentError(cause: unknown): unknown {
158
+ if (!(cause instanceof ProfileContentError)) return cause
159
+ return cause.code === "unsupported" ? new AgentError("unsupported", cause.message, { extra: { feature: "setupHooks" } }) : new AgentError("invalid", cause.message)
160
+ }
161
+
162
+ /** Validated, normalized version content; throws the agent error. */
163
+ export function contentOf(v: unknown): ProfileVersionContent {
164
+ try {
165
+ return checkContent(v)
166
+ } catch (cause) {
167
+ throw contentError(cause)
168
+ }
169
+ }
170
+
171
+ function noCredential(field: string, value: unknown): void {
172
+ if (typeof value !== "string") return
173
+ const kind = credentialFinding(value)
174
+ if (kind) throw new AgentError("invalid", `${field} contains what looks like a ${kind}; keep credentials out of profiles`)
175
+ }
176
+
177
+ /** Metadata values are free text: no credentials in them either. */
178
+ function noCredentialIn(metadata: unknown, projects?: unknown): void {
179
+ if (typeof metadata === "object" && metadata !== null) for (const [key, value] of Object.entries(metadata)) noCredential(`metadata.${key}`, value)
180
+ if (Array.isArray(projects)) for (const project of projects) noCredential("projects", project)
181
+ }
182
+
183
+ function unknownField(body: Record<string, unknown>, allowed: ReadonlySet<string>): void {
184
+ for (const key of Object.keys(body)) {
185
+ if (allowed.has(key)) continue
186
+ if (key === "name") throw new AgentError("invalid", "A profile's name cannot change; create a new profile")
187
+ throw new AgentError("invalid", `unknown field ${/^[A-Za-z0-9_-]{1,40}$/.test(key) ? key : "(unprintable)"}`)
188
+ }
189
+ }
190
+
191
+ const encodeCursor = (value: string | number): string => Buffer.from(JSON.stringify({ k: value })).toString("base64url")
192
+ function decodeCursor(cursor: string | undefined): string | number | undefined {
193
+ if (cursor === undefined) return undefined
194
+ try {
195
+ const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as { k?: unknown }
196
+ if (typeof parsed.k === "string" || typeof parsed.k === "number") return parsed.k
197
+ } catch {
198
+ // answered below
199
+ }
200
+ throw new AgentError("invalid", "Bad cursor; start again without one")
201
+ }
202
+
203
+ function pageLimit(limit: number | undefined): number {
204
+ if (limit === undefined) return LIST_LIMIT_DEFAULT
205
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > LIST_LIMIT_MAX) throw new AgentError("invalid", `limit must be 1–${LIST_LIMIT_MAX}`)
206
+ return limit
207
+ }
208
+
209
+ /** A profile as the API shows it: the contract record, never the internal history. */
210
+ export function profileView(record: ProfileRecord): AgentProfile {
211
+ const { history: _history, createdAt: _createdAt, ...profile } = record
212
+ return profile
213
+ }
214
+
215
+ export class ProfileStore {
216
+ readonly state: StateStore
217
+ private readonly now: Clock
218
+ readonly limits: ProfileStoreLimits
219
+ private readonly ack?: AckFor
220
+ private readonly newID: () => string
221
+
222
+ constructor(options: ProfileStoreOptions) {
223
+ this.state = options.state
224
+ this.now = options.now ?? Date.now
225
+ this.limits = { ...PROFILE_STORE_LIMITS, ...options.limits }
226
+ this.ack = options.ack
227
+ this.newID = options.newID ?? (() => `prof_${randomBytes(12).toString("hex")}`)
228
+ }
229
+
230
+ // ---------- profiles ----------
231
+
232
+ async create(userID: string, body: unknown): Promise<CreateResult> {
233
+ if (!isRecord(body)) throw new AgentError("invalid", "Body must be a JSON object")
234
+ unknownField(body, CREATE_FIELDS)
235
+ const content = contentOf(body.content)
236
+ const problem = profileInputProblem({ ...body, content })
237
+ if (problem) throw new AgentError("invalid", problem)
238
+ noCredential("title", body.title)
239
+ noCredential("description", body.description)
240
+ noCredentialIn(body.metadata, body.projects)
241
+ const name = body.name as string
242
+ const bytes = contentBytes(content)
243
+ await this.ensureQuota(userID, bytes)
244
+
245
+ const id = this.newID()
246
+ const now = this.now()
247
+ await this.reserveName(userID, name, id, now)
248
+ try {
249
+ const version: AgentProfileVersion = { v: 1, profileID: id, version: 1, content, digest: contentDigest(content), createdAt: now }
250
+ const record: ProfileRecord = {
251
+ v: 1,
252
+ id,
253
+ name,
254
+ title: body.title as string,
255
+ ...(body.description !== undefined ? { description: body.description as string } : {}),
256
+ state: "active",
257
+ ...(body.tags !== undefined ? { tags: [...(body.tags as string[])] } : {}),
258
+ ...(body.metadata !== undefined ? { metadata: { ...(body.metadata as Record<string, string>) } } : {}),
259
+ ...(body.projects !== undefined ? { projects: [...(body.projects as string[])] } : {}),
260
+ latestVersion: 1,
261
+ revision: 1,
262
+ updatedAt: now,
263
+ createdAt: now,
264
+ history: [{ version: 1, digest: version.digest, bytes, createdAt: now, ...countsOf(content) }],
265
+ }
266
+ if (!(await this.state.put(userID, versionCollection(id), "1", version, { ifVersion: 0 })).ok) throw new Error(`profile version slot ${id}/1 already exists`)
267
+ if (!(await this.state.put(userID, PROFILES, id, record, { ifVersion: 0 })).ok) throw new Error(`profile ${id} already exists`)
268
+ return { profile: profileView(record), version }
269
+ } catch (cause) {
270
+ await this.releaseName(userID, name, id).catch(() => {})
271
+ throw cause
272
+ }
273
+ }
274
+
275
+ async get(userID: string, id: string): Promise<AgentProfile | undefined> {
276
+ const record = await this.record(userID, id)
277
+ return record && profileView(record)
278
+ }
279
+
280
+ async list(userID: string, options: ListOptions): Promise<Page<AgentProfile>> {
281
+ const limit = pageLimit(options.limit)
282
+ const wanted = options.state ?? "active"
283
+ if (wanted !== "all" && !RECORD_STATES.includes(wanted as RecordState)) throw new AgentError("invalid", "state must be active, archived or all")
284
+ const after = decodeCursor(options.cursor)
285
+ const all = (await this.state.list<ProfileRecord>(userID, PROFILES))
286
+ .map((e) => e.value)
287
+ .filter((p) => wanted === "all" || p.state === wanted)
288
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
289
+ const rest = after === undefined ? all : all.filter((p) => p.name > String(after))
290
+ const items = rest.slice(0, limit)
291
+ return { items: items.map(profileView), ...(rest.length > limit ? { cursor: encodeCursor(items[items.length - 1]!.name) } : {}) }
292
+ }
293
+
294
+ async update(userID: string, id: string, patch: ProfilePatch): Promise<AgentProfile> {
295
+ return (await this.updateWithPrior(userID, id, patch)).after
296
+ }
297
+
298
+ /** The update, with the profile before it (the routes announce state changes). */
299
+ async updateWithPrior(userID: string, id: string, patch: ProfilePatch): Promise<{ before: AgentProfile; after: AgentProfile }> {
300
+ if (!isRecord(patch)) throw new AgentError("invalid", "Body must be a JSON object")
301
+ unknownField(patch, PATCH_FIELDS)
302
+ const expected = this.expected(patch.expectedRevision)
303
+ if (patch.title !== undefined && !isLabel(patch.title)) throw new AgentError("invalid", "title must be 1–120 characters of text")
304
+ if (patch.description !== undefined && patch.description !== null && !isText(patch.description, DESCRIPTION_MAX)) throw new AgentError("invalid", `description must be at most ${DESCRIPTION_MAX} characters`)
305
+ const tags = tagsProblem(patch.tags)
306
+ if (tags) throw new AgentError("invalid", tags)
307
+ if (patch.state !== undefined && !RECORD_STATES.includes(patch.state as RecordState)) throw new AgentError("invalid", "state must be active or archived")
308
+ const extra = (patch.metadata === null ? undefined : metadataProblem(patch.metadata)) ?? (patch.projects === null ? undefined : profileProjectsProblem(patch.projects))
309
+ if (extra) throw new AgentError("invalid", extra)
310
+ noCredential("title", patch.title)
311
+ noCredential("description", patch.description)
312
+ noCredentialIn(patch.metadata, patch.projects)
313
+ let before: ProfileRecord | undefined
314
+ const after = await updateVersioned<ProfileRecord>(this.state, userID, PROFILES, id, (current) => {
315
+ if (!current) throw notFound(id)
316
+ if (current.revision !== expected) throw conflict(`Profile ${id} changed (revision ${current.revision})`, current.revision)
317
+ before = current
318
+ const { description: _d, metadata: _m, projects: _p, ...rest } = current
319
+ const description = patch.description === undefined ? current.description : (patch.description as string | null) ?? undefined
320
+ const metadata = patch.metadata === undefined ? current.metadata : (patch.metadata as Record<string, string> | null) ?? undefined
321
+ const projects = patch.projects === undefined ? current.projects : (patch.projects as string[] | null) ?? undefined
322
+ return {
323
+ ...rest,
324
+ ...(patch.title !== undefined ? { title: patch.title as string } : {}),
325
+ ...(description !== undefined ? { description } : {}),
326
+ ...(metadata !== undefined ? { metadata: { ...metadata } } : {}),
327
+ ...(projects !== undefined ? { projects: [...projects] } : {}),
328
+ ...(patch.tags !== undefined ? { tags: [...(patch.tags as string[])] } : {}),
329
+ ...(patch.state !== undefined ? { state: patch.state as RecordState } : {}),
330
+ revision: current.revision + 1,
331
+ updatedAt: this.now(),
332
+ }
333
+ })
334
+ return { before: profileView(before!), after: profileView(after!) }
335
+ }
336
+
337
+ // ---------- versions ----------
338
+
339
+ async addVersion(userID: string, id: string, input: VersionInput): Promise<CreateResult> {
340
+ if (!isRecord(input)) throw new AgentError("invalid", "Body must be a JSON object")
341
+ const expected = this.expected(input.expectedRevision)
342
+ if ((input.content === undefined) === (input.fromVersion === undefined)) throw new AgentError("invalid", "Send exactly one of content or fromVersion")
343
+ const profile = await this.record(userID, id)
344
+ if (!profile) throw notFound(id)
345
+ if (profile.revision !== expected) throw conflict(`Profile ${id} changed (revision ${profile.revision})`, profile.revision)
346
+ if (profile.state !== "active") throw conflict(`Profile ${id} is archived; unarchive it first`, profile.revision, { state: profile.state })
347
+ let content: ProfileVersionContent
348
+ if (input.fromVersion !== undefined) {
349
+ if (!Number.isSafeInteger(input.fromVersion) || (input.fromVersion as number) < 1) throw new AgentError("invalid", "fromVersion must be a version number")
350
+ const old = await this.version(userID, id, input.fromVersion as number)
351
+ if (!old) throw new AgentError("not_found", `Profile ${id} has no retained version ${input.fromVersion as number}`)
352
+ content = old.content
353
+ } else content = contentOf(input.content)
354
+ const bytes = contentBytes(content)
355
+ await this.ensureQuota(userID, bytes)
356
+
357
+ // Retention: which older versions go once this one is in (oldest first, never a pinned one).
358
+ const pinned = await this.pinnedVersions(userID, id)
359
+ const excess = profile.history.length + 1 - this.limits.versionsPerProfile
360
+ const candidates = [...profile.history].sort((a, b) => a.version - b.version).filter((h) => !pinned.has(h.version))
361
+ if (excess > candidates.length) {
362
+ throw new AgentError("rate_limited", `Profile ${id} already keeps ${profile.history.length} versions and sessions pin the older ones`, { hint: "Unassign an old version from its sessions first" })
363
+ }
364
+ const victims = excess > 0 ? candidates.slice(0, excess) : []
365
+
366
+ const number = profile.latestVersion + 1
367
+ const now = this.now()
368
+ const version: AgentProfileVersion = { v: 1, profileID: id, version: number, content, digest: contentDigest(content), createdAt: now }
369
+ if (!(await this.state.put(userID, versionCollection(id), String(number), version, { ifVersion: 0 })).ok) {
370
+ const healed = await this.heal(userID, id)
371
+ throw conflict(`Profile ${id} got version ${number} from another writer`, healed?.revision ?? profile.revision)
372
+ }
373
+ const entry: VersionEntry = { version: number, digest: version.digest, bytes, createdAt: now, ...countsOf(content) }
374
+ const gone = new Set(victims.map((v) => v.version))
375
+ const written = await updateVersioned<ProfileRecord>(this.state, userID, PROFILES, id, (current) => {
376
+ if (!current) return undefined
377
+ const present = current.history.some((h) => h.version === number)
378
+ if (present && !victims.length) return undefined
379
+ const history = [...current.history.filter((h) => !gone.has(h.version) && h.version !== number), entry].sort((a, b) => a.version - b.version)
380
+ return { ...current, history, latestVersion: Math.max(current.latestVersion, number), revision: current.revision + (present ? 0 : 1), updatedAt: now }
381
+ })
382
+ const after = written ?? (await this.record(userID, id))
383
+ if (!after) throw notFound(id)
384
+ await this.dropVersions(userID, id, victims)
385
+ return { profile: profileView(after), version }
386
+ }
387
+
388
+ /** A retained version (pruned or never written: undefined). */
389
+ async version(userID: string, id: string, number: number): Promise<AgentProfileVersion | undefined> {
390
+ const profile = await this.record(userID, id)
391
+ if (!profile || !profile.history.some((h) => h.version === number)) return undefined
392
+ return (await this.state.get<AgentProfileVersion>(userID, versionCollection(id), String(number)))?.value
393
+ }
394
+
395
+ /**
396
+ * (RP-08) The latest version's counts for these profiles: one scan of the records, plus one version read only for
397
+ * a profile whose latest entry predates the cached counts (bounded by the page).
398
+ */
399
+ async summaries(userID: string, ids: readonly string[]): Promise<Record<string, LatestSummary>> {
400
+ const wanted = new Set(ids)
401
+ const out: Record<string, LatestSummary> = {}
402
+ for (const { value } of await this.state.list<ProfileRecord>(userID, PROFILES)) {
403
+ if (!wanted.has(value.id)) continue
404
+ const entry = value.history.find((h) => h.version === value.latestVersion)
405
+ if (entry?.skills !== undefined && entry.commands !== undefined) {
406
+ out[value.id] = { version: value.latestVersion, skills: entry.skills, commands: entry.commands, instructions: entry.instructions === true }
407
+ continue
408
+ }
409
+ const latest = (await this.state.get<AgentProfileVersion>(userID, versionCollection(value.id), String(value.latestVersion)))?.value
410
+ if (latest) out[value.id] = { version: value.latestVersion, ...countsOf(latest.content) }
411
+ }
412
+ return out
413
+ }
414
+
415
+ /** The retained versions, newest first. */
416
+ async history(userID: string, id: string, options: { limit?: number; cursor?: string }): Promise<Page<VersionEntry>> {
417
+ const limit = pageLimit(options.limit)
418
+ const after = decodeCursor(options.cursor)
419
+ const profile = await this.record(userID, id)
420
+ if (!profile) throw notFound(id)
421
+ const all = [...profile.history].sort((a, b) => b.version - a.version)
422
+ const rest = after === undefined ? all : all.filter((h) => h.version < Number(after))
423
+ const items = rest.slice(0, limit)
424
+ return { items, ...(rest.length > limit ? { cursor: encodeCursor(items[items.length - 1]!.version) } : {}) }
425
+ }
426
+
427
+ // ---------- assignments (pins) ----------
428
+
429
+ async assignment(userID: string, sessionID: string): Promise<ProfileAssignment | undefined> {
430
+ return (await this.state.get<ProfileAssignment>(userID, PROFILE_ASSIGNMENTS, sessionID))?.value
431
+ }
432
+
433
+ /**
434
+ * (RP-08) The user's pins in one scan, at most `cap` of them (usage reads; no reverse index, see usage.ts).
435
+ * `truncated` when the user has more.
436
+ */
437
+ async assignmentsScan(userID: string, cap: number): Promise<{ items: ProfileAssignment[]; truncated: boolean }> {
438
+ const all = await this.state.list<ProfileAssignment>(userID, PROFILE_ASSIGNMENTS)
439
+ return { items: all.slice(0, cap).map((e) => e.value), truncated: all.length > cap }
440
+ }
441
+
442
+ /** Pins a retained version of an active profile to a session (CAS on the session's assignment). */
443
+ async assign(userID: string, sessionID: string, input: AssignInput): Promise<ProfileAssignment> {
444
+ if (!isRecord(input)) throw new AgentError("invalid", "Body must be a JSON object")
445
+ const expected = this.expected(input.expectedRevision)
446
+ if (typeof input.profileID !== "string" || !input.profileID) throw new AgentError("invalid", "profileID is required")
447
+ if (!Number.isSafeInteger(input.version) || (input.version as number) < 1) throw new AgentError("invalid", "version must be a version number")
448
+ const profileID = input.profileID
449
+ const number = input.version as number
450
+ const profile = await this.record(userID, profileID)
451
+ if (!profile) throw notFound(profileID)
452
+ if (profile.state !== "active") throw conflict(`Profile ${profileID} is archived; unarchive it first`, profile.revision, { state: profile.state })
453
+ const version = await this.version(userID, profileID, number)
454
+ if (!version) throw new AgentError("not_found", `Profile ${profileID} has no retained version ${number}`)
455
+
456
+ const now = this.now()
457
+ const pending: ProfileAck = { status: "unsupported", digest: version.digest, notes: ["this hub does not apply profiles to sessions yet"], at: now }
458
+ const written = await updateVersioned<ProfileAssignment>(this.state, userID, PROFILE_ASSIGNMENTS, sessionID, (current) => {
459
+ const revision = current?.revision ?? 0
460
+ if (revision !== expected) throw conflict(`The profile assignment of ${sessionID} changed (revision ${revision})`, revision)
461
+ // The effective version (what the session last confirmed) outlives re-pins until a spoke confirms another.
462
+ return { v: 1, sessionID, profileID, version: number, revision: revision + 1, ...(current?.effective ? { effective: current.effective } : {}), ...(this.ack ? {} : { ack: pending }) }
463
+ })
464
+ let assignment = written!
465
+ // Retention may have dropped the version between the check and the pin: undo rather than pin a gone version.
466
+ if (!(await this.version(userID, profileID, number))) {
467
+ const stored = await this.state.get<ProfileAssignment>(userID, PROFILE_ASSIGNMENTS, sessionID)
468
+ if (stored && stored.value.revision === assignment.revision) await this.state.delete(userID, PROFILE_ASSIGNMENTS, sessionID, { ifVersion: stored.version })
469
+ throw conflict(`Profile ${profileID} version ${number} was just dropped`, profile.revision)
470
+ }
471
+ if (this.ack) {
472
+ const ack = await this.ack(userID, assignment, version)
473
+ assignment = (await this.recordAck(userID, sessionID, assignment.revision, ack)) ?? assignment
474
+ }
475
+ return assignment
476
+ }
477
+
478
+ /**
479
+ * Records the latest acknowledgment of a pin (HWA-15). Acknowledgments are the hub's own status, not a user
480
+ * mutation: they keep the assignment's revision. Undefined when the pin changed meanwhile. A pending ack never
481
+ * replaces a final one of the same revision and digest (the spoke may answer before the pending is stored);
482
+ * applied and partial also become the session's effective version.
483
+ */
484
+ async recordAck(userID: string, sessionID: string, revision: number, ack: ProfileAck): Promise<ProfileAssignment | undefined> {
485
+ let kept: ProfileAssignment | undefined
486
+ const written = await updateVersioned<ProfileAssignment>(this.state, userID, PROFILE_ASSIGNMENTS, sessionID, (current) => {
487
+ if (!current || current.revision !== revision) return undefined
488
+ if (ack.status === "pending" && current.ack && current.ack.status !== "pending" && current.ack.digest === ack.digest) {
489
+ kept = current
490
+ return undefined
491
+ }
492
+ const notes = ack.notes ? { notes: ack.notes.slice(0, PROFILE_APPLY_LIMITS.notes) } : {}
493
+ const effective =
494
+ ack.status === "applied" || ack.status === "partial"
495
+ ? { effective: { profileID: current.profileID, version: current.version, digest: ack.digest, status: ack.status, at: ack.at } }
496
+ : {}
497
+ return { ...current, ack: { ...ack, ...notes }, ...effective }
498
+ })
499
+ return written ?? kept
500
+ }
501
+
502
+ async unassign(userID: string, sessionID: string, expectedRevision: unknown): Promise<ProfileAssignment> {
503
+ const expected = this.expected(expectedRevision)
504
+ const stored = await this.state.get<ProfileAssignment>(userID, PROFILE_ASSIGNMENTS, sessionID)
505
+ if (!stored) throw new AgentError("not_found", `Session ${sessionID} has no profile`)
506
+ if (stored.value.revision !== expected) throw conflict(`The profile assignment of ${sessionID} changed (revision ${stored.value.revision})`, stored.value.revision)
507
+ if (!(await this.state.delete(userID, PROFILE_ASSIGNMENTS, sessionID, { ifVersion: stored.version })).ok) {
508
+ const now = await this.assignment(userID, sessionID)
509
+ throw conflict(`The profile assignment of ${sessionID} changed`, now?.revision ?? 0)
510
+ }
511
+ return stored.value
512
+ }
513
+
514
+ // ---------- internals ----------
515
+
516
+ private expected(v: unknown): number {
517
+ if (!isExpectedRevision(v)) throw new AgentError("invalid", "expectedRevision is required (the revision you read; 0 when there is none)")
518
+ return v
519
+ }
520
+
521
+ private async record(userID: string, id: string): Promise<ProfileRecord | undefined> {
522
+ return (await this.state.get<ProfileRecord>(userID, PROFILES, id))?.value
523
+ }
524
+
525
+ private async pinnedVersions(userID: string, profileID: string): Promise<Set<number>> {
526
+ const out = new Set<number>()
527
+ for (const { value } of await this.state.list<ProfileAssignment>(userID, PROFILE_ASSIGNMENTS)) if (value.profileID === profileID) out.add(value.version)
528
+ return out
529
+ }
530
+
531
+ /** Bytes of retained versions per user stay under bytesPerUser (a soft bound: concurrent writers may pass it by one version). */
532
+ private async ensureQuota(userID: string, bytes: number): Promise<void> {
533
+ let used = 0
534
+ for (const { value } of await this.state.list<ProfileRecord>(userID, PROFILES)) for (const h of value.history) used += h.bytes
535
+ if (used + bytes > this.limits.bytesPerUser) {
536
+ throw new AgentError("rate_limited", `Profile storage is full (${this.limits.bytesPerUser} bytes per user)`, { hint: "Unpin and drop old versions or profiles you no longer use" })
537
+ }
538
+ }
539
+
540
+ private async reserveName(userID: string, name: string, id: string, now: number): Promise<void> {
541
+ const index = (await this.state.get<NameIndex>(userID, PROFILE_INDEX, "names"))?.value
542
+ const held = index?.names[name]
543
+ const stale = held && now - held.at > this.limits.reservationMs && !(await this.record(userID, held.id)) ? held.id : undefined
544
+ await updateVersioned<NameIndex>(this.state, userID, PROFILE_INDEX, "names", (current) => {
545
+ const names = { ...current?.names }
546
+ const holder = names[name]
547
+ if (holder && holder.id !== stale) throw new AgentError("name_taken", `You already have a profile named ${name}`)
548
+ delete names[name]
549
+ if (Object.keys(names).length >= this.limits.profilesPerUser) throw new AgentError("rate_limited", `At most ${this.limits.profilesPerUser} profiles per user`, { hint: "Reuse or archive profiles" })
550
+ names[name] = { id, at: now }
551
+ return { v: 1, names }
552
+ })
553
+ }
554
+
555
+ private async releaseName(userID: string, name: string, id: string): Promise<void> {
556
+ await updateVersioned<NameIndex>(this.state, userID, PROFILE_INDEX, "names", (current) => {
557
+ if (current?.names[name]?.id !== id) return undefined
558
+ const names = { ...current.names }
559
+ delete names[name]
560
+ return { v: 1, names }
561
+ })
562
+ }
563
+
564
+ /** Adopts version slots past latestVersion that a stopped writer left; returns the profile. */
565
+ private async heal(userID: string, id: string): Promise<ProfileRecord | undefined> {
566
+ const profile = await this.record(userID, id)
567
+ if (!profile) return undefined
568
+ const found: VersionEntry[] = []
569
+ for (let n = profile.latestVersion + 1; ; n++) {
570
+ const slot = await this.state.get<AgentProfileVersion>(userID, versionCollection(id), String(n))
571
+ if (!slot) break
572
+ found.push({ version: n, digest: slot.value.digest, bytes: contentBytes(slot.value.content), createdAt: slot.value.createdAt, ...countsOf(slot.value.content) })
573
+ }
574
+ if (!found.length) return profile
575
+ return updateVersioned<ProfileRecord>(this.state, userID, PROFILES, id, (current) => {
576
+ if (!current) return undefined
577
+ const add = found.filter((f) => f.version > current.latestVersion)
578
+ if (!add.length) return undefined
579
+ return { ...current, history: [...current.history, ...add], latestVersion: add[add.length - 1]!.version, revision: current.revision + 1, updatedAt: this.now() }
580
+ }).then(async (written) => written ?? (await this.record(userID, id)))
581
+ }
582
+
583
+ /** Deletes pruned version records, except any a session pinned meanwhile (those go back into the history). */
584
+ private async dropVersions(userID: string, id: string, victims: readonly VersionEntry[]): Promise<void> {
585
+ if (!victims.length) return
586
+ const pinned = await this.pinnedVersions(userID, id)
587
+ const keep = victims.filter((v) => pinned.has(v.version))
588
+ if (keep.length) {
589
+ await updateVersioned<ProfileRecord>(this.state, userID, PROFILES, id, (current) =>
590
+ current ? { ...current, history: [...current.history, ...keep.filter((k) => !current.history.some((h) => h.version === k.version))].sort((a, b) => a.version - b.version) } : undefined,
591
+ )
592
+ }
593
+ for (const victim of victims) if (!pinned.has(victim.version)) await this.state.delete(userID, versionCollection(id), String(victim.version))
594
+ }
595
+ }
596
+
597
+ const notFound = (id: string) => new AgentError("not_found", `No profile ${id}`)