@vellumai/assistant 0.9.0-dev.202606180940.f141ea3 → 0.9.0-dev.202606181130.57d4082

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 CHANGED
@@ -12116,6 +12116,19 @@ paths:
12116
12116
  propertyNames:
12117
12117
  type: string
12118
12118
  additionalProperties: {}
12119
+ sourceType:
12120
+ type: string
12121
+ enum:
12122
+ - heartbeat
12123
+ - memory_consolidation
12124
+ - schedule
12125
+ - auto_analysis
12126
+ - user
12127
+ - other
12128
+ sourceKey:
12129
+ type: string
12130
+ sourceLabel:
12131
+ type: string
12119
12132
  createdAt:
12120
12133
  type: string
12121
12134
  required:
@@ -12296,6 +12309,19 @@ paths:
12296
12309
  propertyNames:
12297
12310
  type: string
12298
12311
  additionalProperties: {}
12312
+ sourceType:
12313
+ type: string
12314
+ enum:
12315
+ - heartbeat
12316
+ - memory_consolidation
12317
+ - schedule
12318
+ - auto_analysis
12319
+ - user
12320
+ - other
12321
+ sourceKey:
12322
+ type: string
12323
+ sourceLabel:
12324
+ type: string
12299
12325
  createdAt:
12300
12326
  type: string
12301
12327
  required:
@@ -12508,6 +12534,19 @@ paths:
12508
12534
  propertyNames:
12509
12535
  type: string
12510
12536
  additionalProperties: {}
12537
+ sourceType:
12538
+ type: string
12539
+ enum:
12540
+ - heartbeat
12541
+ - memory_consolidation
12542
+ - schedule
12543
+ - auto_analysis
12544
+ - user
12545
+ - other
12546
+ sourceKey:
12547
+ type: string
12548
+ sourceLabel:
12549
+ type: string
12511
12550
  createdAt:
12512
12551
  type: string
12513
12552
  required:
@@ -17936,6 +17975,19 @@ paths:
17936
17975
  propertyNames:
17937
17976
  type: string
17938
17977
  additionalProperties: {}
17978
+ sourceType:
17979
+ type: string
17980
+ enum:
17981
+ - heartbeat
17982
+ - memory_consolidation
17983
+ - schedule
17984
+ - auto_analysis
17985
+ - user
17986
+ - other
17987
+ sourceKey:
17988
+ type: string
17989
+ sourceLabel:
17990
+ type: string
17939
17991
  createdAt:
17940
17992
  type: string
17941
17993
  required:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.9.0-dev.202606180940.f141ea3",
