@k2b/cloud 0.30.0 → 0.32.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 +7 -4
- package/src/_internal/app-presentation.ts +22 -1
- package/src/_internal/capabilities.ts +2 -2
- package/src/_internal/define-app.ts +17 -5
- package/src/_internal/registry.ts +50 -6
- package/src/_internal/widget-declarations.ts +53 -0
- package/src/ai/app-skills.ts +1 -7
- package/src/ai/approval-routes.ts +40 -4
- package/src/ai/approvals.ts +68 -10
- 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 +64 -39
- 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/live-turn.browser-harness.tsx +4 -1
- package/src/ai/chat/message-actions.tsx +9 -1
- package/src/ai/chat/message-utils.ts +7 -0
- package/src/ai/chat/messages.ts +34 -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 +67 -12
- package/src/ai/chat/tool-names.ts +18 -2
- package/src/ai/chat/turn-layout.ts +8 -0
- package/src/ai/chat/turn-view.tsx +111 -32
- 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/chat/web-tools.tsx +95 -1
- package/src/ai/client/controller.ts +6 -3
- package/src/ai/client-tool-results.ts +9 -0
- package/src/ai/code-runtime-tools.ts +50 -17
- 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 +334 -91
- package/src/ai/default-tools.ts +87 -11
- package/src/ai/executor.ts +343 -45
- package/src/ai/fetch-file-tool.ts +41 -4
- 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/firecrawl-tools.ts +87 -4
- package/src/ai/html-pdf-tool.ts +3 -2
- package/src/ai/http.ts +37 -12
- package/src/ai/index.ts +98 -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/migrate.ts +120 -2
- 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 +28 -3
- package/src/ai/provider.ts +16 -4
- package/src/ai/request-context.ts +33 -0
- package/src/ai/routes.ts +99 -49
- package/src/ai/runtime.ts +90 -15
- 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 +91 -1
- package/src/ai/system-prompt.ts +69 -10
- package/src/ai/timeline.ts +3 -1
- package/src/ai/tool-audit.ts +11 -1
- package/src/ai/tools.ts +94 -3
- package/src/ai/types.ts +48 -0
- package/src/ai/view-block.ts +941 -0
- package/src/ai/vision-tool.ts +2 -1
- package/src/ai/web-provenance.ts +103 -0
- 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/capabilities.ts +3 -2
- package/src/api/index.ts +3 -0
- package/src/api/me.ts +12 -3
- package/src/api/widgets.ts +36 -5
- 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/CloudResourceSearch.tsx +1 -1
- package/src/browser/app-approval.ts +32 -0
- package/src/browser/choose-files.tsx +26 -13
- package/src/browser/command-shortcuts.ts +21 -6
- 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/provider-list.ts +2 -1
- package/src/browser/save-files.tsx +13 -2
- package/src/browser/spotlight-position.ts +4 -1
- package/src/browser/widgets.ts +7 -5
- package/src/capabilities/executions.ts +3 -0
- package/src/cli/account.ts +21 -2
- package/src/cli/commands.ts +24 -2
- package/src/cli/index.ts +16 -0
- package/src/cli/plugin.ts +30 -0
- package/src/contracts/app-approval.ts +120 -0
- package/src/contracts/app.ts +34 -2
- 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/registry.ts +9 -1
- package/src/contracts/shared.ts +5 -0
- package/src/contracts/widgets.ts +47 -2
- package/src/desktop/solid.tsx +6 -7
- 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 +3 -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/server/widget-request.ts +27 -0
- 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 +241 -0
- package/src/services/approvals/list.ts +120 -0
- package/src/services/approvals/migrate.ts +41 -0
- package/src/services/identity/models-only-auth.ts +25 -0
- package/src/services/notifications/email-frame.ts +16 -3
- 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 +26 -1
- package/src/shared/app-presentation.ts +11 -0
- package/src/shared/email-html.ts +46 -7
- 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/ssr/AdminLayout.tsx +4 -7
- package/src/ssr/AppBadge.tsx +4 -4
- package/src/ssr/AppLaunchpad.island.tsx +1 -1
- package/src/ssr/AppLaunchpadPanel.tsx +4 -4
- 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/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/RailApps.island.tsx +2 -2
- package/src/ssr/app-badges.ts +50 -6
- 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 +265 -0
- package/src/ssr/page-trail.ts +59 -0
- package/src/ssr/platform-messages.ts +4 -0
- package/src/ssr/rail-navigation.ts +5 -3
- package/src/ssr/runtime.ts +6 -1
- package/src/styles/approvals.css +390 -0
- package/src/styles/effects.css +21 -4
- package/src/styles/global.css +39 -3
- package/src/styles/utilities-layout.css +24 -1
- package/src/styles/utilities-navigation.css +6 -18
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,
|
|
@@ -18,17 +18,18 @@ 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";
|
|
24
25
|
import { readThemeFromCookieHeader } from "../shared/theme";
|
|
25
|
-
import type { AiToolApprovalContext } from "./approvals";
|
|
26
26
|
import { assistantAiSettingsState, listAssistantAiModels, resolveAssistantAudioModel, selectAssistantAiModelId } from "./assistant-models";
|
|
27
27
|
import { AI_AUDIO_MAX_BYTES } from "./audio-format";
|
|
28
28
|
import { buildAiCapabilityCatalog } from "./capabilities";
|
|
29
29
|
import { aiChatMessages } from "./chat/messages";
|
|
30
30
|
import { getAiChatQuotas } from "./chat-quotas";
|
|
31
|
-
import {
|
|
31
|
+
import { AI_CLIENT_TOOL_ACTION_MAX_BYTES, aiClientToolOutputSchemas } from "./client-tool-results";
|
|
32
|
+
import { createCloudAiLocalBashTool, createCloudAiLocalTools, createConfiguredDefaultCloudAiTools } from "./default-tools";
|
|
32
33
|
import { enqueueAiDictation } from "./dictation-runtime";
|
|
33
34
|
import { AiDictationConflict, AiDictationListSchema, AiDictationStartSchema, aiDictations } from "./dictations";
|
|
34
35
|
import { aiProjectFilePathFromMount } from "./file-mount";
|
|
@@ -48,6 +49,7 @@ import {
|
|
|
48
49
|
toAiActionFailureResponse,
|
|
49
50
|
toAiErrorResponse,
|
|
50
51
|
} from "./http";
|
|
52
|
+
import { AI_LOCAL_TOOL_NAMES, AiLocalClientSchema } from "./local-tool-contracts";
|
|
51
53
|
import { aiMaintenanceJobs } from "./maintenance";
|
|
52
54
|
import { AI_MEMORY_CONTENT_MAX_CHARS, aiMemories } from "./memories";
|
|
53
55
|
import { aiMemoryLearningRuns } from "./memory-learning-runs";
|
|
@@ -60,10 +62,10 @@ import {
|
|
|
60
62
|
queuedMessageAccepted,
|
|
61
63
|
updateQueuedMessage,
|
|
62
64
|
} from "./message-queue";
|
|
63
|
-
import { personalAiModelPolicy } from "./personal-agent";
|
|
64
65
|
import { aiActorUser, aiPrefsUserId, aiUserPrefs } from "./prefs";
|
|
65
66
|
import { aiProjects } from "./projects";
|
|
66
67
|
import { projectPublicAiStoredMessages, publicAiStoredMessages } from "./public-projection";
|
|
68
|
+
import { type AiChatRequestContext, resolveAiChatRequestContext as resolveContext } from "./request-context";
|
|
67
69
|
import { aiResourceMarker } from "./resource-markers";
|
|
68
70
|
import { isConversationResourceCursor } from "./resource-refs";
|
|
69
71
|
import {
|
|
@@ -85,29 +87,7 @@ import { aiConversations } from "./store";
|
|
|
85
87
|
import { createAiConversationStreamResponse, loadAiStreamState } from "./stream";
|
|
86
88
|
import { composeAiSystemPrompt } from "./system-prompt";
|
|
87
89
|
import { aiToolPromptHints } from "./tools";
|
|
88
|
-
import type { AiConversation,
|
|
89
|
-
|
|
90
|
-
/** Everything a resolved, authorized request needs to run against the shared runtime. */
|
|
91
|
-
type AiChatRequestContext = {
|
|
92
|
-
actor: RequestActor;
|
|
93
|
-
ownerUserId: string;
|
|
94
|
-
modelPolicy: AiModelPolicy;
|
|
95
|
-
toolApprovalContext: AiToolApprovalContext;
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
const actorUser = (actor: RequestActor) => (actor.kind === "user" ? actor.user : actor.delegatedUser);
|
|
99
|
-
|
|
100
|
-
const resolveContext = async (c: Context<AuthContext>): Promise<AiChatRequestContext | ApiErrorResponse> => {
|
|
101
|
-
const actor = c.get("actor");
|
|
102
|
-
const user = actorUser(actor);
|
|
103
|
-
if (!user) return respond(c, fail(err.forbidden("AI conversations require a user-backed actor.")));
|
|
104
|
-
return {
|
|
105
|
-
actor,
|
|
106
|
-
ownerUserId: user.id,
|
|
107
|
-
modelPolicy: personalAiModelPolicy,
|
|
108
|
-
toolApprovalContext: { actorUserId: user.id },
|
|
109
|
-
};
|
|
110
|
-
};
|
|
90
|
+
import type { AiConversation, AiTurn } from "./types";
|
|
111
91
|
|
|
112
92
|
const retryInstruction = (mode: "retry" | "details" | "concise"): string | null => {
|
|
113
93
|
if (mode === "details") return "Answer the user's request again with more detail and specificity.";
|
|
@@ -326,7 +306,11 @@ export const aiRoutes = (() => {
|
|
|
326
306
|
.use(rateLimit())
|
|
327
307
|
.use("*", auth.requireRole("authenticated"))
|
|
328
308
|
.get("/status", async (c) => {
|
|
329
|
-
|
|
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 } }));
|
|
330
314
|
})
|
|
331
315
|
.get("/quotas", async (c) => {
|
|
332
316
|
c.header("Cache-Control", "no-store");
|
|
@@ -402,6 +386,21 @@ export const aiRoutes = (() => {
|
|
|
402
386
|
const user = aiActorUser(ctx.actor);
|
|
403
387
|
if (!user) return respond(c, fail(err.forbidden("AI preferences require a user context.")));
|
|
404
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;
|
|
405
404
|
const prefs = await aiUserPrefs.get(user.id);
|
|
406
405
|
const memoryEnabled = Boolean(prefs?.memoryEnabled);
|
|
407
406
|
const memory = memoryEnabled ? await aiMemories.selectHot(user.id, "") : null;
|
|
@@ -417,13 +416,18 @@ export const aiRoutes = (() => {
|
|
|
417
416
|
}
|
|
418
417
|
const toolsSupported = Boolean(previewProfile?.capabilities.includes("tools"));
|
|
419
418
|
const availableSkills = toolsSupported ? (await aiSkills.list(c.get("accessSubject"))).filter((skill) => skill.enabled) : [];
|
|
420
|
-
const skillCatalog = selectAiSkillCatalog(
|
|
419
|
+
const skillCatalog = selectAiSkillCatalog(
|
|
420
|
+
availableSkills.filter((skill) => !skill.manualOnly).map(({ name, description }) => ({ name, description })),
|
|
421
|
+
previewProfile?.contextWindow ?? 0,
|
|
422
|
+
"",
|
|
423
|
+
);
|
|
421
424
|
const tools = toolsSupported
|
|
422
425
|
? [
|
|
423
426
|
...(await createConfiguredDefaultCloudAiTools({
|
|
424
427
|
accessSubject: c.get("accessSubject"),
|
|
425
428
|
allowedDataBoundaries: ctx.modelPolicy.allowedDataBoundaries,
|
|
426
429
|
})),
|
|
430
|
+
...(client?.localTools === "ask" ? [createCloudAiLocalBashTool(), ...createCloudAiLocalTools()] : []),
|
|
427
431
|
...(memoryEnabled ? [createCloudAiMemoryTool()] : []),
|
|
428
432
|
...(availableSkills.length ? [createCloudAiLoadSkillTool(c.get("accessSubject"))] : []),
|
|
429
433
|
...(skillCatalog.omitted > 0 ? [createCloudAiSearchSkillsTool(c.get("accessSubject"))] : []),
|
|
@@ -434,6 +438,9 @@ export const aiRoutes = (() => {
|
|
|
434
438
|
const promptLocale = getLocale(c);
|
|
435
439
|
const prompt = composeAiSystemPrompt({
|
|
436
440
|
globalInstructions: state.globalInstructions,
|
|
441
|
+
localClient: client
|
|
442
|
+
? { client, cloudUrl: (await coreSettings.get<string>("app.url")) || "", instructions: state.localClientInstructions }
|
|
443
|
+
: undefined,
|
|
437
444
|
user,
|
|
438
445
|
memoryEnabled,
|
|
439
446
|
memoryToolEnabled,
|
|
@@ -446,7 +453,7 @@ export const aiRoutes = (() => {
|
|
|
446
453
|
memory: memory?.text,
|
|
447
454
|
timeZone,
|
|
448
455
|
locale: promptLocale,
|
|
449
|
-
skillCreatorAvailable: availableSkills.some((skill) => skill.name === "skill-creator"),
|
|
456
|
+
skillCreatorAvailable: availableSkills.some((skill) => skill.name === "skill-creator" && !skill.manualOnly),
|
|
450
457
|
});
|
|
451
458
|
return respond(c, ok({ prompt, renderedAt: new Date().toISOString() }));
|
|
452
459
|
})
|
|
@@ -890,6 +897,7 @@ export const aiRoutes = (() => {
|
|
|
890
897
|
try {
|
|
891
898
|
const submission = {
|
|
892
899
|
assistantChat: true as const,
|
|
900
|
+
...(ctx.signedInSession ? { signedInSession: true as const } : {}),
|
|
893
901
|
conversationId: conversation.id,
|
|
894
902
|
chatId: conversation.shortId,
|
|
895
903
|
input,
|
|
@@ -902,6 +910,8 @@ export const aiRoutes = (() => {
|
|
|
902
910
|
modelPolicy: ctx.modelPolicy,
|
|
903
911
|
project: project ?? undefined,
|
|
904
912
|
clientToolIds: body.clientToolIds,
|
|
913
|
+
client: body.client,
|
|
914
|
+
localContext: body.localContext,
|
|
905
915
|
toolSource: { kind: "default" as const, appTools: true },
|
|
906
916
|
toolApprovalContext: ctx.toolApprovalContext,
|
|
907
917
|
expectedDraftRevision: body.draftRevision,
|
|
@@ -999,6 +1009,7 @@ export const aiRoutes = (() => {
|
|
|
999
1009
|
}
|
|
1000
1010
|
const result = await submitAiChatTurn({
|
|
1001
1011
|
assistantChat: true,
|
|
1012
|
+
...(ctx.signedInSession ? { signedInSession: true as const } : {}),
|
|
1002
1013
|
conversationId: conversation.id,
|
|
1003
1014
|
chatId: conversation.shortId,
|
|
1004
1015
|
input,
|
|
@@ -1016,7 +1027,20 @@ export const aiRoutes = (() => {
|
|
|
1016
1027
|
truncateFromSeq: target.seq,
|
|
1017
1028
|
fileSnapshot: originalRunConfig?.kind !== "compact" ? originalRunConfig?.files : undefined,
|
|
1018
1029
|
retrySourceTurnId: target.loopId ?? undefined,
|
|
1019
|
-
|
|
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,
|
|
1020
1044
|
});
|
|
1021
1045
|
rememberLastUsedModel(ctx.actor, result.turn.modelProfileId);
|
|
1022
1046
|
return respond(
|
|
@@ -1108,24 +1132,49 @@ export const aiRoutes = (() => {
|
|
|
1108
1132
|
await abortAiTurn({ conversationId: conversation.id, turnId: turn.id });
|
|
1109
1133
|
return respond(c, ok({ ok: true }));
|
|
1110
1134
|
})
|
|
1111
|
-
.post(
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
callId
|
|
1123
|
-
action
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
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
|
+
)
|
|
1129
1178
|
.get("/conversations/:conversationId/pending-actions/:turnId", async (c) => {
|
|
1130
1179
|
const ctx = await resolveContext(c);
|
|
1131
1180
|
if (ctx instanceof Response) return ctx;
|
|
@@ -1342,6 +1391,7 @@ export const aiRoutes = (() => {
|
|
|
1342
1391
|
"Content-Type": stored.mediaType || "application/octet-stream",
|
|
1343
1392
|
"Content-Disposition": `attachment; filename="${filename}"`,
|
|
1344
1393
|
"Content-Length": String(stored.size),
|
|
1394
|
+
"X-Cloud-Ai-File-Origin": stored.origin,
|
|
1345
1395
|
"Cache-Control": "private, no-store",
|
|
1346
1396
|
});
|
|
1347
1397
|
})
|
package/src/ai/runtime.ts
CHANGED
|
@@ -4,14 +4,16 @@ 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
|
-
import { AiTurnExecutor } from "./executor";
|
|
14
|
+
import { AiTurnExecutor, type ExecutorConfig } from "./executor";
|
|
14
15
|
import { canonicalizeAiConversationAttachments, snapshotAiConversationFiles } from "./file-context";
|
|
16
|
+
import type { AiLocalClient, AiLocalContext } from "./local-tool-contracts";
|
|
15
17
|
import { drainQueuedMessages } from "./message-queue";
|
|
16
18
|
import { AI_TURN_LEASE_MS } from "./protocol";
|
|
17
19
|
import { aiQuotas } from "./quotas";
|
|
@@ -87,6 +89,8 @@ export const enqueueExistingAiTurn = (input: AiTurnJob): Promise<unknown> => enq
|
|
|
87
89
|
export type SubmitAiChatTurnInput = {
|
|
88
90
|
/** Only interactive Assistant HTTP handlers set this server-owned marker. */
|
|
89
91
|
assistantChat?: true;
|
|
92
|
+
/** Only a handler that saw a signed-in browser session sets this server-owned marker; see `AiChatTurnRunConfig`. */
|
|
93
|
+
signedInSession?: true;
|
|
90
94
|
conversationId: string;
|
|
91
95
|
/** Stable public ID exposed as runtime context, not instructions. */
|
|
92
96
|
chatId?: string;
|
|
@@ -102,6 +106,8 @@ export type SubmitAiChatTurnInput = {
|
|
|
102
106
|
systemPrompt?: string;
|
|
103
107
|
project?: AiChatTurnRunConfig["project"];
|
|
104
108
|
clientToolIds?: AiClientToolId[];
|
|
109
|
+
client?: AiLocalClient;
|
|
110
|
+
localContext?: AiLocalContext;
|
|
105
111
|
toolSource?: AiTurnToolSource;
|
|
106
112
|
canInspectAttachedImages?: boolean;
|
|
107
113
|
toolApprovalContext?: AiToolApprovalContext;
|
|
@@ -116,6 +122,7 @@ export type SubmitAiChatTurnInput = {
|
|
|
116
122
|
};
|
|
117
123
|
|
|
118
124
|
export const prepareAiChatTurn = async (input: SubmitAiChatTurnInput) => {
|
|
125
|
+
if (input.localContext && !input.client) throw new Error("Local context requires a local client.");
|
|
119
126
|
if (input.clientToolIds?.length && input.toolSource?.kind !== "default") {
|
|
120
127
|
throw new Error("Optional client tools require the default tool source.");
|
|
121
128
|
}
|
|
@@ -153,6 +160,7 @@ export const prepareAiChatTurn = async (input: SubmitAiChatTurnInput) => {
|
|
|
153
160
|
),
|
|
154
161
|
],
|
|
155
162
|
...(input.assistantChat ? { assistantChat: true } : {}),
|
|
163
|
+
...(input.signedInSession ? { signedInSession: true } : {}),
|
|
156
164
|
input: canonicalInput,
|
|
157
165
|
chatId: input.chatId,
|
|
158
166
|
actor: input.actor,
|
|
@@ -166,6 +174,8 @@ export const prepareAiChatTurn = async (input: SubmitAiChatTurnInput) => {
|
|
|
166
174
|
files,
|
|
167
175
|
canInspectAttachedImages,
|
|
168
176
|
clientToolIds: input.clientToolIds,
|
|
177
|
+
client: input.client,
|
|
178
|
+
localContext: input.localContext,
|
|
169
179
|
toolSource: input.toolSource ?? { kind: "none" },
|
|
170
180
|
toolApprovalContext: input.toolApprovalContext,
|
|
171
181
|
};
|
|
@@ -297,7 +307,7 @@ export const abortAiTurn = async (input: { conversationId: string; turnId: strin
|
|
|
297
307
|
// ---------------------------------------------------------------------------
|
|
298
308
|
|
|
299
309
|
export const AiTurnActionSchema = z.discriminatedUnion("type", [
|
|
300
|
-
z.object({ type: z.literal("approval_response"), approved: z.boolean(), remember: z.
|
|
310
|
+
z.object({ type: z.literal("approval_response"), approved: z.boolean(), remember: z.enum(["chat", "always"]).optional() }),
|
|
301
311
|
z.object({ type: z.literal("tool_result"), result: z.unknown() }),
|
|
302
312
|
]);
|
|
303
313
|
export type AiTurnActionInput = z.infer<typeof AiTurnActionSchema>;
|
|
@@ -312,7 +322,7 @@ export const listPendingAiTurnActions = async (input: { conversationId: string;
|
|
|
312
322
|
const [actions, config] = await Promise.all([aiConversations.listPendingTurnActions(input), aiConversations.getTurnRunConfig(input)]);
|
|
313
323
|
return aiTurnAllowsRememberedApprovals(config)
|
|
314
324
|
? actions
|
|
315
|
-
: actions.map((action) => (action.type === "approval_request" ? { ...action, allowAlways: false } : action));
|
|
325
|
+
: actions.map((action) => (action.type === "approval_request" ? { ...action, allowAlways: false, allowChat: false } : action));
|
|
316
326
|
};
|
|
317
327
|
|
|
318
328
|
export const submitAiTurnAction = async (input: {
|
|
@@ -320,8 +330,11 @@ export const submitAiTurnAction = async (input: {
|
|
|
320
330
|
turnId: string;
|
|
321
331
|
callId: string;
|
|
322
332
|
action: AiTurnActionInput;
|
|
333
|
+
decidedBy: { userId: string; via: ApprovalDecisionChannel; deviceId?: string };
|
|
323
334
|
toolApprovalContext?: AiToolApprovalContext;
|
|
324
|
-
|
|
335
|
+
/** Whether a person decides in a signed-in browser session; only such a decision may remember a website. */
|
|
336
|
+
signedInSession?: boolean;
|
|
337
|
+
}): Promise<{ ok: true } | { ok: false; status: 400 | 403 | 404 | 409; message: string }> => {
|
|
325
338
|
const pending = await aiConversations.getPendingTurnAction(input);
|
|
326
339
|
if (!pending) return { ok: false, status: 404, message: "This request has expired — the assistant already moved on." };
|
|
327
340
|
if (pending.status === "resolved") {
|
|
@@ -338,18 +351,52 @@ export const submitAiTurnAction = async (input: {
|
|
|
338
351
|
|
|
339
352
|
if (input.action.type === "approval_response") {
|
|
340
353
|
if (pending.kind === "client_tool") return { ok: false, status: 400, message: "Frontend tool requests require a tool result." };
|
|
341
|
-
|
|
354
|
+
const remember = input.action.remember;
|
|
355
|
+
if (remember) {
|
|
342
356
|
const config = await aiConversations.getTurnRunConfig(input);
|
|
343
|
-
|
|
357
|
+
const toolName = pending.rememberToolName ?? pending.name;
|
|
358
|
+
const allowed = remember === "always" ? pending.allowAlways : (pending.allowChat ?? pending.allowAlways);
|
|
359
|
+
if (
|
|
360
|
+
!input.action.approved ||
|
|
361
|
+
!allowed ||
|
|
362
|
+
!input.toolApprovalContext ||
|
|
363
|
+
!aiTurnAllowsRememberedApprovals(config) ||
|
|
364
|
+
// A website is remembered only by a person in a signed-in session: `cld`, an API key, or another delegated
|
|
365
|
+
// credential can approve this one request, but cannot let the next ones through.
|
|
366
|
+
(toolName === AI_WEBSITE_APPROVAL_TOOL && (remember !== "chat" || !input.signedInSession))
|
|
367
|
+
) {
|
|
344
368
|
return { ok: false, status: 400, message: "This approval cannot be remembered." };
|
|
345
369
|
}
|
|
346
|
-
|
|
370
|
+
}
|
|
371
|
+
if (pending.approvalId) {
|
|
372
|
+
const decision = await approvals.decide(pending.approvalId, { approved: input.action.approved }, input.decidedBy);
|
|
373
|
+
if (!decision.ok)
|
|
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." };
|
|
379
|
+
}
|
|
380
|
+
if (remember && input.toolApprovalContext) {
|
|
381
|
+
const toolName = pending.rememberToolName ?? pending.name;
|
|
382
|
+
await rememberAiToolApproval(input.toolApprovalContext, {
|
|
383
|
+
toolName,
|
|
384
|
+
approvalScope: pending.approvalScope,
|
|
385
|
+
conversationId: remember === "chat" ? input.conversationId : null,
|
|
386
|
+
});
|
|
347
387
|
}
|
|
348
388
|
await aiToolAudit
|
|
349
389
|
.noteApprovalResolved({
|
|
350
390
|
turnId: input.turnId,
|
|
351
391
|
callId: input.callId,
|
|
352
|
-
|
|
392
|
+
decidedVia: input.decidedBy.via,
|
|
393
|
+
approvalState: input.action.approved
|
|
394
|
+
? remember === "always"
|
|
395
|
+
? "approved_always"
|
|
396
|
+
: remember === "chat"
|
|
397
|
+
? "approved_for_chat"
|
|
398
|
+
: "approved_once"
|
|
399
|
+
: "rejected",
|
|
353
400
|
})
|
|
354
401
|
.catch(() => undefined);
|
|
355
402
|
|
|
@@ -386,11 +433,14 @@ export const submitAiTurnAction = async (input: {
|
|
|
386
433
|
// Worker + sweep lifecycle
|
|
387
434
|
// ---------------------------------------------------------------------------
|
|
388
435
|
|
|
436
|
+
type ApprovalRequestedListener = NonNullable<ExecutorConfig["onApprovalRequested"]>;
|
|
437
|
+
|
|
389
438
|
const runClaimedTurn = async (
|
|
390
439
|
job: AiTurnJob,
|
|
391
440
|
signal: AbortSignal,
|
|
392
441
|
leaseOwner: string,
|
|
393
442
|
onTurnFinalized?: (event: AiTurnFinalizedEvent) => Promise<void>,
|
|
443
|
+
onApprovalRequested?: ApprovalRequestedListener,
|
|
394
444
|
): Promise<void> => {
|
|
395
445
|
const timeoutMinutes = await coreSettings.get<number>("ai.turn_timeout_minutes");
|
|
396
446
|
const runBudgetMs = Math.max(0, Number(timeoutMinutes ?? 30)) * 60_000;
|
|
@@ -419,6 +469,7 @@ const runClaimedTurn = async (
|
|
|
419
469
|
heartbeatMs: AI_TURN_HEARTBEAT_MS,
|
|
420
470
|
enqueueContinuation: (input) => enqueueAiTurn(input).then(() => undefined),
|
|
421
471
|
onTurnFinalized,
|
|
472
|
+
onApprovalRequested,
|
|
422
473
|
});
|
|
423
474
|
await executor.run({ conversationId: job.conversationId, turnId: job.turnId, claim, signal });
|
|
424
475
|
};
|
|
@@ -427,6 +478,7 @@ const processMessage = async (
|
|
|
427
478
|
message: Pick<QueueMessage<AiTurnJob>, "messageId" | "data" | "heartbeat">,
|
|
428
479
|
signal: AbortSignal,
|
|
429
480
|
onTurnFinalized?: (event: AiTurnFinalizedEvent) => Promise<void>,
|
|
481
|
+
onApprovalRequested?: ApprovalRequestedListener,
|
|
430
482
|
): Promise<void> => {
|
|
431
483
|
const leaseOwner = `${AI_WORKER_ID}:${message.messageId}:${crypto.randomUUID()}`;
|
|
432
484
|
let heartbeatFailed = false;
|
|
@@ -449,7 +501,7 @@ const processMessage = async (
|
|
|
449
501
|
}, AI_TURN_HEARTBEAT_MS);
|
|
450
502
|
if (typeof touch === "object" && "unref" in touch) touch.unref();
|
|
451
503
|
try {
|
|
452
|
-
await runClaimedTurn(message.data, signal, leaseOwner, onTurnFinalized);
|
|
504
|
+
await runClaimedTurn(message.data, signal, leaseOwner, onTurnFinalized, onApprovalRequested);
|
|
453
505
|
} catch (error) {
|
|
454
506
|
log.error("AI turn worker error", {
|
|
455
507
|
conversationId: message.data.conversationId,
|
|
@@ -511,6 +563,7 @@ export const sweepAiRuntime = async (onTurnFinalized?: (event: AiTurnFinalizedEv
|
|
|
511
563
|
|
|
512
564
|
type AiRuntimeState = {
|
|
513
565
|
listeners: Set<(event: AiTurnFinalizedEvent) => Promise<void>>;
|
|
566
|
+
approvalListeners: Set<ApprovalRequestedListener>;
|
|
514
567
|
stop: () => void;
|
|
515
568
|
};
|
|
516
569
|
|
|
@@ -518,19 +571,29 @@ let running: AiRuntimeState | null = null;
|
|
|
518
571
|
let refCount = 0;
|
|
519
572
|
|
|
520
573
|
export const startAiRuntime = (
|
|
521
|
-
input: {
|
|
574
|
+
input: {
|
|
575
|
+
concurrency?: number;
|
|
576
|
+
onTurnFinalized?: (event: AiTurnFinalizedEvent) => Promise<void>;
|
|
577
|
+
onApprovalRequested?: ApprovalRequestedListener;
|
|
578
|
+
} = {},
|
|
522
579
|
): (() => void) => {
|
|
523
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;
|
|
524
584
|
if (running) {
|
|
525
585
|
refCount += 1;
|
|
526
586
|
if (listener) running.listeners.add(listener);
|
|
527
|
-
|
|
587
|
+
if (approvalListener) running.approvalListeners.add(approvalListener);
|
|
588
|
+
return releaseAiRuntime(listener, approvalListener);
|
|
528
589
|
}
|
|
529
590
|
|
|
530
591
|
const concurrency = Math.min(Math.max(Math.floor(input.concurrency ?? AI_TURN_WORKER_CONCURRENCY), 1), 64);
|
|
531
592
|
const controller = new AbortController();
|
|
532
593
|
const listeners = new Set<(event: AiTurnFinalizedEvent) => Promise<void>>();
|
|
533
594
|
if (listener) listeners.add(listener);
|
|
595
|
+
const approvalListeners = new Set<ApprovalRequestedListener>();
|
|
596
|
+
if (approvalListener) approvalListeners.add(approvalListener);
|
|
534
597
|
refCount = 1;
|
|
535
598
|
const dispatchTurnFinalized = async (event: AiTurnFinalizedEvent): Promise<void> => {
|
|
536
599
|
const results = await Promise.allSettled([...listeners].map((notify) => notify(event)));
|
|
@@ -538,12 +601,18 @@ export const startAiRuntime = (
|
|
|
538
601
|
if (errors.length > 0) throw new AggregateError(errors, `${errors.length} AI turn finalized listener(s) failed`);
|
|
539
602
|
};
|
|
540
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
|
+
|
|
541
610
|
void superviseRuntimeTask({
|
|
542
611
|
name: "AI turn worker",
|
|
543
612
|
signal: controller.signal,
|
|
544
613
|
run: async (signal) => {
|
|
545
614
|
const worker = await aiTurnQueue().process({ concurrency, signal }, (message) =>
|
|
546
|
-
processMessage(message, message.signal, dispatchTurnFinalized),
|
|
615
|
+
processMessage(message, message.signal, dispatchTurnFinalized, dispatchApprovalRequested),
|
|
547
616
|
);
|
|
548
617
|
try {
|
|
549
618
|
await new Promise<void>((resolve) => {
|
|
@@ -573,22 +642,27 @@ export const startAiRuntime = (
|
|
|
573
642
|
const stopDictations = startAiDictationRuntime();
|
|
574
643
|
const state: AiRuntimeState = {
|
|
575
644
|
listeners,
|
|
645
|
+
approvalListeners,
|
|
576
646
|
stop: () => {
|
|
577
647
|
if (running !== state) return;
|
|
578
648
|
controller.abort();
|
|
579
649
|
stopDictations();
|
|
580
650
|
clearInterval(sweepTimer);
|
|
581
651
|
listeners.clear();
|
|
652
|
+
approvalListeners.clear();
|
|
582
653
|
running = null;
|
|
583
654
|
refCount = 0;
|
|
584
655
|
},
|
|
585
656
|
};
|
|
586
657
|
running = state;
|
|
587
658
|
log.info("AI runtime started", { workerId: AI_WORKER_ID, concurrency });
|
|
588
|
-
return releaseAiRuntime(listener);
|
|
659
|
+
return releaseAiRuntime(listener, approvalListener);
|
|
589
660
|
};
|
|
590
661
|
|
|
591
|
-
const releaseAiRuntime = (
|
|
662
|
+
const releaseAiRuntime = (
|
|
663
|
+
listener: ((event: AiTurnFinalizedEvent) => Promise<void>) | null,
|
|
664
|
+
approvalListener: ApprovalRequestedListener | null,
|
|
665
|
+
): (() => void) => {
|
|
592
666
|
let released = false;
|
|
593
667
|
return () => {
|
|
594
668
|
if (released) return;
|
|
@@ -596,6 +670,7 @@ const releaseAiRuntime = (listener: ((event: AiTurnFinalizedEvent) => Promise<vo
|
|
|
596
670
|
const state = running;
|
|
597
671
|
if (!state) return;
|
|
598
672
|
if (listener) state.listeners.delete(listener);
|
|
673
|
+
if (approvalListener) state.approvalListeners.delete(approvalListener);
|
|
599
674
|
refCount = Math.max(0, refCount - 1);
|
|
600
675
|
if (refCount === 0) state.stop();
|
|
601
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);
|