@vellumai/assistant 0.11.7-dev.202608312314.14ddc99 → 0.11.7-dev.202609010224.44cd29e
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/config-schema.test.ts +34 -0
- package/src/__tests__/conversation-sight-frames.test.ts +139 -3
- package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -0
- package/src/__tests__/sight-frame-retention.test.ts +71 -1
- package/src/config/schema.ts +2 -0
- package/src/config/schemas/sight.ts +45 -0
- package/src/daemon/conversation-messaging.ts +293 -30
- package/src/daemon/conversation-runtime-assembly.ts +9 -4
- package/src/daemon/conversation-sight-frames.ts +36 -19
- package/src/live-voice/__tests__/live-voice-photo.test.ts +637 -1
- package/src/live-voice/__tests__/live-voice-sight-frame-inline.test.ts +787 -0
- package/src/live-voice/__tests__/live-voice-sight-frame.test.ts +347 -0
- package/src/live-voice/live-voice-photo.ts +426 -49
- package/src/live-voice/live-voice-session.ts +47 -1
- package/src/live-voice/protocol.ts +69 -4
- package/src/persistence/attachments-store.ts +108 -31
- package/src/persistence/conversation-types.ts +42 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +166 -1
- package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +53 -9
- package/src/plugins/defaults/memory/v1/job-handlers/index-maintenance.ts +15 -1
- package/src/skills/catalog-install.ts +4 -3
package/package.json
CHANGED
|
@@ -811,6 +811,40 @@ describe("AssistantConfigSchema", () => {
|
|
|
811
811
|
expect(result.success).toBe(false);
|
|
812
812
|
});
|
|
813
813
|
|
|
814
|
+
test("applies sight defaults", () => {
|
|
815
|
+
expect(AssistantConfigSchema.parse({}).sight).toEqual({
|
|
816
|
+
keepLatestFrames: 2,
|
|
817
|
+
});
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
test("accepts a custom sight.keepLatestFrames", () => {
|
|
821
|
+
const result = AssistantConfigSchema.parse({
|
|
822
|
+
sight: { keepLatestFrames: 4 },
|
|
823
|
+
});
|
|
824
|
+
expect(result.sight.keepLatestFrames).toBe(4);
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
test("keeps an out-of-range sight.keepLatestFrames for the reader to clamp", () => {
|
|
828
|
+
// Rejecting it would reset the key to the default, which is fewer frames
|
|
829
|
+
// than either the config or the guardrail asked for. The clamp lives at
|
|
830
|
+
// read (`resolveSightKeepLatestFrames`).
|
|
831
|
+
const result = AssistantConfigSchema.parse({
|
|
832
|
+
sight: { keepLatestFrames: 99 },
|
|
833
|
+
});
|
|
834
|
+
expect(result.sight.keepLatestFrames).toBe(99);
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
test("rejects a non-integer sight.keepLatestFrames", () => {
|
|
838
|
+
expect(
|
|
839
|
+
AssistantConfigSchema.safeParse({ sight: { keepLatestFrames: 2.5 } })
|
|
840
|
+
.success,
|
|
841
|
+
).toBe(false);
|
|
842
|
+
expect(
|
|
843
|
+
AssistantConfigSchema.safeParse({ sight: { keepLatestFrames: "two" } })
|
|
844
|
+
.success,
|
|
845
|
+
).toBe(false);
|
|
846
|
+
});
|
|
847
|
+
|
|
814
848
|
// ── commitMessageLLM config ──────────────────────────────────────────
|
|
815
849
|
|
|
816
850
|
test("default commitMessageLLM values are correct", () => {
|
|
@@ -1,15 +1,24 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
|
+
import {
|
|
4
|
+
KEEP_LATEST_SIGHT_FRAMES,
|
|
5
|
+
MAX_SIGHT_KEEP_LATEST_FRAMES,
|
|
6
|
+
MIN_SIGHT_KEEP_LATEST_FRAMES,
|
|
7
|
+
} from "../config/schemas/sight.js";
|
|
3
8
|
import {
|
|
4
9
|
countMediaBlocks,
|
|
5
10
|
stripMediaPayloadsForRetry,
|
|
6
11
|
} from "../daemon/conversation-media-retry.js";
|
|
7
12
|
import {
|
|
8
|
-
|
|
13
|
+
resolveSightKeepLatestFrames,
|
|
9
14
|
stripAgedSightFrames,
|
|
10
15
|
} from "../daemon/conversation-sight-frames.js";
|
|
11
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
messageMetadataIsAmbientSightKeep,
|
|
18
|
+
sightFrameAttachmentIdsFromMetadata,
|
|
19
|
+
} from "../persistence/conversation-types.js";
|
|
12
20
|
import type { ContentBlock, Message } from "../providers/types.js";
|
|
21
|
+
import { setConfig } from "./helpers/set-config.js";
|
|
13
22
|
|
|
14
23
|
// ---------------------------------------------------------------------------
|
|
15
24
|
// Helpers
|
|
@@ -129,6 +138,34 @@ describe("stripAgedSightFrames", () => {
|
|
|
129
138
|
);
|
|
130
139
|
});
|
|
131
140
|
|
|
141
|
+
test("keeps as many frames as the caller's budget allows", () => {
|
|
142
|
+
const messages: Message[] = [
|
|
143
|
+
user(referenceImage("f1")),
|
|
144
|
+
user(referenceImage("f2")),
|
|
145
|
+
user(referenceImage("f3")),
|
|
146
|
+
user(referenceImage("f4")),
|
|
147
|
+
user(referenceImage("f5")),
|
|
148
|
+
];
|
|
149
|
+
|
|
150
|
+
const result = stripAgedSightFrames(
|
|
151
|
+
messages,
|
|
152
|
+
captureTimes("f1", "f2", "f3", "f4", "f5"),
|
|
153
|
+
4,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
expect(result.replacedBlocks).toBe(1);
|
|
157
|
+
expect(
|
|
158
|
+
result.messages.flatMap((m) =>
|
|
159
|
+
m.content.filter((b) => b.type === "image"),
|
|
160
|
+
),
|
|
161
|
+
).toEqual([
|
|
162
|
+
referenceImage("f2"),
|
|
163
|
+
referenceImage("f3"),
|
|
164
|
+
referenceImage("f4"),
|
|
165
|
+
referenceImage("f5"),
|
|
166
|
+
]);
|
|
167
|
+
});
|
|
168
|
+
|
|
132
169
|
test("stubs several frames on one message independently", () => {
|
|
133
170
|
const messages: Message[] = [
|
|
134
171
|
user(referenceImage("f1"), referenceImage("f2"), referenceImage("f3")),
|
|
@@ -449,6 +486,38 @@ describe("stripAgedSightFrames composed with stripMediaPayloadsForRetry", () =>
|
|
|
449
486
|
});
|
|
450
487
|
});
|
|
451
488
|
|
|
489
|
+
// ---------------------------------------------------------------------------
|
|
490
|
+
// Configured retention budget
|
|
491
|
+
// ---------------------------------------------------------------------------
|
|
492
|
+
|
|
493
|
+
describe("resolveSightKeepLatestFrames", () => {
|
|
494
|
+
afterEach(() => {
|
|
495
|
+
setConfig("sight", {});
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test("defaults to the constant when the workspace configures nothing", () => {
|
|
499
|
+
expect(resolveSightKeepLatestFrames()).toBe(KEEP_LATEST_SIGHT_FRAMES);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
test("honors a configured value inside the guardrail", () => {
|
|
503
|
+
setConfig("sight", { keepLatestFrames: 4 });
|
|
504
|
+
expect(resolveSightKeepLatestFrames()).toBe(4);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("caps a value above the ceiling instead of resetting it", () => {
|
|
508
|
+
// Ten live images per request is what the ceiling exists to prevent, and
|
|
509
|
+
// falling back to the default would show fewer frames than the guardrail
|
|
510
|
+
// itself allows.
|
|
511
|
+
setConfig("sight", { keepLatestFrames: 99 });
|
|
512
|
+
expect(resolveSightKeepLatestFrames()).toBe(MAX_SIGHT_KEEP_LATEST_FRAMES);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test("raises a value below the floor", () => {
|
|
516
|
+
setConfig("sight", { keepLatestFrames: 0 });
|
|
517
|
+
expect(resolveSightKeepLatestFrames()).toBe(MIN_SIGHT_KEEP_LATEST_FRAMES);
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
|
|
452
521
|
// ---------------------------------------------------------------------------
|
|
453
522
|
// Metadata reader
|
|
454
523
|
// ---------------------------------------------------------------------------
|
|
@@ -477,3 +546,70 @@ describe("sightFrameAttachmentIdsFromMetadata", () => {
|
|
|
477
546
|
).toEqual(["att-1"]);
|
|
478
547
|
});
|
|
479
548
|
});
|
|
549
|
+
|
|
550
|
+
describe("messageMetadataIsAmbientSightKeep", () => {
|
|
551
|
+
test("a standalone keep carries the tag and the scripted marker", () => {
|
|
552
|
+
// The shape `persistLiveVoiceSightFrame` writes.
|
|
553
|
+
expect(
|
|
554
|
+
messageMetadataIsAmbientSightKeep(
|
|
555
|
+
JSON.stringify({
|
|
556
|
+
voiceSessionTurn: true,
|
|
557
|
+
scripted: true,
|
|
558
|
+
sightFrameAttachmentIds: ["att-1"],
|
|
559
|
+
}),
|
|
560
|
+
),
|
|
561
|
+
).toBe(true);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test("a spoken turn that merely carried a frame is not a keep", () => {
|
|
565
|
+
// The shape the voice bridge writes when a parked frame rides real
|
|
566
|
+
// speech, which in camera mode is every turn the user takes. Keying on
|
|
567
|
+
// the tag alone would read all of it as machine output.
|
|
568
|
+
expect(
|
|
569
|
+
messageMetadataIsAmbientSightKeep(
|
|
570
|
+
JSON.stringify({
|
|
571
|
+
voiceSessionTurn: true,
|
|
572
|
+
scripted: false,
|
|
573
|
+
sightFrameAttachmentIds: ["att-frame-1"],
|
|
574
|
+
}),
|
|
575
|
+
),
|
|
576
|
+
).toBe(false);
|
|
577
|
+
// Older rows predating the always-stamped default carry no marker at all.
|
|
578
|
+
expect(
|
|
579
|
+
messageMetadataIsAmbientSightKeep(
|
|
580
|
+
JSON.stringify({
|
|
581
|
+
voiceSessionTurn: true,
|
|
582
|
+
sightFrameAttachmentIds: ["att-frame-1"],
|
|
583
|
+
}),
|
|
584
|
+
),
|
|
585
|
+
).toBe(false);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
test("an auto-sent row without the tag is not a keep", () => {
|
|
589
|
+
// `scripted` covers every machine-authored send, onboarding prompts
|
|
590
|
+
// included. Those are none of this predicate's business.
|
|
591
|
+
expect(
|
|
592
|
+
messageMetadataIsAmbientSightKeep(JSON.stringify({ scripted: true })),
|
|
593
|
+
).toBe(false);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test("ordinary and unreadable rows are not keeps", () => {
|
|
597
|
+
expect(messageMetadataIsAmbientSightKeep(null)).toBe(false);
|
|
598
|
+
expect(messageMetadataIsAmbientSightKeep("")).toBe(false);
|
|
599
|
+
expect(
|
|
600
|
+
messageMetadataIsAmbientSightKeep(JSON.stringify({ livePhoto: true })),
|
|
601
|
+
).toBe(false);
|
|
602
|
+
expect(
|
|
603
|
+
messageMetadataIsAmbientSightKeep(
|
|
604
|
+
JSON.stringify({ scripted: true, sightFrameAttachmentIds: [] }),
|
|
605
|
+
),
|
|
606
|
+
).toBe(false);
|
|
607
|
+
// Unreadable metadata leaves the row ordinary, so it keeps its work.
|
|
608
|
+
expect(messageMetadataIsAmbientSightKeep("{not json")).toBe(false);
|
|
609
|
+
expect(
|
|
610
|
+
messageMetadataIsAmbientSightKeep(
|
|
611
|
+
JSON.stringify({ scripted: true, sightFrameAttachmentIds: "att-1" }),
|
|
612
|
+
),
|
|
613
|
+
).toBe(false);
|
|
614
|
+
});
|
|
615
|
+
});
|
|
@@ -86,6 +86,7 @@ const BASELINE: Record<string, readonly string[]> = {
|
|
|
86
86
|
"../../../../../messaging/providers/slack/message-metadata.js",
|
|
87
87
|
"../../../../../persistence/auto-analysis-constants.js",
|
|
88
88
|
"../../../../../persistence/checkpoints.js",
|
|
89
|
+
"../../../../../persistence/conversation-types.js",
|
|
89
90
|
"../../../../../persistence/db-connection.js",
|
|
90
91
|
"../../../../../persistence/embeddings/embed.js",
|
|
91
92
|
"../../../../../persistence/embeddings/embedding-backend.js",
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, test } from "bun:test";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
|
+
import {
|
|
4
|
+
KEEP_LATEST_SIGHT_FRAMES,
|
|
5
|
+
MAX_SIGHT_KEEP_LATEST_FRAMES,
|
|
6
|
+
} from "../config/schemas/sight.js";
|
|
3
7
|
import { applySightFrameRetention } from "../daemon/conversation-runtime-assembly.js";
|
|
4
8
|
import {
|
|
5
9
|
getAttachmentMetadataForMessage,
|
|
@@ -17,6 +21,7 @@ import { getDb } from "../persistence/db-connection.js";
|
|
|
17
21
|
import { initializeDb } from "../persistence/db-init.js";
|
|
18
22
|
import { conversations } from "../persistence/schema.js";
|
|
19
23
|
import type { ContentBlock, Message } from "../providers/types.js";
|
|
24
|
+
import { setConfig } from "./helpers/set-config.js";
|
|
20
25
|
|
|
21
26
|
await initializeDb();
|
|
22
27
|
|
|
@@ -185,6 +190,71 @@ describe("applySightFrameRetention", () => {
|
|
|
185
190
|
});
|
|
186
191
|
});
|
|
187
192
|
|
|
193
|
+
describe("configured retention budget", () => {
|
|
194
|
+
afterEach(() => {
|
|
195
|
+
setConfig("sight", {});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
/** One tagged standalone frame per row, the shape a keep stream leaves. */
|
|
199
|
+
async function seedFrames(
|
|
200
|
+
conversationId: string,
|
|
201
|
+
count: number,
|
|
202
|
+
): Promise<Message[]> {
|
|
203
|
+
ensureConversation(conversationId);
|
|
204
|
+
const assembled: Message[] = [];
|
|
205
|
+
for (let index = 0; index < count; index += 1) {
|
|
206
|
+
const attachmentId = `${conversationId}-att-${index}`;
|
|
207
|
+
await addMessage(conversationId, "user", frameContent(attachmentId), {
|
|
208
|
+
metadata: { sightFrameAttachmentIds: [attachmentId] },
|
|
209
|
+
});
|
|
210
|
+
assembled.push({
|
|
211
|
+
role: "user",
|
|
212
|
+
content: [referenceImage(attachmentId)],
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
return assembled;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function imageCount(messages: Message[]): number {
|
|
219
|
+
return messages.filter((message) => message.content[0].type === "image")
|
|
220
|
+
.length;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
test("keeps the number of frames the workspace configured", async () => {
|
|
224
|
+
setConfig("sight", { keepLatestFrames: 4 });
|
|
225
|
+
const conversationId = "conv-sight-budget-configured";
|
|
226
|
+
const assembled = await seedFrames(conversationId, 6);
|
|
227
|
+
|
|
228
|
+
const retained = applySightFrameRetention(assembled, conversationId);
|
|
229
|
+
|
|
230
|
+
expect(imageCount(retained)).toBe(4);
|
|
231
|
+
expect(retained.length - imageCount(retained)).toBe(2);
|
|
232
|
+
// The survivors are the newest, which is what recency buys the model.
|
|
233
|
+
expect(retained.slice(2).every((m) => m.content[0].type === "image")).toBe(
|
|
234
|
+
true,
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("caps a configured value above the ceiling", async () => {
|
|
239
|
+
setConfig("sight", { keepLatestFrames: 99 });
|
|
240
|
+
const conversationId = "conv-sight-budget-capped";
|
|
241
|
+
const assembled = await seedFrames(conversationId, 8);
|
|
242
|
+
|
|
243
|
+
const retained = applySightFrameRetention(assembled, conversationId);
|
|
244
|
+
|
|
245
|
+
expect(imageCount(retained)).toBe(MAX_SIGHT_KEEP_LATEST_FRAMES);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("falls back to the default when the workspace configures nothing", async () => {
|
|
249
|
+
const conversationId = "conv-sight-budget-default";
|
|
250
|
+
const assembled = await seedFrames(conversationId, 5);
|
|
251
|
+
|
|
252
|
+
const retained = applySightFrameRetention(assembled, conversationId);
|
|
253
|
+
|
|
254
|
+
expect(imageCount(retained)).toBe(KEEP_LATEST_SIGHT_FRAMES);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
188
258
|
describe("forked conversations", () => {
|
|
189
259
|
test("a fork's tag covers both its source and cloned attachment ids", async () => {
|
|
190
260
|
// A fork copies `messages.content` verbatim, so its blocks still name the
|
package/src/config/schema.ts
CHANGED
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
import { PluginUpdatesConfigSchema } from "./schemas/plugin-updates.js";
|
|
55
55
|
import { SecretDetectionConfigSchema } from "./schemas/security.js";
|
|
56
56
|
import { ServicesSchema } from "./schemas/services.js";
|
|
57
|
+
import { SightConfigSchema } from "./schemas/sight.js";
|
|
57
58
|
import { SkillsConfigSchema } from "./schemas/skills.js";
|
|
58
59
|
import {
|
|
59
60
|
RateLimitConfigSchema,
|
|
@@ -118,6 +119,7 @@ export const AssistantConfigSchema = z.object({
|
|
|
118
119
|
twilio: TwilioConfigSchema.default(TwilioConfigSchema.parse({})),
|
|
119
120
|
calls: CallsConfigSchema.default(CallsConfigSchema.parse({})),
|
|
120
121
|
liveVoice: LiveVoiceConfigSchema.default(LiveVoiceConfigSchema.parse({})),
|
|
122
|
+
sight: SightConfigSchema.default(SightConfigSchema.parse({})),
|
|
121
123
|
whatsapp: WhatsAppConfigSchema.default(WhatsAppConfigSchema.parse({})),
|
|
122
124
|
telegram: TelegramConfigSchema.default(TelegramConfigSchema.parse({})),
|
|
123
125
|
slack: SlackConfigSchema.default(SlackConfigSchema.parse({})),
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How many camera frames stay in the model's context as real images, counted
|
|
5
|
+
* newest-first across the whole conversation so the frames from the last few
|
|
6
|
+
* moments are the ones that survive. Older frames become timestamped text
|
|
7
|
+
* stubs (`daemon/conversation-sight-frames.ts`).
|
|
8
|
+
*
|
|
9
|
+
* Smaller than `RETRY_KEEP_LATEST_MEDIA_BLOCKS` (3, in
|
|
10
|
+
* `daemon/conversation-media-retry.ts`) because the two budgets answer
|
|
11
|
+
* different questions. That one is reactive and spends a rejected request's
|
|
12
|
+
* remaining room across every kind of media, including files the user
|
|
13
|
+
* deliberately sent. This one is proactive, applied to every assembly, and
|
|
14
|
+
* governs only frames the camera sampled by itself: background the model
|
|
15
|
+
* glances at, not something anyone chose to attach.
|
|
16
|
+
*/
|
|
17
|
+
export const KEEP_LATEST_SIGHT_FRAMES = 2;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Floor for `sight.keepLatestFrames`. A call whose camera is up keeps at least
|
|
21
|
+
* the current view live; stubbing every frame would leave the model reading
|
|
22
|
+
* timestamps about pictures it can no longer see.
|
|
23
|
+
*/
|
|
24
|
+
export const MIN_SIGHT_KEEP_LATEST_FRAMES = 1;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Ceiling for `sight.keepLatestFrames`. History resends every inline image on
|
|
28
|
+
* every later request, so the ceiling is what stops a long call from growing
|
|
29
|
+
* its own context until the provider rejects it.
|
|
30
|
+
*/
|
|
31
|
+
export const MAX_SIGHT_KEEP_LATEST_FRAMES = 6;
|
|
32
|
+
|
|
33
|
+
export const SightConfigSchema = z
|
|
34
|
+
.object({
|
|
35
|
+
keepLatestFrames: z
|
|
36
|
+
.number({ error: "sight.keepLatestFrames must be a number" })
|
|
37
|
+
.int("sight.keepLatestFrames must be an integer")
|
|
38
|
+
.default(KEEP_LATEST_SIGHT_FRAMES)
|
|
39
|
+
.describe(
|
|
40
|
+
`How many of the newest camera frames a live-voice call keeps in the model's context as images; older frames become timestamped text stubs. Clamped to ${MIN_SIGHT_KEEP_LATEST_FRAMES}..${MAX_SIGHT_KEEP_LATEST_FRAMES} when read.`,
|
|
41
|
+
),
|
|
42
|
+
})
|
|
43
|
+
.describe("Ambient camera frames sampled during a live-voice call");
|
|
44
|
+
|
|
45
|
+
export type SightConfig = z.infer<typeof SightConfigSchema>;
|