From d42edfd54db4bf2e6ee7606644dcfc5c386fd251 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:16:25 -0700 Subject: [PATCH 1/5] fix(digest): a digest reads the recipient's own pods and nothing else Cofounder's condition on #2210 before any outside digest goes out (TASK-257's first commit). The summary query OR'd in every `posts` summary and every pod-less chat summary, whatever pods the recipient is in, so one posts summary would have mailed a pod's activity to its non-members. The dry run on 10-16 found zero such rows, so nothing has leaked; the clause is gone before it can. Test: a member's digest carries their own pod's summary and neither another pod's posts summary nor a pod-less chat summary. It is red on the old query. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157AG2wo8mJYqKezXNs48Ya --- .../dailyDigestService.membership.test.js | 35 +++++++++++++++++++ backend/services/dailyDigestService.ts | 9 +++-- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/backend/__tests__/unit/services/dailyDigestService.membership.test.js b/backend/__tests__/unit/services/dailyDigestService.membership.test.js index 50a308a188..0736770c84 100644 --- a/backend/__tests__/unit/services/dailyDigestService.membership.test.js +++ b/backend/__tests__/unit/services/dailyDigestService.membership.test.js @@ -55,6 +55,41 @@ test('digest reads current pod membership without subscribedPods and excludes an expect(digest.content).not.toContain('Other Pod'); }); +// Cofounder's condition on #2210, before any outside digest: the query used to +// add every `posts` summary and every pod-less chat summary to EVERY digest, +// whatever pods the recipient is in. Zero such rows existed on 10-16, so +// nothing leaked, but one posts summary would have mailed one pod's activity +// to non-members. A digest reads the recipient's own pods and nothing else. +test("a digest never carries another pod's posts summary or a pod-less summary", async () => { + const member = await person(); + const outsider = await person(); + await podWithSummary(member, 'Member Pod'); + const elsewhere = await Pod.create({ name: 'Private Elsewhere', createdBy: outsider._id, members: [outsider._id] }); + await Summary.create({ + type: 'posts', + podId: elsewhere._id, + title: 'Private Elsewhere posts', + content: '- A private post', + timeRange: { start: new Date(Date.now() - 60000), end: new Date() }, + metadata: { totalItems: 1 }, + }); + await Summary.create({ + type: 'chats', + title: 'Pod-less chat summary', + content: '- Somewhere else', + timeRange: { start: new Date(Date.now() - 60000), end: new Date() }, + metadata: { totalItems: 1 }, + }); + const service = new DailyDigestService(); + jest.spyOn(service, 'generateDigestContent').mockImplementation(async (data) => + Object.keys(data.byPod).join(', ')); + + const digest = await service.generateUserDailyDigest(member._id); + + expect(digest.metadata.totalItems).toBe(1); + expect(digest.content).toBe('Member Pod'); +}); + test('scheduler includes a member with recent pod work even after seven days away', async () => { const member = await person(new Date(Date.now() - 10 * 86400000)); const creator = await person(new Date(Date.now() - 10 * 86400000)); diff --git a/backend/services/dailyDigestService.ts b/backend/services/dailyDigestService.ts index 6fc4676618..bbcf71d918 100644 --- a/backend/services/dailyDigestService.ts +++ b/backend/services/dailyDigestService.ts @@ -86,12 +86,11 @@ export class DailyDigestService { const pods = await Pod.find({ members: userId }).select('_id').lean() as Array<{ _id: unknown }>; const podIds = pods.map((pod) => pod._id); + // The recipient's own pods and nothing else. The query used to add every + // `posts` summary and every pod-less chat summary to every digest, so one + // posts summary would have mailed a pod's activity to its non-members. const summaries = await Summary.find({ - $or: [ - { podId: { $in: podIds } }, - { type: 'posts' }, - { type: 'chats', podId: { $exists: false } }, - ], + podId: { $in: podIds }, createdAt: { $gte: startTime, $lte: endTime }, }) .populate('podId', 'name type') From 8c40620af22ee47c299c0032ae2d9f9754ac8e25 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:23:09 -0700 Subject: [PATCH 2/5] feat(digest): the daily digest is what your agents did while you were away Stickiness Phase 2 (TASK-257; Growth spec 2026-10-15). The digest was an LLM narration of chat summaries. It is now the activity recap, the same read the Activity page shows, for the person's own pods over the last 24 hours: - "Waiting on you: ()", up to 3; - " in : (+N more)", up to 5 agents; - " is <status> (<pod>)", up to 5 board moves. The email shows the top 5. Each line states something that happened, so there is no LLM step, no prompt and no narration to get wrong. - The scheduled run gives someone active in the last 20h an empty digest: they have seen it in the app (DIGEST_AWAY_MS). An on-demand generation (POST /daily-digest/generate) has no away rule. - Scout is the guide, not one of "your agents": its welcome would otherwise make every new account's first digest say an agent did something. - A public pod's agent posts and board moves stay out; what waits on you stays in. Found by a dry run against live data before shipping: every signup is in Commonly HQ, so 6 outside strangers' only line would have been a support answer to another person. With the filter, the same data gives 0 digests today (47 candidates, 35 active in the last 20h, 12 away, none with private-pod agent work), which is the honest answer. - Removed with the narration: generateDigestContent, the prompt builder, the cross-conversation insight and score helpers, digestTemplateService.ts, and the fail-closed test arm for the deleted LLM call. createEmptyDigest and the scheduler's selection are unchanged; the scheduler passes requireAway. Tests: the line order and contents; Scout-only is empty; the away rule for the scheduled run only; a public pod's agent work and board moves excluded while its needs-you is kept; summaries are not an input; the scheduler passes requireAway. Mutations: needs-you dropped, Scout not excluded, away rule off, scheduler without requireAway, and the public filter off each fail an arm. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0157AG2wo8mJYqKezXNs48Ya --- .../dailyDigestService.membership.test.js | 140 ++++-- .../services/llmFallbacks.failClosed.test.ts | 14 +- backend/services/dailyDigestService.ts | 427 +++++------------- backend/services/digestTemplateService.ts | 245 ---------- 4 files changed, 219 insertions(+), 607 deletions(-) delete mode 100644 backend/services/digestTemplateService.ts diff --git a/backend/__tests__/unit/services/dailyDigestService.membership.test.js b/backend/__tests__/unit/services/dailyDigestService.membership.test.js index 0736770c84..6ee4d27ff3 100644 --- a/backend/__tests__/unit/services/dailyDigestService.membership.test.js +++ b/backend/__tests__/unit/services/dailyDigestService.membership.test.js @@ -3,6 +3,7 @@ const User = require('../../../models/User'); const Pod = require('../../../models/Pod'); const Summary = require('../../../models/Summary'); const { DailyDigestService } = require('../../../services/dailyDigestService'); +const ActivityService = require('../../../services/activityService'); let serial = 0; const person = async (lastActive = new Date()) => { @@ -37,31 +38,110 @@ beforeAll(setupMongoDb); afterAll(closeMongoDb); afterEach(clearMongoDb); -test('digest reads current pod membership without subscribedPods and excludes another pod', async () => { - const member = await person(); - const creator = await person(); - const other = await person(); - await podWithSummary(creator, 'Member Pod', { members: [member] }); - await podWithSummary(other, 'Other Pod'); +// TASK-257 (stickiness Phase 2): the digest is "what your agents did while you +// were away", built from the activity recap (which reads Pod.members itself), +// never from chat summaries, and with no LLM step. +const recap = (overrides = {}) => ({ + pods: [{ id: 'p1', name: 'Payments API' }], + needsYou: [], + agents: [], + board: [], + ...overrides, +}); + +test('the digest is the recap: what waits on you, then agent work, then the board', async () => { + const member = await person(new Date(Date.now() - 2 * 86400000)); + const spy = jest.spyOn(ActivityService, 'getRecap').mockResolvedValue(recap({ + needsYou: [{ title: 'Rivet requests approval', podName: 'Payments API' }], + agents: [{ + name: 'Rivet', + messageCount: 3, + recap: 'Posted 3 updates.', + updates: [ + { podId: 'p1', podName: 'Payments API', content: 'Opened #41: retry the webhook on 502' }, + { podId: 'p1', podName: 'Payments API', content: 'Ran the suite: green' }, + { podId: 'p1', podName: 'Payments API', content: 'Picked up TASK-7' }, + ], + }], + board: [{ + taskId: 'TASK-7', title: 'Fix the refund rounding', status: 'done', podName: 'Payments API', + }], + })); + + const digest = await new DailyDigestService().generateUserDailyDigest(member._id, { requireAway: true }); + + expect(spy).toHaveBeenCalledWith(member._id, { window: 'today' }); + expect(digest.title).toBe('What your agents did while you were away'); + expect(digest.metadata.totalItems).toBe(3); + expect(digest.content.split('\n')).toEqual([ + '- Waiting on you: Rivet requests approval (Payments API)', + '- Rivet in Payments API: Opened #41: retry the webhook on 502 (+2 more)', + '- TASK-7 Fix the refund rounding is done (Payments API)', + ]); + spy.mockRestore(); +}); + +// Dry run, 2026-10-16: every outside recipient's only line was a support +// answer to someone else in Commonly HQ, the public pod every signup joins. +test("a public pod's agent posts and board moves stay out; what waits on you stays in", async () => { + const member = await person(new Date(Date.now() - 2 * 86400000)); + const hq = await Pod.create({ + name: 'Commonly HQ', createdBy: member._id, members: [member._id], publicRead: true, + }); + const spy = jest.spyOn(ActivityService, 'getRecap').mockResolvedValue(recap({ + needsYou: [{ title: 'Commonly Support mentioned you', podName: 'Commonly HQ', podId: String(hq._id) }], + agents: [{ + name: 'Commonly Support', + messageCount: 1, + updates: [{ podId: String(hq._id), podName: 'Commonly HQ', content: "@someone-else Here's the path" }], + }], + board: [{ + taskId: 'TASK-1', title: 'Public row', status: 'done', podId: String(hq._id), podName: 'Commonly HQ', + }], + })); + + const digest = await new DailyDigestService().generateUserDailyDigest(member._id, { requireAway: true }); + + expect(digest.content.split('\n')).toEqual(['- Waiting on you: Commonly Support mentioned you (Commonly HQ)']); + spy.mockRestore(); +}); + +test("Scout's welcome is not 'your agents' work': a recap of only Scout is an empty digest", async () => { + const member = await person(new Date(Date.now() - 2 * 86400000)); + const spy = jest.spyOn(ActivityService, 'getRecap').mockResolvedValue(recap({ + agents: [{ name: 'Scout', messageCount: 1, updates: [{ podName: 'My Workspace', content: "I'm Scout." }] }], + })); + + const digest = await new DailyDigestService().generateUserDailyDigest(member._id, { requireAway: true }); + + expect(digest.metadata.totalItems).toBe(0); + spy.mockRestore(); +}); + +test('the scheduled run skips someone active in the last 20 hours; an on-demand digest does not', async () => { + const member = await person(new Date()); + const spy = jest.spyOn(ActivityService, 'getRecap').mockResolvedValue(recap({ + board: [{ + taskId: 'TASK-9', title: 'Ship it', status: 'done', podName: 'Payments API', + }], + })); const service = new DailyDigestService(); - jest.spyOn(service, 'generateDigestContent').mockImplementation(async (data) => - Object.keys(data.byPod).join(', ')); - const digest = await service.generateUserDailyDigest(member._id); + const scheduled = await service.generateUserDailyDigest(member._id, { requireAway: true }); + expect(scheduled.metadata.totalItems).toBe(0); + expect(spy).not.toHaveBeenCalled(); - expect(member.subscribedPods).toHaveLength(0); - expect(digest.metadata.totalItems).toBe(1); - expect(digest.content).toContain('Member Pod'); - expect(digest.content).not.toContain('Other Pod'); + const onDemand = await service.generateUserDailyDigest(member._id); + expect(onDemand.metadata.totalItems).toBe(1); + expect(onDemand.title).toBe('What your agents did today'); + spy.mockRestore(); }); -// Cofounder's condition on #2210, before any outside digest: the query used to -// add every `posts` summary and every pod-less chat summary to EVERY digest, -// whatever pods the recipient is in. Zero such rows existed on 10-16, so -// nothing leaked, but one posts summary would have mailed one pod's activity -// to non-members. A digest reads the recipient's own pods and nothing else. -test("a digest never carries another pod's posts summary or a pod-less summary", async () => { - const member = await person(); +// Cofounder's condition on #2210: no digest may carry another pod's activity. +// Chat summaries are no longer an input at all, so another pod's summary (or +// a pod-less one) cannot reach anyone's digest. +test("a digest never carries another pod's summary: summaries are not an input", async () => { + const member = await person(new Date(Date.now() - 2 * 86400000)); const outsider = await person(); await podWithSummary(member, 'Member Pod'); const elsewhere = await Pod.create({ name: 'Private Elsewhere', createdBy: outsider._id, members: [outsider._id] }); @@ -73,21 +153,13 @@ test("a digest never carries another pod's posts summary or a pod-less summary", timeRange: { start: new Date(Date.now() - 60000), end: new Date() }, metadata: { totalItems: 1 }, }); - await Summary.create({ - type: 'chats', - title: 'Pod-less chat summary', - content: '- Somewhere else', - timeRange: { start: new Date(Date.now() - 60000), end: new Date() }, - metadata: { totalItems: 1 }, - }); - const service = new DailyDigestService(); - jest.spyOn(service, 'generateDigestContent').mockImplementation(async (data) => - Object.keys(data.byPod).join(', ')); + const spy = jest.spyOn(ActivityService, 'getRecap').mockResolvedValue(recap()); - const digest = await service.generateUserDailyDigest(member._id); + const digest = await new DailyDigestService().generateUserDailyDigest(member._id, { requireAway: true }); - expect(digest.metadata.totalItems).toBe(1); - expect(digest.content).toBe('Member Pod'); + expect(digest.metadata.totalItems).toBe(0); + expect(digest.content).not.toMatch(/Private Elsewhere|Member Pod update/); + spy.mockRestore(); }); test('scheduler includes a member with recent pod work even after seven days away', async () => { @@ -99,7 +171,7 @@ test('scheduler includes a member with recent pod work even after seven days awa const results = await service.generateAllDailyDigests(); - expect(generate).toHaveBeenCalledWith(member._id); + expect(generate).toHaveBeenCalledWith(member._id, { requireAway: true }); expect(results).toHaveLength(2); }); diff --git a/backend/__tests__/unit/services/llmFallbacks.failClosed.test.ts b/backend/__tests__/unit/services/llmFallbacks.failClosed.test.ts index 7bd3a42659..56f8af375c 100644 --- a/backend/__tests__/unit/services/llmFallbacks.failClosed.test.ts +++ b/backend/__tests__/unit/services/llmFallbacks.failClosed.test.ts @@ -1,6 +1,7 @@ // @ts-nocheck // LLM outages must be visible to callers. These services used to return generic prose -// that looked generated even though the LLM request had failed. +// that looked generated even though the LLM request had failed. (The daily +// digest left this list on 2026-10-16: it no longer calls an LLM at all.) jest.mock('../../../models/Summary', () => ({})); jest.mock('../../../models/Post', () => ({ find: jest.fn() })); @@ -8,16 +9,12 @@ jest.mock('../../../config/db-pg', () => ({ pool: { query: jest.fn() } })); jest.mock('../../../models/Pod', () => ({})); jest.mock('../../../services/podAssetService', () => ({})); jest.mock('../../../models/User', () => ({})); -jest.mock('../../../services/digestTemplateService', () => ({ - createDigestPrompt: jest.fn(() => 'digest prompt'), -})); jest.mock('../../../services/llmService', () => ({ generateText: jest.fn() })); const { generateText } = require('../../../services/llmService'); const Post = require('../../../models/Post'); const summarizerService = require('../../../services/summarizerService'); const chatSummarizerService = require('../../../services/chatSummarizerService'); -const dailyDigestService = require('../../../services/dailyDigestService'); describe('LLM summaries fail closed', () => { beforeEach(() => { @@ -60,11 +57,4 @@ describe('LLM summaries fail closed', () => { [{ content: 'A real message', username: 'lily' }], )).rejects.toThrow('Enhanced chat summary generation returned invalid JSON'); }); - - it('does not create a generic digest after an LLM failure', async () => { - generateText.mockRejectedValueOnce(new Error('LLM unavailable')); - - await expect(dailyDigestService.generateDigestContent({}, { username: 'lily' })) - .rejects.toThrow('LLM unavailable'); - }); }); diff --git a/backend/services/dailyDigestService.ts b/backend/services/dailyDigestService.ts index bbcf71d918..7ee8d6cfd6 100644 --- a/backend/services/dailyDigestService.ts +++ b/backend/services/dailyDigestService.ts @@ -5,55 +5,19 @@ const User = require('../models/User'); // eslint-disable-next-line global-require const Pod = require('../models/Pod'); // eslint-disable-next-line global-require -const DigestTemplateService = require('./digestTemplateService'); -// eslint-disable-next-line global-require -const { generateText } = require('./llmService'); - -interface SummaryAnalytics { - quotes?: Array<{ text: string; author: string; reactions?: number }>; - insights?: Array<{ impact?: string; [key: string]: unknown }>; - timeline?: Array<{ timestamp: string; description: string }>; - atmosphere?: { - overall_sentiment?: string; - energy_level?: string; - }; -} - -interface SummaryDoc { - podId?: { name?: string; type?: string } | null; - type?: string; - content?: string; - metadata?: { - totalItems?: number; - topUsers?: string[]; - topTags?: string[]; - podName?: string; - }; - analytics?: SummaryAnalytics; -} - -interface PodData { - podType: string; - summaries: SummaryDoc[]; - totalMessages: number; - quotes: Array<{ text: string; author: string; reactions?: number }>; - insights: Array<{ impact?: string; [key: string]: unknown }>; -} - -interface OrganizedData { - byPod: Record<string, PodData>; - timeline: Array<{ timestamp: string; description: string }>; -} - -interface CrossConversationInsights { - topUsers: string[]; - topTags: string[]; - bestQuotes: Array<{ text: string; author: string; reactions?: number }>; - keyInsights: Array<{ impact?: string; [key: string]: unknown }>; - timeline: Array<{ timestamp: string; description: string }>; - overallAtmosphere: Record<string, unknown>; - participationOverview: Record<string, unknown>; -} +const ActivityService = require('./activityService'); + +/** + * The daily digest is "what your agents did while you were away" (stickiness + * Phase 2, TASK-257; Growth spec 2026-10-15). It is built from the activity + * recap, the same read the Activity page shows, never from chat summaries: + * what is waiting on the person, what each of their agents posted, and what + * moved on the board, in the person's own pods only. + * + * Every line states something that happened, so there is no LLM step: the + * previous version asked a model to narrate chat summaries, and it could only + * ever be as true as the narration. + */ interface EmptyDigestResult { title: string; @@ -68,11 +32,81 @@ interface DigestResult { error?: string; } +interface RecapLike { + pods?: Array<{ id: string; name: string }>; + needsYou?: Array<{ title?: string; podName?: string }>; + agents?: Array<{ + name?: string; + recap?: string; + messageCount?: number; + updates?: Array<{ podId?: string | null; podName?: string; content?: string }>; + }>; + board?: Array<{ taskId?: string; title?: string; status?: string; podId?: string; podName?: string }>; +} + +/** Someone active this recently has seen it in the app; the email would repeat it. */ +export const DIGEST_AWAY_MS = 20 * 60 * 60 * 1000; +const MAX_NEEDS_YOU = 3; +const MAX_AGENTS = 5; +const MAX_BOARD = 5; +const LINE_MAX = 200; + +// Scout is the guide, not one of "your agents": its welcome would otherwise +// make every new account's first digest say an agent did something. +const isGuide = (name: unknown): boolean => /^scout(\b|-)/i.test(String(name || '').trim()); + +const oneLine = (value: unknown): string => String(value || '').replace(/\s+/g, ' ').trim().slice(0, LINE_MAX); + export class DailyDigestService { - async generateUserDailyDigest(userId: unknown): Promise<unknown> { - try { - console.log(`Generating daily digest for user ${userId}`); + /** + * The digest's lines, most important first: what is waiting on the person, + * then each agent's latest work, then board changes. Empty means nothing + * happened that the person needs to hear about. + */ + static recapLines(recap: RecapLike | null | undefined, publicPodIds: Set<string> = new Set()): string[] { + if (!recap) return []; + const lines: string[] = []; + // A public pod (Commonly HQ, which every signup joins) is not "your + // agents": its agent posts and board moves are someone else's work. The + // dry run on 2026-10-16 found every outside recipient's only line was a + // support answer to another person in HQ. What waits on YOU stays, wherever. + const inPrivatePod = (podId: unknown) => !podId || !publicPodIds.has(String(podId)); + (recap.needsYou || []).slice(0, MAX_NEEDS_YOU).forEach((item) => { + const title = oneLine(item.title); + if (title) lines.push(`Waiting on you: ${title}${item.podName ? ` (${oneLine(item.podName)})` : ''}`); + }); + (recap.agents || []) + .filter((agent) => !isGuide(agent.name)) + .map((agent) => ({ agent, updates: (agent.updates || []).filter((update) => inPrivatePod(update.podId)) })) + .filter(({ updates }) => updates.length > 0) + .slice(0, MAX_AGENTS) + .forEach(({ agent, updates }) => { + const name = oneLine(agent.name) || 'An agent'; + const [latest] = updates; + // Counted from the updates kept, so a public pod's posts never inflate it. + const more = updates.length > 1 ? ` (+${updates.length - 1} more)` : ''; + lines.push(`${name} in ${oneLine(latest.podName) || 'a pod'}: ${oneLine(latest.content)}${more}`); + }); + (recap.board || []).filter((task) => inPrivatePod(task.podId)).slice(0, MAX_BOARD).forEach((task) => { + const title = oneLine(task.title); + if (!title) return; + const id = task.taskId ? `${task.taskId} ` : ''; + const pod = task.podName ? ` (${oneLine(task.podName)})` : ''; + lines.push(`${id}${title} is ${oneLine(task.status) || 'updated'}${pod}`); + }); + return lines; + } + /** + * One person's digest for the last 24 hours. The scheduled run passes + * requireAway, so someone active in the last DIGEST_AWAY_MS gets an empty + * digest (they have seen it in the app); an on-demand generation does not. + */ + async generateUserDailyDigest( + userId: unknown, + { requireAway = false }: { requireAway?: boolean } = {}, + ): Promise<unknown> { + try { const user = await User.findById(userId).lean() as Record<string, unknown> | null; if (!user) { throw new Error(`User ${userId} not found`); @@ -80,24 +114,20 @@ export class DailyDigestService { const endTime = new Date(); const startTime = new Date(endTime.getTime() - 24 * 60 * 60 * 1000); - - // Pod.members is the current access relationship; current pod joins do - // not populate the legacy User.subscribedPods field. - const pods = await Pod.find({ members: userId }).select('_id').lean() as Array<{ _id: unknown }>; - const podIds = pods.map((pod) => pod._id); - - // The recipient's own pods and nothing else. The query used to add every - // `posts` summary and every pod-less chat summary to every digest, so one - // posts summary would have mailed a pod's activity to its non-members. - const summaries = await Summary.find({ - podId: { $in: podIds }, - createdAt: { $gte: startTime, $lte: endTime }, - }) - .populate('podId', 'name type') - .sort({ createdAt: 1 }) - .lean() as SummaryDoc[]; - - if (summaries.length === 0) { + const lastActive = user.lastActive ? new Date(user.lastActive as string).getTime() : 0; + const away = !lastActive || endTime.getTime() - lastActive >= DIGEST_AWAY_MS; + + // The recap reads current pod membership itself (Pod.members). + const recap: RecapLike | null = (requireAway && !away) + ? null + : await ActivityService.getRecap(userId, { window: 'today' }); + const publicPods = recap + ? await Pod.find({ members: userId, publicRead: true }).select('_id').lean() as Array<{ _id: unknown }> + : []; + const lines = DailyDigestService.recapLines(recap, new Set(publicPods.map((pod) => String(pod._id)))); + const podCount = recap?.pods?.length || 0; + + if (lines.length === 0) { const emptyData = DailyDigestService.createEmptyDigest( user as { username: string }, startTime, @@ -112,222 +142,33 @@ export class DailyDigestService { totalItems: 0, topTags: [], topUsers: [], - subscribedPods: podIds.length, + subscribedPods: podCount, userId: String(userId), }, analytics: emptyData.analytics, }); } - const organizedData = DailyDigestService.organizeSummariesForDigest(summaries); - const digestContent = await this.generateDigestContent(organizedData, user as { username: string }); - const insights = DailyDigestService.extractCrossConversationInsights(summaries); - - const digestSummary = await Summary.create({ + return Summary.create({ type: 'daily-digest', - title: `Daily Digest for ${user.username} - ${endTime.toDateString()}`, - content: digestContent, + title: requireAway ? 'What your agents did while you were away' : 'What your agents did today', + content: lines.map((line) => `- ${line}`).join('\n'), timeRange: { start: startTime, end: endTime }, metadata: { - totalItems: summaries.length, - topTags: insights.topTags, - topUsers: insights.topUsers, - subscribedPods: podIds.length, + totalItems: lines.length, + topTags: [], + topUsers: [], + subscribedPods: podCount, userId: String(userId), }, - analytics: { - timeline: insights.timeline, - quotes: insights.bestQuotes, - insights: insights.keyInsights, - atmosphere: insights.overallAtmosphere, - participation: insights.participationOverview, - }, + analytics: DailyDigestService.createEmptyDigest(user as { username: string }, startTime, endTime).analytics, }); - - console.log(`✓ Generated daily digest for ${user.username}`); - return digestSummary; } catch (error) { console.error(`Error generating daily digest for user ${userId}:`, error); throw error; } } - static organizeSummariesForDigest(summaries: SummaryDoc[]): OrganizedData { - const byPod: Record<string, PodData> = {}; - const timeline: Array<{ timestamp: string; description: string }> = []; - - summaries.forEach((summary) => { - const podName = (summary.podId as { name?: string } | null)?.name || 'General'; - const podType = (summary.podId as { type?: string } | null)?.type || summary.type || 'general'; - - if (!byPod[podName]) { - byPod[podName] = { - podType, - summaries: [], - totalMessages: 0, - quotes: [], - insights: [], - }; - } - - byPod[podName].summaries.push(summary); - byPod[podName].totalMessages += summary.metadata?.totalItems || 0; - - if (summary.analytics) { - byPod[podName].quotes.push(...(summary.analytics.quotes || [])); - byPod[podName].insights.push(...(summary.analytics.insights || [])); - timeline.push(...(summary.analytics.timeline || [])); - } - }); - - return { - byPod, - timeline: timeline.sort( - (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), - ), - }; - } - - async generateDigestContent(organizedData: OrganizedData, user: { username: string }): Promise<string> { - const prompt = DigestTemplateService.createDigestPrompt(organizedData, user); - - try { - return await generateText(prompt, { temperature: 0.4 }) as string; - } catch (error) { - console.error('Error generating digest content with AI:', error); - throw error; - } - } - - static createDigestPrompt(organizedData: OrganizedData, user: { username: string }): string { - const { byPod, timeline } = organizedData; - - let podSummaries = ''; - Object.entries(byPod).forEach(([podName, data]) => { - podSummaries += `\n## ${podName} (${data.podType})\n`; - podSummaries += `Messages: ${data.totalMessages}\n`; - data.summaries.forEach((summary) => { - podSummaries += `- ${summary.content}\n`; - }); - - if (data.quotes.length > 0) { - podSummaries += 'Notable quotes:\n'; - data.quotes.slice(0, 3).forEach((quote) => { - podSummaries += ` "${quote.text}" - ${quote.author}\n`; - }); - } - }); - - return `Create a personalized daily digest newsletter for ${user.username}. -You are writing a friendly, engaging daily summary that feels like a thoughtful friend catching them up on what happened in their communities. - -Community Activity Summary: -${podSummaries} - -Timeline of Events: -${timeline - .slice(0, 10) - .map((event) => `${event.timestamp}: ${event.description}`) - .join('\n')} - -Create a well-structured daily digest that includes: - -1. **Opening**: Warm, personalized greeting -2. **Highlights**: Top 3-5 most interesting developments -3. **Community Pulse**: Overall mood and energy across their subscribed communities -4. **Notable Moments**: Best quotes, insights, or funny moments -5. **Looking Ahead**: Any ongoing discussions or trends to watch - -Make it engaging, informative, and personal. Use markdown formatting for structure. Keep it concise but comprehensive - aim for a 2-3 minute read.`; - } - - static extractCrossConversationInsights(summaries: SummaryDoc[]): CrossConversationInsights { - const allQuotes: Array<{ text: string; author: string; reactions?: number }> = []; - const allInsights: Array<{ impact?: string; [key: string]: unknown }> = []; - const allTimeline: Array<{ timestamp: string; description: string }> = []; - const allUsers: string[] = []; - const allTags: string[] = []; - - let totalMessages = 0; - const sentimentScores: number[] = []; - const energyLevels: number[] = []; - - summaries.forEach((summary) => { - totalMessages += summary.metadata?.totalItems || 0; - - if (summary.metadata?.topUsers) { - allUsers.push(...summary.metadata.topUsers); - } - - if (summary.metadata?.topTags) { - allTags.push(...summary.metadata.topTags); - } - - if (summary.analytics) { - allQuotes.push(...(summary.analytics.quotes || [])); - allInsights.push(...(summary.analytics.insights || [])); - allTimeline.push(...(summary.analytics.timeline || [])); - - if (summary.analytics.atmosphere) { - const atm = summary.analytics.atmosphere; - sentimentScores.push(DailyDigestService.sentimentToScore(atm.overall_sentiment)); - energyLevels.push(DailyDigestService.energyToScore(atm.energy_level)); - } - } - }); - - const topUsers = DailyDigestService.getTopItems(allUsers, 5); - const topTags = DailyDigestService.getTopItems(allTags, 8); - const bestQuotes = allQuotes - .sort((a, b) => (b.reactions || 0) - (a.reactions || 0)) - .slice(0, 5); - - const impactScore: Record<string, number> = { high: 3, medium: 2, low: 1 }; - const keyInsights = allInsights - .sort((a, b) => (impactScore[String(b.impact || '')] || 0) - (impactScore[String(a.impact || '')] || 0)) - .slice(0, 8); - - const timeline = allTimeline - .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) - .slice(0, 10); - - const engagementQuality = (() => { - if (totalMessages > 100) return 'intense'; - if (totalMessages > 50) return 'deep'; - if (totalMessages > 20) return 'moderate'; - return 'superficial'; - })(); - - return { - topUsers, - topTags, - bestQuotes, - keyInsights, - timeline, - overallAtmosphere: { - overall_sentiment: DailyDigestService.scoreToSentiment(DailyDigestService.average(sentimentScores)), - energy_level: DailyDigestService.scoreToEnergy(DailyDigestService.average(energyLevels)), - engagement_quality: engagementQuality, - community_cohesion: Math.min(topUsers.length / 10, 1), - topics_diversity: Math.min(topTags.length / 15, 1), - dominant_emotions: ['engagement', 'community'], - }, - participationOverview: { - most_active_users: topUsers.slice(0, 5).map((u) => ({ - username: u, - message_count: 0, - engagement_score: 0.8, - role: 'contributor', - })), - engagement_patterns: { - peak_hours: [], - discussion_length_avg: summaries.length ? totalMessages / summaries.length : 0, - response_time_avg: 5, - }, - }, - }; - } - static createEmptyDigest(user: { username: string }, startTime: Date, endTime: Date): EmptyDigestResult { return { title: `Daily Digest for ${user.username} - ${endTime.toDateString()}`, @@ -374,7 +215,7 @@ This might be a great time to start a new conversation or share something intere createdAt: { $gte: startTime }, }); // Public-read pods include every signup; their activity is not a reason - // to wake every stale account for an LLM-generated digest. + // to wake every stale account. const recentMemberIds = await Pod.distinct('members', { _id: { $in: recentPodIds }, publicRead: { $ne: true }, @@ -393,7 +234,7 @@ This might be a great time to start a new conversation or share something intere const results = await Promise.allSettled( activeUsers.map(async (user) => { try { - const digest = await this.generateUserDailyDigest(user._id); + const digest = await this.generateUserDailyDigest(user._id, { requireAway: true }); return { userId: user._id, success: true, digest }; } catch (error) { console.error(`Failed to generate digest for user ${user._id}:`, error); @@ -412,58 +253,12 @@ This might be a great time to start a new conversation or share something intere } } - static getTopItems(items: string[], limit: number): string[] { - const counts: Record<string, number> = {}; - items.forEach((item) => { - counts[item] = (counts[item] || 0) + 1; - }); - return Object.entries(counts) - .sort(([, a], [, b]) => b - a) - .slice(0, limit) - .map(([item]) => item); - } - - static sentimentToScore(sentiment?: string): number { - const scores: Record<string, number> = { - very_negative: 1, negative: 2, neutral: 3, positive: 4, very_positive: 5, - }; - return scores[sentiment || ''] || 3; - } - - static energyToScore(energy?: string): number { - const scores: Record<string, number> = { - very_low: 1, low: 2, medium: 3, high: 4, very_high: 5, - }; - return scores[energy || ''] || 3; - } - - static scoreToSentiment(score: number): string { - if (score >= 4.5) return 'very_positive'; - if (score >= 3.5) return 'positive'; - if (score >= 2.5) return 'neutral'; - if (score >= 1.5) return 'negative'; - return 'very_negative'; - } - - static scoreToEnergy(score: number): string { - if (score >= 4.5) return 'very_high'; - if (score >= 3.5) return 'high'; - if (score >= 2.5) return 'medium'; - if (score >= 1.5) return 'low'; - return 'very_low'; - } - - static average(arr: number[]): number { - return arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 3; - } - static getTimeOfDayGreeting(): string { const hour = new Date().getHours(); if (hour < 12) return 'morning'; if (hour < 17) return 'afternoon'; return 'evening'; } - } export default new DailyDigestService(); diff --git a/backend/services/digestTemplateService.ts b/backend/services/digestTemplateService.ts deleted file mode 100644 index 01fb61b0ca..0000000000 --- a/backend/services/digestTemplateService.ts +++ /dev/null @@ -1,245 +0,0 @@ -interface UserLike { - username: string; -} - -interface AtmosphereLike { - overall_sentiment?: string; - energy_level?: string; - engagement_quality?: string; -} - -interface InsightsLike { - totalItems?: number; - overallAtmosphere?: AtmosphereLike; - topUsers?: unknown[]; - bestQuotes?: Array<{ text: string; author: string; context: string }>; - keyInsights?: Array<{ type: string; description: string }>; - timeline?: Array<{ timestamp: string; description: string }>; -} - -interface MetadataLike { - subscribedPods?: number; -} - -class DigestTemplateService { - static createDigestPrompt(organizedData: unknown, user: UserLike): string { - const { username } = user; - const date = new Date().toDateString(); - - return `Create a personalized daily digest newsletter for ${username} using this EXACT format and structure: - -COMMUNITY DATA: -${JSON.stringify(organizedData, null, 2)} - -REQUIRED FORMAT (follow this structure exactly): - -# 🌅 Daily Digest - ${date} - -Good morning, ${username}! - -Ready for your daily dose of community insights? Let's dive into what's been happening while you were away. - -## ✨ Today's Highlights - -[List 3-5 most significant developments, each as a bullet point with descriptive titles] - -## 💬 Notable Moments - -### 🔥 Quote of the Day -> "[Most engaging or insightful quote from discussions]" -> -> *— @[username] in [community/context]* - -### 🎯 Key Insights -- **[Insight Type]**: [Brief description of trend, consensus, or development] -- **[Community Vibe]**: [Description of overall mood and energy] - -## 📊 Community Pulse - -- **Overall Mood**: [Emoji] [Sentiment description] -- **Energy Level**: [Emoji] [Activity level description] -- **Engagement Quality**: [Emoji] [Type of discussions happening] -- **Active Communities**: [Number] of your subscribed communities had activity - -## 🔮 Looking Ahead - -[2-3 sentences about trends, upcoming discussions, or community direction] - ---- -*Your personalized digest • Generated with ❤️ by Commonly AI* - -GUIDELINES: -- Use engaging, friendly tone -- Include specific details from the data -- Personalize content for ${username} -- Use emojis strategically for visual appeal -- Keep sections concise but informative -- Focus on community connection and engagement -- If data is limited, acknowledge it positively -- Highlight ${username}'s contributions when present`; - } - - static createAnalyticsPrompt(messages: unknown, podName: string): string { - return `Analyze this chat data and extract structured analytics for community insights. - -CHAT DATA: -Pod: ${podName} -Messages: ${JSON.stringify(messages, null, 2)} - -Extract and return ONLY a valid JSON object with this structure: -{ - "timeline": [ - { - "timestamp": "ISO date", - "event": "topic_shift|peak_activity|heated_discussion|new_participant|milestone", - "description": "Brief description of what happened", - "participants": ["username1", "username2"], - "intensity": 1-10 - } - ], - "quotes": [ - { - "text": "Exact quote text", - "author": "username", - "timestamp": "ISO date", - "context": "What was being discussed", - "sentiment": "positive|negative|neutral|humorous|insightful", - "reactions": 0 - } - ], - "insights": [ - { - "type": "trend|sentiment_shift|new_topic|consensus|disagreement|revelation", - "description": "What insight was gained", - "confidence": 0.0-1.0, - "impact": "low|medium|high", - "participants": ["username1"], - "timestamp": "ISO date" - } - ], - "atmosphere": { - "overall_sentiment": "very_positive|positive|neutral|negative|very_negative", - "energy_level": "very_low|low|medium|high|very_high", - "engagement_quality": "superficial|moderate|deep|intense", - "community_cohesion": 0.0-1.0, - "topics_diversity": 0.0-1.0, - "dominant_emotions": ["emotion1", "emotion2"] - }, - "participation": { - "most_active_users": [ - { - "username": "username", - "message_count": 0, - "engagement_score": 0.0-1.0, - "role": "moderator|contributor|lurker|newcomer" - } - ], - "engagement_patterns": { - "peak_hours": [14, 15, 16], - "discussion_length_avg": 0, - "response_time_avg": 0 - } - } -} - -IMPORTANT: Return ONLY the JSON object, no additional text or explanation.`; - } - - static getPersonalizedGreeting(user: UserLike, timeOfDay: string, activityLevel: string): string { - const { username } = user; - - const greetings: Record<string, Record<string, string>> = { - morning: { - high: `Rise and shine, ${username}! Your communities were buzzing overnight.`, - medium: `Good morning, ${username}! Here's what happened while you were away.`, - low: `Morning, ${username}! It was a quiet night, but there are still some gems to discover.`, - }, - afternoon: { - high: `Good afternoon, ${username}! Catching up on a busy day in your communities?`, - medium: `Hey ${username}! Ready for your daily community roundup?`, - low: `Hi ${username}! Not much happened, but here's what's worth knowing.`, - }, - evening: { - high: `Evening, ${username}! Your communities had quite the active day.`, - medium: `Good evening, ${username}! Time to catch up on today's highlights.`, - low: `Evening, ${username}! A peaceful day in your communities, but still some nice moments to share.`, - }, - }; - - return ( - greetings[timeOfDay]?.[activityLevel] - || `Hello, ${username}! Here's your daily community digest.` - ); - } - - static buildConditionalSections(insights: InsightsLike, _user: UserLike): string { - const sections: string[] = []; - - if (insights.bestQuotes && insights.bestQuotes.length > 0) { - const topQuote = insights.bestQuotes[0]; - sections.push(`### 🔥 Quote of the Day -> "${topQuote.text}" -> -> *— @${topQuote.author} in ${topQuote.context}*`); - } - - if (insights.keyInsights && insights.keyInsights.length > 0) { - let insightText = '### 🎯 Key Insights\n'; - insights.keyInsights.slice(0, 3).forEach((insight) => { - insightText += `- **${insight.type.replace('_', ' ').toUpperCase()}**: ${insight.description}\n`; - }); - sections.push(insightText.trim()); - } - - if (insights.timeline && insights.timeline.length > 0) { - let timelineText = '### ⏰ Timeline Highlights\n'; - insights.timeline.slice(0, 3).forEach((event) => { - const time = new Date(event.timestamp).toLocaleTimeString('en-US', { - hour: 'numeric', - minute: '2-digit', - }); - timelineText += `- **${time}**: ${event.description}\n`; - }); - sections.push(timelineText.trim()); - } - - return sections.join('\n\n'); - } - - static formatCommunityPulse(atmosphere: AtmosphereLike | null, metadata: MetadataLike | null): string { - const moodEmojis: Record<string, string> = { - very_positive: '🌟', - positive: '😊', - neutral: '😐', - negative: '😔', - very_negative: '😞', - }; - - const energyEmojis: Record<string, string> = { - very_high: '🚀', - high: '⚡', - medium: '🔋', - low: '🔅', - very_low: '💤', - }; - - const engagementEmojis: Record<string, string> = { - intense: '🔥', - deep: '🎯', - moderate: '💬', - superficial: '👋', - }; - - return `## 📊 Community Pulse - -- **Overall Mood**: ${moodEmojis[atmosphere?.overall_sentiment || ''] || '😊'} ${atmosphere?.overall_sentiment?.replace('_', ' ') || 'Positive'} -- **Energy Level**: ${energyEmojis[atmosphere?.energy_level || ''] || '🔋'} ${atmosphere?.energy_level?.replace('_', ' ') || 'Moderate'} -- **Engagement Quality**: ${engagementEmojis[atmosphere?.engagement_quality || ''] || '💬'} ${atmosphere?.engagement_quality || 'Good'} discussions -- **Active Communities**: ${metadata?.subscribedPods || 0} of your subscribed communities had activity`; - } -} - -export default DigestTemplateService; -// CJS compat: let require() return the default export directly -// eslint-disable-next-line @typescript-eslint/no-require-imports -module.exports = exports["default"]; Object.assign(module.exports, exports); From f3714e49b7f8a7873400fd20f7dc51f453c8f2cd Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:40:31 -0700 Subject: [PATCH 3/5] fix(digest): an ask from a pod you left stays out; agents by label, Scout by agentName Cofounder's privacy read on #2267 (HOLD at 8c40620a), reproduced: createdBy keeps a departed creator's id after leavePod, and the attention queue still counts the creator as a member (attentionItemService isCurrentMember), so the queue served that person "waiting on you" titles from a pod they can no longer open, and the digest would have mailed them. - The digest keeps an ask only when its pod is in recap.pods, which is the person's pods by Pod.members alone. The test drives the real queue: it pulls the creator from members the way leavePod does, asserts the queue still serves the row (the upstream gap), and asserts the digest drops it. - Sized on prod (read-only): 60 of 481 pods have a creator outside members, 1 of them human, holding 0 open attention rows. Nothing has leaked. - The in-app Activity row has the same gap. Fixing it at the source means switching attentionItemService to isListedPodMember, which reshapes 19 fixtures in that service's tests. That is its own PR. Folded in from the same read (non-blocking): - An agent line names the agent by resolveAgentDisplayLabel (displayName, then instanceId, then username), not by its bot username. - Scout is known by botMetadata.agentName === 'scout', never by name, so a person's own agent called "Scout..." is still their agent. Mutations: the membership filter off, Scout matched by name, and the username instead of the label each fail an arm. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0157AG2wo8mJYqKezXNs48Ya --- .../dailyDigestService.membership.test.js | 94 ++++++++++++++++++- backend/services/dailyDigestService.ts | 65 ++++++++++--- 2 files changed, 145 insertions(+), 14 deletions(-) diff --git a/backend/__tests__/unit/services/dailyDigestService.membership.test.js b/backend/__tests__/unit/services/dailyDigestService.membership.test.js index 6ee4d27ff3..deaa7e690a 100644 --- a/backend/__tests__/unit/services/dailyDigestService.membership.test.js +++ b/backend/__tests__/unit/services/dailyDigestService.membership.test.js @@ -4,6 +4,8 @@ const Pod = require('../../../models/Pod'); const Summary = require('../../../models/Summary'); const { DailyDigestService } = require('../../../services/dailyDigestService'); const ActivityService = require('../../../services/activityService'); +const AttentionItem = require('../../../models/AttentionItem'); +const AttentionItemService = require('../../../services/attentionItemService'); let serial = 0; const person = async (lastActive = new Date()) => { @@ -16,6 +18,17 @@ const person = async (lastActive = new Date()) => { }); }; +const agentUser = async (username, botMetadata) => { + serial += 1; + return User.create({ + username, + email: `digest-agent-${serial}@example.com`, + password: 'Password123!', + isBot: true, + botMetadata, + }); +}; + const podWithSummary = async (owner, name, options = {}) => { const pod = await Pod.create({ name, @@ -52,7 +65,7 @@ const recap = (overrides = {}) => ({ test('the digest is the recap: what waits on you, then agent work, then the board', async () => { const member = await person(new Date(Date.now() - 2 * 86400000)); const spy = jest.spyOn(ActivityService, 'getRecap').mockResolvedValue(recap({ - needsYou: [{ title: 'Rivet requests approval', podName: 'Payments API' }], + needsYou: [{ title: 'Rivet requests approval', podId: 'p1', podName: 'Payments API' }], agents: [{ name: 'Rivet', messageCount: 3, @@ -89,6 +102,7 @@ test("a public pod's agent posts and board moves stay out; what waits on you sta name: 'Commonly HQ', createdBy: member._id, members: [member._id], publicRead: true, }); const spy = jest.spyOn(ActivityService, 'getRecap').mockResolvedValue(recap({ + pods: [{ id: String(hq._id), name: 'Commonly HQ' }], needsYou: [{ title: 'Commonly Support mentioned you', podName: 'Commonly HQ', podId: String(hq._id) }], agents: [{ name: 'Commonly Support', @@ -106,15 +120,89 @@ test("a public pod's agent posts and board moves stay out; what waits on you sta spy.mockRestore(); }); -test("Scout's welcome is not 'your agents' work': a recap of only Scout is an empty digest", async () => { +// Scout is known by agentName, never by its name: a person's own agent can be +// called "Scout..." and is still theirs. +test("Scout's welcome is not 'your agents' work'; the person's own agent named Scout is", async () => { + const member = await person(new Date(Date.now() - 2 * 86400000)); + const scout = await agentUser('scout-a1b2', { + agentName: 'scout', instanceId: 'a1b2', displayName: 'Scout', + }); + const theirs = await agentUser('scout-jr-c3d4', { + agentName: 'claude-code', instanceId: 'c3d4', displayName: 'Scout Jr', + }); + const spy = jest.spyOn(ActivityService, 'getRecap').mockResolvedValue(recap({ + agents: [ + { + id: String(scout._id), + name: 'scout-a1b2', + updates: [{ podId: 'p1', podName: 'My Workspace', content: "I'm Scout." }], + }, + { + id: String(theirs._id), + name: 'scout-jr-c3d4', + updates: [{ podId: 'p1', podName: 'Payments API', content: 'Opened #42' }], + }, + ], + })); + + const digest = await new DailyDigestService().generateUserDailyDigest(member._id, { requireAway: true }); + + expect(digest.content.split('\n')).toEqual(['- Scout Jr in Payments API: Opened #42']); + spy.mockRestore(); +}); + +test("an agent line names the agent by its display label, not its bot username", async () => { const member = await person(new Date(Date.now() - 2 * 86400000)); + const rivet = await agentUser('rivet-e5f6', { + agentName: 'claude-code', instanceId: 'e5f6', displayName: 'Rivet', + }); const spy = jest.spyOn(ActivityService, 'getRecap').mockResolvedValue(recap({ - agents: [{ name: 'Scout', messageCount: 1, updates: [{ podName: 'My Workspace', content: "I'm Scout." }] }], + agents: [{ + id: String(rivet._id), + name: 'rivet-e5f6', + updates: [{ podId: 'p1', podName: 'Payments API', content: 'Ran the suite' }], + }], })); const digest = await new DailyDigestService().generateUserDailyDigest(member._id, { requireAway: true }); + expect(digest.content).toBe('- Rivet in Payments API: Ran the suite'); + spy.mockRestore(); +}); + +// Privacy read on #2267: createdBy keeps a departed creator's id after +// leavePod, and the attention queue still counts the creator as a member, so +// the real queue hands them an ask from a pod they can no longer open. The +// digest keeps only asks from pods in recap.pods (Pod.members alone). +test("a creator who left a pod gets none of its 'waiting on you' titles", async () => { + const creator = await person(new Date(Date.now() - 2 * 86400000)); + const stayer = await person(); + const left = await Pod.create({ name: 'Left Behind', createdBy: creator._id, members: [creator._id, stayer._id] }); + // What leavePod does: members loses them, createdBy keeps them (the + // pre-save hook adds a creator back on save, so this is an update). + await Pod.updateOne({ _id: left._id }, { $pull: { members: creator._id } }); + await AttentionItem.create({ + recipientUserId: creator._id, + podId: left._id, + kind: 'mention', + source: { type: 'message', id: 'msg-left-1' }, + title: 'A private ask in Left Behind', + podName: 'Left Behind', + status: 'open', + }); + const queue = await AttentionItemService.getOpenQueue(creator._id); + // The upstream gap this guards: the queue still serves the row. + expect(queue.items.map((item) => item.title)).toContain('A private ask in Left Behind'); + const pods = await Pod.find({ members: creator._id }).select('_id name').lean(); + const spy = jest.spyOn(ActivityService, 'getRecap').mockResolvedValue(recap({ + pods: pods.map((pod) => ({ id: String(pod._id), name: pod.name })), + needsYou: queue.items, + })); + + const digest = await new DailyDigestService().generateUserDailyDigest(creator._id, { requireAway: true }); + expect(digest.metadata.totalItems).toBe(0); + expect(digest.content).not.toMatch(/Left Behind/); spy.mockRestore(); }); diff --git a/backend/services/dailyDigestService.ts b/backend/services/dailyDigestService.ts index 7ee8d6cfd6..d326a15e10 100644 --- a/backend/services/dailyDigestService.ts +++ b/backend/services/dailyDigestService.ts @@ -6,6 +6,8 @@ const User = require('../models/User'); const Pod = require('../models/Pod'); // eslint-disable-next-line global-require const ActivityService = require('./activityService'); +// eslint-disable-next-line global-require +const { resolveAgentDisplayLabel } = require('./agentIdentityService'); /** * The daily digest is "what your agents did while you were away" (stickiness @@ -34,8 +36,9 @@ interface DigestResult { interface RecapLike { pods?: Array<{ id: string; name: string }>; - needsYou?: Array<{ title?: string; podName?: string }>; + needsYou?: Array<{ title?: string; podId?: string; podName?: string }>; agents?: Array<{ + id?: string; name?: string; recap?: string; messageCount?: number; @@ -51,9 +54,20 @@ const MAX_AGENTS = 5; const MAX_BOARD = 5; const LINE_MAX = 200; -// Scout is the guide, not one of "your agents": its welcome would otherwise -// make every new account's first digest say an agent did something. -const isGuide = (name: unknown): boolean => /^scout(\b|-)/i.test(String(name || '').trim()); +/** What the digest knows about an agent from its User row, keyed by user id. */ +interface AgentIdentity { + label: string; + // Scout is the guide, not one of "your agents": its welcome would otherwise + // make every new account's first digest say an agent did something. Known + // by agentName, never by display name, so a person's own agent called + // "Scout..." still counts. + guide: boolean; +} + +interface RecapContext { + publicPodIds?: Set<string>; + agentIdentities?: Map<string, AgentIdentity>; +} const oneLine = (value: unknown): string => String(value || '').replace(/\s+/g, ' ').trim().slice(0, LINE_MAX); @@ -63,25 +77,38 @@ export class DailyDigestService { * then each agent's latest work, then board changes. Empty means nothing * happened that the person needs to hear about. */ - static recapLines(recap: RecapLike | null | undefined, publicPodIds: Set<string> = new Set()): string[] { + static recapLines( + recap: RecapLike | null | undefined, + { publicPodIds = new Set(), agentIdentities = new Map() }: RecapContext = {}, + ): string[] { if (!recap) return []; const lines: string[] = []; + // recap.pods is the person's pods by Pod.members alone. An ask from a pod + // outside it (a creator who has since left, whose createdBy still names + // them) is a title from a pod they can no longer open, so it stays out. + const memberPodIds = new Set((recap.pods || []).map((pod) => String(pod.id))); // A public pod (Commonly HQ, which every signup joins) is not "your // agents": its agent posts and board moves are someone else's work. The // dry run on 2026-10-16 found every outside recipient's only line was a // support answer to another person in HQ. What waits on YOU stays, wherever. const inPrivatePod = (podId: unknown) => !podId || !publicPodIds.has(String(podId)); - (recap.needsYou || []).slice(0, MAX_NEEDS_YOU).forEach((item) => { + const asks = (recap.needsYou || []).filter((item) => memberPodIds.has(String(item.podId))); + asks.slice(0, MAX_NEEDS_YOU).forEach((item) => { const title = oneLine(item.title); if (title) lines.push(`Waiting on you: ${title}${item.podName ? ` (${oneLine(item.podName)})` : ''}`); }); (recap.agents || []) - .filter((agent) => !isGuide(agent.name)) - .map((agent) => ({ agent, updates: (agent.updates || []).filter((update) => inPrivatePod(update.podId)) })) + .map((agent) => ({ agent, identity: agentIdentities.get(String(agent.id)) })) + .filter(({ identity }) => !identity?.guide) + .map(({ agent, identity }) => ({ + agent, + identity, + updates: (agent.updates || []).filter((update) => inPrivatePod(update.podId)), + })) .filter(({ updates }) => updates.length > 0) .slice(0, MAX_AGENTS) - .forEach(({ agent, updates }) => { - const name = oneLine(agent.name) || 'An agent'; + .forEach(({ agent, identity, updates }) => { + const name = oneLine(identity?.label || agent.name) || 'An agent'; const [latest] = updates; // Counted from the updates kept, so a public pod's posts never inflate it. const more = updates.length > 1 ? ` (+${updates.length - 1} more)` : ''; @@ -97,6 +124,19 @@ export class DailyDigestService { return lines; } + /** Each recap agent's display label and whether it is the guide, from its User row. */ + static async agentIdentities(recap: RecapLike | null): Promise<Map<string, AgentIdentity>> { + const ids = (recap?.agents || []).map((agent) => String(agent.id || '')).filter((id) => /^[a-f0-9]{24}$/i.test(id)); + if (ids.length === 0) return new Map(); + const users = await User.find({ _id: { $in: ids } }).select('username botMetadata').lean() as Array<{ + _id: unknown; username?: string; botMetadata?: { displayName?: string; instanceId?: string; agentName?: string }; + }>; + return new Map(users.map((user) => [String(user._id), { + label: resolveAgentDisplayLabel(user, user.username), + guide: String(user.botMetadata?.agentName || '').toLowerCase() === 'scout', + }])); + } + /** * One person's digest for the last 24 hours. The scheduled run passes * requireAway, so someone active in the last DIGEST_AWAY_MS gets an empty @@ -124,7 +164,10 @@ export class DailyDigestService { const publicPods = recap ? await Pod.find({ members: userId, publicRead: true }).select('_id').lean() as Array<{ _id: unknown }> : []; - const lines = DailyDigestService.recapLines(recap, new Set(publicPods.map((pod) => String(pod._id)))); + const lines = DailyDigestService.recapLines(recap, { + publicPodIds: new Set(publicPods.map((pod) => String(pod._id))), + agentIdentities: await DailyDigestService.agentIdentities(recap), + }); const podCount = recap?.pods?.length || 0; if (lines.length === 0) { From 05e96bb58eb7d0f31cf7846c7ef74e4a1bf0bb25 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:45:21 -0700 Subject: [PATCH 4/5] fix(digest): a line with no pod has unknown provenance and stays out sprint-review on #2267: the public-pod guard admitted a missing podId as private. The feed builds an update's podId from a pod lookup (activity.pod?.id || null), so a lookup that missed on a public pod's activity would have been mailed, which is the line this PR exists to remove. A guard that withholds other people's content fails closed on the unknown case. The suite only passed fail-open because two board fixtures carried no podId; production board rows always carry one. They now do, so the main path exercises the real shape, and a new arm pins the unknown case: an agent update or board move with no pod stays out. Mutation: the guard back to fail-open reds that arm. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0157AG2wo8mJYqKezXNs48Ya --- .../dailyDigestService.membership.test.js | 29 +++++++++++++++++-- backend/services/dailyDigestService.ts | 4 ++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/backend/__tests__/unit/services/dailyDigestService.membership.test.js b/backend/__tests__/unit/services/dailyDigestService.membership.test.js index deaa7e690a..7bcad3678b 100644 --- a/backend/__tests__/unit/services/dailyDigestService.membership.test.js +++ b/backend/__tests__/unit/services/dailyDigestService.membership.test.js @@ -77,7 +77,11 @@ test('the digest is the recap: what waits on you, then agent work, then the boar ], }], board: [{ - taskId: 'TASK-7', title: 'Fix the refund rounding', status: 'done', podName: 'Payments API', + taskId: 'TASK-7', + title: 'Fix the refund rounding', + status: 'done', + podId: 'p1', + podName: 'Payments API', }], })); @@ -151,6 +155,27 @@ test("Scout's welcome is not 'your agents' work'; the person's own agent named S spy.mockRestore(); }); +// sprint-review on #2267: the feed builds an update's podId from a pod lookup +// (activity.pod?.id || null). A line with no pod has unknown provenance and +// could be a public pod's, so it is withheld rather than mailed. +test('an agent update or board move with no pod stays out', async () => { + const member = await person(new Date(Date.now() - 2 * 86400000)); + const spy = jest.spyOn(ActivityService, 'getRecap').mockResolvedValue(recap({ + agents: [{ + name: 'Rivet', + updates: [{ podId: null, podName: 'Direct activity', content: 'Someone else\'s thread' }], + }], + board: [{ + taskId: 'TASK-3', title: 'No pod', status: 'done', podName: 'Somewhere', + }], + })); + + const digest = await new DailyDigestService().generateUserDailyDigest(member._id, { requireAway: true }); + + expect(digest.metadata.totalItems).toBe(0); + spy.mockRestore(); +}); + test("an agent line names the agent by its display label, not its bot username", async () => { const member = await person(new Date(Date.now() - 2 * 86400000)); const rivet = await agentUser('rivet-e5f6', { @@ -210,7 +235,7 @@ test('the scheduled run skips someone active in the last 20 hours; an on-demand const member = await person(new Date()); const spy = jest.spyOn(ActivityService, 'getRecap').mockResolvedValue(recap({ board: [{ - taskId: 'TASK-9', title: 'Ship it', status: 'done', podName: 'Payments API', + taskId: 'TASK-9', title: 'Ship it', status: 'done', podId: 'p1', podName: 'Payments API', }], })); const service = new DailyDigestService(); diff --git a/backend/services/dailyDigestService.ts b/backend/services/dailyDigestService.ts index d326a15e10..53ec7ea848 100644 --- a/backend/services/dailyDigestService.ts +++ b/backend/services/dailyDigestService.ts @@ -91,7 +91,9 @@ export class DailyDigestService { // agents": its agent posts and board moves are someone else's work. The // dry run on 2026-10-16 found every outside recipient's only line was a // support answer to another person in HQ. What waits on YOU stays, wherever. - const inPrivatePod = (podId: unknown) => !podId || !publicPodIds.has(String(podId)); + // A line with no pod (a feed lookup that missed) has unknown provenance, so + // it stays out too: this guard withholds other people's content. + const inPrivatePod = (podId: unknown) => Boolean(podId) && !publicPodIds.has(String(podId)); const asks = (recap.needsYou || []).filter((item) => memberPodIds.has(String(item.podId))); asks.slice(0, MAX_NEEDS_YOU).forEach((item) => { const title = oneLine(item.title); From 5bb97370375e54e006e9920203ae60d2d2f39ce0 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:12:53 -0700 Subject: [PATCH 5/5] test(digest): the departed-creator arm holds with #2271 merged The merge queue dequeued #2267: its departed-creator arm asserted the upstream gap ("the queue still serves the row"), and #2271, merged just ahead of it, closed that gap at the source, so getOpenQueue now returns nothing for a creator who left. The digest code is unchanged. The arm now asserts what is true on main (the queue serves the row to no one), and hands the digest the ask exactly as the queue used to serve it, so the digest's own membership filter is tested by itself. That is the second line of defence: an email never depends on the queue staying right. Verified on main with this branch merged in: the digest, attention, digest-email, scheduler-digest and LLM-fallback suites pass, 65/65. Mutation: the digest membership filter removed reds the arm. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0157AG2wo8mJYqKezXNs48Ya --- .../services/dailyDigestService.membership.test.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/backend/__tests__/unit/services/dailyDigestService.membership.test.js b/backend/__tests__/unit/services/dailyDigestService.membership.test.js index 7bcad3678b..a7e53699dc 100644 --- a/backend/__tests__/unit/services/dailyDigestService.membership.test.js +++ b/backend/__tests__/unit/services/dailyDigestService.membership.test.js @@ -196,9 +196,9 @@ test("an agent line names the agent by its display label, not its bot username", }); // Privacy read on #2267: createdBy keeps a departed creator's id after -// leavePod, and the attention queue still counts the creator as a member, so -// the real queue hands them an ask from a pod they can no longer open. The -// digest keeps only asks from pods in recap.pods (Pod.members alone). +// leavePod. #2271 closed it at the source (the attention queue counts +// Pod.members alone); the digest keeps only asks from pods in recap.pods as +// well, so an email never depends on the queue staying right. test("a creator who left a pod gets none of its 'waiting on you' titles", async () => { const creator = await person(new Date(Date.now() - 2 * 86400000)); const stayer = await person(); @@ -215,13 +215,15 @@ test("a creator who left a pod gets none of its 'waiting on you' titles", async podName: 'Left Behind', status: 'open', }); + // The source no longer serves it (#2271). const queue = await AttentionItemService.getOpenQueue(creator._id); - // The upstream gap this guards: the queue still serves the row. - expect(queue.items.map((item) => item.title)).toContain('A private ask in Left Behind'); + expect(queue.items).toEqual([]); + // If it ever did again, the digest still keeps it out: the ask is handed to + // the digest exactly as the queue used to serve it. const pods = await Pod.find({ members: creator._id }).select('_id name').lean(); const spy = jest.spyOn(ActivityService, 'getRecap').mockResolvedValue(recap({ pods: pods.map((pod) => ({ id: String(pod._id), name: pod.name })), - needsYou: queue.items, + needsYou: [{ title: 'A private ask in Left Behind', podId: String(left._id), podName: 'Left Behind' }], })); const digest = await new DailyDigestService().generateUserDailyDigest(creator._id, { requireAway: true });