@k2b/cloud 0.29.1 → 0.31.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 (136) hide show
  1. package/package.json +4 -2
  2. package/src/_internal/app-presentation.ts +22 -1
  3. package/src/_internal/app-roles.ts +14 -0
  4. package/src/_internal/capabilities.ts +113 -5
  5. package/src/_internal/capability-sentences.ts +413 -0
  6. package/src/_internal/define-app.ts +24 -5
  7. package/src/_internal/registry-validation.ts +5 -0
  8. package/src/_internal/registry.ts +50 -6
  9. package/src/_internal/widget-declarations.ts +53 -0
  10. package/src/access/PermissionEditor.tsx +43 -24
  11. package/src/ai/app-skill-store.ts +298 -0
  12. package/src/ai/app-skills.ts +105 -0
  13. package/src/ai/approval-routes.ts +40 -4
  14. package/src/ai/approvals.ts +68 -10
  15. package/src/ai/browser.ts +10 -0
  16. package/src/ai/capabilities.ts +68 -9
  17. package/src/ai/chart-block.ts +380 -0
  18. package/src/ai/chat/action-sentences.ts +49 -0
  19. package/src/ai/chat/blocks.tsx +97 -40
  20. package/src/ai/chat/chart-block.tsx +79 -0
  21. package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
  22. package/src/ai/chat/message-actions.tsx +3 -1
  23. package/src/ai/chat/message-utils.ts +3 -0
  24. package/src/ai/chat/messages.ts +26 -2
  25. package/src/ai/chat/tool-groups.ts +66 -12
  26. package/src/ai/chat/tool-names.ts +6 -2
  27. package/src/ai/chat/turn-layout.ts +10 -2
  28. package/src/ai/chat/turn-view.tsx +123 -36
  29. package/src/ai/chat/web-tools.tsx +95 -1
  30. package/src/ai/client/controller.ts +6 -3
  31. package/src/ai/code-runtime-tools.ts +109 -61
  32. package/src/ai/code-source-contracts.ts +25 -21
  33. package/src/ai/code-source-tools.ts +3 -1
  34. package/src/ai/default-tool-contracts.ts +198 -0
  35. package/src/ai/default-tools.ts +25 -1
  36. package/src/ai/executor.ts +189 -35
  37. package/src/ai/fetch-file-tool.ts +41 -4
  38. package/src/ai/firecrawl-tools.ts +87 -4
  39. package/src/ai/index.ts +17 -1
  40. package/src/ai/migrate.ts +172 -14
  41. package/src/ai/protocol.ts +22 -1
  42. package/src/ai/routes.ts +11 -0
  43. package/src/ai/runtime-tools.ts +1 -0
  44. package/src/ai/runtime.ts +46 -7
  45. package/src/ai/skill-content.ts +54 -0
  46. package/src/ai/skills-routes.ts +22 -13
  47. package/src/ai/skills.ts +177 -231
  48. package/src/ai/store.ts +66 -1
  49. package/src/ai/tool-audit.ts +11 -1
  50. package/src/ai/tool-sentences.ts +38 -0
  51. package/src/ai/tools.ts +91 -3
  52. package/src/ai/types.ts +47 -1
  53. package/src/ai/web-provenance.ts +103 -0
  54. package/src/api/admin-ai-skills.ts +35 -16
  55. package/src/api/capabilities.ts +3 -2
  56. package/src/api/fanout.ts +128 -0
  57. package/src/api/help.ts +8 -3
  58. package/src/api/mcp.ts +4 -1
  59. package/src/api/search.ts +31 -106
  60. package/src/api/widgets.ts +291 -101
  61. package/src/browser/CloudResourceSearch.browser-harness.tsx +1 -1
  62. package/src/browser/CloudResourceSearch.tsx +1 -1
  63. package/src/browser/command-shortcuts.ts +21 -6
  64. package/src/browser/ndjson.ts +34 -0
  65. package/src/browser/reload.ts +46 -0
  66. package/src/browser/search-stream.ts +4 -26
  67. package/src/browser/spotlight-position.ts +4 -1
  68. package/src/browser/widgets.ts +30 -0
  69. package/src/capabilities/executions.ts +3 -0
  70. package/src/capabilities/server.ts +20 -2
  71. package/src/cli/access.ts +48 -14
  72. package/src/cli/admin/ai-skills.ts +59 -39
  73. package/src/cli/commands.ts +20 -0
  74. package/src/cli/index.ts +16 -0
  75. package/src/cli/plugin.ts +30 -0
  76. package/src/contracts/announcement-cookie.ts +62 -0
  77. package/src/contracts/announcements.ts +14 -52
  78. package/src/contracts/app.ts +34 -2
  79. package/src/contracts/capabilities.ts +24 -0
  80. package/src/contracts/pwa-paths.ts +7 -2
  81. package/src/contracts/pwa.ts +2 -7
  82. package/src/contracts/registry.ts +10 -1
  83. package/src/contracts/shared.ts +5 -0
  84. package/src/contracts/widgets.ts +67 -2
  85. package/src/desktop/solid.tsx +6 -7
  86. package/src/index.ts +1 -0
  87. package/src/server/help.ts +8 -1
  88. package/src/server/index.ts +1 -0
  89. package/src/server/widget-request.ts +27 -0
  90. package/src/services/accounts/users.ts +15 -0
  91. package/src/services/approvals/index.ts +225 -0
  92. package/src/services/approvals/migrate.ts +33 -0
  93. package/src/services/help/index.ts +18 -5
  94. package/src/services/help/types.ts +3 -2
  95. package/src/services/identity/constants.ts +2 -0
  96. package/src/services/identity/key-ring.ts +123 -42
  97. package/src/services/identity/runtime-config.ts +13 -2
  98. package/src/shared/app-presentation.ts +11 -0
  99. package/src/shared/email-html.ts +43 -8
  100. package/src/ssr/AdminLayout.tsx +4 -7
  101. package/src/ssr/AppLaunchpad.island.tsx +1 -1
  102. package/src/ssr/AppLaunchpadPanel.tsx +12 -3
  103. package/src/ssr/GlobalAnnouncements.island.tsx +2 -2
  104. package/src/ssr/GlobalSearchDialog.tsx +1 -43
  105. package/src/ssr/GlobalSearchTrigger.island.tsx +12 -3
  106. package/src/ssr/Layout.tsx +38 -14
  107. package/src/ssr/LayoutFooter.tsx +1 -1
  108. package/src/ssr/LayoutHeader.tsx +17 -20
  109. package/src/ssr/LayoutHelp.tsx +1 -1
  110. package/src/ssr/LayoutHelpTrigger.island.tsx +1 -1
  111. package/src/ssr/LayoutRail.tsx +1 -2
  112. package/src/ssr/PageTrail.tsx +23 -0
  113. package/src/ssr/PageTrailRow.island.tsx +20 -0
  114. package/src/ssr/PageTrailRuntime.island.tsx +26 -0
  115. package/src/ssr/ProfilePreferences.island.tsx +7 -3
  116. package/src/ssr/PwaRuntime.island.tsx +1 -1
  117. package/src/ssr/app-navigation.ts +5 -7
  118. package/src/ssr/global-search-host.tsx +55 -0
  119. package/src/ssr/help.ts +3 -3
  120. package/src/ssr/islands/index.ts +1 -0
  121. package/src/ssr/layout-runtime.ts +60 -0
  122. package/src/ssr/mobile-menu-history.ts +1 -1
  123. package/src/ssr/page-trail-runtime.ts +313 -0
  124. package/src/ssr/page-trail.browser-harness.tsx +216 -0
  125. package/src/ssr/page-trail.ts +59 -0
  126. package/src/ssr/platform-messages.ts +4 -0
  127. package/src/ssr/rail-context.ts +4 -3
  128. package/src/ssr/runtime.ts +14 -1
  129. package/src/styles/effects.css +21 -4
  130. package/src/styles/global.css +35 -2
  131. package/src/styles/utilities-layout.css +24 -1
  132. package/src/styles/utilities-navigation.css +6 -18
  133. package/src/ai/code-mode-skill.ts +0 -114
  134. package/src/ai/data-analysis-skill.ts +0 -10
  135. package/src/ai/grids-skill.ts +0 -158
  136. package/src/ai/skill-seeds.ts +0 -560
