@k2b/cloud 0.6.0-rc.0 → 0.7.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 (315) hide show
  1. package/README.md +2 -2
  2. package/package.json +30 -17
  3. package/scripts/browser-performance.ts +14 -0
  4. package/scripts/build-pdf-renderer.ts +39 -0
  5. package/scripts/build.ts +15 -35
  6. package/scripts/preload.ts +9 -12
  7. package/scripts/sync-recovery-smoke.ts +2 -1
  8. package/src/_internal/app-presentation.ts +14 -2
  9. package/src/_internal/capabilities.ts +108 -7
  10. package/src/_internal/capability-streams.ts +81 -0
  11. package/src/_internal/define-app.ts +62 -36
  12. package/src/_internal/heartbeat.ts +4 -0
  13. package/src/_internal/help-catalog.ts +27 -172
  14. package/src/_internal/help.ts +13 -76
  15. package/src/_internal/page-responses.ts +2 -0
  16. package/src/_internal/postgres-application-name.ts +30 -0
  17. package/src/_internal/process-identity.ts +15 -0
  18. package/src/_internal/process-sync.ts +1 -1
  19. package/src/_internal/registry-validation.ts +29 -25
  20. package/src/_internal/registry.ts +2 -53
  21. package/src/_internal/runtime-context.ts +5 -16
  22. package/src/_internal/server-timing.ts +13 -0
  23. package/src/_internal/status-preserving-ssr.ts +18 -6
  24. package/src/_internal/web-vitals-asset.ts +10 -0
  25. package/src/access/PermissionEditor.tsx +14 -153
  26. package/src/access/PrincipalPicker.tsx +104 -0
  27. package/src/access/ui.ts +2 -0
  28. package/src/ai/admin.ts +14 -13
  29. package/src/ai/assistant-models.ts +12 -1
  30. package/src/ai/audio-format.ts +34 -0
  31. package/src/ai/audio-tool.ts +90 -0
  32. package/src/ai/browser-code-contracts.ts +79 -0
  33. package/src/ai/browser.ts +2 -0
  34. package/src/ai/capabilities.ts +55 -36
  35. package/src/ai/capability-execution.ts +47 -12
  36. package/src/ai/chat/blocks.tsx +157 -95
  37. package/src/ai/chat/capability-result.ts +15 -0
  38. package/src/ai/chat/capability-table.tsx +2 -10
  39. package/src/ai/chat/composer-adapter.ts +48 -3
  40. package/src/ai/chat/message-actions.tsx +8 -7
  41. package/src/ai/chat/message-utils.ts +12 -1
  42. package/src/ai/chat/messages.ts +2 -0
  43. package/src/ai/chat/presentation.tsx +19 -48
  44. package/src/ai/chat/primitives.tsx +43 -1
  45. package/src/ai/chat/tool-disclosure.tsx +17 -2
  46. package/src/ai/chat/tool-groups.ts +52 -0
  47. package/src/ai/chat-quotas.ts +25 -0
  48. package/src/ai/chat-task-contracts.ts +9 -1
  49. package/src/ai/chat-tasks.ts +276 -43
  50. package/src/ai/client/controller.ts +110 -33
  51. package/src/ai/client/file-source.ts +12 -6
  52. package/src/ai/client/live-connection.ts +1 -0
  53. package/src/ai/client/projection.ts +22 -11
  54. package/src/ai/client/transport.ts +4 -0
  55. package/src/ai/code-capability-routes.ts +110 -0
  56. package/src/ai/code-capability-transport.ts +38 -0
  57. package/src/ai/code-mode-skill.ts +114 -0
  58. package/src/ai/code-runtime-tools.ts +166 -0
  59. package/src/ai/code-source-contracts.ts +202 -0
  60. package/src/ai/code-source-tools.ts +145 -0
  61. package/src/ai/compaction.ts +27 -6
  62. package/src/ai/data-analysis-skill.ts +10 -0
  63. package/src/ai/default-tools.ts +25 -2
  64. package/src/ai/dictation-runtime.ts +171 -0
  65. package/src/ai/dictations.ts +188 -0
  66. package/src/ai/draft-content.ts +34 -0
  67. package/src/ai/enrich.ts +6 -0
  68. package/src/ai/executor.ts +191 -31
  69. package/src/ai/file-content-version.ts +22 -0
  70. package/src/ai/file-context.ts +2 -1
  71. package/src/ai/file-media-type.ts +12 -0
  72. package/src/ai/file-reference-contracts.ts +15 -0
  73. package/src/ai/file-tools.ts +2 -1
  74. package/src/ai/files-store.ts +174 -71
  75. package/src/ai/fixtures/vision-encrypted.pdf +0 -0
  76. package/src/ai/grids-skill.ts +40 -10
  77. package/src/ai/http.ts +15 -8
  78. package/src/ai/index.ts +26 -2
  79. package/src/ai/inference-calls.ts +198 -0
  80. package/src/ai/live-events.ts +5 -1
  81. package/src/ai/memory-learning.ts +60 -28
  82. package/src/ai/message-queue.ts +167 -0
  83. package/src/ai/migrate.ts +187 -11
  84. package/src/ai/model-pricing.ts +43 -0
  85. package/src/ai/pdf-render-worker.ts +74 -0
  86. package/src/ai/pdf-render.fixture.ts +28 -0
  87. package/src/ai/pdf-render.ts +77 -0
  88. package/src/ai/projects-routes.ts +3 -3
  89. package/src/ai/projects.ts +20 -6
  90. package/src/ai/protocol.ts +3 -0
  91. package/src/ai/provider.ts +1 -8
  92. package/src/ai/quota-provider.ts +191 -0
  93. package/src/ai/quota-report.ts +152 -0
  94. package/src/ai/quotas-migrate.ts +45 -0
  95. package/src/ai/quotas.ts +168 -0
  96. package/src/ai/routes.ts +215 -11
  97. package/src/ai/run-timeout.ts +10 -0
  98. package/src/ai/runtime-tools.ts +3 -0
  99. package/src/ai/runtime.ts +55 -16
  100. package/src/ai/settings.ts +36 -8
  101. package/src/ai/short-id.ts +2 -2
  102. package/src/ai/skill-catalog.ts +0 -10
  103. package/src/ai/skill-search.ts +47 -0
  104. package/src/ai/skill-seeds.ts +105 -20
  105. package/src/ai/skill-tool.ts +16 -5
  106. package/src/ai/skills-routes.ts +32 -4
  107. package/src/ai/skills.ts +324 -155
  108. package/src/ai/store.ts +373 -191
  109. package/src/ai/stream.ts +5 -0
  110. package/src/ai/structured-runs.ts +4 -3
  111. package/src/ai/structured.ts +8 -1
  112. package/src/ai/system-prompt.ts +12 -2
  113. package/src/ai/timeline.ts +2 -5
  114. package/src/ai/todo-contracts.ts +20 -0
  115. package/src/ai/todo-tool.ts +16 -0
  116. package/src/ai/tool-audit.ts +30 -40
  117. package/src/ai/tools.ts +6 -0
  118. package/src/ai/transcription.ts +202 -0
  119. package/src/ai/turn-timing.ts +114 -0
  120. package/src/ai/types.ts +58 -10
  121. package/src/ai/ui.tsx +2 -0
  122. package/src/ai/usage.ts +118 -118
  123. package/src/ai/vision-tool.ts +39 -10
  124. package/src/api/admin-ai-projects.ts +2 -2
  125. package/src/api/admin-ai-quotas.ts +74 -0
  126. package/src/api/admin-ai-skills.ts +48 -5
  127. package/src/api/admin-ai-usage.ts +2 -2
  128. package/src/api/admin-core-settings.ts +25 -0
  129. package/src/api/admin-identity.ts +91 -51
  130. package/src/api/admin-rail.ts +58 -0
  131. package/src/api/announcements.ts +14 -0
  132. package/src/api/capabilities.ts +292 -43
  133. package/src/api/capability-streams.ts +262 -0
  134. package/src/api/help.ts +8 -44
  135. package/src/api/index.ts +6 -0
  136. package/src/api/mcp.ts +44 -26
  137. package/src/api/me-web-vitals.ts +35 -0
  138. package/src/api/me.ts +2 -0
  139. package/src/api/search/schemas.ts +26 -15
  140. package/src/api/search.bench.ts +1 -1
  141. package/src/api/search.ts +54 -17
  142. package/src/browser/CloudResourceSearch.tsx +640 -702
  143. package/src/browser/app-approval-vault.ts +19 -7
  144. package/src/browser/command-bridge.ts +109 -0
  145. package/src/browser/command-shortcuts.ts +76 -0
  146. package/src/browser/commands.ts +66 -0
  147. package/src/browser/navigation-search.ts +48 -0
  148. package/src/browser/notifications.ts +1 -1
  149. package/src/browser/resource-picker.tsx +5 -18
  150. package/src/browser/resource-search-dialog.ts +9 -0
  151. package/src/browser/resource-search-input.ts +46 -0
  152. package/src/browser/resource-search-messages.ts +76 -40
  153. package/src/browser/resource-search.ts +7 -0
  154. package/src/browser/search-bridge.ts +102 -0
  155. package/src/browser/search-commands.ts +59 -0
  156. package/src/browser/search.ts +13 -0
  157. package/src/browser/spotlight-position.ts +208 -0
  158. package/src/browser/testing.ts +5 -0
  159. package/src/browser/web-vitals.ts +25 -0
  160. package/src/capabilities/claims.ts +179 -0
  161. package/src/capabilities/client.ts +34 -0
  162. package/src/capabilities/command-link.ts +16 -0
  163. package/src/capabilities/executions.ts +298 -0
  164. package/src/capabilities/migrate.ts +81 -0
  165. package/src/capabilities/server.ts +114 -34
  166. package/src/capabilities/stream-body.ts +16 -0
  167. package/src/capabilities/streams.ts +28 -0
  168. package/src/cli/admin/ai-quotas.ts +161 -0
  169. package/src/cli/admin/ai-skills.ts +64 -0
  170. package/src/cli/admin/ai-usage.ts +53 -30
  171. package/src/cli/admin/app-credentials.ts +79 -0
  172. package/src/cli/admin/data.ts +17 -0
  173. package/src/cli/admin/index.ts +9 -0
  174. package/src/cli/capabilities.ts +29 -0
  175. package/src/cli/capability-streams.ts +25 -0
  176. package/src/cli/index.ts +3 -2
  177. package/src/config/env.ts +11 -8
  178. package/src/contracts/app.ts +23 -3
  179. package/src/contracts/capabilities.ts +68 -5
  180. package/src/contracts/capability-streams.ts +46 -0
  181. package/src/contracts/commands.ts +49 -0
  182. package/src/contracts/index.ts +6 -1
  183. package/src/contracts/rail-admin.ts +38 -0
  184. package/src/contracts/rail-preferences.ts +16 -0
  185. package/src/contracts/registry.ts +3 -27
  186. package/src/contracts/settings-types.ts +1 -1
  187. package/src/contracts/shared.ts +9 -5
  188. package/src/contracts/web-vitals.ts +27 -0
  189. package/src/desktop/solid.tsx +3 -3
  190. package/src/index.ts +0 -4
  191. package/src/server/index.ts +3 -0
  192. package/src/server/middleware/auth.ts +3 -2
  193. package/src/server/middleware/route-template.ts +9 -3
  194. package/src/server/middleware/runtime.ts +2 -1
  195. package/src/server/middleware/settings.ts +17 -11
  196. package/src/server/services/access-revision.ts +10 -0
  197. package/src/server/services/access.ts +1 -1
  198. package/src/server/services/index.ts +1 -0
  199. package/src/services/account-category-policy.ts +9 -0
  200. package/src/services/account-lifecycle/audit.ts +1 -1
  201. package/src/services/accounts/app.ts +12 -2
  202. package/src/services/accounts/email-write.ts +23 -0
  203. package/src/services/accounts/groups.ts +15 -4
  204. package/src/services/accounts/identities.ts +249 -0
  205. package/src/services/accounts/identity-reconciliation.ts +87 -0
  206. package/src/services/accounts/ipa-data.ts +2 -2
  207. package/src/services/accounts/local-groups.ts +2 -2
  208. package/src/services/accounts/posix.ts +45 -29
  209. package/src/services/accounts/users.ts +2 -2
  210. package/src/services/announcements/index.ts +69 -8
  211. package/src/services/audit/index.ts +1 -1
  212. package/src/services/cache-fill.ts +35 -0
  213. package/src/services/help/index.ts +109 -0
  214. package/src/services/help/maintenance.ts +25 -0
  215. package/src/services/help/store.ts +180 -0
  216. package/src/services/help/types.ts +46 -0
  217. package/src/services/identity/invocation-actor.ts +2 -2
  218. package/src/services/identity/key-config.ts +2 -1
  219. package/src/services/identity/key-ring.ts +1 -1
  220. package/src/services/index.ts +15 -0
  221. package/src/services/ipa/users.ts +268 -221
  222. package/src/services/legal-consent.ts +1 -1
  223. package/src/services/logging/index.ts +15 -13
  224. package/src/services/logging/trace.ts +2 -1
  225. package/src/services/logging/web-vitals.ts +81 -0
  226. package/src/services/mandates/index.ts +18 -4
  227. package/src/services/mandates/policy.ts +65 -2
  228. package/src/services/notifications/batches.ts +124 -57
  229. package/src/services/notifications/browser.ts +12 -0
  230. package/src/services/notifications/index.ts +0 -2
  231. package/src/services/notifications/platform.ts +0 -4
  232. package/src/services/oauth-tokens.ts +3 -3
  233. package/src/services/pdf/gotenberg.ts +107 -84
  234. package/src/services/pdf/index.ts +3 -0
  235. package/src/services/providers/local/users.ts +25 -16
  236. package/src/services/public-http.ts +101 -0
  237. package/src/services/rail-shortcuts.ts +95 -0
  238. package/src/services/rail-snapshot.ts +53 -0
  239. package/src/services/request-cache-redis.ts +28 -0
  240. package/src/services/session/index.ts +15 -2
  241. package/src/services/session/user.ts +62 -37
  242. package/src/services/settings/core-settings.ts +21 -28
  243. package/src/services/settings/defaults.ts +5 -0
  244. package/src/services/settings/store.ts +58 -84
  245. package/src/shared/ai-costs.ts +42 -0
  246. package/src/shared/ai-platform-prompt.ts +1 -1
  247. package/src/shared/ai-quotas.ts +136 -0
  248. package/src/shared/ai-usage.ts +6 -16
  249. package/src/shared/app-presentation.ts +21 -15
  250. package/src/shared/capability-messages.ts +14 -0
  251. package/src/shared/help.ts +6 -42
  252. package/src/shared/index.ts +6 -0
  253. package/src/ssr/AdminLayout.tsx +7 -5
  254. package/src/ssr/AdminSidebar.tsx +29 -15
  255. package/src/ssr/AppLaunchpad.island.tsx +24 -137
  256. package/src/ssr/AppLaunchpadPanel.tsx +181 -0
  257. package/src/ssr/BrowserPushRegistration.island.tsx +12 -0
  258. package/src/ssr/GlobalSearchDialog.tsx +167 -20
  259. package/src/ssr/GlobalSearchTrigger.island.tsx +33 -22
  260. package/src/ssr/Layout.tsx +17 -6
  261. package/src/ssr/LayoutBreadcrumbs.island.tsx +3 -5
  262. package/src/ssr/LayoutFooter.tsx +1 -1
  263. package/src/ssr/LayoutHeader.tsx +46 -14
  264. package/src/ssr/LayoutHelp.tsx +23 -22
  265. package/src/ssr/{HotkeysHelpRail.island.tsx → LayoutHelpTrigger.island.tsx} +18 -12
  266. package/src/ssr/LayoutRail.tsx +17 -9
  267. package/src/ssr/MobileNavigation.tsx +71 -0
  268. package/src/ssr/MobileProfileActions.tsx +49 -0
  269. package/src/ssr/ProfilePreferences.island.tsx +3 -36
  270. package/src/ssr/RailApps.island.tsx +2 -2
  271. package/src/ssr/RailEditor.tsx +17 -2
  272. package/src/ssr/WorkspaceNavigation.island.tsx +7 -0
  273. package/src/ssr/WorkspaceNavigationProvider.tsx +16 -0
  274. package/src/ssr/admin-navigation.ts +7 -1
  275. package/src/ssr/help.ts +29 -0
  276. package/src/ssr/islands/SearchBar.tsx +67 -0
  277. package/src/ssr/islands/index.ts +3 -1
  278. package/src/ssr/layout-context.ts +2 -2
  279. package/src/ssr/layout-help-search.ts +15 -0
  280. package/src/ssr/mobile-menu-history.ts +70 -0
  281. package/src/ssr/platform-messages.ts +16 -8
  282. package/src/ssr/profile-actions.ts +42 -0
  283. package/src/ssr/rail-context.ts +8 -4
  284. package/src/ssr/rail-messages.ts +2 -0
  285. package/src/ssr/rail-navigation.ts +3 -3
  286. package/src/ssr/workspace-navigation.ts +75 -0
  287. package/src/styles/effects.css +6 -6
  288. package/src/styles/global.css +54 -0
  289. package/src/styles/input.css +43 -41
  290. package/src/styles/resource-search.css +505 -0
  291. package/src/styles/utilities-feedback.css +13 -0
  292. package/src/styles/utilities-layout.css +1 -5
  293. package/src/styles/utilities-navigation.css +3 -39
  294. package/src/types/ambient.d.ts +5 -0
  295. package/src/workflows/ai/runtime.ts +5 -1
  296. package/src/workflows/ai/store.ts +2 -0
  297. package/src/workflows/ai-actions.ts +16 -13
  298. package/src/workflows/definition.ts +10 -5
  299. package/src/workflows/store/actions.ts +44 -20
  300. package/src/workflows/store/index.ts +2 -0
  301. package/src/workflows/store/runs.ts +36 -0
  302. package/src/workflows/store/worker-pool.ts +55 -0
  303. package/src/workflows/store/worker-runtime.ts +90 -0
  304. package/scripts/app-favicon.test.ts +0 -49
  305. package/src/ai/chat/blocks.render.test.tsx +0 -1108
  306. package/src/ai/kit-skill.ts +0 -22
  307. package/src/ai/usage-migrate.ts +0 -81
  308. package/src/browser/CloudResourceSearch.behavior.test.tsx +0 -74
  309. package/src/browser/notification-target.ts +0 -27
  310. package/src/contracts/notification-live.ts +0 -66
  311. package/src/services/notifications/live.ts +0 -50
  312. package/src/ssr/BrowserNotifications.island.tsx +0 -135
  313. package/src/ssr/GlobalSearchDialog.behavior.test.tsx +0 -96
  314. package/src/ssr/Layout.render.test.tsx +0 -148
  315. package/src/ssr/islands/SearchBar.island.tsx +0 -80
