@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.
- package/CHANGELOG.md +229 -0
- package/Dockerfile +7 -2
- package/README.md +153 -24
- package/package.json +5 -3
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/daemon.ts +8 -1
- package/packages/claude-code/src/launcher.ts +29 -9
- package/packages/claude-code/src/profile.ts +173 -0
- package/packages/claude-code/src/sessions.ts +22 -4
- package/packages/cli/dist/hub-cli.js +6474 -3470
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
- package/packages/cli/src/artifacts-mcp.ts +160 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
- package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +95 -13
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +380 -9
- package/packages/cli/src/messaging.ts +3 -1
- package/packages/cli/src/setup/commands.ts +3 -2
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +403 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/profile.ts +163 -0
- package/packages/codex/src/sessions.ts +12 -0
- package/packages/codex/src/spoke.ts +19 -1
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +96 -2
- package/packages/node/src/opencode-warm.ts +325 -0
- package/packages/node/src/run.ts +27 -1
- package/packages/preset/package.json +1 -1
- package/packages/preset/src/profile.ts +34 -0
- package/packages/preset/src/spoke.ts +4 -0
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/artifacts.ts +142 -0
- package/packages/protocol/src/concierge.ts +366 -0
- package/packages/protocol/src/contracts.ts +104 -0
- package/packages/protocol/src/graph.ts +70 -0
- package/packages/protocol/src/hub-workspace.ts +201 -0
- package/packages/protocol/src/index.ts +169 -6
- package/packages/protocol/src/launch.ts +22 -0
- package/packages/protocol/src/profiles.ts +301 -0
- package/packages/protocol/src/teams.ts +204 -0
- 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/address.ts +50 -2
- package/packages/sdk/src/artifact-jobs.ts +194 -0
- package/packages/sdk/src/artifacts.ts +187 -0
- package/packages/sdk/src/client.ts +226 -5
- package/packages/sdk/src/concierge.ts +88 -0
- package/packages/sdk/src/errors.ts +3 -1
- package/packages/sdk/src/index.ts +5 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +548 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/index.ts +1 -0
- package/packages/spoke-sdk/src/profile-apply.ts +245 -0
- package/src/central/actions.ts +7 -0
- package/src/central/agent/acting-routes.ts +2 -1
- package/src/central/agent/acting-switch.ts +2 -1
- package/src/central/agent/acting.ts +215 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +17 -0
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +69 -2
- package/src/central/agent/delivery.ts +95 -15
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +5 -0
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +11 -3
- package/src/central/agent/form-check.ts +85 -0
- package/src/central/agent/grant-requests.ts +2 -1
- package/src/central/agent/grant-routes.ts +4 -3
- package/src/central/agent/grants.ts +29 -2
- package/src/central/agent/idempotency.ts +133 -0
- package/src/central/agent/intent-routes.ts +116 -0
- package/src/central/agent/intents.ts +505 -0
- package/src/central/agent/interaction-graph-routes.ts +212 -0
- package/src/central/agent/interaction-graph.ts +865 -0
- package/src/central/agent/launch-engine.ts +16 -2
- package/src/central/agent/launch-routes.ts +6 -1
- package/src/central/agent/launch-spec.ts +14 -1
- package/src/central/agent/message-routes.ts +2 -1
- package/src/central/agent/messages.ts +27 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/read-routes.ts +86 -19
- package/src/central/agent/routes.ts +176 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- package/src/central/agent-data.ts +4 -0
- package/src/central/artifacts/access.ts +44 -0
- package/src/central/artifacts/blob-store.ts +113 -0
- package/src/central/artifacts/blob-support.ts +98 -0
- package/src/central/artifacts/events.ts +27 -0
- package/src/central/artifacts/host-caps.ts +162 -0
- package/src/central/artifacts/host.ts +315 -0
- package/src/central/artifacts/idempotency.ts +109 -0
- package/src/central/artifacts/jobs-routes.ts +773 -0
- package/src/central/artifacts/jobs.ts +344 -0
- package/src/central/artifacts/maintenance.ts +103 -0
- package/src/central/artifacts/routes.ts +360 -0
- package/src/central/artifacts/store.ts +776 -0
- package/src/central/artifacts/workspaces.ts +31 -0
- package/src/central/backend/file-blobs.ts +377 -0
- package/src/central/backend/index.ts +12 -3
- package/src/central/backend/memory-blobs.ts +116 -0
- package/src/central/backend/postgres-blobs.ts +513 -0
- package/src/central/backend/postgres-migrate.ts +33 -0
- package/src/central/backend/postgres-state.ts +19 -0
- package/src/central/backend/types.ts +8 -0
- package/src/central/concierge/activity.ts +142 -0
- package/src/central/concierge/arm.ts +63 -0
- package/src/central/concierge/board-render.ts +190 -0
- package/src/central/concierge/board-results.ts +169 -0
- package/src/central/concierge/board-routes.ts +339 -0
- package/src/central/concierge/board.ts +249 -0
- package/src/central/concierge/context.ts +221 -0
- package/src/central/concierge/entitlements.ts +13 -0
- package/src/central/concierge/guard.ts +120 -0
- package/src/central/concierge/inbound.ts +89 -0
- package/src/central/concierge/index.ts +84 -0
- package/src/central/concierge/notes.ts +41 -0
- package/src/central/concierge/notifier.ts +986 -0
- package/src/central/concierge/prompt.ts +130 -0
- package/src/central/concierge/proposals.ts +262 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +62 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +69 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +740 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +277 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +7 -1
- package/src/central/http.ts +633 -50
- package/src/central/mcp-http.ts +10 -21
- package/src/central/profiles/apply.ts +333 -0
- package/src/central/profiles/content.ts +174 -0
- package/src/central/profiles/events.ts +40 -0
- package/src/central/profiles/idempotency.ts +14 -0
- package/src/central/profiles/import.ts +139 -0
- package/src/central/profiles/routes.ts +356 -0
- package/src/central/profiles/store.ts +597 -0
- package/src/central/profiles/usage-ports.ts +120 -0
- package/src/central/profiles/usage.ts +364 -0
- package/src/central/push.ts +7 -0
- package/src/central/registry.ts +43 -6
- package/src/central/store.ts +4 -0
- package/src/central/teams/collections.ts +34 -0
- package/src/central/teams/discovery.ts +346 -0
- package/src/central/teams/draft-edits.ts +46 -0
- package/src/central/teams/events.ts +46 -0
- package/src/central/teams/intake-routes.ts +100 -0
- package/src/central/teams/intake.ts +772 -0
- package/src/central/teams/policy.ts +231 -0
- package/src/central/teams/routes.ts +516 -0
- package/src/central/teams/seats.ts +272 -0
- package/src/central/teams/staffing.ts +888 -0
- package/src/central/teams/store.ts +667 -0
- package/src/central/teams/validation.ts +256 -0
- package/src/central/workspaces/board.ts +408 -0
- package/src/central/workspaces/checkpoints.ts +316 -0
- package/src/central/workspaces/coordinator.ts +841 -0
- package/src/central/workspaces/events.ts +85 -0
- package/src/central/workspaces/lifecycle.ts +93 -0
- package/src/central/workspaces/master.ts +490 -0
- package/src/central/workspaces/members.ts +632 -0
- package/src/central/workspaces/routes.ts +354 -0
- package/src/central/workspaces/sidechats-routes.ts +410 -0
- package/src/central/workspaces/sidechats.ts +482 -0
- package/src/central/workspaces/store.ts +571 -0
- package/src/central/workspaces/supervision.ts +355 -0
- package/src/index.ts +97 -15
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +383 -18
- package/src/spoke/listing.ts +47 -4
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +176 -0
- package/src/spoke/spoke.ts +319 -20
- package/src/spoke/timeline.ts +6 -1
- package/src/spoke/tracker.ts +23 -1
- package/src/spoke/who-teams.ts +81 -0
- package/src/tui-messaging.ts +32 -18
- package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
- package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
- package/web/dist/assets/Library-CtxRqTjo.js +2 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -0
- package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
- package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
- package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
- package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
- package/web/dist/assets/index-DaH5GUdV.js +31 -0
- package/web/dist/assets/index-WWuKfWxL.css +1 -0
- package/web/dist/assets/live-BUofsQpz.css +1 -0
- package/web/dist/assets/live-YyTjE47g.js +5 -0
- package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
- package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
- package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
- package/web/dist/assets/source-CvUHTc3O.js +1 -0
- package/web/dist/assets/ui-C9Igxzjm.js +1 -0
- package/web/dist/index.html +5 -2
- package/web/dist/sw.js +22 -2
- package/web/dist/assets/index-Dm8OmkaO.css +0 -1
- package/web/dist/assets/index-FUJbj_Du.js +0 -20
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Importing a skills/commands bundle into profile content (HWA-14). A bundle is a bounded list of text files
|
|
2
|
+
// {path, text}, in the layout harnesses already use:
|
|
3
|
+
//
|
|
4
|
+
// AGENTS.md or CLAUDE.md → instructions (at most one)
|
|
5
|
+
// skills/<name>/SKILL.md → a skill (front matter `description`; `name` must match the folder)
|
|
6
|
+
// commands/<name>.md → a command (front matter `description`; `$ARGUMENTS` → `{{arguments}}`)
|
|
7
|
+
//
|
|
8
|
+
// Anything else (scripts, hook folders, package/requirements manifests, harness settings) is `unsupported`:
|
|
9
|
+
// profiles never carry code to run or dependencies to install. Front matter that would grant tools
|
|
10
|
+
// (`allowed-tools`) or pick a model is dropped with a note: a profile confers no authority. Text only; nothing is
|
|
11
|
+
// fetched, executed or written to disk. The result goes through the same content rules as any version.
|
|
12
|
+
import { isSlug, PROFILE_LIMITS, type ProfileCommand, type ProfileSkill, type ProfileVersionContent } from "../../protocol"
|
|
13
|
+
import { checkContent, ProfileContentError } from "./content"
|
|
14
|
+
|
|
15
|
+
export const IMPORT_LIMITS = {
|
|
16
|
+
/** One instructions file, every skill and every command. */
|
|
17
|
+
files: 1 + PROFILE_LIMITS.skillsPerProfile + PROFILE_LIMITS.commandsPerProfile,
|
|
18
|
+
pathChars: 200,
|
|
19
|
+
/** Total bytes of the files' text. */
|
|
20
|
+
bytes: PROFILE_LIMITS.versionBytes,
|
|
21
|
+
notes: 64,
|
|
22
|
+
} as const
|
|
23
|
+
|
|
24
|
+
export interface BundleFile {
|
|
25
|
+
readonly path: string
|
|
26
|
+
readonly text: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ImportResult {
|
|
30
|
+
readonly content: ProfileVersionContent
|
|
31
|
+
/** What was dropped or defaulted, per file; bounded, never file text. */
|
|
32
|
+
readonly notes: string[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const INSTRUCTION_FILES = new Set(["AGENTS.md", "CLAUDE.md"])
|
|
36
|
+
const SKILL_PATH = /^skills\/([^/]+)\/SKILL\.md$/
|
|
37
|
+
const COMMAND_PATH = /^commands\/([^/]+)\.md$/
|
|
38
|
+
const SAFE_PATH = /^[A-Za-z0-9._/-]+$/
|
|
39
|
+
|
|
40
|
+
const invalid = (message: string) => new ProfileContentError("invalid", message)
|
|
41
|
+
const shown = (path: string) => (SAFE_PATH.test(path) && path.length <= IMPORT_LIMITS.pathChars ? path : "(unprintable path)")
|
|
42
|
+
|
|
43
|
+
function checkPath(path: string): void {
|
|
44
|
+
if (!path || path.length > IMPORT_LIMITS.pathChars) throw invalid(`file paths must be 1–${IMPORT_LIMITS.pathChars} characters`)
|
|
45
|
+
if (path.includes("\\") || path.startsWith("/") || path.split("/").some((s) => s === "" || s === "." || s === "..")) {
|
|
46
|
+
throw invalid(`${shown(path)}: paths must be relative, with / separators and no . or .. segments`)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface FrontMatter {
|
|
51
|
+
readonly fields: Record<string, string>
|
|
52
|
+
readonly body: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** `---\nkey: value\n---\nbody`; text without front matter is all body. */
|
|
56
|
+
function frontMatter(text: string): FrontMatter {
|
|
57
|
+
if (!text.startsWith("---\n")) return { fields: {}, body: text }
|
|
58
|
+
const end = text.indexOf("\n---", 3)
|
|
59
|
+
if (end < 0) return { fields: {}, body: text }
|
|
60
|
+
const after = text.indexOf("\n", end + 4)
|
|
61
|
+
const body = after < 0 ? "" : text.slice(after + 1)
|
|
62
|
+
const fields: Record<string, string> = {}
|
|
63
|
+
for (const line of text.slice(4, end).split("\n")) {
|
|
64
|
+
const match = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line)
|
|
65
|
+
if (!match) continue
|
|
66
|
+
fields[match[1]!.toLowerCase()] = match[2]!.trim().replace(/^(["'])(.*)\1$/, "$2")
|
|
67
|
+
}
|
|
68
|
+
return { fields, body }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function ignored(path: string, fields: Record<string, string>, keep: readonly string[], notes: string[]): void {
|
|
72
|
+
for (const key of Object.keys(fields)) {
|
|
73
|
+
if (keep.includes(key)) continue
|
|
74
|
+
if (key === "allowed-tools") notes.push(`${path}: allowed-tools ignored (profiles grant no tool permissions)`)
|
|
75
|
+
else notes.push(`${path}: ${key} ignored`)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function describe(path: string, fields: Record<string, string>, fallback: string, notes: string[]): string {
|
|
80
|
+
const description = fields.description?.trim()
|
|
81
|
+
if (description) return description
|
|
82
|
+
notes.push(`${path}: no description; using a default`)
|
|
83
|
+
return fallback
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Converts a bundle to normalized version content; throws ProfileContentError (invalid / unsupported). */
|
|
87
|
+
export function importBundle(files: unknown): ImportResult {
|
|
88
|
+
if (!Array.isArray(files)) throw invalid("files must be a list of {path, text}")
|
|
89
|
+
if (files.length > IMPORT_LIMITS.files) throw invalid(`at most ${IMPORT_LIMITS.files} files`)
|
|
90
|
+
let bytes = 0
|
|
91
|
+
const seen = new Set<string>()
|
|
92
|
+
for (const file of files) {
|
|
93
|
+
if (typeof file !== "object" || file === null || Array.isArray(file)) throw invalid("each file must be {path, text}")
|
|
94
|
+
const { path, text, ...rest } = file as Record<string, unknown>
|
|
95
|
+
if (typeof path !== "string" || typeof text !== "string" || Object.keys(rest).length) throw invalid("each file must be {path, text} with text content")
|
|
96
|
+
checkPath(path)
|
|
97
|
+
if (seen.has(path)) throw invalid(`${shown(path)} appears twice`)
|
|
98
|
+
seen.add(path)
|
|
99
|
+
bytes += new TextEncoder().encode(text).length
|
|
100
|
+
}
|
|
101
|
+
if (bytes > IMPORT_LIMITS.bytes) throw invalid(`bundle is too large (more than ${IMPORT_LIMITS.bytes} bytes)`)
|
|
102
|
+
|
|
103
|
+
let instructions: string | undefined
|
|
104
|
+
const skills: ProfileSkill[] = []
|
|
105
|
+
const commands: ProfileCommand[] = []
|
|
106
|
+
const notes: string[] = []
|
|
107
|
+
for (const file of files as BundleFile[]) {
|
|
108
|
+
const { path } = file
|
|
109
|
+
const text = file.text.replace(/\r\n?/g, "\n")
|
|
110
|
+
if (INSTRUCTION_FILES.has(path)) {
|
|
111
|
+
if (instructions !== undefined) throw invalid("only one of AGENTS.md and CLAUDE.md")
|
|
112
|
+
instructions = text
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
const skill = SKILL_PATH.exec(path)
|
|
116
|
+
const command = COMMAND_PATH.exec(path)
|
|
117
|
+
if (skill || command) {
|
|
118
|
+
const name = (skill ?? command)![1]!
|
|
119
|
+
if (!isSlug(name)) throw invalid(`${shown(path)}: names must be 2–40 lowercase letters, digits or dashes`)
|
|
120
|
+
const { fields, body } = frontMatter(text)
|
|
121
|
+
if (skill) {
|
|
122
|
+
if (fields.name !== undefined && fields.name !== name) notes.push(`${path}: name ${isSlug(fields.name) ? fields.name : "(invalid)"} differs from its folder; using ${name}`)
|
|
123
|
+
ignored(path, fields, ["name", "description"], notes)
|
|
124
|
+
skills.push({ name, description: describe(path, fields, `Skill ${name}`, notes), body })
|
|
125
|
+
} else {
|
|
126
|
+
ignored(path, fields, ["description"], notes)
|
|
127
|
+
if (/\$[1-9]/.test(body)) notes.push(`${path}: positional $1…$9 are kept as text; use {{name}} parameters`)
|
|
128
|
+
commands.push({ name, description: describe(path, fields, `Command ${name}`, notes), template: body.replaceAll("$ARGUMENTS", "{{arguments}}") })
|
|
129
|
+
}
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
throw new ProfileContentError(
|
|
133
|
+
"unsupported",
|
|
134
|
+
`${shown(path)}: only AGENTS.md or CLAUDE.md, skills/<name>/SKILL.md and commands/<name>.md are imported; profiles carry no scripts, hooks, settings or dependency manifests`,
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
const content = checkContent({ ...(instructions !== undefined ? { instructions } : {}), skills, commands })
|
|
138
|
+
return { content, notes: notes.slice(0, IMPORT_LIMITS.notes) }
|
|
139
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
// The profile registry on /api/agent (HWA-14; contract: Profiles in docs/plans/2026-10-30-hub-workspaces-artifacts-
|
|
2
|
+
// contracts.md):
|
|
3
|
+
//
|
|
4
|
+
// POST /profiles {name, title, description?, tags?, content} manage
|
|
5
|
+
// POST /profiles/import {name, title, description?, tags?, files} manage
|
|
6
|
+
// GET /profiles?state=active|archived|all&limit&cursor read
|
|
7
|
+
// GET /profiles?include=usage,summary + {usage: {id → counts}, hidden?, summaries: {id → latest counts}} (RP-08)
|
|
8
|
+
// GET /profiles/:id → {profile, latest} (latest: version summary) read
|
|
9
|
+
// GET /profiles/:id/usage?limit&cursor where it is used (usage.ts; per-category scopes) read
|
|
10
|
+
// PATCH /profiles/:id {expectedRevision, title?, description?, tags?, state?, metadata?, projects?} manage
|
|
11
|
+
// GET /profiles/:id/versions?limit&cursor the retained history, newest first read
|
|
12
|
+
// POST /profiles/:id/versions {expectedRevision, content | fromVersion | files} manage
|
|
13
|
+
// GET /profiles/:id/versions/:version the immutable version in full read
|
|
14
|
+
// GET /profiles/:id/versions/:version/skills/:name one skill body (on-demand loading) read
|
|
15
|
+
// POST /profiles/:id/versions/:version/commands/:name/render {params} → {prompt, secretRefs} read
|
|
16
|
+
// GET /sessions/:addr/profile {assignment, profile, version summary} | {assignment: null}
|
|
17
|
+
// PUT /sessions/:addr/profile {profileID, version, expectedRevision} assign
|
|
18
|
+
// DELETE /sessions/:addr/profile?expectedRevision=n assign
|
|
19
|
+
// GET /sessions/:addr/profile/skills/:name the pinned version's skill
|
|
20
|
+
// POST /sessions/:addr/profile/commands/:name/render the pinned version's command, rendered
|
|
21
|
+
//
|
|
22
|
+
// Who: users, always (their own records; another user's are not_found). App tokens by scope: profiles:read,
|
|
23
|
+
// profiles:manage, profiles:assign; session-addressed routes also need sessions:read (resolving an address
|
|
24
|
+
// reveals a session). Session tokens only read their OWN pinned profile (summary, skills, command rendering);
|
|
25
|
+
// they never list, create, version, archive or assign. Rendering returns text and confers nothing: sending it
|
|
26
|
+
// to a session goes through the existing prompt verb and its grants. Assigning pins a version; it grants no
|
|
27
|
+
// authority and does not by itself change a running session (HWA-15 applies pins through the spoke). An app's pin
|
|
28
|
+
// also needs a usable prompt grant on the session (authorizePin, HWA-15); the answer's ack is `pending` until the
|
|
29
|
+
// spoke acknowledges, `unsupported` or `pending` + `offline` (see apply.ts). Unpinning stops re-applying the pin;
|
|
30
|
+
// what a running session already applied stays until it restarts (no clear op in v1).
|
|
31
|
+
// Creates accept Idempotency-Key. Every change is in the admin audit (IDs only) and the journal (compact events).
|
|
32
|
+
import { type AgentProfileVersion, PROFILE_SCOPES } from "../../protocol"
|
|
33
|
+
import type { AgentContext, AgentRoute } from "../agent/routes"
|
|
34
|
+
import type { AgentEvents, EventInput } from "../agent/events"
|
|
35
|
+
import { AgentError } from "../agent/errors"
|
|
36
|
+
import type { Store } from "../store"
|
|
37
|
+
import { ProfileContentError, parseTemplate, renderTemplate, secretReferences, contentBytes } from "./content"
|
|
38
|
+
import { profileArchivedEvent, profileAssignedEvent, profileCreatedEvent, profileVersionEvent } from "./events"
|
|
39
|
+
import { Idempotency } from "./idempotency"
|
|
40
|
+
import type { PinAuthorizer } from "./apply"
|
|
41
|
+
import { importBundle } from "./import"
|
|
42
|
+
import { contentError, type ProfileStore } from "./store"
|
|
43
|
+
import { boundedText, PROFILE_JSON_BODY_MAX } from "../agent/body"
|
|
44
|
+
import { ProfileUsage, usageView } from "./usage"
|
|
45
|
+
|
|
46
|
+
const PREFIX = "/api/agent"
|
|
47
|
+
const NO_STORE = { "cache-control": "no-store" }
|
|
48
|
+
const [READ_SCOPE, MANAGE_SCOPE, ASSIGN_SCOPE] = PROFILE_SCOPES
|
|
49
|
+
const READ = { scope: READ_SCOPE } as const
|
|
50
|
+
const MANAGE = { scope: MANAGE_SCOPE } as const
|
|
51
|
+
const ASSIGN = { scope: ASSIGN_SCOPE } as const
|
|
52
|
+
/** Session-addressed reads: sessions (their own only) and apps with profiles:read (+ sessions:read). */
|
|
53
|
+
const SESSION_READ = { session: true, scope: READ_SCOPE } as const
|
|
54
|
+
|
|
55
|
+
export interface ProfileRoutesDeps {
|
|
56
|
+
readonly profiles: ProfileStore
|
|
57
|
+
readonly directory: { resolveOne(userID: string, addr: string): { readonly sessionID: string } }
|
|
58
|
+
readonly events?: Pick<AgentEvents, "append">
|
|
59
|
+
readonly audit?: Pick<Store, "audit">
|
|
60
|
+
/** Idempotent creates; one over the profiles' StateStore when absent. */
|
|
61
|
+
readonly idempotency?: Idempotency
|
|
62
|
+
/**
|
|
63
|
+
* (HWA-15) Checks an app's pin beyond its scopes (pinAuthorizer: a usable prompt grant on the session). Without
|
|
64
|
+
* it apps cannot pin at all (fail closed); users always can.
|
|
65
|
+
*/
|
|
66
|
+
readonly authorizePin?: PinAuthorizer
|
|
67
|
+
/** (RP-08) Where profiles are used; without it every category reads as unused. */
|
|
68
|
+
readonly usage?: ProfileUsage
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A version without its bodies: what clients list before loading a skill on demand. */
|
|
72
|
+
export function versionSummary(version: AgentProfileVersion) {
|
|
73
|
+
const { content } = version
|
|
74
|
+
return {
|
|
75
|
+
profileID: version.profileID,
|
|
76
|
+
version: version.version,
|
|
77
|
+
digest: version.digest,
|
|
78
|
+
createdAt: version.createdAt,
|
|
79
|
+
bytes: contentBytes(content),
|
|
80
|
+
instructions: content.instructions !== undefined,
|
|
81
|
+
...(content.model !== undefined ? { model: content.model } : {}),
|
|
82
|
+
skills: content.skills.map(({ name, description }) => ({ name, description })),
|
|
83
|
+
commands: content.commands.map(({ name, description, template }) => {
|
|
84
|
+
const parsed = parseTemplate(template)
|
|
85
|
+
return { name, description, params: parsed.ok ? parsed.params : [] }
|
|
86
|
+
}),
|
|
87
|
+
secretRefs: secretReferences(content),
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const ok = (body: unknown, status = 200) => Response.json(body, { status, headers: NO_STORE })
|
|
92
|
+
|
|
93
|
+
async function bodyOf(request: Request): Promise<Record<string, unknown>> {
|
|
94
|
+
const raw = JSON.parse(await boundedText(request, PROFILE_JSON_BODY_MAX)) as unknown
|
|
95
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new AgentError("invalid", "Body must be a JSON object")
|
|
96
|
+
return raw as Record<string, unknown>
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function only(body: Record<string, unknown>, allowed: readonly string[]): void {
|
|
100
|
+
for (const key of Object.keys(body)) if (!allowed.includes(key)) throw new AgentError("invalid", `unknown field ${/^[A-Za-z0-9_-]{1,40}$/.test(key) ? key : "(unprintable)"}`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function versionParam(raw: string | undefined): number {
|
|
104
|
+
if (!raw || !/^[1-9][0-9]{0,8}$/.test(raw)) throw new AgentError("invalid", "version must be a version number")
|
|
105
|
+
return Number(raw)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function intParam(url: URL, name: string): number | undefined {
|
|
109
|
+
const raw = url.searchParams.get(name)
|
|
110
|
+
return raw === null ? undefined : /^-?[0-9]{1,12}$/.test(raw) ? Number(raw) : Number.NaN
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function importContent(files: unknown) {
|
|
114
|
+
try {
|
|
115
|
+
return importBundle(files)
|
|
116
|
+
} catch (cause) {
|
|
117
|
+
throw cause instanceof ProfileContentError ? contentError(cause) : cause
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function render(version: AgentProfileVersion, name: string, body: Record<string, unknown>) {
|
|
122
|
+
only(body, ["params"])
|
|
123
|
+
const params = body.params ?? {}
|
|
124
|
+
if (typeof params !== "object" || params === null || Array.isArray(params)) throw new AgentError("invalid", "params must be an object of text values")
|
|
125
|
+
const command = version.content.commands.find((c) => c.name === name)
|
|
126
|
+
if (!command) throw new AgentError("not_found", `No command ${name} in version ${version.version}`)
|
|
127
|
+
let prompt: string
|
|
128
|
+
try {
|
|
129
|
+
prompt = renderTemplate(command.template, params as Record<string, unknown>)
|
|
130
|
+
} catch (cause) {
|
|
131
|
+
throw contentError(cause)
|
|
132
|
+
}
|
|
133
|
+
const parsed = parseTemplate(command.template)
|
|
134
|
+
return { prompt, secretRefs: parsed.ok ? parsed.secrets : [], profileID: version.profileID, version: version.version, command: name }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function skillOf(version: AgentProfileVersion, name: string) {
|
|
138
|
+
const skill = version.content.skills.find((s) => s.name === name)
|
|
139
|
+
if (!skill) throw new AgentError("not_found", `No skill ${name} in version ${version.version}`)
|
|
140
|
+
return { skill, profileID: version.profileID, version: version.version }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function profileRoutes(deps: ProfileRoutesDeps): AgentRoute[] {
|
|
144
|
+
const { profiles, directory } = deps
|
|
145
|
+
const idempotency = deps.idempotency ?? new Idempotency({ state: profiles.state })
|
|
146
|
+
const usage = deps.usage ?? new ProfileUsage({ profiles })
|
|
147
|
+
|
|
148
|
+
async function changed(context: AgentContext, action: string, target: string, event?: EventInput): Promise<void> {
|
|
149
|
+
await deps.audit?.audit({ actor: context.user.id, action, target, detail: context.actor.kind })
|
|
150
|
+
if (event) await deps.events?.append(context.user.id, event).catch((cause) => console.error(`[hub] ${event.type} event failed: ${(cause as Error).message}`))
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The session a session-addressed route names, for this caller. */
|
|
154
|
+
function sessionTarget(context: AgentContext): string {
|
|
155
|
+
const { actor, user } = context
|
|
156
|
+
if (actor.kind === "app" && !actor.scopes.includes("sessions:read")) throw new AgentError("forbidden", "Session profile routes also need scope sessions:read")
|
|
157
|
+
const sessionID = directory.resolveOne(user.id, context.params.addr!).sessionID
|
|
158
|
+
if (actor.kind === "session" && sessionID !== actor.sessionID) throw new AgentError("forbidden", "A session token can only read its own profile")
|
|
159
|
+
return sessionID
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function version(userID: string, id: string, number: number): Promise<AgentProfileVersion> {
|
|
163
|
+
const found = await profiles.version(userID, id, number)
|
|
164
|
+
if (!found) throw new AgentError("not_found", `No version ${number} of profile ${id}`)
|
|
165
|
+
return found
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The pinned version of the addressed session. */
|
|
169
|
+
async function pinned(context: AgentContext): Promise<AgentProfileVersion> {
|
|
170
|
+
const sessionID = sessionTarget(context)
|
|
171
|
+
const assignment = await profiles.assignment(context.user.id, sessionID)
|
|
172
|
+
if (!assignment) throw new AgentError("not_found", `Session ${sessionID} has no profile`)
|
|
173
|
+
return version(context.user.id, assignment.profileID, assignment.version)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const create = (path: string, input: (body: Record<string, unknown>) => { body: Record<string, unknown>; notes?: string[] }): AgentRoute => ({
|
|
177
|
+
method: "POST",
|
|
178
|
+
path,
|
|
179
|
+
allow: MANAGE,
|
|
180
|
+
async handle(context) {
|
|
181
|
+
const raw = await bodyOf(context.request)
|
|
182
|
+
return idempotency.run(context.user.id, `POST ${context.url.pathname}`, context.request.headers.get("idempotency-key"), raw, async () => {
|
|
183
|
+
const { body, notes } = input(raw)
|
|
184
|
+
const { profile, version } = await profiles.create(context.user.id, body)
|
|
185
|
+
await changed(context, "profile.create", profile.id, profileCreatedEvent(profile.id, profile.revision))
|
|
186
|
+
return { status: 201, body: { profile, version: versionSummary(version), ...(notes ? { notes } : {}) } }
|
|
187
|
+
})
|
|
188
|
+
},
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
return [
|
|
192
|
+
create(`${PREFIX}/profiles`, (body) => ({ body })),
|
|
193
|
+
create(`${PREFIX}/profiles/import`, (body) => {
|
|
194
|
+
only(body, ["name", "title", "description", "tags", "metadata", "projects", "files"])
|
|
195
|
+
const { files, ...rest } = body
|
|
196
|
+
const { content, notes } = importContent(files)
|
|
197
|
+
return { body: { ...rest, content }, notes }
|
|
198
|
+
}),
|
|
199
|
+
{
|
|
200
|
+
method: "GET",
|
|
201
|
+
path: `${PREFIX}/profiles`,
|
|
202
|
+
allow: READ,
|
|
203
|
+
async handle({ url, user, actor }) {
|
|
204
|
+
const limit = intParam(url, "limit")
|
|
205
|
+
const state = url.searchParams.get("state") ?? undefined
|
|
206
|
+
const cursor = url.searchParams.get("cursor") ?? undefined
|
|
207
|
+
const include = url.searchParams.getAll("include").flatMap((i) => i.split(","))
|
|
208
|
+
if (include.some((i) => i !== "usage" && i !== "summary")) throw new AgentError("invalid", "include must be usage and/or summary")
|
|
209
|
+
const page = await profiles.list(user.id, { ...(limit !== undefined ? { limit } : {}), ...(state ? { state } : {}), ...(cursor ? { cursor } : {}) })
|
|
210
|
+
return ok({
|
|
211
|
+
...page,
|
|
212
|
+
...(include.includes("summary") ? { summaries: await profiles.summaries(user.id, page.items.map((p) => p.id)) } : {}),
|
|
213
|
+
...(include.includes("usage") ? await usage.counts(user.id, page.items, usageView(actor)) : {}),
|
|
214
|
+
})
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
method: "GET",
|
|
219
|
+
path: `${PREFIX}/profiles/:id/usage`,
|
|
220
|
+
allow: READ,
|
|
221
|
+
async handle({ params, url, user, actor }) {
|
|
222
|
+
const limit = intParam(url, "limit")
|
|
223
|
+
const cursor = url.searchParams.get("cursor") ?? undefined
|
|
224
|
+
return ok(await usage.usage(user.id, params.id!, usageView(actor), { ...(limit !== undefined ? { limit } : {}), ...(cursor !== undefined ? { cursor } : {}) }))
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
method: "GET",
|
|
229
|
+
path: `${PREFIX}/profiles/:id`,
|
|
230
|
+
allow: READ,
|
|
231
|
+
async handle({ params, user }) {
|
|
232
|
+
const profile = await profiles.get(user.id, params.id!)
|
|
233
|
+
if (!profile) throw new AgentError("not_found", `No profile ${params.id}`)
|
|
234
|
+
const latest = await profiles.version(user.id, profile.id, profile.latestVersion)
|
|
235
|
+
return ok({ profile, ...(latest ? { latest: versionSummary(latest) } : {}) })
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
method: "PATCH",
|
|
240
|
+
path: `${PREFIX}/profiles/:id`,
|
|
241
|
+
allow: MANAGE,
|
|
242
|
+
async handle(context) {
|
|
243
|
+
const { before, after } = await profiles.updateWithPrior(context.user.id, context.params.id!, await bodyOf(context.request))
|
|
244
|
+
const event = before.state !== after.state ? profileArchivedEvent(after.id, after.state === "archived", after.revision) : undefined
|
|
245
|
+
await changed(context, "profile.update", after.id, event)
|
|
246
|
+
return ok({ profile: after })
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
method: "GET",
|
|
251
|
+
path: `${PREFIX}/profiles/:id/versions`,
|
|
252
|
+
allow: READ,
|
|
253
|
+
async handle({ params, url, user }) {
|
|
254
|
+
const limit = intParam(url, "limit")
|
|
255
|
+
const cursor = url.searchParams.get("cursor") ?? undefined
|
|
256
|
+
return ok(await profiles.history(user.id, params.id!, { ...(limit !== undefined ? { limit } : {}), ...(cursor ? { cursor } : {}) }))
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
method: "POST",
|
|
261
|
+
path: `${PREFIX}/profiles/:id/versions`,
|
|
262
|
+
allow: MANAGE,
|
|
263
|
+
async handle(context) {
|
|
264
|
+
const raw = await bodyOf(context.request)
|
|
265
|
+
return idempotency.run(context.user.id, `POST ${context.url.pathname}`, context.request.headers.get("idempotency-key"), raw, async () => {
|
|
266
|
+
only(raw, ["expectedRevision", "content", "fromVersion", "files"])
|
|
267
|
+
const sources = ["content", "fromVersion", "files"].filter((k) => raw[k] !== undefined)
|
|
268
|
+
if (sources.length !== 1) throw new AgentError("invalid", "Send exactly one of content, fromVersion or files")
|
|
269
|
+
const imported = raw.files !== undefined ? importContent(raw.files) : undefined
|
|
270
|
+
const { profile, version } = await profiles.addVersion(context.user.id, context.params.id!, {
|
|
271
|
+
expectedRevision: raw.expectedRevision,
|
|
272
|
+
...(imported ? { content: imported.content } : raw.content !== undefined ? { content: raw.content } : { fromVersion: raw.fromVersion }),
|
|
273
|
+
})
|
|
274
|
+
await changed(context, "profile.version", profile.id, profileVersionEvent(profile.id, version.version, profile.revision))
|
|
275
|
+
return { status: 201, body: { profile, version: versionSummary(version), ...(imported ? { notes: imported.notes } : {}) } }
|
|
276
|
+
})
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
method: "GET",
|
|
281
|
+
path: `${PREFIX}/profiles/:id/versions/:version`,
|
|
282
|
+
allow: READ,
|
|
283
|
+
handle: async ({ params, user }) => ok({ version: await version(user.id, params.id!, versionParam(params.version)) }),
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
method: "GET",
|
|
287
|
+
path: `${PREFIX}/profiles/:id/versions/:version/skills/:name`,
|
|
288
|
+
allow: READ,
|
|
289
|
+
handle: async ({ params, user }) => ok(skillOf(await version(user.id, params.id!, versionParam(params.version)), params.name!)),
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
method: "POST",
|
|
293
|
+
path: `${PREFIX}/profiles/:id/versions/:version/commands/:name/render`,
|
|
294
|
+
allow: READ,
|
|
295
|
+
async handle({ params, user, request }) {
|
|
296
|
+
const body = await bodyOf(request)
|
|
297
|
+
return ok(render(await version(user.id, params.id!, versionParam(params.version)), params.name!, body))
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
method: "GET",
|
|
302
|
+
path: `${PREFIX}/sessions/:addr/profile`,
|
|
303
|
+
allow: SESSION_READ,
|
|
304
|
+
async handle(context) {
|
|
305
|
+
const sessionID = sessionTarget(context)
|
|
306
|
+
const assignment = await profiles.assignment(context.user.id, sessionID)
|
|
307
|
+
if (!assignment) return ok({ assignment: null })
|
|
308
|
+
const [profile, pinnedVersion] = await Promise.all([profiles.get(context.user.id, assignment.profileID), profiles.version(context.user.id, assignment.profileID, assignment.version)])
|
|
309
|
+
return ok({ assignment, ...(profile ? { profile } : {}), ...(pinnedVersion ? { version: versionSummary(pinnedVersion) } : {}) })
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
method: "PUT",
|
|
314
|
+
path: `${PREFIX}/sessions/:addr/profile`,
|
|
315
|
+
allow: ASSIGN,
|
|
316
|
+
async handle(context) {
|
|
317
|
+
const sessionID = sessionTarget(context)
|
|
318
|
+
const body = await bodyOf(context.request)
|
|
319
|
+
only(body, ["profileID", "version", "expectedRevision"])
|
|
320
|
+
if (context.actor.kind !== "user") {
|
|
321
|
+
if (!deps.authorizePin) throw new AgentError("forbidden", "This hub does not let apps pin profiles")
|
|
322
|
+
await deps.authorizePin(context.actor, context.user.id, sessionID)
|
|
323
|
+
}
|
|
324
|
+
const assignment = await profiles.assign(context.user.id, sessionID, body)
|
|
325
|
+
await changed(context, "profile.assign", sessionID, profileAssignedEvent(sessionID, assignment.profileID, assignment.version, assignment.revision))
|
|
326
|
+
return ok({ assignment })
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
method: "DELETE",
|
|
331
|
+
path: `${PREFIX}/sessions/:addr/profile`,
|
|
332
|
+
allow: ASSIGN,
|
|
333
|
+
async handle(context) {
|
|
334
|
+
const sessionID = sessionTarget(context)
|
|
335
|
+
await profiles.unassign(context.user.id, sessionID, intParam(context.url, "expectedRevision"))
|
|
336
|
+
await changed(context, "profile.unassign", sessionID)
|
|
337
|
+
return new Response(null, { status: 204, headers: NO_STORE })
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
method: "GET",
|
|
342
|
+
path: `${PREFIX}/sessions/:addr/profile/skills/:name`,
|
|
343
|
+
allow: SESSION_READ,
|
|
344
|
+
handle: async (context) => ok(skillOf(await pinned(context), context.params.name!)),
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
method: "POST",
|
|
348
|
+
path: `${PREFIX}/sessions/:addr/profile/commands/:name/render`,
|
|
349
|
+
allow: SESSION_READ,
|
|
350
|
+
async handle(context) {
|
|
351
|
+
const body = await bodyOf(context.request)
|
|
352
|
+
return ok(render(await pinned(context), context.params.name!, body))
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
]
|
|
356
|
+
}
|