Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 201 additions & 8 deletions apps/desktop/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const { fileURLToPath, pathToFileURL } = require("node:url");
const { updateElectronApp, UpdateSourceType } = require("update-electron-app");
const {
bundlePaths,
contextPacketPaths,
readJSONIfExists,
readTextIfExists,
sourceTrackPaths,
Expand Down Expand Up @@ -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,
};
}
Comment thread
qyinm marked this conversation as resolved.

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) {
Comment thread
qyinm marked this conversation as resolved.
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;
Expand Down Expand Up @@ -1898,6 +2004,7 @@ async function loadBundleSnapshot(directoryName) {
recordingPath: paths.recordingPath,
listenerSession,
sessionStatus: normalizeAppSessionStatus({ listenerSession, sttChunks }),
sourceContextStatus: sourceContextStatusFromMetadata(metadata, directoryPath),
};

return {
Expand Down Expand Up @@ -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 }),
};
}

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -4630,7 +4820,9 @@ if (process.env.MIRROR_NOTE_TEST_EXPORTS === "1") {
normalizeCustomSummaryTemplates,
normalizeAppSessionStatus,
normalizeListenerSessionState,
normalizeSourceContextStatus,
normalizeSummarySettings,
noteHasCompletedTranscriptForSourceContext,
nativeHelperWorkingDirectory,
normalizeTranscriptSegments,
parseSTTChunkLedgerJSONL,
Expand All @@ -4642,6 +4834,7 @@ if (process.env.MIRROR_NOTE_TEST_EXPORTS === "1") {
renderSummaryResultMarkdown,
requestChatCompletion,
retryTranscript,
sourceContextStatusFromMetadata,
uploadRecordingAsset,
uploadTranscriptAsset,
uploadMarkdownNote,
Expand Down
43 changes: 43 additions & 0 deletions apps/desktop/main/native-launch.node-test.cjs
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
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,
latestRetryableSTTChunks,
nativeHelperWorkingDirectory,
normalizeAppSessionStatus,
normalizeListenerSessionState,
normalizeSourceContextStatus,
normalizeTranscriptSegments,
noteHasCompletedTranscriptForSourceContext,
parseSTTChunkLedgerJSONL,
parseTranscriptJSONL,
serializeTranscriptJSONL,
sourceContextStatusFromMetadata,
} = require("../main.cjs");

assert.equal(nativeHelperWorkingDirectory(), process.cwd());
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading