@k2b/cloud 0.44.0 → 0.46.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 (137) hide show
  1. package/package.json +9 -2
  2. package/src/_internal/app-queries.ts +73 -0
  3. package/src/_internal/capabilities.ts +137 -9
  4. package/src/_internal/define-app.ts +2 -0
  5. package/src/_internal/registry-validation.ts +17 -4
  6. package/src/_internal/registry.ts +32 -4
  7. package/src/_internal/resource-principals.ts +36 -0
  8. package/src/access/GroupCoverage.tsx +20 -5
  9. package/src/access/PermissionEditor.tsx +11 -2
  10. package/src/access/PrincipalAvatar.tsx +30 -21
  11. package/src/access/messages.ts +4 -4
  12. package/src/account/EntitySearch.tsx +12 -0
  13. package/src/ai/capabilities.ts +4 -2
  14. package/src/ai/chat/map-messages.ts +270 -0
  15. package/src/ai/chat/map-notebook.browser-harness.tsx +28 -0
  16. package/src/ai/chat/map-notebook.tsx +301 -0
  17. package/src/ai/chat/map-widget.browser-harness.tsx +71 -0
  18. package/src/ai/chat/map-widget.css +107 -0
  19. package/src/ai/chat/map-widget.tsx +1279 -0
  20. package/src/ai/chat/message-utils.ts +1 -0
  21. package/src/ai/chat/tool-names.ts +2 -0
  22. package/src/ai/chat/turn-layout.ts +2 -1
  23. package/src/ai/chat/turn-view.tsx +17 -1
  24. package/src/ai/default-tools.ts +3 -0
  25. package/src/ai/dictation-runtime.ts +6 -2
  26. package/src/ai/executor.ts +22 -4
  27. package/src/ai/index.ts +1 -0
  28. package/src/ai/map-contracts.ts +209 -0
  29. package/src/ai/map-execution.ts +14 -0
  30. package/src/ai/map-notebook-save.ts +276 -0
  31. package/src/ai/map-notebook.ts +32 -0
  32. package/src/ai/map-opening-hours.ts +40 -0
  33. package/src/ai/map-routes.ts +84 -0
  34. package/src/ai/map-service.ts +288 -0
  35. package/src/ai/map-store.ts +137 -0
  36. package/src/ai/map-tool.ts +73 -0
  37. package/src/ai/migrate.ts +3 -0
  38. package/src/ai/project-tool.ts +4 -4
  39. package/src/ai/projects-authorization.ts +53 -0
  40. package/src/ai/projects-routes.ts +56 -46
  41. package/src/ai/projects.ts +100 -79
  42. package/src/ai/protocol.ts +3 -1
  43. package/src/ai/recent-resources.ts +3 -3
  44. package/src/ai/routes.ts +32 -30
  45. package/src/ai/skill-tool.ts +4 -4
  46. package/src/ai/skills-routes.ts +27 -26
  47. package/src/ai/skills.ts +120 -81
  48. package/src/ai/store.ts +19 -6
  49. package/src/ai/tools.ts +14 -0
  50. package/src/ai/types.ts +3 -0
  51. package/src/api/accounts-entities.ts +6 -3
  52. package/src/api/admin-ai-skills.ts +4 -1
  53. package/src/api/auth.ts +9 -2
  54. package/src/api/capabilities.ts +90 -12
  55. package/src/api/mcp.ts +4 -2
  56. package/src/browser/notifications.ts +25 -8
  57. package/src/browser/session.ts +39 -0
  58. package/src/browser/shared-text-history-validation.ts +151 -0
  59. package/src/browser/shared-text-history.ts +464 -0
  60. package/src/browser/shared-text-offline-authorized.ts +29 -0
  61. package/src/browser/shared-text-offline.ts +351 -0
  62. package/src/browser/shared-text-provider.ts +714 -0
  63. package/src/browser/shared-text-table-contract.ts +150 -0
  64. package/src/browser/shared-text.ts +19 -0
  65. package/src/capabilities/discussion-providers.ts +99 -0
  66. package/src/capabilities/executions.ts +2 -2
  67. package/src/capabilities/migrate.ts +10 -1
  68. package/src/capabilities/resource-readers.ts +58 -0
  69. package/src/capabilities/server-invoke.ts +23 -13
  70. package/src/capabilities/server.ts +17 -6
  71. package/src/capabilities/testing.ts +13 -0
  72. package/src/contracts/capabilities.ts +137 -4
  73. package/src/contracts/discussions.ts +91 -0
  74. package/src/contracts/geo-document.ts +60 -0
  75. package/src/contracts/geo-snapshot.ts +86 -0
  76. package/src/contracts/geo.ts +14 -0
  77. package/src/contracts/index.ts +2 -0
  78. package/src/contracts/outgoing-mail.ts +1 -1
  79. package/src/contracts/resource-types.ts +21 -0
  80. package/src/contracts/shared.ts +2 -1
  81. package/src/geo/AddressAutocomplete.tsx +213 -0
  82. package/src/geo/messages.ts +29 -0
  83. package/src/geo/navigation.ts +25 -0
  84. package/src/geo/ui.ts +2 -0
  85. package/src/server/api/respond.ts +10 -2
  86. package/src/server/index.ts +1 -0
  87. package/src/server/services/access.ts +23 -10
  88. package/src/server/services/index.ts +1 -0
  89. package/src/services/accounts/app.ts +56 -17
  90. package/src/services/accounts/entities.ts +36 -16
  91. package/src/services/accounts/group-sql.ts +23 -0
  92. package/src/services/accounts/groups.ts +26 -17
  93. package/src/services/accounts/identities.ts +8 -1
  94. package/src/services/accounts/identity-reconciliation.ts +3 -1
  95. package/src/services/accounts/local-groups.ts +141 -27
  96. package/src/services/accounts/machine-group-management.ts +94 -0
  97. package/src/services/accounts/users.ts +38 -8
  98. package/src/services/geo/cache.ts +2 -1
  99. package/src/services/geo/geoapify.ts +3 -0
  100. package/src/services/identity/invocation-token.ts +110 -0
  101. package/src/services/mandates/index.ts +2 -2
  102. package/src/services/mandates/policy.ts +1 -0
  103. package/src/services/pdf/geo-snapshot.ts +107 -0
  104. package/src/services/pdf/index.ts +2 -0
  105. package/src/services/session/index.ts +12 -3
  106. package/src/shared/email-html.ts +1 -0
  107. package/src/shared/markdown/extensions/katex.ts +8 -1
  108. package/src/shared/markdown/extensions/tables.ts +14 -1
  109. package/src/shared/markdown/index.ts +22 -11
  110. package/src/shared-text/anchors.ts +29 -0
  111. package/src/shared-text/awareness.ts +87 -0
  112. package/src/shared-text/changes.ts +27 -0
  113. package/src/shared-text/documents.ts +428 -77
  114. package/src/shared-text/events.ts +12 -2
  115. package/src/shared-text/index.ts +16 -1
  116. package/src/shared-text/log.ts +24 -5
  117. package/src/shared-text/snapshots.ts +69 -23
  118. package/src/shared-text/structural-history.ts +377 -0
  119. package/src/shared-text/table-actions.ts +432 -0
  120. package/src/shared-text/types.ts +24 -5
  121. package/src/ssr/AdminArea.tsx +2 -2
  122. package/src/ssr/AdminLayout.tsx +1 -1
  123. package/src/ssr/AdminSidebar.tsx +1 -1
  124. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  125. package/src/ssr/CloudShell.island.tsx +19 -3
  126. package/src/ssr/Layout.tsx +1 -1
  127. package/src/ssr/MobileProfileActions.tsx +2 -2
  128. package/src/ssr/ProfilePreferences.tsx +2 -1
  129. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
  130. package/src/ssr/admin-navigation.ts +1 -11
  131. package/src/ssr/app-launchpad.tsx +6 -1
  132. package/src/ssr/platform-messages.ts +2 -0
  133. package/src/ssr/profile-actions.ts +3 -6
  134. package/src/ssr/shell-data.tsx +1 -1
  135. package/src/styles/global.css +2 -0
  136. package/src/workflows/language/expressions.ts +3 -2
  137. package/src/workflows/runtime/executor.ts +2 -1
