@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.7-dev.202608312314.14ddc99",
3
+ "version": "0.11.7-dev.202609010224.44cd29e",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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
- KEEP_LATEST_SIGHT_FRAMES,
13
+ resolveSightKeepLatestFrames,
9
14
  stripAgedSightFrames,
10
15
  } from "../daemon/conversation-sight-frames.js";
11
- import { sightFrameAttachmentIdsFromMetadata } from "../persistence/conversation-types.js";
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
@@ -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>;