@k2b/cloud 0.38.0 → 0.40.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 +12 -5
- package/scripts/build-canvas-workers.ts +109 -29
- package/scripts/build.ts +3 -5
- package/src/_internal/app-favicon.ts +4 -2
- package/src/_internal/app-path.ts +25 -0
- package/src/_internal/app-presentation.ts +4 -0
- package/src/_internal/canvas-worker.ts +5 -3
- 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 +216 -1
- package/src/_internal/capability-streams.ts +7 -0
- package/src/_internal/define-app.ts +63 -16
- package/src/_internal/font-preloads.ts +2 -1
- package/src/_internal/panel-cookie.ts +39 -0
- package/src/_internal/registry-validation.ts +21 -26
- package/src/_internal/runtime-context.ts +1 -0
- package/src/_internal/runtime-watcher.ts +6 -0
- package/src/_internal/static-assets.ts +52 -3
- package/src/_internal/status-preserving-ssr.ts +2 -0
- package/src/access/PermissionEditor.browser-harness.tsx +57 -38
- package/src/access/PermissionEditor.tsx +32 -7
- package/src/access/ResourceApiKeys.tsx +5 -1
- package/src/account/EntitySearch.tsx +1 -1
- package/src/ai/action-review.ts +101 -0
- package/src/ai/approval-routes.ts +5 -2
- package/src/ai/approvals.ts +139 -28
- package/src/ai/background-review.ts +72 -0
- package/src/ai/browser.ts +10 -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 +398 -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 +669 -0
- package/src/ai/chat/action-sentences.ts +1 -44
- package/src/ai/chat/blocks.tsx +19 -25
- package/src/ai/chat/message-actions.tsx +6 -0
- package/src/ai/chat/tool-action.tsx +481 -0
- package/src/ai/chat/turn-view.tsx +45 -163
- package/src/ai/chat/view-block.tsx +22 -7
- package/src/ai/chat-task-contracts.ts +31 -3
- package/src/ai/chat-task-reasons-migrate.ts +75 -0
- package/src/ai/chat-task-reasons.ts +92 -0
- package/src/ai/chat-tasks.ts +438 -78
- package/src/ai/client/controller.ts +59 -0
- package/src/ai/default-tools.ts +9 -1
- package/src/ai/executor.ts +190 -39
- package/src/ai/index.ts +14 -0
- package/src/ai/migrate.ts +39 -4
- package/src/ai/projects-routes.ts +3 -0
- package/src/ai/protocol.ts +14 -2
- package/src/ai/recent-resources.ts +240 -0
- package/src/ai/recent-sent-refs.ts +38 -0
- package/src/ai/request-context.ts +7 -1
- package/src/ai/routes.ts +172 -2
- package/src/ai/runtime.ts +13 -27
- package/src/ai/skills-routes.ts +3 -0
- package/src/ai/skills.ts +10 -1
- package/src/ai/store.ts +232 -27
- package/src/ai/timeline.ts +9 -1
- package/src/ai/tool-audit.ts +8 -0
- package/src/ai/tool-wording.ts +67 -0
- package/src/ai/turn-abort.ts +33 -0
- package/src/ai/types.ts +40 -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-core-settings.ts +190 -139
- 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 +6 -2
- package/src/api/app-approval.ts +54 -6
- package/src/api/approvals-loader.ts +23 -1
- package/src/api/approvals.ts +3 -1
- package/src/api/auth.ts +25 -6
- 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 +7 -1
- package/src/api/lasting-access-messages.ts +17 -0
- package/src/api/mcp.ts +3 -0
- package/src/api/me-panel-consent.ts +71 -0
- package/src/api/me.ts +80 -33
- 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 +180 -238
- package/src/approvals/messages.ts +12 -24
- package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
- package/src/browser/CloudResourceSearch.tsx +10 -6
- package/src/browser/app-approval.ts +60 -6
- package/src/browser/commands.ts +22 -6
- package/src/browser/composer-catalog.ts +145 -0
- package/src/browser/composer-insert-messages.ts +31 -0
- package/src/browser/composer-insert.ts +160 -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 +35 -0
- package/src/browser/panel-shortcuts.ts +31 -0
- package/src/browser/panel-slot.ts +756 -0
- package/src/browser/panel.ts +65 -0
- package/src/browser/resource-picker.tsx +12 -1
- 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 +159 -12
- package/src/contracts/app.ts +14 -0
- 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/registry.ts +3 -1
- package/src/contracts/seen-hints.ts +35 -0
- package/src/contracts/shared.ts +2 -0
- package/src/server/client-ip.ts +5 -0
- package/src/server/help-payload.ts +38 -0
- package/src/server/middleware/auth.ts +11 -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/server/services/access.ts +5 -1
- package/src/services/accounts/posix.ts +2 -1
- package/src/services/app-approval.ts +533 -90
- package/src/services/approvals/device.ts +9 -5
- package/src/services/approvals/index.ts +6 -5
- package/src/services/approvals/list.ts +26 -5
- package/src/services/auth-flows/notification-sender.ts +3 -0
- 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 +32 -3
- 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/browser.ts +16 -5
- 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/image-protocol.ts +135 -0
- package/src/services/pdf/image-variants.ts +124 -0
- package/src/services/pdf/image-worker.ts +31 -0
- package/src/services/pdf/image.ts +109 -0
- package/src/services/pdf/index.ts +9 -0
- package/src/services/service-account-credentials.ts +21 -0
- package/src/services/session/index.ts +97 -16
- package/src/services/session/recent.ts +13 -0
- package/src/services/settings/api.ts +11 -6
- package/src/services/settings/app.ts +5 -4
- package/src/services/settings/core-settings.ts +4 -3
- package/src/services/settings/index.ts +19 -17
- package/src/services/settings/namespace.ts +2 -1
- package/src/services/settings/store.ts +83 -11
- package/src/services/webauthn.ts +12 -0
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/app-presentation.ts +1 -0
- package/src/shared/email-html.ts +2 -0
- package/src/shared/help.ts +3 -0
- package/src/shared/redirect.ts +12 -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 +95 -10
- package/src/ssr/Layout.tsx +66 -10
- package/src/ssr/LayoutHeader.tsx +15 -9
- package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
- package/src/ssr/LayoutHelp.tsx +435 -274
- package/src/ssr/LayoutHelpPanel.browser-harness.tsx +136 -0
- package/src/ssr/LayoutHelpTrigger.tsx +27 -2
- package/src/ssr/LayoutRail.tsx +2 -2
- package/src/ssr/LayoutTabBar.tsx +29 -0
- package/src/ssr/MobileNavigation.tsx +2 -2
- package/src/ssr/PanelLayout.tsx +35 -0
- package/src/ssr/PanelRuntime.island.tsx +94 -0
- package/src/ssr/PanelSlot.tsx +158 -0
- package/src/ssr/PanelTrigger.tsx +49 -0
- package/src/ssr/ShellRuntime.island.tsx +69 -5
- package/src/ssr/app-appearance.ts +3 -2
- package/src/ssr/app-color.ts +105 -0
- package/src/ssr/app-launchpad.tsx +18 -1
- package/src/ssr/app-navigation.ts +28 -0
- package/src/ssr/compact-shell.baseline.json +5270 -0
- package/src/ssr/compact-shell.browser-harness.ts +439 -0
- package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
- package/src/ssr/help-messages.ts +4 -0
- package/src/ssr/help.ts +20 -0
- package/src/ssr/hints.ts +25 -0
- package/src/ssr/index.ts +5 -0
- package/src/ssr/islands/index.ts +4 -3
- package/src/ssr/layout-context.ts +2 -0
- package/src/ssr/layout-help-article.server.ts +50 -0
- package/src/ssr/layout-help-start.ts +33 -7
- package/src/ssr/panel-consent.ts +29 -0
- package/src/ssr/panel-context.ts +10 -0
- package/src/ssr/panel-slot.browser-harness.tsx +159 -0
- package/src/ssr/platform-messages.ts +48 -0
- package/src/ssr/rail-navigation.ts +44 -0
- package/src/ssr/shell-data.tsx +5 -0
- package/src/ssr/shell-shortcuts.ts +13 -2
- package/src/styles/effects.css +201 -11
- package/src/styles/file-chooser.css +2 -1
- package/src/styles/global.css +314 -63
- package/src/styles/panel.css +416 -0
- package/src/styles/resource-search.css +6 -2
- package/src/styles/tokens.css +36 -13
- 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 +22 -43
package/src/ai/runtime.ts
CHANGED
|
@@ -8,7 +8,13 @@ import { type ApprovalDecisionChannel, approvals } from "../services/approvals";
|
|
|
8
8
|
import { logger } from "../services/logging";
|
|
9
9
|
import { superviseRuntimeTask } from "../services/runtime-lifecycle";
|
|
10
10
|
import { coreSettings } from "../services/settings/api";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
AI_WEBSITE_APPROVAL_TOOL,
|
|
13
|
+
type AiToolApprovalChangeContext,
|
|
14
|
+
type AiToolApprovalContext,
|
|
15
|
+
aiTurnAllowsRememberedApprovals,
|
|
16
|
+
rememberAiToolApproval,
|
|
17
|
+
} from "./approvals";
|
|
12
18
|
import { aiChatAccessSubject, selectAssistantAiModelId } from "./assistant-models";
|
|
13
19
|
import { startAiDictationRuntime } from "./dictation-runtime";
|
|
14
20
|
import { AiTurnExecutor, type ExecutorConfig } from "./executor";
|
|
@@ -20,8 +26,9 @@ import { aiQuotas } from "./quotas";
|
|
|
20
26
|
import { parseAiResourceMarker } from "./resource-markers";
|
|
21
27
|
import { isAiVisionModelConfigured } from "./settings";
|
|
22
28
|
import { aiConversations } from "./store";
|
|
23
|
-
import {
|
|
29
|
+
import { publishAiWireEvent } from "./stream";
|
|
24
30
|
import { aiToolAudit } from "./tool-audit";
|
|
31
|
+
import { finishAiTurnAbort } from "./turn-abort";
|
|
25
32
|
import type {
|
|
26
33
|
AiChatTurnRunConfig,
|
|
27
34
|
AiClientToolId,
|
|
@@ -40,6 +47,7 @@ import type {
|
|
|
40
47
|
import { isAiImageMediaType } from "./types";
|
|
41
48
|
import { validateAiTurnRequest } from "./validate";
|
|
42
49
|
|
|
50
|
+
export { createAiTurnCodeAndClientTools } from "./default-tools";
|
|
43
51
|
export type { ValidateAiTurnInput } from "./validate";
|
|
44
52
|
export { isAiSettingsError, validateAiTurnRequest } from "./validate";
|
|
45
53
|
|
|
@@ -273,32 +281,10 @@ export const submitAiCompaction = async (input: SubmitAiCompactionInput): Promis
|
|
|
273
281
|
// ---------------------------------------------------------------------------
|
|
274
282
|
|
|
275
283
|
export const abortAiTurn = async (input: { conversationId: string; turnId: string }): Promise<{ ok: true }> => {
|
|
276
|
-
// Capture
|
|
284
|
+
// Capture wire coordinates before the cancellation flag can wake its owner.
|
|
277
285
|
const active = await aiConversations.getActiveTurn({ conversationId: input.conversationId }).catch(() => null);
|
|
278
286
|
const request = await aiConversations.requestTurnAbort({ ...input, reason: "user" });
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
// Tell any live owner to stop (its heartbeat also detects the cancel flag).
|
|
282
|
-
await publishAiTurnAbort(input).catch(() => undefined);
|
|
283
|
-
|
|
284
|
-
if (request.ownerless) {
|
|
285
|
-
const finalized = await aiConversations.completeTurn({ ...input, status: "aborted", error: null });
|
|
286
|
-
const config = await aiConversations.getTurnRunConfig(input);
|
|
287
|
-
if (finalized === "completed" && !(config?.kind === "chat" && config.background)) {
|
|
288
|
-
const attempt = active?.turn.id === input.turnId ? active.turn.attempt : 1;
|
|
289
|
-
const seq = (active?.turn.id === input.turnId ? active.liveSeq : 0) + 1;
|
|
290
|
-
await publishAiWireEvent({
|
|
291
|
-
v: 1,
|
|
292
|
-
conversationId: input.conversationId,
|
|
293
|
-
turnId: input.turnId,
|
|
294
|
-
attempt,
|
|
295
|
-
seq,
|
|
296
|
-
type: "turn_finished",
|
|
297
|
-
status: "aborted",
|
|
298
|
-
error: null,
|
|
299
|
-
}).catch(() => undefined);
|
|
300
|
-
}
|
|
301
|
-
}
|
|
287
|
+
await finishAiTurnAbort(input, request, active);
|
|
302
288
|
return { ok: true };
|
|
303
289
|
};
|
|
304
290
|
|
|
@@ -331,7 +317,7 @@ export const submitAiTurnAction = async (input: {
|
|
|
331
317
|
callId: string;
|
|
332
318
|
action: AiTurnActionInput;
|
|
333
319
|
decidedBy: { userId: string; via: ApprovalDecisionChannel; deviceId?: string };
|
|
334
|
-
toolApprovalContext?:
|
|
320
|
+
toolApprovalContext?: AiToolApprovalChangeContext;
|
|
335
321
|
/** Whether a person decides in a signed-in browser session; only such a decision may remember a website. */
|
|
336
322
|
signedInSession?: boolean;
|
|
337
323
|
}): Promise<{ ok: true } | { ok: false; status: 400 | 403 | 404 | 409; message: string }> => {
|
package/src/ai/skills-routes.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { HTTPException } from "hono/http-exception";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { AuthenticatedPrincipalSchema } from "../contracts/shared";
|
|
5
5
|
import { type AuthContext, auth, err, fail, ok, rateLimit, respond, v } from "../server";
|
|
6
|
+
import { noTransform } from "../server/middleware/no-transform";
|
|
6
7
|
import { localizedAppName } from "../ssr/runtime";
|
|
7
8
|
import { aiProjects } from "./projects";
|
|
8
9
|
import {
|
|
@@ -63,6 +64,8 @@ type AiSkillsRouteDependencies = {
|
|
|
63
64
|
|
|
64
65
|
const buildAiSkillsRoutes = (dependencies: AiSkillsRouteDependencies = {}) =>
|
|
65
66
|
new Hono<AuthContext>()
|
|
67
|
+
// The list combines skills from separate grants; the writer of one can rename it next to another skill's text.
|
|
68
|
+
.use("*", noTransform())
|
|
66
69
|
.onError((error, c) => {
|
|
67
70
|
if (error instanceof AiSkillAppForbiddenError) return respond(c, fail(err.forbidden(error.message)));
|
|
68
71
|
if (error instanceof HTTPException) return error.getResponse();
|
package/src/ai/skills.ts
CHANGED
|
@@ -522,7 +522,15 @@ const skillSnapshotFiles = (skill: AiSkill): { path: string; content: string }[]
|
|
|
522
522
|
...skill.references.map((reference) => ({ path: `${skill.name}/${reference.path}`, content: reference.content })),
|
|
523
523
|
];
|
|
524
524
|
|
|
525
|
-
type SkillListOptions = {
|
|
525
|
+
type SkillListOptions = {
|
|
526
|
+
name?: string;
|
|
527
|
+
/** Only these Skills, by short ID, such as the ones a person recently sent. */
|
|
528
|
+
shortIds?: readonly string[];
|
|
529
|
+
query?: string;
|
|
530
|
+
enabledOnly?: boolean;
|
|
531
|
+
modelInvocableOnly?: boolean;
|
|
532
|
+
limit?: number;
|
|
533
|
+
};
|
|
526
534
|
async function listSkillSummaries(
|
|
527
535
|
subject: AccessSubject | null,
|
|
528
536
|
options: SkillListOptions & {
|
|
@@ -551,6 +559,7 @@ async function listSkillSummaries(
|
|
|
551
559
|
ON disabled.skill_id = skill.id AND disabled.user_id = ${userId}::uuid
|
|
552
560
|
WHERE access.permission <> 'none'
|
|
553
561
|
AND ${options.name === undefined ? sql`true` : sql`skill.name = ${options.name}`}
|
|
562
|
+
AND ${options.shortIds === undefined ? sql`true` : sql`skill.short_id = ANY(${toPgTextArray([...options.shortIds])}::text[])`}
|
|
554
563
|
AND (${!options.manageOnly} OR access.permission='admin')
|
|
555
564
|
AND ${options.project ? sql`EXISTS(SELECT 1 FROM ai.project_skills link WHERE link.skill_id=skill.id AND link.project_id=${options.project.id}::uuid) = ${options.project.linked}` : sql`true`}
|
|
556
565
|
AND (${!options.modelInvocableOnly} OR NOT manual_only)
|
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,
|
|
@@ -2992,12 +3192,14 @@ export const aiConversations: AiConversationService = {
|
|
|
2992
3192
|
return Boolean(rows[0]);
|
|
2993
3193
|
},
|
|
2994
3194
|
|
|
2995
|
-
requestTurnAbort: async (input) => {
|
|
3195
|
+
requestTurnAbort: async (input, options) => {
|
|
2996
3196
|
const reason = input.reason ?? "user";
|
|
2997
|
-
const rows = await sql<TurnRow[]>`
|
|
3197
|
+
const rows = await (options?.db ?? sql)<TurnRow[]>`
|
|
2998
3198
|
UPDATE ai.turns
|
|
2999
3199
|
SET cancel_requested_at = COALESCE(cancel_requested_at, now()),
|
|
3000
|
-
cancellation_reason =
|
|
3200
|
+
cancellation_reason = CASE WHEN ${reason} = 'routine_deleted' THEN ${reason}
|
|
3201
|
+
WHEN ${reason} = 'routine_paused' AND cancellation_reason IS DISTINCT FROM 'routine_deleted' THEN ${reason}
|
|
3202
|
+
ELSE COALESCE(cancellation_reason, ${reason}) END
|
|
3001
3203
|
WHERE id = ${input.turnId}
|
|
3002
3204
|
AND conversation_id = ${input.conversationId}
|
|
3003
3205
|
AND status IN ('queued', 'running', 'waiting_for_action')
|
|
@@ -3576,7 +3778,7 @@ export const aiConversations: AiConversationService = {
|
|
|
3576
3778
|
if (input.background && input.turnId) {
|
|
3577
3779
|
const own = await aiConversations.listTurnMessages({ conversationId: input.conversationId, loopId: input.turnId });
|
|
3578
3780
|
const context = input.background.context;
|
|
3579
|
-
return [...context, ...own.map((row) => ({ seq: context.length + row.seq
|
|
3781
|
+
return [...context, ...projectSettledAiToolChecks(own.map((row) => ({ ...row, seq: context.length + row.seq })))];
|
|
3580
3782
|
}
|
|
3581
3783
|
// Results delivered after this turn started belong to the next turn's context.
|
|
3582
3784
|
// Compare durable message sequences rather than wall-clock timestamps.
|
|
@@ -3590,20 +3792,23 @@ export const aiConversations: AiConversationService = {
|
|
|
3590
3792
|
`
|
|
3591
3793
|
).map(rowToMessage)
|
|
3592
3794
|
: await aiConversations.listContextMessages({ conversationId: input.conversationId });
|
|
3593
|
-
return
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3795
|
+
return projectSettledAiToolChecks(
|
|
3796
|
+
rows.map((row) => ({
|
|
3797
|
+
seq: row.seq,
|
|
3798
|
+
kind: row.kind,
|
|
3799
|
+
meta: row.meta,
|
|
3800
|
+
message:
|
|
3801
|
+
input.turnInput !== undefined && input.turnId === row.loopId && row.message.role === "user" && !row.meta?.steerId
|
|
3802
|
+
? {
|
|
3803
|
+
role: "user" as const,
|
|
3804
|
+
content:
|
|
3805
|
+
typeof input.turnInput === "string"
|
|
3806
|
+
? [{ type: "text" as const, text: input.turnInput }]
|
|
3807
|
+
: input.turnInput.map((part) => (typeof part === "string" ? { type: "text" as const, text: part } : part)),
|
|
3808
|
+
}
|
|
3809
|
+
: row.message,
|
|
3810
|
+
})),
|
|
3811
|
+
);
|
|
3607
3812
|
},
|
|
3608
3813
|
append: async (message, opts) => {
|
|
3609
3814
|
// Initial input and durable steering are already persisted transactionally before Nessi appends them.
|
package/src/ai/timeline.ts
CHANGED
|
@@ -36,6 +36,9 @@ const isAssistantPart = (entry: AiStoredMessage): boolean =>
|
|
|
36
36
|
entry.kind === "message" && (entry.message.role === "assistant" || entry.message.role === "tool_result");
|
|
37
37
|
|
|
38
38
|
const sameLoop = (entry: AiStoredMessage, loopId: string | null): boolean => (loopId ? entry.loopId === loopId : entry.loopId === null);
|
|
39
|
+
/** Each delivered routine run is its own answer, with its own run link and system line, also when runs follow each other. */
|
|
40
|
+
const sameRoutineRun = (entry: AiStoredMessage, first: AiStoredMessage): boolean =>
|
|
41
|
+
(entry.meta?.scheduledTask?.occurrenceId ?? null) === (first.meta?.scheduledTask?.occurrenceId ?? null);
|
|
39
42
|
|
|
40
43
|
const assistantActionEntry = (entries: AiStoredMessage[]): AiStoredMessage | null => {
|
|
41
44
|
const assistantEntries = entries.filter((entry) => entry.kind === "message" && entry.message.role === "assistant");
|
|
@@ -107,7 +110,12 @@ export const buildAiMessageTimeline = (messages: AiStoredMessage[]): AiMessageTi
|
|
|
107
110
|
pendingSteers = [];
|
|
108
111
|
const entries = [entry];
|
|
109
112
|
index += 1;
|
|
110
|
-
while (
|
|
113
|
+
while (
|
|
114
|
+
index < messages.length &&
|
|
115
|
+
isAssistantPart(messages[index]!) &&
|
|
116
|
+
sameLoop(messages[index]!, loopId) &&
|
|
117
|
+
(loopId !== null || sameRoutineRun(messages[index]!, entry))
|
|
118
|
+
) {
|
|
111
119
|
entries.push(messages[index]!);
|
|
112
120
|
index += 1;
|
|
113
121
|
}
|
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;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CapabilityActionWording,
|
|
3
|
+
capabilityActionOutcome,
|
|
4
|
+
capabilityActionSubject,
|
|
5
|
+
capabilityReviewSentence,
|
|
6
|
+
capabilitySentenceText,
|
|
7
|
+
resolveCapabilityActionSentences,
|
|
8
|
+
} from "../_internal/capability-sentences";
|
|
9
|
+
import { CapabilityActionReceiptSchema } from "../contracts/capabilities";
|
|
10
|
+
import { type AiStoredActionReview, isLegacyActionReview, storedActionReview } from "./action-review";
|
|
11
|
+
import { displayToolName, isRecord } from "./chat/message-utils";
|
|
12
|
+
import type { AiTurnBlock } from "./protocol";
|
|
13
|
+
import { CLOUD_AI_TOOL_PRESENTATION } from "./tool-sentences";
|
|
14
|
+
|
|
15
|
+
type ToolBlock = Extract<AiTurnBlock, { kind: "tool" }>;
|
|
16
|
+
|
|
17
|
+
/** The review Cloud stored for the call, else the one its open approval carries. */
|
|
18
|
+
const reviewOf = (block: ToolBlock): AiStoredActionReview | null =>
|
|
19
|
+
block.review ?? (block.approval?.review ? storedActionReview(block.approval.review) : null);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* How the chat words one call that acts on something: an app Action through its saved presentation, a
|
|
23
|
+
* Cloud tool through Cloud's own catalog of the same shape. `null` for calls that only read.
|
|
24
|
+
*/
|
|
25
|
+
export const toolWording = (block: ToolBlock, locale: string): CapabilityActionWording | null => {
|
|
26
|
+
const presentation = block.presentation;
|
|
27
|
+
if (presentation?.kind === "capability") {
|
|
28
|
+
if (presentation.capabilityKind !== "action") return null;
|
|
29
|
+
return {
|
|
30
|
+
title: presentation.title,
|
|
31
|
+
...(presentation.sentences ? { sentences: presentation.sentences } : {}),
|
|
32
|
+
...(presentation.fields ? { fields: presentation.fields } : {}),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const sentences = resolveCapabilityActionSentences(block.name, CLOUD_AI_TOOL_PRESENTATION, locale);
|
|
36
|
+
return sentences ? { title: displayToolName(block.name, locale), sentences } : null;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* What the call does or would do, as one sentence; `null` for calls that only read. A stored review's action sentence
|
|
41
|
+
* wins, so it reads the same after a reload and after the app changed. An old review's message is the question of the
|
|
42
|
+
* card, not the call's short subject: steps and receipts keep the app's catalog sentence.
|
|
43
|
+
*/
|
|
44
|
+
export const toolSubject = (block: ToolBlock, locale: string, timeZone: string): string | null => {
|
|
45
|
+
const review = reviewOf(block);
|
|
46
|
+
if (review && !isLegacyActionReview(review))
|
|
47
|
+
return capabilitySentenceText(review.sentence.action, review.slots, { locale: review.locale, timeZone });
|
|
48
|
+
const wording = toolWording(block, locale);
|
|
49
|
+
return wording ? capabilityActionSubject(wording, block.args, { locale, timeZone }) : null;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** The sentence for one outcome, or `null` when the tool has none that renders for this call. */
|
|
53
|
+
export const toolOutcome = (block: ToolBlock, key: "done" | "rejected" | "notRun", locale: string, timeZone: string): string | null => {
|
|
54
|
+
const result = isRecord(block.result) ? block.result : undefined;
|
|
55
|
+
const review = reviewOf(block);
|
|
56
|
+
if (review && !isLegacyActionReview(review)) {
|
|
57
|
+
const receipt = key === "done" ? CapabilityActionReceiptSchema.safeParse(result?.receipt) : null;
|
|
58
|
+
if (receipt?.success) return capabilitySentenceText(receipt.data.text, receipt.data.slots ?? {}, { locale: review.locale, timeZone });
|
|
59
|
+
return capabilityReviewSentence(review, key, { timeZone });
|
|
60
|
+
}
|
|
61
|
+
if (review && key === "done" && review.legacy.summary) return review.legacy.summary;
|
|
62
|
+
const wording = toolWording(block, locale);
|
|
63
|
+
if (!wording) return null;
|
|
64
|
+
// App Actions return their data inside the result envelope; Cloud tools return the data itself.
|
|
65
|
+
const data = block.presentation?.kind === "capability" ? result?.data : block.result;
|
|
66
|
+
return capabilityActionOutcome(wording, key, { input: block.args, data }, { locale, timeZone });
|
|
67
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { aiConversations } from "./store";
|
|
2
|
+
import { publishAiTurnAbort, publishAiWireEvent } from "./stream";
|
|
3
|
+
import type { AiTurnAbortRequest } from "./types";
|
|
4
|
+
|
|
5
|
+
/** Finish a committed cancellation request; shared by interactive and routine cancellation. */
|
|
6
|
+
export const finishAiTurnAbort = async (
|
|
7
|
+
input: { conversationId: string; turnId: string },
|
|
8
|
+
request: AiTurnAbortRequest,
|
|
9
|
+
active: Awaited<ReturnType<typeof aiConversations.getActiveTurn>> = null,
|
|
10
|
+
): Promise<void> => {
|
|
11
|
+
if (!request.found) return;
|
|
12
|
+
// Tell any live owner to stop (its heartbeat also detects the cancel flag).
|
|
13
|
+
await publishAiTurnAbort(input).catch(() => undefined);
|
|
14
|
+
|
|
15
|
+
if (request.ownerless) {
|
|
16
|
+
const finalized = await aiConversations.completeTurn({ ...input, status: "aborted", error: null });
|
|
17
|
+
const config = await aiConversations.getTurnRunConfig(input);
|
|
18
|
+
if (finalized === "completed" && !(config?.kind === "chat" && config.background)) {
|
|
19
|
+
const attempt = active?.turn.id === input.turnId ? active.turn.attempt : 1;
|
|
20
|
+
const seq = (active?.turn.id === input.turnId ? active.liveSeq : 0) + 1;
|
|
21
|
+
await publishAiWireEvent({
|
|
22
|
+
v: 1,
|
|
23
|
+
conversationId: input.conversationId,
|
|
24
|
+
turnId: input.turnId,
|
|
25
|
+
attempt,
|
|
26
|
+
seq,
|
|
27
|
+
type: "turn_finished",
|
|
28
|
+
status: "aborted",
|
|
29
|
+
error: null,
|
|
30
|
+
}).catch(() => undefined);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
};
|