package/src/ai/skills.ts CHANGED
@@ -1,23 +1,28 @@
1
1
  import { type SQL, type SQLQuery, sql } from "bun";
2
2
  import { listApps } from "../_internal/registry";
3
3
  import { AuthenticatedPrincipalSchema, type ServiceAccountKind } from "../contracts/shared";
4
- import type { AccessSubject } from "../server";
5
4
  import type { RequestAuthority } from "../server/middleware/auth";
6
5
  import {
7
6
  buildAccessPrincipalCondition,
8
7
  createAccess,
9
8
  deleteAccess,
9
+ ensureManagerRemains,
10
10
  hasPermission,
11
11
  type PermissionLevel,
12
12
  type Principal,
13
13
  } from "../server/services/access";
14
14
  import { accessRevision } from "../server/services/access-revision";
15
- import { getRoles } from "../services/accounts/users";
16
- import { usableAppIds } from "../services/app-access";
15
+ import { evaluateAppAccess, usableAppIds } from "../services/app-access";
17
16
  import { logger } from "../services/logging";
18
17
  import { toPgTextArray, toPgUuidArray } from "../services/postgres";
19
18
  import { mountAiSkillFilePath } from "./file-mount";
20
19
  import { aiProjects } from "./projects";
20
+ import {
21
+ type AiResourceContext,
22
+ aiResourceAccessSubject,
23
+ aiResourcePermissionCap,
24
+ requireAiResourceCreation,
25
+ } from "./projects-authorization";
21
26
  import { AI_SHORT_ID_PATTERN, withAiShortIdForDb } from "./short-id";
22
27
  import { type AiSkillContent, appSkillStatus, contentHash } from "./skill-content";
23
28
  import {
@@ -67,7 +72,7 @@ export type AiSkillAccess = {
67
72
  permission: AiSkillPermission;
68
73
  displayName?: string;
69
74
  avatarHash?: string | null;
70
- /** Kind of a `service_account` principal; presentation only. */
75
+ /** Kind of a `service_account` principal, including manager eligibility. */
71
76
  serviceAccountKind?: ServiceAccountKind;
72
77
  createdAt: string;
73
78
  };
@@ -200,13 +205,21 @@ const jsonObject = (value: AiSkillExtraFrontmatter | string): AiSkillExtraFrontm
200
205
  const snapshotFiles = (value: SnapshotRow["files"]): { path: string; content: string }[] =>
201
206
  typeof value === "string" ? (JSON.parse(value) as { path: string; content: string }[]) : value;
202
207
 
203
- const principalForSubject = (subject: AccessSubject): Principal =>
204
- subject.type === "user"
208
+ const aiResourceUserId = (input: AiResourceContext | null): string | null => {
209
+ const subject = aiResourceAccessSubject(input);
210
+ return subject?.type === "user" ? subject.userId : null;
211
+ };
212
+
213
+ const principalForSubject = (input: AiResourceContext): Principal => {
214
+ const subject = aiResourceAccessSubject(input)!;
215
+ return subject.type === "user"
205
216
  ? { type: "user", userId: subject.userId }
206
217
  : { type: "service_account", serviceAccountId: subject.serviceAccountId };
218
+ };
207
219
 
208
- const accessMatch = (subject: AccessSubject | null): SQLQuery =>
209
- subject === null
220
+ const accessMatch = (input: AiResourceContext | null, audience: SkillAudience): SQLQuery => {
221
+ const subject = aiResourceAccessSubject(input);
222
+ return subject === null || (aiResourcePermissionCap(input) === "none" && audience.brokerReadAppIds.length === 0)
210
223
  ? sql`FALSE`
211
224
  : buildAccessPrincipalCondition({
212
225
  subject,
@@ -217,6 +230,7 @@ const accessMatch = (subject: AccessSubject | null): SQLQuery =>
217
230
  authenticatedOnly: sql`access.authenticated_only`,
218
231
  },
219
232
  });
233
+ };
220
234
 
