@k2b/cloud 0.31.0 → 0.33.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 +8 -6
- package/src/_internal/define-app.ts +20 -9
- package/src/_internal/page-responses.ts +5 -1
- package/src/ai/app-skills.ts +1 -7
- package/src/ai/audio-tool.ts +2 -1
- package/src/ai/browser.ts +109 -0
- package/src/ai/capabilities.ts +6 -2
- package/src/ai/chart-block.ts +431 -81
- package/src/ai/chat/action-sentences.ts +13 -7
- package/src/ai/chat/blocks.tsx +11 -7
- package/src/ai/chat/builtin-tools.tsx +9 -7
- package/src/ai/chat/chart-block.tsx +218 -44
- package/src/ai/chat/explorer-block.tsx +276 -0
- package/src/ai/chat/file-tools.tsx +40 -1
- package/src/ai/chat/message-actions.tsx +6 -0
- package/src/ai/chat/message-utils.ts +7 -0
- package/src/ai/chat/messages.ts +16 -0
- package/src/ai/chat/presentation.tsx +13 -3
- package/src/ai/chat/reference-text.tsx +34 -0
- package/src/ai/chat/tool-groups.ts +1 -0
- package/src/ai/chat/tool-names.ts +14 -0
- package/src/ai/chat/turn-view.tsx +17 -7
- package/src/ai/chat/view-block.browser-harness.tsx +35 -0
- package/src/ai/chat/view-block.tsx +417 -0
- package/src/ai/chat/visual-tools.tsx +32 -53
- package/src/ai/client-tool-results.ts +9 -0
- package/src/ai/code-source-contracts.ts +17 -2
- package/src/ai/code-source-tools.ts +1 -0
- package/src/ai/default-tool-contracts.ts +334 -91
- package/src/ai/default-tools.ts +89 -13
- package/src/ai/executor.ts +173 -31
- package/src/ai/file-error.ts +2 -0
- package/src/ai/file-tools.ts +27 -7
- package/src/ai/files-store.ts +60 -0
- package/src/ai/html-pdf-tool.ts +3 -2
- package/src/ai/http.ts +37 -12
- package/src/ai/index.ts +90 -0
- package/src/ai/local-tool-contracts.ts +244 -0
- package/src/ai/malformed-tool-call-repair.ts +144 -0
- package/src/ai/markdown-pdf-tool.ts +2 -1
- package/src/ai/memories.ts +48 -1
- package/src/ai/memory-learning.ts +17 -6
- package/src/ai/memory-tool.ts +1 -0
- package/src/ai/migrate.ts +15 -0
- package/src/ai/openai-api-messages.ts +53 -0
- package/src/ai/openai-api-slots.ts +40 -0
- package/src/ai/openai-api.ts +383 -0
- package/src/ai/protocol.ts +6 -2
- package/src/ai/provider.ts +17 -4
- package/src/ai/request-context.ts +33 -0
- package/src/ai/routes.ts +96 -57
- package/src/ai/runtime.ts +48 -12
- package/src/ai/settings.ts +5 -0
- package/src/ai/skill-catalog.ts +20 -5
- package/src/ai/skill-format.ts +17 -7
- package/src/ai/skill-tool.ts +15 -5
- package/src/ai/skills-routes.ts +20 -6
- package/src/ai/skills.ts +26 -7
- package/src/ai/store.ts +25 -0
- package/src/ai/system-prompt.ts +69 -10
- package/src/ai/timeline.ts +3 -1
- package/src/ai/tools.ts +3 -0
- package/src/ai/types.ts +9 -0
- package/src/ai/view-block.ts +941 -0
- package/src/ai/vision-tool.ts +2 -1
- package/src/api/admin-identity.ts +6 -1
- package/src/api/app-approval-actions.ts +36 -0
- package/src/api/app-approval.ts +10 -2
- package/src/api/approvals-loader.ts +15 -0
- package/src/api/approvals.ts +120 -0
- package/src/api/index.ts +3 -0
- package/src/api/me.ts +12 -3
- package/src/approvals/Approvals.browser-harness.tsx +16 -0
- package/src/approvals/Approvals.tsx +621 -0
- package/src/approvals/ApprovalsNavStatus.tsx +20 -0
- package/src/approvals/messages.ts +95 -0
- package/src/approvals/ui.ts +8 -0
- package/src/browser/app-approval.ts +32 -0
- package/src/browser/choose-files.tsx +26 -13
- package/src/browser/commands.ts +13 -4
- package/src/browser/file-provider-error.ts +11 -0
- package/src/browser/file-providers.ts +3 -12
- package/src/browser/files.tsx +1 -2
- package/src/browser/live-connection.ts +2 -3
- package/src/browser/navigation-search.ts +4 -1
- package/src/browser/provider-list.ts +2 -1
- package/src/browser/save-files.tsx +13 -2
- package/src/cli/account.ts +21 -2
- package/src/cli/commands.ts +4 -2
- package/src/contracts/app-approval.ts +120 -0
- package/src/contracts/approval-paths.ts +4 -0
- package/src/contracts/approvals.ts +81 -0
- package/src/contracts/command-path.ts +17 -0
- package/src/contracts/commands.ts +5 -15
- package/src/contracts/shared.ts +5 -0
- package/src/events/live-protocol.ts +2 -1
- package/src/events/live-server-message.ts +40 -0
- package/src/events/testing.ts +88 -0
- package/src/server/index.ts +2 -0
- package/src/server/middleware/auth.ts +11 -1
- package/src/server/request-timeout.ts +22 -0
- package/src/server/services/access.ts +4 -3
- package/src/services/app-approval.ts +103 -15
- package/src/services/approvals/cursor.ts +15 -0
- package/src/services/approvals/device.ts +112 -0
- package/src/services/approvals/index.ts +27 -11
- package/src/services/approvals/list.ts +120 -0
- package/src/services/approvals/migrate.ts +9 -1
- package/src/services/identity/models-only-auth.ts +25 -0
- package/src/services/notifications/email-frame.ts +16 -3
- package/src/services/outbox.ts +13 -6
- package/src/services/service-account-credentials.ts +22 -10
- package/src/services/settings/core-settings.ts +27 -0
- package/src/shared/ai-platform-prompt.ts +29 -3
- package/src/shared/email-html.ts +4 -0
- package/src/shared/index.ts +6 -1
- package/src/shared/markdown/extensions/code.ts +14 -0
- package/src/shared/markdown/index.ts +4 -4
- package/src/shared/theme.ts +41 -20
- package/src/ssr/AnnouncementBanners.tsx +30 -0
- package/src/ssr/AppBadge.tsx +4 -4
- package/src/ssr/AppLaunchpadPanel.tsx +6 -6
- package/src/ssr/CloudShell.island.tsx +91 -0
- package/src/ssr/GlobalSearchTrigger.tsx +51 -0
- package/src/ssr/Layout.tsx +56 -85
- package/src/ssr/LayoutHeader.tsx +31 -77
- package/src/ssr/LayoutHelp.tsx +2 -57
- package/src/ssr/LayoutHelpTrigger.tsx +51 -0
- package/src/ssr/{LayoutPreferences.island.tsx → LayoutPreferences.tsx} +2 -2
- package/src/ssr/LayoutRail.tsx +15 -69
- package/src/ssr/MinimalLayout.tsx +2 -3
- package/src/ssr/MinimalLayoutPreferences.island.tsx +12 -15
- package/src/ssr/MobileNavigation.tsx +13 -8
- package/src/ssr/MobileProfileActions.tsx +10 -7
- package/src/ssr/PageTrailRow.tsx +14 -0
- package/src/ssr/{ProfilePreferences.island.tsx → ProfilePreferences.tsx} +12 -7
- package/src/ssr/PwaLayout.tsx +0 -2
- package/src/ssr/PwaRuntime.island.tsx +1 -1
- package/src/ssr/{RailApps.island.tsx → RailApps.tsx} +2 -2
- package/src/ssr/ShellRuntime.island.tsx +170 -0
- package/src/ssr/TimezoneCookie.island.tsx +3 -19
- package/src/ssr/WorkspaceNavigationProvider.tsx +20 -1
- package/src/ssr/announcements-dialog.tsx +66 -0
- package/src/ssr/app-badges.ts +50 -6
- package/src/ssr/{AppLaunchpad.island.tsx → app-launchpad.tsx} +42 -62
- package/src/ssr/busy-action.ts +19 -0
- package/src/ssr/islands/index.ts +9 -6
- package/src/ssr/launchpad-surface.tsx +15 -0
- package/src/ssr/layout-help-registry.tsx +60 -0
- package/src/ssr/mobile-navigation.browser-harness.tsx +20 -0
- package/src/ssr/page-trail.browser-harness.tsx +52 -3
- package/src/ssr/preference-controller.ts +30 -21
- package/src/ssr/profile-actions.ts +26 -15
- package/src/ssr/profile-preferences-messages.ts +8 -4
- package/src/ssr/rail-context.ts +8 -12
- package/src/ssr/rail-navigation.ts +5 -3
- package/src/ssr/runtime.ts +6 -1
- package/src/ssr/shell-data.tsx +48 -0
- package/src/ssr/timezone-cookie.ts +22 -0
- package/src/ssr/workspace-navigation.ts +25 -2
- package/src/styles/approvals.css +390 -0
- package/src/styles/global.css +22 -7
- package/src/styles/utilities-navigation.css +13 -0
- package/src/workflows/language/compiler.ts +1 -1
- package/src/ssr/AppWorkspaceController.island.tsx +0 -21
- package/src/ssr/BrowserPushRegistration.island.tsx +0 -12
- package/src/ssr/GlobalAnnouncements.island.tsx +0 -117
- package/src/ssr/GlobalSearchTrigger.island.tsx +0 -75
- package/src/ssr/LayoutHelpTrigger.island.tsx +0 -61
- package/src/ssr/PageTrailRow.island.tsx +0 -20
- package/src/ssr/PageTrailRuntime.island.tsx +0 -26
- package/src/ssr/RegisteredHelpDocuments.island.tsx +0 -3
- /package/src/ssr/{LayoutBreadcrumbs.island.tsx → LayoutBreadcrumbs.tsx} +0 -0
package/src/ai/routes.ts
CHANGED
|
@@ -2,9 +2,9 @@ import { type Context, Hono } from "hono";
|
|
|
2
2
|
import { bodyLimit } from "hono/body-limit";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { listCapabilities } from "../_internal/registry";
|
|
5
|
+
import { APPROVAL_CLIENT_HEADER } from "../contracts/approvals";
|
|
5
6
|
import {
|
|
6
7
|
type AccessSubject,
|
|
7
|
-
type ApiErrorResponse,
|
|
8
8
|
type AuthContext,
|
|
9
9
|
auth,
|
|
10
10
|
err,
|
|
@@ -23,13 +23,13 @@ import { ImageMetadataError } from "../services/image-metadata";
|
|
|
23
23
|
import { logger } from "../services/logging";
|
|
24
24
|
import { coreSettings } from "../services/settings/api";
|
|
25
25
|
import { readThemeFromCookieHeader } from "../shared/theme";
|
|
26
|
-
import type { AiToolApprovalContext } from "./approvals";
|
|
27
26
|
import { assistantAiSettingsState, listAssistantAiModels, resolveAssistantAudioModel, selectAssistantAiModelId } from "./assistant-models";
|
|
28
27
|
import { AI_AUDIO_MAX_BYTES } from "./audio-format";
|
|
29
28
|
import { buildAiCapabilityCatalog } from "./capabilities";
|
|
30
29
|
import { aiChatMessages } from "./chat/messages";
|
|
31
30
|
import { getAiChatQuotas } from "./chat-quotas";
|
|
32
|
-
import {
|
|
31
|
+
import { AI_CLIENT_TOOL_ACTION_MAX_BYTES, aiClientToolOutputSchemas } from "./client-tool-results";
|
|
32
|
+
import { createCloudAiLocalBashTool, createCloudAiLocalTools, createConfiguredDefaultCloudAiTools } from "./default-tools";
|
|
33
33
|
import { enqueueAiDictation } from "./dictation-runtime";
|
|
34
34
|
import { AiDictationConflict, AiDictationListSchema, AiDictationStartSchema, aiDictations } from "./dictations";
|
|
35
35
|
import { aiProjectFilePathFromMount } from "./file-mount";
|
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
toAiActionFailureResponse,
|
|
50
50
|
toAiErrorResponse,
|
|
51
51
|
} from "./http";
|
|
52
|
+
import { AI_LOCAL_TOOL_NAMES, AiLocalClientSchema } from "./local-tool-contracts";
|
|
52
53
|
import { aiMaintenanceJobs } from "./maintenance";
|
|
53
54
|
import { AI_MEMORY_CONTENT_MAX_CHARS, aiMemories } from "./memories";
|
|
54
55
|
import { aiMemoryLearningRuns } from "./memory-learning-runs";
|
|
@@ -61,10 +62,10 @@ import {
|
|
|
61
62
|
queuedMessageAccepted,
|
|
62
63
|
updateQueuedMessage,
|
|
63
64
|
} from "./message-queue";
|
|
64
|
-
import { personalAiModelPolicy } from "./personal-agent";
|
|
65
65
|
import { aiActorUser, aiPrefsUserId, aiUserPrefs } from "./prefs";
|
|
66
66
|
import { aiProjects } from "./projects";
|
|
67
67
|
import { projectPublicAiStoredMessages, publicAiStoredMessages } from "./public-projection";
|
|
68
|
+
import { type AiChatRequestContext, resolveAiChatRequestContext as resolveContext } from "./request-context";
|
|
68
69
|
import { aiResourceMarker } from "./resource-markers";
|
|
69
70
|
import { isConversationResourceCursor } from "./resource-refs";
|
|
70
71
|
import {
|
|
@@ -86,32 +87,7 @@ import { aiConversations } from "./store";
|
|
|
86
87
|
import { createAiConversationStreamResponse, loadAiStreamState } from "./stream";
|
|
87
88
|
import { composeAiSystemPrompt } from "./system-prompt";
|
|
88
89
|
import { aiToolPromptHints } from "./tools";
|
|
89
|
-
import type { AiConversation,
|
|
90
|
-
|
|
91
|
-
/** Everything a resolved, authorized request needs to run against the shared runtime. */
|
|
92
|
-
type AiChatRequestContext = {
|
|
93
|
-
actor: RequestActor;
|
|
94
|
-
ownerUserId: string;
|
|
95
|
-
modelPolicy: AiModelPolicy;
|
|
96
|
-
toolApprovalContext: AiToolApprovalContext;
|
|
97
|
-
/** A person in a signed-in browser session, not `cld`, an API key, or another delegated credential. */
|
|
98
|
-
signedInSession: boolean;
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
const actorUser = (actor: RequestActor) => (actor.kind === "user" ? actor.user : actor.delegatedUser);
|
|
102
|
-
|
|
103
|
-
const resolveContext = async (c: Context<AuthContext>): Promise<AiChatRequestContext | ApiErrorResponse> => {
|
|
104
|
-
const actor = c.get("actor");
|
|
105
|
-
const user = actorUser(actor);
|
|
106
|
-
if (!user) return respond(c, fail(err.forbidden("AI conversations require a user-backed actor.")));
|
|
107
|
-
return {
|
|
108
|
-
actor,
|
|
109
|
-
ownerUserId: user.id,
|
|
110
|
-
modelPolicy: personalAiModelPolicy,
|
|
111
|
-
toolApprovalContext: { actorUserId: user.id },
|
|
112
|
-
signedInSession: actor.kind === "user" && !actor.delegation && c.get("credentialKind") === "session",
|
|
113
|
-
};
|
|
114
|
-
};
|
|
90
|
+
import type { AiConversation, AiTurn } from "./types";
|
|
115
91
|
|
|
116
92
|
const retryInstruction = (mode: "retry" | "details" | "concise"): string | null => {
|
|
117
93
|
if (mode === "details") return "Answer the user's request again with more detail and specificity.";
|
|
@@ -330,7 +306,11 @@ export const aiRoutes = (() => {
|
|
|
330
306
|
.use(rateLimit())
|
|
331
307
|
.use("*", auth.requireRole("authenticated"))
|
|
332
308
|
.get("/status", async (c) => {
|
|
333
|
-
|
|
309
|
+
const [status, enabled] = await Promise.all([
|
|
310
|
+
assistantAiSettingsState(c.get("accessSubject")),
|
|
311
|
+
coreSettings.get<boolean>("ai.openai_api_enabled"),
|
|
312
|
+
]);
|
|
313
|
+
return respond(c, ok({ ...status, openaiApi: { enabled } }));
|
|
334
314
|
})
|
|
335
315
|
.get("/quotas", async (c) => {
|
|
336
316
|
c.header("Cache-Control", "no-store");
|
|
@@ -406,6 +386,21 @@ export const aiRoutes = (() => {
|
|
|
406
386
|
const user = aiActorUser(ctx.actor);
|
|
407
387
|
if (!user) return respond(c, fail(err.forbidden("AI preferences require a user context.")));
|
|
408
388
|
|
|
389
|
+
const clientKind = c.req.query("clientKind");
|
|
390
|
+
const parsedClient =
|
|
391
|
+
clientKind === undefined
|
|
392
|
+
? undefined
|
|
393
|
+
: AiLocalClientSchema.safeParse({
|
|
394
|
+
kind: clientKind,
|
|
395
|
+
name: c.req.query("clientName"),
|
|
396
|
+
cwd: c.req.query("cwd"),
|
|
397
|
+
os: c.req.query("os"),
|
|
398
|
+
shell: c.req.query("shell"),
|
|
399
|
+
localTools: c.req.query("localTools"),
|
|
400
|
+
});
|
|
401
|
+
if (parsedClient && !parsedClient.success)
|
|
402
|
+
return respond(c, fail(err.badInput(`Invalid local client: ${parsedClient.error.issues[0]?.message}`)));
|
|
403
|
+
const client = parsedClient?.success ? parsedClient.data : undefined;
|
|
409
404
|
const prefs = await aiUserPrefs.get(user.id);
|
|
410
405
|
const memoryEnabled = Boolean(prefs?.memoryEnabled);
|
|
411
406
|
const memory = memoryEnabled ? await aiMemories.selectHot(user.id, "") : null;
|
|
@@ -421,13 +416,18 @@ export const aiRoutes = (() => {
|
|
|
421
416
|
}
|
|
422
417
|
const toolsSupported = Boolean(previewProfile?.capabilities.includes("tools"));
|
|
423
418
|
const availableSkills = toolsSupported ? (await aiSkills.list(c.get("accessSubject"))).filter((skill) => skill.enabled) : [];
|
|
424
|
-
const skillCatalog = selectAiSkillCatalog(
|
|
419
|
+
const skillCatalog = selectAiSkillCatalog(
|
|
420
|
+
availableSkills.filter((skill) => !skill.manualOnly).map(({ name, description }) => ({ name, description })),
|
|
421
|
+
previewProfile?.contextWindow ?? 0,
|
|
422
|
+
"",
|
|
423
|
+
);
|
|
425
424
|
const tools = toolsSupported
|
|
426
425
|
? [
|
|
427
426
|
...(await createConfiguredDefaultCloudAiTools({
|
|
428
427
|
accessSubject: c.get("accessSubject"),
|
|
429
428
|
allowedDataBoundaries: ctx.modelPolicy.allowedDataBoundaries,
|
|
430
429
|
})),
|
|
430
|
+
...(client?.localTools === "ask" ? [createCloudAiLocalBashTool(), ...createCloudAiLocalTools()] : []),
|
|
431
431
|
...(memoryEnabled ? [createCloudAiMemoryTool()] : []),
|
|
432
432
|
...(availableSkills.length ? [createCloudAiLoadSkillTool(c.get("accessSubject"))] : []),
|
|
433
433
|
...(skillCatalog.omitted > 0 ? [createCloudAiSearchSkillsTool(c.get("accessSubject"))] : []),
|
|
@@ -438,6 +438,9 @@ export const aiRoutes = (() => {
|
|
|
438
438
|
const promptLocale = getLocale(c);
|
|
439
439
|
const prompt = composeAiSystemPrompt({
|
|
440
440
|
globalInstructions: state.globalInstructions,
|
|
441
|
+
localClient: client
|
|
442
|
+
? { client, cloudUrl: (await coreSettings.get<string>("app.url")) || "", instructions: state.localClientInstructions }
|
|
443
|
+
: undefined,
|
|
441
444
|
user,
|
|
442
445
|
memoryEnabled,
|
|
443
446
|
memoryToolEnabled,
|
|
@@ -450,7 +453,7 @@ export const aiRoutes = (() => {
|
|
|
450
453
|
memory: memory?.text,
|
|
451
454
|
timeZone,
|
|
452
455
|
locale: promptLocale,
|
|
453
|
-
skillCreatorAvailable: availableSkills.some((skill) => skill.name === "skill-creator"),
|
|
456
|
+
skillCreatorAvailable: availableSkills.some((skill) => skill.name === "skill-creator" && !skill.manualOnly),
|
|
454
457
|
});
|
|
455
458
|
return respond(c, ok({ prompt, renderedAt: new Date().toISOString() }));
|
|
456
459
|
})
|
|
@@ -907,6 +910,8 @@ export const aiRoutes = (() => {
|
|
|
907
910
|
modelPolicy: ctx.modelPolicy,
|
|
908
911
|
project: project ?? undefined,
|
|
909
912
|
clientToolIds: body.clientToolIds,
|
|
913
|
+
client: body.client,
|
|
914
|
+
localContext: body.localContext,
|
|
910
915
|
toolSource: { kind: "default" as const, appTools: true },
|
|
911
916
|
toolApprovalContext: ctx.toolApprovalContext,
|
|
912
917
|
expectedDraftRevision: body.draftRevision,
|
|
@@ -1022,7 +1027,20 @@ export const aiRoutes = (() => {
|
|
|
1022
1027
|
truncateFromSeq: target.seq,
|
|
1023
1028
|
fileSnapshot: originalRunConfig?.kind !== "compact" ? originalRunConfig?.files : undefined,
|
|
1024
1029
|
retrySourceTurnId: target.loopId ?? undefined,
|
|
1025
|
-
|
|
1030
|
+
// Local context and tools belong to the retrying client, not the original workspace.
|
|
1031
|
+
clientToolIds:
|
|
1032
|
+
body.clientToolIds ??
|
|
1033
|
+
(originalRunConfig?.kind !== "compact"
|
|
1034
|
+
? originalRunConfig?.clientToolIds?.filter((id) => id !== "local_bash" && !AI_LOCAL_TOOL_NAMES.some((name) => name === id))
|
|
1035
|
+
: undefined),
|
|
1036
|
+
client: body.client,
|
|
1037
|
+
localContext: body.client
|
|
1038
|
+
? {
|
|
1039
|
+
instructions: body.localContext?.instructions ?? [],
|
|
1040
|
+
skills: body.localContext?.skills ?? [],
|
|
1041
|
+
selectedSkills: originalRunConfig?.kind !== "compact" ? (originalRunConfig?.localContext?.selectedSkills ?? []) : [],
|
|
1042
|
+
}
|
|
1043
|
+
: undefined,
|
|
1026
1044
|
});
|
|
1027
1045
|
rememberLastUsedModel(ctx.actor, result.turn.modelProfileId);
|
|
1028
1046
|
return respond(
|
|
@@ -1114,29 +1132,49 @@ export const aiRoutes = (() => {
|
|
|
1114
1132
|
await abortAiTurn({ conversationId: conversation.id, turnId: turn.id });
|
|
1115
1133
|
return respond(c, ok({ ok: true }));
|
|
1116
1134
|
})
|
|
1117
|
-
.post(
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
callId
|
|
1129
|
-
action
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1135
|
+
.post(
|
|
1136
|
+
"/conversations/:conversationId/turns/:turnId/actions/:callId",
|
|
1137
|
+
bodyLimit({ maxSize: AI_CLIENT_TOOL_ACTION_MAX_BYTES }),
|
|
1138
|
+
v("json", AiTurnActionSchema),
|
|
1139
|
+
async (c) => {
|
|
1140
|
+
const ctx = await resolveContext(c);
|
|
1141
|
+
if (ctx instanceof Response) return ctx;
|
|
1142
|
+
const conversation = await loadConversation(c, ctx);
|
|
1143
|
+
if (!conversation) return notFound(c);
|
|
1144
|
+
const turn = await loadTurn(conversation, c.req.param("turnId"));
|
|
1145
|
+
const callId = c.req.param("callId");
|
|
1146
|
+
if (!turn || !callId) return notFound(c);
|
|
1147
|
+
const action = c.req.valid("json");
|
|
1148
|
+
if (action.type === "tool_result") {
|
|
1149
|
+
const pending = await aiConversations.getPendingTurnAction({ conversationId: conversation.id, turnId: turn.id, callId });
|
|
1150
|
+
const schema = pending?.kind === "client_tool" ? aiClientToolOutputSchemas.get(pending.name) : undefined;
|
|
1151
|
+
if (schema) {
|
|
1152
|
+
const parsed = schema.safeParse(action.result);
|
|
1153
|
+
if (!parsed.success)
|
|
1154
|
+
return respond(c, fail(err.badInput(`Invalid ${pending?.name} result: ${parsed.error.issues[0]?.message}`)));
|
|
1155
|
+
action.result = parsed.data;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
const result = await submitAiTurnAction({
|
|
1159
|
+
conversationId: conversation.id,
|
|
1160
|
+
turnId: turn.id,
|
|
1161
|
+
callId,
|
|
1162
|
+
action,
|
|
1163
|
+
decidedBy: {
|
|
1164
|
+
userId: ctx.ownerUserId,
|
|
1165
|
+
via: approvalDecisionChannel({
|
|
1166
|
+
credentialKind: c.get("credentialKind"),
|
|
1167
|
+
actor: ctx.actor,
|
|
1168
|
+
client: c.req.header(APPROVAL_CLIENT_HEADER),
|
|
1169
|
+
}),
|
|
1170
|
+
},
|
|
1171
|
+
toolApprovalContext: ctx.toolApprovalContext,
|
|
1172
|
+
signedInSession: ctx.signedInSession,
|
|
1173
|
+
});
|
|
1174
|
+
if (!result.ok) return toAiActionFailureResponse(c, result);
|
|
1175
|
+
return respond(c, ok({ ok: true }));
|
|
1176
|
+
},
|
|
1177
|
+
)
|
|
1140
1178
|
.get("/conversations/:conversationId/pending-actions/:turnId", async (c) => {
|
|
1141
1179
|
const ctx = await resolveContext(c);
|
|
1142
1180
|
if (ctx instanceof Response) return ctx;
|
|
@@ -1353,6 +1391,7 @@ export const aiRoutes = (() => {
|
|
|
1353
1391
|
"Content-Type": stored.mediaType || "application/octet-stream",
|
|
1354
1392
|
"Content-Disposition": `attachment; filename="${filename}"`,
|
|
1355
1393
|
"Content-Length": String(stored.size),
|
|
1394
|
+
"X-Cloud-Ai-File-Origin": stored.origin,
|
|
1356
1395
|
"Cache-Control": "private, no-store",
|
|
1357
1396
|
});
|
|
1358
1397
|
})
|
package/src/ai/runtime.ts
CHANGED
|
@@ -11,8 +11,9 @@ import { coreSettings } from "../services/settings/api";
|
|
|
11
11
|
import { AI_WEBSITE_APPROVAL_TOOL, type AiToolApprovalContext, aiTurnAllowsRememberedApprovals, rememberAiToolApproval } from "./approvals";
|
|
12
12
|
import { aiChatAccessSubject, selectAssistantAiModelId } from "./assistant-models";
|
|
13
13
|
import { startAiDictationRuntime } from "./dictation-runtime";
|
|
14
|
-
import { AiTurnExecutor } from "./executor";
|
|
14
|
+
import { AiTurnExecutor, type ExecutorConfig } from "./executor";
|
|
15
15
|
import { canonicalizeAiConversationAttachments, snapshotAiConversationFiles } from "./file-context";
|
|
16
|
+
import type { AiLocalClient, AiLocalContext } from "./local-tool-contracts";
|
|
16
17
|
import { drainQueuedMessages } from "./message-queue";
|
|
17
18
|
import { AI_TURN_LEASE_MS } from "./protocol";
|
|
18
19
|
import { aiQuotas } from "./quotas";
|
|
@@ -105,6 +106,8 @@ export type SubmitAiChatTurnInput = {
|
|
|
105
106
|
systemPrompt?: string;
|
|
106
107
|
project?: AiChatTurnRunConfig["project"];
|
|
107
108
|
clientToolIds?: AiClientToolId[];
|
|
109
|
+
client?: AiLocalClient;
|
|
110
|
+
localContext?: AiLocalContext;
|
|
108
111
|
toolSource?: AiTurnToolSource;
|
|
109
112
|
canInspectAttachedImages?: boolean;
|
|
110
113
|
toolApprovalContext?: AiToolApprovalContext;
|
|
@@ -119,6 +122,7 @@ export type SubmitAiChatTurnInput = {
|
|
|
119
122
|
};
|
|
120
123
|
|
|
121
124
|
export const prepareAiChatTurn = async (input: SubmitAiChatTurnInput) => {
|
|
125
|
+
if (input.localContext && !input.client) throw new Error("Local context requires a local client.");
|
|
122
126
|
if (input.clientToolIds?.length && input.toolSource?.kind !== "default") {
|
|
123
127
|
throw new Error("Optional client tools require the default tool source.");
|
|
124
128
|
}
|
|
@@ -170,6 +174,8 @@ export const prepareAiChatTurn = async (input: SubmitAiChatTurnInput) => {
|
|
|
170
174
|
files,
|
|
171
175
|
canInspectAttachedImages,
|
|
172
176
|
clientToolIds: input.clientToolIds,
|
|
177
|
+
client: input.client,
|
|
178
|
+
localContext: input.localContext,
|
|
173
179
|
toolSource: input.toolSource ?? { kind: "none" },
|
|
174
180
|
toolApprovalContext: input.toolApprovalContext,
|
|
175
181
|
};
|
|
@@ -324,11 +330,11 @@ export const submitAiTurnAction = async (input: {
|
|
|
324
330
|
turnId: string;
|
|
325
331
|
callId: string;
|
|
326
332
|
action: AiTurnActionInput;
|
|
327
|
-
decidedBy: { userId: string; via: ApprovalDecisionChannel };
|
|
333
|
+
decidedBy: { userId: string; via: ApprovalDecisionChannel; deviceId?: string };
|
|
328
334
|
toolApprovalContext?: AiToolApprovalContext;
|
|
329
335
|
/** Whether a person decides in a signed-in browser session; only such a decision may remember a website. */
|
|
330
336
|
signedInSession?: boolean;
|
|
331
|
-
}): Promise<{ ok: true } | { ok: false; status: 400 | 404 | 409; message: string }> => {
|
|
337
|
+
}): Promise<{ ok: true } | { ok: false; status: 400 | 403 | 404 | 409; message: string }> => {
|
|
332
338
|
const pending = await aiConversations.getPendingTurnAction(input);
|
|
333
339
|
if (!pending) return { ok: false, status: 404, message: "This request has expired — the assistant already moved on." };
|
|
334
340
|
if (pending.status === "resolved") {
|
|
@@ -365,9 +371,11 @@ export const submitAiTurnAction = async (input: {
|
|
|
365
371
|
if (pending.approvalId) {
|
|
366
372
|
const decision = await approvals.decide(pending.approvalId, { approved: input.action.approved }, input.decidedBy);
|
|
367
373
|
if (!decision.ok)
|
|
368
|
-
return decision.code === "
|
|
369
|
-
? { ok: false, status:
|
|
370
|
-
:
|
|
374
|
+
return decision.code === "REJECT_ONLY"
|
|
375
|
+
? { ok: false, status: 403, message: "Approve this request in Cloud. An editor can only reject it." }
|
|
376
|
+
: decision.code === "ALREADY_DECIDED"
|
|
377
|
+
? { ok: false, status: 409, message: "AI action was already resolved with a different response." }
|
|
378
|
+
: { ok: false, status: 404, message: "This request has expired — the assistant already moved on." };
|
|
371
379
|
}
|
|
372
380
|
if (remember && input.toolApprovalContext) {
|
|
373
381
|
const toolName = pending.rememberToolName ?? pending.name;
|
|
@@ -425,11 +433,14 @@ export const submitAiTurnAction = async (input: {
|
|
|
425
433
|
// Worker + sweep lifecycle
|
|
426
434
|
// ---------------------------------------------------------------------------
|
|
427
435
|
|
|
436
|
+
type ApprovalRequestedListener = NonNullable<ExecutorConfig["onApprovalRequested"]>;
|
|
437
|
+
|
|
428
438
|
const runClaimedTurn = async (
|
|
429
439
|
job: AiTurnJob,
|
|
430
440
|
signal: AbortSignal,
|
|
431
441
|
leaseOwner: string,
|
|
432
442
|
onTurnFinalized?: (event: AiTurnFinalizedEvent) => Promise<void>,
|
|
443
|
+
onApprovalRequested?: ApprovalRequestedListener,
|
|
433
444
|
): Promise<void> => {
|
|
434
445
|
const timeoutMinutes = await coreSettings.get<number>("ai.turn_timeout_minutes");
|
|
435
446
|
const runBudgetMs = Math.max(0, Number(timeoutMinutes ?? 30)) * 60_000;
|
|
@@ -458,6 +469,7 @@ const runClaimedTurn = async (
|
|
|
458
469
|
heartbeatMs: AI_TURN_HEARTBEAT_MS,
|
|
459
470
|
enqueueContinuation: (input) => enqueueAiTurn(input).then(() => undefined),
|
|
460
471
|
onTurnFinalized,
|
|
472
|
+
onApprovalRequested,
|
|
461
473
|
});
|
|
462
474
|
await executor.run({ conversationId: job.conversationId, turnId: job.turnId, claim, signal });
|
|
463
475
|
};
|
|
@@ -466,6 +478,7 @@ const processMessage = async (
|
|
|
466
478
|
message: Pick<QueueMessage<AiTurnJob>, "messageId" | "data" | "heartbeat">,
|
|
467
479
|
signal: AbortSignal,
|
|
468
480
|
onTurnFinalized?: (event: AiTurnFinalizedEvent) => Promise<void>,
|
|
481
|
+
onApprovalRequested?: ApprovalRequestedListener,
|
|
469
482
|
): Promise<void> => {
|
|
470
483
|
const leaseOwner = `${AI_WORKER_ID}:${message.messageId}:${crypto.randomUUID()}`;
|
|
471
484
|
let heartbeatFailed = false;
|
|
@@ -488,7 +501,7 @@ const processMessage = async (
|
|
|
488
501
|
}, AI_TURN_HEARTBEAT_MS);
|
|
489
502
|
if (typeof touch === "object" && "unref" in touch) touch.unref();
|
|
490
503
|
try {
|
|
491
|
-
await runClaimedTurn(message.data, signal, leaseOwner, onTurnFinalized);
|
|
504
|
+
await runClaimedTurn(message.data, signal, leaseOwner, onTurnFinalized, onApprovalRequested);
|
|
492
505
|
} catch (error) {
|
|
493
506
|
log.error("AI turn worker error", {
|
|
494
507
|
conversationId: message.data.conversationId,
|
|
@@ -550,6 +563,7 @@ export const sweepAiRuntime = async (onTurnFinalized?: (event: AiTurnFinalizedEv
|
|
|
550
563
|
|
|
551
564
|
type AiRuntimeState = {
|
|
552
565
|
listeners: Set<(event: AiTurnFinalizedEvent) => Promise<void>>;
|
|
566
|
+
approvalListeners: Set<ApprovalRequestedListener>;
|
|
553
567
|
stop: () => void;
|
|
554
568
|
};
|
|
555
569
|
|
|
@@ -557,19 +571,29 @@ let running: AiRuntimeState | null = null;
|
|
|
557
571
|
let refCount = 0;
|
|
558
572
|
|
|
559
573
|
export const startAiRuntime = (
|
|
560
|
-
input: {
|
|
574
|
+
input: {
|
|
575
|
+
concurrency?: number;
|
|
576
|
+
onTurnFinalized?: (event: AiTurnFinalizedEvent) => Promise<void>;
|
|
577
|
+
onApprovalRequested?: ApprovalRequestedListener;
|
|
578
|
+
} = {},
|
|
561
579
|
): (() => void) => {
|
|
562
580
|
const listener = input.onTurnFinalized ? (event: AiTurnFinalizedEvent) => input.onTurnFinalized!(event) : null;
|
|
581
|
+
const approvalListener: ApprovalRequestedListener | null = input.onApprovalRequested
|
|
582
|
+
? (event) => input.onApprovalRequested!(event)
|
|
583
|
+
: null;
|
|
563
584
|
if (running) {
|
|
564
585
|
refCount += 1;
|
|
565
586
|
if (listener) running.listeners.add(listener);
|
|
566
|
-
|
|
587
|
+
if (approvalListener) running.approvalListeners.add(approvalListener);
|
|
588
|
+
return releaseAiRuntime(listener, approvalListener);
|
|
567
589
|
}
|
|
568
590
|
|
|
569
591
|
const concurrency = Math.min(Math.max(Math.floor(input.concurrency ?? AI_TURN_WORKER_CONCURRENCY), 1), 64);
|
|
570
592
|
const controller = new AbortController();
|
|
571
593
|
const listeners = new Set<(event: AiTurnFinalizedEvent) => Promise<void>>();
|
|
572
594
|
if (listener) listeners.add(listener);
|
|
595
|
+
const approvalListeners = new Set<ApprovalRequestedListener>();
|
|
596
|
+
if (approvalListener) approvalListeners.add(approvalListener);
|
|
573
597
|
refCount = 1;
|
|
574
598
|
const dispatchTurnFinalized = async (event: AiTurnFinalizedEvent): Promise<void> => {
|
|
575
599
|
const results = await Promise.allSettled([...listeners].map((notify) => notify(event)));
|
|
@@ -577,12 +601,18 @@ export const startAiRuntime = (
|
|
|
577
601
|
if (errors.length > 0) throw new AggregateError(errors, `${errors.length} AI turn finalized listener(s) failed`);
|
|
578
602
|
};
|
|
579
603
|
|
|
604
|
+
const dispatchApprovalRequested: ApprovalRequestedListener = async (event) => {
|
|
605
|
+
const results = await Promise.allSettled([...approvalListeners].map((notify) => notify(event)));
|
|
606
|
+
const errors = results.filter((result): result is PromiseRejectedResult => result.status === "rejected").map((result) => result.reason);
|
|
607
|
+
if (errors.length) throw new AggregateError(errors, `${errors.length} AI approval requested listener(s) failed`);
|
|
608
|
+
};
|
|
609
|
+
|
|
580
610
|
void superviseRuntimeTask({
|
|
581
611
|
name: "AI turn worker",
|
|
582
612
|
signal: controller.signal,
|
|
583
613
|
run: async (signal) => {
|
|
584
614
|
const worker = await aiTurnQueue().process({ concurrency, signal }, (message) =>
|
|
585
|
-
processMessage(message, message.signal, dispatchTurnFinalized),
|
|
615
|
+
processMessage(message, message.signal, dispatchTurnFinalized, dispatchApprovalRequested),
|
|
586
616
|
);
|
|
587
617
|
try {
|
|
588
618
|
await new Promise<void>((resolve) => {
|
|
@@ -612,22 +642,27 @@ export const startAiRuntime = (
|
|
|
612
642
|
const stopDictations = startAiDictationRuntime();
|
|
613
643
|
const state: AiRuntimeState = {
|
|
614
644
|
listeners,
|
|
645
|
+
approvalListeners,
|
|
615
646
|
stop: () => {
|
|
616
647
|
if (running !== state) return;
|
|
617
648
|
controller.abort();
|
|
618
649
|
stopDictations();
|
|
619
650
|
clearInterval(sweepTimer);
|
|
620
651
|
listeners.clear();
|
|
652
|
+
approvalListeners.clear();
|
|
621
653
|
running = null;
|
|
622
654
|
refCount = 0;
|
|
623
655
|
},
|
|
624
656
|
};
|
|
625
657
|
running = state;
|
|
626
658
|
log.info("AI runtime started", { workerId: AI_WORKER_ID, concurrency });
|
|
627
|
-
return releaseAiRuntime(listener);
|
|
659
|
+
return releaseAiRuntime(listener, approvalListener);
|
|
628
660
|
};
|
|
629
661
|
|
|
630
|
-
const releaseAiRuntime = (
|
|
662
|
+
const releaseAiRuntime = (
|
|
663
|
+
listener: ((event: AiTurnFinalizedEvent) => Promise<void>) | null,
|
|
664
|
+
approvalListener: ApprovalRequestedListener | null,
|
|
665
|
+
): (() => void) => {
|
|
631
666
|
let released = false;
|
|
632
667
|
return () => {
|
|
633
668
|
if (released) return;
|
|
@@ -635,6 +670,7 @@ const releaseAiRuntime = (listener: ((event: AiTurnFinalizedEvent) => Promise<vo
|
|
|
635
670
|
const state = running;
|
|
636
671
|
if (!state) return;
|
|
637
672
|
if (listener) state.listeners.delete(listener);
|
|
673
|
+
if (approvalListener) state.approvalListeners.delete(approvalListener);
|
|
638
674
|
refCount = Math.max(0, refCount - 1);
|
|
639
675
|
if (refCount === 0) state.stop();
|
|
640
676
|
};
|
package/src/ai/settings.ts
CHANGED
|
@@ -315,6 +315,7 @@ export const resolveAiSettingsStateFromRaw = async (input: {
|
|
|
315
315
|
visionModelId?: string;
|
|
316
316
|
profilesJson: string;
|
|
317
317
|
globalInstructions?: string;
|
|
318
|
+
localClientInstructions?: string;
|
|
318
319
|
compactionInstructions?: string;
|
|
319
320
|
maxToolResultChars?: unknown;
|
|
320
321
|
firecrawlApiKey?: string;
|
|
@@ -327,6 +328,7 @@ export const resolveAiSettingsStateFromRaw = async (input: {
|
|
|
327
328
|
defaultModelId: input.defaultModelId ?? "",
|
|
328
329
|
visionModelId: input.visionModelId ?? "",
|
|
329
330
|
globalInstructions: input.globalInstructions ?? "",
|
|
331
|
+
localClientInstructions: input.localClientInstructions ?? "",
|
|
330
332
|
compactionInstructions: input.compactionInstructions ?? "",
|
|
331
333
|
maxToolResultChars: normalizeMaxToolResultChars(input.maxToolResultChars),
|
|
332
334
|
firecrawlConfigured: Boolean(input.firecrawlApiKey?.trim()),
|
|
@@ -417,6 +419,7 @@ const readAiSettingsSnapshot = async (): Promise<{ state: AiSettingsState; crede
|
|
|
417
419
|
visionModelId,
|
|
418
420
|
profilesJson,
|
|
419
421
|
globalInstructions,
|
|
422
|
+
localClientInstructions,
|
|
420
423
|
compactionInstructions,
|
|
421
424
|
maxToolResultChars,
|
|
422
425
|
firecrawlApiKey,
|
|
@@ -426,6 +429,7 @@ const readAiSettingsSnapshot = async (): Promise<{ state: AiSettingsState; crede
|
|
|
426
429
|
coreSettings.get<string>("ai.vision_model_id"),
|
|
427
430
|
coreSettings.get<string>("ai.model_profiles_json"),
|
|
428
431
|
coreSettings.get<string>("ai.global_instructions"),
|
|
432
|
+
coreSettings.get<string>("ai.local_client_instructions"),
|
|
429
433
|
coreSettings.get<string>("ai.compaction_instructions"),
|
|
430
434
|
coreSettings.get<number>("ai.max_tool_result_chars"),
|
|
431
435
|
coreSettings.get<string>(AI_FIRECRAWL_API_KEY_SETTING_KEY),
|
|
@@ -446,6 +450,7 @@ const readAiSettingsSnapshot = async (): Promise<{ state: AiSettingsState; crede
|
|
|
446
450
|
visionModelId: visionModelId ?? "",
|
|
447
451
|
profilesJson: profilesJson ?? "[]",
|
|
448
452
|
globalInstructions: globalInstructions ?? "",
|
|
453
|
+
localClientInstructions: localClientInstructions ?? "",
|
|
449
454
|
compactionInstructions: compactionInstructions ?? "",
|
|
450
455
|
maxToolResultChars,
|
|
451
456
|
firecrawlApiKey: firecrawlApiKey ?? "",
|
package/src/ai/skill-catalog.ts
CHANGED
|
@@ -4,9 +4,18 @@ import type { AiSkillSummary } from "./skills";
|
|
|
4
4
|
export const AI_SKILL_CATALOG_MAX_CHARS = 8_000;
|
|
5
5
|
const AI_SKILL_CATALOG_MIN_CHARS = AI_SKILL_NAME_MAX_CHARS + AI_SKILL_DESCRIPTION_MAX_CHARS + 8;
|
|
6
6
|
|
|
7
|
-
export type AiSkillCatalogEntry = Pick<AiSkillSummary, "name" | "description"
|
|
7
|
+
export type AiSkillCatalogEntry = Pick<AiSkillSummary, "name" | "description"> & { source?: "local"; manualOnly?: boolean };
|
|
8
8
|
|
|
9
|
-
const
|
|
9
|
+
export const mergeAiSkillCatalog = (
|
|
10
|
+
cloud: readonly AiSkillCatalogEntry[],
|
|
11
|
+
local: readonly AiSkillCatalogEntry[],
|
|
12
|
+
): AiSkillCatalogEntry[] => {
|
|
13
|
+
const localNames = new Set(local.map((skill) => skill.name));
|
|
14
|
+
return [...cloud.filter((skill) => !skill.manualOnly && !localNames.has(skill.name)), ...local.filter((skill) => !skill.manualOnly)];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const catalogLine = (skill: AiSkillCatalogEntry): string =>
|
|
18
|
+
`- ${skill.name}${skill.source === "local" ? " [local]" : ""}: ${skill.description.replace(/\s+/g, " ").trim()}`;
|
|
10
19
|
|
|
11
20
|
const terms = (value: string): string[] => [
|
|
12
21
|
...new Set(
|
|
@@ -30,7 +39,13 @@ const relevance = (skill: AiSkillCatalogEntry, query: string): number => {
|
|
|
30
39
|
return score;
|
|
31
40
|
};
|
|
32
41
|
|
|
33
|
-
const
|
|
42
|
+
export const searchAiSkillCatalog = (skills: readonly AiSkillCatalogEntry[], query: string): AiSkillCatalogEntry[] =>
|
|
43
|
+
rankAiSkillCatalog(
|
|
44
|
+
skills.filter((skill) => relevance(skill, query) > 0),
|
|
45
|
+
query,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
export const rankAiSkillCatalog = (skills: readonly AiSkillCatalogEntry[], query: string): AiSkillCatalogEntry[] =>
|
|
34
49
|
skills
|
|
35
50
|
.map((skill, index) => ({ skill, index, score: relevance(skill, query) }))
|
|
36
51
|
.sort((left, right) => right.score - left.score || left.index - right.index)
|
|
@@ -46,12 +61,12 @@ export const selectAiSkillCatalog = (
|
|
|
46
61
|
AI_SKILL_CATALOG_MAX_CHARS,
|
|
47
62
|
Math.max(AI_SKILL_CATALOG_MIN_CHARS, contextWindow > 0 ? Math.floor(contextWindow * 0.02 * 4) : AI_SKILL_CATALOG_MAX_CHARS),
|
|
48
63
|
);
|
|
49
|
-
const normalized = skills.map((skill) => ({
|
|
64
|
+
const normalized = skills.map((skill) => ({ ...skill, description: skill.description.replace(/\s+/g, " ").trim() }));
|
|
50
65
|
if (normalized.map(catalogLine).join("\n").length <= budget) return { skills: normalized, omitted: 0 };
|
|
51
66
|
|
|
52
67
|
const selected: AiSkillCatalogEntry[] = [];
|
|
53
68
|
let used = 0;
|
|
54
|
-
for (const skill of
|
|
69
|
+
for (const skill of rankAiSkillCatalog(normalized, query)) {
|
|
55
70
|
const size = catalogLine(skill).length + (selected.length ? 1 : 0);
|
|
56
71
|
if (used + size > budget) continue;
|
|
57
72
|
selected.push(skill);
|
package/src/ai/skill-format.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { parseDocument, stringify } from "yaml";
|
|
2
|
+
import { AI_SKILL_FILE_MOUNT } from "./file-mount";
|
|
2
3
|
|
|
3
4
|
export const AI_SKILL_NAME_MAX_CHARS = 64;
|
|
4
5
|
export const AI_SKILL_DESCRIPTION_MAX_CHARS = 1024;
|
|
@@ -26,7 +27,11 @@ export type AiSkillDocument = {
|
|
|
26
27
|
extraFrontmatter: AiSkillExtraFrontmatter;
|
|
27
28
|
};
|
|
28
29
|
|
|
29
|
-
const
|
|
30
|
+
export const isAiSkillManualOnly = (extraFrontmatter: AiSkillExtraFrontmatter): boolean =>
|
|
31
|
+
extraFrontmatter["disable-model-invocation"] === true;
|
|
32
|
+
|
|
33
|
+
export const mountAiSkillInstructionLinks = (name: string, instructions: string): string =>
|
|
34
|
+
instructions.replace(/\]\((references\/[a-z0-9][a-z0-9._-]*\.md)\)/g, (_, path: string) => `](${AI_SKILL_FILE_MOUNT}/${name}/${path})`);
|
|
30
35
|
|
|
31
36
|
const assertJsonValue = (value: unknown, path: string): void => {
|
|
32
37
|
if (value === null || typeof value === "string" || typeof value === "boolean") return;
|
|
@@ -35,7 +40,7 @@ const assertJsonValue = (value: unknown, path: string): void => {
|
|
|
35
40
|
value.forEach((item, index) => assertJsonValue(item, `${path}[${index}]`));
|
|
36
41
|
return;
|
|
37
42
|
}
|
|
38
|
-
if (typeof value === "object") {
|
|
43
|
+
if (typeof value === "object" && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)) {
|
|
39
44
|
for (const [key, child] of Object.entries(value)) assertJsonValue(child, `${path}.${key}`);
|
|
40
45
|
return;
|
|
41
46
|
}
|
|
@@ -73,8 +78,8 @@ export const validateAiSkillInstructions = (value: string): string => {
|
|
|
73
78
|
|
|
74
79
|
export const validateAiSkillExtraFrontmatter = (value: AiSkillExtraFrontmatter = {}): AiSkillExtraFrontmatter => {
|
|
75
80
|
for (const key of Object.keys(value)) {
|
|
76
|
-
if (
|
|
77
|
-
throw new Error(`
|
|
81
|
+
if (key === "name" || key === "description") {
|
|
82
|
+
throw new Error(`SKILL.md frontmatter field "${key}" must not appear in extraFrontmatter; it would collide on export.`);
|
|
78
83
|
}
|
|
79
84
|
}
|
|
80
85
|
assertJsonValue(value, "Skill frontmatter");
|
|
@@ -109,7 +114,8 @@ export const validateAiSkillReferences = (references: readonly AiSkillReferenceI
|
|
|
109
114
|
});
|
|
110
115
|
};
|
|
111
116
|
|
|
112
|
-
|
|
117
|
+
/** Split SKILL.md into its frontmatter fields and body without validating either; local clients apply their own rules. */
|
|
118
|
+
export const splitAiSkillMarkdown = (source: string): { fields: Record<string, unknown>; body: string } => {
|
|
113
119
|
const normalized = source.replace(/\r\n/g, "\n");
|
|
114
120
|
if (!normalized.startsWith("---\n")) throw new Error("SKILL.md must start with YAML frontmatter.");
|
|
115
121
|
const end = normalized.indexOf("\n---\n", 4);
|
|
@@ -120,7 +126,11 @@ export const parseAiSkillMarkdown = (source: string): AiSkillDocument => {
|
|
|
120
126
|
if (!frontmatter || typeof frontmatter !== "object" || Array.isArray(frontmatter)) {
|
|
121
127
|
throw new Error("SKILL.md frontmatter must be a YAML object.");
|
|
122
128
|
}
|
|
123
|
-
|
|
129
|
+
return { fields: { ...(frontmatter as Record<string, unknown>) }, body: normalized.slice(end + 5) };
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export const parseAiSkillMarkdown = (source: string): AiSkillDocument => {
|
|
133
|
+
const { fields, body } = splitAiSkillMarkdown(source);
|
|
124
134
|
if (typeof fields.name !== "string") throw new Error("SKILL.md frontmatter requires a string name.");
|
|
125
135
|
if (typeof fields.description !== "string") throw new Error("SKILL.md frontmatter requires a string description.");
|
|
126
136
|
const name = validateAiSkillName(fields.name);
|
|
@@ -130,7 +140,7 @@ export const parseAiSkillMarkdown = (source: string): AiSkillDocument => {
|
|
|
130
140
|
return {
|
|
131
141
|
name,
|
|
132
142
|
description,
|
|
133
|
-
instructions: validateAiSkillInstructions(
|
|
143
|
+
instructions: validateAiSkillInstructions(body),
|
|
134
144
|
extraFrontmatter: validateAiSkillExtraFrontmatter(fields),
|
|
135
145
|
};
|
|
136
146
|
};
|