@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.
- package/package.json +4 -2
- package/src/_internal/app-presentation.ts +22 -1
- package/src/_internal/app-roles.ts +14 -0
- package/src/_internal/capabilities.ts +113 -5
- package/src/_internal/capability-sentences.ts +413 -0
- package/src/_internal/define-app.ts +24 -5
- package/src/_internal/registry-validation.ts +5 -0
- package/src/_internal/registry.ts +50 -6
- package/src/_internal/widget-declarations.ts +53 -0
- package/src/access/PermissionEditor.tsx +43 -24
- package/src/ai/app-skill-store.ts +298 -0
- package/src/ai/app-skills.ts +105 -0
- package/src/ai/approval-routes.ts +40 -4
- package/src/ai/approvals.ts +68 -10
- package/src/ai/browser.ts +10 -0
- package/src/ai/capabilities.ts +68 -9
- package/src/ai/chart-block.ts +380 -0
- package/src/ai/chat/action-sentences.ts +49 -0
- package/src/ai/chat/blocks.tsx +97 -40
- package/src/ai/chat/chart-block.tsx +79 -0
- package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
- package/src/ai/chat/message-actions.tsx +3 -1
- package/src/ai/chat/message-utils.ts +3 -0
- package/src/ai/chat/messages.ts +26 -2
- package/src/ai/chat/tool-groups.ts +66 -12
- package/src/ai/chat/tool-names.ts +6 -2
- package/src/ai/chat/turn-layout.ts +10 -2
- package/src/ai/chat/turn-view.tsx +123 -36
- package/src/ai/chat/web-tools.tsx +95 -1
- package/src/ai/client/controller.ts +6 -3
- package/src/ai/code-runtime-tools.ts +109 -61
- package/src/ai/code-source-contracts.ts +25 -21
- package/src/ai/code-source-tools.ts +3 -1
- package/src/ai/default-tool-contracts.ts +198 -0
- package/src/ai/default-tools.ts +25 -1
- package/src/ai/executor.ts +189 -35
- package/src/ai/fetch-file-tool.ts +41 -4
- package/src/ai/firecrawl-tools.ts +87 -4
- package/src/ai/index.ts +17 -1
- package/src/ai/migrate.ts +172 -14
- package/src/ai/protocol.ts +22 -1
- package/src/ai/routes.ts +11 -0
- package/src/ai/runtime-tools.ts +1 -0
- package/src/ai/runtime.ts +46 -7
- package/src/ai/skill-content.ts +54 -0
- package/src/ai/skills-routes.ts +22 -13
- package/src/ai/skills.ts +177 -231
- package/src/ai/store.ts +66 -1
- package/src/ai/tool-audit.ts +11 -1
- package/src/ai/tool-sentences.ts +38 -0
- package/src/ai/tools.ts +91 -3
- package/src/ai/types.ts +47 -1
- package/src/ai/web-provenance.ts +103 -0
- package/src/api/admin-ai-skills.ts +35 -16
- package/src/api/capabilities.ts +3 -2
- package/src/api/fanout.ts +128 -0
- package/src/api/help.ts +8 -3
- package/src/api/mcp.ts +4 -1
- package/src/api/search.ts +31 -106
- package/src/api/widgets.ts +291 -101
- package/src/browser/CloudResourceSearch.browser-harness.tsx +1 -1
- package/src/browser/CloudResourceSearch.tsx +1 -1
- package/src/browser/command-shortcuts.ts +21 -6
- package/src/browser/ndjson.ts +34 -0
- package/src/browser/reload.ts +46 -0
- package/src/browser/search-stream.ts +4 -26
- package/src/browser/spotlight-position.ts +4 -1
- package/src/browser/widgets.ts +30 -0
- package/src/capabilities/executions.ts +3 -0
- package/src/capabilities/server.ts +20 -2
- package/src/cli/access.ts +48 -14
- package/src/cli/admin/ai-skills.ts +59 -39
- package/src/cli/commands.ts +20 -0
- package/src/cli/index.ts +16 -0
- package/src/cli/plugin.ts +30 -0
- package/src/contracts/announcement-cookie.ts +62 -0
- package/src/contracts/announcements.ts +14 -52
- package/src/contracts/app.ts +34 -2
- package/src/contracts/capabilities.ts +24 -0
- package/src/contracts/pwa-paths.ts +7 -2
- package/src/contracts/pwa.ts +2 -7
- package/src/contracts/registry.ts +10 -1
- package/src/contracts/shared.ts +5 -0
- package/src/contracts/widgets.ts +67 -2
- package/src/desktop/solid.tsx +6 -7
- package/src/index.ts +1 -0
- package/src/server/help.ts +8 -1
- package/src/server/index.ts +1 -0
- package/src/server/widget-request.ts +27 -0
- package/src/services/accounts/users.ts +15 -0
- package/src/services/approvals/index.ts +225 -0
- package/src/services/approvals/migrate.ts +33 -0
- package/src/services/help/index.ts +18 -5
- package/src/services/help/types.ts +3 -2
- package/src/services/identity/constants.ts +2 -0
- package/src/services/identity/key-ring.ts +123 -42
- package/src/services/identity/runtime-config.ts +13 -2
- package/src/shared/app-presentation.ts +11 -0
- package/src/shared/email-html.ts +43 -8
- package/src/ssr/AdminLayout.tsx +4 -7
- package/src/ssr/AppLaunchpad.island.tsx +1 -1
- package/src/ssr/AppLaunchpadPanel.tsx +12 -3
- package/src/ssr/GlobalAnnouncements.island.tsx +2 -2
- package/src/ssr/GlobalSearchDialog.tsx +1 -43
- package/src/ssr/GlobalSearchTrigger.island.tsx +12 -3
- package/src/ssr/Layout.tsx +38 -14
- package/src/ssr/LayoutFooter.tsx +1 -1
- package/src/ssr/LayoutHeader.tsx +17 -20
- package/src/ssr/LayoutHelp.tsx +1 -1
- package/src/ssr/LayoutHelpTrigger.island.tsx +1 -1
- package/src/ssr/LayoutRail.tsx +1 -2
- package/src/ssr/PageTrail.tsx +23 -0
- package/src/ssr/PageTrailRow.island.tsx +20 -0
- package/src/ssr/PageTrailRuntime.island.tsx +26 -0
- package/src/ssr/ProfilePreferences.island.tsx +7 -3
- package/src/ssr/PwaRuntime.island.tsx +1 -1
- package/src/ssr/app-navigation.ts +5 -7
- package/src/ssr/global-search-host.tsx +55 -0
- package/src/ssr/help.ts +3 -3
- package/src/ssr/islands/index.ts +1 -0
- package/src/ssr/layout-runtime.ts +60 -0
- package/src/ssr/mobile-menu-history.ts +1 -1
- package/src/ssr/page-trail-runtime.ts +313 -0
- package/src/ssr/page-trail.browser-harness.tsx +216 -0
- package/src/ssr/page-trail.ts +59 -0
- package/src/ssr/platform-messages.ts +4 -0
- package/src/ssr/rail-context.ts +4 -3
- package/src/ssr/runtime.ts +14 -1
- package/src/styles/effects.css +21 -4
- package/src/styles/global.css +35 -2
- package/src/styles/utilities-layout.css +24 -1
- package/src/styles/utilities-navigation.css +6 -18
- package/src/ai/code-mode-skill.ts +0 -114
- package/src/ai/data-analysis-skill.ts +0 -10
- package/src/ai/grids-skill.ts +0 -158
- 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
|
|
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
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
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
|
+
};
|
package/src/ai/protocol.ts
CHANGED
|
@@ -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?: {
|
|
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 }));
|
package/src/ai/runtime-tools.ts
CHANGED
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.
|
|
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
|
-
|
|
348
|
+
const remember = input.action.remember;
|
|
349
|
+
if (remember) {
|
|
342
350
|
const config = await aiConversations.getTurnRunConfig(input);
|
|
343
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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";
|