diff --git a/apps/desktop/src/chat/tools/index.ts b/apps/desktop/src/chat/tools/index.ts index 27e571c33f..fb7cca9cd8 100644 --- a/apps/desktop/src/chat/tools/index.ts +++ b/apps/desktop/src/chat/tools/index.ts @@ -9,6 +9,7 @@ import { import { buildSearchCalendarEventsTool } from "./search-calendar-events"; import { buildSearchContactsTool } from "./search-contacts"; import { buildSearchSessionsTool } from "./search-sessions"; +import { buildApplySessionCorrectionTool } from "./session-correction"; import type { CalendarEventSearchResult, ContactSearchResult, @@ -72,6 +73,10 @@ export const buildChatTools = (deps: ToolDependencies) => ({ ), web_search: withToolLogging("web_search", buildWebSearchTool(deps)), edit_summary: withToolLogging("edit_summary", buildEditSummaryTool(deps)), + apply_session_correction: withToolLogging( + "apply_session_correction", + buildApplySessionCorrectionTool(deps), + ), }); type LocalTools = { @@ -198,6 +203,30 @@ type LocalTools = { }>; }; }; + apply_session_correction: { + input: { + sessionId?: string; + target?: "summary" | "transcript" | "summary_and_transcript"; + enhancedNoteId?: string; + oldText: string; + newText: string; + }; + output: { + status: string; + message?: string; + sessionId?: string; + summaryChanges?: Array<{ + enhancedNoteId: string; + title: string; + replacements: number; + }>; + transcriptChanges?: Array<{ + transcriptId: string; + wordReplacements: number; + memoReplacements: number; + }>; + }; + }; }; export type Tools = LocalTools; diff --git a/apps/desktop/src/chat/tools/session-correction.test.ts b/apps/desktop/src/chat/tools/session-correction.test.ts new file mode 100644 index 0000000000..5014a40b93 --- /dev/null +++ b/apps/desktop/src/chat/tools/session-correction.test.ts @@ -0,0 +1,438 @@ +import { describe, expect, it, vi } from "vitest"; + +import { md2json } from "@hypr/editor/markdown"; + +import { + buildApplySessionCorrectionTool, + sessionCorrectionTestInternals, +} from "./session-correction"; + +function createStore(tables: Record>) { + return { + getCell: vi.fn((table: string, rowId: string, cellId: string) => { + return tables[table]?.[rowId]?.[cellId]; + }), + setCell: vi.fn( + (table: string, rowId: string, cellId: string, value: unknown) => { + tables[table][rowId][cellId] = value; + }, + ), + setPartialRow: vi.fn( + (table: string, rowId: string, partial: Record) => { + tables[table][rowId] = { + ...tables[table][rowId], + ...partial, + }; + }, + ), + } as any; +} + +function createIndexes(tables: Record>) { + return { + getSliceRowIds: vi.fn((indexId: string, sessionId: string) => { + if (indexId === "enhancedNotesBySession") { + return Object.keys(tables.enhanced_notes ?? {}).filter( + (id) => tables.enhanced_notes[id]?.session_id === sessionId, + ); + } + + if (indexId === "transcriptBySession") { + return Object.keys(tables.transcripts ?? {}).filter( + (id) => tables.transcripts[id]?.session_id === sessionId, + ); + } + + return []; + }), + } as any; +} + +function summaryContent(markdown: string) { + return JSON.stringify(md2json(markdown)); +} + +describe("session correction chat tool internals", () => { + it("applies exact summary corrections to matching enhanced notes", () => { + const tables = { + enhanced_notes: { + "note-1": { + session_id: "session-1", + title: "Summary", + content: summaryContent("Discussed X roadmap."), + }, + "note-2": { + session_id: "session-1", + title: "Tasks", + content: summaryContent("No correction here."), + }, + }, + }; + const store = createStore(tables); + const indexes = createIndexes(tables); + + const changes = sessionCorrectionTestInternals.applySummaryCorrection({ + store, + indexes, + sessionId: "session-1", + oldText: "X roadmap", + newText: "Y roadmap", + }); + + expect(changes).toEqual([ + { + enhancedNoteId: "note-1", + title: "Summary", + replacements: 1, + }, + ]); + expect(tables.enhanced_notes["note-1"].content).toContain("Y roadmap"); + expect(tables.enhanced_notes["note-2"].content).toContain( + "No correction here.", + ); + }); + + it("updates transcript words and memo markdown for exact corrections", () => { + const tables = { + transcripts: { + "transcript-1": { + session_id: "session-1", + words: JSON.stringify([ + { id: "w1", text: "It", start_ms: 0, end_ms: 100, channel: 0 }, + { id: "w2", text: "is", start_ms: 100, end_ms: 200, channel: 0 }, + { id: "w3", text: "X", start_ms: 200, end_ms: 300, channel: 0 }, + ]), + memo_md: "Speaker 1: It is X", + }, + }, + }; + const store = createStore(tables); + const indexes = createIndexes(tables); + + const changes = sessionCorrectionTestInternals.applyTranscriptCorrection({ + store, + indexes, + sessionId: "session-1", + oldText: "X", + newText: "Y", + }); + + expect(changes).toEqual([ + { + transcriptId: "transcript-1", + wordReplacements: 1, + memoReplacements: 1, + }, + ]); + expect(JSON.parse(tables.transcripts["transcript-1"].words)).toMatchObject([ + { text: "It" }, + { text: "is" }, + { text: "Y" }, + ]); + expect(tables.transcripts["transcript-1"].memo_md).toBe( + "Speaker 1: It is Y", + ); + }); + + it("updates every matching transcript word span when memo has repeated text", () => { + const tables = { + transcripts: { + "transcript-1": { + session_id: "session-1", + words: JSON.stringify([ + { id: "w1", text: "X", start_ms: 0, end_ms: 100, channel: 0 }, + { id: "w2", text: "then", start_ms: 100, end_ms: 200, channel: 0 }, + { id: "w3", text: "X", start_ms: 200, end_ms: 300, channel: 0 }, + ]), + memo_md: "Speaker 1: X then X", + }, + }, + }; + const store = createStore(tables); + const indexes = createIndexes(tables); + + const changes = sessionCorrectionTestInternals.applyTranscriptCorrection({ + store, + indexes, + sessionId: "session-1", + oldText: "X", + newText: "Y", + }); + + expect(changes).toEqual([ + { + transcriptId: "transcript-1", + wordReplacements: 2, + memoReplacements: 2, + }, + ]); + expect(JSON.parse(tables.transcripts["transcript-1"].words)).toMatchObject([ + { text: "Y" }, + { text: "then" }, + { text: "Y" }, + ]); + expect(tables.transcripts["transcript-1"].memo_md).toBe( + "Speaker 1: Y then Y", + ); + }); + + it("does not partially update transcript rows when words and memo do not both match", () => { + const tables = { + transcripts: { + "transcript-1": { + session_id: "session-1", + words: JSON.stringify([ + { id: "w1", text: "X", start_ms: 0, end_ms: 100, channel: 0 }, + ]), + memo_md: "Speaker 1: no correction here", + }, + }, + }; + const store = createStore(tables); + const indexes = createIndexes(tables); + + const changes = sessionCorrectionTestInternals.applyTranscriptCorrection({ + store, + indexes, + sessionId: "session-1", + oldText: "X", + newText: "Y", + }); + + expect(changes).toEqual([]); + expect(store.setCell).not.toHaveBeenCalled(); + expect(JSON.parse(tables.transcripts["transcript-1"].words)).toMatchObject([ + { text: "X" }, + ]); + expect(tables.transcripts["transcript-1"].memo_md).toBe( + "Speaker 1: no correction here", + ); + }); + + it("does not remove transcript words for blank replacement text", () => { + const tables = { + transcripts: { + "transcript-1": { + session_id: "session-1", + words: JSON.stringify([ + { id: "w1", text: "X", start_ms: 0, end_ms: 100, channel: 0 }, + ]), + memo_md: "Speaker 1: X", + }, + }, + }; + const store = createStore(tables); + const indexes = createIndexes(tables); + + const changes = sessionCorrectionTestInternals.applyTranscriptCorrection({ + store, + indexes, + sessionId: "session-1", + oldText: "X", + newText: " ", + }); + + expect(changes).toEqual([]); + expect(store.setCell).not.toHaveBeenCalled(); + expect(JSON.parse(tables.transcripts["transcript-1"].words)).toMatchObject([ + { text: "X" }, + ]); + expect(tables.transcripts["transcript-1"].memo_md).toBe("Speaker 1: X"); + }); + + it("can replace transcript phrases with a different word count", () => { + const result = sessionCorrectionTestInternals.replaceTranscriptWords( + [ + { id: "w1", text: "not", start_ms: 0, end_ms: 100, channel: 0 }, + { id: "w2", text: "X", start_ms: 100, end_ms: 300, channel: 0 }, + ], + "not X", + "Y instead", + ); + + expect(result.count).toBe(1); + expect(result.words).toMatchObject([ + { id: "w1", text: "Y", start_ms: 0, end_ms: 150 }, + { id: "w1:correction:1", text: "instead", start_ms: 150, end_ms: 300 }, + ]); + }); + + it("returns an explicit error for a summary id outside the session", async () => { + const tables = { + enhanced_notes: { + "note-1": { + session_id: "session-1", + title: "Summary", + content: summaryContent("Discussed X roadmap."), + }, + }, + transcripts: {}, + }; + const store = createStore(tables); + const indexes = createIndexes(tables); + const tool = buildApplySessionCorrectionTool({ + getStore: () => store, + getIndexes: () => indexes, + getSessionId: () => "session-1", + getEnhancedNoteId: () => undefined, + }); + + const result = await (tool as any).execute({ + target: "summary", + enhancedNoteId: "missing-note", + oldText: "X roadmap", + newText: "Y roadmap", + }); + + expect(result).toEqual({ + status: "error", + message: "The requested summary does not belong to the target session.", + sessionId: "session-1", + }); + expect(store.setPartialRow).not.toHaveBeenCalled(); + }); + + it("still applies transcript correction when an invalid summary id is provided for the default target", async () => { + const tables = { + enhanced_notes: { + "note-1": { + session_id: "session-1", + title: "Summary", + content: summaryContent("No correction here."), + }, + }, + transcripts: { + "transcript-1": { + session_id: "session-1", + words: JSON.stringify([ + { id: "w1", text: "X", start_ms: 0, end_ms: 100, channel: 0 }, + ]), + memo_md: "Speaker 1: X", + }, + }, + }; + const store = createStore(tables); + const indexes = createIndexes(tables); + const tool = buildApplySessionCorrectionTool({ + getStore: () => store, + getIndexes: () => indexes, + getSessionId: () => "session-1", + getEnhancedNoteId: () => undefined, + }); + + const result = await (tool as any).execute({ + enhancedNoteId: "missing-note", + oldText: "X", + newText: "Y", + }); + + expect(result).toMatchObject({ + status: "applied", + sessionId: "session-1", + summaryChanges: [], + transcriptChanges: [ + { + transcriptId: "transcript-1", + wordReplacements: 1, + memoReplacements: 1, + }, + ], + }); + expect(JSON.parse(tables.transcripts["transcript-1"].words)).toMatchObject([ + { text: "Y" }, + ]); + expect(tables.transcripts["transcript-1"].memo_md).toBe("Speaker 1: Y"); + }); + + it("defaults summary correction to the active enhanced note", async () => { + const tables = { + enhanced_notes: { + "note-1": { + session_id: "session-1", + title: "Summary", + content: summaryContent("Discussed X roadmap."), + }, + "note-2": { + session_id: "session-1", + title: "Other", + content: summaryContent("Discussed X roadmap."), + }, + }, + transcripts: {}, + }; + const store = createStore(tables); + const indexes = createIndexes(tables); + const tool = buildApplySessionCorrectionTool({ + getStore: () => store, + getIndexes: () => indexes, + getSessionId: () => "session-1", + getEnhancedNoteId: () => "note-1", + }); + + const result = await (tool as any).execute({ + target: "summary", + oldText: "X roadmap", + newText: "Y roadmap", + }); + + expect(result).toMatchObject({ + status: "applied", + summaryChanges: [ + { + enhancedNoteId: "note-1", + title: "Summary", + replacements: 1, + }, + ], + }); + expect(tables.enhanced_notes["note-1"].content).toContain("Y roadmap"); + expect(tables.enhanced_notes["note-2"].content).toContain("X roadmap"); + }); + + it("does not use the active enhanced note for explicit session corrections", async () => { + const tables = { + enhanced_notes: { + "note-1": { + session_id: "session-1", + title: "Current", + content: summaryContent("Discussed X roadmap."), + }, + "note-2": { + session_id: "session-2", + title: "Target", + content: summaryContent("Discussed X roadmap."), + }, + }, + transcripts: {}, + }; + const store = createStore(tables); + const indexes = createIndexes(tables); + const tool = buildApplySessionCorrectionTool({ + getStore: () => store, + getIndexes: () => indexes, + getSessionId: () => "session-1", + getEnhancedNoteId: () => "note-1", + }); + + const result = await (tool as any).execute({ + sessionId: "session-2", + target: "summary", + oldText: "X roadmap", + newText: "Y roadmap", + }); + + expect(result).toMatchObject({ + status: "applied", + sessionId: "session-2", + summaryChanges: [ + { + enhancedNoteId: "note-2", + title: "Target", + replacements: 1, + }, + ], + }); + expect(tables.enhanced_notes["note-1"].content).toContain("X roadmap"); + expect(tables.enhanced_notes["note-2"].content).toContain("Y roadmap"); + }); +}); diff --git a/apps/desktop/src/chat/tools/session-correction.ts b/apps/desktop/src/chat/tools/session-correction.ts new file mode 100644 index 0000000000..dbf66c1d27 --- /dev/null +++ b/apps/desktop/src/chat/tools/session-correction.ts @@ -0,0 +1,447 @@ +import { tool } from "ai"; +import { z } from "zod"; + +import { json2md, md2json, parseJsonContent } from "@hypr/editor/markdown"; + +import type { ToolDependencies } from "./types"; + +import * as main from "~/store/tinybase/store/main"; + +type Store = NonNullable>; +type Indexes = NonNullable>; + +type CorrectionTarget = "summary" | "transcript" | "summary_and_transcript"; + +type ReplacementResult = { + text: string; + count: number; +}; + +type TranscriptWord = { + id?: string | null; + text?: string | null; + start_ms?: number | null; + end_ms?: number | null; + channel?: number | null; + speaker?: string | null; + metadata?: unknown; +}; + +type SummaryChange = { + enhancedNoteId: string; + title: string; + replacements: number; +}; + +type TranscriptChange = { + transcriptId: string; + wordReplacements: number; + memoReplacements: number; +}; + +function replaceExact( + value: string, + oldText: string, + newText: string, +): ReplacementResult { + if (!oldText) { + return { text: value, count: 0 }; + } + + const parts = value.split(oldText); + if (parts.length === 1) { + return { text: value, count: 0 }; + } + + return { + text: parts.join(newText), + count: parts.length - 1, + }; +} + +function getSummaryTitle(store: Store, enhancedNoteId: string): string { + const title = store.getCell("enhanced_notes", enhancedNoteId, "title"); + return typeof title === "string" && title.trim() ? title : "Summary"; +} + +function getSummaryCandidateIds({ + indexes, + sessionId, + enhancedNoteId, +}: { + indexes: Indexes; + sessionId: string; + enhancedNoteId?: string; +}): string[] { + const noteIds = indexes.getSliceRowIds( + main.INDEXES.enhancedNotesBySession, + sessionId, + ); + + if (!enhancedNoteId) { + return noteIds; + } + + return noteIds.includes(enhancedNoteId) ? [enhancedNoteId] : []; +} + +function hasEnhancedNoteInSession({ + indexes, + sessionId, + enhancedNoteId, +}: { + indexes: Indexes; + sessionId: string; + enhancedNoteId: string; +}): boolean { + return indexes + .getSliceRowIds(main.INDEXES.enhancedNotesBySession, sessionId) + .includes(enhancedNoteId); +} + +function applySummaryCorrection({ + store, + indexes, + sessionId, + enhancedNoteId, + oldText, + newText, +}: { + store: Store; + indexes: Indexes; + sessionId: string; + enhancedNoteId?: string; + oldText: string; + newText: string; +}): SummaryChange[] { + const noteIds = getSummaryCandidateIds({ + indexes, + sessionId, + enhancedNoteId, + }); + const changes: SummaryChange[] = []; + + for (const noteId of noteIds) { + const raw = store.getCell("enhanced_notes", noteId, "content"); + const currentContent = json2md( + parseJsonContent(typeof raw === "string" ? raw : undefined), + ); + const replaced = replaceExact(currentContent, oldText, newText); + if (replaced.count === 0) { + continue; + } + + store.setPartialRow("enhanced_notes", noteId, { + content: JSON.stringify(md2json(replaced.text)), + }); + changes.push({ + enhancedNoteId: noteId, + title: getSummaryTitle(store, noteId), + replacements: replaced.count, + }); + } + + return changes; +} + +function parseTranscriptWords(value: unknown): TranscriptWord[] { + if (typeof value !== "string" || !value) { + return []; + } + + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? (parsed as TranscriptWord[]) : []; + } catch { + return []; + } +} + +function tokenizeReplacement(value: string): string[] { + return value + .split(/\s+/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function wordRangeMatchesAt( + words: TranscriptWord[], + target: string[], + start: number, +): boolean { + if (target.length === 0 || start + target.length > words.length) { + return false; + } + + return target.every((text, index) => words[start + index].text === text); +} + +function buildReplacementWords( + original: TranscriptWord[], + newText: string, +): TranscriptWord[] { + const tokens = tokenizeReplacement(newText); + if (tokens.length === 0) { + return []; + } + + const first = original[0] ?? {}; + const last = original[original.length - 1] ?? first; + const startMs = typeof first.start_ms === "number" ? first.start_ms : null; + const endMs = typeof last.end_ms === "number" ? last.end_ms : startMs; + const duration = + startMs !== null && endMs !== null ? Math.max(endMs - startMs, 0) : 0; + const step = tokens.length > 0 ? duration / tokens.length : 0; + const baseId = typeof first.id === "string" && first.id ? first.id : null; + + return tokens.map((text, index) => { + const wordStart = + startMs === null ? first.start_ms : Math.round(startMs + step * index); + const wordEnd = + startMs === null + ? first.end_ms + : Math.round(startMs + step * (index + 1)); + + return { + ...first, + id: index === 0 ? first.id : `${baseId ?? "word"}:correction:${index}`, + text, + start_ms: wordStart, + end_ms: wordEnd, + }; + }); +} + +function replaceTranscriptWords( + words: TranscriptWord[], + oldText: string, + newText: string, +): { words: TranscriptWord[]; count: number } { + const target = tokenizeReplacement(oldText); + const replacementTokens = tokenizeReplacement(newText); + if ( + target.length === 0 || + replacementTokens.length === 0 || + target.length > words.length + ) { + return { words, count: 0 }; + } + + const nextWords: TranscriptWord[] = []; + let count = 0; + for (let index = 0; index < words.length; ) { + if (wordRangeMatchesAt(words, target, index)) { + const original = words.slice(index, index + target.length); + nextWords.push(...buildReplacementWords(original, newText)); + index += target.length; + count++; + continue; + } + + nextWords.push(words[index]); + index++; + } + + return count === 0 ? { words, count: 0 } : { words: nextWords, count }; +} + +function applyTranscriptCorrection({ + store, + indexes, + sessionId, + oldText, + newText, +}: { + store: Store; + indexes: Indexes; + sessionId: string; + oldText: string; + newText: string; +}): TranscriptChange[] { + if (tokenizeReplacement(newText).length === 0) { + return []; + } + + const transcriptIds = indexes.getSliceRowIds( + main.INDEXES.transcriptBySession, + sessionId, + ); + const changes: TranscriptChange[] = []; + + for (const transcriptId of transcriptIds) { + const rawWords = store.getCell("transcripts", transcriptId, "words"); + const words = parseTranscriptWords(rawWords); + const wordResult = replaceTranscriptWords(words, oldText, newText); + + const rawMemo = store.getCell("transcripts", transcriptId, "memo_md"); + const hasMemo = typeof rawMemo === "string" && rawMemo.length > 0; + const memoResult = replaceExact(hasMemo ? rawMemo : "", oldText, newText); + + if (wordResult.count === 0 && memoResult.count === 0) { + continue; + } + if ( + words.length > 0 && + hasMemo && + (wordResult.count === 0 || memoResult.count === 0) + ) { + continue; + } + + if (wordResult.count > 0) { + store.setCell( + "transcripts", + transcriptId, + "words", + JSON.stringify(wordResult.words), + ); + } + if (memoResult.count > 0) { + store.setCell("transcripts", transcriptId, "memo_md", memoResult.text); + } + + changes.push({ + transcriptId, + wordReplacements: wordResult.count, + memoReplacements: memoResult.count, + }); + } + + return changes; +} + +function shouldEditSummary(target: CorrectionTarget): boolean { + return target === "summary" || target === "summary_and_transcript"; +} + +function shouldEditTranscript(target: CorrectionTarget): boolean { + return target === "transcript" || target === "summary_and_transcript"; +} + +export const buildApplySessionCorrectionTool = ( + deps: Pick< + ToolDependencies, + "getStore" | "getIndexes" | "getSessionId" | "getEnhancedNoteId" + >, +) => + tool({ + description: + "Apply an exact correction to a session summary and/or transcript. Use this when the user corrects note content, for example 'it's not X but Y'. Read the note first if you need the exact old text.", + inputSchema: z.object({ + sessionId: z + .string() + .optional() + .describe("The session ID to edit. Defaults to the current session."), + target: z + .enum(["summary", "transcript", "summary_and_transcript"]) + .default("summary_and_transcript") + .describe("Which session content to correct."), + enhancedNoteId: z + .string() + .optional() + .describe( + "Optional summary ID to restrict summary edits. Defaults to summaries in the target session.", + ), + oldText: z + .string() + .min(1) + .describe("Exact text currently present in the note or transcript."), + newText: z.string().describe("Replacement text."), + }), + execute: async (params: { + sessionId?: string; + target?: CorrectionTarget; + enhancedNoteId?: string; + oldText: string; + newText: string; + }) => { + const store = deps.getStore(); + const indexes = deps.getIndexes(); + const sessionId = params.sessionId ?? deps.getSessionId(); + const target = params.target ?? "summary_and_transcript"; + + if (!store || !indexes || !sessionId) { + return { + status: "error", + message: + "No active session selected. Provide sessionId explicitly when calling apply_session_correction.", + }; + } + + const newText = params.newText.trim(); + if (!newText) { + return { + status: "error", + message: "Replacement text cannot be blank.", + sessionId, + }; + } + + const enhancedNoteId = + params.enhancedNoteId ?? + (params.sessionId ? undefined : deps.getEnhancedNoteId()); + let editSummary = shouldEditSummary(target); + if ( + editSummary && + enhancedNoteId && + !hasEnhancedNoteInSession({ + indexes, + sessionId, + enhancedNoteId, + }) + ) { + if (target === "summary") { + return { + status: "error", + message: + "The requested summary does not belong to the target session.", + sessionId, + }; + } + editSummary = false; + } + + const summaryChanges = editSummary + ? applySummaryCorrection({ + store, + indexes, + sessionId, + enhancedNoteId, + oldText: params.oldText, + newText, + }) + : []; + const transcriptChanges = shouldEditTranscript(target) + ? applyTranscriptCorrection({ + store, + indexes, + sessionId, + oldText: params.oldText, + newText, + }) + : []; + + if (summaryChanges.length === 0 && transcriptChanges.length === 0) { + return { + status: "not_found", + message: + "No exact match found. Read the note and call apply_session_correction with the exact current text.", + sessionId, + }; + } + + return { + status: "applied", + sessionId, + summaryChanges, + transcriptChanges, + }; + }, + }); + +export const sessionCorrectionTestInternals = { + applySummaryCorrection, + applyTranscriptCorrection, + replaceExact, + replaceTranscriptWords, +}; diff --git a/apps/desktop/src/chat/transport/use-transport.ts b/apps/desktop/src/chat/transport/use-transport.ts index 87612850ac..2f9e8e121c 100644 --- a/apps/desktop/src/chat/transport/use-transport.ts +++ b/apps/desktop/src/chat/transport/use-transport.ts @@ -19,6 +19,7 @@ Context and local-note tool guidance: - When the user asks about "this note", "this meeting", "the current note", or pronouns that likely refer to the open note, use read_current_note before answering. - When the user asks to find or search for exact wording in notes, use grep_notes. If the answer needs the full source after a match, use read_note with the returned session id. - When the user asks about people from the current note or related meetings, use list_related_notes and then read_note as needed. +- When the user corrects note content with wording like "it's not X but Y", use apply_session_correction to update the current session summary and/or transcript. Use read_current_note first when you need the exact current text. Do not merely acknowledge the correction when it should update the note. - Do not ask the user to open or share a meeting note until search_sessions, grep_notes, or read_note cannot find enough local context. - Do not assume note contents from chat history when a file-backed tool can read the current source of truth.