221
235
  export const getRow = async (skillId: string, db: SQL = sql): Promise<SkillRow | null> =>
222
236
  (await db<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${skillId}::uuid`)[0] ?? null;
@@ -231,38 +245,64 @@ const getRowByName = async (name: string, db: SQL = sql): Promise<SkillRow | nul
231
245
  * Resolve before opening a transaction: role lookup uses its own pool connection, and a transaction that waits for a
232
246
  * second connection can exhaust the pool under concurrent requests.
233
247
  */
234
- type SkillAudience = { roles: readonly string[]; usableAppIds: readonly string[]; declaredAppIds: readonly string[] };
235
- const subjectRoles = async (subject: AccessSubject | null, authority: RequestAuthority | null = null): Promise<SkillAudience> => {
236
- const actor = authority?.actor;
237
- const roles = actor
238
- ? actor.kind === "user"
239
- ? actor.user.roles
240
- : (actor.delegatedUser?.roles ?? [])
241
- : subject?.type === "user"
242
- ? await getRoles(subject.userId)
243
- : [];
248
+ type SkillAudience = {
249
+ roles: readonly string[];
250
+ usableAppIds: readonly string[];
251
+ declaredAppIds: readonly string[];
252
+ brokerReadAppIds: readonly string[];
253
+ };
254
+ const subjectRoles = async (subject: AiResourceContext | null, authority: RequestAuthority | null = null): Promise<SkillAudience> => {
255
+ const actor = authority?.actor ?? subject?.actor;
256
+ const roles = actor?.kind === "user" ? actor.user.roles : (actor?.delegatedUser?.roles ?? []);
244
257
  const apps = await listApps().catch(() => {
245
258
  logger("ai:skills").warn("App registry unavailable; app Skills are unavailable");
246
259
  return [];
247
260
  });
261
+ const usable = [...(await usableAppIds(authority, apps))];
262
+ const resourceActor = subject?.actor;
263
+ // App Skill brokers support independent machines, including resource bindings.
264
+ // Keep identity matching and credential checks even for legacy undeclared apps.
265
+ const brokerAuthority: RequestAuthority | null =
266
+ authority &&
267
+ resourceActor?.kind === "service_account" &&
268
+ !resourceActor.delegatedUser &&
269
+ ["standalone", "agent", "resource_bound"].includes(resourceActor.serviceAccount.kind) &&
270
+ subject?.accessSubject.type === "service_account" &&
271
+ subject.accessSubject.serviceAccountId === resourceActor.serviceAccount.id
272
+ ? { ...authority, actor: resourceActor, accessSubject: subject.accessSubject, scopes: resourceActor.scopes }
273
+ : null;
248
274
  return {
249
275
  roles,
250
- usableAppIds: [...(await usableAppIds(authority, apps))],
276
+ usableAppIds: usable,
251
277
  declaredAppIds: apps.filter((app) => app.access !== undefined).map((app) => app.id),
278
+ brokerReadAppIds: usable.filter(
279
+ (id) =>
280
+ evaluateAppAccess(authority, { id, access: { kind: "signedIn" } }) &&
281
+ evaluateAppAccess(brokerAuthority, { id, access: { kind: "signedIn" } }),
282
+ ),
252
283
  };
253
284
  };
254
285
 
255
286
  // App access applies to every grant; the older navigation audience keeps its legacy grant behavior.
256
- const effectivePermission = (skillId: SQLQuery, subject: AccessSubject | null, roles: SkillAudience) => sql`
257
- SELECT permission FROM (
287
+ const effectivePermission = (skillId: SQLQuery, subject: AiResourceContext | null, roles: SkillAudience) => sql`
288
+ SELECT CASE
289
+ WHEN permission = 'none' THEN permission
290
+ WHEN ${aiResourcePermissionCap(subject)} = 'none' THEN
291
+ CASE WHEN EXISTS (
292
+ SELECT 1 FROM ai.app_skills app WHERE app.skill_id=${skillId}
293
+ AND app.app_id = ANY(${toPgTextArray([...roles.brokerReadAppIds])}::text[])
294
+ ) THEN 'read'::auth.permission_level ELSE 'none'::auth.permission_level END
295
+ WHEN ${aiResourcePermissionCap(subject)} = 'read' THEN 'read'::auth.permission_level
296
+ WHEN ${aiResourcePermissionCap(subject)} = 'write' AND permission = 'admin' THEN 'write'::auth.permission_level
297
+ ELSE permission END AS permission FROM (
258
298
  SELECT access.permission, (access.user_id IS NOT NULL OR access.group_id IS NOT NULL OR access.service_account_id IS NOT NULL) AS direct
259
299
  FROM ai.skill_access link JOIN auth.access access ON access.id=link.access_id
260
- WHERE link.skill_id=${skillId} AND ${accessMatch(subject)}
300
+ WHERE link.skill_id=${skillId} AND ${accessMatch(subject, roles)}
261
301
  UNION ALL
262
302
  SELECT 'read'::auth.permission_level, false AS direct FROM ai.project_skills link
263
303
  JOIN ai.project_access project_access ON project_access.project_id=link.project_id
264
304
  JOIN auth.access access ON access.id=project_access.access_id
265
- WHERE link.skill_id=${skillId} AND access.permission <> 'none' AND ${accessMatch(subject)}
305
+ WHERE link.skill_id=${skillId} AND access.permission <> 'none' AND ${accessMatch(subject, roles)}
266
306
  ) grants WHERE NOT EXISTS (
267
307
  SELECT 1 FROM ai.app_skills app WHERE app.skill_id=${skillId}
268
308
  AND (NOT app.available OR NOT(app.app_id = ANY(${toPgTextArray([...roles.usableAppIds])}::text[])) OR (NOT (app.app_id = ANY(${toPgTextArray([...roles.declaredAppIds])}::text[])) AND NOT grants.direct AND app.required_roles IS NOT NULL AND NOT(app.required_roles && ${toPgTextArray([...roles.roles])}::text[])))
@@ -271,7 +311,7 @@ const effectivePermission = (skillId: SQLQuery, subject: AccessSubject | null, r
271
311
 
272
312
  const permissionFor = async (
273
313
  skillId: string,
274
- subject: AccessSubject | null,
314
+ subject: AiResourceContext | null,
275
315
  db: SQL,
276
316
  roles: SkillAudience,
277
317
  ): Promise<AiSkillPermission | "none"> => {
@@ -283,7 +323,7 @@ const permissionFor = async (
283
323
 
284
324
  const canMutate = async (
285
325
  skillId: string,
286
- subject: AccessSubject | null,
326
+ subject: AiResourceContext | null,
287
327
  required: AiSkillPermission,
288
328
  db: SQL,
289
329
  roles: SkillAudience,
@@ -297,12 +337,13 @@ const canMutate = async (
297
337
  return false;
298
338
  };
299
339
 
300
- const enabledFor = async (skillId: string, subject: AccessSubject | null, db: SQL = sql): Promise<boolean> => {
301
- if (subject?.type !== "user") return true;
340
+ const enabledFor = async (skillId: string, subject: AiResourceContext | null, db: SQL = sql): Promise<boolean> => {
341
+ const accessSubject = aiResourceAccessSubject(subject);
342
+ if (accessSubject?.type !== "user") return true;
302
343
  const [disabled] = await db<{ disabled: boolean }[]>`
303
344
  SELECT true AS disabled
304
345
  FROM ai.skill_user_disabled
305
- WHERE skill_id = ${skillId}::uuid AND user_id = ${subject.userId}::uuid
346
+ WHERE skill_id = ${skillId}::uuid AND user_id = ${aiResourceUserId(subject)}::uuid
306
347
  `;
307
348
  return !disabled;
308
349
  };
@@ -314,7 +355,7 @@ export const listReferences = async (skillId: string, db: SQL = sql): Promise<Ai
314
355
  ORDER BY path
315
356
  `;
316
357
 
317
- const toSkill = async (row: SkillRow, subject: AccessSubject | null, db: SQL, roles: SkillAudience): Promise<AiSkill | null> => {
358
+ const toSkill = async (row: SkillRow, subject: AiResourceContext | null, db: SQL, roles: SkillAudience): Promise<AiSkill | null> => {
318
359
  const permission = await permissionFor(row.id, subject, db, roles);
319
360
  if (permission === "none") return null;
320
361
  const references = await listReferences(row.id, db);
@@ -343,7 +384,7 @@ const toSkill = async (row: SkillRow, subject: AccessSubject | null, db: SQL, ro
343
384
 
344
385
  const requireSkill = async (
345
386
  row: SkillRow | null,
346
- subject: AccessSubject | null,
387
+ subject: AiResourceContext | null,
347
388
  required: AiSkillPermission,
348
389
  db: SQL,
349
390
  roles: SkillAudience,
@@ -420,40 +461,33 @@ const checkAccessRevision = async (skillId: string, expected: string | undefined
420
461
  };
421
462
 
422
463
  const updateSkillAccess = async (skillId: string, accessId: string, permission: AiSkillPermission, db: SQL): Promise<boolean> => {
423
- const [bound] = await db<{ id: string; permission: AiSkillPermission }[]>`
424
- SELECT access.id, access.permission
464
+ const [bound] = await db<{ id: string }[]>`
465
+ SELECT access.id
425
466
  FROM ai.skill_access skill_access
426
467
  JOIN auth.access access ON access.id = skill_access.access_id
427
468
  WHERE skill_access.skill_id = ${skillId}::uuid AND skill_access.short_id = ${accessId}
428
469
  `;
429
470
  if (!bound) return false;
430
- if (bound.permission === "admin" && permission !== "admin") {
431
- const [admins] = await db<{ count: number }[]>`
432
- SELECT count(*)::int AS count FROM ai.skill_access skill_access
433
- JOIN auth.access access ON access.id = skill_access.access_id
434
- WHERE skill_access.skill_id = ${skillId}::uuid AND access.permission = 'admin'
435
- `;
436
- if ((admins?.count ?? 0) <= 1) throw new AiSkillLastAdminError();
437
- }
471
+ const before = await listSkillAccess(skillId, db);
472
+ const guarded = ensureManagerRemains({
473
+ before,
474
+ after: before.map((entry) => (entry.shortId === accessId ? { ...entry, permission } : entry)),
475
+ });
476
+ if (!guarded.ok) throw new AiSkillLastAdminError();
438
477
  return (await db`UPDATE auth.access SET permission = ${permission}::auth.permission_level WHERE id = ${bound.id}::uuid`).count === 1;
439
478
  };
440
479
 
441
480
  const revokeSkillAccess = async (skillId: string, accessId: string, db: SQL): Promise<boolean> => {
442
- const [bound] = await db<{ access_id: string; permission: AiSkillPermission }[]>`
443
- SELECT skill_access.access_id, access.permission
481
+ const [bound] = await db<{ access_id: string }[]>`
482
+ SELECT skill_access.access_id
444
483
  FROM ai.skill_access skill_access
445
484
  JOIN auth.access access ON access.id = skill_access.access_id
446
485
  WHERE skill_access.skill_id = ${skillId}::uuid AND skill_access.short_id = ${accessId}
447
486
  `;
448
487
  if (!bound) return false;
449
- if (bound.permission === "admin") {
450
- const [admins] = await db<{ count: number }[]>`
451
- SELECT count(*)::int AS count FROM ai.skill_access skill_access
452
- JOIN auth.access access ON access.id = skill_access.access_id
453
- WHERE skill_access.skill_id = ${skillId}::uuid AND access.permission = 'admin'
454
- `;
455
- if ((admins?.count ?? 0) <= 1) throw new AiSkillLastAdminError();
456
- }
488
+ const before = await listSkillAccess(skillId, db);
489
+ const guarded = ensureManagerRemains({ before, after: before.filter((entry) => entry.shortId !== accessId) });
490
+ if (!guarded.ok) throw new AiSkillLastAdminError();
457
491
  const deleted = await deleteAccess({ id: bound.access_id }, db);
458
492
  if (!deleted.ok) throw new Error(deleted.error.message);
459
493
  return true;
@@ -554,7 +588,7 @@ type SkillListOptions = {
554
588
  limit?: number;
555
589
  };
556
590
  async function listSkillSummaries(
557
- subject: AccessSubject | null,
591
+ subject: AiResourceContext | null,
558
592
  options: SkillListOptions & {
559
593
  project?: { id: string; linked: boolean };
560
594
  manageOnly?: boolean;
@@ -562,7 +596,7 @@ async function listSkillSummaries(
562
596
  } = {},
563
597
  ): Promise<AiSkillSummary[]> {
564
598
  const roles = await subjectRoles(subject, options.authority);
565
- const userId = subject?.type === "user" ? subject.userId : null;
599
+ const userId = aiResourceUserId(subject);
566
600
  const query = (options.query ?? "").trim().slice(0, 200);
567
601
  const limit = Math.max(1, Math.min(201, options.limit ?? 200));
568
602
  const rows = await withAiSkillSearch(query, async (bm25) => {
@@ -607,7 +641,7 @@ async function listSkillSummaries(
607
641
  }));
608
642
  }
609
643
 
610
- async function linkedSkillProjects(skillId: string, subject: AccessSubject | null) {
644
+ async function linkedSkillProjects(skillId: string, subject: AiResourceContext | null) {
611
645
  const links = await sql<
612
646
  { projectId: string }[]
613
647
  >`SELECT project_id AS "projectId" FROM ai.project_skills WHERE skill_id=${skillId}::uuid ORDER BY project_id`;
@@ -621,8 +655,12 @@ async function linkedSkillProjects(skillId: string, subject: AccessSubject | nul
621
655
 
622
656
  export type AiConversationSkillUse = { name: string; description: string; turns: number; lastLoadedAt: string };
623
657
 
624
- const listSkillTurnFiles = async (turnId: string, subject: AccessSubject | null, roles: SkillAudience): Promise<AiSkillFileToolStat[]> => {
625
- const userId = subject?.type === "user" ? subject.userId : null;
658
+ const listSkillTurnFiles = async (
659
+ turnId: string,
660
+ subject: AiResourceContext | null,
661
+ roles: SkillAudience,
662
+ ): Promise<AiSkillFileToolStat[]> => {
663
+ const userId = aiResourceUserId(subject);
626
664
  const rows = await sql<SnapshotRow[]>`
627
665
  SELECT DISTINCT snapshot.skill_name, snapshot.description, snapshot.revision, snapshot.instructions,
628
666
  snapshot.files, snapshot.loaded_at
@@ -671,7 +709,7 @@ export const aiSkills = {
671
709
 
672
710
  async create(
673
711
  input: {
674
- subject: AccessSubject;
712
+ subject: AiResourceContext;
675
713
  name: string;
676
714
  description: string;
677
715
  instructions: string;
@@ -680,6 +718,7 @@ export const aiSkills = {
680
718
  },
681
719
  authority: RequestAuthority | null = null,
682
720
  ): Promise<AiSkill> {
721
+ requireAiResourceCreation(input.subject);
683
722
  const fields = validatedFields(input);
684
723
  const roles = await subjectRoles(input.subject, authority);
685
724
  return sql.begin(async (tx) => {
@@ -706,13 +745,13 @@ export const aiSkills = {
706
745
  });
707
746
  },
708
747
 
709
- async list(subject: AccessSubject | null, options: SkillListOptions = {}): Promise<AiSkillSummary[]> {
748
+ async list(subject: AiResourceContext | null, options: SkillListOptions = {}): Promise<AiSkillSummary[]> {
710
749
  return listSkillSummaries(subject, options);
711
750
  },
712
751
 
713
752
  async projectSkills(
714
753
  projectId: string,
715
- subject: AccessSubject | null,
754
+ subject: AiResourceContext | null,
716
755
  options: { query?: string; page?: number; available?: boolean; authority?: RequestAuthority | null } = {},
717
756
  ) {
718
757
  if (!(await aiProjects.get(projectId, subject, options.available ? "admin" : "read"))) return null;
@@ -732,7 +771,7 @@ export const aiSkills = {
732
771
  skillId: string,
733
772
  projectId: string,
734
773
  linked: boolean,
735
- subject: AccessSubject | null,
774
+ subject: AiResourceContext | null,
736
775
  authority: RequestAuthority | null = null,
737
776
  ): Promise<boolean> {
738
777
  if (!(await aiProjects.get(projectId, subject, "admin"))) return false;
@@ -747,13 +786,13 @@ export const aiSkills = {
747
786
  });
748
787
  },
749
788
 
750
- async linkedProjects(skillId: string, subject: AccessSubject | null, authority: RequestAuthority | null = null) {
789
+ async linkedProjects(skillId: string, subject: AiResourceContext | null, authority: RequestAuthority | null = null) {
751
790
  if (!(await this.get(skillId, subject, "admin", authority))) return null;
752
791
  return linkedSkillProjects(skillId, subject);
753
792
  },
754
793
 
755
794
  async search(
756
- subject: AccessSubject | null,
795
+ subject: AiResourceContext | null,
757
796
  query: string,
758
797
  limit = 30,
759
798
  options: { modelInvocableOnly?: boolean; authority?: RequestAuthority | null } = {},
@@ -765,7 +804,7 @@ export const aiSkills = {
765
804
 
766
805
  async get(
767
806
  skillId: string,
768
- subject: AccessSubject | null,
807
+ subject: AiResourceContext | null,
769
808
  required: AiSkillPermission = "read",
770
809
  authority: RequestAuthority | null = null,
771
810
  ): Promise<AiSkill | null> {
@@ -774,7 +813,7 @@ export const aiSkills = {
774
813
 
775
814
  async getByShortId(
776
815
  shortId: string,
777
- subject: AccessSubject | null,
816
+ subject: AiResourceContext | null,
778
817
  required: AiSkillPermission = "read",
779
818
  authority: RequestAuthority | null = null,
780
819
  ): Promise<AiSkill | null> {
@@ -783,7 +822,7 @@ export const aiSkills = {
783
822
 
784
823
  async getByName(
785
824
  name: string,
786
- subject: AccessSubject | null,
825
+ subject: AiResourceContext | null,
787
826
  required: AiSkillPermission = "read",
788
827
  authority: RequestAuthority | null = null,
789
828
  ): Promise<AiSkill | null> {
@@ -792,7 +831,7 @@ export const aiSkills = {
792
831
 
793
832
  async update(
794
833
  skillId: string,
795
- subject: AccessSubject | null,
834
+ subject: AiResourceContext | null,
796
835
  input: {
797
836
  expectedRevision: number;
798
837
  name: string;
@@ -828,7 +867,7 @@ export const aiSkills = {
828
867
  });
829
868
  },
830
869
 
831
- async delete(skillId: string, subject: AccessSubject | null, authority: RequestAuthority | null = null): Promise<boolean> {
870
+ async delete(skillId: string, subject: AiResourceContext | null, authority: RequestAuthority | null = null): Promise<boolean> {
832
871
  const roles = await subjectRoles(subject, authority);
833
872
  return sql.begin(async (tx) => {
834
873
  const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${skillId}::uuid FOR UPDATE`;
@@ -844,7 +883,7 @@ export const aiSkills = {
844
883
 
845
884
  async listAccess(
846
885
  skillId: string,
847
- subject: AccessSubject | null,
886
+ subject: AiResourceContext | null,
848
887
  authority: RequestAuthority | null = null,
849
888
  ): Promise<AiSkillAccess[] | null> {
850
889
  const roles = await subjectRoles(subject, authority);
@@ -857,7 +896,7 @@ export const aiSkills = {
857
896
 
858
897
  async grantAccess(
859
898
  skillId: string,
860
- subject: AccessSubject | null,
899
+ subject: AiResourceContext | null,
861
900
  input: { principal: Principal; permission: AiSkillPermission },
862
901
  expectedAccessRevision?: string,
863
902
  authority: RequestAuthority | null = null,
@@ -874,7 +913,7 @@ export const aiSkills = {
874
913
  async updateAccess(
875
914
  skillId: string,
876
915
  accessId: string,
877
- subject: AccessSubject | null,
916
+ subject: AiResourceContext | null,
878
917
  permission: AiSkillPermission,
879
918
  expectedAccessRevision?: string,
880
919
  authority: RequestAuthority | null = null,
@@ -891,7 +930,7 @@ export const aiSkills = {
891
930
  async revokeAccess(
892
931
  skillId: string,
893
932
  accessId: string,
894
- subject: AccessSubject | null,
933
+ subject: AiResourceContext | null,
895
934
  expectedAccessRevision?: string,
896
935
  authority: RequestAuthority | null = null,
897
936
  ): Promise<boolean> {
@@ -1032,23 +1071,23 @@ export const aiSkills = {
1032
1071
 
1033
1072
  async setEnabled(
1034
1073
  skillId: string,
1035
- subject: AccessSubject | null,
1074
+ subject: AiResourceContext | null,
1036
1075
  enabled: boolean,
1037
1076
  authority: RequestAuthority | null = null,
1038
1077
  ): Promise<boolean | null> {
1039
- if (subject?.type !== "user") return null;
1078
+ if (!aiResourceUserId(subject)) return null;
1040
1079
  const roles = await subjectRoles(subject, authority);
1041
1080
  return sql.begin(async (tx) => {
1042
1081
  if (!(await requireSkill(await getRow(skillId, tx), subject, "read", tx, roles))) return null;
1043
1082
  if (enabled) {
1044
1083
  await tx`
1045
1084
  DELETE FROM ai.skill_user_disabled
1046
- WHERE skill_id = ${skillId}::uuid AND user_id = ${subject.userId}::uuid
1085
+ WHERE skill_id = ${skillId}::uuid AND user_id = ${aiResourceUserId(subject)}::uuid
1047
1086
  `;
1048
1087
  } else {
1049
1088
  await tx`
1050
1089
  INSERT INTO ai.skill_user_disabled (skill_id, user_id)
1051
- VALUES (${skillId}::uuid, ${subject.userId}::uuid)
1090
+ VALUES (${skillId}::uuid, ${aiResourceUserId(subject)}::uuid)
1052
1091
  ON CONFLICT (skill_id, user_id) DO NOTHING
1053
1092
  `;
1054
1093
  }
@@ -1058,7 +1097,7 @@ export const aiSkills = {
1058
1097
 
1059
1098
  async setReference(
1060
1099
  skillId: string,
1061
- subject: AccessSubject | null,
1100
+ subject: AiResourceContext | null,
1062
1101
  input: { expectedRevision: number; path: string; content: string },
1063
1102
  authority: RequestAuthority | null = null,
1064
1103
  ): Promise<AiSkill | null> {
@@ -1075,7 +1114,7 @@ export const aiSkills = {
1075
1114
 
1076
1115
  async setReferences(
1077
1116
  skillId: string,
1078
- subject: AccessSubject | null,
1117
+ subject: AiResourceContext | null,
1079
1118
  input: { expectedRevision: number; references: readonly AiSkillReferenceInput[] },
1080
1119
  authority: RequestAuthority | null = null,
1081
1120
  ): Promise<AiSkill | null> {
@@ -1109,7 +1148,7 @@ export const aiSkills = {
1109
1148
 
1110
1149
  async removeReference(
1111
1150
  skillId: string,
1112
- subject: AccessSubject | null,
1151
+ subject: AiResourceContext | null,
1113
1152
  input: { expectedRevision: number; path: string },
1114
1153
  authority: RequestAuthority | null = null,
1115
1154
  ): Promise<AiSkill | null> {
@@ -1135,7 +1174,7 @@ export const aiSkills = {
1135
1174
  async loadForTurn(
1136
1175
  turnId: string,
1137
1176
  selector: string | { id: string },
1138
- subject: AccessSubject | null,
1177
+ subject: AiResourceContext | null,
1139
1178
  authority: RequestAuthority | null = null,
1140
1179
  ): Promise<AiLoadedSkillSnapshot | null> {
1141
1180
  const name = typeof selector === "string" ? validateAiSkillName(selector) : null;
@@ -1181,7 +1220,7 @@ export const aiSkills = {
1181
1220
 
1182
1221
  async listTurnFiles(
1183
1222
  turnId: string,
1184
- subject: AccessSubject | null,
1223
+ subject: AiResourceContext | null,
1185
1224
  authority: RequestAuthority | null = null,
1186
1225
  ): Promise<AiSkillFileToolStat[]> {
1187
1226
  return listSkillTurnFiles(turnId, subject, await subjectRoles(subject, authority));
@@ -1190,7 +1229,7 @@ export const aiSkills = {
1190
1229
  async readTurnFile(
1191
1230
  turnId: string,
1192
1231
  path: string,
1193
- subject: AccessSubject | null,
1232
+ subject: AiResourceContext | null,
1194
1233
  authority: RequestAuthority | null = null,
1195
1234
  ): Promise<AiSkillFileToolContent | null> {
1196
1235
  const roles = await subjectRoles(subject, authority);
@@ -1198,7 +1237,7 @@ export const aiSkills = {
1198
1237
  const stat = files.find((file) => file.path === path);
1199
1238
  if (!stat) return null;
1200
1239
  const [skillName] = path.split("/", 1);
1201
- const userId = subject?.type === "user" ? subject.userId : null;
1240
+ const userId = aiResourceUserId(subject);
1202
1241
  const [row] = await sql<SnapshotRow[]>`
1203
1242
  SELECT DISTINCT snapshot.skill_name, snapshot.description, snapshot.revision, snapshot.instructions,
1204
1243
  snapshot.files, snapshot.loaded_at
package/src/ai/store.ts CHANGED
@@ -7,12 +7,14 @@ import { type CapabilityActionReviewPresentation, CapabilityActionReviewPresenta
7
7
  import { logger } from "../services/logging";
8
8
  import { toPgTextArray } from "../services/postgres";
9
9
  import { AI_TOOL_CHECK_STALE_MS, type AiActionCheck, type AiStoredActionReview } from "./action-review";
10
+ import { AiMapSchema } from "./map-contracts";
10
11
 
11
12
  export { AI_TOOL_CHECK_STALE_MS };
12
13
 
13
14
  import { AI_WEBSITE_APPROVAL_TOOL } from "./approvals";
14
15
  import { projectSettledAiToolChecks } from "./capability-result";
15
16
  import { aiTurnErrorText } from "./chat/turn-error";
17
+ import { projectAiMaps } from "./map-store";
16
18
  import { AI_MEMORY_LEARNING_DEFAULT_ENABLED } from "./prefs";
17
19
  import type { AiTurnBlock } from "./protocol";
18
20
  import { collectRecentSentResourceRefs, RECENT_SENT_CONVERSATION_SCAN_LIMIT, RECENT_SENT_MESSAGE_SCAN_LIMIT } from "./recent-sent-refs";
@@ -1515,6 +1517,17 @@ export const aiConversations: AiConversationService = {
1515
1517
  SELECT ${target.id}::uuid, path, bytes, media_type, size, origin, dictation_recorded_at, updated_at, version
1516
1518
  FROM ai.files WHERE conversation_id = ${input.sourceConversationId}::uuid
1517
1519
  `;
1520
+ // A fork owns independent snapshots. Rewrite only copied map references, never the source conversation.
1521
+ const mapRows = await tx<
1522
+ { id: string; snapshot: unknown; retry: unknown }[]
1523
+ >`SELECT id,snapshot,retry FROM ai.maps WHERE conversation_id=${input.sourceConversationId}::uuid AND (id IN (SELECT (message->'result'->'map'->>'id')::uuid FROM ai.messages WHERE conversation_id=${target.id}::uuid AND role='tool_result' AND message->>'name'='map') OR snapshot->>'originCallId' IN (SELECT block->>'id' FROM ai.messages CROSS JOIN LATERAL jsonb_array_elements(message->'content') block WHERE conversation_id=${target.id}::uuid AND role='assistant' AND block->>'type'='tool_call' AND block->>'name'='map'))`;
1524
+ for (const saved of mapRows) {
1525
+ const id = crypto.randomUUID();
1526
+ const snapshot = AiMapSchema.parse(typeof saved.snapshot === "string" ? JSON.parse(saved.snapshot) : saved.snapshot);
1527
+ const next = { ...snapshot, id, conversationId: target.short_id };
1528
+ await tx`INSERT INTO ai.maps(id,conversation_id,revision,snapshot,retry) VALUES(${id}::uuid,${target.id}::uuid,${snapshot.revision},${JSON.stringify(next)}::text::jsonb,${saved.retry ? JSON.stringify(saved.retry) : null}::text::jsonb)`;
1529
+ await tx`UPDATE ai.messages SET message=jsonb_set(message,'{result,map}',${JSON.stringify({ ...next })}::text::jsonb) WHERE conversation_id=${target.id}::uuid AND role='tool_result' AND message->>'name'='map' AND message->'result'->'map'->>'id'=${saved.id}`;
1530
+ }
1518
1531
  return rowToConversation(target);
1519
1532
  });
1520
1533
  },
@@ -2569,7 +2582,7 @@ export const aiConversations: AiConversationService = {
2569
2582
  AND NOT (kind = 'summary' AND compacted_at IS NOT NULL)
2570
2583
  ORDER BY seq ASC, (kind = 'summary')::int ASC
2571
2584
  `;
2572
- return rows.map(rowToMessage);
2585
+ return projectAiMaps(rows.map(rowToMessage));
2573
2586
  },
2574
2587
 
2575
2588
  listMessagesPage: async (input) => {
@@ -2598,7 +2611,7 @@ export const aiConversations: AiConversationService = {
2598
2611
  AND NOT (kind = 'summary' AND compacted_at IS NOT NULL)
2599
2612
  ORDER BY seq ASC, (kind = 'summary')::int ASC
2600
2613
  `;
2601
- const messages = rows.map(rowToMessage);
2614
+ const messages = await projectAiMaps(rows.map(rowToMessage));
2602
2615
  const oldestSeq = messages[0]?.seq;
2603
2616
  if (oldestSeq === undefined) return { messages, hasMore: false };
2604
2617
  const older = await sql<{ exists: boolean }[]>`
@@ -2639,7 +2652,7 @@ export const aiConversations: AiConversationService = {
2639
2652
  `;
2640
2653
  const page = rows.filter((row) => Number(row.page_rank) <= limit);
2641
2654
  return {
2642
- messages: page.map(rowToMessage),
2655
+ messages: await projectAiMaps(page.map(rowToMessage)),
2643
2656
  ...(rows.some((row) => Number(row.page_rank) > limit) && page.at(-1) ? { nextCursor: String(page.at(-1)!.seq) } : {}),
2644
2657
  };
2645
2658
  },
@@ -2758,7 +2771,7 @@ export const aiConversations: AiConversationService = {
2758
2771
  AND compacted_at IS NULL
2759
2772
  ORDER BY seq ASC
2760
2773
  `;
2761
- return rows.map(rowToMessage);
2774
+ return projectAiMaps(rows.map(rowToMessage));
2762
2775
  },
2763
2776
 
2764
2777
  listTurnMessages: async (input) => {
@@ -2773,7 +2786,7 @@ export const aiConversations: AiConversationService = {
2773
2786
  AND (${input.includeCompacted ?? false} OR compacted_at IS NULL)
2774
2787
  ORDER BY seq ASC
2775
2788
  `;
2776
- return rows.map(rowToMessage);
2789
+ return projectAiMaps(rows.map(rowToMessage));
2777
2790
  },
2778
2791
 
2779
2792
  setMessageFeedback: async (input) => {
@@ -3968,7 +3981,7 @@ export const aiConversations: AiConversationService = {
3968
3981
  ).map(rowToMessage)
3969
3982
  : await aiConversations.listContextMessages({ conversationId: input.conversationId });
3970
3983
  return projectSettledAiToolChecks(
3971
- rows.map((row) => ({
3984
+ (await projectAiMaps(rows, true)).map((row) => ({
3972
3985
  seq: row.seq,
3973
3986
  kind: row.kind,
3974
3987
  meta: row.meta,
package/src/ai/tools.ts CHANGED
@@ -4,6 +4,7 @@ import type { z } from "zod";
4
4
  import type { CapabilityActionCallReview } from "../contracts/capabilities";
5
5
  import type { RequestActor } from "../server";
6
6
  import { aiToolNeedsApproval } from "./approvals";
7
+ import { resolveAiToolResultMaxChars } from "./tool-result-budget";
7
8
  import type {
8
9
  AiApprovalTarget,
9
10
  AiDataBoundary,
@@ -64,6 +65,8 @@ export const defineAiTool = <TInput extends z.ZodType, TOutput extends z.ZodType
64
65
  timeZone?: string;
65
66
  /** Localized short status; never include payloads or secrets. */
66
67
  reportProgress?: (message: string) => Promise<void>;
68
+ /** A validated result snapshot while a server tool is still running. */
69
+ reportResult?: (result: z.infer<TOutput>) => Promise<void>;
67
70
  /** Asks like `requestApproval`, and lets the person remember the approval of `target`. */
68
71
  requestApprovalFor?: (message: string, target: AiApprovalTarget, review?: CapabilityActionCallReview) => Promise<boolean>;
69
72
  /** Relays a decision for an effect another platform component consumes and executes. */
@@ -117,6 +120,8 @@ export type AiToolPreparationContext = {
117
120
  locale?: string;
118
121
  timeZone?: string;
119
122
  reportToolProgress?: (callId: string, message: string) => Promise<void>;
123
+ reportToolResult?: (callId: string, result: unknown) => Promise<void>;
124
+ maxToolResultChars?: number;
120
125
  /** Uses the approved snapshot immediately before the tool executes its effect. */
121
126
  consumeApproval?: (approvalCallId: string, input: unknown) => Promise<AiApprovalUse>;
122
127
  /** Links a relayed decision to the record the effect owner will consume. */
@@ -241,6 +246,15 @@ export const prepareAiTools = (input: AiToolPreparationContext & { tools?: AiRun
241
246
  locale: input.locale,
242
247
  timeZone: input.timeZone,
243
248
  reportProgress: callId && report ? (message) => report(callId, message) : undefined,
249
+ reportResult:
250
+ callId && input.reportToolResult
251
+ ? async (result) => {
252
+ const validated = tool.def.outputSchema.parse(result);
253
+ const maximum = input.maxToolResultChars ?? resolveAiToolResultMaxChars({ configuredMaxChars: Number.MAX_SAFE_INTEGER });
254
+ if (JSON.stringify(validated).length > maximum) throw new Error("AI_TOOL_INTERIM_RESULT_TOO_LARGE");
255
+ await input.reportToolResult!(callId, validated);
256
+ }
257
+ : undefined,
244
258
  reportWebsiteReceipts:
245
259
  callId && input.reportWebsiteReceipts ? (receipts) => input.reportWebsiteReceipts!(callId, receipts) : undefined,
246
260
  });