package/src/ai/index.ts CHANGED
@@ -1,13 +1,20 @@
1
+ export { type AiAppSkillIssue, aiAppSkills, appSkillsRegistryState, reconcileAppSkills } from "./app-skill-store";
1
2
  export { type AiApprovalPreferenceRoutes, type AiApprovalPreferenceView, createAiApprovalPreferenceRoutes } from "./approval-routes";
2
3
  export {
4
+ AI_WEBSITE_APPROVAL_TOOL,
3
5
  type AiToolApprovalContext,
4
6
  type AiToolApprovalPreference,
7
+ type AiToolApprovalRemember,
5
8
  aiToolAllowsAlways,
6
9
  aiToolApprovalScope,
7
10
  aiToolNeedsApproval,
11
+ aiTurnAllowsWebsiteApprovals,
12
+ aiWebsiteApprovalScope,
13
+ findRememberedAiToolApproval,
8
14
  forgetAiToolApproval,
9
15
  hasRememberedAiToolApproval,
10
16
  listAiToolApprovalPreferences,
17
+ parseAiWebsiteApprovalScope,
11
18
  rememberAiToolApproval,
12
19
  revokeAiToolApprovalPreference,
13
20
  } from "./approvals";
@@ -47,11 +54,17 @@ export { authorizeCodeExecution } from "./code-execution";
47
54
  export { CODE_SOURCE_TOOLS, type CodeSourceToolName } from "./code-source-contracts";
48
55
  export { listAiCredentialProfileIds } from "./credentials";