package/src/ai/skills.ts CHANGED
@@ -1,3 +1,7 @@
1
+ import { aiProjects } from "./projects";
2
+ import { AuthenticatedPrincipalSchema } from "../contracts/shared";
3
+ import { createHash } from "node:crypto";
4
+ import { accessRevision } from "../server/services/access-revision";
1
5
  import { type SQL, type SQLQuery, sql } from "bun";
2
6
  import type { AccessSubject } from "../server";
3
7
  import {
@@ -9,6 +13,7 @@ import {
9
13
  type Principal,
10
14
  } from "../server/services/access";
11
15
  import { toPgUuidArray } from "../services/postgres";
16
+ import { aiSkillSearchSql, withAiSkillSearch } from "./skill-search";
12
17
  import { mountAiSkillFilePath } from "./file-mount";
13
18
  import { AI_SHORT_ID_PATTERN, withAiShortIdForDb } from "./short-id";
14
19
  import {
@@ -21,6 +26,7 @@ import {
21
26
  validateAiSkillName,
22
27
  validateAiSkillReferences,
23
28
  } from "./skill-format";
29
+ import { getBuiltinAiSkillTemplates } from "./skill-seeds";
24
30
  import type { AiSkillFileToolContent, AiSkillFileToolStat } from "./types";
25
31
 
26
32
  export type AiSkillPermission = Exclude<PermissionLevel, "none">;
@@ -55,7 +61,22 @@ export type AiSkillAccess = {
55
61
  createdAt: string;
56
62
  };
57
63
 
64
+ export type AiSkillTemplate = {
65
+ key: string;
66
+ version: number;
67
+ name: string;
68
+ description: string;
69
+ instructions: string;
70
+ extraFrontmatter?: AiSkillExtraFrontmatter;
71
+ references?: readonly AiSkillReferenceInput[];
72
+ };
73
+
58
74
  export type AiSkillAdminListItem = {
75
+ revision: number;
76
+ templateId: string | null;
77
+ templateVersion: number | null;
78
+ currentTemplateVersion: number | null;
79
+ templateStatus: "current" | "modified" | "update_available" | null;
59
80
  id: string;
60
81
  shortId: string;
61
82
  name: string;
@@ -86,6 +107,9 @@ type SkillRow = {
86
107
  id: string;
87
108
  short_id: string;
88
109
  managed_key: string | null;
110
+ template_id: string | null;
111
+ template_version: number | null;
112
+ template_hash: string | null;
89
113
  name: string;
90
114
  description: string;
91
115
  instructions: string;
@@ -106,13 +130,14 @@ type SkillAccessRow = {
106
130
  display_name: string | null;
107
131
  };
108
132
 
109
- type SkillSummaryRow = SkillRow & {
133
+ type SkillSummaryRow = Pick<SkillRow, "id" | "short_id" | "name" | "description" | "revision" | "created_at" | "updated_at"> & {
110
134
  permission: AiSkillPermission;
111
135
  reference_count: number;
112
136
  enabled: boolean;
113
137
  };
114
138
 
115
139
  type AdminSkillRow = SkillRow & {
140
+ content_references: AiSkillReference[] | string;
116
141
  reference_count: number;
117
142
  access_count: number;
118
143
  admin_count: number;
@@ -162,7 +187,7 @@ const principalForSubject = (subject: AccessSubject): Principal =>
162
187
  : { type: "service_account", serviceAccountId: subject.serviceAccountId };
163
188
 
164
189
  const accessMatch = (subject: AccessSubject | null): SQLQuery =>
165
- buildAccessPrincipalCondition({
190
+ subject === null ? sql`FALSE` : buildAccessPrincipalCondition({
166
191
  subject,
167
192
  columns: {
168
193
  userId: sql`access.user_id`,
@@ -181,17 +206,22 @@ const getRowByShortId = async (shortId: string, db: SQL = sql): Promise<SkillRow
181
206
  const getRowByName = async (name: string, db: SQL = sql): Promise<SkillRow | null> =>
182
207
  (await db<SkillRow[]>`SELECT * FROM ai.skills WHERE name = ${name}`)[0] ?? null;
183
208
 
209
+ // Project links grant read only. Their creator's later permissions are irrelevant.
210
+ const effectivePermission = (skillId: SQLQuery, subject: AccessSubject | null) => sql`
211
+ SELECT permission FROM (
212
+ SELECT access.permission FROM ai.skill_access link JOIN auth.access access ON access.id=link.access_id
213
+ WHERE link.skill_id=${skillId} AND ${accessMatch(subject)}
214
+ UNION ALL
215
+ SELECT 'read'::auth.permission_level FROM ai.project_skills link
216
+ JOIN ai.project_access project_access ON project_access.project_id=link.project_id
217
+ JOIN auth.access access ON access.id=project_access.access_id
218
+ WHERE link.skill_id=${skillId} AND access.permission <> 'none' AND ${accessMatch(subject)}
219
+ ) grants ORDER BY CASE permission WHEN 'admin' THEN 3 WHEN 'write' THEN 2 WHEN 'read' THEN 1 ELSE 0 END DESC LIMIT 1
220
+ `;
221
+
184
222
  const permissionFor = async (skillId: string, subject: AccessSubject | null, db: SQL = sql): Promise<AiSkillPermission | "none"> => {
185
- const match = accessMatch(subject);
186
- const rows = await db<{ permission: AiSkillPermission }[]>`
187
- SELECT access.permission
188
- FROM ai.skill_access skill_access
189
- JOIN auth.access access ON access.id = skill_access.access_id
190
- WHERE skill_access.skill_id = ${skillId}::uuid AND ${match}
191
- ORDER BY CASE access.permission WHEN 'admin' THEN 3 WHEN 'write' THEN 2 WHEN 'read' THEN 1 ELSE 0 END DESC
192
- LIMIT 1
193
- `;
194
- return rows[0]?.permission ?? "none";
223
+ const [row] = await db<{ permission: AiSkillPermission | null }[]>`SELECT (${effectivePermission(sql`${skillId}::uuid`, subject)}) AS permission`;
224
+ return row?.permission ?? "none";
195
225
  };
196
226
 
197
227
  const enabledFor = async (skillId: string, subject: AccessSubject | null, db: SQL = sql): Promise<boolean> => {
@@ -280,6 +310,7 @@ const createSkillAccess = async (
280
310
  input: { principal: Principal; permission: AiSkillPermission },
281
311
  db: SQL,
282
312
  ): Promise<AiSkillAccess> => {
313
+ AuthenticatedPrincipalSchema.parse(input.principal);
283
314
  const created = await createAccess(input, db);
284
315
  if (!created.ok) throw new Error(created.error.message);
285
316
  let shortId = "";
@@ -300,6 +331,11 @@ const createSkillAccess = async (
300
331
  return entry;
301
332
  };
302
333
 
334
+ const checkAccessRevision = async (skillId: string, expected: string | undefined, db: SQL) => {
335
+ if (expected !== undefined && accessRevision(await listSkillAccess(skillId, db)) !== expected)
336
+ throw new AiSkillRevisionConflictError();
337
+ };
338
+
303
339
  const updateSkillAccess = async (skillId: string, accessId: string, permission: AiSkillPermission, db: SQL): Promise<boolean> => {
304
340
  const [bound] = await db<{ id: string; permission: AiSkillPermission }[]>`
305
341
  SELECT access.id, access.permission
@@ -361,26 +397,92 @@ const validatedFields = (input: {
361
397
  }
362
398
  };
363
399
 
364
- const sameReferences = (left: readonly AiSkillReference[], right: readonly AiSkillReference[]): boolean =>
365
- left.length === right.length &&
366
- left.every((reference, index) => reference.path === right[index]?.path && reference.content === right[index]?.content);
400
+ // JSON object key order and reference insertion order are not content changes.
401
+ const canonicalJson = (value: unknown): string => {
402
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
403
+ if (value !== null && typeof value === "object") {
404
+ return `{${Object.entries(value)
405
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
406
+ .map(([key, child]) => `${JSON.stringify(key)}:${canonicalJson(child)}`)
407
+ .join(",")}}`;
408
+ }
409
+ return JSON.stringify(value);
410
+ };
411
+
412
+ const contentHash = (fields: ReturnType<typeof validatedFields>): string =>
413
+ createHash("sha256")
414
+ .update(
415
+ canonicalJson({
416
+ ...fields,
417
+ references: [...fields.references].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)),
418
+ }),
419
+ )
420
+ .digest("hex");
421
+
422
+ const rowContentHash = async (row: SkillRow, db: SQL = sql): Promise<string> =>
423
+ contentHash({
424
+ name: row.name,
425
+ description: row.description,
426
+ instructions: row.instructions,
427
+ extraFrontmatter: jsonObject(row.extra_frontmatter),
428
+ references: await listReferences(row.id, db),
429
+ });
430
+
431
+ const validateTemplate = (input: AiSkillTemplate) => {
432
+ if (!input.key.trim() || !Number.isSafeInteger(input.version) || input.version < 1 || input.version > 2147483647)
433
+ throw new AiSkillInputError("A template needs a stable ID and a positive integer version.");
434
+ const fields = validatedFields(input);
435
+ return { fields, hash: contentHash(fields) };
436
+ };
437
+
438
+ const replaceTemplate = async (row: SkillRow, template: AiSkillTemplate, db: SQL): Promise<void> => {
439
+ const { fields, hash } = validateTemplate(template);
440
+ await db`UPDATE ai.skills SET name = ${fields.name}, description = ${fields.description},
441
+ instructions = ${fields.instructions}, extra_frontmatter = (${JSON.stringify(fields.extraFrontmatter)}::text)::jsonb,
442
+ template_id = ${template.key}, template_version = ${template.version}, template_hash = ${hash},
443
+ revision = revision + 1, updated_at = now() WHERE id = ${row.id}::uuid`;
444
+ await db`DELETE FROM ai.skill_references WHERE skill_id = ${row.id}::uuid`;
445
+ for (const ref of fields.references)
446
+ await db`INSERT INTO ai.skill_references (skill_id, path, content)
447
+ VALUES (${row.id}::uuid, ${ref.path}, ${ref.content})`;
448
+ };
367
449
 
368
450
  const skillSearchPattern = (search?: string): string | null => {
369
451
  const value = search?.trim().toLowerCase();
370
452
  return value ? `%${value.replace(/[\\%_]/g, (char) => `\\${char}`)}%` : null;
371
453
  };
372
454
 
373
- const toAdminSkill = (row: AdminSkillRow): AiSkillAdminListItem => ({
374
- id: row.id,
375
- shortId: row.short_id,
376
- name: row.name,
377
- description: row.description,
378
- referenceCount: Number(row.reference_count),
379
- accessCount: Number(row.access_count),
380
- adminCount: Number(row.admin_count),
381
- createdAt: iso(row.created_at),
382
- updatedAt: iso(row.updated_at),
383
- });
455
+ const toAdminSkill = (row: AdminSkillRow): AiSkillAdminListItem => {
456
+ const template = getBuiltinAiSkillTemplates().find((entry) => entry.key === row.template_id);
457
+ return {
458
+ revision: Number(row.revision),
459
+ templateId: row.template_id,
460
+ templateVersion: row.template_version,
461
+ currentTemplateVersion: template?.version ?? null,
462
+ templateStatus: !row.template_id
463
+ ? null
464
+ : template && template.version > Number(row.template_version)
465
+ ? "update_available"
466
+ : contentHash({
467
+ name: row.name,
468
+ description: row.description,
469
+ instructions: row.instructions,
470
+ extraFrontmatter: jsonObject(row.extra_frontmatter),
471
+ references: snapshotFiles(row.content_references),
472
+ }) === row.template_hash
473
+ ? "current"
474
+ : "modified",
475
+ id: row.id,
476
+ shortId: row.short_id,
477
+ name: row.name,
478
+ description: row.description,
479
+ referenceCount: Number(row.reference_count),
480
+ accessCount: Number(row.access_count),
481
+ adminCount: Number(row.admin_count),
482
+ createdAt: iso(row.created_at),
483
+ updatedAt: iso(row.updated_at),
484
+ };
485
+ };
384
486
 
385
487
  const skillSnapshotFiles = (skill: AiSkill): { path: string; content: string }[] => [
386
488
  {
@@ -390,90 +492,104 @@ const skillSnapshotFiles = (skill: AiSkill): { path: string; content: string }[]
390
492
  ...skill.references.map((reference) => ({ path: `${skill.name}/${reference.path}`, content: reference.content })),
391
493
  ];
392
494
 
393
- export const aiSkills = {
394
- async seedOnce(input: {
395
- key: string;
396
- name: string;
397
- description: string;
398
- instructions: string;
399
- extraFrontmatter?: AiSkillExtraFrontmatter;
400
- references?: readonly AiSkillReferenceInput[];
401
- }): Promise<void> {
402
- const key = input.key.trim();
403
- if (!key) throw new AiSkillInputError("Skill seed key is required.");
404
- const fields = validatedFields(input);
405
- await sql.begin(async (tx) => {
406
- const [claimed] = await tx<{ key: string }[]>`
407
- INSERT INTO ai.skill_seeds (key) VALUES (${key})
408
- ON CONFLICT (key) DO NOTHING
409
- RETURNING key
410
- `;
411
- if (!claimed) return;
495
+ type SkillListOptions = { query?: string; enabledOnly?: boolean; limit?: number };
496
+ async function listSkillSummaries(subject: AccessSubject | null, options: SkillListOptions & {
497
+ project?: { id: string; linked: boolean }; manageOnly?: boolean; offset?: number;
498
+ } = {}): Promise<AiSkillSummary[]> {
499
+ const userId = subject?.type === "user" ? subject.userId : null;
500
+ const query = (options.query ?? "").trim().slice(0, 200);
501
+ const limit = Math.max(1, Math.min(201, options.limit ?? 200));
502
+ const rows = await withAiSkillSearch(query, async (bm25) => {
503
+ const search = aiSkillSearchSql(query, bm25);
504
+ return sql<SkillSummaryRow[]>`
505
+ SELECT skill.id, skill.short_id, skill.name, skill.description, skill.revision, skill.created_at, skill.updated_at,
506
+ access.permission,
507
+ count(DISTINCT reference.path)::int AS reference_count,
508
+ (disabled.user_id IS NULL) AS enabled
509
+ FROM ai.skills skill
510
+ JOIN LATERAL (${effectivePermission(sql`skill.id`, subject)}) access ON true
511
+ LEFT JOIN ai.skill_references reference ON reference.skill_id = skill.id
512
+ LEFT JOIN ai.skill_user_disabled disabled
513
+ ON disabled.skill_id = skill.id AND disabled.user_id = ${userId}::uuid
514
+ WHERE access.permission <> 'none'
515
+ AND (${!options.manageOnly} OR access.permission='admin')
516
+ AND ${options.project ? sql`EXISTS(SELECT 1 FROM ai.project_skills link WHERE link.skill_id=skill.id AND link.project_id=${options.project.id}::uuid) = ${options.project.linked}` : sql`true`}
517
+ AND (${!options.enabledOnly} OR disabled.user_id IS NULL) AND ${search.matches}
518
+ GROUP BY skill.id, disabled.user_id, access.permission
519
+ ORDER BY ${search.rank} DESC, ${search.textRank} DESC, skill.name, skill.id
520
+ LIMIT ${limit} OFFSET ${options.offset ?? 0}
521
+ `;
522
+ });
523
+ return rows.map((row) => ({
524
+ id: row.id,
525
+ shortId: row.short_id,
526
+ name: row.name,
527
+ description: row.description,
528
+ permission: row.permission,
529
+ revision: Number(row.revision),
530
+ referenceCount: Number(row.reference_count),
531
+ enabled: row.enabled,
532
+ createdAt: iso(row.created_at),
533
+ updatedAt: iso(row.updated_at),
534
+ }));
535
+ }
412
536
 
413
- let [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE managed_key = ${key} FOR UPDATE`;
414
- if (!row) {
415
- const [conflict] = await tx<{ id: string }[]>`SELECT id FROM ai.skills WHERE name = ${fields.name}`;
416
- if (conflict) return;
417
- const rows = await withAiShortIdForDb(
418
- tx,
419
- "idx_ai_skills_short_id",
420
- (attempt, shortId) => attempt<SkillRow[]>`
421
- INSERT INTO ai.skills (short_id, name, description, instructions, extra_frontmatter)
422
- VALUES (
423
- ${shortId}, ${fields.name}, ${fields.description}, ${fields.instructions},
424
- (${JSON.stringify(fields.extraFrontmatter)}::text)::jsonb
425
- )
426
- RETURNING *
427
- `,
428
- );
429
- row = rows[0]!;
430
- } else {
431
- const references = await listReferences(row.id, tx);
432
- const changed =
433
- row.name !== fields.name ||
434
- row.description !== fields.description ||
435
- row.instructions !== fields.instructions ||
436
- JSON.stringify(jsonObject(row.extra_frontmatter)) !== JSON.stringify(fields.extraFrontmatter) ||
437
- !sameReferences(references, fields.references);
438
- if (changed) {
439
- [row] = await tx<SkillRow[]>`
440
- UPDATE ai.skills
441
- SET name = ${fields.name}, description = ${fields.description}, instructions = ${fields.instructions},
442
- extra_frontmatter = (${JSON.stringify(fields.extraFrontmatter)}::text)::jsonb,
443
- managed_key = NULL, revision = revision + 1, updated_at = now()
444
- WHERE id = ${row.id}::uuid
445
- RETURNING *
446
- `;
447
- } else {
448
- [row] = await tx<SkillRow[]>`
449
- UPDATE ai.skills SET managed_key = NULL WHERE id = ${row.id}::uuid RETURNING *
450
- `;
451
- }
452
- }
537
+ async function linkedSkillProjects(skillId: string, subject: AccessSubject | null) {
538
+ const links = await sql<{projectId:string}[]>`SELECT project_id AS "projectId" FROM ai.project_skills WHERE skill_id=${skillId}::uuid ORDER BY project_id`;
539
+ const result: Array<{ projectId: string; shortId: string | null; name: string | null }> = [];
540
+ for (const link of links) {
541
+ const project = await aiProjects.get(link.projectId, subject);
542
+ result.push({ ...link, shortId: project?.shortId ?? null, name: project?.name ?? null });
543
+ }
544
+ return result;
545
+ }
453
546
 
454
- const currentReferences = await listReferences(row!.id, tx);
455
- if (!sameReferences(currentReferences, fields.references)) {
456
- await tx`DELETE FROM ai.skill_references WHERE skill_id = ${row!.id}::uuid`;
457
- for (const reference of fields.references) {
458
- await tx`
459
- INSERT INTO ai.skill_references (skill_id, path, content)
460
- VALUES (${row!.id}::uuid, ${reference.path}, ${reference.content})
461
- `;
547
+ export const aiSkills = {
548
+ async seedOnce(template: AiSkillTemplate): Promise<void> {
549
+ const { fields, hash } = validateTemplate(template);
550
+ await sql.begin(async (tx) => {
551
+ const [claimed] = await tx`INSERT INTO ai.skill_seeds (key, catalog_version)
552
+ VALUES (${template.key}, ${template.version}) ON CONFLICT (key) DO NOTHING RETURNING key`;
553
+ // Seed lock always precedes the Skill lock, including administrator adoption/reset.
554
+ const [seed] = await tx<{ skill_id: string | null; catalog_version: number }[]>`
555
+ SELECT skill_id, catalog_version FROM ai.skill_seeds WHERE key = ${template.key} FOR UPDATE`;
556
+ if (!seed || seed.catalog_version > template.version) return;
557
+ await tx`UPDATE ai.skill_seeds SET catalog_version = ${template.version} WHERE key = ${template.key}`;
558
+ if (seed.skill_id) {
559
+ const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${seed.skill_id}::uuid FOR UPDATE`;
560
+ if (!row || row.template_id !== template.key || Number(row.template_version) >= template.version) return;
561
+ if ((await rowContentHash(row, tx)) !== row.template_hash) return;
562
+ // A user may have claimed the new name. Keep the existing Skill rather than fail startup.
563
+ const [collision] = await tx`SELECT id FROM ai.skills WHERE name = ${fields.name} AND id <> ${row.id}::uuid`;
564
+ if (!collision) {
565
+ try {
566
+ await tx.savepoint((attempt) => replaceTemplate(row, template, attempt));
567
+ } catch (error) {
568
+ // A concurrent normal create may claim the name after our read.
569
+ if (!(typeof error === "object" && error !== null && "constraint" in error && error.constraint === "idx_ai_skills_name"))
570
+ throw error;
571
+ }
462
572
  }
573
+ return;
463
574
  }
464
-
465
- const [grant] = await tx<{ id: string; permission: AiSkillPermission }[]>`
466
- SELECT access.id, access.permission
467
- FROM ai.skill_access skill_access
468
- JOIN auth.access access ON access.id = skill_access.access_id
469
- WHERE skill_access.skill_id = ${row!.id}::uuid AND access.authenticated_only = true
470
- LIMIT 1
471
- `;
472
- if (!grant) {
473
- await createSkillAccess(row!.id, { principal: { type: "authenticated" }, permission: "read" }, tx);
474
- } else if (grant.permission !== "read") {
475
- await tx`UPDATE auth.access SET permission = 'read' WHERE id = ${grant.id}::uuid`;
476
- }
575
+ // Old seed markers and name collisions never authorize adoption or replacement.
576
+ if (!claimed) return;
577
+ const [collision] = await tx`SELECT id FROM ai.skills WHERE name = ${fields.name}`;
578
+ if (collision) return;
579
+ const [row] = await withAiShortIdForDb(
580
+ tx,
581
+ "idx_ai_skills_short_id",
582
+ (attempt, shortId) => attempt<SkillRow[]>`
583
+ INSERT INTO ai.skills (short_id, name, description, instructions, extra_frontmatter, template_id, template_version, template_hash)
584
+ VALUES (${shortId}, ${fields.name}, ${fields.description}, ${fields.instructions},
585
+ (${JSON.stringify(fields.extraFrontmatter)}::text)::jsonb, ${template.key}, ${template.version}, ${hash}) ON CONFLICT (name) DO NOTHING RETURNING *`,
586
+ );
587
+ if (!row) return;
588
+ for (const ref of fields.references)
589
+ await tx`INSERT INTO ai.skill_references (skill_id, path, content)
590
+ VALUES (${row!.id}::uuid, ${ref.path}, ${ref.content})`;
591
+ await tx`UPDATE ai.skill_seeds SET skill_id = ${row!.id}::uuid WHERE key = ${template.key}`;
592
+ await createSkillAccess(row!.id, { principal: { type: "authenticated" }, permission: "read" }, tx);
477
593
  });
478
594
  },
479
595
 
@@ -510,39 +626,38 @@ export const aiSkills = {
510
626
  });
511
627
  },
512
628
 
513
- async list(subject: AccessSubject | null): Promise<AiSkillSummary[]> {
514
- const match = accessMatch(subject);
515
- const userId = subject?.type === "user" ? subject.userId : null;
516
- const rows = await sql<SkillSummaryRow[]>`
517
- SELECT skill.*,
518
- (array_agg(access.permission ORDER BY
519
- CASE access.permission WHEN 'admin' THEN 3 WHEN 'write' THEN 2 WHEN 'read' THEN 1 ELSE 0 END DESC
520
- ))[1] AS permission,
521
- count(DISTINCT reference.path)::int AS reference_count,
522
- (disabled.user_id IS NULL) AS enabled
523
- FROM ai.skills skill
524
- JOIN ai.skill_access skill_access ON skill_access.skill_id = skill.id
525
- JOIN auth.access access ON access.id = skill_access.access_id
526
- LEFT JOIN ai.skill_references reference ON reference.skill_id = skill.id
527
- LEFT JOIN ai.skill_user_disabled disabled
528
- ON disabled.skill_id = skill.id AND disabled.user_id = ${userId}::uuid
529
- WHERE access.permission <> 'none' AND ${match}
530
- GROUP BY skill.id, disabled.user_id
531
- ORDER BY skill.name, skill.id
532
- LIMIT 200
533
- `;
534
- return rows.map((row) => ({
535
- id: row.id,
536
- shortId: row.short_id,
537
- name: row.name,
538
- description: row.description,
539
- permission: row.permission,
540
- revision: Number(row.revision),
541
- referenceCount: Number(row.reference_count),
542
- enabled: row.enabled,
543
- createdAt: iso(row.created_at),
544
- updatedAt: iso(row.updated_at),
545
- }));
629
+ async list(subject: AccessSubject | null, options: SkillListOptions = {}): Promise<AiSkillSummary[]> {
630
+ return listSkillSummaries(subject, options);
631
+ },
632
+
633
+ async projectSkills(projectId: string, subject: AccessSubject | null, options: { query?: string; page?: number; available?: boolean } = {}) {
634
+ if (!(await aiProjects.get(projectId, subject, options.available ? "admin" : "read"))) return null;
635
+ const page = Math.max(1, Math.min(100000, Math.trunc(options.page ?? 1)));
636
+ const skills = await listSkillSummaries(subject, { query: options.query, limit: 31, offset: (page-1)*30,
637
+ project: { id: projectId, linked: !options.available }, manageOnly: options.available });
638
+ return { items: skills.slice(0,30), hasNext: skills.length>30, page };
639
+ },
640
+
641
+ async linkProject(skillId: string, projectId: string, linked: boolean, subject: AccessSubject | null): Promise<boolean> {
642
+ if (!(await aiProjects.get(projectId, subject, "admin"))) return false;
643
+ return sql.begin(async tx => {
644
+ const [row] = await tx`SELECT id FROM ai.skills WHERE id=${skillId}::uuid FOR UPDATE`;
645
+ if (!row || !hasPermission(await permissionFor(skillId, subject, tx), "admin")) return false;
646
+ if (linked) await tx`INSERT INTO ai.project_skills(project_id,skill_id) VALUES(${projectId}::uuid,${skillId}::uuid) ON CONFLICT DO NOTHING`;
647
+ else await tx`DELETE FROM ai.project_skills WHERE project_id=${projectId}::uuid AND skill_id=${skillId}::uuid`;
648
+ return true;
649
+ });
650
+ },
651
+
652
+ async linkedProjects(skillId: string, subject: AccessSubject | null) {
653
+ if (!(await this.get(skillId, subject, "admin"))) return null;
654
+ return linkedSkillProjects(skillId, subject);
655
+ },
656
+
657
+ async search(subject: AccessSubject | null, query: string, limit = 30): Promise<{ skills: AiSkillSummary[]; more: boolean }> {
658
+ const size = Math.max(1, Math.min(200, limit));
659
+ const skills = await aiSkills.list(subject, { query, enabledOnly: true, limit: size + 1 });
660
+ return { skills: skills.slice(0, size), more: skills.length > size };
546
661
  },
547
662
 
548
663
  async get(skillId: string, subject: AccessSubject | null, required: AiSkillPermission = "read"): Promise<AiSkill | null> {
@@ -607,39 +722,89 @@ export const aiSkills = {
607
722
  },
608
723
 
609
724
  async listAccess(skillId: string, subject: AccessSubject | null): Promise<AiSkillAccess[] | null> {
610
- if (!(await requireSkill(await getRow(skillId), subject, "admin"))) return null;
611
- return listSkillAccess(skillId);
725
+ return sql.begin(async tx => {
726
+ const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id=${skillId}::uuid FOR UPDATE`;
727
+ if (!row || !hasPermission(await permissionFor(row.id, subject, tx), "admin")) return null;
728
+ return listSkillAccess(skillId, tx);
729
+ });
612
730
  },
613
731
 
614
732
  async grantAccess(
615
733
  skillId: string,
616
734
  subject: AccessSubject | null,
617
735
  input: { principal: Principal; permission: AiSkillPermission },
736
+ expectedAccessRevision?: string,
618
737
  ): Promise<AiSkillAccess | null> {
619
738
  return sql.begin(async (tx) => {
620
739
  const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${skillId}::uuid FOR UPDATE`;
621
740
  if (!row || !hasPermission(await permissionFor(row.id, subject, tx), "admin")) return null;
741
+ await checkAccessRevision(skillId, expectedAccessRevision, tx);
622
742
  return createSkillAccess(skillId, input, tx);
623
743
  });
624
744
  },
625
745
 
626
- async updateAccess(skillId: string, accessId: string, subject: AccessSubject | null, permission: AiSkillPermission): Promise<boolean> {
746
+ async updateAccess(skillId: string, accessId: string, subject: AccessSubject | null, permission: AiSkillPermission, expectedAccessRevision?: string): Promise<boolean> {
627
747
  return sql.begin(async (tx) => {
628
748
  const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${skillId}::uuid FOR UPDATE`;
629
- return row && hasPermission(await permissionFor(row.id, subject, tx), "admin")
630
- ? updateSkillAccess(skillId, accessId, permission, tx)
631
- : false;
749
+ if (!row || !hasPermission(await permissionFor(row.id, subject, tx), "admin")) return false;
750
+ await checkAccessRevision(skillId, expectedAccessRevision, tx);
751
+ return updateSkillAccess(skillId, accessId, permission, tx);
632
752
  });
633
753
  },
634
754
 
635
- async revokeAccess(skillId: string, accessId: string, subject: AccessSubject | null): Promise<boolean> {
755
+ async revokeAccess(skillId: string, accessId: string, subject: AccessSubject | null, expectedAccessRevision?: string): Promise<boolean> {
636
756
  return sql.begin(async (tx) => {
637
757
  const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${skillId}::uuid FOR UPDATE`;
638
- return row && hasPermission(await permissionFor(row.id, subject, tx), "admin") ? revokeSkillAccess(skillId, accessId, tx) : false;
758
+ if (!row || !hasPermission(await permissionFor(row.id, subject, tx), "admin")) return false;
759
+ await checkAccessRevision(skillId, expectedAccessRevision, tx);
760
+ return revokeSkillAccess(skillId, accessId, tx);
639
761
  });
640
762
  },
641
763
 
642
764
  admin: {
765
+ linkedProjects: linkedSkillProjects,
766
+ // Platform-owned recovery API: callers must enforce platform-admin authorization.
767
+ async applyTemplate(
768
+ skillId: string,
769
+ input: {
770
+ templateId: string;
771
+ templateVersion: number;
772
+ expectedRevision: number;
773
+ mode: "associate" | "reset";
774
+ },
775
+ ): Promise<boolean> {
776
+ const template = getBuiltinAiSkillTemplates().find((entry) => entry.key === input.templateId);
777
+ if (!template) throw new AiSkillInputError("Unknown Skill template.");
778
+ if (template.version !== input.templateVersion) throw new AiSkillRevisionConflictError();
779
+ const { hash } = validateTemplate(template);
780
+ return sql.begin(async (tx) => {
781
+ await tx`INSERT INTO ai.skill_seeds (key, catalog_version) VALUES (${template.key}, ${template.version})
782
+ ON CONFLICT (key) DO NOTHING`;
783
+ const [seed] = await tx<{ skill_id: string | null; catalog_version: number }[]>`
784
+ SELECT skill_id, catalog_version FROM ai.skill_seeds WHERE key = ${template.key} FOR UPDATE`;
785
+ const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${skillId}::uuid FOR UPDATE`;
786
+ if (!row) return false;
787
+ if (
788
+ Number(row.revision) !== input.expectedRevision ||
789
+ seed!.catalog_version > template.version ||
790
+ Number(row.template_version) > template.version
791
+ )
792
+ throw new AiSkillRevisionConflictError();
793
+ if (seed!.skill_id && seed!.skill_id !== row.id)
794
+ throw new AiSkillInputError("This template is already linked, possibly to a deleted Skill.");
795
+ if (input.mode === "associate") {
796
+ if (row.template_id) throw new AiSkillInputError("This Skill already has a template.");
797
+ await tx`UPDATE ai.skills SET template_id = ${template.key}, template_version = ${template.version},
798
+ template_hash = ${hash}, managed_key = NULL, revision = revision + 1, updated_at = now() WHERE id = ${row.id}::uuid`;
799
+ } else {
800
+ if (row.template_id !== template.key) throw new AiSkillInputError("Associate this Skill with its template first.");
801
+ await replaceTemplate(row, template, tx);
802
+ }
803
+ await tx`UPDATE ai.skill_seeds SET skill_id = ${row.id}::uuid, catalog_version = ${template.version} WHERE key = ${template.key}`;
804
+ return true;
805
+ });
806
+ },
807
+
643
808
  async list(params: {
644
809
  search?: string;
645
810
  page?: number;
@@ -659,6 +824,8 @@ export const aiSkills = {
659
824
  `;
660
825
  const rows = await sql<AdminSkillRow[]>`
661
826
  SELECT skill.*,
827
+ (SELECT COALESCE(jsonb_agg(jsonb_build_object('path', content.path, 'content', content.content)), '[]'::jsonb)
828
+ FROM ai.skill_references content WHERE content.skill_id = skill.id) AS content_references,
662
829
  count(DISTINCT reference.path)::int AS reference_count,
663
830
  count(DISTINCT skill_access.access_id)::int AS access_count,
664
831
  count(DISTINCT skill_access.access_id) FILTER (WHERE access.permission = 'admin')::int AS admin_count
@@ -704,6 +871,8 @@ export const aiSkills = {
704
871
  async getByShortId(shortId: string): Promise<AiSkillAdminListItem | null> {
705
872
  const rows = await sql<AdminSkillRow[]>`
706
873
  SELECT skill.*,
874
+ (SELECT COALESCE(jsonb_agg(jsonb_build_object('path', content.path, 'content', content.content)), '[]'::jsonb)
875
+ FROM ai.skill_references content WHERE content.skill_id = skill.id) AS content_references,
707
876
  count(DISTINCT reference.path)::int AS reference_count,
708
877
  count(DISTINCT skill_access.access_id)::int AS access_count,
709
878
  count(DISTINCT skill_access.access_id) FILTER (WHERE access.permission = 'admin')::int AS admin_count
@@ -885,17 +1054,15 @@ export const aiSkills = {
885
1054
  },
886
1055
 
887
1056
  async listTurnFiles(turnId: string, subject: AccessSubject | null): Promise<AiSkillFileToolStat[]> {
888
- const match = accessMatch(subject);
889
1057
  const userId = subject?.type === "user" ? subject.userId : null;
890
1058
  const rows = await sql<SnapshotRow[]>`
891
1059
  SELECT DISTINCT snapshot.skill_name, snapshot.description, snapshot.revision, snapshot.instructions,
892
1060
  snapshot.files, snapshot.loaded_at
893
1061
  FROM ai.turn_skill_snapshots snapshot
894
- JOIN ai.skill_access skill_access ON skill_access.skill_id = snapshot.skill_id
895
- JOIN auth.access access ON access.id = skill_access.access_id
1062
+ JOIN LATERAL (${effectivePermission(sql`snapshot.skill_id`, subject)}) access ON true
896
1063
  LEFT JOIN ai.skill_user_disabled disabled
897
1064
  ON disabled.skill_id = snapshot.skill_id AND disabled.user_id = ${userId}::uuid
898
- WHERE snapshot.turn_id = ${turnId}::uuid AND access.permission <> 'none' AND ${match}
1065
+ WHERE snapshot.turn_id = ${turnId}::uuid AND access.permission <> 'none'
899
1066
  AND disabled.user_id IS NULL
900
1067
  ORDER BY snapshot.skill_name
901
1068
  `;
@@ -914,21 +1081,23 @@ export const aiSkills = {
914
1081
  const stat = files.find((file) => file.path === path);
915
1082
  if (!stat) return null;
916
1083
  const [skillName] = path.split("/", 1);
917
- const match = accessMatch(subject);
918
1084
  const userId = subject?.type === "user" ? subject.userId : null;
919
1085
  const [row] = await sql<SnapshotRow[]>`
920
1086
  SELECT DISTINCT snapshot.skill_name, snapshot.description, snapshot.revision, snapshot.instructions,
921
1087
  snapshot.files, snapshot.loaded_at
922
1088
  FROM ai.turn_skill_snapshots snapshot
923
- JOIN ai.skill_access skill_access ON skill_access.skill_id = snapshot.skill_id
924
- JOIN auth.access access ON access.id = skill_access.access_id
1089
+ JOIN LATERAL (${effectivePermission(sql`snapshot.skill_id`, subject)}) access ON true
925
1090
  LEFT JOIN ai.skill_user_disabled disabled
926
1091
  ON disabled.skill_id = snapshot.skill_id AND disabled.user_id = ${userId}::uuid
927
1092
  WHERE snapshot.turn_id = ${turnId}::uuid AND snapshot.skill_name = ${skillName}
928
- AND access.permission <> 'none' AND ${match} AND disabled.user_id IS NULL
1093
+ AND access.permission <> 'none' AND disabled.user_id IS NULL
929
1094
  LIMIT 1
930
1095
  `;
931
1096
  const file = row ? snapshotFiles(row.files).find((entry) => entry.path === path) : null;
932
1097
  return file ? { ...stat, bytes: new TextEncoder().encode(file.content) } : null;
933
1098
  },
934
1099
  };
1100
+
1101
+ export const seedCloudAiSkills = async (): Promise<void> => {
1102
+ for (const seed of getBuiltinAiSkillTemplates()) await aiSkills.seedOnce(seed);
1103
+ };