@k2b/cloud 0.29.1 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -2
- package/src/_internal/app-presentation.ts +22 -1
- package/src/_internal/app-roles.ts +14 -0
- package/src/_internal/capabilities.ts +113 -5
- package/src/_internal/capability-sentences.ts +413 -0
- package/src/_internal/define-app.ts +24 -5
- package/src/_internal/registry-validation.ts +5 -0
- package/src/_internal/registry.ts +50 -6
- package/src/_internal/widget-declarations.ts +53 -0
- package/src/access/PermissionEditor.tsx +43 -24
- package/src/ai/app-skill-store.ts +298 -0
- package/src/ai/app-skills.ts +105 -0
- package/src/ai/approval-routes.ts +40 -4
- package/src/ai/approvals.ts +68 -10
- package/src/ai/browser.ts +10 -0
- package/src/ai/capabilities.ts +68 -9
- package/src/ai/chart-block.ts +380 -0
- package/src/ai/chat/action-sentences.ts +49 -0
- package/src/ai/chat/blocks.tsx +97 -40
- package/src/ai/chat/chart-block.tsx +79 -0
- package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
- package/src/ai/chat/message-actions.tsx +3 -1
- package/src/ai/chat/message-utils.ts +3 -0
- package/src/ai/chat/messages.ts +26 -2
- package/src/ai/chat/tool-groups.ts +66 -12
- package/src/ai/chat/tool-names.ts +6 -2
- package/src/ai/chat/turn-layout.ts +10 -2
- package/src/ai/chat/turn-view.tsx +123 -36
- package/src/ai/chat/web-tools.tsx +95 -1
- package/src/ai/client/controller.ts +6 -3
- package/src/ai/code-runtime-tools.ts +109 -61
- package/src/ai/code-source-contracts.ts +25 -21
- package/src/ai/code-source-tools.ts +3 -1
- package/src/ai/default-tool-contracts.ts +198 -0
- package/src/ai/default-tools.ts +25 -1
- package/src/ai/executor.ts +189 -35
- package/src/ai/fetch-file-tool.ts +41 -4
- package/src/ai/firecrawl-tools.ts +87 -4
- package/src/ai/index.ts +17 -1
- package/src/ai/migrate.ts +172 -14
- package/src/ai/protocol.ts +22 -1
- package/src/ai/routes.ts +11 -0
- package/src/ai/runtime-tools.ts +1 -0
- package/src/ai/runtime.ts +46 -7
- package/src/ai/skill-content.ts +54 -0
- package/src/ai/skills-routes.ts +22 -13
- package/src/ai/skills.ts +177 -231
- package/src/ai/store.ts +66 -1
- package/src/ai/tool-audit.ts +11 -1
- package/src/ai/tool-sentences.ts +38 -0
- package/src/ai/tools.ts +91 -3
- package/src/ai/types.ts +47 -1
- package/src/ai/web-provenance.ts +103 -0
- package/src/api/admin-ai-skills.ts +35 -16
- package/src/api/capabilities.ts +3 -2
- package/src/api/fanout.ts +128 -0
- package/src/api/help.ts +8 -3
- package/src/api/mcp.ts +4 -1
- package/src/api/search.ts +31 -106
- package/src/api/widgets.ts +291 -101
- package/src/browser/CloudResourceSearch.browser-harness.tsx +1 -1
- package/src/browser/CloudResourceSearch.tsx +1 -1
- package/src/browser/command-shortcuts.ts +21 -6
- package/src/browser/ndjson.ts +34 -0
- package/src/browser/reload.ts +46 -0
- package/src/browser/search-stream.ts +4 -26
- package/src/browser/spotlight-position.ts +4 -1
- package/src/browser/widgets.ts +30 -0
- package/src/capabilities/executions.ts +3 -0
- package/src/capabilities/server.ts +20 -2
- package/src/cli/access.ts +48 -14
- package/src/cli/admin/ai-skills.ts +59 -39
- package/src/cli/commands.ts +20 -0
- package/src/cli/index.ts +16 -0
- package/src/cli/plugin.ts +30 -0
- package/src/contracts/announcement-cookie.ts +62 -0
- package/src/contracts/announcements.ts +14 -52
- package/src/contracts/app.ts +34 -2
- package/src/contracts/capabilities.ts +24 -0
- package/src/contracts/pwa-paths.ts +7 -2
- package/src/contracts/pwa.ts +2 -7
- package/src/contracts/registry.ts +10 -1
- package/src/contracts/shared.ts +5 -0
- package/src/contracts/widgets.ts +67 -2
- package/src/desktop/solid.tsx +6 -7
- package/src/index.ts +1 -0
- package/src/server/help.ts +8 -1
- package/src/server/index.ts +1 -0
- package/src/server/widget-request.ts +27 -0
- package/src/services/accounts/users.ts +15 -0
- package/src/services/approvals/index.ts +225 -0
- package/src/services/approvals/migrate.ts +33 -0
- package/src/services/help/index.ts +18 -5
- package/src/services/help/types.ts +3 -2
- package/src/services/identity/constants.ts +2 -0
- package/src/services/identity/key-ring.ts +123 -42
- package/src/services/identity/runtime-config.ts +13 -2
- package/src/shared/app-presentation.ts +11 -0
- package/src/shared/email-html.ts +43 -8
- package/src/ssr/AdminLayout.tsx +4 -7
- package/src/ssr/AppLaunchpad.island.tsx +1 -1
- package/src/ssr/AppLaunchpadPanel.tsx +12 -3
- package/src/ssr/GlobalAnnouncements.island.tsx +2 -2
- package/src/ssr/GlobalSearchDialog.tsx +1 -43
- package/src/ssr/GlobalSearchTrigger.island.tsx +12 -3
- package/src/ssr/Layout.tsx +38 -14
- package/src/ssr/LayoutFooter.tsx +1 -1
- package/src/ssr/LayoutHeader.tsx +17 -20
- package/src/ssr/LayoutHelp.tsx +1 -1
- package/src/ssr/LayoutHelpTrigger.island.tsx +1 -1
- package/src/ssr/LayoutRail.tsx +1 -2
- package/src/ssr/PageTrail.tsx +23 -0
- package/src/ssr/PageTrailRow.island.tsx +20 -0
- package/src/ssr/PageTrailRuntime.island.tsx +26 -0
- package/src/ssr/ProfilePreferences.island.tsx +7 -3
- package/src/ssr/PwaRuntime.island.tsx +1 -1
- package/src/ssr/app-navigation.ts +5 -7
- package/src/ssr/global-search-host.tsx +55 -0
- package/src/ssr/help.ts +3 -3
- package/src/ssr/islands/index.ts +1 -0
- package/src/ssr/layout-runtime.ts +60 -0
- package/src/ssr/mobile-menu-history.ts +1 -1
- package/src/ssr/page-trail-runtime.ts +313 -0
- package/src/ssr/page-trail.browser-harness.tsx +216 -0
- package/src/ssr/page-trail.ts +59 -0
- package/src/ssr/platform-messages.ts +4 -0
- package/src/ssr/rail-context.ts +4 -3
- package/src/ssr/runtime.ts +14 -1
- package/src/styles/effects.css +21 -4
- package/src/styles/global.css +35 -2
- package/src/styles/utilities-layout.css +24 -1
- package/src/styles/utilities-navigation.css +6 -18
- package/src/ai/code-mode-skill.ts +0 -114
- package/src/ai/data-analysis-skill.ts +0 -10
- package/src/ai/grids-skill.ts +0 -158
- package/src/ai/skill-seeds.ts +0 -560
package/src/ai/store.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { type SQL, sql } from "bun";
|
|
|
4
4
|
import { type CapabilityActionReview, CapabilityActionReviewSchema } from "../contracts/capabilities";
|
|
5
5
|
import { logger } from "../services/logging";
|
|
6
6
|
import { toPgTextArray } from "../services/postgres";
|
|
7
|
+
import { AI_WEBSITE_APPROVAL_TOOL } from "./approvals";
|
|
7
8
|
import { aiTurnErrorText } from "./chat/turn-error";
|
|
8
9
|
import { AI_MEMORY_LEARNING_DEFAULT_ENABLED } from "./prefs";
|
|
9
10
|
import type { AiTurnBlock } from "./protocol";
|
|
@@ -36,6 +37,7 @@ import type {
|
|
|
36
37
|
AiTurnStatus,
|
|
37
38
|
AiTurnSteer,
|
|
38
39
|
AiTurnSweepResult,
|
|
40
|
+
AiWebsiteReceipt,
|
|
39
41
|
} from "./types";
|
|
40
42
|
|
|
41
43
|
/** A queued turn this old without a claim is considered lost and re-enqueued by the sweep. */
|
|
@@ -191,6 +193,7 @@ type TurnRow = {
|
|
|
191
193
|
};
|
|
192
194
|
|
|
193
195
|
type PendingActionRow = {
|
|
196
|
+
approval_id: string | null;
|
|
194
197
|
turn_id: string;
|
|
195
198
|
conversation_id: string;
|
|
196
199
|
call_id: string;
|
|
@@ -202,6 +205,8 @@ type PendingActionRow = {
|
|
|
202
205
|
review: unknown | null;
|
|
203
206
|
approval_scope: string;
|
|
204
207
|
allow_always: boolean;
|
|
208
|
+
allow_chat: boolean | null;
|
|
209
|
+
remember_tool_name: string | null;
|
|
205
210
|
frontend_mode: AiFrontendToolMode | null;
|
|
206
211
|
resolved_event: unknown | null;
|
|
207
212
|
};
|
|
@@ -678,9 +683,12 @@ const pendingActionToPublicEvent = (row: PendingActionRow): AiPendingTurnAction
|
|
|
678
683
|
message: row.message ?? undefined,
|
|
679
684
|
review: parseCapabilityActionReview(row.review),
|
|
680
685
|
allowAlways: row.allow_always,
|
|
686
|
+
allowChat: row.allow_always || row.allow_chat === true,
|
|
687
|
+
...(row.remember_tool_name === AI_WEBSITE_APPROVAL_TOOL ? { website: row.approval_scope } : {}),
|
|
681
688
|
};
|
|
682
689
|
|
|
683
690
|
const rowToPendingActionRecord = (row: PendingActionRow): AiPendingTurnActionRecord => ({
|
|
691
|
+
approvalId: row.approval_id ?? undefined,
|
|
684
692
|
turnId: row.turn_id,
|
|
685
693
|
conversationId: row.conversation_id,
|
|
686
694
|
callId: row.call_id,
|
|
@@ -692,6 +700,8 @@ const rowToPendingActionRecord = (row: PendingActionRow): AiPendingTurnActionRec
|
|
|
692
700
|
review: parseCapabilityActionReview(row.review),
|
|
693
701
|
approvalScope: row.approval_scope,
|
|
694
702
|
allowAlways: row.allow_always,
|
|
703
|
+
allowChat: row.allow_always || row.allow_chat === true,
|
|
704
|
+
...(row.remember_tool_name ? { rememberToolName: row.remember_tool_name } : {}),
|
|
695
705
|
frontendMode: row.frontend_mode ?? undefined,
|
|
696
706
|
resolvedEvent: row.resolved_event ? parseJsonValue<InboundEvent>(row.resolved_event) : null,
|
|
697
707
|
});
|
|
@@ -784,6 +794,18 @@ const messageSearchText = (message: Message): string => {
|
|
|
784
794
|
return text.slice(0, SEARCH_TEXT_MAX_CHARS);
|
|
785
795
|
};
|
|
786
796
|
|
|
797
|
+
/**
|
|
798
|
+
* An ended turn uses no further approval: cancel its open questions and the turn's own approvals that no call used.
|
|
799
|
+
* A Studio request linked from the turn stays as decided, because the code run uses it when the call runs there.
|
|
800
|
+
*/
|
|
801
|
+
const cancelTurnApprovals = async (db: typeof sql, turnId: string): Promise<void> => {
|
|
802
|
+
await db`
|
|
803
|
+
UPDATE approvals.requests SET status = 'cancelled'
|
|
804
|
+
WHERE id IN (SELECT approval_id FROM ai.pending_actions WHERE turn_id = ${turnId} AND approval_id IS NOT NULL)
|
|
805
|
+
AND (status = 'pending' OR (status = 'approved' AND requester_kind = 'assistant_turn'))
|
|
806
|
+
`;
|
|
807
|
+
};
|
|
808
|
+
|
|
787
809
|
/**
|
|
788
810
|
* Record how a turn ended on its messages when its loop could not record it: a stop while an approval waits, a run
|
|
789
811
|
* time limit, or a turn the sweep finalizes. History reads the ending from the last assistant message, so such a turn
|
|
@@ -878,6 +900,35 @@ const recordTurnEnd = async (
|
|
|
878
900
|
}
|
|
879
901
|
};
|
|
880
902
|
|
|
903
|
+
/**
|
|
904
|
+
* Records requests a website approval let through on the assistant message that holds the tool call, as each one
|
|
905
|
+
* goes out, so the chat keeps every receipt whether the call succeeds, fails, or its turn stops. Website approvals
|
|
906
|
+
* apply only to interactive chats, whose messages live in `ai.messages`. A receipt with a known index replaces itself.
|
|
907
|
+
*/
|
|
908
|
+
export const recordAiWebsiteReceipts = async (input: {
|
|
909
|
+
conversationId: string;
|
|
910
|
+
turnId: string;
|
|
911
|
+
callId: string;
|
|
912
|
+
receipts: AiWebsiteReceipt[];
|
|
913
|
+
}): Promise<void> => {
|
|
914
|
+
if (!input.receipts.length) return;
|
|
915
|
+
const receipts = Object.fromEntries(input.receipts.map(({ index, method, url }) => [String(index), { method, url }]));
|
|
916
|
+
await sql`
|
|
917
|
+
UPDATE ai.messages
|
|
918
|
+
SET meta = COALESCE(meta, '{}'::jsonb) || jsonb_build_object(
|
|
919
|
+
'websiteReceipts',
|
|
920
|
+
COALESCE(meta->'websiteReceipts', '{}'::jsonb) || jsonb_build_object(
|
|
921
|
+
${input.callId}::text,
|
|
922
|
+
COALESCE(meta->'websiteReceipts'->${input.callId}::text, '{}'::jsonb) || (${JSON.stringify(receipts)}::text)::jsonb
|
|
923
|
+
)
|
|
924
|
+
)
|
|
925
|
+
WHERE conversation_id = ${input.conversationId}::uuid
|
|
926
|
+
AND loop_id = ${input.turnId}::text
|
|
927
|
+
AND role = 'assistant'
|
|
928
|
+
AND message->'content' @> jsonb_build_array(jsonb_build_object('type', 'tool_call', 'id', ${input.callId}::text))
|
|
929
|
+
`;
|
|
930
|
+
};
|
|
931
|
+
|
|
881
932
|
/** Insert a message inside an open conversation-lock transaction and bump the conversation. */
|
|
882
933
|
const insertMessageLocked = async (
|
|
883
934
|
input: {
|
|
@@ -3017,6 +3068,7 @@ export const aiConversations: AiConversationService = {
|
|
|
3017
3068
|
WHERE turn_id = ${input.turnId}
|
|
3018
3069
|
AND status = 'pending'
|
|
3019
3070
|
`;
|
|
3071
|
+
await cancelTurnApprovals(tx, input.turnId);
|
|
3020
3072
|
if (input.status !== "completed") {
|
|
3021
3073
|
await recordTurnEnd(tx, {
|
|
3022
3074
|
conversationId: input.conversationId,
|
|
@@ -3173,6 +3225,7 @@ export const aiConversations: AiConversationService = {
|
|
|
3173
3225
|
WHERE turn_id = ${finalized.turnId}
|
|
3174
3226
|
AND status = 'pending'
|
|
3175
3227
|
`;
|
|
3228
|
+
await cancelTurnApprovals(sql, finalized.turnId);
|
|
3176
3229
|
await sql`
|
|
3177
3230
|
UPDATE ai.turn_steers
|
|
3178
3231
|
SET status = 'discarded', consumed_at = COALESCE(consumed_at, now())
|
|
@@ -3261,6 +3314,7 @@ export const aiConversations: AiConversationService = {
|
|
|
3261
3314
|
savePendingTurnAction: async (input) => {
|
|
3262
3315
|
await sql`
|
|
3263
3316
|
INSERT INTO ai.pending_actions (
|
|
3317
|
+
approval_id,
|
|
3264
3318
|
turn_id,
|
|
3265
3319
|
conversation_id,
|
|
3266
3320
|
call_id,
|
|
@@ -3271,12 +3325,15 @@ export const aiConversations: AiConversationService = {
|
|
|
3271
3325
|
review,
|
|
3272
3326
|
approval_scope,
|
|
3273
3327
|
allow_always,
|
|
3328
|
+
allow_chat,
|
|
3329
|
+
remember_tool_name,
|
|
3274
3330
|
frontend_mode,
|
|
3275
3331
|
status,
|
|
3276
3332
|
resolved_event,
|
|
3277
3333
|
resolved_at
|
|
3278
3334
|
)
|
|
3279
3335
|
VALUES (
|
|
3336
|
+
${input.approvalId ?? null}::uuid,
|
|
3280
3337
|
${input.turnId},
|
|
3281
3338
|
${input.conversationId},
|
|
3282
3339
|
${input.callId},
|
|
@@ -3287,6 +3344,8 @@ export const aiConversations: AiConversationService = {
|
|
|
3287
3344
|
${input.review ? JSON.stringify(input.review) : null}::text::jsonb,
|
|
3288
3345
|
${input.approvalScope},
|
|
3289
3346
|
${input.allowAlways},
|
|
3347
|
+
${input.allowChat ?? input.allowAlways},
|
|
3348
|
+
${input.rememberToolName ?? null},
|
|
3290
3349
|
${input.frontendMode ?? null},
|
|
3291
3350
|
${input.resolvedEvent ? "resolved" : "pending"},
|
|
3292
3351
|
${input.resolvedEvent ? JSON.stringify(input.resolvedEvent) : null}::text::jsonb,
|
|
@@ -3294,6 +3353,7 @@ export const aiConversations: AiConversationService = {
|
|
|
3294
3353
|
)
|
|
3295
3354
|
ON CONFLICT (turn_id, call_id)
|
|
3296
3355
|
DO UPDATE SET
|
|
3356
|
+
approval_id = EXCLUDED.approval_id,
|
|
3297
3357
|
kind = EXCLUDED.kind,
|
|
3298
3358
|
tool_name = EXCLUDED.tool_name,
|
|
3299
3359
|
args = EXCLUDED.args,
|
|
@@ -3301,6 +3361,8 @@ export const aiConversations: AiConversationService = {
|
|
|
3301
3361
|
review = EXCLUDED.review,
|
|
3302
3362
|
approval_scope = EXCLUDED.approval_scope,
|
|
3303
3363
|
allow_always = EXCLUDED.allow_always,
|
|
3364
|
+
allow_chat = EXCLUDED.allow_chat,
|
|
3365
|
+
remember_tool_name = EXCLUDED.remember_tool_name,
|
|
3304
3366
|
frontend_mode = EXCLUDED.frontend_mode
|
|
3305
3367
|
`;
|
|
3306
3368
|
},
|
|
@@ -3370,12 +3432,15 @@ export const aiConversations: AiConversationService = {
|
|
|
3370
3432
|
|
|
3371
3433
|
clearPendingTurnActions: async (input) => {
|
|
3372
3434
|
await sql`
|
|
3373
|
-
UPDATE ai.pending_actions
|
|
3435
|
+
WITH aborted AS (UPDATE ai.pending_actions
|
|
3374
3436
|
SET status = 'aborted',
|
|
3375
3437
|
resolved_at = COALESCE(resolved_at, now())
|
|
3376
3438
|
WHERE conversation_id = ${input.conversationId}
|
|
3377
3439
|
AND turn_id = ${input.turnId}
|
|
3378
3440
|
AND status = 'pending'
|
|
3441
|
+
RETURNING approval_id)
|
|
3442
|
+
UPDATE approvals.requests SET status='cancelled'
|
|
3443
|
+
WHERE id IN (SELECT approval_id FROM aborted WHERE approval_id IS NOT NULL) AND status='pending'
|
|
3379
3444
|
`;
|
|
3380
3445
|
},
|
|
3381
3446
|
|
package/src/ai/tool-audit.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { sql } from "bun";
|
|
2
|
+
import type { ApprovalDecisionChannel } from "../services/approvals";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* AI-specific tool-call state only: approval, turn linkage, and the request id
|
|
@@ -8,7 +9,14 @@ import { sql } from "bun";
|
|
|
8
9
|
*/
|
|
9
10
|
export type AiToolCallLocation = "server" | "client" | "client_view" | "client_interaction";
|
|
10
11
|
|
|
11
|
-
export type AiToolApprovalState =
|
|
12
|
+
export type AiToolApprovalState =
|
|
13
|
+
| "not_required"
|
|
14
|
+
| "waiting"
|
|
15
|
+
| "approved_once"
|
|
16
|
+
| "approved_for_chat"
|
|
17
|
+
| "approved_always"
|
|
18
|
+
| "approved_by_preference"
|
|
19
|
+
| "rejected";
|
|
12
20
|
|
|
13
21
|
export const aiToolAudit = {
|
|
14
22
|
/** Resolve the current server-recorded invocation, never a model-supplied chat id. */
|
|
@@ -140,12 +148,14 @@ export const aiToolAudit = {
|
|
|
140
148
|
turnId: string;
|
|
141
149
|
callId: string;
|
|
142
150
|
approvalState: Exclude<AiToolApprovalState, "not_required" | "waiting">;
|
|
151
|
+
decidedVia?: ApprovalDecisionChannel;
|
|
143
152
|
}): Promise<void> => {
|
|
144
153
|
const approved = input.approvalState !== "rejected";
|
|
145
154
|
await sql`
|
|
146
155
|
UPDATE ai.tool_calls
|
|
147
156
|
SET
|
|
148
157
|
approval_state = ${input.approvalState},
|
|
158
|
+
approval_decided_via = ${input.decidedVia ?? null},
|
|
149
159
|
status = CASE WHEN ${approved} THEN status ELSE 'rejected' END,
|
|
150
160
|
approved_at = CASE WHEN ${approved} THEN now() ELSE approved_at END,
|
|
151
161
|
rejected_at = CASE WHEN ${approved} THEN rejected_at ELSE now() END
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { CapabilityPresentationCatalog } from "../contracts/capabilities";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sentences for Cloud's own Assistant tools that act on something, in the catalog shape every app uses for
|
|
5
|
+
* its Actions and keyed by tool name. The chat words these calls through the same renderer as app Actions;
|
|
6
|
+
* a test checks every placeholder against the tool's input schema. A tool that only reads or does several
|
|
7
|
+
* things, such as `memory` searching, adding, or forgetting, keeps its title with what it acted on instead.
|
|
8
|
+
*/
|
|
9
|
+
export const CLOUD_AI_TOOL_PRESENTATION = {
|
|
10
|
+
baseLocale: "en",
|
|
11
|
+
sentences: {
|
|
12
|
+
// Code asks for approvals of what it does inside the run, so a rejection or a stop does not mean the code did not run.
|
|
13
|
+
code_run: { approval: "Run code", done: "Ran code" },
|
|
14
|
+
code_action: { approval: "Run app action {input.action}", done: "Ran app action {input.action}" },
|
|
15
|
+
web_extract: { approval: "Read {input.url}", done: "Read {input.url}", notRun: "{input.url} not read" },
|
|
16
|
+
fetch_file: { approval: "Download {input.url}", done: "Downloaded {input.url}", notRun: "{input.url} not downloaded" },
|
|
17
|
+
write_file: { approval: "Write {input.path}", done: "Wrote {input.path}", notRun: "{input.path} not written" },
|
|
18
|
+
},
|
|
19
|
+
translations: {
|
|
20
|
+
de: {
|
|
21
|
+
actions: {
|
|
22
|
+
code_run: { sentences: { approval: "Code ausführen", done: "Code ausgeführt" } },
|
|
23
|
+
code_action: { sentences: { approval: "App-Aktion {input.action} ausführen", done: "App-Aktion {input.action} ausgeführt" } },
|
|
24
|
+
web_extract: { sentences: { approval: "{input.url} lesen", done: "{input.url} gelesen", notRun: "{input.url} nicht gelesen" } },
|
|
25
|
+
fetch_file: {
|
|
26
|
+
sentences: {
|
|
27
|
+
approval: "{input.url} herunterladen",
|
|
28
|
+
done: "{input.url} heruntergeladen",
|
|
29
|
+
notRun: "{input.url} nicht heruntergeladen",
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
write_file: {
|
|
33
|
+
sentences: { approval: "{input.path} schreiben", done: "{input.path} geschrieben", notRun: "{input.path} nicht geschrieben" },
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
} as const satisfies CapabilityPresentationCatalog;
|
package/src/ai/tools.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { z } from "zod";
|
|
|
4
4
|
import type { RequestActor } from "../server";
|
|
5
5
|
import { aiToolNeedsApproval } from "./approvals";
|
|
6
6
|
import type {
|
|
7
|
+
AiApprovalTarget,
|
|
7
8
|
AiDataBoundary,
|
|
8
9
|
AiFrontendToolMode,
|
|
9
10
|
AiProjectFileToolSource,
|
|
@@ -13,6 +14,7 @@ import type {
|
|
|
13
14
|
AiToolApprovalPolicy,
|
|
14
15
|
AiToolDefinition,
|
|
15
16
|
AiToolRuntime,
|
|
17
|
+
AiWebsiteReceipt,
|
|
16
18
|
} from "./types";
|
|
17
19
|
|
|
18
20
|
export const defineAiTool = <TInput extends z.ZodType, TOutput extends z.ZodType>(config: {
|
|
@@ -60,6 +62,12 @@ export const defineAiTool = <TInput extends z.ZodType, TOutput extends z.ZodType
|
|
|
60
62
|
timeZone?: string;
|
|
61
63
|
/** Localized short status; never include payloads or secrets. */
|
|
62
64
|
reportProgress?: (message: string) => Promise<void>;
|
|
65
|
+
/** Asks like `requestApproval`, and lets the person remember the approval of `target`. */
|
|
66
|
+
requestApprovalFor?: (message: string, target: AiApprovalTarget) => Promise<boolean>;
|
|
67
|
+
/** Relays a decision for an effect another platform component consumes and executes. */
|
|
68
|
+
requestRelayedApproval?: (message: string, options: { approvalId?: string; target?: AiApprovalTarget }) => Promise<boolean>;
|
|
69
|
+
/** Shows requests a website approval let through as they go out, so the chat keeps them whatever the outcome. */
|
|
70
|
+
reportWebsiteReceipts?: (receipts: AiWebsiteReceipt[]) => Promise<void>;
|
|
63
71
|
},
|
|
64
72
|
) => Promise<z.infer<TOutput>>,
|
|
65
73
|
): AiToolRuntime<TInput, TOutput> {
|
|
@@ -106,15 +114,93 @@ export type AiToolPreparationContext = {
|
|
|
106
114
|
locale?: string;
|
|
107
115
|
timeZone?: string;
|
|
108
116
|
reportToolProgress?: (callId: string, message: string) => Promise<void>;
|
|
117
|
+
/** Uses the approved snapshot immediately before the tool executes its effect. */
|
|
118
|
+
consumeApproval?: (approvalCallId: string, input: unknown) => Promise<AiApprovalUse>;
|
|
119
|
+
/** Links a relayed decision to the record the effect owner will consume. */
|
|
120
|
+
linkApproval?: (approvalCallId: string, approvalId: string) => void;
|
|
121
|
+
/** Hears what a nested approval asks for before the approval is requested. */
|
|
122
|
+
describeApproval?: (approvalCallId: string, target: AiApprovalTarget) => void;
|
|
123
|
+
reportWebsiteReceipts?: (callId: string, receipts: AiWebsiteReceipt[]) => Promise<void>;
|
|
109
124
|
};
|
|
110
125
|
|
|
126
|
+
/**
|
|
127
|
+
* What using an approval found: `approved` lets the effect run, `changed` means the approved snapshot no longer
|
|
128
|
+
* matches and asks again, and `used` means an earlier run of the call already used it, so the call must not run again.
|
|
129
|
+
*/
|
|
130
|
+
export type AiApprovalUse = "approved" | "changed" | "used";
|
|
131
|
+
|
|
132
|
+
const approvalCallIdPattern = /^(.*)-approval-(\d+)$/;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Whether `askedCallIds` holds a question of the same tool call that Nessi asked after `approvalCallId`, the call's
|
|
136
|
+
* own approval or one of its numbered questions. A turn that continues after such a question runs the tool from its
|
|
137
|
+
* start again, so the approvals before it are used again by the same call.
|
|
138
|
+
*/
|
|
139
|
+
export const aiApprovalAskedLater = (approvalCallId: string, askedCallIds: Iterable<string>): boolean => {
|
|
140
|
+
const own = approvalCallIdPattern.exec(approvalCallId);
|
|
141
|
+
const callId = own ? own[1] : approvalCallId,
|
|
142
|
+
index = own ? Number(own[2]) : -1;
|
|
143
|
+
for (const asked of askedCallIds) {
|
|
144
|
+
const later = approvalCallIdPattern.exec(asked);
|
|
145
|
+
if (later && later[1] === callId && Number(later[2]) > index) return true;
|
|
146
|
+
}
|
|
147
|
+
return false;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const approvalUsedError = () =>
|
|
151
|
+
new Error("This approval was already used, so Cloud does not run the call again. Check its outcome before you call again.");
|
|
152
|
+
|
|
111
153
|
export const prepareAiTools = (input: AiToolPreparationContext & { tools?: AiRuntimeTool[] }): PreparedAiTools => {
|
|
112
154
|
const canonicalNames = new Map<string, string>();
|
|
113
155
|
const approvalPolicies = new Map<string, AiToolApprovalPolicy>();
|
|
114
156
|
const frontendModes = new Map<string, AiFrontendToolMode>();
|
|
115
157
|
|
|
158
|
+
const approvalContext = async (toolInput: unknown, ctx: ToolContext, needsApproval: boolean) => {
|
|
159
|
+
const callId = ctx.callId;
|
|
160
|
+
const use = (approvalCallId: string): Promise<AiApprovalUse> =>
|
|
161
|
+
input.consumeApproval ? input.consumeApproval(approvalCallId, toolInput) : Promise.resolve("approved");
|
|
162
|
+
if (needsApproval && input.consumeApproval) {
|
|
163
|
+
const used = callId ? await use(callId) : "changed";
|
|
164
|
+
if (used === "used") throw approvalUsedError();
|
|
165
|
+
if (used === "changed") throw new Error("This approval no longer matches the call. Ask again.");
|
|
166
|
+
}
|
|
167
|
+
// Nessi numbers the approvals of one execution in request order; count them to name each one's call ID.
|
|
168
|
+
let approvals = 0;
|
|
169
|
+
const requestApproval = async (message: string, target?: AiApprovalTarget): Promise<boolean> => {
|
|
170
|
+
while (true) {
|
|
171
|
+
const approvalCallId = `${callId}-approval-${approvals++}`;
|
|
172
|
+
if (target) input.describeApproval?.(approvalCallId, target);
|
|
173
|
+
if (!(await ctx.requestApproval(message))) return false;
|
|
174
|
+
const used = await use(approvalCallId);
|
|
175
|
+
if (used === "approved") return true;
|
|
176
|
+
if (used === "used") throw approvalUsedError();
|
|
177
|
+
// A changed snapshot requires a new human decision under the next Nessi action ID.
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
return {
|
|
181
|
+
...ctx,
|
|
182
|
+
requestApproval,
|
|
183
|
+
requestApprovalFor: (message: string, target: AiApprovalTarget) => requestApproval(message, target),
|
|
184
|
+
requestRelayedApproval: async (message: string, options: { approvalId?: string; target?: AiApprovalTarget }) => {
|
|
185
|
+
const approvalCallId = `${callId}-approval-${approvals++}`;
|
|
186
|
+
if (options.approvalId) input.linkApproval?.(approvalCallId, options.approvalId);
|
|
187
|
+
if (options.target) input.describeApproval?.(approvalCallId, options.target);
|
|
188
|
+
if (!(await ctx.requestApproval(message))) return false;
|
|
189
|
+
// The effect's own record is used where it runs. Without one, as for a website request from code, the relay
|
|
190
|
+
// that hands over the decision uses the chat's record; a decision it cannot use is not handed over as approval.
|
|
191
|
+
return options.approvalId !== undefined || (await use(approvalCallId)) === "approved";
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
};
|
|
195
|
+
|
|
116
196
|
const tools = (input.tools ?? []).map((tool): Tool => {
|
|
117
|
-
if (!isCloudAiTool(tool))
|
|
197
|
+
if (!isCloudAiTool(tool)) {
|
|
198
|
+
if (tool.kind !== "server") return tool;
|
|
199
|
+
return {
|
|
200
|
+
...tool,
|
|
201
|
+
execute: async (toolInput, ctx) => tool.execute(toolInput, await approvalContext(toolInput, ctx, tool.def.needsApproval === true)),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
118
204
|
|
|
119
205
|
canonicalNames.set(tool.def.name, tool.def.canonicalName ?? tool.def.name);
|
|
120
206
|
approvalPolicies.set(tool.def.name, tool.def.approval);
|
|
@@ -130,12 +216,12 @@ export const prepareAiTools = (input: AiToolPreparationContext & { tools?: AiRun
|
|
|
130
216
|
});
|
|
131
217
|
|
|
132
218
|
if (tool.location === "server") {
|
|
133
|
-
return nessiTool.server((toolInput, ctx) => {
|
|
219
|
+
return nessiTool.server(async (toolInput, ctx) => {
|
|
134
220
|
if (!input.actor) throw new Error(`AI server tool "${tool.def.name}" requires a request actor.`);
|
|
135
221
|
const report = input.reportToolProgress;
|
|
136
222
|
const callId = ctx.callId;
|
|
137
223
|
return tool.run(toolInput, {
|
|
138
|
-
...ctx,
|
|
224
|
+
...(await approvalContext(toolInput, ctx, aiToolNeedsApproval(tool.def.approval))),
|
|
139
225
|
actor: input.actor,
|
|
140
226
|
conversationId: input.conversationId,
|
|
141
227
|
turnId: input.turnId,
|
|
@@ -147,6 +233,8 @@ export const prepareAiTools = (input: AiToolPreparationContext & { tools?: AiRun
|
|
|
147
233
|
locale: input.locale,
|
|
148
234
|
timeZone: input.timeZone,
|
|
149
235
|
reportProgress: callId && report ? (message) => report(callId, message) : undefined,
|
|
236
|
+
reportWebsiteReceipts:
|
|
237
|
+
callId && input.reportWebsiteReceipts ? (receipts) => input.reportWebsiteReceipts!(callId, receipts) : undefined,
|
|
150
238
|
});
|
|
151
239
|
});
|
|
152
240
|
}
|
package/src/ai/types.ts
CHANGED
|
@@ -12,7 +12,8 @@ import type {
|
|
|
12
12
|
} from "@k2b/nessi";
|
|
13
13
|
import type { Usage } from "@k2b/nessi/ai";
|
|
14
14
|
import type { z } from "zod";
|
|
15
|
-
import type {
|
|
15
|
+
import type { CapabilitySentenceField } from "../_internal/capability-sentences";
|
|
16
|
+
import type { CapabilityActionReview, CapabilityActionSentences, CloudResourceRef } from "../contracts/capabilities";
|
|
16
17
|
import type { RequestActor } from "../server";
|
|
17
18
|
import type { AiTurnBlock } from "./protocol";
|
|
18
19
|
|
|
@@ -302,6 +303,8 @@ export type AiStoredMessage = {
|
|
|
302
303
|
toolPresentations?: Record<string, AiToolPresentation>;
|
|
303
304
|
/** How the person decided an approval; `expired` when the turn ended while it still waited. */
|
|
304
305
|
toolOutcomes?: Record<string, "rejected" | "approved" | "expired">;
|
|
306
|
+
/** Requests a website approval let through, by tool call and then by `AiWebsiteReceipt.index`. */
|
|
307
|
+
websiteReceipts?: Record<string, Record<string, { method: string; url: string }>>;
|
|
305
308
|
/** Why the turn failed, on the last message of its loop. The chat words it in the reader's language. */
|
|
306
309
|
turnError?: AiTurnError;
|
|
307
310
|
} | null;
|
|
@@ -419,6 +422,10 @@ export type AiPendingTurnAction =
|
|
|
419
422
|
message?: string;
|
|
420
423
|
review?: CapabilityActionReview;
|
|
421
424
|
allowAlways: boolean;
|
|
425
|
+
/** Whether the approval can be remembered for this chat. */
|
|
426
|
+
allowChat: boolean;
|
|
427
|
+
/** The website origin a chat approval would allow, for an HTTP request a code run makes. */
|
|
428
|
+
website?: string;
|
|
422
429
|
}
|
|
423
430
|
| {
|
|
424
431
|
type: "frontend_tool";
|
|
@@ -440,6 +447,7 @@ export type AiTurnAbortRequest =
|
|
|
440
447
|
};
|
|
441
448
|
|
|
442
449
|
export type AiPendingTurnActionRecord = {
|
|
450
|
+
approvalId?: string;
|
|
443
451
|
turnId: string;
|
|
444
452
|
conversationId: string;
|
|
445
453
|
callId: string;
|
|
@@ -451,6 +459,10 @@ export type AiPendingTurnActionRecord = {
|
|
|
451
459
|
review?: CapabilityActionReview;
|
|
452
460
|
approvalScope: string;
|
|
453
461
|
allowAlways: boolean;
|
|
462
|
+
/** Whether the approval can be remembered for this chat; defaults to `allowAlways`. */
|
|
463
|
+
allowChat?: boolean;
|
|
464
|
+
/** Remembered-approval name when it differs from `name`: the website or Action a code run asks for. */
|
|
465
|
+
rememberToolName?: string;
|
|
454
466
|
frontendMode?: AiFrontendToolMode;
|
|
455
467
|
resolvedEvent: InboundEvent | null;
|
|
456
468
|
};
|
|
@@ -606,6 +618,11 @@ export type AiChatTurnRunConfig = {
|
|
|
606
618
|
selectedSkillIds?: string[];
|
|
607
619
|
/** Server-owned marker: model grants apply only to interactive Assistant chat. */
|
|
608
620
|
assistantChat?: true;
|
|
621
|
+
/**
|
|
622
|
+
* Server-owned marker: a person started this turn in a signed-in browser session, not through `cld`, an API key,
|
|
623
|
+
* a delegated account, or a schedule. Only such a turn uses website approvals.
|
|
624
|
+
*/
|
|
625
|
+
signedInSession?: true;
|
|
609
626
|
kind?: "chat";
|
|
610
627
|
input: Input;
|
|
611
628
|
/** Stable public ID exposed as runtime context, not instructions. */
|
|
@@ -1015,6 +1032,23 @@ export type AiAccessResult<TAccess = unknown> = {
|
|
|
1015
1032
|
|
|
1016
1033
|
export type AiToolApprovalPolicy = "never" | "once" | "always" | { kind: "user-configurable"; default: "once" | "always"; scope?: string };
|
|
1017
1034
|
|
|
1035
|
+
/**
|
|
1036
|
+
* What a server tool asks a person to approve when the approval can be remembered: a website or an Action, not the
|
|
1037
|
+
* tool itself. A target can always be remembered for its chat.
|
|
1038
|
+
*/
|
|
1039
|
+
export type AiApprovalTarget = {
|
|
1040
|
+
toolName: string;
|
|
1041
|
+
approvalScope: string;
|
|
1042
|
+
/** Whether the person may also remember it everywhere. */
|
|
1043
|
+
always: boolean;
|
|
1044
|
+
};
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* A request a remembered website approval let through without asking. The chat shows it with its full URL and a revoke
|
|
1048
|
+
* action; `index` orders the receipts of one tool call, and a receipt reported again with the same index replaces it.
|
|
1049
|
+
*/
|
|
1050
|
+
export type AiWebsiteReceipt = { index: number; method: string; url: string };
|
|
1051
|
+
|
|
1018
1052
|
export type AiFrontendToolMode = "client" | "client_view" | "client_interaction";
|
|
1019
1053
|
|
|
1020
1054
|
export type AiCapabilityToolPresentation = {
|
|
@@ -1025,6 +1059,12 @@ export type AiCapabilityToolPresentation = {
|
|
|
1025
1059
|
appAccent?: string;
|
|
1026
1060
|
title: string;
|
|
1027
1061
|
capabilityKind: "query" | "action";
|
|
1062
|
+
/** Actions: the app's sentences in the locale of the turn, worded for each call by the chat. */
|
|
1063
|
+
sentences?: CapabilityActionSentences;
|
|
1064
|
+
/** Actions: field formats for the sentences and labelled fields for the generic sentence, in schema order. */
|
|
1065
|
+
fields?: CapabilitySentenceField[];
|
|
1066
|
+
/** Actions: Cloud offered the model `approvalReason` for this call, so the chat may show it as the model's reason. */
|
|
1067
|
+
approvalReason?: true;
|
|
1028
1068
|
};
|
|
1029
1069
|
|
|
1030
1070
|
export type AiToolPresentation = AiCapabilityToolPresentation;
|
|
@@ -1064,6 +1104,12 @@ export type AiToolRuntime<TInput extends z.ZodType = z.ZodType, TOutput extends
|
|
|
1064
1104
|
timeZone?: string;
|
|
1065
1105
|
/** Localized short status; never include payloads or secrets. */
|
|
1066
1106
|
reportProgress?: (message: string) => Promise<void>;
|
|
1107
|
+
/** Asks like `requestApproval`, and lets the person remember the approval of `target`. */
|
|
1108
|
+
requestApprovalFor?: (message: string, target: AiApprovalTarget) => Promise<boolean>;
|
|
1109
|
+
/** Relays a decision for an effect another platform component consumes and executes. */
|
|
1110
|
+
requestRelayedApproval?: (message: string, options: { approvalId?: string; target?: AiApprovalTarget }) => Promise<boolean>;
|
|
1111
|
+
/** Shows requests a website approval let through as they go out, so the chat keeps them whatever the outcome. */
|
|
1112
|
+
reportWebsiteReceipts?: (receipts: AiWebsiteReceipt[]) => Promise<void>;
|
|
1067
1113
|
},
|
|
1068
1114
|
): Promise<z.infer<TOutput>>;
|
|
1069
1115
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { domainToUnicode } from "node:url";
|
|
2
|
+
import { sql } from "bun";
|
|
3
|
+
import { toPgTextArray } from "../services/postgres";
|
|
4
|
+
|
|
5
|
+
/** The longest address web_extract and fetch_file accept; a longer one can never be read, so it is never recorded. */
|
|
6
|
+
const WEB_ADDRESS_MAX_CHARS = 2_000;
|
|
7
|
+
|
|
8
|
+
const URL_TOKEN = /https?:\/\/[^\s<>"'`\\^{}|[\]]+/gi;
|
|
9
|
+
/**
|
|
10
|
+
* An address typed without its scheme, such as `example.org/news`. It must start the word: a host inside another
|
|
11
|
+
* address, an email address, or a longer host name is not a typed address of its own.
|
|
12
|
+
*/
|
|
13
|
+
const BARE_TOKEN =
|
|
14
|
+
/(?<![\w@.\-/:%+~#=?&])(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z][a-z0-9-]*[a-z0-9](?::\d{1,5})?(?:\/[^\s<>"'`\\^{}|[\]]*)?/gi;
|
|
15
|
+
|
|
16
|
+
/** Drops punctuation that ends the sentence around an address, and a closing bracket the address did not open. */
|
|
17
|
+
const trimToken = (token: string): string => {
|
|
18
|
+
let end = token.length;
|
|
19
|
+
while (end > 0) {
|
|
20
|
+
const last = token[end - 1]!;
|
|
21
|
+
if (".,;:!?*'\"".includes(last)) end--;
|
|
22
|
+
else if (last === ")" && token.slice(0, end).split("(").length <= token.slice(0, end).split(")").length - 1) end--;
|
|
23
|
+
else break;
|
|
24
|
+
}
|
|
25
|
+
return token.slice(0, end);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** The forms of one address that count as the same read: as written, without its fragment, and over HTTPS. */
|
|
29
|
+
const addressForms = (raw: string, add: (address: string) => void) => {
|
|
30
|
+
let url: URL;
|
|
31
|
+
try {
|
|
32
|
+
url = new URL(raw);
|
|
33
|
+
} catch {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return;
|
|
37
|
+
const forms = [url.href];
|
|
38
|
+
if (url.hash) forms.push(url.href.slice(0, -url.hash.length));
|
|
39
|
+
// Upgrading to HTTPS carries no data the address did not already carry.
|
|
40
|
+
if (url.protocol === "http:") forms.push(...forms.map((form) => `https:${form.slice("http:".length)}`));
|
|
41
|
+
for (const form of forms) if (form.length <= WEB_ADDRESS_MAX_CHARS) add(form);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Every whole address in a text, normalized like a URL the tools read. Only complete tokens count, never a part of a
|
|
46
|
+
* longer address; with `typed`, an address written without its scheme counts too, as a person often types it.
|
|
47
|
+
*/
|
|
48
|
+
export const webAddressesIn = (text: string, options: { typed?: boolean } = {}): Set<string> => {
|
|
49
|
+
const addresses = new Set<string>();
|
|
50
|
+
const add = (address: string) => addresses.add(address);
|
|
51
|
+
const rest = text.replace(URL_TOKEN, (token) => {
|
|
52
|
+
addressForms(trimToken(token), add);
|
|
53
|
+
return " ";
|
|
54
|
+
});
|
|
55
|
+
if (options.typed)
|
|
56
|
+
for (const [token] of rest.matchAll(BARE_TOKEN)) {
|
|
57
|
+
const bare = trimToken(token);
|
|
58
|
+
addressForms(`https://${bare}`, add);
|
|
59
|
+
addressForms(`http://${bare}`, add);
|
|
60
|
+
}
|
|
61
|
+
return addresses;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Records addresses a web search returned or a page read in this chat links to; later reads of them do not ask. */
|
|
65
|
+
export const recordWebAddresses = async (conversationId: string, addresses: Iterable<string>): Promise<void> => {
|
|
66
|
+
const list = [...addresses];
|
|
67
|
+
if (!list.length) return;
|
|
68
|
+
await sql`
|
|
69
|
+
INSERT INTO ai.web_addresses (conversation_id, address)
|
|
70
|
+
SELECT ${conversationId}::uuid, address FROM unnest(${toPgTextArray(list)}::text[]) address
|
|
71
|
+
ON CONFLICT DO NOTHING
|
|
72
|
+
`;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Whether the chat itself supplied this address: the person wrote it, a web search returned it, or a page read
|
|
77
|
+
* earlier links to it. A message another chat sent counts as neither. Anything else, such as an address the model
|
|
78
|
+
* assembled from other data, has no provenance. `url` is the normalized address the tool reads.
|
|
79
|
+
*/
|
|
80
|
+
export const hasWebAddressProvenance = async (conversationId: string, url: string): Promise<boolean> => {
|
|
81
|
+
const [recorded] = await sql<{ found: boolean }[]>`
|
|
82
|
+
SELECT EXISTS (SELECT 1 FROM ai.web_addresses WHERE conversation_id = ${conversationId}::uuid AND address = ${url}) AS found
|
|
83
|
+
`;
|
|
84
|
+
if (recorded?.found) return true;
|
|
85
|
+
// Only the person's own messages that name the host are read, never tool results.
|
|
86
|
+
const { hostname } = new URL(url);
|
|
87
|
+
const hosts = [...new Set([hostname, domainToUnicode(hostname) || hostname])];
|
|
88
|
+
const rows = await sql<{ text: string }[]>`
|
|
89
|
+
SELECT text FROM (
|
|
90
|
+
SELECT message, meta FROM ai.messages WHERE conversation_id = ${conversationId}::uuid AND kind = 'message' AND role = 'user'
|
|
91
|
+
UNION ALL
|
|
92
|
+
SELECT message, meta FROM ai.task_messages WHERE conversation_id = ${conversationId}::uuid AND kind = 'message' AND role = 'user'
|
|
93
|
+
) entry
|
|
94
|
+
CROSS JOIN LATERAL (
|
|
95
|
+
SELECT string_agg(CASE WHEN jsonb_typeof(part) = 'string' THEN part #>> '{}' ELSE part->>'text' END, chr(10)) AS text
|
|
96
|
+
FROM jsonb_array_elements(CASE WHEN jsonb_typeof(entry.message->'content') = 'array' THEN entry.message->'content' ELSE '[]'::jsonb END) part
|
|
97
|
+
WHERE jsonb_typeof(part) = 'string' OR part->>'type' = 'text'
|
|
98
|
+
) content
|
|
99
|
+
WHERE NOT COALESCE(entry.meta ? 'agentMessage', false)
|
|
100
|
+
AND EXISTS (SELECT 1 FROM unnest(${toPgTextArray(hosts)}::text[]) host WHERE strpos(lower(content.text), host) > 0)
|
|
101
|
+
`;
|
|
102
|
+
return rows.some((row) => webAddressesIn(row.text, { typed: true }).has(url));
|
|
103
|
+
};
|