@vellumai/assistant 0.12.4-dev.202609231914.158603f → 0.12.4-dev.202609232017.d454a3d
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/openapi.yaml +54 -0
- package/package.json +1 -1
- package/src/api/events/generation-handoff.ts +4 -0
- package/src/api/events/message-complete.ts +4 -0
- package/src/api/responses/conversation-message.ts +4 -0
- package/src/cli/lib/bundled-marketplace.json +24 -0
- package/src/cli/lib/bundled-plugin-packages.json +21 -0
- package/src/daemon/__tests__/auto-profile-router.test.ts +152 -0
- package/src/daemon/auto-profile-router.ts +211 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +11 -0
- package/src/daemon/conversation-agent-loop.ts +25 -5
- package/src/persistence/conversation-types.ts +8 -0
- package/src/runtime/routes/auto-profile-routes.ts +83 -0
- package/src/runtime/routes/conversation-routes.ts +9 -0
- package/src/runtime/routes/index.ts +2 -0
package/openapi.yaml
CHANGED
|
@@ -2597,6 +2597,58 @@ paths:
|
|
|
2597
2597
|
- userId
|
|
2598
2598
|
- authenticated
|
|
2599
2599
|
additionalProperties: false
|
|
2600
|
+
/v1/auto-profile/preview:
|
|
2601
|
+
post:
|
|
2602
|
+
operationId: autoprofile_preview_post
|
|
2603
|
+
summary: Preview the Auto profile's pick for a draft
|
|
2604
|
+
description:
|
|
2605
|
+
Ask the Auto profile which default profile a draft message would run on, using the conversation's recent
|
|
2606
|
+
history when one is given. The pick is kept briefly so the turn that sends the same text reuses it. Spends one
|
|
2607
|
+
Jev call per request.
|
|
2608
|
+
tags:
|
|
2609
|
+
- conversations
|
|
2610
|
+
requestBody:
|
|
2611
|
+
required: true
|
|
2612
|
+
content:
|
|
2613
|
+
application/json:
|
|
2614
|
+
schema:
|
|
2615
|
+
type: object
|
|
2616
|
+
properties:
|
|
2617
|
+
text:
|
|
2618
|
+
type: string
|
|
2619
|
+
minLength: 1
|
|
2620
|
+
maxLength: 20000
|
|
2621
|
+
conversationId:
|
|
2622
|
+
type: string
|
|
2623
|
+
description: Conversation the draft belongs to, for recent history. Omit for a new chat.
|
|
2624
|
+
required:
|
|
2625
|
+
- text
|
|
2626
|
+
responses:
|
|
2627
|
+
"200":
|
|
2628
|
+
description: Successful response
|
|
2629
|
+
content:
|
|
2630
|
+
application/json:
|
|
2631
|
+
schema:
|
|
2632
|
+
type: object
|
|
2633
|
+
properties:
|
|
2634
|
+
profile:
|
|
2635
|
+
anyOf:
|
|
2636
|
+
- type: string
|
|
2637
|
+
- type: "null"
|
|
2638
|
+
outcome:
|
|
2639
|
+
type: string
|
|
2640
|
+
enum:
|
|
2641
|
+
- routed
|
|
2642
|
+
- unavailable
|
|
2643
|
+
- timeout
|
|
2644
|
+
- error
|
|
2645
|
+
- fallback
|
|
2646
|
+
confidence:
|
|
2647
|
+
type: number
|
|
2648
|
+
required:
|
|
2649
|
+
- profile
|
|
2650
|
+
- outcome
|
|
2651
|
+
additionalProperties: false
|
|
2600
2652
|
/v1/avatar/accent:
|
|
2601
2653
|
post:
|
|
2602
2654
|
operationId: avatar_accent_post
|
|
@@ -22518,6 +22570,8 @@ paths:
|
|
|
22518
22570
|
type: boolean
|
|
22519
22571
|
noResponse:
|
|
22520
22572
|
type: boolean
|
|
22573
|
+
autoRoutedProfile:
|
|
22574
|
+
type: string
|
|
22521
22575
|
cameraFrame:
|
|
22522
22576
|
type: boolean
|
|
22523
22577
|
const: true
|
package/package.json
CHANGED
|
@@ -34,6 +34,10 @@ export const GenerationHandoffEventSchema = z.object({
|
|
|
34
34
|
modeSession: ModeSessionSchema.optional(),
|
|
35
35
|
attachments: z.array(AssistantOutboundAttachmentSchema).optional(),
|
|
36
36
|
attachmentWarnings: z.array(z.string()).optional(),
|
|
37
|
+
/** The default profile the Auto profile routed the finished turn to,
|
|
38
|
+
* mirroring `message_complete`, so a reply that hands off to a queued
|
|
39
|
+
* turn still gets its badge on the live row. */
|
|
40
|
+
autoRoutedProfile: z.string().optional(),
|
|
37
41
|
});
|
|
38
42
|
|
|
39
43
|
export type GenerationHandoffEvent = z.infer<
|
|
@@ -42,6 +42,10 @@ export const MessageCompleteEventSchema = z.object({
|
|
|
42
42
|
* its reply through the `send_user_message` tool, so a client can give the
|
|
43
43
|
* live row the same per-row treatment it will get from history. */
|
|
44
44
|
assistantTextVisibility: z.enum(["private", "visible"]).optional(),
|
|
45
|
+
/** The default profile the Auto profile routed this turn to, mirroring the
|
|
46
|
+
* field of the same name on `ConversationMessage`. Set only on a turn that
|
|
47
|
+
* ran on Auto, so the live row can show which profile answered. */
|
|
48
|
+
autoRoutedProfile: z.string().optional(),
|
|
45
49
|
});
|
|
46
50
|
|
|
47
51
|
export type MessageCompleteEvent = z.infer<typeof MessageCompleteEventSchema>;
|
|
@@ -624,6 +624,10 @@ export const ConversationMessageSchema = z.object({
|
|
|
624
624
|
* sentinel text, and treat the row as the turn's reply so nothing keeps
|
|
625
625
|
* waiting for one. */
|
|
626
626
|
noResponse: z.boolean().optional(),
|
|
627
|
+
/** The default profile key the Auto profile routed this turn to
|
|
628
|
+
* (`metadata.autoRoutedProfile`). Set only on an assistant row produced by
|
|
629
|
+
* a turn that ran on Auto; clients show which profile answered. */
|
|
630
|
+
autoRoutedProfile: z.string().optional(),
|
|
627
631
|
/** Set only on standalone ambient camera-frame rows, derived from
|
|
628
632
|
* `messageMetadataIsAmbientSightKeep`. Clients may fold consecutive frames
|
|
629
633
|
* into the following user message. Absent on shutter photos, spoken turns
|
|
@@ -1483,6 +1483,30 @@
|
|
|
1483
1483
|
"logo": "webull-mcp.png",
|
|
1484
1484
|
"category": "finance"
|
|
1485
1485
|
}
|
|
1486
|
+
},
|
|
1487
|
+
{
|
|
1488
|
+
"name": "wix",
|
|
1489
|
+
"source": {
|
|
1490
|
+
"source": "local",
|
|
1491
|
+
"path": "plugins/mcp-catalog/wix",
|
|
1492
|
+
"version": "1.0.2"
|
|
1493
|
+
},
|
|
1494
|
+
"description": "Manage Wix sites, stores, bookings, and CMS content.",
|
|
1495
|
+
"homepage": "https://dev.wix.com/docs/sdk/articles/use-the-wix-mcp/about-the-wix-mcp",
|
|
1496
|
+
"license": "MIT",
|
|
1497
|
+
"integration": {
|
|
1498
|
+
"kind": "mcp",
|
|
1499
|
+
"displayName": "Wix",
|
|
1500
|
+
"documentationUrl": "https://dev.wix.com/docs/sdk/articles/use-the-wix-mcp/about-the-wix-mcp",
|
|
1501
|
+
"verifiedAt": "2026-09-23",
|
|
1502
|
+
"verification": "documentation-only",
|
|
1503
|
+
"setup": {
|
|
1504
|
+
"mode": "oauth",
|
|
1505
|
+
"instructions": "Sign in with the Wix account that owns the sites you want to manage."
|
|
1506
|
+
},
|
|
1507
|
+
"logo": "wix-mcp.png",
|
|
1508
|
+
"category": "commerce"
|
|
1509
|
+
}
|
|
1486
1510
|
}
|
|
1487
1511
|
]
|
|
1488
1512
|
}
|
|
@@ -781,6 +781,27 @@
|
|
|
781
781
|
"contentBase64": "ewogICIkc2NoZW1hIjogImh0dHBzOi8vYWdlbnQtcGx1Z2lucy5vcmcvc2NoZW1hcy8xLjAuMC9wbHVnaW4uc2NoZW1hLmpzb24iLAogICJuYW1lIjogIndlYnVsbCIsCiAgInZlcnNpb24iOiAiMS4wLjEiLAogICJkZXNjcmlwdGlvbiI6ICJSZXZpZXcgYnJva2VyYWdlIGFjY291bnRzLCBvcmRlcnMsIHBvc2l0aW9ucywgYW5kIG1hcmtldCBkYXRhLiIsCiAgImhvbWVwYWdlIjogImh0dHBzOi8vZGV2ZWxvcGVyLndlYnVsbC5jb20vYXBpcy9kb2NzL0FJLWZyaWVuZGx5LVJlc291cmNlcy9tY3AvIiwKICAibGljZW5zZSI6ICJNSVQiCn0K"
|
|
782
782
|
}
|
|
783
783
|
]
|
|
784
|
+
},
|
|
785
|
+
"plugins/mcp-catalog/wix": {
|
|
786
|
+
"version": "1.0.2",
|
|
787
|
+
"files": [
|
|
788
|
+
{
|
|
789
|
+
"path": "ICON_ATTRIBUTION.md",
|
|
790
|
+
"contentBase64": "IyBJY29uIGF0dHJpYnV0aW9uCgpTb3VyY2U6IFtXaXgncyBvZmZpY2lhbCAxOTJweCB3ZWJzaXRlIGljb25dKGh0dHBzOi8vd3d3LndpeC5jb20vZmF2aWNvbi5pY28pLCBsaW5rZWQgZnJvbSBbd2l4LmNvbV0oaHR0cHM6Ly93d3cud2l4LmNvbS8pLgoKVGhlIGFzc2V0IHdhcyByZXNpemVkIHRvIDEyOHgxMjggYW5kIGZsYXR0ZW5lZCBvbnRvIGFuIG9wYXF1ZSB3aGl0ZSBiYWNrZ3JvdW5kIGZvciB0aGVtZSBjb250cmFzdC4gSXQgaXMgYSB0cmFkZW1hcmsgb2YgV2l4LmNvbSBhbmQgaXMgdXNlZCBvbmx5IHRvIGlkZW50aWZ5IHRoaXMgaW50ZWdyYXRpb24uCg=="
|
|
791
|
+
},
|
|
792
|
+
{
|
|
793
|
+
"path": "icon.png",
|
|
794
|
+
"contentBase64": "iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAIAAABMXPacAAAN7ElEQVR42u2dbUxbVdzA710pUNpJxxilpIW+QAuMlo6iRDNf5tucQ9miyURNiLr5gnOyGsj0w3xLnI7xQR0YmfODxheMCSMBgppl4gLZ5lyBlkGBAisd71sLLS194z4f7pPmPue2BW5vcj485/eJ/XvuufeeX+8553/ugeEEQWAIeGyBfQH/30ECIIMEQAYJgAwSABkkADJIAGSQAMggAZBBAiCDBEAGCYAMEgAZJAAySABkkADIIAGQQQIggwRABgmADBIAGSQAMkgAZJAAyCABkEECIIMEQAYJgAwSABkkADJIAGSQAMggAZBBAiCDBEAGCYAMEgAZJAAySABkkADIIAGQQQIggwRABgmATMJmDzAajV999dXq6iqPx9PpdPv371cqlRs8dnl52WQydXd32+32pKQkuVy+e/dujUbD5XI3cjhBECaTqaura2hoyOv1pqenv/vuu3l5edQC/f39v/32m81mCwaDUqn05MmTW7duJT+9evVqU1OT3+/HcZz8EyUikai8vPzBBx9MSkraeAsEAoErV650dHTY7fZQKETWxuVyq6qqHnvssU0bIDbJtWvXsrOzyWO5XK5er+/o6AiFQuseODg4eOjQobS0NA6HEz5cJBLV1tZOTk6ue3gwGGxpaVGpVAkJ//ulKS4uHhsbo5Zpb28vKCgI31peXt7CwkL40+np6X379gG3n5aW9vHHHy8vL2/w9j0ez6effpqZmQnUU1ZWNjo6utnGJAhi0wLm5+cfffRR6rl37tz577//xj7KbDYDR1GpqKiwWq2xa+jo6MjJyaEe9fzzz/v9/nCBxcXF3bt3UwsUFhZSBRAEcfnyZYlEApxdIBB88803a2trG7n9lpYWoVAI1JCZmXnhwgUGrc9EAEEQb775JnAFR44c8Xq90crfuXPn2Wefjf0gHjlyxO12R6thaWnp8ccfp5bncDiffPIJtUx3dzfQNHQBa2trp0+fpnc4ubm5169fX/fG+/r6du3aBRzL4XDq6upi3D77Ar788svExETqRSgUCpvNFq38jz/+mJycHFvA9u3b//jjj2g1GI3GHTt2AF/btrY2apm2trZwdx9NAEEQc3Nzzz33HP0CXnzxxbt378a4a5fL9dprr+E4Dhz40EMPjY+PM2t9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"
|
|
795
|
+
},
|
|
796
|
+
{
|
|
797
|
+
"path": "mcp.json",
|
|
798
|
+
"contentBase64": "ewogICIkc2NoZW1hIjogImh0dHBzOi8vYWdlbnQtcGx1Z2lucy5vcmcvc2NoZW1hcy8xLjAuMC9tY3Auc2NoZW1hLmpzb24iLAogICJtY3BTZXJ2ZXJzIjogewogICAgIndpeCI6IHsKICAgICAgInR5cGUiOiAic3RyZWFtYWJsZS1odHRwIiwKICAgICAgInVybCI6ICJodHRwczovL21jcC53aXguY29tL21jcCIKICAgIH0KICB9Cn0K"
|
|
799
|
+
},
|
|
800
|
+
{
|
|
801
|
+
"path": "plugin.json",
|
|
802
|
+
"contentBase64": "ewogICIkc2NoZW1hIjogImh0dHBzOi8vYWdlbnQtcGx1Z2lucy5vcmcvc2NoZW1hcy8xLjAuMC9wbHVnaW4uc2NoZW1hLmpzb24iLAogICJuYW1lIjogIndpeCIsCiAgInZlcnNpb24iOiAiMS4wLjIiLAogICJkZXNjcmlwdGlvbiI6ICJNYW5hZ2UgV2l4IHNpdGVzLCBzdG9yZXMsIGJvb2tpbmdzLCBhbmQgQ01TIGNvbnRlbnQuIiwKICAiaG9tZXBhZ2UiOiAiaHR0cHM6Ly9kZXYud2l4LmNvbS9kb2NzL3Nkay9hcnRpY2xlcy91c2UtdGhlLXdpeC1tY3AvYWJvdXQtdGhlLXdpeC1tY3AiLAogICJsaWNlbnNlIjogIk1JVCIKfQo="
|
|
803
|
+
}
|
|
804
|
+
]
|
|
784
805
|
}
|
|
785
806
|
}
|
|
786
807
|
}
|
|
@@ -9,10 +9,16 @@ import type {
|
|
|
9
9
|
} from "../../providers/types.js";
|
|
10
10
|
import {
|
|
11
11
|
AUTO_PROFILE_FALLBACK,
|
|
12
|
+
AUTO_PROFILE_PREVIEW_REUSE_MS,
|
|
12
13
|
autoProfileCandidates,
|
|
13
14
|
autoProfileFallback,
|
|
15
|
+
autoProfilePreviewContext,
|
|
16
|
+
type AutoProfileRoute,
|
|
17
|
+
clearAutoProfilePreviewsForTesting,
|
|
14
18
|
recentConversationForRouter,
|
|
19
|
+
rememberAutoProfilePreview,
|
|
15
20
|
routeAutoProfile,
|
|
21
|
+
takeAutoProfilePreview,
|
|
16
22
|
} from "../auto-profile-router.js";
|
|
17
23
|
|
|
18
24
|
function text(role: "user" | "assistant", body: string): Message {
|
|
@@ -54,6 +60,17 @@ const profiles: Record<string, ProfileEntry> = {
|
|
|
54
60
|
"latency-optimized": managed("Fast"),
|
|
55
61
|
};
|
|
56
62
|
|
|
63
|
+
const contextFor = (
|
|
64
|
+
history: readonly Message[],
|
|
65
|
+
userMessage: string,
|
|
66
|
+
candidatesFrom: Record<string, ProfileEntry>,
|
|
67
|
+
) =>
|
|
68
|
+
autoProfilePreviewContext(
|
|
69
|
+
history,
|
|
70
|
+
userMessage,
|
|
71
|
+
autoProfileCandidates(candidatesFrom),
|
|
72
|
+
);
|
|
73
|
+
|
|
57
74
|
const base = {
|
|
58
75
|
conversationId: "conv-1",
|
|
59
76
|
history: [text("user", "hi"), text("assistant", "hello")],
|
|
@@ -226,3 +243,138 @@ describe("routeAutoProfile", () => {
|
|
|
226
243
|
expect(autoProfileFallback([])).toBe(AUTO_PROFILE_FALLBACK);
|
|
227
244
|
});
|
|
228
245
|
});
|
|
246
|
+
|
|
247
|
+
describe("draft preview reuse", () => {
|
|
248
|
+
const routed: AutoProfileRoute = {
|
|
249
|
+
profile: "quality-optimized",
|
|
250
|
+
outcome: "routed",
|
|
251
|
+
confidence: 0.9,
|
|
252
|
+
latencyMs: 120,
|
|
253
|
+
};
|
|
254
|
+
const history = [text("user", "hi"), text("assistant", "hello")];
|
|
255
|
+
const context = (h: Message[] = history) =>
|
|
256
|
+
contextFor(h, "Refactor the auth module", profiles);
|
|
257
|
+
const take = (
|
|
258
|
+
overrides: Partial<Parameters<typeof takeAutoProfilePreview>[0]> = {},
|
|
259
|
+
) =>
|
|
260
|
+
takeAutoProfilePreview({
|
|
261
|
+
conversationId: "conv-1",
|
|
262
|
+
text: "Refactor the auth module",
|
|
263
|
+
history,
|
|
264
|
+
profiles,
|
|
265
|
+
...overrides,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("a routed preview is reused once by the turn that sends the same text", () => {
|
|
269
|
+
clearAutoProfilePreviewsForTesting();
|
|
270
|
+
rememberAutoProfilePreview(
|
|
271
|
+
"conv-1",
|
|
272
|
+
"Refactor the auth\nmodule",
|
|
273
|
+
routed,
|
|
274
|
+
context(),
|
|
275
|
+
);
|
|
276
|
+
expect(take()).toEqual(routed);
|
|
277
|
+
expect(take()).toBeUndefined();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("a different draft, another conversation, or an expired preview is not reused", () => {
|
|
281
|
+
clearAutoProfilePreviewsForTesting();
|
|
282
|
+
const at = Date.now();
|
|
283
|
+
rememberAutoProfilePreview("conv-1", "hello", routed, context(), at);
|
|
284
|
+
expect(take({ text: "hello there" })).toBeUndefined();
|
|
285
|
+
expect(take({ text: "hello", conversationId: "conv-2" })).toBeUndefined();
|
|
286
|
+
expect(
|
|
287
|
+
take({ text: "hello", now: at + AUTO_PROFILE_PREVIEW_REUSE_MS + 1 }),
|
|
288
|
+
).toBeUndefined();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("a preview is not reused once the conversation or the candidates moved on", () => {
|
|
292
|
+
clearAutoProfilePreviewsForTesting();
|
|
293
|
+
rememberAutoProfilePreview("conv-1", "do that too", routed, context());
|
|
294
|
+
expect(
|
|
295
|
+
take({
|
|
296
|
+
text: "do that too",
|
|
297
|
+
history: [...history, text("assistant", "Anything else?")],
|
|
298
|
+
}),
|
|
299
|
+
).toBeUndefined();
|
|
300
|
+
rememberAutoProfilePreview("conv-1", "do that too", routed, context());
|
|
301
|
+
expect(
|
|
302
|
+
take({
|
|
303
|
+
text: "do that too",
|
|
304
|
+
profiles: {
|
|
305
|
+
...profiles,
|
|
306
|
+
"cost-optimized": managed("Budget", "disabled"),
|
|
307
|
+
},
|
|
308
|
+
}),
|
|
309
|
+
).toBeUndefined();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("a preview made before the conversation existed fits only a turn with no history", () => {
|
|
313
|
+
clearAutoProfilePreviewsForTesting();
|
|
314
|
+
rememberAutoProfilePreview(
|
|
315
|
+
undefined,
|
|
316
|
+
"first message",
|
|
317
|
+
routed,
|
|
318
|
+
contextFor([], "first message", profiles),
|
|
319
|
+
);
|
|
320
|
+
expect(
|
|
321
|
+
take({ conversationId: "conv-old", text: "first message" }),
|
|
322
|
+
).toBeUndefined();
|
|
323
|
+
expect(
|
|
324
|
+
take({ conversationId: "conv-new", text: "first message", history: [] }),
|
|
325
|
+
).toEqual(routed);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("a fallback is not remembered, so the turn asks Jev again", () => {
|
|
329
|
+
clearAutoProfilePreviewsForTesting();
|
|
330
|
+
rememberAutoProfilePreview(
|
|
331
|
+
"conv-1",
|
|
332
|
+
"hi",
|
|
333
|
+
{ profile: AUTO_PROFILE_FALLBACK, outcome: "timeout", latencyMs: 1000 },
|
|
334
|
+
contextFor(history, "hi", profiles),
|
|
335
|
+
);
|
|
336
|
+
expect(take({ text: "hi" })).toBeUndefined();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("abandoned previews expire and the map stays bounded", () => {
|
|
340
|
+
clearAutoProfilePreviewsForTesting();
|
|
341
|
+
const at = Date.now();
|
|
342
|
+
rememberAutoProfilePreview(
|
|
343
|
+
"conv-stale",
|
|
344
|
+
"old draft",
|
|
345
|
+
routed,
|
|
346
|
+
context(),
|
|
347
|
+
at,
|
|
348
|
+
);
|
|
349
|
+
for (let i = 0; i < 250; i += 1) {
|
|
350
|
+
rememberAutoProfilePreview(
|
|
351
|
+
`conv-${i}`,
|
|
352
|
+
"draft",
|
|
353
|
+
routed,
|
|
354
|
+
context(),
|
|
355
|
+
at + AUTO_PROFILE_PREVIEW_REUSE_MS + 1,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
expect(
|
|
359
|
+
take({
|
|
360
|
+
conversationId: "conv-stale",
|
|
361
|
+
text: "old draft",
|
|
362
|
+
now: at + AUTO_PROFILE_PREVIEW_REUSE_MS + 2,
|
|
363
|
+
}),
|
|
364
|
+
).toBeUndefined();
|
|
365
|
+
expect(
|
|
366
|
+
take({
|
|
367
|
+
conversationId: "conv-0",
|
|
368
|
+
text: "draft",
|
|
369
|
+
now: at + AUTO_PROFILE_PREVIEW_REUSE_MS + 2,
|
|
370
|
+
}),
|
|
371
|
+
).toBeUndefined();
|
|
372
|
+
expect(
|
|
373
|
+
take({
|
|
374
|
+
conversationId: "conv-249",
|
|
375
|
+
text: "draft",
|
|
376
|
+
now: at + AUTO_PROFILE_PREVIEW_REUSE_MS + 2,
|
|
377
|
+
}),
|
|
378
|
+
).toEqual(routed);
|
|
379
|
+
});
|
|
380
|
+
});
|
|
@@ -9,13 +9,18 @@
|
|
|
9
9
|
* cannot judge runs exactly as a Balanced turn would.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { getConversationProfilesForProvider } from "../config/default-profile-catalog.js";
|
|
12
13
|
import {
|
|
14
|
+
AUTO_PROFILE_KEY,
|
|
13
15
|
AUTO_PROFILE_ROUTER_CALL_SITE,
|
|
14
16
|
DEFAULT_PROFILE_KEYS,
|
|
15
17
|
type DefaultProfileKey,
|
|
16
18
|
isDefaultProfileKey,
|
|
17
19
|
} from "../config/default-profile-names.js";
|
|
20
|
+
import { getConfig } from "../config/loader.js";
|
|
18
21
|
import type { ProfileEntry } from "../config/schemas/llm.js";
|
|
22
|
+
import { getMessagesPaginated } from "../persistence/conversation-crud.js";
|
|
23
|
+
import { resolveConversationId } from "../persistence/conversation-key-store.js";
|
|
19
24
|
import { askTypesafe } from "../providers/jev/ask.js";
|
|
20
25
|
import type { ContentBlock, Message, Provider } from "../providers/types.js";
|
|
21
26
|
import { getLogger } from "../util/logger.js";
|
|
@@ -218,3 +223,209 @@ export async function routeAutoProfile(args: {
|
|
|
218
223
|
latencyMs: result.latencyMs,
|
|
219
224
|
};
|
|
220
225
|
}
|
|
226
|
+
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
// Draft preview and send-time reuse
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* How long a draft preview stays reusable by the turn that sends the same
|
|
233
|
+
* text. Long enough to cover the pause between the preview and the send,
|
|
234
|
+
* short enough that a stale pick never outlives the draft it was made for.
|
|
235
|
+
*/
|
|
236
|
+
export const AUTO_PROFILE_PREVIEW_REUSE_MS = 60_000;
|
|
237
|
+
|
|
238
|
+
/** Recent history rows the preview shows the router, matching the turn's window. */
|
|
239
|
+
const PREVIEW_HISTORY_ROWS = 12;
|
|
240
|
+
|
|
241
|
+
/** Cache key for a preview made before the conversation has a server id. */
|
|
242
|
+
const NO_CONVERSATION_KEY = "*";
|
|
243
|
+
|
|
244
|
+
interface RememberedPreview {
|
|
245
|
+
text: string;
|
|
246
|
+
/** What the router saw besides the text: the candidate set and the last
|
|
247
|
+
* line of recent history. A send whose context differs re-routes. */
|
|
248
|
+
context: string;
|
|
249
|
+
route: AutoProfileRoute;
|
|
250
|
+
at: number;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Bound on remembered previews. Abandoned drafts never get taken, so
|
|
255
|
+
* expiry alone would let the map grow with every conversation ever
|
|
256
|
+
* previewed; oldest entries go first once the bound is reached.
|
|
257
|
+
*/
|
|
258
|
+
const MAX_REMEMBERED_PREVIEWS = 200;
|
|
259
|
+
|
|
260
|
+
const rememberedPreviews = new Map<string, RememberedPreview>();
|
|
261
|
+
|
|
262
|
+
function normalizeDraft(text: string): string {
|
|
263
|
+
return text.replace(/\s+/g, " ").trim();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The routing inputs other than the draft, reduced to a comparable string:
|
|
268
|
+
* which profiles were candidates, and the last line of history the router
|
|
269
|
+
* saw. Both sides (preview from persisted rows, turn from the live history)
|
|
270
|
+
* render history through `recentConversationForRouter`, so the last line
|
|
271
|
+
* agrees whenever the conversation has not moved.
|
|
272
|
+
*/
|
|
273
|
+
export function autoProfilePreviewContext(
|
|
274
|
+
history: readonly Message[],
|
|
275
|
+
userMessage: string,
|
|
276
|
+
candidates: readonly DefaultProfileKey[],
|
|
277
|
+
): string {
|
|
278
|
+
const rendered = recentConversationForRouter(history, userMessage);
|
|
279
|
+
const lastLine = rendered.slice(rendered.lastIndexOf("\n") + 1);
|
|
280
|
+
return `${candidates.join(",")}\u0000${lastLine}`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function pruneRememberedPreviews(now: number): void {
|
|
284
|
+
for (const [key, entry] of rememberedPreviews) {
|
|
285
|
+
if (now - entry.at > AUTO_PROFILE_PREVIEW_REUSE_MS) {
|
|
286
|
+
rememberedPreviews.delete(key);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
while (rememberedPreviews.size >= MAX_REMEMBERED_PREVIEWS) {
|
|
290
|
+
const oldest = rememberedPreviews.keys().next().value;
|
|
291
|
+
if (oldest === undefined) {
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
rememberedPreviews.delete(oldest);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Keep a routed preview so the turn that sends the same text reuses it: one
|
|
300
|
+
* Jev call per message instead of two, and the reply's badge matches what
|
|
301
|
+
* the composer showed. Only a routed pick is kept; a fallback is worth
|
|
302
|
+
* retrying at send time.
|
|
303
|
+
*/
|
|
304
|
+
export function rememberAutoProfilePreview(
|
|
305
|
+
conversationId: string | undefined,
|
|
306
|
+
text: string,
|
|
307
|
+
route: AutoProfileRoute,
|
|
308
|
+
context: string,
|
|
309
|
+
now: number = Date.now(),
|
|
310
|
+
): void {
|
|
311
|
+
if (route.outcome !== "routed") {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
pruneRememberedPreviews(now);
|
|
315
|
+
rememberedPreviews.set(conversationId ?? NO_CONVERSATION_KEY, {
|
|
316
|
+
text: normalizeDraft(text),
|
|
317
|
+
context,
|
|
318
|
+
route,
|
|
319
|
+
at: now,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The remembered preview for this turn, consumed on read. It has to match
|
|
325
|
+
* the text and the routing context the turn would otherwise send to Jev.
|
|
326
|
+
* A preview made before the conversation existed saw an empty history, so
|
|
327
|
+
* it fits only a turn whose history is still empty: with no history and the
|
|
328
|
+
* same text and candidates, the two requests are identical.
|
|
329
|
+
*/
|
|
330
|
+
export function takeAutoProfilePreview(args: {
|
|
331
|
+
conversationId: string;
|
|
332
|
+
text: string;
|
|
333
|
+
history: readonly Message[];
|
|
334
|
+
profiles: Readonly<Record<string, ProfileEntry>>;
|
|
335
|
+
now?: number;
|
|
336
|
+
}): AutoProfileRoute | undefined {
|
|
337
|
+
const now = args.now ?? Date.now();
|
|
338
|
+
const wanted = normalizeDraft(args.text);
|
|
339
|
+
const candidates = autoProfileCandidates(args.profiles);
|
|
340
|
+
const context = autoProfilePreviewContext(args.history, wanted, candidates);
|
|
341
|
+
const historyIsEmpty =
|
|
342
|
+
recentConversationForRouter(args.history, wanted).length === 0;
|
|
343
|
+
const keys = historyIsEmpty
|
|
344
|
+
? [args.conversationId, NO_CONVERSATION_KEY]
|
|
345
|
+
: [args.conversationId];
|
|
346
|
+
for (const key of keys) {
|
|
347
|
+
const remembered = rememberedPreviews.get(key);
|
|
348
|
+
if (!remembered) {
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (now - remembered.at > AUTO_PROFILE_PREVIEW_REUSE_MS) {
|
|
352
|
+
rememberedPreviews.delete(key);
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (remembered.text === wanted && remembered.context === context) {
|
|
356
|
+
rememberedPreviews.delete(key);
|
|
357
|
+
return remembered.route;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return undefined;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** For tests. */
|
|
364
|
+
export function clearAutoProfilePreviewsForTesting(): void {
|
|
365
|
+
rememberedPreviews.clear();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Recent conversational rows for the preview: the same window the turn sees,
|
|
370
|
+
* counted over rows that carry spoken text so tool traffic cannot crowd a
|
|
371
|
+
* short follow-up's context out of the page.
|
|
372
|
+
*/
|
|
373
|
+
function recentHistoryFromDb(conversationId: string | undefined): Message[] {
|
|
374
|
+
const resolved = conversationId
|
|
375
|
+
? resolveConversationId(conversationId)
|
|
376
|
+
: null;
|
|
377
|
+
if (!resolved) {
|
|
378
|
+
return [];
|
|
379
|
+
}
|
|
380
|
+
return getMessagesPaginated(
|
|
381
|
+
resolved,
|
|
382
|
+
PREVIEW_HISTORY_ROWS,
|
|
383
|
+
undefined,
|
|
384
|
+
(row) =>
|
|
385
|
+
(row.role === "user" || row.role === "assistant") &&
|
|
386
|
+
plainTextOf(row.content).length > 0,
|
|
387
|
+
).messages.map((row) => ({
|
|
388
|
+
role: row.role as Message["role"],
|
|
389
|
+
content: row.content,
|
|
390
|
+
}));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Route a draft the user has not sent yet, so the composer can show the pick
|
|
395
|
+
* as they type. Returns null when the Auto profile is not available on this
|
|
396
|
+
* install; otherwise the same route a turn on the draft would get, remembered
|
|
397
|
+
* for that turn to reuse.
|
|
398
|
+
*/
|
|
399
|
+
export async function previewAutoProfile(args: {
|
|
400
|
+
conversationId?: string;
|
|
401
|
+
text: string;
|
|
402
|
+
signal?: AbortSignal;
|
|
403
|
+
}): Promise<AutoProfileRoute | null> {
|
|
404
|
+
const { llm } = getConfig();
|
|
405
|
+
const profiles = getConversationProfilesForProvider(
|
|
406
|
+
llm.profiles,
|
|
407
|
+
llm.defaultProvider ?? null,
|
|
408
|
+
);
|
|
409
|
+
if (profiles[AUTO_PROFILE_KEY]?.source !== "managed") {
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
const history = recentHistoryFromDb(args.conversationId);
|
|
413
|
+
const route = await routeAutoProfile({
|
|
414
|
+
conversationId: args.conversationId ?? NO_CONVERSATION_KEY,
|
|
415
|
+
history,
|
|
416
|
+
userMessage: args.text,
|
|
417
|
+
profiles,
|
|
418
|
+
...(args.signal ? { signal: args.signal } : {}),
|
|
419
|
+
});
|
|
420
|
+
rememberAutoProfilePreview(
|
|
421
|
+
args.conversationId,
|
|
422
|
+
args.text,
|
|
423
|
+
route,
|
|
424
|
+
autoProfilePreviewContext(
|
|
425
|
+
history,
|
|
426
|
+
normalizeDraft(args.text),
|
|
427
|
+
autoProfileCandidates(profiles),
|
|
428
|
+
),
|
|
429
|
+
);
|
|
430
|
+
return route;
|
|
431
|
+
}
|
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
updateMessageContent,
|
|
54
54
|
} from "../persistence/conversation-crud.js";
|
|
55
55
|
import { syncMessageToDisk } from "../persistence/conversation-disk-view.js";
|
|
56
|
+
import { AUTO_ROUTED_PROFILE_METADATA_KEY } from "../persistence/conversation-types.js";
|
|
56
57
|
import { enqueueLexicalIndexForMessage } from "../persistence/job-handlers/message-lexical.js";
|
|
57
58
|
import {
|
|
58
59
|
backfillMessageIdOnLogs,
|
|
@@ -275,6 +276,12 @@ export interface EventHandlerState {
|
|
|
275
276
|
* the model ever having seen it.
|
|
276
277
|
*/
|
|
277
278
|
pendingNotifiedInferenceProfile: string | null;
|
|
279
|
+
/**
|
|
280
|
+
* The default profile the Auto profile routed this turn to, stamped on the
|
|
281
|
+
* turn's assistant rows and carried on `message_complete` so clients can
|
|
282
|
+
* show which profile answered. `undefined` on every turn not run on Auto.
|
|
283
|
+
*/
|
|
284
|
+
autoRoutedProfile: string | undefined;
|
|
278
285
|
pendingDirectiveDisplayBuffer: string;
|
|
279
286
|
firstAssistantText: string;
|
|
280
287
|
/** Most recent resolved provider for the current exchange's usage accounting. */
|
|
@@ -698,6 +705,7 @@ export interface EventHandlerDeps {
|
|
|
698
705
|
export function createEventHandlerState(): EventHandlerState {
|
|
699
706
|
return {
|
|
700
707
|
pendingNotifiedInferenceProfile: null,
|
|
708
|
+
autoRoutedProfile: undefined,
|
|
701
709
|
pendingDirectiveDisplayBuffer: "",
|
|
702
710
|
firstAssistantText: "",
|
|
703
711
|
exchangeProviderName: undefined,
|
|
@@ -3305,6 +3313,9 @@ export async function handleMessageComplete(
|
|
|
3305
3313
|
...(event.assistantTextVisibility
|
|
3306
3314
|
? { [ASSISTANT_TEXT_VISIBILITY_KEY]: event.assistantTextVisibility }
|
|
3307
3315
|
: {}),
|
|
3316
|
+
...(state.autoRoutedProfile
|
|
3317
|
+
? { [AUTO_ROUTED_PROFILE_METADATA_KEY]: state.autoRoutedProfile }
|
|
3318
|
+
: {}),
|
|
3308
3319
|
};
|
|
3309
3320
|
state.lastAssistantTextVisibility = event.assistantTextVisibility;
|
|
3310
3321
|
const persisted = await finalizeInflightContent(
|
|
@@ -105,6 +105,7 @@ import {
|
|
|
105
105
|
type AutoProfileRoute,
|
|
106
106
|
plainTextOf,
|
|
107
107
|
routeAutoProfile,
|
|
108
|
+
takeAutoProfilePreview,
|
|
108
109
|
} from "./auto-profile-router.js";
|
|
109
110
|
import { conversationSupportsDynamicUi } from "./channel-ui-capability.js";
|
|
110
111
|
import type { Conversation } from "./conversation.js";
|
|
@@ -688,21 +689,33 @@ export async function runAgentLoopImpl(
|
|
|
688
689
|
if (profiles[AUTO_PROFILE_KEY]?.source !== "managed") {
|
|
689
690
|
return null;
|
|
690
691
|
}
|
|
691
|
-
const
|
|
692
|
+
const userMessage = plainTextOf(
|
|
693
|
+
getMessageById(userMessageId)?.content ?? [],
|
|
694
|
+
);
|
|
695
|
+
// A draft the composer already previewed carries its pick over, so
|
|
696
|
+
// the reply lands on the profile the pill showed with no second call.
|
|
697
|
+
const previewed = takeAutoProfilePreview({
|
|
692
698
|
conversationId: ctx.conversationId,
|
|
699
|
+
text: userMessage,
|
|
693
700
|
history: ctx.messages,
|
|
694
|
-
userMessage: plainTextOf(
|
|
695
|
-
getMessageById(userMessageId)?.content ?? [],
|
|
696
|
-
),
|
|
697
701
|
profiles,
|
|
698
|
-
signal: abortController.signal,
|
|
699
702
|
});
|
|
703
|
+
const route =
|
|
704
|
+
previewed ??
|
|
705
|
+
(await routeAutoProfile({
|
|
706
|
+
conversationId: ctx.conversationId,
|
|
707
|
+
history: ctx.messages,
|
|
708
|
+
userMessage,
|
|
709
|
+
profiles,
|
|
710
|
+
signal: abortController.signal,
|
|
711
|
+
}));
|
|
700
712
|
rlog.info(
|
|
701
713
|
{
|
|
702
714
|
outcome: route.outcome,
|
|
703
715
|
profile: route.profile,
|
|
704
716
|
confidence: route.confidence ?? null,
|
|
705
717
|
latencyMs: route.latencyMs,
|
|
718
|
+
reusedPreview: previewed !== undefined,
|
|
706
719
|
},
|
|
707
720
|
"Auto profile routed the turn",
|
|
708
721
|
);
|
|
@@ -963,6 +976,7 @@ export async function runAgentLoopImpl(
|
|
|
963
976
|
startToolProfilingRequest(ctx.conversationId);
|
|
964
977
|
let turnStarted = false;
|
|
965
978
|
const state = createEventHandlerState();
|
|
979
|
+
state.autoRoutedProfile = autoRoute?.profile;
|
|
966
980
|
// Publish this turn's flushed-content watermark so the worker → daemon
|
|
967
981
|
// persist hand-off can cap a snapshot anchor at flushed content rather than
|
|
968
982
|
// the live seq counter, which runs ahead while the turn streams. Cleared in
|
|
@@ -2240,6 +2254,9 @@ export async function runAgentLoopImpl(
|
|
|
2240
2254
|
...(state.lastAssistantMessageId
|
|
2241
2255
|
? { messageId: state.lastAssistantMessageId }
|
|
2242
2256
|
: {}),
|
|
2257
|
+
...(state.autoRoutedProfile
|
|
2258
|
+
? { autoRoutedProfile: state.autoRoutedProfile }
|
|
2259
|
+
: {}),
|
|
2243
2260
|
modeSession: ctx.modeSessions.getTurnOwner(reqId),
|
|
2244
2261
|
});
|
|
2245
2262
|
publishLoopMessagesChanged();
|
|
@@ -2268,6 +2285,9 @@ export async function runAgentLoopImpl(
|
|
|
2268
2285
|
...(state.lastAssistantTextVisibility
|
|
2269
2286
|
? { assistantTextVisibility: state.lastAssistantTextVisibility }
|
|
2270
2287
|
: {}),
|
|
2288
|
+
...(state.autoRoutedProfile
|
|
2289
|
+
? { autoRoutedProfile: state.autoRoutedProfile }
|
|
2290
|
+
: {}),
|
|
2271
2291
|
});
|
|
2272
2292
|
if (shouldEmitQueuedConversationNotices) {
|
|
2273
2293
|
for (const notice of drainConversationNotices(ctx.conversationId)) {
|
|
@@ -167,6 +167,14 @@ export function isSystemCardMetadata(
|
|
|
167
167
|
*/
|
|
168
168
|
export const NO_RESPONSE_MESSAGE_KIND = "no_response";
|
|
169
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Metadata key on an assistant row from a turn that ran on the Auto profile:
|
|
172
|
+
* the default profile key the router picked for the turn. Projected to
|
|
173
|
+
* `ConversationMessage.autoRoutedProfile` so clients can show which profile
|
|
174
|
+
* answered. Absent on every other row.
|
|
175
|
+
*/
|
|
176
|
+
export const AUTO_ROUTED_PROFILE_METADATA_KEY = "autoRoutedProfile";
|
|
177
|
+
|
|
170
178
|
/**
|
|
171
179
|
* Shared predicate for the deliberate-silence marker, mirroring
|
|
172
180
|
* {@link isSystemCardMetadata} so display merging, transcript rendering, and
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto profile routes.
|
|
3
|
+
*
|
|
4
|
+
* POST /v1/auto-profile/preview — which default profile the Auto profile
|
|
5
|
+
* would pick for a draft the user has not sent yet.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
|
|
9
|
+
import { previewAutoProfile } from "../../daemon/auto-profile-router.js";
|
|
10
|
+
import { ACTOR_PRINCIPALS } from "../auth/route-policy.js";
|
|
11
|
+
import { BadRequestError } from "./errors.js";
|
|
12
|
+
import type { RouteDefinition, RouteHandlerArgs } from "./types.js";
|
|
13
|
+
|
|
14
|
+
const MAX_DRAFT_CHARS = 20_000;
|
|
15
|
+
|
|
16
|
+
const previewRequestSchema = z.object({
|
|
17
|
+
text: z.string().min(1).max(MAX_DRAFT_CHARS),
|
|
18
|
+
conversationId: z
|
|
19
|
+
.string()
|
|
20
|
+
.describe(
|
|
21
|
+
"Conversation the draft belongs to, for recent history. Omit for a new chat.",
|
|
22
|
+
)
|
|
23
|
+
.optional(),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const previewResponseSchema = z.object({
|
|
27
|
+
/** The default profile key the draft would run on; null when the Auto
|
|
28
|
+
* profile is not available on this install. */
|
|
29
|
+
profile: z.string().nullable(),
|
|
30
|
+
outcome: z.enum(["routed", "unavailable", "timeout", "error", "fallback"]),
|
|
31
|
+
confidence: z.number().optional(),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
async function handlePreviewAutoProfile(
|
|
35
|
+
args: RouteHandlerArgs,
|
|
36
|
+
): Promise<z.infer<typeof previewResponseSchema>> {
|
|
37
|
+
const parsed = previewRequestSchema.safeParse(args.body ?? {});
|
|
38
|
+
if (!parsed.success) {
|
|
39
|
+
throw new BadRequestError(
|
|
40
|
+
`text must be a non-empty string of at most ${MAX_DRAFT_CHARS} characters`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
// The composer aborts a preview the moment the draft changes; carrying the
|
|
44
|
+
// abort through stops the retired Jev call and keeps its late answer out
|
|
45
|
+
// of the reuse cache.
|
|
46
|
+
const route = await previewAutoProfile({
|
|
47
|
+
text: parsed.data.text,
|
|
48
|
+
...(parsed.data.conversationId
|
|
49
|
+
? { conversationId: parsed.data.conversationId }
|
|
50
|
+
: {}),
|
|
51
|
+
...(args.abortSignal ? { signal: args.abortSignal } : {}),
|
|
52
|
+
});
|
|
53
|
+
if (!route) {
|
|
54
|
+
return { profile: null, outcome: "unavailable" };
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
profile: route.profile,
|
|
58
|
+
outcome: route.outcome,
|
|
59
|
+
...(route.confidence !== undefined ? { confidence: route.confidence } : {}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const ROUTES: RouteDefinition[] = [
|
|
64
|
+
{
|
|
65
|
+
operationId: "previewAutoProfile",
|
|
66
|
+
endpoint: "auto-profile/preview",
|
|
67
|
+
method: "POST",
|
|
68
|
+
policy: {
|
|
69
|
+
requiredScopes: ["chat.write"],
|
|
70
|
+
allowedPrincipalTypes: ACTOR_PRINCIPALS,
|
|
71
|
+
},
|
|
72
|
+
summary: "Preview the Auto profile's pick for a draft",
|
|
73
|
+
description:
|
|
74
|
+
"Ask the Auto profile which default profile a draft message would run on, " +
|
|
75
|
+
"using the conversation's recent history when one is given. The pick is " +
|
|
76
|
+
"kept briefly so the turn that sends the same text reuses it. Spends one " +
|
|
77
|
+
"Jev call per request.",
|
|
78
|
+
tags: ["conversations"],
|
|
79
|
+
requestBody: previewRequestSchema,
|
|
80
|
+
responseBody: previewResponseSchema,
|
|
81
|
+
handler: handlePreviewAutoProfile,
|
|
82
|
+
},
|
|
83
|
+
];
|
|
@@ -142,6 +142,7 @@ import {
|
|
|
142
142
|
import { listConversationModeSessionsByIds } from "../../persistence/conversation-mode-sessions.js";
|
|
143
143
|
import { searchConversations } from "../../persistence/conversation-queries.js";
|
|
144
144
|
import {
|
|
145
|
+
AUTO_ROUTED_PROFILE_METADATA_KEY,
|
|
145
146
|
computerUseScreenshotAttachmentIdsFromMetadata,
|
|
146
147
|
isNoResponseMetadata,
|
|
147
148
|
messageMetadataIsAmbientSightKeep,
|
|
@@ -1110,6 +1111,7 @@ export async function handleListMessages({
|
|
|
1110
1111
|
let backgroundToolCompletion: ConversationMessage["backgroundToolCompletion"];
|
|
1111
1112
|
let systemCard: boolean | undefined;
|
|
1112
1113
|
let noResponse: boolean | undefined;
|
|
1114
|
+
let autoRoutedProfile: string | undefined;
|
|
1113
1115
|
const cameraFrame = messageMetadataIsAmbientSightKeep(msg.metadata)
|
|
1114
1116
|
? true
|
|
1115
1117
|
: undefined;
|
|
@@ -1130,6 +1132,9 @@ export async function handleListMessages({
|
|
|
1130
1132
|
if (isNoResponseMetadata(meta)) {
|
|
1131
1133
|
noResponse = true;
|
|
1132
1134
|
}
|
|
1135
|
+
if (typeof meta[AUTO_ROUTED_PROFILE_METADATA_KEY] === "string") {
|
|
1136
|
+
autoRoutedProfile = meta[AUTO_ROUTED_PROFILE_METADATA_KEY];
|
|
1137
|
+
}
|
|
1133
1138
|
// Channel facts live in the row's provider envelope. A reaction row,
|
|
1134
1139
|
// either direction, projects its structured fact so clients never
|
|
1135
1140
|
// render the stored "[reaction]" sentinel; a row deleted on its
|
|
@@ -1212,6 +1217,7 @@ export async function handleListMessages({
|
|
|
1212
1217
|
backgroundToolCompletion,
|
|
1213
1218
|
systemCard,
|
|
1214
1219
|
noResponse,
|
|
1220
|
+
autoRoutedProfile,
|
|
1215
1221
|
cameraFrame,
|
|
1216
1222
|
reaction,
|
|
1217
1223
|
providerError,
|
|
@@ -1451,6 +1457,9 @@ export async function handleListMessages({
|
|
|
1451
1457
|
: {}),
|
|
1452
1458
|
...(m.systemCard ? { systemCard: true } : {}),
|
|
1453
1459
|
...(m.noResponse ? { noResponse: true } : {}),
|
|
1460
|
+
...(m.autoRoutedProfile
|
|
1461
|
+
? { autoRoutedProfile: m.autoRoutedProfile }
|
|
1462
|
+
: {}),
|
|
1454
1463
|
...(m.cameraFrame ? { cameraFrame: true as const } : {}),
|
|
1455
1464
|
// The row's own marker, so a client gates per-row presentation on what
|
|
1456
1465
|
// this row was written with rather than on the live flag.
|
|
@@ -25,6 +25,7 @@ import { ROUTES as ATTACHMENT_ROUTES } from "./attachment-routes.js";
|
|
|
25
25
|
import { ROUTES as AUDIO_ROUTES } from "./audio-routes.js";
|
|
26
26
|
import { ROUTES as AUDIT_ROUTES } from "./audit-routes.js";
|
|
27
27
|
import { ROUTES as AUTH_ROUTES } from "./auth-routes.js";
|
|
28
|
+
import { ROUTES as AUTO_PROFILE_ROUTES } from "./auto-profile-routes.js";
|
|
28
29
|
import { ROUTES as AVATAR_ROUTES } from "./avatar-routes.js";
|
|
29
30
|
import { ROUTES as BACKGROUND_TOOL_ROUTES } from "./background-tool-routes.js";
|
|
30
31
|
import { ROUTES as BACKGROUND_WAKE_ROUTES } from "./background-wake-routes.js";
|
|
@@ -217,6 +218,7 @@ export const ROUTES: RouteDefinition[] = [
|
|
|
217
218
|
...CONVERSATION_QUERY_ROUTES,
|
|
218
219
|
...CONVERSATION_STARTER_ROUTES,
|
|
219
220
|
...ACTIVATION_ROUTES,
|
|
221
|
+
...AUTO_PROFILE_ROUTES,
|
|
220
222
|
...DEBUG_ROUTES,
|
|
221
223
|
...DEFAULT_PROVIDER_ROUTES,
|
|
222
224
|
...DESKTOP_SETUP_ROUTES,
|