49
56
  export {
57
+ CLOUD_AI_CHART_MAX_SERIES,
58
+ CLOUD_AI_CHART_MAX_VALUES,
50
59
  CLOUD_AI_TEXT_EDITOR_MAX_CHARS,
51
60
  type CloudAiCardInput,
52
61
  CloudAiCardInputSchema,
53
62
  type CloudAiCardOutput,
54
63
  CloudAiCardOutputSchema,
64
+ type CloudAiChartInput,
65
+ CloudAiChartInputSchema,
66
+ type CloudAiChartOutput,
67
+ CloudAiChartOutputSchema,
55
68
  type CloudAiLocalBashInput,
56
69
  CloudAiLocalBashInputSchema,
57
70
  type CloudAiLocalBashOutput,
@@ -64,6 +77,7 @@ export {
64
77
  CloudAiTextEditorInputSchema,
65
78
  type CloudAiTextEditorOutput,
66
79
  CloudAiTextEditorOutputSchema,
80
+ parseCloudAiChartInput,
67
81
  } from "./default-tool-contracts";
68
82
  export {
69
83
  type AiChatEnrichment,
@@ -258,6 +272,7 @@ export {
258
272
  type AiSkillAccess,
259
273
  type AiSkillAdminListItem,
260
274
  type AiSkillAdminSummary,
275
+ AiSkillAppForbiddenError,
261
276
  AiSkillInputError,
262
277
  AiSkillLastAdminError,
263
278
  type AiSkillPermission,
@@ -265,7 +280,6 @@ export {
265
280
  AiSkillRevisionConflictError,
266
281
  type AiSkillSummary,
267
282
  aiSkills,
268
- seedCloudAiSkills,
269
283
  } from "./skills";
270
284
  export { type AiSkillsRoutes, aiSkillsRoutes } from "./skills-routes";
271
285
  export { aiConversations } from "./store";
@@ -301,6 +315,7 @@ export {
301
315
  } from "./transcription";
302
316
  export type {
303
317
  AiAccessResult,
318
+ AiApprovalTarget,
304
319
  AiCapabilityToolPresentation,
305
320
  AiClientToolId,
306
321
  AiConversation,
@@ -352,6 +367,7 @@ export type {
352
367
  AiTurnStatus,
353
368
  AiTurnToolSource,
354
369
  AiUserContentPart,
370
+ AiWebsiteReceipt,
355
371
  } from "./types";
356
372
  export { AI_MESSAGE_FEEDBACK_REASONS, isAiImageMediaType } from "./types";
357
373
  export { isAiSettingsError } from "./validate";
package/src/ai/migrate.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { sql } from "bun";
2
+ import { CapabilityActionReviewSchema } from "../contracts/capabilities";
3
+ import { APPROVAL_MAX_WAIT_MS, approvalAssurance, approvalScopeFromToolName, approvals, migrateApprovals } from "../services/approvals";
2
4
  import { migrateAiMessageQueue } from "./message-queue";
3
5
  import { migrateAiModelAccess } from "./model-access-migrate";
4
6
  import { migrateAiQuotas } from "./quotas-migrate";
@@ -21,6 +23,8 @@ export const migrateCloudAi = async (): Promise<void> => {
21
23
  if (!outbox?.ready) {
22
24
  throw new Error("AI live updates write to events.outbox, which does not exist. Run Core's events migration before the AI migration.");
23
25
  }
26
+ // Approval requests reference auth.users, so they follow the check that Core's own migrations ran.
27
+ await migrateApprovals();
24
28
  await sql`CREATE SCHEMA IF NOT EXISTS ai`.simple();
25
29
  console.log(" ✓ ai schema");
26
30
 
@@ -640,7 +644,11 @@ export const migrateCloudAi = async (): Promise<void> => {
640
644
  PRIMARY KEY (turn_id, request_id)
641
645
  )`.simple();
642
646
 
647
+ await sql`ALTER TABLE ai.pending_actions ADD COLUMN IF NOT EXISTS approval_id UUID`.simple();
643
648
  await sql`ALTER TABLE ai.pending_actions ADD COLUMN IF NOT EXISTS review JSONB`.simple();
649
+ // A code run asks for its nested HTTP requests and Actions; their remembered approval names a website or Action, not the run.
650
+ await sql`ALTER TABLE ai.pending_actions ADD COLUMN IF NOT EXISTS remember_tool_name TEXT`.simple();
651
+ await sql`ALTER TABLE ai.pending_actions ADD COLUMN IF NOT EXISTS allow_chat BOOLEAN NOT NULL DEFAULT FALSE`.simple();
644
652
 
645
653
  await sql`
646
654
  CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_pending_actions_turn_call
@@ -700,13 +708,39 @@ export const migrateCloudAi = async (): Promise<void> => {
700
708
  FROM ai.tool_approval_preferences
701
709
  WHERE tool_name ~ '^[a-z][a-z0-9-]*__(query|action)__'
702
710
  AND NOT (length(tool_name) = 64 AND tool_name ~ '__[0-9a-f]{12}$')
703
- ON CONFLICT (actor_user_id, tool_name, approval_scope) DO NOTHING
711
+ ON CONFLICT DO NOTHING
704
712
  `.simple();
705
713
  await sql`
706
714
  DELETE FROM ai.tool_approval_preferences
707
715
  WHERE tool_name ~ '^[a-z][a-z0-9-]*__(query|action)__'
708
716
  `.simple();
709
717
 
718
+ // An approval limited to one chat ends with that chat. NULL means it applies everywhere.
719
+ await sql`
720
+ ALTER TABLE ai.tool_approval_preferences
721
+ ADD COLUMN IF NOT EXISTS conversation_id UUID REFERENCES ai.conversations(id) ON DELETE CASCADE
722
+ `.simple();
723
+ await sql`
724
+ CREATE UNIQUE INDEX IF NOT EXISTS ai_tool_approval_preferences_reach
725
+ ON ai.tool_approval_preferences(actor_user_id, tool_name, approval_scope, conversation_id) NULLS NOT DISTINCT
726
+ `.simple();
727
+ await sql`ALTER TABLE ai.tool_approval_preferences DROP CONSTRAINT IF EXISTS ai_tool_approval_preferences_unique`.simple();
728
+ await sql`
729
+ CREATE INDEX IF NOT EXISTS idx_ai_tool_approval_preferences_conversation
730
+ ON ai.tool_approval_preferences(conversation_id)
731
+ WHERE conversation_id IS NOT NULL
732
+ `.simple();
733
+ // Addresses a chat's web searches returned and the pages it read link to, normalized; web_extract and fetch_file
734
+ // read them without asking. Recorded when the tool runs, so a read never scans stored results.
735
+ await sql`
736
+ CREATE TABLE IF NOT EXISTS ai.web_addresses (
737
+ conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
738
+ address TEXT NOT NULL,
739
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
740
+ PRIMARY KEY (conversation_id, address)
741
+ )
742
+ `.simple();
743
+
710
744
  await sql`
711
745
  CREATE TABLE IF NOT EXISTS ai.tool_calls (
712
746
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -724,9 +758,23 @@ export const migrateCloudAi = async (): Promise<void> => {
724
758
  rejected_at TIMESTAMPTZ,
725
759
  completed_at TIMESTAMPTZ,
726
760
  CONSTRAINT ai_tool_calls_status_check CHECK (status IN ('pending', 'running', 'waiting_for_approval', 'waiting_for_frontend', 'completed', 'failed', 'rejected')),
727
- CONSTRAINT ai_tool_calls_approval_state_check CHECK (approval_state IN ('not_required', 'waiting', 'approved_once', 'approved_always', 'approved_by_preference', 'rejected'))
761
+ CONSTRAINT ai_tool_calls_approval_state_check CHECK (approval_state IN ('not_required', 'waiting', 'approved_once', 'approved_for_chat', 'approved_always', 'approved_by_preference', 'rejected'))
728
762
  )
729
763
  `.simple();
764
+ await sql`
765
+ DO $$
766
+ BEGIN
767
+ IF NOT EXISTS (
768
+ SELECT 1 FROM pg_constraint
769
+ WHERE conname = 'ai_tool_calls_approval_state_check'
770
+ AND pg_get_constraintdef(oid) LIKE '%approved_for_chat%'
771
+ ) THEN
772
+ ALTER TABLE ai.tool_calls DROP CONSTRAINT IF EXISTS ai_tool_calls_approval_state_check;
773
+ ALTER TABLE ai.tool_calls ADD CONSTRAINT ai_tool_calls_approval_state_check
774
+ CHECK (approval_state IN ('not_required', 'waiting', 'approved_once', 'approved_for_chat', 'approved_always', 'approved_by_preference', 'rejected'));
775
+ END IF;
776
+ END $$
777
+ `.simple();
730
778
 
731
779
  await sql`
732
780
  CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_tool_calls_turn_call
@@ -738,6 +786,7 @@ export const migrateCloudAi = async (): Promise<void> => {
738
786
  ON ai.tool_calls(conversation_id, created_at DESC)
739
787
  `.simple();
740
788
 
789
+ await sql`ALTER TABLE ai.tool_calls ADD COLUMN IF NOT EXISTS approval_decided_via TEXT`.simple();
741
790
  await sql`ALTER TABLE ai.tool_calls ADD COLUMN IF NOT EXISTS idempotency_key TEXT`.simple();
742
791
 
743
792
  // Capability shape, outcome, and latency belong to capabilities.executions,
@@ -1620,18 +1669,65 @@ export const migrateCloudAi = async (): Promise<void> => {
1620
1669
  await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_skills_name ON ai.skills(name)`.simple();
1621
1670
  await sql`ALTER TABLE ai.skills ADD COLUMN IF NOT EXISTS managed_key TEXT`.simple();
1622
1671
  await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_skills_managed_key ON ai.skills(managed_key) WHERE managed_key IS NOT NULL`.simple();
1623
- await sql`
1624
- CREATE TABLE IF NOT EXISTS ai.skill_seeds (
1625
- key TEXT PRIMARY KEY,
1626
- seeded_at TIMESTAMPTZ NOT NULL DEFAULT now()
1627
- )
1628
- `.simple();
1629
- // A seed's UUID deliberately has no FK: it is a durable deletion tombstone.
1630
- await sql`ALTER TABLE ai.skill_seeds ADD COLUMN IF NOT EXISTS skill_id UUID`.simple();
1631
- await sql`ALTER TABLE ai.skill_seeds ADD COLUMN IF NOT EXISTS catalog_version INTEGER NOT NULL DEFAULT 0`.simple();
1632
- await sql`ALTER TABLE ai.skills ADD COLUMN IF NOT EXISTS template_id TEXT,
1633
- ADD COLUMN IF NOT EXISTS template_version INTEGER, ADD COLUMN IF NOT EXISTS template_hash TEXT`.simple();
1634
- await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_skills_template_id ON ai.skills(template_id) WHERE template_id IS NOT NULL`.simple();
1672
+ await sql.begin(async (tx) => {
1673
+ await tx`SELECT pg_advisory_xact_lock(hashtext('cloud:ai:app-skills'))`;
1674
+ await tx`CREATE TABLE IF NOT EXISTS ai.app_skill_catalogs (
1675
+ app_id text NOT NULL, manifest_hash text NOT NULL, skills jsonb NOT NULL,
1676
+ created_at timestamptz NOT NULL DEFAULT now(), last_seen_at timestamptz NOT NULL DEFAULT now(),
1677
+ PRIMARY KEY (app_id, manifest_hash)
1678
+ )`.simple();
1679
+ await tx`CREATE INDEX IF NOT EXISTS idx_ai_app_skill_catalogs_last_seen ON ai.app_skill_catalogs(last_seen_at)`.simple();
1680
+ await tx`CREATE TABLE IF NOT EXISTS ai.app_skills (
1681
+ key text PRIMARY KEY, app_id text NOT NULL, app_name text NOT NULL, name text NOT NULL,
1682
+ skill_id uuid NULL, source jsonb NULL, source_hash text NULL, applied_hash text NULL,
1683
+ manifest_hash text NULL, available boolean NOT NULL DEFAULT false, required_roles text[] NULL,
1684
+ conflict text NULL CHECK (conflict IN ('name_taken', 'invalid')),
1685
+ updated_at timestamptz NOT NULL DEFAULT now()
1686
+ )`.simple();
1687
+ await tx`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_app_skills_skill ON ai.app_skills(skill_id) WHERE skill_id IS NOT NULL`.simple();
1688
+ await tx`CREATE INDEX IF NOT EXISTS idx_ai_app_skills_app ON ai.app_skills(app_id)`.simple();
1689
+ // Adopt only durable seed associations. A missing linked row remains a deletion tombstone.
1690
+ await tx`DO $$ BEGIN
1691
+ IF to_regclass('ai.skill_seeds') IS NOT NULL THEN
1692
+ -- Installations from before seed links lack these columns; their seeds adopt nothing.
1693
+ ALTER TABLE ai.skill_seeds ADD COLUMN IF NOT EXISTS skill_id uuid;
1694
+ ALTER TABLE ai.skills ADD COLUMN IF NOT EXISTS template_id text, ADD COLUMN IF NOT EXISTS template_hash text;
1695
+ -- A seed never installed its Skill again. An unlinked seed whose name is free stays deleted through a link
1696
+ -- to no Skill; when a Skill holds the name, it stays unlinked and an administrator decides who owns it.
1697
+ INSERT INTO ai.app_skills(key, app_id, app_name, name, skill_id, applied_hash)
1698
+ SELECT 'app:' || mapping.app_id || '/' || mapping.name, mapping.app_id, mapping.app_name, mapping.name,
1699
+ CASE WHEN seed.skill_id IS NOT NULL THEN seed.skill_id
1700
+ WHEN NOT EXISTS (SELECT 1 FROM ai.skills named WHERE named.name = mapping.name) THEN gen_random_uuid() END,
1701
+ CASE WHEN skill.template_id = seed.key THEN skill.template_hash ELSE NULL END
1702
+ FROM ai.skill_seeds seed
1703
+ JOIN (VALUES
1704
+ ('grids:cloud-grids', 'grids', 'Grids', 'cloud-grids'),
1705
+ ('assistant:cloud-assistant', 'assistant', 'Assistant', 'cloud-assistant'),
1706
+ ('assistant:scheduled-tasks', 'assistant', 'Assistant', 'scheduled-tasks'),
1707
+ ('core:skill-creator', 'assistant', 'Assistant', 'skill-creator'),
1708
+ ('mail:cloud-mail', 'mail', 'Mail', 'cloud-mail'),
1709
+ ('notebooks:cloud-notebooks', 'notebooks', 'Notebooks', 'cloud-notebooks'),
1710
+ ('contacts:cloud-contacts', 'contacts', 'Contacts', 'cloud-contacts'),
1711
+ ('spaces:cloud-spaces', 'spaces', 'Spaces', 'cloud-spaces'),
1712
+ ('weather:cloud-weather', 'weather', 'Weather', 'cloud-weather'),
1713
+ ('assistant:code-mode', 'assistant', 'Assistant', 'assistant-code-mode'),
1714
+ ('assistant:data-analysis', 'assistant', 'Assistant', 'assistant-data-analysis')
1715
+ ) mapping(seed_key, app_id, app_name, name) ON mapping.seed_key = seed.key
1716
+ LEFT JOIN ai.skills skill ON skill.id = seed.skill_id
1717
+ WHERE true
1718
+ ON CONFLICT (key) DO NOTHING;
1719
+ DROP TABLE ai.skill_seeds;
1720
+ END IF;
1721
+ END $$`.simple();
1722
+ // Only alter ai.skills once: ALTER TABLE takes an exclusive lock even when nothing changes.
1723
+ await tx`DO $$ BEGIN
1724
+ IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'ai' AND table_name = 'skills'
1725
+ AND column_name IN ('template_id', 'template_version', 'template_hash')) THEN
1726
+ ALTER TABLE ai.skills DROP COLUMN IF EXISTS template_id,
1727
+ DROP COLUMN IF EXISTS template_version, DROP COLUMN IF EXISTS template_hash;
1728
+ END IF;
1729
+ END $$`.simple();
1730
+ });
1635
1731
  await backfillAiShortIds(
1636
1732
  "idx_ai_skills_short_id",
1637
1733
  await sql<{ id: string }[]>`SELECT id FROM ai.skills WHERE short_id IS NULL`,
@@ -2478,5 +2574,67 @@ export const migrateCloudAi = async (): Promise<void> => {
2478
2574
  ` ! ${legacyPaths.files} conversation and ${legacyPaths.project_files} project file paths keep a non-NFC Unicode form because a normalized twin exists`,
2479
2575
  );
2480
2576
  }
2577
+ await backfillApprovalRequests();
2481
2578
  console.log(" ✓ ai conversation tables");
2482
2579
  };
2580
+
2581
+ const backfillApprovalRequests = async (): Promise<void> => {
2582
+ const decoded = (value: unknown): unknown => (typeof value === "string" ? JSON.parse(value) : value);
2583
+ while (true) {
2584
+ const count = await sql.begin(async (db) => {
2585
+ // Completed turns and ownerless chats cannot still ask a person; leave their legacy rows alone.
2586
+ const rows = await db<
2587
+ {
2588
+ id: string;
2589
+ turn_id: string;
2590
+ call_id: string;
2591
+ tool_name: string;
2592
+ approval_scope: string;
2593
+ message: string | null;
2594
+ review: unknown;
2595
+ args: unknown;
2596
+ allow_always: boolean;
2597
+ allow_chat: boolean;
2598
+ owner: string;
2599
+ title: string;
2600
+ expiry: Date;
2601
+ }[]
2602
+ >`SELECT a.*, c.created_by_user_id AS owner, c.title,
2603
+ COALESCE(t.deadline, a.created_at + interval '24 hours') AS expiry
2604
+ FROM ai.pending_actions a JOIN ai.turns t ON t.id=a.turn_id JOIN ai.conversations c ON c.id=a.conversation_id
2605
+ WHERE a.status='pending' AND a.kind IN ('approval','custom_approval') AND a.approval_id IS NULL
2606
+ AND c.created_by_user_id IS NOT NULL AND t.status='waiting_for_action'
2607
+ -- A running Assistant may already have asked again for this action; keep the request it saw.
2608
+ AND NOT EXISTS (SELECT 1 FROM approvals.requests r
2609
+ WHERE r.requester_kind='assistant_turn' AND r.requester_ref=a.turn_id::text || '/' || a.call_id)
2610
+ ORDER BY a.created_at, a.id LIMIT 200 FOR UPDATE OF a SKIP LOCKED`;
2611
+ for (const row of rows) {
2612
+ const review = CapabilityActionReviewSchema.safeParse(row.review == null ? null : decoded(row.review));
2613
+ // request computes both canonical digests; preserve the original wait deadline, including expiry.
2614
+ const record = await approvals.request(
2615
+ {
2616
+ ...approvalScopeFromToolName(row.tool_name),
2617
+ approvalScope: row.approval_scope,
2618
+ requester: { kind: "assistant_turn", ref: `${row.turn_id}/${row.call_id}`, appId: "assistant", title: row.title },
2619
+ subjectId: row.owner,
2620
+ approverUserId: row.owner,
2621
+ // Every turn that can ask allows remembering, so these flags hold what the call offers.
2622
+ assurance: approvalAssurance(row.allow_always || row.allow_chat),
2623
+ sentence: row.message ?? row.tool_name,
2624
+ review: review.success ? review.data : null,
2625
+ input: decoded(row.args),
2626
+ expiresInMs: Math.min(APPROVAL_MAX_WAIT_MS, row.expiry.getTime() - Date.now()),
2627
+ },
2628
+ db,
2629
+ );
2630
+ if (!record.ok) continue;
2631
+ await db`UPDATE approvals.requests SET expires_at=${row.expiry},
2632
+ status=CASE WHEN ${row.expiry} <= now() THEN 'expired' ELSE status END
2633
+ WHERE id=${record.id}::uuid AND status='pending'`;
2634
+ await db`UPDATE ai.pending_actions SET approval_id=${record.id}::uuid WHERE id=${row.id}::uuid`;
2635
+ }
2636
+ return rows.length;
2637
+ });
2638
+ if (!count) return;
2639
+ }
2640
+ };
@@ -44,7 +44,15 @@ export type AiTurnBlock =
44
44
  result?: unknown;
45
45
  isError?: boolean;
46
46
  /** Present while status is awaiting_approval. */
47
- approval?: { message?: string; review?: CapabilityActionReview; allowAlways: boolean };
47
+ approval?: {
48
+ message?: string;
49
+ review?: CapabilityActionReview;
50
+ allowAlways: boolean;
51
+ /** Whether the approval can be remembered for this chat. */
52
+ allowChat?: boolean;
53
+ /** The website origin a chat approval would allow, for an HTTP request a code run makes. */
54
+ website?: string;
55
+ };
48
56
  /**
49
57
  * `true` once the user approved this call in the chat; `false` when its turn ended while the approval still
50
58
  * waited. Either way the approval stays visible as a receipt.
@@ -54,6 +62,8 @@ export type AiTurnBlock =
54
62
  frontendMode?: AiFrontendToolMode;
55
63
  /** Saved Cloud-owned display snapshot for capability calls. */
56
64
  presentation?: AiToolPresentation;
65
+ /** Requests a website approval let through without asking, in order; shown whatever the call's outcome. */
66
+ receipts?: { method: string; url: string }[];
57
67
  }
58
68
  | { id: string; kind: "compaction"; status: "running" | "completed" | "skipped" | "failed"; result?: CompactResult };
59
69
 
@@ -215,6 +225,14 @@ const canonicalToolName = (name: string, presentation?: AiToolPresentation): str
215
225
  return candidates.length === 1 ? candidates[0]! : name;
216
226
  };
217
227
 
228
+ /** The receipts of one tool call, keyed by their index, in the order the requests went out. */
229
+ export const orderedWebsiteReceipts = (
230
+ receipts: Record<string, { method: string; url: string }> | undefined,
231
+ ): { method: string; url: string }[] =>
232
+ Object.entries(receipts ?? {})
233
+ .sort(([a], [b]) => Number(a) - Number(b))
234
+ .map(([, receipt]) => receipt);
235
+
218
236
  /**
219
237
  * Convert persisted loop messages into the block model. Shared by the executor
220
238
  * (baseline rebuild on claim) and the client (rendering finished turns), so a
@@ -228,6 +246,7 @@ export const buildBlocksFromMessages = (
228
246
  steerId?: string;
229
247
  toolPresentations?: Record<string, AiToolPresentation>;
230
248
  toolOutcomes?: Record<string, "rejected" | "approved" | "expired">;
249
+ websiteReceipts?: Record<string, Record<string, { method: string; url: string }>>;
231
250
  } | null;
232
251
  }[],
233
252
  ): AiTurnBlock[] => {
@@ -243,6 +262,7 @@ export const buildBlocksFromMessages = (
243
262
  blocks.push({ id: messageBlockId(seq, index), kind: "thinking", text: block.thinking });
244
263
  } else if (block.type === "tool_call") {
245
264
  const outcome = meta?.toolOutcomes?.[block.id];
265
+ const receipts = orderedWebsiteReceipts(meta?.websiteReceipts?.[block.id]);
246
266
  toolIndex.set(block.id, blocks.length);
247
267
  blocks.push({
248
268
  id: toolBlockId(block.id),
@@ -255,6 +275,7 @@ export const buildBlocksFromMessages = (
255
275
  // A turn that ended before an approved call returned records the approval on the call's message, and an
256
276
  // approval it left waiting as expired.
257
277
  ...(outcome === "approved" || outcome === "expired" ? { approved: outcome === "approved" } : {}),
278
+ ...(receipts.length ? { receipts } : {}),
258
279
  });
259
280
  }
260
281
  });
package/src/ai/routes.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  v,
19
19
  } from "../server";
20
20
  import { isRequestCredentialCurrent } from "../server/middleware/auth";
21
+ import { approvalDecisionChannel } from "../services/approvals";
21
22
  import { ImageMetadataError } from "../services/image-metadata";
22
23
  import { logger } from "../services/logging";
23
24
  import { coreSettings } from "../services/settings/api";
@@ -93,6 +94,8 @@ type AiChatRequestContext = {
93
94
  ownerUserId: string;
94
95
  modelPolicy: AiModelPolicy;
95
96
  toolApprovalContext: AiToolApprovalContext;
97
+ /** A person in a signed-in browser session, not `cld`, an API key, or another delegated credential. */
98
+ signedInSession: boolean;
96
99
  };
97
100
 
98
101
  const actorUser = (actor: RequestActor) => (actor.kind === "user" ? actor.user : actor.delegatedUser);
@@ -106,6 +109,7 @@ const resolveContext = async (c: Context<AuthContext>): Promise<AiChatRequestCon
106
109
  ownerUserId: user.id,
107
110
  modelPolicy: personalAiModelPolicy,
108
111
  toolApprovalContext: { actorUserId: user.id },
112
+ signedInSession: actor.kind === "user" && !actor.delegation && c.get("credentialKind") === "session",
109
113
  };
110
114
  };
111
115
 
@@ -890,6 +894,7 @@ export const aiRoutes = (() => {
890
894
  try {
891
895
  const submission = {
892
896
  assistantChat: true as const,
897
+ ...(ctx.signedInSession ? { signedInSession: true as const } : {}),
893
898
  conversationId: conversation.id,
894
899
  chatId: conversation.shortId,
895
900
  input,
@@ -999,6 +1004,7 @@ export const aiRoutes = (() => {
999
1004
  }
1000
1005
  const result = await submitAiChatTurn({
1001
1006
  assistantChat: true,
1007
+ ...(ctx.signedInSession ? { signedInSession: true as const } : {}),
1002
1008
  conversationId: conversation.id,
1003
1009
  chatId: conversation.shortId,
1004
1010
  input,
@@ -1121,7 +1127,12 @@ export const aiRoutes = (() => {
1121
1127
  turnId: turn.id,
1122
1128
  callId,
1123
1129
  action: c.req.valid("json"),
1130
+ decidedBy: {
1131
+ userId: ctx.ownerUserId,
1132
+ via: approvalDecisionChannel({ credentialKind: c.get("credentialKind"), actor: ctx.actor }),
1133
+ },
1124
1134
  toolApprovalContext: ctx.toolApprovalContext,
1135
+ signedInSession: ctx.signedInSession,
1125
1136
  });
1126
1137
  if (!result.ok) return toAiActionFailureResponse(c, result);
1127
1138
  return respond(c, ok({ ok: true }));
@@ -2,6 +2,7 @@ export { CloudAiTranscribeAudioInputSchema, CloudAiTranscribeAudioOutputSchema,
2
2
  export {
3
3
  CLOUD_AI_DEFERRED_BUILTIN_TOOL_NAMES,
4
4
  createCloudAiCardTool,
5
+ createCloudAiChartTool,
5
6
  createCloudAiCodeTools,
6
7
  createCloudAiLocalBashTool,
7
8
  createCloudAiSurveyTool,
package/src/ai/runtime.ts CHANGED
@@ -4,10 +4,11 @@ import type { QueueMessage } from "@k2b/sync";
4
4
  import { z } from "zod";
5
5
  import { lazySync } from "../_internal/process-sync";
6
6
  import type { RequestActor } from "../server";
7
+ import { type ApprovalDecisionChannel, approvals } from "../services/approvals";
7
8
  import { logger } from "../services/logging";
8
9
  import { superviseRuntimeTask } from "../services/runtime-lifecycle";
9
10
  import { coreSettings } from "../services/settings/api";
10
- import { type AiToolApprovalContext, aiTurnAllowsRememberedApprovals, rememberAiToolApproval } from "./approvals";
11
+ import { AI_WEBSITE_APPROVAL_TOOL, type AiToolApprovalContext, aiTurnAllowsRememberedApprovals, rememberAiToolApproval } from "./approvals";
11
12
  import { aiChatAccessSubject, selectAssistantAiModelId } from "./assistant-models";
12
13
  import { startAiDictationRuntime } from "./dictation-runtime";
13
14
  import { AiTurnExecutor } from "./executor";
@@ -87,6 +88,8 @@ export const enqueueExistingAiTurn = (input: AiTurnJob): Promise<unknown> => enq
87
88
  export type SubmitAiChatTurnInput = {
88
89
  /** Only interactive Assistant HTTP handlers set this server-owned marker. */
89
90
  assistantChat?: true;
91
+ /** Only a handler that saw a signed-in browser session sets this server-owned marker; see `AiChatTurnRunConfig`. */
92
+ signedInSession?: true;
90
93
  conversationId: string;
91
94
  /** Stable public ID exposed as runtime context, not instructions. */
92
95
  chatId?: string;
@@ -153,6 +156,7 @@ export const prepareAiChatTurn = async (input: SubmitAiChatTurnInput) => {
153
156
  ),
154
157
  ],
155
158
  ...(input.assistantChat ? { assistantChat: true } : {}),
159
+ ...(input.signedInSession ? { signedInSession: true } : {}),
156
160
  input: canonicalInput,
157
161
  chatId: input.chatId,
158
162
  actor: input.actor,
@@ -297,7 +301,7 @@ export const abortAiTurn = async (input: { conversationId: string; turnId: strin
297
301
  // ---------------------------------------------------------------------------
298
302
 
299
303
  export const AiTurnActionSchema = z.discriminatedUnion("type", [
300
- z.object({ type: z.literal("approval_response"), approved: z.boolean(), remember: z.literal("always").optional() }),
304
+ z.object({ type: z.literal("approval_response"), approved: z.boolean(), remember: z.enum(["chat", "always"]).optional() }),
301
305
  z.object({ type: z.literal("tool_result"), result: z.unknown() }),
302
306
  ]);
303
307
  export type AiTurnActionInput = z.infer<typeof AiTurnActionSchema>;
@@ -312,7 +316,7 @@ export const listPendingAiTurnActions = async (input: { conversationId: string;
312
316
  const [actions, config] = await Promise.all([aiConversations.listPendingTurnActions(input), aiConversations.getTurnRunConfig(input)]);
313
317
  return aiTurnAllowsRememberedApprovals(config)
314
318
  ? actions
315
- : actions.map((action) => (action.type === "approval_request" ? { ...action, allowAlways: false } : action));
319
+ : actions.map((action) => (action.type === "approval_request" ? { ...action, allowAlways: false, allowChat: false } : action));
316
320
  };
317
321
 
318
322
  export const submitAiTurnAction = async (input: {
@@ -320,7 +324,10 @@ export const submitAiTurnAction = async (input: {
320
324
  turnId: string;
321
325
  callId: string;
322
326
  action: AiTurnActionInput;
327
+ decidedBy: { userId: string; via: ApprovalDecisionChannel };
323
328
  toolApprovalContext?: AiToolApprovalContext;
329
+ /** Whether a person decides in a signed-in browser session; only such a decision may remember a website. */
330
+ signedInSession?: boolean;
324
331
  }): Promise<{ ok: true } | { ok: false; status: 400 | 404 | 409; message: string }> => {
325
332
  const pending = await aiConversations.getPendingTurnAction(input);
326
333
  if (!pending) return { ok: false, status: 404, message: "This request has expired — the assistant already moved on." };
@@ -338,18 +345,50 @@ export const submitAiTurnAction = async (input: {
338
345
 
339
346
  if (input.action.type === "approval_response") {
340
347
  if (pending.kind === "client_tool") return { ok: false, status: 400, message: "Frontend tool requests require a tool result." };
341
- if (input.action.remember === "always") {
348
+ const remember = input.action.remember;
349
+ if (remember) {
342
350
  const config = await aiConversations.getTurnRunConfig(input);
343
- if (!input.action.approved || !pending.allowAlways || !input.toolApprovalContext || !aiTurnAllowsRememberedApprovals(config)) {
351
+ const toolName = pending.rememberToolName ?? pending.name;
352
+ const allowed = remember === "always" ? pending.allowAlways : (pending.allowChat ?? pending.allowAlways);
353
+ if (
354
+ !input.action.approved ||
355
+ !allowed ||
356
+ !input.toolApprovalContext ||
357
+ !aiTurnAllowsRememberedApprovals(config) ||
358
+ // A website is remembered only by a person in a signed-in session: `cld`, an API key, or another delegated
359
+ // credential can approve this one request, but cannot let the next ones through.
360
+ (toolName === AI_WEBSITE_APPROVAL_TOOL && (remember !== "chat" || !input.signedInSession))
361
+ ) {
344
362
  return { ok: false, status: 400, message: "This approval cannot be remembered." };
345
363
  }
346
- await rememberAiToolApproval(input.toolApprovalContext, { toolName: pending.name, approvalScope: pending.approvalScope });
364
+ }
365
+ if (pending.approvalId) {
366
+ const decision = await approvals.decide(pending.approvalId, { approved: input.action.approved }, input.decidedBy);
367
+ if (!decision.ok)
368
+ return decision.code === "ALREADY_DECIDED"
369
+ ? { ok: false, status: 409, message: "AI action was already resolved with a different response." }
370
+ : { ok: false, status: 404, message: "This request has expired — the assistant already moved on." };
371
+ }
372
+ if (remember && input.toolApprovalContext) {
373
+ const toolName = pending.rememberToolName ?? pending.name;
374
+ await rememberAiToolApproval(input.toolApprovalContext, {
375
+ toolName,
376
+ approvalScope: pending.approvalScope,
377
+ conversationId: remember === "chat" ? input.conversationId : null,
378
+ });
347
379
  }
348
380
  await aiToolAudit
349
381
  .noteApprovalResolved({
350
382
  turnId: input.turnId,
351
383
  callId: input.callId,
352
- approvalState: input.action.approved ? (input.action.remember === "always" ? "approved_always" : "approved_once") : "rejected",
384
+ decidedVia: input.decidedBy.via,
385
+ approvalState: input.action.approved
386
+ ? remember === "always"
387
+ ? "approved_always"
388
+ : remember === "chat"
389
+ ? "approved_for_chat"
390
+ : "approved_once"
391
+ : "rejected",
353
392
  })
354
393
  .catch(() => undefined);
355
394
 
@@ -0,0 +1,54 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ type AiSkillDocument,
4
+ type AiSkillExtraFrontmatter,
5
+ type AiSkillReferenceInput,
6
+ validateAiSkillDescription,
7
+ validateAiSkillExtraFrontmatter,
8
+ validateAiSkillInstructions,
9
+ validateAiSkillName,
10
+ validateAiSkillReferences,
11
+ } from "./skill-format";
12
+
13
+ export type AiSkillContent = AiSkillDocument & { references: readonly AiSkillReferenceInput[] };
14
+
15
+ export const validateSkillContent = (input: {
16
+ name: string;
17
+ description: string;
18
+ instructions: string;
19
+ extraFrontmatter?: AiSkillExtraFrontmatter;
20
+ references?: readonly AiSkillReferenceInput[];
21
+ }): AiSkillContent => ({
22
+ name: validateAiSkillName(input.name),
23
+ description: validateAiSkillDescription(input.description),
24
+ instructions: validateAiSkillInstructions(input.instructions),
25
+ extraFrontmatter: validateAiSkillExtraFrontmatter(input.extraFrontmatter),
26
+ references: validateAiSkillReferences(input.references ?? []).sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)),
27
+ });
28
+
29
+ // Keep the former template hash algorithm: key order and reference insertion order are immaterial.
30
+ export const canonicalJson = (value: unknown): string => {
31
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
32
+ if (value !== null && typeof value === "object")
33
+ return `{${Object.entries(value)
34
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
35
+ .map(([key, child]) => `${JSON.stringify(key)}:${canonicalJson(child)}`)
36
+ .join(",")}}`;
37
+ const result = JSON.stringify(value);
38
+ if (result === undefined) throw new Error("Skill content must be JSON.");
39
+ return result;
40
+ };
41
+ export const canonicalHash = (value: unknown): string => createHash("sha256").update(canonicalJson(value)).digest("hex");
42
+ export const contentHash = (fields: AiSkillContent): string =>
43
+ canonicalHash({
44
+ name: fields.name,
45
+ description: fields.description,
46
+ instructions: fields.instructions,
47
+ extraFrontmatter: fields.extraFrontmatter,
48
+ references: [...fields.references].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)),
49
+ });
50
+
51
+ /** How an installed app Skill relates to its app: the app's version, an administrator's override, or an override with a newer app version. */
52
+ export type AppSkillStatus = "current" | "modified" | "update_available";
53
+ export const appSkillStatus = (rowHash: string, sourceHash: string | null, appliedHash: string | null): AppSkillStatus =>
54
+ rowHash === (sourceHash ?? appliedHash) ? "current" : sourceHash === null || sourceHash === appliedHash ? "modified" : "update_available";