3
+ "version": "0.9.0-dev.202606181130.57d4082",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
package/src/api/index.ts CHANGED
@@ -385,6 +385,8 @@ export {
385
385
  FeedItemDetailPanelKindSchema,
386
386
  FeedItemDetailPanelSchema,
387
387
  FeedItemSchema,
388
+ type FeedItemSourceType,
389
+ FeedItemSourceTypeSchema,
388
390
  type FeedItemStatus,
389
391
  FeedItemStatusSchema,
390
392
  type FeedItemType,
@@ -55,6 +55,25 @@ export const FeedItemCategorySchema = z.enum([
55
55
  ]);
56
56
  export type FeedItemCategory = z.infer<typeof FeedItemCategorySchema>;
57
57
 
58
+ /**
59
+ * Producer of a feed item's source conversation — lets clients filter the
60
+ * activity feed by what generated each notification: the periodic
61
+ * heartbeat, a memory-consolidation pass, a recurring schedule, an
62
+ * auto-analysis run, etc. Derived at read time from the source
63
+ * conversation's `source` column (see `home/feed-source-enrichment.ts`).
64
+ * Individual schedules are distinguished by `sourceKey`/`sourceLabel`, not
65
+ * this coarse type.
66
+ */
67
+ export const FeedItemSourceTypeSchema = z.enum([
68
+ "heartbeat",
69
+ "memory_consolidation",
70
+ "schedule",
71
+ "auto_analysis",
72
+ "user",
73
+ "other",
74
+ ]);
75
+ export type FeedItemSourceType = z.infer<typeof FeedItemSourceTypeSchema>;
76
+
58
77
  /**
59
78
  * A single action button attached to a feed item.
60
79
  *
@@ -120,6 +139,13 @@ export const FeedItemSchema = z.object({
120
139
  noteworthy: z.boolean().optional(),
121
140
  fromAssistant: z.boolean().optional(),
122
141
  metadata: z.record(z.string(), z.unknown()).optional(),
142
+ // Source-conversation classification, enriched at read time. `sourceKey`
143
+ // is the stable filter id — `schedule:<id>` for schedules so each filters
144
+ // separately, otherwise the `sourceType`. `sourceLabel` is the display
145
+ // string (a schedule's name, or a static label like "Heartbeat").
146
+ sourceType: FeedItemSourceTypeSchema.optional(),
147
+ sourceKey: z.string().optional(),
148
+ sourceLabel: z.string().optional(),
123
149
  createdAt: z.string(),
124
150
  });
125
151
  export type FeedItem = z.infer<typeof FeedItemSchema>;
@@ -0,0 +1,151 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import { type FeedItem } from "../api/responses/home.js";
4
+ import {
5
+ classifyConversationSource,
6
+ enrichFeedItemsWithSource,
7
+ type FeedSourceEnrichmentDeps,
8
+ } from "./feed-source-enrichment.js";
9
+
10
+ function makeItem(partial: Partial<FeedItem> & Pick<FeedItem, "id">): FeedItem {
11
+ return {
12
+ type: "notification",
13
+ priority: 50,
14
+ summary: "summary",
15
+ timestamp: "2026-06-18T00:00:00.000Z",
16
+ createdAt: "2026-06-18T00:00:00.000Z",
17
+ status: "new",
18
+ ...partial,
19
+ };
20
+ }
21
+
22
+ describe("classifyConversationSource", () => {
23
+ it("maps each known producer source to its coarse type", () => {
24
+ expect(classifyConversationSource("heartbeat")).toBe("heartbeat");
25
+ expect(classifyConversationSource("memory-retrospective")).toBe(
26
+ "memory_consolidation",
27
+ );
28
+ expect(classifyConversationSource("memory-retrospective-fork")).toBe(
29
+ "memory_consolidation",
30
+ );
31
+ expect(classifyConversationSource("schedule")).toBe("schedule");
32
+ expect(classifyConversationSource("auto-analysis")).toBe("auto_analysis");
33
+ expect(classifyConversationSource("user")).toBe("user");
34
+ expect(classifyConversationSource("home-feed")).toBe("user");
35
+ });
36
+
37
+ it("falls through to 'other' for unknown / paired-delivery / empty sources", () => {
38
+ expect(classifyConversationSource("notification")).toBe("other");
39
+ expect(classifyConversationSource("something-else")).toBe("other");
40
+ expect(classifyConversationSource(null)).toBe("other");
41
+ expect(classifyConversationSource(undefined)).toBe("other");
42
+ });
43
+ });
44
+
45
+ describe("enrichFeedItemsWithSource", () => {
46
+ const deps = (
47
+ conv: Record<string, { source: string; scheduleJobId: string | null }>,
48
+ schedules: Record<string, string> = {},
49
+ ): FeedSourceEnrichmentDeps => ({
50
+ getConversationRow: (id) => conv[id] ?? null,
51
+ getScheduleName: (id) => schedules[id] ?? null,
52
+ });
53
+
54
+ it("classifies a heartbeat item with a static label", () => {
55
+ const [item] = enrichFeedItemsWithSource(
56
+ [makeItem({ id: "n1", conversationId: "c1" })],
57
+ deps({ c1: { source: "heartbeat", scheduleJobId: null } }),
58
+ );
59
+ expect(item.sourceType).toBe("heartbeat");
60
+ expect(item.sourceKey).toBe("heartbeat");
61
+ expect(item.sourceLabel).toBe("Heartbeat");
62
+ });
63
+
64
+ it("classifies a memory-consolidation item", () => {
65
+ const [item] = enrichFeedItemsWithSource(
66
+ [makeItem({ id: "n2", conversationId: "c2" })],
67
+ deps({ c2: { source: "memory-retrospective", scheduleJobId: null } }),
68
+ );
69
+ expect(item.sourceType).toBe("memory_consolidation");
70
+ expect(item.sourceKey).toBe("memory_consolidation");
71
+ expect(item.sourceLabel).toBe("Memory consolidation");
72
+ });
73
+
74
+ it("gives distinct keys and names to two different schedules", () => {
75
+ const [a, b] = enrichFeedItemsWithSource(
76
+ [
77
+ makeItem({ id: "n3", conversationId: "cA" }),
78
+ makeItem({ id: "n4", conversationId: "cB" }),
79
+ ],
80
+ deps(
81
+ {
82
+ cA: { source: "schedule", scheduleJobId: "sched-A" },
83
+ cB: { source: "schedule", scheduleJobId: "sched-B" },
84
+ },
85
+ { "sched-A": "Morning digest", "sched-B": "Evening recap" },
86
+ ),
87
+ );
88
+ expect(a.sourceType).toBe("schedule");
89
+ expect(a.sourceKey).toBe("schedule:sched-A");
90
+ expect(a.sourceLabel).toBe("Morning digest");
91
+ // scheduleId recovered from the conversation row is surfaced in metadata.
92
+ expect(a.metadata?.scheduleId).toBe("sched-A");
93
+
94
+ expect(b.sourceKey).toBe("schedule:sched-B");
95
+ expect(b.sourceLabel).toBe("Evening recap");
96
+ });
97
+
98
+ it("prefers an explicit metadata.scheduleId over the conversation row", () => {
99
+ const [item] = enrichFeedItemsWithSource(
100
+ [
101
+ makeItem({
102
+ id: "n5",
103
+ conversationId: "cX",
104
+ metadata: { scheduleId: "from-payload" },
105
+ }),
106
+ ],
107
+ deps(
108
+ { cX: { source: "schedule", scheduleJobId: "from-row" } },
109
+ { "from-payload": "Payload schedule" },
110
+ ),
111
+ );
112
+ expect(item.sourceKey).toBe("schedule:from-payload");
113
+ expect(item.sourceLabel).toBe("Payload schedule");
114
+ });
115
+
116
+ it("labels a schedule with no resolvable name as 'Scheduled'", () => {
117
+ const [item] = enrichFeedItemsWithSource(
118
+ [makeItem({ id: "n6", conversationId: "cY" })],
119
+ deps({ cY: { source: "schedule", scheduleJobId: "gone" } }),
120
+ );
121
+ expect(item.sourceKey).toBe("schedule:gone");
122
+ expect(item.sourceLabel).toBe("Scheduled");
123
+ });
124
+
125
+ it("classifies items with no source conversation as 'other'", () => {
126
+ const [item] = enrichFeedItemsWithSource(
127
+ [makeItem({ id: "n7" })],
128
+ deps({}),
129
+ );
130
+ expect(item.sourceType).toBe("other");
131
+ expect(item.sourceKey).toBe("other");
132
+ expect(item.sourceLabel).toBe("Other");
133
+ });
134
+
135
+ it("resolves each distinct conversation at most once", () => {
136
+ const calls: string[] = [];
137
+ enrichFeedItemsWithSource(
138
+ [
139
+ makeItem({ id: "n8", conversationId: "dup" }),
140
+ makeItem({ id: "n9", conversationId: "dup" }),
141
+ ],
142
+ {
143
+ getConversationRow: (id) => {
144
+ calls.push(id);
145
+ return { source: "heartbeat", scheduleJobId: null };
146
+ },
147
+ },
148
+ );
149
+ expect(calls).toEqual(["dup"]);
150
+ });
151
+ });
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Read-time enrichment of home-feed items with their source-conversation
3
+ * classification.
4
+ *
5
+ * The activity feed surfaces notifications produced by many background
6
+ * flows — the periodic heartbeat, memory-consolidation passes, each
7
+ * recurring schedule, auto-analysis runs, etc. Clients let the user filter
8
+ * the feed by that producer, so every item is tagged with:
9
+ *
10
+ * - `sourceType` — coarse producer category.
11
+ * - `sourceKey` — stable filter id (`schedule:<id>` per schedule,
12
+ * otherwise the `sourceType`).
13
+ * - `sourceLabel` — human-readable display (a schedule's name, or a
14
+ * static label such as "Heartbeat").
15
+ *
16
+ * The classification is derived from the source conversation's `source`
17
+ * column (and, for schedules, its name) rather than persisted onto the
18
+ * feed item. Resolving at read time keeps labels fresh across schedule
19
+ * renames and retroactively classifies items written before this feature
20
+ * existed, with no migration.
21
+ *
22
+ * The lookups are injectable so the enrichment can be unit-tested without
23
+ * a live database; production callers use the defaults.
24
+ */
25
+
26
+ import {
27
+ type FeedItem,
28
+ type FeedItemSourceType,
29
+ } from "../api/responses/home.js";
30
+ import { AUTO_ANALYSIS_SOURCE } from "../memory/auto-analysis-constants.js";
31
+ import { getConversation } from "../memory/conversation-crud.js";
32
+ import {
33
+ MEMORY_RETROSPECTIVE_FORK_SOURCE,
34
+ MEMORY_RETROSPECTIVE_SOURCE,
35
+ } from "../memory/memory-retrospective-constants.js";
36
+ import { getSchedule } from "../schedule/schedule-store.js";
37
+
38
+ /** Minimal source-conversation shape the enrichment needs. */
39
+ interface ConversationSourceRow {
40
+ source: string;
41
+ scheduleJobId: string | null;
42
+ }
43
+
44
+ export interface FeedSourceEnrichmentDeps {
45
+ /** Resolve the source columns for a conversation, or null when missing. */
46
+ getConversationRow?: (id: string) => ConversationSourceRow | null;
47
+ /** Resolve a schedule's display name, or null when missing. */
48
+ getScheduleName?: (id: string) => string | null;
49
+ }
50
+
51
+ /**
52
+ * Map a conversation's `source` column to a coarse feed source type.
53
+ * Mechanical mapping over the known producer sources; anything else
54
+ * (including the broadcaster's paired `"notification"` delivery
55
+ * conversations) falls through to `"other"`.
56
+ */
57
+ export function classifyConversationSource(
58
+ source: string | null | undefined,
59
+ ): FeedItemSourceType {
60
+ switch (source) {
61
+ case "heartbeat":
62
+ return "heartbeat";
63
+ case MEMORY_RETROSPECTIVE_SOURCE:
64
+ case MEMORY_RETROSPECTIVE_FORK_SOURCE:
65
+ return "memory_consolidation";
66
+ case "schedule":
67
+ return "schedule";
68
+ case AUTO_ANALYSIS_SOURCE:
69
+ return "auto_analysis";
70
+ case "user":
71
+ case "home-feed":
72
+ return "user";
73
+ default:
74
+ return "other";
75
+ }
76
+ }
77
+
78
+ /** Static display labels for non-schedule source types. */
79
+ const STATIC_SOURCE_LABELS: Record<
80
+ Exclude<FeedItemSourceType, "schedule">,
81
+ string
82
+ > = {
83
+ heartbeat: "Heartbeat",
84
+ memory_consolidation: "Memory consolidation",
85
+ auto_analysis: "Auto-analysis",
86
+ user: "Conversation",
87
+ other: "Other",
88
+ };
89
+
90
+ function defaultGetConversationRow(id: string): ConversationSourceRow | null {
91
+ try {
92
+ const row = getConversation(id);
93
+ return row
94
+ ? { source: row.source, scheduleJobId: row.scheduleJobId }
95
+ : null;
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
101
+ function defaultGetScheduleName(id: string): string | null {
102
+ try {
103
+ return getSchedule(id)?.name ?? null;
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Return a copy of each feed item enriched with `sourceType`, `sourceKey`,
111
+ * and `sourceLabel`. Conversation and schedule lookups are memoized across
112
+ * the batch so each distinct source is resolved at most once.
113
+ */
114
+ export function enrichFeedItemsWithSource(
115
+ items: FeedItem[],
116
+ deps: FeedSourceEnrichmentDeps = {},
117
+ ): FeedItem[] {
118
+ if (items.length === 0) return items;
119
+
120
+ const getConversationRow =
121
+ deps.getConversationRow ?? defaultGetConversationRow;
122
+ const getScheduleName = deps.getScheduleName ?? defaultGetScheduleName;
123
+
124
+ const convCache = new Map<string, ConversationSourceRow | null>();
125
+ const resolveConv = (id: string): ConversationSourceRow | null => {
126
+ if (!convCache.has(id)) convCache.set(id, getConversationRow(id));
127
+ return convCache.get(id) ?? null;
128
+ };
129
+
130
+ const scheduleNameCache = new Map<string, string | null>();
131
+ const resolveScheduleName = (id: string): string | null => {
132
+ if (!scheduleNameCache.has(id)) {
133
+ scheduleNameCache.set(id, getScheduleName(id));
134
+ }
135
+ return scheduleNameCache.get(id) ?? null;
136
+ };
137
+
138
+ return items.map((item) => {
139
+ const row = item.conversationId ? resolveConv(item.conversationId) : null;
140
+ const sourceType = classifyConversationSource(row?.source);
141
+
142
+ const metadataScheduleId =
143
+ typeof item.metadata?.scheduleId === "string"
144
+ ? item.metadata.scheduleId
145
+ : undefined;
146
+ const scheduleId = metadataScheduleId ?? row?.scheduleJobId ?? undefined;
147
+
148
+ let sourceKey: string;
149
+ let sourceLabel: string;
150
+ if (sourceType === "schedule" && scheduleId) {
151
+ sourceKey = `schedule:${scheduleId}`;
152
+ sourceLabel = resolveScheduleName(scheduleId) ?? "Scheduled";
153
+ } else if (sourceType === "schedule") {
154
+ sourceKey = "schedule";
155
+ sourceLabel = "Scheduled";
156
+ } else {
157
+ sourceKey = sourceType;
158
+ sourceLabel = STATIC_SOURCE_LABELS[sourceType];
159
+ }
160
+
161
+ // Surface the scheduleId in metadata when it was recovered from the
162
+ // source conversation so clients have a single place to read it.
163
+ const metadata =
164
+ scheduleId !== undefined && metadataScheduleId === undefined
165
+ ? { ...(item.metadata ?? {}), scheduleId }
166
+ : item.metadata;
167
+
168
+ return {
169
+ ...item,
170
+ sourceType,
171
+ sourceKey,
172
+ sourceLabel,
173
+ ...(metadata ? { metadata } : {}),
174
+ };
175
+ });
176
+ }
@@ -28,6 +28,7 @@ import {
28
28
  type FeedItemStatus,
29
29
  HomeFeedResponseSchema,
30
30
  } from "../../api/responses/home.js";
31
+ import { enrichFeedItemsWithSource } from "../../home/feed-source-enrichment.js";
31
32
  import { patchFeedItemStatus, readHomeFeed } from "../../home/feed-writer.js";
32
33
  import { revalidateHomeContentInBackground } from "../../home/home-content-refresh.js";
33
34
  import { getPersonalizedGreeting } from "../../home/home-greeting.js";
@@ -135,8 +136,10 @@ export async function handleGetHomeFeed({
135
136
  // v2 schema dropped per-item `minTimeAway` gating; surface every item
136
137
  // and let the client decide what to render based on its own
137
138
  // session state. `timeAwaySeconds` survives only to feed the
138
- // context-banner relative-time label.
139
- const filtered = feed.items;
139
+ // context-banner relative-time label. Each item is enriched with its
140
+ // source-conversation classification (`sourceType`/`sourceKey`/
141
+ // `sourceLabel`) so clients can filter the feed by what produced it.
142
+ const filtered = enrichFeedItemsWithSource(feed.items);
140
143
 
141
144
  const now = new Date();
142
145
 
@@ -287,7 +290,9 @@ export function handleListHomeFeed({
287
290
  const total = filtered.length;
288
291
  const offset = params.offset ?? 0;
289
292
  const limit = params.limit ?? 20;
290
- const items = filtered.slice(offset, offset + limit);
293
+ const items = enrichFeedItemsWithSource(
294
+ filtered.slice(offset, offset + limit),
295
+ );
291
296
 
292
297
  return {
293
298
  items,