From 4fbed87765ef0cdf4ac74fc0102cf9e72d9388c8 Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Tue, 24 Mar 2026 22:48:00 -0700 Subject: [PATCH] feat: generate contact summaries from related meeting notes - add a persisted humans.summary field to the store and human markdown format - regenerate contact summaries when session notes, enhanced summaries, or participant mappings change - render generated summaries in the contacts detail view and cover the flow with enhancer tests --- apps/desktop/src/contacts/details.tsx | 24 +- .../calendar/process/participants/execute.ts | 1 + .../src/services/enhancer/index.test.ts | 118 +++++- apps/desktop/src/services/enhancer/index.ts | 379 +++++++++++++++++- .../persister/human/transform.test.ts | 8 + .../tinybase/persister/human/transform.ts | 2 + .../src/store/tinybase/store/initialize.ts | 1 + .../src/store/tinybase/store/sessions.ts | 1 + packages/store/src/tinybase.ts | 1 + packages/store/src/zod.ts | 1 + 10 files changed, 526 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/contacts/details.tsx b/apps/desktop/src/contacts/details.tsx index 97d4ec6456..5373ac8af3 100644 --- a/apps/desktop/src/contacts/details.tsx +++ b/apps/desktop/src/contacts/details.tsx @@ -81,6 +81,12 @@ export function DetailsColumn({ "email", main.STORE_ID, ) as string | undefined; + const summary = main.UI.useCell( + "humans", + selectedHumanId ?? "", + "summary", + main.STORE_ID, + ) as string | undefined; const duplicateHumanIds = main.UI.useSliceRowIds( main.INDEXES.humansByEmail, @@ -297,12 +303,18 @@ export function DetailsColumn({ Summary
-

- AI-generated summary of all interactions and notes with this - contact will appear here. This will synthesize key - discussion points, action items, and relationship context - across all meetings and notes. -

+ {summary?.trim() ? ( +

+ {summary} +

+ ) : ( +

+ AI-generated summary of all interactions and notes with + this contact will appear here. This will synthesize key + discussion points, action items, and relationship context + across all meetings and notes. +

+ )}
)} diff --git a/apps/desktop/src/services/calendar/process/participants/execute.ts b/apps/desktop/src/services/calendar/process/participants/execute.ts index 7625f4bcdc..8f13140532 100644 --- a/apps/desktop/src/services/calendar/process/participants/execute.ts +++ b/apps/desktop/src/services/calendar/process/participants/execute.ts @@ -26,6 +26,7 @@ export function executeForParticipantsSync( org_id: "", job_title: "", linkedin_username: "", + summary: "", memo: "", pinned: false, } satisfies HumanStorage); diff --git a/apps/desktop/src/services/enhancer/index.test.ts b/apps/desktop/src/services/enhancer/index.test.ts index 0f636ae5cd..57bca33bff 100644 --- a/apps/desktop/src/services/enhancer/index.test.ts +++ b/apps/desktop/src/services/enhancer/index.test.ts @@ -5,6 +5,20 @@ import { EnhancerService } from "."; import { listenerStore } from "~/store/zustand/listener/instance"; +const aiMocks = vi.hoisted(() => ({ + generateText: vi + .fn() + .mockResolvedValue({ text: "Generated contact summary" }), +})); + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + generateText: aiMocks.generateText, + }; +}); + vi.mock("@hypr/plugin-analytics", () => ({ commands: { event: vi.fn().mockResolvedValue(undefined), @@ -25,22 +39,40 @@ type Tables = Record>>; function createTables(data?: { transcripts?: Record; - enhanced_notes?: Record; - sessions?: Record; + enhanced_notes?: Record< + string, + { session_id: string; template_id?: string; content?: string } + >; + sessions?: Record< + string, + { title: string; raw_md?: string; created_at?: string } + >; + humans?: Record; + mapping_session_participant?: Record< + string, + { session_id: string; human_id: string; source?: string } + >; }): Tables { return { transcripts: data?.transcripts ?? {}, enhanced_notes: data?.enhanced_notes ?? {}, sessions: data?.sessions ?? {}, + humans: data?.humans ?? {}, + mapping_session_participant: data?.mapping_session_participant ?? {}, templates: {}, }; } function createMockStore(tables: Tables) { + let listenerCount = 0; + return { getCell: vi.fn((table: string, rowId: string, cellId: string) => { return tables[table]?.[rowId]?.[cellId]; }), + getRow: vi.fn((table: string, rowId: string) => { + return tables[table]?.[rowId]; + }), getValue: vi.fn((valueId: string) => { if (valueId === "user_id") return "user-1"; return undefined; @@ -49,7 +81,17 @@ function createMockStore(tables: Tables) { if (!tables[table]) tables[table] = {}; tables[table][rowId] = row; }), - setPartialRow: vi.fn(), + setPartialRow: vi.fn( + (table: string, rowId: string, row: Record) => { + if (!tables[table]) tables[table] = {}; + tables[table][rowId] = { + ...(tables[table][rowId] ?? {}), + ...row, + }; + }, + ), + addRowListener: vi.fn(() => `listener-${++listenerCount}`), + delListener: vi.fn(), } as any; } @@ -66,6 +108,17 @@ function createMockIndexes(tables: Tables) { (id) => tables.enhanced_notes[id]?.session_id === sliceId, ); } + if (indexId === "sessionsByHuman") { + return Object.keys(tables.mapping_session_participant ?? {}).filter( + (id) => tables.mapping_session_participant[id]?.human_id === sliceId, + ); + } + if (indexId === "sessionParticipantsBySession") { + return Object.keys(tables.mapping_session_participant ?? {}).filter( + (id) => + tables.mapping_session_participant[id]?.session_id === sliceId, + ); + } return []; }), }; @@ -452,6 +505,9 @@ describe("EnhancerService", () => { let subscriber: ((state: any) => void) | undefined; beforeEach(() => { + aiMocks.generateText.mockResolvedValue({ + text: "Generated contact summary", + }); vi.mocked(listenerStore.subscribe).mockImplementation((cb: any) => { subscriber = cb; return () => { @@ -549,4 +605,60 @@ describe("EnhancerService", () => { expect((service as any).activeAutoEnhance.size).toBe(0); }); }); + + describe("contact summaries", () => { + it("stores a generated summary for a participant with meeting notes", async () => { + const tables = createTables({ + humans: { + "human-1": { + name: "Adhit", + email: "adhit@janet.ai", + memo: "Met through Janet.", + }, + }, + sessions: { + "session-1": { + title: "Breakfast at MKT", + raw_md: JSON.stringify({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "Discussed GTM plans and follow-ups.", + }, + ], + }, + ], + }), + created_at: "2026-03-23T10:00:00.000Z", + }, + }, + mapping_session_participant: { + "mapping-1": { + session_id: "session-1", + human_id: "human-1", + source: "auto", + }, + }, + }); + const store = createMockStore(tables); + const deps = createDeps({ + mainStore: store, + indexes: createMockIndexes(tables), + }); + const service = new EnhancerService(deps); + + service.queueContactSummaryUpdate("human-1"); + await Promise.resolve(); + await Promise.resolve(); + + expect(aiMocks.generateText).toHaveBeenCalled(); + expect(store.setPartialRow).toHaveBeenCalledWith("humans", "human-1", { + summary: "Generated contact summary", + }); + }); + }); }); diff --git a/apps/desktop/src/services/enhancer/index.ts b/apps/desktop/src/services/enhancer/index.ts index a5baca333f..cd9c3e0b79 100644 --- a/apps/desktop/src/services/enhancer/index.ts +++ b/apps/desktop/src/services/enhancer/index.ts @@ -1,6 +1,7 @@ -import type { LanguageModel } from "ai"; +import { generateText, type LanguageModel } from "ai"; import { commands as analyticsCommands } from "@hypr/plugin-analytics"; +import { json2md, parseJsonContent } from "@hypr/tiptap/shared"; import { getEligibility } from "./eligibility"; @@ -51,9 +52,12 @@ export function initEnhancerService(deps: EnhancerDeps): EnhancerService { export class EnhancerService { private activeAutoEnhance = new Set(); + private activeContactSummary = new Set(); + private queuedContactSummary = new Set(); private pendingRetries = new Map>(); private unsubscribe: (() => void) | null = null; private eventListeners = new Set<(event: EnhancerEvent) => void>(); + private storeListenerIds: string[] = []; constructor(private deps: EnhancerDeps) {} @@ -66,6 +70,92 @@ export class EnhancerService { this.clearRetry(sessionId); } }); + + const { mainStore } = this.deps; + + this.storeListenerIds = [ + mainStore.addRowListener( + "sessions", + null, + (_store, _tableId, rowId, getCellChange) => { + if ( + !getCellChange || + (!getCellChange("sessions", rowId, "raw_md")[0] && + !getCellChange("sessions", rowId, "title")[0] && + !getCellChange("sessions", rowId, "event_json")[0]) + ) { + return; + } + + this.queueContactSummariesForSession(rowId); + }, + ), + mainStore.addRowListener( + "enhanced_notes", + null, + (store, _tableId, rowId, getCellChange) => { + if (!getCellChange) { + return; + } + + const [contentChanged] = getCellChange( + "enhanced_notes", + rowId, + "content", + ); + const [sessionChanged, previousSessionId, nextSessionId] = + getCellChange("enhanced_notes", rowId, "session_id"); + + if (!contentChanged && !sessionChanged) { + return; + } + + if (typeof previousSessionId === "string" && previousSessionId) { + this.queueContactSummariesForSession(previousSessionId); + } + if (typeof nextSessionId === "string" && nextSessionId) { + this.queueContactSummariesForSession(nextSessionId); + return; + } + + const sessionId = store.getCell( + "enhanced_notes", + rowId, + "session_id", + ); + if (typeof sessionId === "string" && sessionId) { + this.queueContactSummariesForSession(sessionId); + } + }, + ), + mainStore.addRowListener( + "mapping_session_participant", + null, + (_store, _tableId, rowId, getCellChange) => { + if (!getCellChange) { + return; + } + + const candidateHumanIds = new Set(); + const [, previousHumanId, nextHumanId] = getCellChange( + "mapping_session_participant", + rowId, + "human_id", + ); + + if (typeof previousHumanId === "string" && previousHumanId) { + candidateHumanIds.add(previousHumanId); + } + if (typeof nextHumanId === "string" && nextHumanId) { + candidateHumanIds.add(nextHumanId); + } + + for (const humanId of candidateHumanIds) { + this.queueContactSummaryUpdate(humanId); + } + }, + ), + ]; } dispose() { @@ -74,7 +164,13 @@ export class EnhancerService { for (const timer of this.pendingRetries.values()) clearTimeout(timer); this.pendingRetries.clear(); this.activeAutoEnhance.clear(); + this.activeContactSummary.clear(); + this.queuedContactSummary.clear(); this.eventListeners.clear(); + for (const listenerId of this.storeListenerIds) { + this.deps.mainStore.delListener(listenerId); + } + this.storeListenerIds = []; if (instance === this) instance = null; } @@ -245,4 +341,285 @@ export class EnhancerService { return enhancedNoteId; } + + queueContactSummariesForSession(sessionId: string) { + for (const humanId of this.getParticipantHumanIds(sessionId)) { + this.queueContactSummaryUpdate(humanId); + } + } + + queueContactSummaryUpdate(humanId: string) { + if (!humanId) { + return; + } + + if (this.activeContactSummary.has(humanId)) { + this.queuedContactSummary.add(humanId); + return; + } + + this.activeContactSummary.add(humanId); + + void this.generateContactSummary(humanId).finally(() => { + this.activeContactSummary.delete(humanId); + + if (this.queuedContactSummary.delete(humanId)) { + this.queueContactSummaryUpdate(humanId); + } + }); + } + + private async generateContactSummary(humanId: string) { + const model = this.deps.getModel(); + if (!model) { + return; + } + + const store = this.deps.mainStore; + if (!store.getRow("humans", humanId)) { + return; + } + + const summaryInput = this.buildContactSummaryInput(humanId); + + if (!summaryInput) { + store.setPartialRow("humans", humanId, { summary: "" }); + return; + } + + try { + const result = await generateText({ + model, + temperature: 0, + system: CONTACT_SUMMARY_SYSTEM_PROMPT, + prompt: buildContactSummaryPrompt(summaryInput), + }); + + store.setPartialRow("humans", humanId, { + summary: result.text.trim(), + }); + } catch (error) { + console.error("Failed to generate contact summary:", error); + } + } + + private buildContactSummaryInput(humanId: string) { + const store = this.deps.mainStore; + const human = store.getRow("humans", humanId); + if (!human) { + return null; + } + + const sessionIds = this.getSessionIdsForHuman(humanId); + const sessions = sessionIds + .map((sessionId) => this.getSessionSummarySource(sessionId)) + .filter( + (session): session is NonNullable => session !== null, + ) + .slice(0, CONTACT_SUMMARY_SESSION_LIMIT); + + const memo = + typeof human.memo === "string" && human.memo.trim() + ? human.memo.trim() + : ""; + if (!memo && sessions.length === 0) { + return null; + } + + return { + name: + (typeof human.name === "string" && human.name.trim()) || + (typeof human.email === "string" && human.email.trim()) || + "Unknown contact", + memo, + sessions, + }; + } + + private getSessionIdsForHuman(humanId: string): string[] { + const mappingIds = this.deps.indexes.getSliceRowIds( + INDEXES.sessionsByHuman, + humanId, + ); + const sessionIds = new Set(); + + for (const mappingId of mappingIds) { + const source = this.deps.mainStore.getCell( + "mapping_session_participant", + mappingId, + "source", + ); + if (source === "excluded") { + continue; + } + + const sessionId = this.deps.mainStore.getCell( + "mapping_session_participant", + mappingId, + "session_id", + ); + if (typeof sessionId === "string" && sessionId) { + sessionIds.add(sessionId); + } + } + + return Array.from(sessionIds).sort((a, b) => { + return this.getSessionSortKey(b) - this.getSessionSortKey(a); + }); + } + + private getParticipantHumanIds(sessionId: string): string[] { + const mappingIds = this.deps.indexes.getSliceRowIds( + INDEXES.sessionParticipantsBySession, + sessionId, + ); + const humanIds = new Set(); + + for (const mappingId of mappingIds) { + const source = this.deps.mainStore.getCell( + "mapping_session_participant", + mappingId, + "source", + ); + if (source === "excluded") { + continue; + } + + const humanId = this.deps.mainStore.getCell( + "mapping_session_participant", + mappingId, + "human_id", + ); + if (typeof humanId === "string" && humanId) { + humanIds.add(humanId); + } + } + + return Array.from(humanIds); + } + + private getSessionSummarySource(sessionId: string) { + const session = this.deps.mainStore.getRow("sessions", sessionId); + if (!session) { + return null; + } + + const sections: string[] = []; + const enhancedNotes = this.getEnhancedNoteIds(sessionId); + for (const noteId of enhancedNotes) { + const markdown = jsonToMarkdown( + this.deps.mainStore.getCell("enhanced_notes", noteId, "content"), + ); + if (markdown) { + sections.push(`AI summary\n${truncatePromptChunk(markdown)}`); + } + } + + const rawNotes = jsonToMarkdown(session.raw_md); + if (rawNotes) { + sections.push(`Manual notes\n${truncatePromptChunk(rawNotes)}`); + } + + if (sections.length === 0) { + return null; + } + + return { + title: + (typeof session.title === "string" && session.title.trim()) || + "Untitled note", + happenedAt: this.getSessionDateLabel(sessionId), + content: sections.join("\n\n"), + }; + } + + private getSessionDateLabel(sessionId: string): string { + const eventJson = this.deps.mainStore.getCell( + "sessions", + sessionId, + "event_json", + ); + if (typeof eventJson === "string" && eventJson.trim()) { + try { + const parsed = JSON.parse(eventJson) as { started_at?: string }; + if (typeof parsed.started_at === "string" && parsed.started_at) { + return parsed.started_at; + } + } catch { + // Ignore invalid event JSON and fall back to created_at. + } + } + + const createdAt = this.deps.mainStore.getCell( + "sessions", + sessionId, + "created_at", + ); + return typeof createdAt === "string" ? createdAt : ""; + } + + private getSessionSortKey(sessionId: string): number { + const label = this.getSessionDateLabel(sessionId); + const parsed = Date.parse(label); + return Number.isNaN(parsed) ? 0 : parsed; + } +} + +const CONTACT_SUMMARY_SESSION_LIMIT = 12; +const CONTACT_SUMMARY_CHUNK_LIMIT = 1500; + +const CONTACT_SUMMARY_SYSTEM_PROMPT = `You write concise relationship summaries for a contacts view in a meeting notes app. + +Use only the provided notes. +Keep the output under 140 words. +Focus on the person's role, active workstreams, commitments, preferences, and follow-ups worth remembering. +Prefer concrete facts over generic framing. +Do not mention missing information or speculate. +Return plain text only.`; + +function buildContactSummaryPrompt(input: { + name: string; + memo: string; + sessions: Array<{ title: string; happenedAt: string; content: string }>; +}) { + const parts = [`Contact: ${input.name}`]; + + if (input.memo) { + parts.push(`Manual contact notes:\n${truncatePromptChunk(input.memo)}`); + } + + const sessionBlocks = input.sessions.map((session, index) => { + const heading = [`Meeting ${index + 1}: ${session.title}`]; + if (session.happenedAt) { + heading.push(`Date: ${session.happenedAt}`); + } + + return `${heading.join("\n")}\n${session.content}`; + }); + + parts.push(`Meetings:\n${sessionBlocks.join("\n\n---\n\n")}`); + + return `${parts.join("\n\n")}\n\nWrite one concise relationship summary for this contact.`; +} + +function truncatePromptChunk(value: string): string { + const trimmed = value.trim(); + if (trimmed.length <= CONTACT_SUMMARY_CHUNK_LIMIT) { + return trimmed; + } + + return `${trimmed.slice(0, CONTACT_SUMMARY_CHUNK_LIMIT).trimEnd()}...`; +} + +function jsonToMarkdown(value: unknown): string { + if (typeof value !== "string" || !value.trim()) { + return ""; + } + + const trimmed = value.trim(); + if (!trimmed.startsWith("{")) { + return trimmed; + } + + return json2md(parseJsonContent(trimmed)).trim(); } diff --git a/apps/desktop/src/store/tinybase/persister/human/transform.test.ts b/apps/desktop/src/store/tinybase/persister/human/transform.test.ts index 735a93d539..c891be5f98 100644 --- a/apps/desktop/src/store/tinybase/persister/human/transform.test.ts +++ b/apps/desktop/src/store/tinybase/persister/human/transform.test.ts @@ -65,6 +65,7 @@ describe("frontmatterToHuman", () => { org_id: "org-1", job_title: "Engineer", linkedin_username: "johndoe", + summary: "", memo: "Notes", pinned: false, pin_order: undefined, @@ -81,6 +82,7 @@ describe("humanToFrontmatter", () => { org_id: "", job_title: "", linkedin_username: "", + summary: "", memo: "", pinned: false, created_at: "", @@ -99,6 +101,7 @@ describe("humanToFrontmatter", () => { org_id: "", job_title: "", linkedin_username: "", + summary: "", memo: "", pinned: false, created_at: "", @@ -114,6 +117,7 @@ describe("humanToFrontmatter", () => { org_id: "", job_title: "", linkedin_username: "", + summary: "", memo: "", pinned: false, created_at: "", @@ -132,6 +136,7 @@ describe("humanToFrontmatter", () => { org_id: "", job_title: "", linkedin_username: "", + summary: "", memo: "", pinned: false, created_at: "", @@ -147,6 +152,7 @@ describe("humanToFrontmatter", () => { org_id: "", job_title: "", linkedin_username: "", + summary: "", memo: "Some notes", pinned: false, created_at: "", @@ -163,6 +169,7 @@ describe("humanToFrontmatter", () => { org_id: "org-1", job_title: "Engineer", linkedin_username: "johndoe", + summary: "Concise relationship summary", memo: "Notes", pinned: false, }); @@ -175,6 +182,7 @@ describe("humanToFrontmatter", () => { org_id: "org-1", job_title: "Engineer", linkedin_username: "johndoe", + summary: "Concise relationship summary", pinned: false, pin_order: 0, }, diff --git a/apps/desktop/src/store/tinybase/persister/human/transform.ts b/apps/desktop/src/store/tinybase/persister/human/transform.ts index 7c5acde1b1..f20b384906 100644 --- a/apps/desktop/src/store/tinybase/persister/human/transform.ts +++ b/apps/desktop/src/store/tinybase/persister/human/transform.ts @@ -35,6 +35,7 @@ function frontmatterToStore( org_id: String(frontmatter.org_id ?? ""), job_title: String(frontmatter.job_title ?? ""), linkedin_username: String(frontmatter.linkedin_username ?? ""), + summary: String(frontmatter.summary ?? ""), pinned: Boolean(frontmatter.pinned ?? false), pin_order: frontmatter.pin_order != null ? Number(frontmatter.pin_order) : undefined, @@ -52,6 +53,7 @@ function storeToFrontmatter( org_id: store.org_id ?? "", job_title: store.job_title ?? "", linkedin_username: store.linkedin_username ?? "", + summary: store.summary ?? "", pinned: store.pinned ?? false, pin_order: store.pin_order ?? 0, }; diff --git a/apps/desktop/src/store/tinybase/store/initialize.ts b/apps/desktop/src/store/tinybase/store/initialize.ts index f6e85aeffe..5f2c1aa57b 100644 --- a/apps/desktop/src/store/tinybase/store/initialize.ts +++ b/apps/desktop/src/store/tinybase/store/initialize.ts @@ -31,6 +31,7 @@ function initializeStore(store: Store): void { name: "", email: "", org_id: "", + summary: "", }); } diff --git a/apps/desktop/src/store/tinybase/store/sessions.ts b/apps/desktop/src/store/tinybase/store/sessions.ts index fa79fe9796..a89cee6b89 100644 --- a/apps/desktop/src/store/tinybase/store/sessions.ts +++ b/apps/desktop/src/store/tinybase/store/sessions.ts @@ -197,6 +197,7 @@ function createParticipantsFromEvent( org_id: "", job_title: "", linkedin_username: "", + summary: "", memo: "", pinned: false, } satisfies HumanStorage); diff --git a/packages/store/src/tinybase.ts b/packages/store/src/tinybase.ts index 4455153461..11227f4404 100644 --- a/packages/store/src/tinybase.ts +++ b/packages/store/src/tinybase.ts @@ -50,6 +50,7 @@ export const tableSchemaForTinybase = { org_id: { type: "string" }, job_title: { type: "string" }, linkedin_username: { type: "string" }, + summary: { type: "string" }, memo: { type: "string" }, pinned: { type: "boolean" }, pin_order: { type: "number" }, diff --git a/packages/store/src/zod.ts b/packages/store/src/zod.ts index 1e3fe0d95a..7a084e7654 100644 --- a/packages/store/src/zod.ts +++ b/packages/store/src/zod.ts @@ -13,6 +13,7 @@ export const humanSchema = z.object({ (val) => val ?? undefined, z.string().optional(), ), + summary: z.preprocess((val) => val ?? undefined, z.string().optional()), memo: z.preprocess((val) => val ?? undefined, z.string().optional()), pinned: z.preprocess((val) => val ?? false, z.boolean()), pin_order: z.preprocess((val) => val ?? undefined, z.number().optional()),