diff --git a/apps/desktop/main.cjs b/apps/desktop/main.cjs index 22373e4..81ac92f 100644 --- a/apps/desktop/main.cjs +++ b/apps/desktop/main.cjs @@ -13,6 +13,7 @@ const { fileURLToPath, pathToFileURL } = require("node:url"); const { updateElectronApp, UpdateSourceType } = require("update-electron-app"); const { bundlePaths, + contextPacketPaths, readJSONIfExists, readTextIfExists, sourceTrackPaths, @@ -1429,6 +1430,111 @@ function normalizeStoredAppSessionStatus(value) { }; } +function normalizeSourceContextStatus(value) { + const raw = value && typeof value === "object" ? value : {}; + const state = optionalNonEmptyString(raw.state); + if (!["idle", "generating", "ready", "failed"].includes(state)) { + return null; + } + return { + state, + detail: optionalNonEmptyString(raw.detail), + retryable: Boolean(raw.retryable), + updatedAt: optionalNonEmptyString(raw.updatedAt), + packetId: optionalNonEmptyString(raw.packetId), + objectCount: Number.isFinite(Number(raw.objectCount)) ? Number(raw.objectCount) : 0, + relationCount: Number.isFinite(Number(raw.relationCount)) ? Number(raw.relationCount) : 0, + warningCount: Number.isFinite(Number(raw.warningCount)) ? Number(raw.warningCount) : 0, + agentBriefPath: null, + projectContextPath: null, + contextDirectoryPath: null, + }; +} + +function withDerivedSourceContextPaths(status, directoryPath, availablePaths = {}) { + const paths = contextPacketPaths(directoryPath); + return { + ...status, + agentBriefPath: availablePaths.agentBrief === false ? null : paths.agentBriefPath, + projectContextPath: availablePaths.projectContext === false ? null : paths.projectContextPath, + contextDirectoryPath: paths.contextDirectoryPath, + }; +} + +function sourceContextStatusFromMetadata(metadata, directoryPath, options = {}) { + const verifyFiles = options.verifyFiles !== false; + const paths = contextPacketPaths(directoryPath); + const stored = normalizeSourceContextStatus(metadata?.sourceContext); + if (!verifyFiles) { + if (stored) { + return stored.state === "ready" ? withDerivedSourceContextPaths(stored, directoryPath) : stored; + } + return { + state: "idle", + detail: null, + retryable: false, + updatedAt: null, + packetId: null, + objectCount: 0, + relationCount: 0, + warningCount: 0, + agentBriefPath: null, + projectContextPath: null, + contextDirectoryPath: paths.contextDirectoryPath, + }; + } + + const hasAgentBrief = fsSync.existsSync(paths.agentBriefPath); + const hasProjectContext = fsSync.existsSync(paths.projectContextPath); + if (hasAgentBrief || hasProjectContext) { + return withDerivedSourceContextPaths({ + state: "ready", + detail: null, + retryable: false, + updatedAt: optionalNonEmptyString(metadata?.sourceContext?.updatedAt), + packetId: optionalNonEmptyString(metadata?.sourceContext?.packetId), + objectCount: Number.isFinite(Number(metadata?.sourceContext?.objectCount)) ? Number(metadata.sourceContext.objectCount) : 0, + relationCount: Number.isFinite(Number(metadata?.sourceContext?.relationCount)) ? Number(metadata.sourceContext.relationCount) : 0, + warningCount: Number.isFinite(Number(metadata?.sourceContext?.warningCount)) ? Number(metadata.sourceContext.warningCount) : 0, + agentBriefPath: null, + projectContextPath: null, + contextDirectoryPath: null, + }, directoryPath, { agentBrief: hasAgentBrief, projectContext: hasProjectContext }); + } + + if (stored && stored.state !== "ready") { + return stored; + } + + return { + state: "idle", + detail: null, + retryable: false, + updatedAt: null, + packetId: null, + objectCount: 0, + relationCount: 0, + warningCount: 0, + agentBriefPath: null, + projectContextPath: null, + contextDirectoryPath: paths.contextDirectoryPath, + }; +} + +function noteHasCompletedTranscriptForSourceContext(note) { + const sessionState = optionalNonEmptyString(note?.sessionStatus?.state); + if (["recording", "degraded", "finalizing", "transcribing"].includes(sessionState)) { + return false; + } + return sessionState === "completed" + && Array.isArray(note?.transcriptSegments) + && note.transcriptSegments.some((segment) => !segment.isPreview && typeof segment.text === "string" && segment.text.trim()); +} + +function errorMessage(error, fallback = "Source context generation failed.") { + return error instanceof Error && error.message ? error.message : fallback; +} + async function writeListenerSessionState(noteId, patch) { if (!noteId) { return; @@ -1898,6 +2004,7 @@ async function loadBundleSnapshot(directoryName) { recordingPath: paths.recordingPath, listenerSession, sessionStatus: normalizeAppSessionStatus({ listenerSession, sttChunks }), + sourceContextStatus: sourceContextStatusFromMetadata(metadata, directoryPath), }; return { @@ -1930,6 +2037,7 @@ function noteSummaryFromRow(row) { recordingPath: row.recording_path, listenerSession, sessionStatus: normalizeStoredAppSessionStatus(row.session_status_json), + sourceContextStatus: sourceContextStatusFromMetadata(metadata, row.directory_path, { verifyFiles: false }), }; } @@ -2540,6 +2648,7 @@ async function readNote(id) { return { ...summary, sessionStatus: normalizeAppSessionStatus({ listenerSession: summary.listenerSession, sttChunks }), + sourceContextStatus: sourceContextStatusFromMetadata(parseMetadataJSON(row.metadata_json), directoryPath), directoryPath, markdown: row.markdown, metadata: parseMetadataJSON(row.metadata_json), @@ -3057,17 +3166,96 @@ async function generateNoteSummary(noteID, templateID) { async function generateSourceContext(noteID) { const note = await readNote(noteID); + const directoryPath = resolveBundleDirectory(noteID); + const paths = bundlePaths(directoryPath); + const currentMetadata = await readJSONIfExists(paths.metadataPath) || {}; + const currentStatus = sourceContextStatusFromMetadata(currentMetadata, directoryPath); + if (currentStatus.state === "ready") { + return { + noteId: note.id, + sourceContextStatus: currentStatus, + }; + } + if (!noteHasCompletedTranscriptForSourceContext(note)) { + const blockedStatus = { + state: "failed", + detail: "No completed transcript is available for source context generation.", + retryable: true, + updatedAt: new Date().toISOString(), + }; + currentMetadata.sourceContext = blockedStatus; + await writeJSONFileAtomic(paths.metadataPath, currentMetadata); + await syncNotesDatabaseFromFiles([noteID]); + throw new Error(blockedStatus.detail); + } + + currentMetadata.sourceContext = { + state: "generating", + detail: null, + retryable: false, + updatedAt: new Date().toISOString(), + }; + await writeJSONFileAtomic(paths.metadataPath, currentMetadata); + await syncNotesDatabaseFromFiles([noteID]); + const settings = await readElectronSettings(); const summarySettings = normalizeSummarySettings(settings.summary); - const payload = await nativeCaptureRequest("generateSourceContext", { - targetDirectory: note.directoryPath, - summarySettings, - }); + try { + const payload = await nativeCaptureRequest("generateSourceContext", { + targetDirectory: note.directoryPath, + summarySettings, + }); + const sourceContext = payload.sourceContext || payload; + const nextMetadata = await readJSONIfExists(paths.metadataPath) || {}; + nextMetadata.sourceContext = { + state: "ready", + detail: null, + retryable: false, + updatedAt: new Date().toISOString(), + packetId: optionalNonEmptyString(sourceContext.packetId), + objectCount: Number.isFinite(Number(sourceContext.objectCount)) ? Number(sourceContext.objectCount) : 0, + relationCount: Number.isFinite(Number(sourceContext.relationCount)) ? Number(sourceContext.relationCount) : 0, + warningCount: Number.isFinite(Number(sourceContext.warningCount)) ? Number(sourceContext.warningCount) : 0, + }; + await writeJSONFileAtomic(paths.metadataPath, nextMetadata); + await syncNotesDatabaseFromFiles([noteID]); - return { - noteId: note.id, - ...(payload.sourceContext || payload), - }; + return { + noteId: note.id, + ...sourceContext, + sourceContextStatus: sourceContextStatusFromMetadata(nextMetadata, directoryPath), + }; + } catch (error) { + const message = errorMessage(error); + const nextMetadata = await readJSONIfExists(paths.metadataPath) || {}; + nextMetadata.sourceContext = { + state: "failed", + detail: message, + retryable: true, + updatedAt: new Date().toISOString(), + }; + await writeJSONFileAtomic(paths.metadataPath, nextMetadata); + await syncNotesDatabaseFromFiles([noteID]); + throw error; + } +} + +async function openAgentBrief(noteID) { + const directoryPath = resolveBundleDirectory(noteID); + const paths = contextPacketPaths(directoryPath); + const targetPath = fsSync.existsSync(paths.agentBriefPath) + ? paths.agentBriefPath + : fsSync.existsSync(paths.projectContextPath) + ? paths.projectContextPath + : null; + if (!targetPath) { + throw new Error("No agent context file is available for this note."); + } + const result = await shell.openPath(targetPath); + if (result) { + throw new Error(result); + } + return true; } async function testSummaryProvider() { @@ -4522,6 +4710,8 @@ function registerIPCHandlers() { ipcMain.handle("notes:read", (_event, id) => readNote(id)); ipcMain.handle("notes:save", (_event, id, markdown) => saveNote(id, markdown)); ipcMain.handle("summary:generate", (_event, id, templateId) => generateNoteSummary(id, templateId)); + ipcMain.handle("source-context:generate", (_event, id) => generateSourceContext(id)); + ipcMain.handle("source-context:open-agent-brief", (_event, id) => openAgentBrief(id)); ipcMain.handle("summary:test-provider", () => testSummaryProvider()); ipcMain.handle("summary:list-models", (_event, provider) => listSummaryModels(provider)); ipcMain.handle("transcript:save", (_event, id, segments) => saveTranscript(id, segments)); @@ -4630,7 +4820,9 @@ if (process.env.MIRROR_NOTE_TEST_EXPORTS === "1") { normalizeCustomSummaryTemplates, normalizeAppSessionStatus, normalizeListenerSessionState, + normalizeSourceContextStatus, normalizeSummarySettings, + noteHasCompletedTranscriptForSourceContext, nativeHelperWorkingDirectory, normalizeTranscriptSegments, parseSTTChunkLedgerJSONL, @@ -4642,6 +4834,7 @@ if (process.env.MIRROR_NOTE_TEST_EXPORTS === "1") { renderSummaryResultMarkdown, requestChatCompletion, retryTranscript, + sourceContextStatusFromMetadata, uploadRecordingAsset, uploadTranscriptAsset, uploadMarkdownNote, diff --git a/apps/desktop/main/native-launch.node-test.cjs b/apps/desktop/main/native-launch.node-test.cjs index 9546d47..c4804bf 100644 --- a/apps/desktop/main/native-launch.node-test.cjs +++ b/apps/desktop/main/native-launch.node-test.cjs @@ -1,7 +1,12 @@ const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); process.env.MIRROR_NOTE_TEST_EXPORTS = "1"; +const { contextPacketPaths } = require("./artifact-files.cjs"); + const { latestFailedSTTChunks, latestSTTChunks, @@ -9,10 +14,13 @@ const { nativeHelperWorkingDirectory, normalizeAppSessionStatus, normalizeListenerSessionState, + normalizeSourceContextStatus, normalizeTranscriptSegments, + noteHasCompletedTranscriptForSourceContext, parseSTTChunkLedgerJSONL, parseTranscriptJSONL, serializeTranscriptJSONL, + sourceContextStatusFromMetadata, } = require("../main.cjs"); assert.equal(nativeHelperWorkingDirectory(), process.cwd()); @@ -134,6 +142,41 @@ assert.deepEqual(latestSTTChunks(ledgerRecords).map((record) => `${record.chunkI assert.equal(latestSTTChunks(ledgerRecords)[1].retryState, "completed"); assert.equal(latestSTTChunks(ledgerRecords)[1].recordedAt, "2026-05-04T00:00:00.000Z"); +const sourceContextTranscriptNote = { + sessionStatus: { state: "completed" }, + transcriptSegments: [{ id: "seg-1", startTimeSeconds: 0, endTimeSeconds: 1, text: "Decision", isPreview: false }], +}; +assert.equal(noteHasCompletedTranscriptForSourceContext(sourceContextTranscriptNote), true); +for (const state of ["recording", "degraded", "finalizing", "transcribing"]) { + assert.equal(noteHasCompletedTranscriptForSourceContext({ + ...sourceContextTranscriptNote, + sessionStatus: { state }, + }), false); +} +assert.equal(noteHasCompletedTranscriptForSourceContext({ + ...sourceContextTranscriptNote, + transcriptSegments: [{ id: "seg-1", startTimeSeconds: 0, endTimeSeconds: 1, text: "Decision", isPreview: true }], +}), false); +assert.equal(normalizeSourceContextStatus({ state: "failed", detail: "Add key.", retryable: true }).retryable, true); + +const contextTempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "mirrornote-source-context-")); +const generatedContextPaths = contextPacketPaths(contextTempDirectory); +fs.mkdirSync(generatedContextPaths.contextDirectoryPath, { recursive: true }); +fs.writeFileSync(generatedContextPaths.agentBriefPath, "# Agent Brief\n", "utf8"); +assert.deepEqual(sourceContextStatusFromMetadata({}, contextTempDirectory), { + state: "ready", + detail: null, + retryable: false, + updatedAt: null, + packetId: null, + objectCount: 0, + relationCount: 0, + warningCount: 0, + agentBriefPath: generatedContextPaths.agentBriefPath, + projectContextPath: null, + contextDirectoryPath: generatedContextPaths.contextDirectoryPath, +}); + const interruptedLedgerRecords = parseSTTChunkLedgerJSONL([ JSON.stringify({ sessionID: "session-a", diff --git a/apps/desktop/preload.cjs b/apps/desktop/preload.cjs index 6e84b8b..677003b 100644 --- a/apps/desktop/preload.cjs +++ b/apps/desktop/preload.cjs @@ -5,6 +5,8 @@ contextBridge.exposeInMainWorld("mirrorNote", { readNote: (id) => ipcRenderer.invoke("notes:read", id), saveNote: (id, markdown) => ipcRenderer.invoke("notes:save", id, markdown), generateSummary: (id, templateId) => ipcRenderer.invoke("summary:generate", id, templateId), + generateSourceContext: (id) => ipcRenderer.invoke("source-context:generate", id), + openAgentBrief: (id) => ipcRenderer.invoke("source-context:open-agent-brief", id), testSummaryProvider: () => ipcRenderer.invoke("summary:test-provider"), listSummaryModels: (provider) => ipcRenderer.invoke("summary:list-models", provider), saveTranscript: (id, segments) => ipcRenderer.invoke("transcript:save", id, segments), diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index e4c88ac..18645d7 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -42,6 +42,7 @@ import { shouldClearProcessingNote, statusDetailForCaptureState, } from "./utils/captureActivity"; +import { canAutoGenerateSourceContext } from "./utils/sourceContext"; import { WorkspaceHeader } from "./components/WorkspaceHeader"; interface NavigationTarget { @@ -81,7 +82,9 @@ export function App() { const [pendingTranscriptGenerationNoteId, setPendingTranscriptGenerationNoteId] = useState(null); const [workspaceAddMenuOpen, setWorkspaceAddMenuOpen] = useState(false); const [generatingSummaryId, setGeneratingSummaryId] = useState(null); + const [generatingSourceContextId, setGeneratingSourceContextId] = useState(null); const [summaryError, setSummaryError] = useState(""); + const [sourceContextError, setSourceContextError] = useState(""); const [sttModelDownloadProgress, setSTTModelDownloadProgress] = useState(null); const [selectedSummaryTemplateId, setSelectedSummaryTemplateId] = useState(DEFAULT_SUMMARY_TEMPLATE_ID); const [selectedTemplateId, setSelectedTemplateId] = useState(DEFAULT_SUMMARY_TEMPLATE_ID); @@ -101,6 +104,8 @@ export function App() { const transcriptSaveTimerRef = useRef(null); const renameTimerRef = useRef(null); const generatingSummaryIdRef = useRef(null); + const generatingSourceContextIdRef = useRef(null); + const sourceContextAutoQueuedRef = useRef>(new Set()); const lastDefaultSummaryTemplateIdRef = useRef(DEFAULT_SUMMARY_TEMPLATE_ID); const navigationBackStackRef = useRef([]); const navigationForwardStackRef = useRef([]); @@ -201,6 +206,11 @@ export function App() { setGeneratingSummaryId(noteId); } + function setGeneratingSourceContext(noteId: string | null) { + generatingSourceContextIdRef.current = noteId; + setGeneratingSourceContextId(noteId); + } + function lockNoteAssetMutation(noteId: string) { noteAssetMutationIdRef.current = noteId; setNoteAssetMutationId(noteId); @@ -340,6 +350,22 @@ export function App() { } }, [activeTab, hasTranscriptTab]); + useEffect(() => { + if (!selectedNote || !canAutoGenerateSourceContext(selectedNote)) { + return; + } + if (sourceContextAutoQueuedRef.current.has(selectedNote.id)) { + return; + } + sourceContextAutoQueuedRef.current.add(selectedNote.id); + void generateSourceContext(selectedNote.id, true); + }, [ + selectedNote?.id, + selectedNote?.sessionStatus?.state, + selectedNote?.sourceContextStatus.state, + selectedNote?.transcriptSegments.length, + ]); + async function refreshNotes(preferredId?: string) { setStatusMessage(t("status.loadingNotes")); try { @@ -383,6 +409,7 @@ export function App() { clearPendingSave(); clearPendingTranscriptSave(); setSummaryError(""); + setSourceContextError(""); setMode("notes"); setSidebarMode("notes"); setActiveTab("note"); @@ -445,6 +472,7 @@ export function App() { replaceEditorMarkdown(""); setActiveTab("note"); setSummaryError(""); + setSourceContextError(""); setStatusMessage(""); } @@ -979,6 +1007,72 @@ export function App() { } } + async function generateSourceContext(noteId = selectedNoteRef.current?.id, isAuto = false) { + if (!noteId || generatingSourceContextIdRef.current === noteId) { + return; + } + + setGeneratingSourceContext(noteId); + setSourceContextError(""); + if (!isAuto) { + setSaveState("saving"); + setStatusMessage(t("status.generatingContext")); + } + try { + if (selectedNoteRef.current?.id === noteId) { + const savedNote = await saveNow(); + const savedTranscript = await savePendingTranscriptNow(); + if (!savedNote || !savedTranscript) { + throw new Error(t("error.somethingFailed")); + } + } + await api.generateSourceContext(noteId); + const nextNote = await api.readNote(noteId); + updateNoteSummary(nextNote); + if (selectedNoteRef.current?.id === noteId) { + setSelectedNote(nextNote); + selectedNoteRef.current = nextNote; + replaceEditorMarkdown(nextNote.markdown || ""); + lastSavedMarkdownRef.current = nextNote.markdown || ""; + setLastSavedMarkdown(nextNote.markdown || ""); + setSaveState("saved"); + setStatusMessage(""); + } + } catch (error) { + sourceContextAutoQueuedRef.current.delete(noteId); + const message = messageForError(error); + if (selectedNoteRef.current?.id === noteId) { + setSourceContextError(message); + setSaveState("failed"); + setStatusMessage(""); + try { + const nextNote = await api.readNote(noteId); + setSelectedNote(nextNote); + selectedNoteRef.current = nextNote; + updateNoteSummary(nextNote); + } catch { + // Keep the visible error from the generation request. + } + } + } finally { + if (generatingSourceContextIdRef.current === noteId) { + setGeneratingSourceContext(null); + } + } + } + + async function openAgentBrief(noteId = selectedNoteRef.current?.id) { + if (!noteId) { + return; + } + setSourceContextError(""); + try { + await api.openAgentBrief(noteId); + } catch (error) { + setSourceContextError(messageForError(error)); + } + } + function updateTranscriptSegment(segmentId: string, text: string) { const current = selectedNoteRef.current; if (!current) { @@ -1534,10 +1628,14 @@ export function App() { markdown={editorMarkdown} contentRevision={editorContentRevision} isGeneratingSummary={isGeneratingSelectedNoteSummary} + isGeneratingSourceContext={generatingSourceContextId === selectedNote.id} summaryError={summaryError} + sourceContextError={sourceContextError} summaryTemplates={allSummaryTemplates} selectedSummaryTemplateId={selectedSummaryTemplateId} onGenerateSummary={() => void generateSummary()} + onGenerateSourceContext={() => void generateSourceContext(selectedNote.id, false)} + onOpenAgentBrief={() => void openAgentBrief(selectedNote.id)} onSelectSummaryTemplate={setSelectedSummaryTemplateId} onManageTemplates={() => void showTemplates(selectedSummaryTemplateId)} onChangeMarkdown={onEditorMarkdownChange} diff --git a/apps/desktop/src/components/NoteTab.tsx b/apps/desktop/src/components/NoteTab.tsx index 6196a9d..02ad2d7 100644 --- a/apps/desktop/src/components/NoteTab.tsx +++ b/apps/desktop/src/components/NoteTab.tsx @@ -1,9 +1,10 @@ -import { Sparkles } from "lucide-react"; +import { FileText, LoaderCircle, RefreshCw, Sparkles } from "lucide-react"; import { NoteEditor } from "./NoteEditor"; import { ListSelect } from "./ListSelect"; import type { NoteDetail, SummaryTemplate } from "../types"; import { useI18n } from "../i18n"; import { hasSummaryMarkdown } from "../utils/noteMarkdown"; +import { canRetrySourceContext } from "../utils/sourceContext"; const MANAGE_TEMPLATES_VALUE = "__manage-templates"; @@ -12,10 +13,14 @@ interface NoteTabProps { markdown: string; contentRevision: number; isGeneratingSummary: boolean; + isGeneratingSourceContext: boolean; summaryError: string; + sourceContextError: string; summaryTemplates: SummaryTemplate[]; selectedSummaryTemplateId: string; onGenerateSummary: () => void; + onGenerateSourceContext: () => void; + onOpenAgentBrief: () => void; onSelectSummaryTemplate: (templateId: string) => void; onManageTemplates: () => void; onChangeMarkdown: (markdown: string) => void; @@ -26,10 +31,14 @@ export function NoteTab({ markdown, contentRevision, isGeneratingSummary, + isGeneratingSourceContext, summaryError, + sourceContextError, summaryTemplates, selectedSummaryTemplateId, onGenerateSummary, + onGenerateSourceContext, + onOpenAgentBrief, onSelectSummaryTemplate, onManageTemplates, onChangeMarkdown, @@ -37,6 +46,10 @@ export function NoteTab({ const { t } = useI18n(); const hasUsableTranscript = note.transcriptSegments.some((segment) => !segment.isPreview && segment.text.trim()); const hasSummary = hasSummaryMarkdown(markdown); + const sourceContextStatus = note.sourceContextStatus; + const showSourceContextStatus = + isGeneratingSourceContext || sourceContextStatus.state === "generating" || sourceContextStatus.state === "ready" || sourceContextStatus.state === "failed"; + const sourceContextDetail = sourceContextError || (sourceContextStatus.state === "failed" ? sourceContextStatus.detail : null); const buttonLabel = isGeneratingSummary ? t("workspace.generating") : hasUsableTranscript @@ -86,6 +99,43 @@ export function NoteTab({ {summaryError} ) : null} + {showSourceContextStatus ? ( +
+ + {isGeneratingSourceContext || sourceContextStatus.state === "generating" ? ( + + {sourceContextStatus.state === "ready" ? ( + + ) : null} + {sourceContextStatus.state === "failed" && canRetrySourceContext(sourceContextStatus) ? ( + + ) : null} +
+ ) : null} + {sourceContextDetail ? ( + + {sourceContextDetail} + + ) : null} {isGeneratingSummary ? (
diff --git a/apps/desktop/src/i18n.tsx b/apps/desktop/src/i18n.tsx index df61f88..25cf996 100644 --- a/apps/desktop/src/i18n.tsx +++ b/apps/desktop/src/i18n.tsx @@ -81,6 +81,7 @@ type I18nKey = | "status.deletingNote" | "status.chunkProgress" | "status.finalizingTranscript" + | "status.generatingContext" | "status.generatingSummary" | "status.loadingNotes" | "status.openingNote" @@ -161,6 +162,8 @@ type I18nKey = | "workspace.recordingPlayback" | "workspace.regenerateSummary" | "workspace.revealInFinder" + | "workspace.agentContextFailed" + | "workspace.agentContextReady" | "workspace.selectNote" | "workspace.showSidebar" | "workspace.startRecording" @@ -172,10 +175,13 @@ type I18nKey = | "workspace.uploadRecording" | "workspace.uploadTranscription" | "workspace.generateSummary" + | "workspace.generatingContext" | "workspace.generating" | "workspace.generatingSummaryTitle" | "workspace.manageTemplates" | "workspace.noTranscript" + | "workspace.openAgentBrief" + | "workspace.retryContext" | "workspace.transcript" | "workspace.uploadAsset" | "activity.captureFailed" @@ -266,6 +272,7 @@ const translations: Record> = { "status.deletingNote": "Deleting note...", "status.chunkProgress": "chunks {completed} done, {running} running, {failed} failed", "status.finalizingTranscript": "Finalizing transcript...", + "status.generatingContext": "Generating context...", "status.generatingSummary": "Generating summary...", "status.loadingNotes": "Loading notes...", "status.openingNote": "Opening note...", @@ -346,6 +353,8 @@ const translations: Record> = { "workspace.recordingPlayback": "Recording playback", "workspace.regenerateSummary": "Regenerate Summary", "workspace.revealInFinder": "Reveal in Finder", + "workspace.agentContextFailed": "Context failed", + "workspace.agentContextReady": "Ready", "workspace.selectNote": "Select a note", "workspace.showSidebar": "Show sidebar", "workspace.startRecording": "Start recording", @@ -357,10 +366,13 @@ const translations: Record> = { "workspace.uploadRecording": "Upload MP3 recording", "workspace.uploadTranscription": "Upload transcription", "workspace.generateSummary": "Generate Summary", + "workspace.generatingContext": "Generating context", "workspace.generating": "Generating...", "workspace.generatingSummaryTitle": "Generating summary", "workspace.manageTemplates": "Manage templates...", "workspace.noTranscript": "No transcript", + "workspace.openAgentBrief": "Open agent brief", + "workspace.retryContext": "Retry", "workspace.transcript": "Transcript", "workspace.uploadAsset": "Upload recording or transcript", "activity.captureFailed": "Capture failed", @@ -450,6 +462,7 @@ const translations: Record> = { "status.deletingNote": "노트 삭제 중...", "status.chunkProgress": "청크 {completed} 완료, {running} 실행 중, {failed} 실패", "status.finalizingTranscript": "전사 마무리 중...", + "status.generatingContext": "컨텍스트 생성 중...", "status.generatingSummary": "요약 생성 중...", "status.loadingNotes": "노트 불러오는 중...", "status.openingNote": "노트 여는 중...", @@ -530,6 +543,8 @@ const translations: Record> = { "workspace.recordingPlayback": "녹음 재생", "workspace.regenerateSummary": "요약 다시 생성", "workspace.revealInFinder": "Finder에서 보기", + "workspace.agentContextFailed": "컨텍스트 실패", + "workspace.agentContextReady": "준비됨", "workspace.selectNote": "노트를 선택하세요", "workspace.showSidebar": "사이드바 보기", "workspace.startRecording": "녹음 시작", @@ -541,10 +556,13 @@ const translations: Record> = { "workspace.uploadRecording": "녹음 mp3 업로드", "workspace.uploadTranscription": "전사 업로드", "workspace.generateSummary": "요약 생성", + "workspace.generatingContext": "컨텍스트 생성 중", "workspace.generating": "생성 중...", "workspace.generatingSummaryTitle": "요약 생성 중", "workspace.manageTemplates": "템플릿 관리...", "workspace.noTranscript": "전사 없음", + "workspace.openAgentBrief": "agent brief 열기", + "workspace.retryContext": "재시도", "workspace.transcript": "전사", "workspace.uploadAsset": "녹음 또는 전사 업로드", "activity.captureFailed": "캡처 실패", diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 5dffa72..3d3fd8b 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -1084,6 +1084,65 @@ button { overflow-wrap: anywhere; } +.note-source-context-status { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + min-width: 0; + color: var(--muted-foreground); + font-size: 12px; +} + +.note-source-context-pill, +.note-source-context-action { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + height: 26px; + border: 0; + border-radius: 999px; + padding: 0 9px; + font-size: 12px; + font-weight: 650; + white-space: nowrap; +} + +.note-source-context-pill { + background: var(--secondary); + color: var(--muted-foreground); +} + +.note-source-context-status.ready .note-source-context-pill { + background: rgba(37, 99, 235, 0.1); + color: #2563eb; +} + +.note-source-context-status.failed .note-source-context-pill { + background: rgba(185, 28, 28, 0.08); + color: var(--destructive); +} + +.note-source-context-action { + background: transparent; + color: var(--foreground); +} + +.note-source-context-action:hover, +.note-source-context-action:focus-visible { + background: var(--accent); +} + +.note-source-context-action:disabled { + color: var(--muted-foreground); + cursor: not-allowed; +} + +.note-source-context-status .icon.spin { + animation: progress-ring-spin 780ms linear infinite; +} + .note-summary-generating { width: calc(100% - 56px); max-width: none; diff --git a/apps/desktop/src/types.ts b/apps/desktop/src/types.ts index 519da8e..366d4c7 100644 --- a/apps/desktop/src/types.ts +++ b/apps/desktop/src/types.ts @@ -10,6 +10,7 @@ export type TranscriptSource = "microphone" | "system"; export type ListenerSessionState = "starting" | "recording" | "stopping" | "recovering" | "failed" | "completed"; export type AppSessionState = "recording" | "degraded" | "finalizing" | "transcribing" | "failed" | "completed" | "recoverable"; export type STTChunkStatus = "queued" | "running" | "completed" | "failed"; +export type SourceContextState = "idle" | "generating" | "ready" | "failed"; export type SpeakerLabelingMode = "local" | "disabled"; export type AppLanguage = "en" | "ko"; export type SummaryProvider = "openai" | "ollama" | "lmstudio"; @@ -82,6 +83,7 @@ export interface NoteSummary { recordingPath: string; listenerSession: ListenerSession | null; sessionStatus: AppSessionStatus | null; + sourceContextStatus: SourceContextStatus; } export interface TranscriptSegment { @@ -116,6 +118,20 @@ export interface AppSessionStatus { runningChunkCount: number; } +export interface SourceContextStatus { + state: SourceContextState; + detail: string | null; + retryable: boolean; + updatedAt: string | null; + packetId: string | null; + objectCount: number; + relationCount: number; + warningCount: number; + agentBriefPath: string | null; + projectContextPath: string | null; + contextDirectoryPath: string | null; +} + export interface STTChunk { sessionID: string | null; chunkID: string; @@ -235,6 +251,8 @@ export interface MirrorNoteAPI { readNote: (id: string) => Promise; saveNote: (id: string, markdown: string) => Promise; generateSummary: (id: string, templateId?: string) => Promise; + generateSourceContext: (id: string) => Promise<{ noteId: string; sourceContextStatus: SourceContextStatus }>; + openAgentBrief: (id: string) => Promise; testSummaryProvider: () => Promise<{ ok: boolean; message: string }>; listSummaryModels: (provider?: SummaryProvider) => Promise<{ models: SummaryModelOption[]; message?: string }>; saveTranscript: (id: string, segments: TranscriptSegment[]) => Promise; diff --git a/apps/desktop/src/utils/sourceContext.test.ts b/apps/desktop/src/utils/sourceContext.test.ts new file mode 100644 index 0000000..1fc0180 --- /dev/null +++ b/apps/desktop/src/utils/sourceContext.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { canAutoGenerateSourceContext, canRetrySourceContext } from "./sourceContext"; +import type { AppSessionStatus, NoteDetail, SourceContextStatus, TranscriptSegment } from "../types"; + +function status(state: AppSessionStatus["state"]): AppSessionStatus { + return { + state, + detail: null, + retryableChunkCount: 0, + failedChunkCount: 0, + runningChunkCount: state === "transcribing" ? 1 : 0, + }; +} + +function sourceContext(state: SourceContextStatus["state"], retryable = false): SourceContextStatus { + return { + state, + detail: null, + retryable, + updatedAt: null, + packetId: null, + objectCount: 0, + relationCount: 0, + warningCount: 0, + agentBriefPath: null, + projectContextPath: null, + contextDirectoryPath: null, + }; +} + +function segment(patch: Partial = {}): TranscriptSegment { + return { + id: "segment-1", + startTimeSeconds: 0, + endTimeSeconds: 2, + text: "Decision: ship the context packet.", + ...patch, + }; +} + +function note(patch: Partial> = {}) { + return { + sessionStatus: status("completed"), + sourceContextStatus: sourceContext("idle"), + transcriptSegments: [segment()], + ...patch, + }; +} + +describe("canAutoGenerateSourceContext", () => { + it("does not start while capture or transcription is still active", () => { + for (const state of ["recording", "degraded", "finalizing", "transcribing"] as AppSessionStatus["state"][]) { + expect(canAutoGenerateSourceContext(note({ sessionStatus: status(state) }))).toBe(false); + } + }); + + it("queues once for a completed note with transcript text", () => { + expect(canAutoGenerateSourceContext(note())).toBe(true); + }); + + it("does not duplicate ready, generating, or failed generation", () => { + for (const state of ["ready", "generating", "failed"] as SourceContextStatus["state"][]) { + expect(canAutoGenerateSourceContext(note({ sourceContextStatus: sourceContext(state) }))).toBe(false); + } + }); + + it("requires a non-preview transcript segment", () => { + expect(canAutoGenerateSourceContext(note({ transcriptSegments: [] }))).toBe(false); + expect(canAutoGenerateSourceContext(note({ transcriptSegments: [segment({ isPreview: true })] }))).toBe(false); + expect(canAutoGenerateSourceContext(note({ transcriptSegments: [segment({ text: " " })] }))).toBe(false); + }); +}); + +describe("canRetrySourceContext", () => { + it("keeps failed source context generation retryable in the UI", () => { + expect(canRetrySourceContext(sourceContext("failed", false))).toBe(true); + expect(canRetrySourceContext(sourceContext("idle", true))).toBe(true); + }); +}); diff --git a/apps/desktop/src/utils/sourceContext.ts b/apps/desktop/src/utils/sourceContext.ts new file mode 100644 index 0000000..49c8b3d --- /dev/null +++ b/apps/desktop/src/utils/sourceContext.ts @@ -0,0 +1,24 @@ +import type { NoteDetail, SourceContextStatus, TranscriptSegment } from "../types"; + +const ACTIVE_SESSION_STATES = new Set(["recording", "degraded", "finalizing", "transcribing"]); + +export function canAutoGenerateSourceContext(note: Pick): boolean { + if (ACTIVE_SESSION_STATES.has(note.sessionStatus?.state || "")) { + return false; + } + if (note.sessionStatus?.state !== "completed") { + return false; + } + if (!hasUsableTranscriptSegment(note.transcriptSegments)) { + return false; + } + return note.sourceContextStatus.state === "idle"; +} + +export function canRetrySourceContext(status: SourceContextStatus): boolean { + return status.state === "failed" || status.retryable; +} + +export function hasUsableTranscriptSegment(segments: TranscriptSegment[]): boolean { + return segments.some((segment) => !segment.isPreview && segment.text.trim().length > 0); +} diff --git a/scripts/smoke_notes_store.cjs b/scripts/smoke_notes_store.cjs index d6eadf5..23337e0 100644 --- a/scripts/smoke_notes_store.cjs +++ b/scripts/smoke_notes_store.cjs @@ -89,6 +89,16 @@ async function main() { assert.equal(renamed.title, "Renamed Smoke"); await notesStore.saveNote(created.id, "# Renamed Smoke\n\nBody from smoke test.\n"); + fs.writeFileSync(created.metadataPath, JSON.stringify({ + listenerSession: { + state: "completed", + startedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + detail: null, + error: null, + }, + }, null, 2)); await notesStore.saveTranscript(created.id, [ { id: "segment-1", @@ -220,6 +230,63 @@ async function main() { notesStore.__testHooks.nativeCaptureRequest = originalNativeCaptureRequest; } + const blockedContextNote = await notesStore.createNote("No Transcript Context"); + fs.writeFileSync(blockedContextNote.metadataPath, JSON.stringify({ + listenerSession: { + state: "completed", + startedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + detail: null, + error: null, + }, + }, null, 2)); + await assert.rejects( + () => notesStore.generateSourceContext(blockedContextNote.id), + /No completed transcript is available/, + ); + const blockedContextDetail = await notesStore.readNote(blockedContextNote.id); + assert.equal(blockedContextDetail.sourceContextStatus.state, "failed"); + assert.equal(blockedContextDetail.sourceContextStatus.retryable, true); + assert.equal(await notesStore.deleteNote(blockedContextNote.id), true); + + const providerMissingNote = await notesStore.createNote("Provider Missing Context"); + fs.writeFileSync(providerMissingNote.metadataPath, JSON.stringify({ + listenerSession: { + state: "completed", + startedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + detail: null, + error: null, + }, + }, null, 2)); + await notesStore.saveTranscript(providerMissingNote.id, [ + { + id: "provider-segment-1", + startTimeSeconds: 0, + endTimeSeconds: 1, + text: "Provider missing should stay retryable.", + source: "microphone", + }, + ]); + try { + notesStore.__testHooks.nativeCaptureRequest = async () => { + throw new Error("Add an OpenAI API key in Settings before generating source context."); + }; + await assert.rejects( + () => notesStore.generateSourceContext(providerMissingNote.id), + /OpenAI API key/, + ); + const providerMissingDetail = await notesStore.readNote(providerMissingNote.id); + assert.equal(providerMissingDetail.sourceContextStatus.state, "failed"); + assert.equal(providerMissingDetail.sourceContextStatus.retryable, true); + assert.match(providerMissingDetail.sourceContextStatus.detail, /OpenAI API key/); + } finally { + notesStore.__testHooks.nativeCaptureRequest = originalNativeCaptureRequest; + } + assert.equal(await notesStore.deleteNote(providerMissingNote.id), true); + notesStore.closeNotesDatabase(); fs.writeFileSync(settings.notesDatabasePath, "not a sqlite database", "utf8");