@k2b/cloud 0.28.0 → 0.29.1
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 +9 -6
- package/src/_internal/cli-plugins.ts +18 -0
- package/src/_internal/define-app.ts +21 -5
- package/src/_internal/registry-validation.ts +30 -1
- package/src/_internal/runtime-context.ts +1 -0
- package/src/access/PermissionEditor.tsx +11 -4
- package/src/ai/assistant-models.ts +25 -4
- package/src/ai/audio-tool.ts +3 -10
- package/src/ai/capabilities.ts +35 -9
- package/src/ai/chat/blocks.tsx +259 -197
- package/src/ai/chat/builtin-tools.tsx +3 -1
- package/src/ai/chat/composer-adapter.ts +8 -0
- package/src/ai/chat/live-turn.browser-harness.tsx +16 -3
- package/src/ai/chat/message-actions.tsx +1 -1
- package/src/ai/chat/message-utils.ts +28 -6
- package/src/ai/chat/messages.ts +20 -24
- package/src/ai/chat/presentation.tsx +1 -8
- package/src/ai/chat/tool-disclosure.tsx +21 -14
- package/src/ai/chat/tool-groups.ts +64 -37
- package/src/ai/chat/tool-names.ts +157 -0
- package/src/ai/chat/turn-layout.ts +14 -2
- package/src/ai/chat/turn-view.tsx +132 -70
- package/src/ai/chat-tasks.ts +16 -3
- package/src/ai/code-mode-skill.ts +4 -4
- package/src/ai/compaction.ts +22 -3
- package/src/ai/default-tools.ts +9 -2
- package/src/ai/executor.ts +127 -40
- package/src/ai/file-media-type.ts +4 -0
- package/src/ai/files-store.ts +17 -10
- package/src/ai/http.ts +11 -10
- package/src/ai/index.ts +1 -0
- package/src/ai/memories.ts +2 -0
- package/src/ai/memory-learning.ts +59 -5
- package/src/ai/memory-workflow-evidence.ts +35 -10
- package/src/ai/projects-routes.ts +5 -1
- package/src/ai/projects.ts +5 -1
- package/src/ai/protocol.ts +43 -5
- package/src/ai/provider.ts +16 -1
- package/src/ai/quota-provider.ts +4 -1
- package/src/ai/routes.ts +20 -10
- package/src/ai/settings.ts +54 -17
- package/src/ai/skill-seeds.ts +11 -3
- package/src/ai/store.ts +76 -33
- package/src/ai/structured.ts +2 -1
- package/src/ai/system-prompt.ts +5 -1
- package/src/ai/transcription.ts +58 -13
- package/src/ai/turn-failure.ts +5 -1
- package/src/ai/turn-guidance.ts +505 -0
- package/src/ai/types.ts +3 -1
- package/src/ai/ui.tsx +1 -0
- package/src/api/admin-core-settings.ts +16 -1
- package/src/api/admin-outgoing-mail.ts +18 -0
- package/src/api/me.ts +42 -1
- package/src/browser/FileChooser.tsx +71 -295
- package/src/browser/FileSaver.tsx +407 -0
- package/src/browser/app-badges.ts +16 -0
- package/src/browser/choose-files.browser-harness.tsx +22 -6
- package/src/browser/choose-files.tsx +23 -36
- package/src/browser/file-chooser-messages.ts +64 -0
- package/src/browser/file-providers.ts +258 -8
- package/src/browser/files.tsx +26 -3
- package/src/browser/notifications.ts +37 -1
- package/src/browser/provider-folder.tsx +301 -0
- package/src/browser/provider-list.ts +39 -0
- package/src/browser/save-files.browser-harness.tsx +35 -0
- package/src/browser/save-files.tsx +107 -0
- package/src/cli/admin/outgoing-mail.ts +30 -2
- package/src/contracts/app.ts +10 -0
- package/src/contracts/commands.ts +3 -1
- package/src/contracts/notification-group.ts +6 -0
- package/src/contracts/notification-types.ts +6 -0
- package/src/contracts/outgoing-mail.ts +56 -11
- package/src/contracts/registry.ts +1 -0
- package/src/contracts/user-notifications.ts +74 -0
- package/src/server/middleware/request-logger.ts +11 -2
- package/src/services/accounts/avatar.ts +5 -2
- package/src/services/image-metadata.ts +438 -0
- package/src/services/index.ts +1 -0
- package/src/services/logging/redaction.ts +50 -0
- package/src/services/mandates/index.ts +4 -2
- package/src/services/mandates/policy.ts +20 -0
- package/src/services/notifications/browser.ts +48 -15
- package/src/services/notifications/dispatcher.ts +31 -1
- package/src/services/notifications/platform.ts +32 -4
- package/src/services/notifications/preview.ts +23 -0
- package/src/services/notifications/quiet.ts +153 -0
- package/src/services/notifications/user.ts +6 -0
- package/src/services/outbox.ts +24 -9
- package/src/services/outgoing-mail/index.ts +33 -5
- package/src/services/outgoing-mail/messages.ts +60 -19
- package/src/services/outgoing-mail/store.ts +63 -4
- package/src/services/pdf/template-preview.ts +2 -1
- package/src/services/settings/app.ts +3 -0
- package/src/services/settings/store.ts +4 -1
- package/src/services/weather/geo.ts +6 -2
- package/src/shared/ai-platform-prompt.ts +2 -2
- package/src/shared/network-address.ts +1 -0
- package/src/shared/redirect.ts +2 -1
- package/src/shared/template-rendering.ts +120 -7
- package/src/ssr/AppBadge.tsx +30 -0
- package/src/ssr/AppLaunchpadPanel.tsx +47 -26
- package/src/ssr/IslandError.tsx +33 -0
- package/src/ssr/Layout.tsx +3 -0
- package/src/ssr/LayoutHelp.tsx +4 -2
- package/src/ssr/RailApps.island.tsx +9 -2
- package/src/ssr/app-badges.ts +93 -0
- package/src/ssr/rail-messages.ts +4 -0
- package/src/ssr/rail-navigation.ts +2 -0
- package/src/styles/effects.css +165 -0
- package/src/styles/file-chooser.css +25 -0
- package/src/styles/utilities-markdown-table.css +5 -1
- package/src/styles/utilities-navigation.css +41 -0
package/src/ai/default-tools.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import type { AccessSubject } from "../server";
|
|
3
|
+
import { resolveAssistantAudioModel } from "./assistant-models";
|
|
2
4
|
import { createCloudAiTranscribeAudioTool } from "./audio-tool";
|
|
3
5
|
import {
|
|
4
6
|
CODE_RUNTIME_TOOL_NAMES,
|
|
@@ -158,7 +160,7 @@ export const createCloudAiCodeTools = () => [
|
|
|
158
160
|
defineAiTool({
|
|
159
161
|
name: "code_check",
|
|
160
162
|
description:
|
|
161
|
-
"Mandatory HTML app self-test: write files and steps.json (at most 20 main-flow steps), call code_check({id} OR {files}),
|
|
163
|
+
"Mandatory HTML app self-test: write files and steps.json (at most 20 main-flow steps, ending in the filled main state), call code_check({id} OR {files}), fix every error and layout warning, call view_image({path, prompt: review.prompt}) for EVERY path in review.paths (screenshots and PDFs), fix what it finds and check again, then code_open/code_present/code_publish. Runs desktop 1280×800 and phone 390×844 in opposite themes on separate throwaway copies of database, shared KV/files and only your own KV.user. Steps run in both views; reload keeps the copy. Match accessible role/name, label or text exactly, then case-insensitively, then by substring; ambiguous targets fail with candidates, placeholders are never names. Fill numbers with a dot. Upload file is an app-relative source path or chat file path/name. AI runs for real; HTTP, capability effects and approvals are unavailable; read-only granted capabilities run except in background turns. Returns passed, content hash (files, steps and tables), height, issues, calls, captured downloads, three whole-page PNG chat paths (up to 2000 px tall; cropped marks a longer page), review and an untrusted aria tree (4 KiB). Passing means not broken; misaligned rows, also in PDFs, and broken values only warn. Self-test is a workflow guard, not a security mechanism. Aborting closes pages and discards copies. Bounded to 45 seconds, 1000 copied rows/16 MiB (schema only beyond), 64 downloads/250 MiB total/50 MiB per file.",
|
|
162
164
|
inputSchema: CodeCheckInput,
|
|
163
165
|
outputSchema: z.json(),
|
|
164
166
|
approval: "never",
|
|
@@ -198,6 +200,7 @@ export const CLOUD_AI_DEFERRED_BUILTIN_TOOL_NAMES = new Set<string>([
|
|
|
198
200
|
|
|
199
201
|
export const createConfiguredDefaultCloudAiTools = async (config?: {
|
|
200
202
|
firecrawlApiKey?: string | null;
|
|
203
|
+
accessSubject?: AccessSubject | null;
|
|
201
204
|
fetch?: typeof fetch;
|
|
202
205
|
allowedDataBoundaries?: AiDataBoundary[];
|
|
203
206
|
}) => {
|
|
@@ -213,8 +216,12 @@ export const createConfiguredDefaultCloudAiTools = async (config?: {
|
|
|
213
216
|
createCloudAiPresentTool(),
|
|
214
217
|
createCloudAiCalculateTool(),
|
|
215
218
|
createCloudAiViewImageTool(),
|
|
216
|
-
createCloudAiTranscribeAudioTool(),
|
|
217
219
|
];
|
|
220
|
+
const audioModelConfigured = await resolveAssistantAudioModel(config?.accessSubject ?? null, config?.allowedDataBoundaries).then(
|
|
221
|
+
() => true,
|
|
222
|
+
() => false,
|
|
223
|
+
);
|
|
224
|
+
if (audioModelConfigured) tools.push(createCloudAiTranscribeAudioTool());
|
|
218
225
|
const firecrawlConfigured =
|
|
219
226
|
config && "firecrawlApiKey" in config ? Boolean(config.firecrawlApiKey?.trim()) : await isCloudAiFirecrawlConfigured();
|
|
220
227
|
if (firecrawlConfigured) {
|
package/src/ai/executor.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import type { CompactEvent, LoopAggregate, NessiLoop, OutboundEvent } from "@k2b/nessi";
|
|
1
|
+
import type { AssistantMessage, CompactEvent, LoopAggregate, NessiLoop, OutboundEvent } from "@k2b/nessi";
|
|
2
2
|
import { compact, nessi } from "@k2b/nessi";
|
|
3
3
|
import { listCapabilities } from "../_internal/registry";
|
|
4
4
|
import type { CapabilityActionReview } from "../contracts/capabilities";
|
|
5
5
|
import type { AccessSubject, RequestActor } from "../server";
|
|
6
6
|
import { createHelpReader } from "../services/help";
|
|
7
7
|
import { logger } from "../services/logging";
|
|
8
|
+
import { getMandate } from "../services/mandates";
|
|
9
|
+
import type { MandatePolicyV1 } from "../services/mandates/policy";
|
|
8
10
|
import { coreSettings } from "../services/settings/api";
|
|
9
11
|
import { normalizeLocale } from "../shared/locale";
|
|
10
12
|
import {
|
|
@@ -14,13 +16,13 @@ import {
|
|
|
14
16
|
aiTurnAllowsRememberedApprovals,
|
|
15
17
|
hasRememberedAiToolApproval,
|
|
16
18
|
} from "./approvals";
|
|
17
|
-
import { isAssistantChatTurn } from "./assistant-models";
|
|
19
|
+
import { aiChatAccessSubject, isAssistantChatTurn, resolveAssistantAudioModel } from "./assistant-models";
|
|
18
20
|
import { CODE_RUNTIME_TOOL_NAMES } from "./browser-code-contracts";
|
|
19
|
-
import { createAiToolResolver, createRunToolStore } from "./capabilities";
|
|
21
|
+
import { type AiCapabilityCatalogEntry, createAiToolResolver, createRunToolStore } from "./capabilities";
|
|
20
22
|
import { AiCapabilityExecutionError, executeAiCapability, resolveAiCapabilityActor, reviewAiCapability } from "./capability-execution";
|
|
21
23
|
import { aiChatMessages } from "./chat/messages";
|
|
22
24
|
import { aiTurnErrorText } from "./chat/turn-error";
|
|
23
|
-
import { aiChatTasks } from "./chat-tasks";
|
|
25
|
+
import { AiChatTaskAuthorityError, aiChatTasks } from "./chat-tasks";
|
|
24
26
|
import { createCloudCompactFn } from "./compaction";
|
|
25
27
|
import { createCloudAiCodeTools, createCloudAiLocalBashTool, createConfiguredDefaultCloudAiTools } from "./default-tools";
|
|
26
28
|
import { aiFileStore } from "./files-store";
|
|
@@ -60,6 +62,7 @@ import { aiToolAudit } from "./tool-audit";
|
|
|
60
62
|
import { acceptCanonicalToolNames } from "./tool-call-names";
|
|
61
63
|
import { resolveAiToolResultMaxChars } from "./tool-result-budget";
|
|
62
64
|
import { aiToolPromptHints, type PreparedAiTools, prepareAiTools } from "./tools";
|
|
65
|
+
import { AiTranscriptionError } from "./transcription";
|
|
63
66
|
import {
|
|
64
67
|
AiTurnFailure,
|
|
65
68
|
type AiTurnFailureInfo,
|
|
@@ -68,6 +71,7 @@ import {
|
|
|
68
71
|
aiTurnReasonFromThrown,
|
|
69
72
|
rememberProviderErrors,
|
|
70
73
|
} from "./turn-failure";
|
|
74
|
+
import { type AiTurnGuidance, loadAiTurnGuidance } from "./turn-guidance";
|
|
71
75
|
import { type AiTurnPolicyToolCall, applyAiTurnPolicy } from "./turn-policy";
|
|
72
76
|
import { createTurnTimingRecorder, withDurableTurnTiming } from "./turn-timing";
|
|
73
77
|
import type {
|
|
@@ -91,10 +95,17 @@ const AI_COALESCE_MS = 25;
|
|
|
91
95
|
const AI_COALESCE_MAX_CHARS = 512;
|
|
92
96
|
const AI_ACTION_BUDGET_MS = 24 * 60 * 60_000;
|
|
93
97
|
|
|
98
|
+
// Failed generation can contain calls that nessi deliberately never executes or resumes.
|
|
99
|
+
const isToolRound = (message: AssistantMessage): boolean =>
|
|
100
|
+
message.stopReason !== "error" &&
|
|
101
|
+
message.stopReason !== "interrupted" &&
|
|
102
|
+
message.stopReason !== "aborted" &&
|
|
103
|
+
message.content.some((block) => block.type === "tool_call");
|
|
104
|
+
|
|
94
105
|
const toolRoundState = (messages: AiStoredMessage[]): { issued: number; completed: number } => {
|
|
95
106
|
const completedCallIds = new Set(messages.flatMap(({ message }) => (message.role === "tool_result" ? [message.callId] : [])));
|
|
96
107
|
const rounds = messages.flatMap(({ message }) => {
|
|
97
|
-
if (message.role !== "assistant") return [];
|
|
108
|
+
if (message.role !== "assistant" || !isToolRound(message)) return [];
|
|
98
109
|
const callIds = message.content.flatMap((block) => (block.type === "tool_call" ? [block.id] : []));
|
|
99
110
|
return callIds.length > 0 ? [callIds] : [];
|
|
100
111
|
});
|
|
@@ -557,7 +568,10 @@ const materializeChatConfig = async (config: AiChatTurnRunConfig, signal: AbortS
|
|
|
557
568
|
tools:
|
|
558
569
|
source.kind === "default"
|
|
559
570
|
? [
|
|
560
|
-
...(await createConfiguredDefaultCloudAiTools(
|
|
571
|
+
...(await createConfiguredDefaultCloudAiTools({
|
|
572
|
+
accessSubject: aiChatAccessSubject(config.actor),
|
|
573
|
+
allowedDataBoundaries: config.modelPolicy?.allowedDataBoundaries,
|
|
574
|
+
})),
|
|
561
575
|
// Cloud runs the code tools itself; only tools a client must run wait for that client to declare them.
|
|
562
576
|
...[createCloudAiLocalBashTool(), ...createCloudAiCodeTools()].filter(
|
|
563
577
|
(tool) => tool.location === "server" || config.clientToolIds?.some((name) => name === tool.def.name),
|
|
@@ -855,6 +869,20 @@ export class AiTurnExecutor {
|
|
|
855
869
|
(tool) => (!allowed || allowed.has(tool.def.name)) && !(config.mandate && ["code_open", "code_secret"].includes(tool.def.name)),
|
|
856
870
|
)
|
|
857
871
|
: [];
|
|
872
|
+
let audioUnavailableInstruction: string | undefined;
|
|
873
|
+
if (
|
|
874
|
+
defaultToolSource &&
|
|
875
|
+
toolsSupported &&
|
|
876
|
+
[...(config.files?.attached ?? []), ...(config.files?.available ?? [])].some((file) => file.mediaType.startsWith("audio/")) &&
|
|
877
|
+
!sourceToolNames.includes("transcribe_audio")
|
|
878
|
+
) {
|
|
879
|
+
try {
|
|
880
|
+
await resolveAssistantAudioModel(aiChatAccessSubject(config.actor), material.modelPolicy?.allowedDataBoundaries);
|
|
881
|
+
} catch (error) {
|
|
882
|
+
const reason = error instanceof AiTranscriptionError ? error.message : "Audio transcription is not available right now.";
|
|
883
|
+
audioUnavailableInstruction = `Audio files in this conversation cannot be transcribed: ${reason} Tell the user this reason when they ask about audio content.`;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
858
886
|
const offeredToolNames = new Set(activeTools.map((tool) => tool.def.name));
|
|
859
887
|
// Built-ins that exist but this turn does not offer: client tools without their client, tools a
|
|
860
888
|
// task cannot use, and tools outside the conversation's fixed scope. load_tools explains each one.
|
|
@@ -963,6 +991,52 @@ export class AiTurnExecutor {
|
|
|
963
991
|
|
|
964
992
|
const appliedSteers: AiTurnSteer[] = [];
|
|
965
993
|
|
|
994
|
+
let mandatePolicy: MandatePolicyV1 | null | undefined;
|
|
995
|
+
if (config.background && config.mandate) {
|
|
996
|
+
mandatePolicy = null;
|
|
997
|
+
try {
|
|
998
|
+
const mandate = await getMandate(config.mandate.id);
|
|
999
|
+
if (mandate && mandate.state === "active" && mandate.revision === config.mandate.revision) {
|
|
1000
|
+
mandatePolicy = mandate.policy;
|
|
1001
|
+
} else {
|
|
1002
|
+
backgroundError = "Scheduled task mandate is unavailable or changed; recreate or update the task.";
|
|
1003
|
+
}
|
|
1004
|
+
} catch {
|
|
1005
|
+
backgroundError = "Scheduled task mandate could not be loaded; retry or update the task.";
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
if (backgroundError) {
|
|
1009
|
+
signal.removeEventListener("abort", onSignal);
|
|
1010
|
+
await this.finalize(
|
|
1011
|
+
conversationId,
|
|
1012
|
+
turnId,
|
|
1013
|
+
pipeline,
|
|
1014
|
+
"failed",
|
|
1015
|
+
{ error: { code: "not_allowed" }, detail: backgroundError, message: backgroundError },
|
|
1016
|
+
"chat",
|
|
1017
|
+
promptLocale,
|
|
1018
|
+
);
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
const authorizeBackground =
|
|
1022
|
+
config.background && config.mandate
|
|
1023
|
+
? async (entry: AiCapabilityCatalogEntry, args: unknown) => {
|
|
1024
|
+
try {
|
|
1025
|
+
await aiChatTasks.authorizeCapability({
|
|
1026
|
+
mandate: config.mandate!,
|
|
1027
|
+
appId: entry.appId,
|
|
1028
|
+
capabilityId: entry.operation.localId,
|
|
1029
|
+
kind: entry.kind,
|
|
1030
|
+
input: args,
|
|
1031
|
+
approval: entry.kind === "action" && "approval" in entry.operation ? entry.operation.approval : undefined,
|
|
1032
|
+
});
|
|
1033
|
+
} catch (error) {
|
|
1034
|
+
if (!(error instanceof AiChatTaskAuthorityError && error.code === "MANDATE_POLICY_DENIED"))
|
|
1035
|
+
backgroundError = error instanceof Error ? error.message : "Background capability access denied";
|
|
1036
|
+
throw error;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
: undefined;
|
|
966
1040
|
const toolStore = config.background ? createRunToolStore(await aiConversations.getLoadedTools({ conversationId })) : aiConversations;
|
|
967
1041
|
const tools = toolActor
|
|
968
1042
|
? createAiToolResolver({
|
|
@@ -970,6 +1044,7 @@ export class AiTurnExecutor {
|
|
|
970
1044
|
actor: capabilityAuthority?.actor ?? toolActor,
|
|
971
1045
|
staticTools: activeTools,
|
|
972
1046
|
allowedTools,
|
|
1047
|
+
mandatePolicy,
|
|
973
1048
|
unofferedTools,
|
|
974
1049
|
runtimeContext: dynamicToolRuntimeContext,
|
|
975
1050
|
store: toolStore,
|
|
@@ -995,25 +1070,7 @@ export class AiTurnExecutor {
|
|
|
995
1070
|
}),
|
|
996
1071
|
}
|
|
997
1072
|
: {}),
|
|
998
|
-
|
|
999
|
-
? {
|
|
1000
|
-
authorizeBackground: async (entry, args) => {
|
|
1001
|
-
try {
|
|
1002
|
-
await aiChatTasks.authorizeCapability({
|
|
1003
|
-
mandate: config.mandate!,
|
|
1004
|
-
appId: entry.appId,
|
|
1005
|
-
capabilityId: entry.operation.localId,
|
|
1006
|
-
kind: entry.kind,
|
|
1007
|
-
input: args,
|
|
1008
|
-
approval: entry.kind === "action" && "approval" in entry.operation ? entry.operation.approval : undefined,
|
|
1009
|
-
});
|
|
1010
|
-
} catch (error) {
|
|
1011
|
-
backgroundError = error instanceof Error ? error.message : "Background capability access denied";
|
|
1012
|
-
throw error;
|
|
1013
|
-
}
|
|
1014
|
-
},
|
|
1015
|
-
}
|
|
1016
|
-
: {}),
|
|
1073
|
+
authorizeBackground,
|
|
1017
1074
|
onReview: (callId, review) => {
|
|
1018
1075
|
capabilityActionReviews.set(callId, review);
|
|
1019
1076
|
if (review.approvalScope) rememberableCapabilityApprovals.set(callId, review.approvalScope);
|
|
@@ -1022,16 +1079,7 @@ export class AiTurnExecutor {
|
|
|
1022
1079
|
? {
|
|
1023
1080
|
execute: async (entry, args, context) => {
|
|
1024
1081
|
try {
|
|
1025
|
-
|
|
1026
|
-
await aiChatTasks.authorizeCapability({
|
|
1027
|
-
mandate: config.mandate,
|
|
1028
|
-
appId: entry.appId,
|
|
1029
|
-
capabilityId: entry.operation.localId,
|
|
1030
|
-
kind: entry.kind,
|
|
1031
|
-
input: args,
|
|
1032
|
-
approval: entry.kind === "action" && "approval" in entry.operation ? entry.operation.approval : undefined,
|
|
1033
|
-
});
|
|
1034
|
-
}
|
|
1082
|
+
await authorizeBackground?.(entry, args);
|
|
1035
1083
|
const result = await executeAiCapability({
|
|
1036
1084
|
conversationId,
|
|
1037
1085
|
turnId,
|
|
@@ -1101,12 +1149,43 @@ export class AiTurnExecutor {
|
|
|
1101
1149
|
skillSubject,
|
|
1102
1150
|
activeTools.some((tool) => tool.def.name === "load_skill"),
|
|
1103
1151
|
);
|
|
1104
|
-
const
|
|
1152
|
+
const chat = await aiConversations.getConversation({ conversationId });
|
|
1153
|
+
const workingPlan = chat?.todoPlan;
|
|
1154
|
+
const skillCatalogOffered = activeTools.some((tool) => tool.def.name === "load_skill");
|
|
1155
|
+
const skillCreatorAvailable = availableSkills.some((skill) => skill.name === "skill-creator");
|
|
1156
|
+
// Offer hints and the recent-work summary only guide a fresh turn a person follows; a resumed
|
|
1157
|
+
// attempt after an approval or a scheduled run gets neither.
|
|
1158
|
+
let guidance: AiTurnGuidance = {};
|
|
1159
|
+
if (isFresh && !config.background && defaultToolSource && user && chat) {
|
|
1160
|
+
try {
|
|
1161
|
+
guidance = await loadAiTurnGuidance(
|
|
1162
|
+
{
|
|
1163
|
+
chat,
|
|
1164
|
+
turnId,
|
|
1165
|
+
ownerUserId: user.id,
|
|
1166
|
+
input: config.input,
|
|
1167
|
+
instructions: [settings.globalInstructions, config.project?.instructions, memory?.text],
|
|
1168
|
+
skillOffers: skillCatalogOffered && skillCreatorAvailable && loadedSkills.length === 0,
|
|
1169
|
+
memoryOffers: memoryToolEnabled,
|
|
1170
|
+
hasAttachments: Boolean(config.files?.attached.length),
|
|
1171
|
+
},
|
|
1172
|
+
aiConversations,
|
|
1173
|
+
);
|
|
1174
|
+
} catch (error) {
|
|
1175
|
+
log.warn("AI turn guidance unavailable; continuing without it", {
|
|
1176
|
+
conversationId,
|
|
1177
|
+
turnId,
|
|
1178
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1105
1182
|
const systemPrompt = composeAiSystemPrompt({
|
|
1106
1183
|
globalInstructions: settings.globalInstructions,
|
|
1107
1184
|
loadedSkills,
|
|
1108
1185
|
turnInstructions: [
|
|
1109
1186
|
material.systemPrompt,
|
|
1187
|
+
audioUnavailableInstruction,
|
|
1188
|
+
guidance.offerHint,
|
|
1110
1189
|
workingPlan
|
|
1111
1190
|
? `Current working plan (new todo_write results supersede this):\n${JSON.stringify({ todos: workingPlan.todos })}`
|
|
1112
1191
|
: undefined,
|
|
@@ -1122,7 +1201,7 @@ export class AiTurnExecutor {
|
|
|
1122
1201
|
project: config.project,
|
|
1123
1202
|
files: config.files,
|
|
1124
1203
|
projectToolEnabled,
|
|
1125
|
-
skills:
|
|
1204
|
+
skills: skillCatalogOffered ? skillCatalog.skills : undefined,
|
|
1126
1205
|
omittedSkillCount: activeTools.some((tool) => tool.def.name === "search_skills") ? skillCatalog.omitted : 0,
|
|
1127
1206
|
user,
|
|
1128
1207
|
memoryEnabled: memoryActive,
|
|
@@ -1135,11 +1214,19 @@ export class AiTurnExecutor {
|
|
|
1135
1214
|
timeZone,
|
|
1136
1215
|
locale: promptLocale,
|
|
1137
1216
|
interactive: !config.background,
|
|
1138
|
-
skillCreatorAvailable
|
|
1217
|
+
skillCreatorAvailable,
|
|
1218
|
+
recentWork: guidance.recentWork,
|
|
1139
1219
|
});
|
|
1140
1220
|
// The turn policy counts the whole turn, including rounds that compaction archived.
|
|
1141
1221
|
const turnMessages = await aiConversations.listTurnMessages({ conversationId, loopId: turnId, includeCompacted: true });
|
|
1142
1222
|
const turnBlocks = buildBlocksFromMessages(turnMessages);
|
|
1223
|
+
const providerNamesByCallId = new Map(
|
|
1224
|
+
turnMessages.flatMap(({ message }) =>
|
|
1225
|
+
message.role === "assistant"
|
|
1226
|
+
? message.content.flatMap((part) => (part.type === "tool_call" ? [[part.id, part.name] as const] : []))
|
|
1227
|
+
: [],
|
|
1228
|
+
),
|
|
1229
|
+
);
|
|
1143
1230
|
const priorToolRounds = toolRoundState(turnMessages);
|
|
1144
1231
|
const quotaSubject = accessSubjectForActor(material.actor);
|
|
1145
1232
|
const deadline = claim.turn.deadline ? Date.parse(claim.turn.deadline) : null;
|
|
@@ -1174,7 +1261,7 @@ export class AiTurnExecutor {
|
|
|
1174
1261
|
runBudgetMs: claim.turn.runBudgetMs ?? null,
|
|
1175
1262
|
finishedToolCalls: turnBlocks
|
|
1176
1263
|
.slice(turnBlocks.findLastIndex((block) => block.kind === "steer_applied") + 1)
|
|
1177
|
-
.flatMap((block) => (block.kind === "tool" ? [block] : [])),
|
|
1264
|
+
.flatMap((block) => (block.kind === "tool" ? [{ ...block, name: providerNamesByCallId.get(block.callId) ?? block.name }] : [])),
|
|
1178
1265
|
onDecision: (decision) =>
|
|
1179
1266
|
decision.kind === "hint"
|
|
1180
1267
|
? log.warn("AI turn got a loop hint", { conversationId, turnId, hints: decision.hints })
|
|
@@ -1413,7 +1500,7 @@ export class AiTurnExecutor {
|
|
|
1413
1500
|
result: event.result,
|
|
1414
1501
|
isError: event.isError === true,
|
|
1415
1502
|
});
|
|
1416
|
-
} else if (event.type === "turn_end" &&
|
|
1503
|
+
} else if (event.type === "turn_end" && !abortController.signal.aborted && isToolRound(event.message)) {
|
|
1417
1504
|
noteToolRound();
|
|
1418
1505
|
} else if (event.type === "issue") {
|
|
1419
1506
|
lastIssueMessage = event.issue.message;
|
|
@@ -38,3 +38,7 @@ export const guessAiMediaType = (path: string): string => {
|
|
|
38
38
|
const ext = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
|
|
39
39
|
return MEDIA_TYPES[ext] ?? "application/octet-stream";
|
|
40
40
|
};
|
|
41
|
+
|
|
42
|
+
/** Image classification includes filenames when clients send a generic media type. */
|
|
43
|
+
export const isAiImage = (path: string, mediaType: string): boolean =>
|
|
44
|
+
mediaType.startsWith("image/") || guessAiMediaType(path).startsWith("image/");
|
package/src/ai/files-store.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type SQL, sql } from "bun";
|
|
2
|
+
import { stripImageMetadata } from "../services/image-metadata";
|
|
2
3
|
import { AiFileVersionConflict, AiFileWriteError, aiFileContentVersion } from "./file-content-version";
|
|
4
|
+
import { isAiImage } from "./file-media-type";
|
|
3
5
|
|
|
4
6
|
export { guessAiMediaType } from "./file-media-type";
|
|
5
7
|
|
|
@@ -79,9 +81,11 @@ export const createUniqueAiFileInTransaction = async (
|
|
|
79
81
|
throw new AiFileWriteError("STORAGE_FULL", `File exceeds the per-file limit of ${Math.floor(maxFile / (1024 * 1024))} MB.`);
|
|
80
82
|
}
|
|
81
83
|
|
|
84
|
+
const bytes = isAiImage(input.path, input.mediaType ?? "application/octet-stream") ? stripImageMetadata(input.bytes) : input.bytes;
|
|
85
|
+
|
|
82
86
|
await tx`SELECT id FROM ai.conversations WHERE id = ${input.conversationId} FOR UPDATE`;
|
|
83
87
|
const total = await aiConversationStoredBytes(tx, input.conversationId);
|
|
84
|
-
if (total +
|
|
88
|
+
if (total + bytes.byteLength > maxConversation) {
|
|
85
89
|
throw new AiFileWriteError("STORAGE_FULL", `Conversation storage limit of ${Math.floor(maxConversation / (1024 * 1024))} MB exceeded.`);
|
|
86
90
|
}
|
|
87
91
|
|
|
@@ -89,7 +93,7 @@ export const createUniqueAiFileInTransaction = async (
|
|
|
89
93
|
const path = numberedAiFilePath(input.path, number);
|
|
90
94
|
const rows = await tx<FileRow[]>`
|
|
91
95
|
INSERT INTO ai.files (conversation_id, path, bytes, media_type, size, origin, dictation_recorded_at, updated_at)
|
|
92
|
-
SELECT ${input.conversationId}, ${path}, ${
|
|
96
|
+
SELECT ${input.conversationId}, ${path}, ${bytes}, ${input.mediaType ?? "application/octet-stream"}, ${bytes.byteLength}, ${input.origin}, ${input.dictationRecordedAt ?? null}, now()
|
|
93
97
|
WHERE NOT EXISTS (
|
|
94
98
|
SELECT 1 FROM ai.files WHERE conversation_id = ${input.conversationId} AND normalize(path, NFC) = ${path.normalize("NFC")}
|
|
95
99
|
)
|
|
@@ -191,7 +195,8 @@ export const aiFileStore = {
|
|
|
191
195
|
producerCallKey: string;
|
|
192
196
|
mediaType: string;
|
|
193
197
|
}): Promise<AiFileStat> {
|
|
194
|
-
|
|
198
|
+
const bytes = isAiImage(input.path, input.mediaType) ? stripImageMetadata(input.bytes) : input.bytes;
|
|
199
|
+
if (bytes.byteLength > AI_FILES_MAX_FILE_BYTES_DEFAULT)
|
|
195
200
|
throw new AiFileWriteError("STORAGE_FULL", "File exceeds the destination file limit; nothing was written.");
|
|
196
201
|
if (!normalizeAiFilePath(input.path)) {
|
|
197
202
|
throw new Error("Invalid tool artifact path or file size.");
|
|
@@ -210,18 +215,18 @@ export const aiFileStore = {
|
|
|
210
215
|
Number(row.version) === 1 &&
|
|
211
216
|
row.producer_call_key === input.producerCallKey &&
|
|
212
217
|
row.media_type === input.mediaType &&
|
|
213
|
-
Buffer.from(row.bytes).equals(Buffer.from(
|
|
218
|
+
Buffer.from(row.bytes).equals(Buffer.from(bytes))
|
|
214
219
|
)
|
|
215
220
|
return toStat(row);
|
|
216
221
|
throw new AiFileWriteError("CONFLICT", "Destination file already exists. Choose another path; nothing was written.");
|
|
217
222
|
}
|
|
218
223
|
const total = await aiConversationStoredBytes(tx, input.conversationId);
|
|
219
|
-
if (total +
|
|
224
|
+
if (total + bytes.byteLength > AI_FILES_MAX_CONVERSATION_BYTES_DEFAULT) {
|
|
220
225
|
throw new AiFileWriteError("STORAGE_FULL", "Conversation storage limit exceeded.");
|
|
221
226
|
}
|
|
222
227
|
const rows = await tx<FileRow[]>`
|
|
223
228
|
INSERT INTO ai.files (conversation_id, path, bytes, media_type, size, origin, producer_call_key)
|
|
224
|
-
VALUES (${input.conversationId}, ${path}, ${
|
|
229
|
+
VALUES (${input.conversationId}, ${path}, ${bytes}, ${input.mediaType}, ${bytes.byteLength}, 'assistant', ${input.producerCallKey})
|
|
225
230
|
RETURNING path, size, media_type, origin, dictation_recorded_at, updated_at, version
|
|
226
231
|
`;
|
|
227
232
|
return toStat(rows[0]!);
|
|
@@ -364,6 +369,8 @@ export const aiFileStore = {
|
|
|
364
369
|
throw new AiFileWriteError("STORAGE_FULL", `File exceeds the per-file limit of ${Math.floor(maxFile / (1024 * 1024))} MB.`);
|
|
365
370
|
}
|
|
366
371
|
|
|
372
|
+
const bytes = isAiImage(input.path, input.mediaType ?? "application/octet-stream") ? stripImageMetadata(input.bytes) : input.bytes;
|
|
373
|
+
|
|
367
374
|
return sql.begin(async (tx) => {
|
|
368
375
|
const [conversation] = await tx<
|
|
369
376
|
{ id: string; created_by_user_id: string; archived_at: Date | null }[]
|
|
@@ -384,7 +391,7 @@ export const aiFileStore = {
|
|
|
384
391
|
if (version !== input.expectedVersion) throw new AiFileVersionConflict();
|
|
385
392
|
}
|
|
386
393
|
const otherBytes = await aiConversationStoredBytes(tx, input.conversationId, path);
|
|
387
|
-
if (otherBytes +
|
|
394
|
+
if (otherBytes + bytes.byteLength > maxConversation) {
|
|
388
395
|
throw new AiFileWriteError(
|
|
389
396
|
"STORAGE_FULL",
|
|
390
397
|
`Conversation storage limit of ${Math.floor(maxConversation / (1024 * 1024))} MB exceeded.`,
|
|
@@ -394,7 +401,7 @@ export const aiFileStore = {
|
|
|
394
401
|
if (input.allowUserOverwrite) {
|
|
395
402
|
const written = await tx<{ id: string }[]>`
|
|
396
403
|
UPDATE ai.files
|
|
397
|
-
SET dictation_recorded_at = NULL, bytes = ${
|
|
404
|
+
SET dictation_recorded_at = NULL, bytes = ${bytes}, media_type = ${input.mediaType ?? "application/octet-stream"}, size = ${bytes.byteLength}, updated_at = now(), version = version + 1
|
|
398
405
|
WHERE conversation_id = ${input.conversationId} AND path = ${path} AND origin = 'user'
|
|
399
406
|
RETURNING id
|
|
400
407
|
`;
|
|
@@ -402,13 +409,13 @@ export const aiFileStore = {
|
|
|
402
409
|
} else {
|
|
403
410
|
await tx`
|
|
404
411
|
INSERT INTO ai.files (conversation_id, path, bytes, media_type, size, origin, updated_at)
|
|
405
|
-
VALUES (${input.conversationId}, ${path}, ${
|
|
412
|
+
VALUES (${input.conversationId}, ${path}, ${bytes}, ${input.mediaType ?? "application/octet-stream"}, ${bytes.byteLength}, 'user', now())
|
|
406
413
|
`;
|
|
407
414
|
}
|
|
408
415
|
} else {
|
|
409
416
|
const written = await tx<{ id: string }[]>`
|
|
410
417
|
INSERT INTO ai.files (conversation_id, path, bytes, media_type, size, origin, updated_at)
|
|
411
|
-
VALUES (${input.conversationId}, ${path}, ${
|
|
418
|
+
VALUES (${input.conversationId}, ${path}, ${bytes}, ${input.mediaType ?? "application/octet-stream"}, ${bytes.byteLength}, 'assistant', now())
|
|
412
419
|
ON CONFLICT (conversation_id, path) DO UPDATE SET
|
|
413
420
|
bytes = EXCLUDED.bytes,
|
|
414
421
|
media_type = EXCLUDED.media_type,
|
package/src/ai/http.ts
CHANGED
|
@@ -122,6 +122,15 @@ export type AiTurnContentPart = z.infer<typeof AiUserContentPartSchema>;
|
|
|
122
122
|
|
|
123
123
|
export const AiClientToolIdSchema = z.enum(["local_bash", ...CODE_RUNTIME_TOOL_NAMES] satisfies [AiClientToolId, ...AiClientToolId[]]);
|
|
124
124
|
|
|
125
|
+
// Names use aiCapabilityToolName's provider grammar and 64-character limit. The 64-item cap matches allowedTools, with headroom
|
|
126
|
+
// for retired and not-yet-known names; 64 names × 64 ASCII characters bounds advertised name text to 4 KiB.
|
|
127
|
+
const AI_CLIENT_TOOL_MAX_ITEMS = 64;
|
|
128
|
+
const AiClientToolIdsSchema = z
|
|
129
|
+
.array(z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/))
|
|
130
|
+
.max(AI_CLIENT_TOOL_MAX_ITEMS)
|
|
131
|
+
.refine((ids) => new Set(ids).size === ids.length, "Client tool IDs must be unique")
|
|
132
|
+
.transform((ids) => ids.filter((id): id is AiClientToolId => AiClientToolIdSchema.safeParse(id).success));
|
|
133
|
+
|
|
125
134
|
export const AiTurnInputSchema = z
|
|
126
135
|
.object({
|
|
127
136
|
message: z.string().trim().max(20000).optional(),
|
|
@@ -131,11 +140,7 @@ export const AiTurnInputSchema = z
|
|
|
131
140
|
.max(AI_TURN_ATTACHMENT_MAX_ITEMS * 2 + 1)
|
|
132
141
|
.optional(),
|
|
133
142
|
modelProfileId: z.string().trim().min(1).optional(),
|
|
134
|
-
clientToolIds:
|
|
135
|
-
.array(AiClientToolIdSchema)
|
|
136
|
-
.max(1 + CODE_RUNTIME_TOOL_NAMES.length)
|
|
137
|
-
.refine((ids) => new Set(ids).size === ids.length, "Client tool IDs must be unique")
|
|
138
|
-
.optional(),
|
|
143
|
+
clientToolIds: AiClientToolIdsSchema.optional(),
|
|
139
144
|
})
|
|
140
145
|
.refine((input) => Boolean(input.message?.trim() || input.content?.length), {
|
|
141
146
|
message: "Message or content is required.",
|
|
@@ -156,11 +161,7 @@ export const AiSubmitConversationDraftInputSchema = z.object({
|
|
|
156
161
|
queueId: z.uuid().optional(),
|
|
157
162
|
draftRevision: z.number().int().min(1),
|
|
158
163
|
modelProfileId: z.string().trim().min(1).optional(),
|
|
159
|
-
clientToolIds:
|
|
160
|
-
.array(AiClientToolIdSchema)
|
|
161
|
-
.max(1 + CODE_RUNTIME_TOOL_NAMES.length)
|
|
162
|
-
.refine((ids) => new Set(ids).size === ids.length, "Client tool IDs must be unique")
|
|
163
|
-
.optional(),
|
|
164
|
+
clientToolIds: AiClientToolIdsSchema.optional(),
|
|
164
165
|
});
|
|
165
166
|
|
|
166
167
|
export const AiSteerInputSchema = z.object({
|
package/src/ai/index.ts
CHANGED
|
@@ -76,6 +76,7 @@ export {
|
|
|
76
76
|
} from "./enrich";
|
|
77
77
|
export { AiFileVersionConflict, AiFileWriteError, aiFileContentVersion } from "./file-content-version";
|
|
78
78
|
export { aiConversationFileManifest } from "./file-context";
|
|
79
|
+
export { isAiImage } from "./file-media-type";
|
|
79
80
|
export { AiFileLocation, AiFileReference } from "./file-reference-contracts";
|
|
80
81
|
export {
|
|
81
82
|
AI_FILES_MAX_CONVERSATION_BYTES_DEFAULT,
|
package/src/ai/memories.ts
CHANGED
|
@@ -412,6 +412,7 @@ export const aiMemories = {
|
|
|
412
412
|
* Apply one model proposal as an ownership-checked transaction. Background
|
|
413
413
|
* learning may only mutate normal background memories; user, agent, and
|
|
414
414
|
* pinned entries remain authoritative.
|
|
415
|
+
* The source chat must still be active; the row lock makes this atomic with archiving.
|
|
415
416
|
*/
|
|
416
417
|
async applyBackgroundProposal(input: {
|
|
417
418
|
userId: string;
|
|
@@ -437,6 +438,7 @@ export const aiMemories = {
|
|
|
437
438
|
FROM ai.conversations c
|
|
438
439
|
WHERE c.id = ${input.sourceConversationId}::uuid
|
|
439
440
|
AND c.created_by_user_id = ${input.userId}::uuid
|
|
441
|
+
AND c.archived_at IS NULL
|
|
440
442
|
AND (${input.sourceMessageId ?? null}::uuid IS NULL OR EXISTS (
|
|
441
443
|
SELECT 1 FROM ai.messages m
|
|
442
444
|
WHERE m.id = ${input.sourceMessageId ?? null}::uuid AND m.conversation_id = c.id
|