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()),