@k2b/cloud 0.39.0 → 0.41.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/README.md +8 -0
- package/package.json +11 -4
- package/src/_internal/app-favicon.ts +4 -2
- package/src/_internal/capabilities.ts +72 -48
- package/src/_internal/capability-reviews.ts +81 -0
- package/src/_internal/capability-schemas.ts +29 -0
- package/src/_internal/capability-sentences.ts +217 -1
- package/src/_internal/capability-streams.ts +7 -0
- package/src/_internal/define-app.ts +49 -18
- package/src/_internal/font-preloads.ts +2 -1
- package/src/_internal/heartbeat.ts +203 -14
- package/src/_internal/registry.ts +28 -1
- package/src/_internal/runtime-watcher.ts +6 -0
- package/src/_internal/setup-lock.ts +103 -0
- package/src/_internal/static-assets.ts +52 -3
- package/src/_internal/status-preserving-ssr.ts +2 -0
- package/src/access/PermissionEditor.tsx +2 -2
- package/src/account/EntitySearch.tsx +1 -1
- package/src/ai/action-review.ts +101 -0
- package/src/ai/background-review.ts +72 -0
- package/src/ai/capabilities.ts +29 -11
- package/src/ai/capability-execution.ts +76 -43
- package/src/ai/capability-result.ts +95 -0
- package/src/ai/chat/action-card.ts +464 -0
- package/src/ai/chat/action-messages.ts +129 -0
- package/src/ai/chat/action-question.browser-harness.tsx +245 -0
- package/src/ai/chat/action-question.fixtures.ts +733 -0
- package/src/ai/chat/action-question.tsx +680 -0
- package/src/ai/chat/action-text.ts +288 -0
- package/src/ai/chat/blocks.tsx +19 -25
- package/src/ai/chat/message-actions.tsx +6 -0
- package/src/ai/chat/tool-action-words.ts +31 -0
- package/src/ai/chat/tool-action.tsx +456 -0
- package/src/ai/chat/turn-view.tsx +10 -160
- package/src/ai/chat/view-block.tsx +15 -5
- package/src/ai/chat-task-contracts.ts +4 -1
- package/src/ai/chat-task-reasons-migrate.ts +7 -0
- package/src/ai/chat-task-reasons.ts +1 -1
- package/src/ai/chat-tasks.ts +55 -25
- package/src/ai/client/controller.ts +59 -0
- package/src/ai/default-tools.ts +9 -1
- package/src/ai/executor.ts +198 -39
- package/src/ai/firecrawl-tools.ts +15 -2
- package/src/ai/index.ts +3 -0
- package/src/ai/migrate.ts +26 -2
- package/src/ai/open-questions.ts +172 -0
- package/src/ai/projects-routes.ts +3 -0
- package/src/ai/protocol.ts +14 -2
- package/src/ai/question-text.ts +8 -0
- package/src/ai/recent-resources.ts +240 -0
- package/src/ai/recent-sent-refs.ts +38 -0
- package/src/ai/routes.ts +195 -5
- package/src/ai/runtime.ts +1 -0
- package/src/ai/skills-routes.ts +3 -0
- package/src/ai/skills.ts +10 -1
- package/src/ai/store.ts +245 -24
- package/src/ai/testing.ts +36 -0
- package/src/ai/tool-audit.ts +8 -0
- package/src/ai/tool-sentences.ts +152 -34
- package/src/ai/tool-wording.ts +36 -8
- package/src/ai/tools.ts +8 -3
- package/src/ai/types.ts +43 -4
- package/src/api/accounts-entities.ts +2 -1
- package/src/api/admin-ai-projects.ts +2 -0
- package/src/api/admin-ai-skills.ts +2 -0
- package/src/api/admin-ai-usage.ts +2 -0
- package/src/api/admin-geoip.ts +80 -0
- package/src/api/admin-identity.ts +3 -0
- package/src/api/admin-lifecycle.ts +3 -0
- package/src/api/admin-outgoing-mail.ts +2 -0
- package/src/api/app-approval-actions.ts +31 -7
- package/src/api/app-approval.ts +46 -5
- package/src/api/approvals-loader.ts +23 -1
- package/src/api/approvals.ts +2 -0
- package/src/api/auth/schemas.ts +3 -0
- package/src/api/auth.ts +67 -12
- package/src/api/capabilities.ts +44 -11
- package/src/api/capability-streams.ts +12 -0
- package/src/api/help.ts +4 -28
- package/src/api/index.ts +6 -1
- package/src/api/lasting-access-messages.ts +17 -0
- package/src/api/mcp.ts +30 -3
- package/src/api/me-panel-consent.ts +71 -0
- package/src/api/me.ts +159 -36
- package/src/api/search.ts +3 -1
- package/src/api/sync-ops.ts +2 -0
- package/src/api/widgets.ts +3 -0
- package/src/approvals/Approvals.tsx +166 -262
- package/src/approvals/approval-card.ts +68 -0
- package/src/approvals/messages.ts +12 -24
- package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
- package/src/browser/app-approval.ts +54 -5
- package/src/browser/commands.ts +22 -6
- package/src/browser/composer-catalog.ts +45 -3
- package/src/browser/composer-insert.ts +10 -0
- package/src/browser/composer-open-messages.ts +17 -0
- package/src/browser/composer-open.ts +110 -0
- package/src/browser/discoverable-command.ts +6 -0
- package/src/browser/hints.ts +6 -0
- package/src/browser/live-connection.ts +26 -2
- package/src/browser/navigation-apps.ts +7 -0
- package/src/browser/panel-context-bridge.ts +30 -0
- package/src/browser/panel-context-receiver.ts +53 -0
- package/src/browser/panel-menu.tsx +56 -0
- package/src/browser/panel-messages.ts +11 -0
- package/src/browser/panel-shortcuts.ts +31 -0
- package/src/browser/panel-slot.ts +412 -10
- package/src/browser/panel.ts +37 -0
- package/src/browser/search-commands.ts +6 -3
- package/src/capabilities/client.ts +8 -2
- package/src/capabilities/reviews.ts +205 -0
- package/src/capabilities/server.ts +10 -3
- package/src/capabilities/testing.ts +47 -0
- package/src/capabilities/types.ts +2 -2
- package/src/cli/account.ts +13 -3
- package/src/cli/admin/geoip.ts +115 -0
- package/src/cli/admin/index.ts +3 -0
- package/src/contracts/app-approval.ts +123 -16
- package/src/contracts/approvals.ts +2 -2
- package/src/contracts/capabilities.ts +410 -18
- package/src/contracts/geoip.ts +42 -0
- package/src/contracts/index.ts +3 -0
- package/src/contracts/panel-context-plain.ts +99 -0
- package/src/contracts/panel-context.ts +93 -0
- package/src/contracts/seen-hints.ts +35 -0
- package/src/contracts/shared.ts +3 -0
- package/src/server/client-ip.ts +5 -0
- package/src/server/help-payload.ts +38 -0
- package/src/server/middleware/auth.ts +13 -1
- package/src/server/middleware/middleware.ts +9 -0
- package/src/server/middleware/no-transform.ts +19 -0
- package/src/server/middleware/rate-limit.ts +4 -6
- package/src/server/request-context/address.ts +45 -0
- package/src/server/request-context/geoip.ts +380 -0
- package/src/server/request-context/index.ts +33 -0
- package/src/server/request-context/user-agent.ts +95 -0
- package/src/services/accounts/app.ts +23 -3
- package/src/services/accounts/users.ts +12 -1
- package/src/services/app-approval.ts +382 -59
- package/src/services/approvals/device.ts +49 -31
- package/src/services/approvals/index.ts +7 -5
- package/src/services/approvals/list.ts +27 -6
- package/src/services/approvals/question.ts +68 -0
- package/src/services/audit/index.ts +1 -0
- package/src/services/auth-flows/ipa.ts +39 -2
- package/src/services/auth-flows/notification-sender.ts +3 -0
- package/src/services/auth-flows/password-reset.ts +32 -6
- package/src/services/geoip/index.ts +164 -0
- package/src/services/geoip/store.ts +30 -0
- package/src/services/geoip/upload.ts +150 -0
- package/src/services/identity/routes.ts +5 -1
- package/src/services/index.ts +13 -0
- package/src/services/known-browsers.ts +312 -0
- package/src/services/login-address-store.ts +51 -0
- package/src/services/login-context.ts +118 -0
- package/src/services/mandates/index.ts +3 -0
- package/src/services/mandates/policy.ts +15 -3
- package/src/services/notifications/email-frame.ts +1 -1
- package/src/services/oauth-tokens.ts +16 -2
- package/src/services/panel-context-consent.ts +58 -0
- package/src/services/pdf/markdown.ts +160 -2
- package/src/services/service-account-credentials.ts +21 -0
- package/src/services/session/index.ts +296 -41
- package/src/services/session/recent.ts +13 -0
- package/src/services/webauthn.ts +12 -0
- package/src/shared/email-html.ts +2 -0
- package/src/shared/help.ts +3 -0
- package/src/shared-text/cursor.ts +22 -0
- package/src/shared-text/documents.ts +142 -0
- package/src/shared-text/events.ts +43 -0
- package/src/shared-text/index.ts +12 -0
- package/src/shared-text/log.ts +48 -0
- package/src/shared-text/text-diff.ts +23 -0
- package/src/shared-text/types.ts +41 -0
- package/src/ssr/AdminLayout.tsx +2 -0
- package/src/ssr/AppLaunchpadPanel.tsx +2 -2
- package/src/ssr/CloudShell.island.tsx +10 -2
- package/src/ssr/HelpPanelArticle.island.tsx +308 -0
- package/src/ssr/HelpPanelBack.island.tsx +32 -0
- package/src/ssr/HelpPanelMenu.island.tsx +133 -0
- package/src/ssr/HelpPanelSearch.island.tsx +123 -0
- package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
- package/src/ssr/HelpPanelView.island.tsx +27 -0
- package/src/ssr/Layout.tsx +25 -2
- package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
- package/src/ssr/LayoutHelp.tsx +222 -419
- package/src/ssr/LayoutHelpPanel.browser-harness.tsx +135 -0
- package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
- package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
- package/src/ssr/LayoutHelpTrigger.tsx +27 -2
- package/src/ssr/MobileNavigation.tsx +2 -2
- package/src/ssr/PanelLayout.tsx +9 -1
- package/src/ssr/PanelRuntime.island.tsx +7 -0
- package/src/ssr/PanelSlot.tsx +53 -3
- package/src/ssr/ShellRuntime.island.tsx +11 -2
- package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
- package/src/ssr/app-color.ts +14 -9
- package/src/ssr/app-navigation.ts +16 -3
- package/src/ssr/compact-shell.browser-harness.ts +30 -22
- package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
- package/src/ssr/help-messages.ts +4 -0
- package/src/ssr/help.ts +21 -0
- package/src/ssr/hints.ts +25 -0
- package/src/ssr/index.ts +3 -0
- package/src/ssr/layout-context.ts +2 -0
- package/src/ssr/layout-help-article.server.ts +50 -0
- package/src/ssr/layout-help-panel-controls.tsx +43 -0
- package/src/ssr/layout-help-panel-document.ts +67 -0
- package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
- package/src/ssr/layout-help-panel-menu.tsx +113 -0
- package/src/ssr/layout-help-panel-modules.ts +7 -0
- package/src/ssr/layout-help-panel-reader.tsx +159 -0
- package/src/ssr/layout-help-panel-search.tsx +69 -0
- package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
- package/src/ssr/layout-help-panel-state.ts +141 -0
- package/src/ssr/layout-help-panel-types.ts +19 -0
- package/src/ssr/layout-help-start.ts +33 -7
- package/src/ssr/layout-help-views.tsx +484 -0
- package/src/ssr/panel-consent.ts +29 -0
- package/src/ssr/panel-context.ts +10 -0
- package/src/ssr/panel-idle.ts +9 -0
- package/src/ssr/panel-slot.browser-harness.tsx +53 -4
- package/src/ssr/platform-messages.ts +18 -0
- package/src/ssr/shell-data.tsx +2 -1
- package/src/styles/approvals.css +6 -0
- package/src/styles/effects.css +213 -9
- package/src/styles/file-chooser.css +2 -1
- package/src/styles/global.css +49 -12
- package/src/styles/panel.css +47 -4
- package/src/styles/resource-search.css +6 -2
- package/src/styles/tokens.css +17 -11
- package/src/styles/utilities-buttons.css +3 -2
- package/src/styles/utilities-data.css +2 -1
- package/src/styles/utilities-feedback.css +2 -2
- package/src/styles/utilities-layout.css +3 -13
- package/src/styles/utilities-markdown-editor.css +6 -6
- package/src/styles/utilities-navigation.css +17 -22
package/src/ai/store.ts
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import type { DoneReason, InboundEvent, LoopAggregate, Message, SessionStore, StoreEntry } from "@k2b/nessi";
|
|
2
2
|
import type { Usage } from "@k2b/nessi/ai";
|
|
3
3
|
import { type SQL, sql } from "bun";
|
|
4
|
-
import { type
|
|
4
|
+
import { type CapabilityActionReviewData, CapabilityActionReviewDataSchema } from "../contracts/capabilities";
|
|
5
5
|
import { logger } from "../services/logging";
|
|
6
6
|
import { toPgTextArray } from "../services/postgres";
|
|
7
|
+
import { AI_TOOL_CHECK_STALE_MS, type AiActionCheck, type AiStoredActionReview } from "./action-review";
|
|
8
|
+
|
|
9
|
+
export { AI_TOOL_CHECK_STALE_MS };
|
|
10
|
+
|
|
7
11
|
import { AI_WEBSITE_APPROVAL_TOOL } from "./approvals";
|
|
12
|
+
import { projectSettledAiToolChecks } from "./capability-result";
|
|
8
13
|
import { aiTurnErrorText } from "./chat/turn-error";
|
|
9
14
|
import { AI_MEMORY_LEARNING_DEFAULT_ENABLED } from "./prefs";
|
|
10
15
|
import type { AiTurnBlock } from "./protocol";
|
|
16
|
+
import { collectRecentSentResourceRefs, RECENT_SENT_CONVERSATION_SCAN_LIMIT, RECENT_SENT_MESSAGE_SCAN_LIMIT } from "./recent-sent-refs";
|
|
11
17
|
import { AiRunTimeout } from "./run-timeout";
|
|
12
18
|
import { withAiShortId, withAiShortIdForDb } from "./short-id";
|
|
13
19
|
import { parseAiTodoPlan } from "./todo-contracts";
|
|
@@ -65,6 +71,45 @@ export const resolveAiTurnShortIds = async (input: {
|
|
|
65
71
|
return new Map(rows.map((row) => [row.id, row.short_id]));
|
|
66
72
|
};
|
|
67
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Marker texts of the owner's latest sent messages, newest first. The candidates carry no message bodies; only the
|
|
76
|
+
* newest few are read for their resource markers.
|
|
77
|
+
*/
|
|
78
|
+
export const recentSentResourceMarkersQuery = (ownerUserId: string) => sql<
|
|
79
|
+
{ conversation_id: string; short_id: string; created_at: Date | string; markers: string[] }[]
|
|
80
|
+
>`
|
|
81
|
+
WITH conversations AS MATERIALIZED (
|
|
82
|
+
SELECT id FROM ai.conversations
|
|
83
|
+
WHERE created_by_user_id = ${ownerUserId}::uuid AND archived_at IS NULL
|
|
84
|
+
ORDER BY updated_at DESC
|
|
85
|
+
LIMIT ${RECENT_SENT_CONVERSATION_SCAN_LIMIT}
|
|
86
|
+
), recent_messages AS MATERIALIZED (
|
|
87
|
+
SELECT candidate.id, candidate.conversation_id, candidate.created_at FROM conversations
|
|
88
|
+
CROSS JOIN LATERAL (
|
|
89
|
+
SELECT message.id, message.conversation_id, message.created_at
|
|
90
|
+
FROM ai.messages message
|
|
91
|
+
WHERE message.conversation_id = conversations.id
|
|
92
|
+
AND message.role = 'user' AND message.kind = 'message'
|
|
93
|
+
AND NOT (COALESCE(message.meta, '{}'::jsonb) ? 'scheduledTask')
|
|
94
|
+
AND NOT (COALESCE(message.meta, '{}'::jsonb) ? 'agentMessage')
|
|
95
|
+
ORDER BY message.seq DESC
|
|
96
|
+
LIMIT ${RECENT_SENT_MESSAGE_SCAN_LIMIT}
|
|
97
|
+
) candidate
|
|
98
|
+
ORDER BY candidate.created_at DESC, candidate.id DESC
|
|
99
|
+
LIMIT ${RECENT_SENT_MESSAGE_SCAN_LIMIT}
|
|
100
|
+
)
|
|
101
|
+
SELECT recent_messages.conversation_id, sent.short_id, recent_messages.created_at,
|
|
102
|
+
array_agg(part->>'text' ORDER BY ordinal) AS markers
|
|
103
|
+
FROM recent_messages
|
|
104
|
+
JOIN ai.messages sent ON sent.id = recent_messages.id
|
|
105
|
+
CROSS JOIN LATERAL jsonb_array_elements(
|
|
106
|
+
CASE WHEN jsonb_typeof(sent.message->'content') = 'array' THEN sent.message->'content' ELSE '[]'::jsonb END
|
|
107
|
+
) WITH ORDINALITY AS content(part, ordinal)
|
|
108
|
+
WHERE part->>'type' = 'text' AND ltrim(part->>'text') LIKE '[Attached Cloud resource %'
|
|
109
|
+
GROUP BY recent_messages.id, recent_messages.conversation_id, sent.short_id, recent_messages.created_at
|
|
110
|
+
ORDER BY recent_messages.created_at DESC, recent_messages.id DESC
|
|
111
|
+
`;
|
|
112
|
+
|
|
68
113
|
type ConversationSearchBackend = "native" | "bm25";
|
|
69
114
|
let conversationSearchBackendPromise: Promise<ConversationSearchBackend> | null = null;
|
|
70
115
|
|
|
@@ -485,9 +530,9 @@ const parseJsonValue = <T>(value: unknown): T => {
|
|
|
485
530
|
return value as T;
|
|
486
531
|
};
|
|
487
532
|
|
|
488
|
-
const parseCapabilityActionReview = (value: unknown):
|
|
533
|
+
const parseCapabilityActionReview = (value: unknown): CapabilityActionReviewData | undefined => {
|
|
489
534
|
if (value === null || value === undefined) return undefined;
|
|
490
|
-
const parsed =
|
|
535
|
+
const parsed = CapabilityActionReviewDataSchema.safeParse(parseJsonValue(value));
|
|
491
536
|
return parsed.success ? parsed.data : undefined;
|
|
492
537
|
};
|
|
493
538
|
|
|
@@ -530,6 +575,13 @@ const conversationRunStatus = (status: AiTurnStatus | null | undefined, browserP
|
|
|
530
575
|
const todoMessageJson = () => sql`(CASE WHEN jsonb_typeof(message) = 'string' THEN (message #>> '{}')::jsonb ELSE message END)`;
|
|
531
576
|
const todoMetaJson = () => sql`(CASE WHEN jsonb_typeof(meta) = 'string' THEN (meta #>> '{}')::jsonb ELSE meta END)`;
|
|
532
577
|
|
|
578
|
+
/** What a copied message keeps of its meta: its todo plan, and its calls' reviews and checks, so a fork reads the same. */
|
|
579
|
+
const copiedMessageMetaJson = () => sql`NULLIF(jsonb_strip_nulls(jsonb_build_object(
|
|
580
|
+
'todoPlan', ${todoMetaJson()}->'todoPlan',
|
|
581
|
+
'toolReviews', ${todoMetaJson()}->'toolReviews',
|
|
582
|
+
'toolChecks', ${todoMetaJson()}->'toolChecks'
|
|
583
|
+
)), '{}'::jsonb)`;
|
|
584
|
+
|
|
533
585
|
// A scheduled result arriving during an interactive turn becomes context on the next turn.
|
|
534
586
|
const visibleToTurn = (turnId?: string) => sql`(
|
|
535
587
|
${turnId ?? null}::text IS NULL OR NOT COALESCE(meta ? 'scheduledTask', false) OR seq < ALL (
|
|
@@ -929,6 +981,114 @@ export const recordAiWebsiteReceipts = async (input: {
|
|
|
929
981
|
`;
|
|
930
982
|
};
|
|
931
983
|
|
|
984
|
+
/** Reviews and outcome checks belong to the assistant message containing the call, including scheduled turns. */
|
|
985
|
+
const recordAiToolMeta = async (
|
|
986
|
+
input: { conversationId: string; turnId: string; callId: string },
|
|
987
|
+
key: "toolReviews" | "toolChecks",
|
|
988
|
+
value: AiStoredActionReview | AiActionCheck,
|
|
989
|
+
db = sql,
|
|
990
|
+
): Promise<void> => {
|
|
991
|
+
let written = false;
|
|
992
|
+
for (const table of ["ai.messages", "ai.task_messages"]) {
|
|
993
|
+
const rows = await db<{ id: string }[]>`
|
|
994
|
+
UPDATE ${db(table)}
|
|
995
|
+
SET meta = COALESCE(meta, '{}'::jsonb) || jsonb_build_object(
|
|
996
|
+
${key}::text, COALESCE(meta->${key}::text, '{}'::jsonb) ||
|
|
997
|
+
jsonb_build_object(${input.callId}::text, ${JSON.stringify(value)}::text::jsonb)
|
|
998
|
+
)
|
|
999
|
+
WHERE conversation_id = ${input.conversationId}::uuid AND loop_id = ${input.turnId}::text
|
|
1000
|
+
AND role = 'assistant'
|
|
1001
|
+
AND message->'content' @> jsonb_build_array(jsonb_build_object('type', 'tool_call', 'id', ${input.callId}::text))
|
|
1002
|
+
RETURNING id`;
|
|
1003
|
+
written ||= rows.length > 0;
|
|
1004
|
+
}
|
|
1005
|
+
if (!written) throw new Error("AI tool call message is unavailable.");
|
|
1006
|
+
};
|
|
1007
|
+
|
|
1008
|
+
export const recordAiToolReview = (input: { conversationId: string; turnId: string; callId: string; review: AiStoredActionReview }) =>
|
|
1009
|
+
recordAiToolMeta(input, "toolReviews", input.review);
|
|
1010
|
+
|
|
1011
|
+
export const recordAiToolCheck = (input: { conversationId: string; turnId: string; callId: string; check: AiActionCheck }, db = sql) =>
|
|
1012
|
+
recordAiToolMeta(input, "toolChecks", input.check, db);
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* Writes one call's check only where `allowed` holds for its current state, in one statement per table, so two Core
|
|
1016
|
+
* replicas or the executor and a person's re-check can never both hold a check, and a settled result is never replaced.
|
|
1017
|
+
*/
|
|
1018
|
+
const writeAiToolCheckWhere = async (
|
|
1019
|
+
input: { conversationId: string; turnId: string; callId: string },
|
|
1020
|
+
check: AiActionCheck,
|
|
1021
|
+
allowed: (current: SQL.Query<unknown>) => SQL.Query<unknown>,
|
|
1022
|
+
): Promise<boolean> => {
|
|
1023
|
+
let written = false;
|
|
1024
|
+
for (const table of ["ai.messages", "ai.task_messages"]) {
|
|
1025
|
+
const current = sql`meta->'toolChecks'->${input.callId}::text`;
|
|
1026
|
+
const rows = await sql<{ id: string }[]>`
|
|
1027
|
+
UPDATE ${sql(table)}
|
|
1028
|
+
SET meta = COALESCE(meta, '{}'::jsonb) || jsonb_build_object(
|
|
1029
|
+
'toolChecks', COALESCE(meta->'toolChecks', '{}'::jsonb) ||
|
|
1030
|
+
jsonb_build_object(${input.callId}::text, ${JSON.stringify(check)}::text::jsonb)
|
|
1031
|
+
)
|
|
1032
|
+
WHERE conversation_id = ${input.conversationId}::uuid AND loop_id = ${input.turnId}::text
|
|
1033
|
+
AND role = 'assistant'
|
|
1034
|
+
AND message->'content' @> jsonb_build_array(jsonb_build_object('type', 'tool_call', 'id', ${input.callId}::text))
|
|
1035
|
+
AND (${allowed(current)})
|
|
1036
|
+
RETURNING id`;
|
|
1037
|
+
written ||= rows.length > 0;
|
|
1038
|
+
}
|
|
1039
|
+
return written;
|
|
1040
|
+
};
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* Claims the check of one call: stores `checking` at `at`, which is the claim's token, when no check holds and none
|
|
1044
|
+
* settled. A call without any check yet may be claimed only with `first`, which the executor uses after an unknown
|
|
1045
|
+
* outcome. A `checking` older than `staleBefore` belonged to a worker or request that stopped and may be taken over.
|
|
1046
|
+
*/
|
|
1047
|
+
export const claimAiToolCheck = (input: {
|
|
1048
|
+
conversationId: string;
|
|
1049
|
+
turnId: string;
|
|
1050
|
+
callId: string;
|
|
1051
|
+
at: string;
|
|
1052
|
+
staleBefore: string;
|
|
1053
|
+
first?: boolean;
|
|
1054
|
+
}): Promise<boolean> =>
|
|
1055
|
+
writeAiToolCheckWhere(
|
|
1056
|
+
input,
|
|
1057
|
+
{ state: "checking", at: input.at },
|
|
1058
|
+
(current) => sql`
|
|
1059
|
+
${input.first ? sql`${current} IS NULL OR` : sql``}
|
|
1060
|
+
${current}->>'state' IN ('pending', 'failed')
|
|
1061
|
+
OR (${current}->>'state' = 'checking' AND COALESCE(${current}->>'at', '') < ${input.staleBefore}::text)`,
|
|
1062
|
+
);
|
|
1063
|
+
|
|
1064
|
+
/** Stores the result of the claim made at `claimedAt`, only while that claim still holds. */
|
|
1065
|
+
export const settleAiToolCheck = (input: {
|
|
1066
|
+
conversationId: string;
|
|
1067
|
+
turnId: string;
|
|
1068
|
+
callId: string;
|
|
1069
|
+
claimedAt: string;
|
|
1070
|
+
check: AiActionCheck;
|
|
1071
|
+
}): Promise<boolean> =>
|
|
1072
|
+
writeAiToolCheckWhere(
|
|
1073
|
+
input,
|
|
1074
|
+
input.check,
|
|
1075
|
+
(current) => sql`${current}->>'state' = 'checking' AND ${current}->>'at' = ${input.claimedAt}::text`,
|
|
1076
|
+
);
|
|
1077
|
+
|
|
1078
|
+
/** The stored check of one call, or null. */
|
|
1079
|
+
export const readAiToolCheck = async (input: { conversationId: string; turnId: string; callId: string }): Promise<AiActionCheck | null> => {
|
|
1080
|
+
for (const table of ["ai.messages", "ai.task_messages"]) {
|
|
1081
|
+
const [row] = await sql<{ check: AiActionCheck | null }[]>`
|
|
1082
|
+
SELECT meta->'toolChecks'->${input.callId}::text AS check FROM ${sql(table)}
|
|
1083
|
+
WHERE conversation_id = ${input.conversationId}::uuid AND loop_id = ${input.turnId}::text
|
|
1084
|
+
AND role = 'assistant'
|
|
1085
|
+
AND message->'content' @> jsonb_build_array(jsonb_build_object('type', 'tool_call', 'id', ${input.callId}::text))
|
|
1086
|
+
LIMIT 1`;
|
|
1087
|
+
if (row?.check) return row.check;
|
|
1088
|
+
}
|
|
1089
|
+
return null;
|
|
1090
|
+
};
|
|
1091
|
+
|
|
932
1092
|
/** Insert a message inside an open conversation-lock transaction and bump the conversation. */
|
|
933
1093
|
const insertMessageLocked = async (
|
|
934
1094
|
input: {
|
|
@@ -1219,15 +1379,17 @@ export const aiConversations: AiConversationService = {
|
|
|
1219
1379
|
);
|
|
1220
1380
|
const target = rows[0];
|
|
1221
1381
|
if (!target) throw new Error("Failed to fork conversation.");
|
|
1382
|
+
// Copies keep their original send time because Recent orders by it.
|
|
1222
1383
|
await tx`
|
|
1223
1384
|
INSERT INTO ai.messages (
|
|
1224
1385
|
short_id, conversation_id, seq, kind, role, message, search_text, loop_id,
|
|
1225
|
-
model_profile_id, provider_model, usage, stop_reason, loop_aggregate, loop_done_reason, meta
|
|
1386
|
+
model_profile_id, provider_model, usage, stop_reason, loop_aggregate, loop_done_reason, meta, created_at
|
|
1226
1387
|
)
|
|
1227
1388
|
SELECT
|
|
1228
1389
|
short_id, ${target.id}::uuid, seq, kind, role, message, search_text, loop_id,
|
|
1229
1390
|
model_profile_id, provider_model, usage, stop_reason, loop_aggregate, loop_done_reason,
|
|
1230
|
-
|
|
1391
|
+
${copiedMessageMetaJson()},
|
|
1392
|
+
created_at
|
|
1231
1393
|
FROM ai.messages
|
|
1232
1394
|
WHERE conversation_id = ${input.sourceConversationId}::uuid
|
|
1233
1395
|
AND compacted_at IS NULL
|
|
@@ -1563,6 +1725,18 @@ export const aiConversations: AiConversationService = {
|
|
|
1563
1725
|
};
|
|
1564
1726
|
},
|
|
1565
1727
|
|
|
1728
|
+
listRecentSentResourceRefs: async ({ ownerUserId }) => {
|
|
1729
|
+
const rows = await recentSentResourceMarkersQuery(ownerUserId);
|
|
1730
|
+
return collectRecentSentResourceRefs(
|
|
1731
|
+
rows.map((row) => ({
|
|
1732
|
+
conversationId: row.conversation_id,
|
|
1733
|
+
messageId: row.short_id,
|
|
1734
|
+
lastSentAt: iso(row.created_at),
|
|
1735
|
+
markers: row.markers,
|
|
1736
|
+
})),
|
|
1737
|
+
);
|
|
1738
|
+
},
|
|
1739
|
+
|
|
1566
1740
|
listUserConversationResources: async (input) => {
|
|
1567
1741
|
const limit = Math.min(Math.max(Math.floor(input.limit ?? 20), 1), 100);
|
|
1568
1742
|
const pattern = searchPattern(input.search);
|
|
@@ -2507,6 +2681,7 @@ export const aiConversations: AiConversationService = {
|
|
|
2507
2681
|
|
|
2508
2682
|
await sql.begin(async (tx) => {
|
|
2509
2683
|
await tx`SELECT id FROM ai.conversations WHERE id = ${input.targetConversationId} FOR UPDATE`;
|
|
2684
|
+
// Copies keep their original send time because Recent orders by it.
|
|
2510
2685
|
await tx`
|
|
2511
2686
|
INSERT INTO ai.messages (
|
|
2512
2687
|
short_id,
|
|
@@ -2523,7 +2698,8 @@ export const aiConversations: AiConversationService = {
|
|
|
2523
2698
|
stop_reason,
|
|
2524
2699
|
loop_aggregate,
|
|
2525
2700
|
loop_done_reason,
|
|
2526
|
-
meta
|
|
2701
|
+
meta,
|
|
2702
|
+
created_at
|
|
2527
2703
|
)
|
|
2528
2704
|
SELECT
|
|
2529
2705
|
short_id,
|
|
@@ -2540,7 +2716,8 @@ export const aiConversations: AiConversationService = {
|
|
|
2540
2716
|
stop_reason,
|
|
2541
2717
|
loop_aggregate,
|
|
2542
2718
|
loop_done_reason,
|
|
2543
|
-
|
|
2719
|
+
${copiedMessageMetaJson()},
|
|
2720
|
+
created_at
|
|
2544
2721
|
FROM ai.messages
|
|
2545
2722
|
WHERE conversation_id = ${input.sourceConversationId}
|
|
2546
2723
|
AND compacted_at IS NULL
|
|
@@ -2862,10 +3039,33 @@ export const aiConversations: AiConversationService = {
|
|
|
2862
3039
|
`;
|
|
2863
3040
|
if (!rows[0]) return null;
|
|
2864
3041
|
// The waits let a reconnecting client show work time without time spent waiting for the user.
|
|
2865
|
-
const waits = await
|
|
3042
|
+
const [waits, metadata] = await Promise.all([
|
|
3043
|
+
activeTurnWaits(rows[0].id),
|
|
3044
|
+
sql<{ tool_reviews: Record<string, AiStoredActionReview> | null; tool_checks: Record<string, AiActionCheck> | null }[]>`
|
|
3045
|
+
SELECT meta->'toolReviews' AS tool_reviews, meta->'toolChecks' AS tool_checks
|
|
3046
|
+
FROM (
|
|
3047
|
+
SELECT meta, seq, conversation_id, loop_id, role FROM ai.messages
|
|
3048
|
+
UNION ALL
|
|
3049
|
+
SELECT meta, seq, conversation_id, loop_id, role FROM ai.task_messages
|
|
3050
|
+
) messages
|
|
3051
|
+
WHERE conversation_id = ${input.conversationId}
|
|
3052
|
+
AND loop_id = ${rows[0].id}
|
|
3053
|
+
AND role = 'assistant'
|
|
3054
|
+
AND (meta ? 'toolReviews' OR meta ? 'toolChecks')
|
|
3055
|
+
ORDER BY seq ASC
|
|
3056
|
+
`,
|
|
3057
|
+
]);
|
|
3058
|
+
// A person may re-check while the model continues. Durable checks outrank the worker's earlier live snapshot.
|
|
3059
|
+
const reviews = new Map(metadata.flatMap((row) => Object.entries(row.tool_reviews ?? {})));
|
|
3060
|
+
const checks = new Map(metadata.flatMap((row) => Object.entries(row.tool_checks ?? {})));
|
|
2866
3061
|
return {
|
|
2867
3062
|
turn: rowToTurn(rows[0]),
|
|
2868
|
-
liveBlocks: rowToLiveBlocks(rows[0])
|
|
3063
|
+
liveBlocks: rowToLiveBlocks(rows[0]).map((block) => {
|
|
3064
|
+
if (block.kind !== "tool") return block;
|
|
3065
|
+
const review = reviews.get(block.callId);
|
|
3066
|
+
const check = checks.get(block.callId);
|
|
3067
|
+
return { ...block, ...(review ? { review } : {}), ...(check ? { check } : {}) };
|
|
3068
|
+
}),
|
|
2869
3069
|
liveSeq: Number(rows[0].live_seq ?? 0),
|
|
2870
3070
|
actionWaitMs: Math.max(0, Math.round(waits.actionWaitMs)),
|
|
2871
3071
|
waitingSince: waits.waitingSince,
|
|
@@ -3407,6 +3607,24 @@ export const aiConversations: AiConversationService = {
|
|
|
3407
3607
|
return row ? rowToPendingActionRecord(row) : null;
|
|
3408
3608
|
},
|
|
3409
3609
|
|
|
3610
|
+
listTurnToolReviews: async (input) => {
|
|
3611
|
+
// Only the review metadata, never the messages themselves, which can carry large tool results.
|
|
3612
|
+
const rows = await sql<{ tool_reviews: Record<string, AiStoredActionReview> | null }[]>`
|
|
3613
|
+
SELECT meta->'toolReviews' AS tool_reviews
|
|
3614
|
+
FROM (
|
|
3615
|
+
SELECT meta, seq, conversation_id, loop_id, role FROM ai.messages
|
|
3616
|
+
UNION ALL
|
|
3617
|
+
SELECT meta, seq, conversation_id, loop_id, role FROM ai.task_messages
|
|
3618
|
+
) messages
|
|
3619
|
+
WHERE conversation_id = ${input.conversationId}
|
|
3620
|
+
AND loop_id = ${input.turnId}
|
|
3621
|
+
AND role = 'assistant'
|
|
3622
|
+
AND meta ? 'toolReviews'
|
|
3623
|
+
ORDER BY seq ASC
|
|
3624
|
+
`;
|
|
3625
|
+
return new Map(rows.flatMap((row) => Object.entries(row.tool_reviews ?? {})));
|
|
3626
|
+
},
|
|
3627
|
+
|
|
3410
3628
|
listPendingActionRecords: async (input) => {
|
|
3411
3629
|
const rows = await sql<PendingActionRow[]>`
|
|
3412
3630
|
SELECT *
|
|
@@ -3578,7 +3796,7 @@ export const aiConversations: AiConversationService = {
|
|
|
3578
3796
|
if (input.background && input.turnId) {
|
|
3579
3797
|
const own = await aiConversations.listTurnMessages({ conversationId: input.conversationId, loopId: input.turnId });
|
|
3580
3798
|
const context = input.background.context;
|
|
3581
|
-
return [...context, ...own.map((row) => ({ seq: context.length + row.seq
|
|
3799
|
+
return [...context, ...projectSettledAiToolChecks(own.map((row) => ({ ...row, seq: context.length + row.seq })))];
|
|
3582
3800
|
}
|
|
3583
3801
|
// Results delivered after this turn started belong to the next turn's context.
|
|
3584
3802
|
// Compare durable message sequences rather than wall-clock timestamps.
|
|
@@ -3592,20 +3810,23 @@ export const aiConversations: AiConversationService = {
|
|
|
3592
3810
|
`
|
|
3593
3811
|
).map(rowToMessage)
|
|
3594
3812
|
: await aiConversations.listContextMessages({ conversationId: input.conversationId });
|
|
3595
|
-
return
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3813
|
+
return projectSettledAiToolChecks(
|
|
3814
|
+
rows.map((row) => ({
|
|
3815
|
+
seq: row.seq,
|
|
3816
|
+
kind: row.kind,
|
|
3817
|
+
meta: row.meta,
|
|
3818
|
+
message:
|
|
3819
|
+
input.turnInput !== undefined && input.turnId === row.loopId && row.message.role === "user" && !row.meta?.steerId
|
|
3820
|
+
? {
|
|
3821
|
+
role: "user" as const,
|
|
3822
|
+
content:
|
|
3823
|
+
typeof input.turnInput === "string"
|
|
3824
|
+
? [{ type: "text" as const, text: input.turnInput }]
|
|
3825
|
+
: input.turnInput.map((part) => (typeof part === "string" ? { type: "text" as const, text: part } : part)),
|
|
3826
|
+
}
|
|
3827
|
+
: row.message,
|
|
3828
|
+
})),
|
|
3829
|
+
);
|
|
3609
3830
|
},
|
|
3610
3831
|
append: async (message, opts) => {
|
|
3611
3832
|
// Initial input and durable steering are already persisted transactionally before Nessi appends them.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { actionFixtures } from "./chat/action-question.fixtures";
|
|
2
|
+
import { type AiOpenQuestion, openQuestionFromRow } from "./open-questions";
|
|
3
|
+
import type { AiTurnBlock } from "./protocol";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Demo Assistant questions for host tests and screenshots, in German and English, with fixed times. The demo data may
|
|
7
|
+
* change in any release: assert on what your host renders from it, not on its wording.
|
|
8
|
+
*/
|
|
9
|
+
export const aiQuestionFixtures = (locale: "de" | "en"): Record<string, { block: Extract<AiTurnBlock, { kind: "tool" }> }> =>
|
|
10
|
+
actionFixtures(locale);
|
|
11
|
+
|
|
12
|
+
/** A waiting question as `listAiOpenQuestions` presents it from the chat's tool block. */
|
|
13
|
+
export const aiOpenQuestionFixture = (
|
|
14
|
+
input: {
|
|
15
|
+
id: string;
|
|
16
|
+
conversationId: string;
|
|
17
|
+
conversationTitle: string | null;
|
|
18
|
+
createdAt: string;
|
|
19
|
+
block: Extract<AiTurnBlock, { kind: "tool" }>;
|
|
20
|
+
},
|
|
21
|
+
context: { locale: string; timeZone: string },
|
|
22
|
+
): AiOpenQuestion =>
|
|
23
|
+
openQuestionFromRow(
|
|
24
|
+
{
|
|
25
|
+
id: input.id,
|
|
26
|
+
conversationId: input.conversationId,
|
|
27
|
+
conversationTitle: input.conversationTitle,
|
|
28
|
+
displayCallId: input.block.callId,
|
|
29
|
+
appId: input.block.presentation?.kind === "capability" ? input.block.presentation.appId : "core",
|
|
30
|
+
sentence: "",
|
|
31
|
+
toolName: input.block.name,
|
|
32
|
+
createdAt: new Date(input.createdAt),
|
|
33
|
+
block: input.block,
|
|
34
|
+
},
|
|
35
|
+
context,
|
|
36
|
+
);
|
package/src/ai/tool-audit.ts
CHANGED
|
@@ -30,6 +30,14 @@ export const aiToolAudit = {
|
|
|
30
30
|
LIMIT 1`;
|
|
31
31
|
return row?.conversation_id ?? null;
|
|
32
32
|
},
|
|
33
|
+
hasCapabilityDispatch: async (input: { turnId: string; callId: string }): Promise<boolean> => {
|
|
34
|
+
const rows = await sql`
|
|
35
|
+
SELECT 1 FROM ai.tool_calls
|
|
36
|
+
WHERE turn_id = ${input.turnId} AND call_id = ${input.callId} AND request_id IS NOT NULL
|
|
37
|
+
LIMIT 1
|
|
38
|
+
`;
|
|
39
|
+
return rows.length > 0;
|
|
40
|
+
},
|
|
33
41
|
noteCapabilityDispatch: async (input: {
|
|
34
42
|
conversationId: string;
|
|
35
43
|
turnId: string;
|
package/src/ai/tool-sentences.ts
CHANGED
|
@@ -1,38 +1,156 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { actionReview } from "../capabilities/reviews";
|
|
2
|
+
import type { CapabilityActionCallReview } from "../contracts/capabilities";
|
|
3
|
+
import { CodeActionInput } from "./browser-code-contracts";
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
5
|
+
// Like the catalog these forms replace, code runs name no "not run" form: code asks about what it does inside the run,
|
|
6
|
+
// so a declined or stopped question does not mean the code did not run. No tool names a "rejected" form, so a declined
|
|
7
|
+
// call keeps Cloud's own words.
|
|
8
|
+
const forms = {
|
|
9
|
+
en: {
|
|
10
|
+
code_run: {
|
|
11
|
+
action: "Run code",
|
|
12
|
+
running: "Running code",
|
|
13
|
+
done: "Ran code",
|
|
14
|
+
confirm: "Run code",
|
|
15
|
+
decline: "Do not run",
|
|
16
|
+
},
|
|
17
|
+
code_action: {
|
|
18
|
+
action: "Run app action {action}",
|
|
19
|
+
running: "Running app action {action}",
|
|
20
|
+
done: "Ran app action {action}",
|
|
21
|
+
confirm: "Run action",
|
|
22
|
+
decline: "Do not run",
|
|
23
|
+
},
|
|
24
|
+
web_extract: {
|
|
25
|
+
action: "Read page on {site}",
|
|
26
|
+
running: "Reading page on {site}",
|
|
27
|
+
done: "Read page on {site}",
|
|
28
|
+
confirm: "Read page",
|
|
29
|
+
decline: "Do not read",
|
|
30
|
+
notRun: "Page on {site} not read",
|
|
31
|
+
},
|
|
32
|
+
fetch_file: {
|
|
33
|
+
action: "Download file from {site}",
|
|
34
|
+
running: "Downloading file from {site}",
|
|
35
|
+
done: "Downloaded file from {site}",
|
|
36
|
+
confirm: "Download file",
|
|
37
|
+
decline: "Do not download",
|
|
38
|
+
notRun: "File from {site} not downloaded",
|
|
39
|
+
},
|
|
40
|
+
write_file: {
|
|
41
|
+
action: "Write {file}",
|
|
42
|
+
running: "Writing {file}",
|
|
43
|
+
done: "Wrote {file}",
|
|
44
|
+
confirm: "Write file",
|
|
45
|
+
decline: "Do not write",
|
|
46
|
+
notRun: "{file} not written",
|
|
47
|
+
},
|
|
18
48
|
},
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
},
|
|
49
|
+
de: {
|
|
50
|
+
code_run: {
|
|
51
|
+
action: "Code ausführen",
|
|
52
|
+
running: "Code wird ausgeführt",
|
|
53
|
+
done: "Code ausgeführt",
|
|
54
|
+
confirm: "Code ausführen",
|
|
55
|
+
decline: "Nicht ausführen",
|
|
56
|
+
},
|
|
57
|
+
code_action: {
|
|
58
|
+
action: "App-Aktion {action} ausführen",
|
|
59
|
+
running: "App-Aktion {action} wird ausgeführt",
|
|
60
|
+
done: "App-Aktion {action} ausgeführt",
|
|
61
|
+
confirm: "Aktion ausführen",
|
|
62
|
+
decline: "Nicht ausführen",
|
|
63
|
+
},
|
|
64
|
+
web_extract: {
|
|
65
|
+
action: "Seite auf {site} lesen",
|
|
66
|
+
running: "Seite auf {site} wird gelesen",
|
|
67
|
+
done: "Seite auf {site} gelesen",
|
|
68
|
+
confirm: "Seite lesen",
|
|
69
|
+
decline: "Nicht lesen",
|
|
70
|
+
notRun: "Seite auf {site} nicht gelesen",
|
|
71
|
+
},
|
|
72
|
+
fetch_file: {
|
|
73
|
+
action: "Datei von {site} herunterladen",
|
|
74
|
+
running: "Datei von {site} wird heruntergeladen",
|
|
75
|
+
done: "Datei von {site} heruntergeladen",
|
|
76
|
+
confirm: "Datei herunterladen",
|
|
77
|
+
decline: "Nicht herunterladen",
|
|
78
|
+
notRun: "Datei von {site} nicht heruntergeladen",
|
|
79
|
+
},
|
|
80
|
+
write_file: {
|
|
81
|
+
action: "{file} schreiben",
|
|
82
|
+
running: "{file} wird geschrieben",
|
|
83
|
+
done: "{file} geschrieben",
|
|
84
|
+
confirm: "Datei schreiben",
|
|
85
|
+
decline: "Nicht schreiben",
|
|
86
|
+
notRun: "{file} nicht geschrieben",
|
|
36
87
|
},
|
|
37
88
|
},
|
|
38
|
-
} as const
|
|
89
|
+
} as const;
|
|
90
|
+
|
|
91
|
+
/** The bound of a `fields` preview value. */
|
|
92
|
+
const ADDRESS_MAX = 2000;
|
|
93
|
+
const readableAddress = (address: string): string | null => {
|
|
94
|
+
try {
|
|
95
|
+
return decodeURI(address);
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
102
|
+
|
|
103
|
+
/** Cloud tools use the same per-call review contract as application Actions. Safe for historical, incomplete inputs. */
|
|
104
|
+
export const cloudToolReview = (name: string, args: unknown, locale: string): CapabilityActionCallReview | null => {
|
|
105
|
+
if (!isRecord(args)) return null;
|
|
106
|
+
const language = /^de(?:-|$)/i.test(locale) ? "de" : "en";
|
|
107
|
+
const input = args;
|
|
108
|
+
const sentence = forms[language];
|
|
109
|
+
try {
|
|
110
|
+
if (name === "code_run") {
|
|
111
|
+
return actionReview({ locale: language, sentence: sentence.code_run }, {});
|
|
112
|
+
}
|
|
113
|
+
if (name === "code_action") {
|
|
114
|
+
const parsed = CodeActionInput.shape.action.safeParse(input.action);
|
|
115
|
+
if (!parsed.success) return null;
|
|
116
|
+
return actionReview({ locale: language, sentence: sentence.code_action }, { action: { kind: "text", value: parsed.data } });
|
|
117
|
+
}
|
|
118
|
+
if (name === "web_extract" || name === "fetch_file") {
|
|
119
|
+
if (typeof input.url !== "string" || !input.url.trim()) return null;
|
|
120
|
+
const href = input.url;
|
|
121
|
+
const url = new URL(href);
|
|
122
|
+
if (!url.host) return null;
|
|
123
|
+
const label = language === "de" ? "Adresse" : "Address";
|
|
124
|
+
const review = (rows: { label: string; value: string }[]) =>
|
|
125
|
+
actionReview(
|
|
126
|
+
{ locale: language, sentence: sentence[name] },
|
|
127
|
+
// A place title holds 200 characters; the address rows keep the whole host.
|
|
128
|
+
{ site: { kind: "place", title: url.host.length > 200 ? `${url.host.slice(0, 99)}…${url.host.slice(-100)}` : url.host } },
|
|
129
|
+
{ preview: { kind: "fields", rows } },
|
|
130
|
+
);
|
|
131
|
+
// One address row holds at most 2,000 characters. An address whose encoded form is longer shows as written;
|
|
132
|
+
// if even that does not fit, the encoded address continues over numbered rows, so every character stays visible.
|
|
133
|
+
for (const address of [href, readableAddress(href)])
|
|
134
|
+
if (address && address.length <= ADDRESS_MAX) {
|
|
135
|
+
try {
|
|
136
|
+
return review([{ label, value: address }]);
|
|
137
|
+
} catch {
|
|
138
|
+
// Try the next form.
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const parts = Array.from({ length: Math.ceil(href.length / ADDRESS_MAX) }, (_, index) =>
|
|
142
|
+
href.slice(index * ADDRESS_MAX, (index + 1) * ADDRESS_MAX),
|
|
143
|
+
);
|
|
144
|
+
return review(parts.map((value, index) => ({ label: `${label} ${index + 1}/${parts.length}`, value })));
|
|
145
|
+
}
|
|
146
|
+
if (name === "write_file") {
|
|
147
|
+
if (typeof input.path !== "string" || !input.path.trim()) return null;
|
|
148
|
+
const file = input.path.trim().split("/").at(-1);
|
|
149
|
+
if (!file) return null;
|
|
150
|
+
return actionReview({ locale: language, sentence: sentence.write_file }, { file: { kind: "object", title: file.slice(0, 200) } });
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
// Historical calls can predate the current bounds or carry partial input; they keep their generic tool title.
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
};
|