Skip to content

Commit 3ff7e5d

Browse files
authored
Add post-session context generation
Add automatic post-session source context generation with retryable status tracking, compact UI actions, and review fixes for source context metadata normalization, portable paths, note-list I/O, and save guards.
1 parent 19df4d6 commit 3ff7e5d

11 files changed

Lines changed: 660 additions & 9 deletions

File tree

apps/desktop/main.cjs

Lines changed: 201 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const { fileURLToPath, pathToFileURL } = require("node:url");
1313
const { updateElectronApp, UpdateSourceType } = require("update-electron-app");
1414
const {
1515
bundlePaths,
16+
contextPacketPaths,
1617
readJSONIfExists,
1718
readTextIfExists,
1819
sourceTrackPaths,
@@ -1429,6 +1430,111 @@ function normalizeStoredAppSessionStatus(value) {
14291430
};
14301431
}
14311432

1433+
function normalizeSourceContextStatus(value) {
1434+
const raw = value && typeof value === "object" ? value : {};
1435+
const state = optionalNonEmptyString(raw.state);
1436+
if (!["idle", "generating", "ready", "failed"].includes(state)) {
1437+
return null;
1438+
}
1439+
return {
1440+
state,
1441+
detail: optionalNonEmptyString(raw.detail),
1442+
retryable: Boolean(raw.retryable),
1443+
updatedAt: optionalNonEmptyString(raw.updatedAt),
1444+
packetId: optionalNonEmptyString(raw.packetId),
1445+
objectCount: Number.isFinite(Number(raw.objectCount)) ? Number(raw.objectCount) : 0,
1446+
relationCount: Number.isFinite(Number(raw.relationCount)) ? Number(raw.relationCount) : 0,
1447+
warningCount: Number.isFinite(Number(raw.warningCount)) ? Number(raw.warningCount) : 0,
1448+
agentBriefPath: null,
1449+
projectContextPath: null,
1450+
contextDirectoryPath: null,
1451+
};
1452+
}
1453+
1454+
function withDerivedSourceContextPaths(status, directoryPath, availablePaths = {}) {
1455+
const paths = contextPacketPaths(directoryPath);
1456+
return {
1457+
...status,
1458+
agentBriefPath: availablePaths.agentBrief === false ? null : paths.agentBriefPath,
1459+
projectContextPath: availablePaths.projectContext === false ? null : paths.projectContextPath,
1460+
contextDirectoryPath: paths.contextDirectoryPath,
1461+
};
1462+
}
1463+
1464+
function sourceContextStatusFromMetadata(metadata, directoryPath, options = {}) {
1465+
const verifyFiles = options.verifyFiles !== false;
1466+
const paths = contextPacketPaths(directoryPath);
1467+
const stored = normalizeSourceContextStatus(metadata?.sourceContext);
1468+
if (!verifyFiles) {
1469+
if (stored) {
1470+
return stored.state === "ready" ? withDerivedSourceContextPaths(stored, directoryPath) : stored;
1471+
}
1472+
return {
1473+
state: "idle",
1474+
detail: null,
1475+
retryable: false,
1476+
updatedAt: null,
1477+
packetId: null,
1478+
objectCount: 0,
1479+
relationCount: 0,
1480+
warningCount: 0,
1481+
agentBriefPath: null,
1482+
projectContextPath: null,
1483+
contextDirectoryPath: paths.contextDirectoryPath,
1484+
};
1485+
}
1486+
1487+
const hasAgentBrief = fsSync.existsSync(paths.agentBriefPath);
1488+
const hasProjectContext = fsSync.existsSync(paths.projectContextPath);
1489+
if (hasAgentBrief || hasProjectContext) {
1490+
return withDerivedSourceContextPaths({
1491+
state: "ready",
1492+
detail: null,
1493+
retryable: false,
1494+
updatedAt: optionalNonEmptyString(metadata?.sourceContext?.updatedAt),
1495+
packetId: optionalNonEmptyString(metadata?.sourceContext?.packetId),
1496+
objectCount: Number.isFinite(Number(metadata?.sourceContext?.objectCount)) ? Number(metadata.sourceContext.objectCount) : 0,
1497+
relationCount: Number.isFinite(Number(metadata?.sourceContext?.relationCount)) ? Number(metadata.sourceContext.relationCount) : 0,
1498+
warningCount: Number.isFinite(Number(metadata?.sourceContext?.warningCount)) ? Number(metadata.sourceContext.warningCount) : 0,
1499+
agentBriefPath: null,
1500+
projectContextPath: null,
1501+
contextDirectoryPath: null,
1502+
}, directoryPath, { agentBrief: hasAgentBrief, projectContext: hasProjectContext });
1503+
}
1504+
1505+
if (stored && stored.state !== "ready") {
1506+
return stored;
1507+
}
1508+
1509+
return {
1510+
state: "idle",
1511+
detail: null,
1512+
retryable: false,
1513+
updatedAt: null,
1514+
packetId: null,
1515+
objectCount: 0,
1516+
relationCount: 0,
1517+
warningCount: 0,
1518+
agentBriefPath: null,
1519+
projectContextPath: null,
1520+
contextDirectoryPath: paths.contextDirectoryPath,
1521+
};
1522+
}
1523+
1524+
function noteHasCompletedTranscriptForSourceContext(note) {
1525+
const sessionState = optionalNonEmptyString(note?.sessionStatus?.state);
1526+
if (["recording", "degraded", "finalizing", "transcribing"].includes(sessionState)) {
1527+
return false;
1528+
}
1529+
return sessionState === "completed"
1530+
&& Array.isArray(note?.transcriptSegments)
1531+
&& note.transcriptSegments.some((segment) => !segment.isPreview && typeof segment.text === "string" && segment.text.trim());
1532+
}
1533+
1534+
function errorMessage(error, fallback = "Source context generation failed.") {
1535+
return error instanceof Error && error.message ? error.message : fallback;
1536+
}
1537+
14321538
async function writeListenerSessionState(noteId, patch) {
14331539
if (!noteId) {
14341540
return;
@@ -1898,6 +2004,7 @@ async function loadBundleSnapshot(directoryName) {
18982004
recordingPath: paths.recordingPath,
18992005
listenerSession,
19002006
sessionStatus: normalizeAppSessionStatus({ listenerSession, sttChunks }),
2007+
sourceContextStatus: sourceContextStatusFromMetadata(metadata, directoryPath),
19012008
};
19022009

19032010
return {
@@ -1930,6 +2037,7 @@ function noteSummaryFromRow(row) {
19302037
recordingPath: row.recording_path,
19312038
listenerSession,
19322039
sessionStatus: normalizeStoredAppSessionStatus(row.session_status_json),
2040+
sourceContextStatus: sourceContextStatusFromMetadata(metadata, row.directory_path, { verifyFiles: false }),
19332041
};
19342042
}
19352043

@@ -2540,6 +2648,7 @@ async function readNote(id) {
25402648
return {
25412649
...summary,
25422650
sessionStatus: normalizeAppSessionStatus({ listenerSession: summary.listenerSession, sttChunks }),
2651+
sourceContextStatus: sourceContextStatusFromMetadata(parseMetadataJSON(row.metadata_json), directoryPath),
25432652
directoryPath,
25442653
markdown: row.markdown,
25452654
metadata: parseMetadataJSON(row.metadata_json),
@@ -3057,17 +3166,96 @@ async function generateNoteSummary(noteID, templateID) {
30573166

30583167
async function generateSourceContext(noteID) {
30593168
const note = await readNote(noteID);
3169+
const directoryPath = resolveBundleDirectory(noteID);
3170+
const paths = bundlePaths(directoryPath);
3171+
const currentMetadata = await readJSONIfExists(paths.metadataPath) || {};
3172+
const currentStatus = sourceContextStatusFromMetadata(currentMetadata, directoryPath);
3173+
if (currentStatus.state === "ready") {
3174+
return {
3175+
noteId: note.id,
3176+
sourceContextStatus: currentStatus,
3177+
};
3178+
}
3179+
if (!noteHasCompletedTranscriptForSourceContext(note)) {
3180+
const blockedStatus = {
3181+
state: "failed",
3182+
detail: "No completed transcript is available for source context generation.",
3183+
retryable: true,
3184+
updatedAt: new Date().toISOString(),
3185+
};
3186+
currentMetadata.sourceContext = blockedStatus;
3187+
await writeJSONFileAtomic(paths.metadataPath, currentMetadata);
3188+
await syncNotesDatabaseFromFiles([noteID]);
3189+
throw new Error(blockedStatus.detail);
3190+
}
3191+
3192+
currentMetadata.sourceContext = {
3193+
state: "generating",
3194+
detail: null,
3195+
retryable: false,
3196+
updatedAt: new Date().toISOString(),
3197+
};
3198+
await writeJSONFileAtomic(paths.metadataPath, currentMetadata);
3199+
await syncNotesDatabaseFromFiles([noteID]);
3200+
30603201
const settings = await readElectronSettings();
30613202
const summarySettings = normalizeSummarySettings(settings.summary);
3062-
const payload = await nativeCaptureRequest("generateSourceContext", {
3063-
targetDirectory: note.directoryPath,
3064-
summarySettings,
3065-
});
3203+
try {
3204+
const payload = await nativeCaptureRequest("generateSourceContext", {
3205+
targetDirectory: note.directoryPath,
3206+
summarySettings,
3207+
});
3208+
const sourceContext = payload.sourceContext || payload;
3209+
const nextMetadata = await readJSONIfExists(paths.metadataPath) || {};
3210+
nextMetadata.sourceContext = {
3211+
state: "ready",
3212+
detail: null,
3213+
retryable: false,
3214+
updatedAt: new Date().toISOString(),
3215+
packetId: optionalNonEmptyString(sourceContext.packetId),
3216+
objectCount: Number.isFinite(Number(sourceContext.objectCount)) ? Number(sourceContext.objectCount) : 0,
3217+
relationCount: Number.isFinite(Number(sourceContext.relationCount)) ? Number(sourceContext.relationCount) : 0,
3218+
warningCount: Number.isFinite(Number(sourceContext.warningCount)) ? Number(sourceContext.warningCount) : 0,
3219+
};
3220+
await writeJSONFileAtomic(paths.metadataPath, nextMetadata);
3221+
await syncNotesDatabaseFromFiles([noteID]);
30663222

3067-
return {
3068-
noteId: note.id,
3069-
...(payload.sourceContext || payload),
3070-
};
3223+
return {
3224+
noteId: note.id,
3225+
...sourceContext,
3226+
sourceContextStatus: sourceContextStatusFromMetadata(nextMetadata, directoryPath),
3227+
};
3228+
} catch (error) {
3229+
const message = errorMessage(error);
3230+
const nextMetadata = await readJSONIfExists(paths.metadataPath) || {};
3231+
nextMetadata.sourceContext = {
3232+
state: "failed",
3233+
detail: message,
3234+
retryable: true,
3235+
updatedAt: new Date().toISOString(),
3236+
};
3237+
await writeJSONFileAtomic(paths.metadataPath, nextMetadata);
3238+
await syncNotesDatabaseFromFiles([noteID]);
3239+
throw error;
3240+
}
3241+
}
3242+
3243+
async function openAgentBrief(noteID) {
3244+
const directoryPath = resolveBundleDirectory(noteID);
3245+
const paths = contextPacketPaths(directoryPath);
3246+
const targetPath = fsSync.existsSync(paths.agentBriefPath)
3247+
? paths.agentBriefPath
3248+
: fsSync.existsSync(paths.projectContextPath)
3249+
? paths.projectContextPath
3250+
: null;
3251+
if (!targetPath) {
3252+
throw new Error("No agent context file is available for this note.");
3253+
}
3254+
const result = await shell.openPath(targetPath);
3255+
if (result) {
3256+
throw new Error(result);
3257+
}
3258+
return true;
30713259
}
30723260

30733261
async function testSummaryProvider() {
@@ -4522,6 +4710,8 @@ function registerIPCHandlers() {
45224710
ipcMain.handle("notes:read", (_event, id) => readNote(id));
45234711
ipcMain.handle("notes:save", (_event, id, markdown) => saveNote(id, markdown));
45244712
ipcMain.handle("summary:generate", (_event, id, templateId) => generateNoteSummary(id, templateId));
4713+
ipcMain.handle("source-context:generate", (_event, id) => generateSourceContext(id));
4714+
ipcMain.handle("source-context:open-agent-brief", (_event, id) => openAgentBrief(id));
45254715
ipcMain.handle("summary:test-provider", () => testSummaryProvider());
45264716
ipcMain.handle("summary:list-models", (_event, provider) => listSummaryModels(provider));
45274717
ipcMain.handle("transcript:save", (_event, id, segments) => saveTranscript(id, segments));
@@ -4630,7 +4820,9 @@ if (process.env.MIRROR_NOTE_TEST_EXPORTS === "1") {
46304820
normalizeCustomSummaryTemplates,
46314821
normalizeAppSessionStatus,
46324822
normalizeListenerSessionState,
4823+
normalizeSourceContextStatus,
46334824
normalizeSummarySettings,
4825+
noteHasCompletedTranscriptForSourceContext,
46344826
nativeHelperWorkingDirectory,
46354827
normalizeTranscriptSegments,
46364828
parseSTTChunkLedgerJSONL,
@@ -4642,6 +4834,7 @@ if (process.env.MIRROR_NOTE_TEST_EXPORTS === "1") {
46424834
renderSummaryResultMarkdown,
46434835
requestChatCompletion,
46444836
retryTranscript,
4837+
sourceContextStatusFromMetadata,
46454838
uploadRecordingAsset,
46464839
uploadTranscriptAsset,
46474840
uploadMarkdownNote,

apps/desktop/main/native-launch.node-test.cjs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,26 @@
11
const assert = require("node:assert/strict");
2+
const fs = require("node:fs");
3+
const os = require("node:os");
4+
const path = require("node:path");
25

36
process.env.MIRROR_NOTE_TEST_EXPORTS = "1";
47

8+
const { contextPacketPaths } = require("./artifact-files.cjs");
9+
510
const {
611
latestFailedSTTChunks,
712
latestSTTChunks,
813
latestRetryableSTTChunks,
914
nativeHelperWorkingDirectory,
1015
normalizeAppSessionStatus,
1116
normalizeListenerSessionState,
17+
normalizeSourceContextStatus,
1218
normalizeTranscriptSegments,
19+
noteHasCompletedTranscriptForSourceContext,
1320
parseSTTChunkLedgerJSONL,
1421
parseTranscriptJSONL,
1522
serializeTranscriptJSONL,
23+
sourceContextStatusFromMetadata,
1624
} = require("../main.cjs");
1725

1826
assert.equal(nativeHelperWorkingDirectory(), process.cwd());
@@ -134,6 +142,41 @@ assert.deepEqual(latestSTTChunks(ledgerRecords).map((record) => `${record.chunkI
134142
assert.equal(latestSTTChunks(ledgerRecords)[1].retryState, "completed");
135143
assert.equal(latestSTTChunks(ledgerRecords)[1].recordedAt, "2026-05-04T00:00:00.000Z");
136144

145+
const sourceContextTranscriptNote = {
146+
sessionStatus: { state: "completed" },
147+
transcriptSegments: [{ id: "seg-1", startTimeSeconds: 0, endTimeSeconds: 1, text: "Decision", isPreview: false }],
148+
};
149+
assert.equal(noteHasCompletedTranscriptForSourceContext(sourceContextTranscriptNote), true);
150+
for (const state of ["recording", "degraded", "finalizing", "transcribing"]) {
151+
assert.equal(noteHasCompletedTranscriptForSourceContext({
152+
...sourceContextTranscriptNote,
153+
sessionStatus: { state },
154+
}), false);
155+
}
156+
assert.equal(noteHasCompletedTranscriptForSourceContext({
157+
...sourceContextTranscriptNote,
158+
transcriptSegments: [{ id: "seg-1", startTimeSeconds: 0, endTimeSeconds: 1, text: "Decision", isPreview: true }],
159+
}), false);
160+
assert.equal(normalizeSourceContextStatus({ state: "failed", detail: "Add key.", retryable: true }).retryable, true);
161+
162+
const contextTempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "mirrornote-source-context-"));
163+
const generatedContextPaths = contextPacketPaths(contextTempDirectory);
164+
fs.mkdirSync(generatedContextPaths.contextDirectoryPath, { recursive: true });
165+
fs.writeFileSync(generatedContextPaths.agentBriefPath, "# Agent Brief\n", "utf8");
166+
assert.deepEqual(sourceContextStatusFromMetadata({}, contextTempDirectory), {
167+
state: "ready",
168+
detail: null,
169+
retryable: false,
170+
updatedAt: null,
171+
packetId: null,
172+
objectCount: 0,
173+
relationCount: 0,
174+
warningCount: 0,
175+
agentBriefPath: generatedContextPaths.agentBriefPath,
176+
projectContextPath: null,
177+
contextDirectoryPath: generatedContextPaths.contextDirectoryPath,
178+
});
179+
137180
const interruptedLedgerRecords = parseSTTChunkLedgerJSONL([
138181
JSON.stringify({
139182
sessionID: "session-a",

apps/desktop/preload.cjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ contextBridge.exposeInMainWorld("mirrorNote", {
55
readNote: (id) => ipcRenderer.invoke("notes:read", id),
66
saveNote: (id, markdown) => ipcRenderer.invoke("notes:save", id, markdown),
77
generateSummary: (id, templateId) => ipcRenderer.invoke("summary:generate", id, templateId),
8+
generateSourceContext: (id) => ipcRenderer.invoke("source-context:generate", id),
9+
openAgentBrief: (id) => ipcRenderer.invoke("source-context:open-agent-brief", id),
810
testSummaryProvider: () => ipcRenderer.invoke("summary:test-provider"),
911
listSummaryModels: (provider) => ipcRenderer.invoke("summary:list-models", provider),
1012
saveTranscript: (id, segments) => ipcRenderer.invoke("transcript:save", id, segments),

0 commit comments

Comments
 (0)