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
87 changes: 87 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,93 @@ Changes you will notice:

### Fixed

- **A Discover run with a long recording is no longer cut off or started over.** The batch request
ended after a fixed 30 minutes, and five minutes without a finished file was taken for a stuck
analysis. Either way the rest of the run went one file at a time, where a recording gets five
minutes and is then transcribed again from the start, and where Stop does not reach the work in
progress. A run with audio or video now always goes through the batch, the batch keeps reporting
while one long file is transcribed, and the run is bounded by its own checks (no progress, and at
most six hours).
- **Stop leaves nothing behind.** After a cancel, a file that was in flight could still report
progress, which put back counts that read as a paused analysis; a document that was mid-analysis
was listed as failed; and an image whose vision request was cancelled was stored with a
filename-only "Limited analysis" result. A cancelled batch now reports nothing more, and a
cancelled file is neither failed nor recorded.
- **Leaving Discover during an analysis no longer says "Analysis cancelled by user".** The notice
now appears only when you stop the run. A run left behind this way also no longer writes over the
progress of the run a returning Discover started.
- **The Dashboard waits for a running analysis.** Switching to the Dashboard closed Discover, which
stopped the run without saying so. The Dashboard toggle and **Back to dashboard** now explain that
the dashboard opens when the analysis finishes; the way back to Guided stays open.
- **Progress from other work no longer shows as analysis progress.** Organize batches, model
downloads and **Reanalyze all** report on the same channel as a Discover batch, and their counts
were written into the analysis state: "Analysis continuing in background: 3/7 files" could be an
organize batch, and a finished reanalysis could leave a paused analysis behind. Only a Discover
analysis updates that state now, and another sender can no longer take over the running batch
(after which its progress was dropped and Stop did nothing).
- **A file that finishes right after another appears without waiting for the next one.** The second
result was held back until another file finished, which behind a long recording could be many
minutes.
- **The completion notice counts every file of a run that waited for an earlier batch,** not only
the files the run analyzed itself.
- **Dates follow your calendar day, not the UTC day.** A file saved in the evening west of Greenwich
was named and dated for the next day, and a date written in a document or file name moved back a
day east of it. Suggested names, the file date given to the model, dates the model reads from the
content and the settings export file name now use the local day. A date-range search of the
analysis history also no longer skips the first or last month of the range.
- **A recording longer than the transcription limit is recorded once instead of failing every
time.** Each attempt decoded the audio up to the limit before failing, and the smart-folder
watcher retried it. The file is now kept as "not transcribed", with the limit in its description.
- **Two models are no longer loaded onto a GPU that has room for one.** A model counted against the
memory budget only once it had finished loading, and the vision runtime only once it had started
(about a minute on a laptop GPU), so a text model and the vision runtime started moments apart
both fit on paper. Memory now counts from the moment a load begins, a second caller for the same
model shares the load, and background work does not start on memory that is spoken for.
- **An image being analyzed keeps the vision runtime.** The runtime could be shut down under an
image in three ways: by its idle timer while another image was still running, by a text model
taking its memory between its start and the request, and by a start that failed without reporting
it (the app then polled a dead port until the startup timeout). Each left the image with an
OCR-only result or the batch waiting. A request that timed out also kept the runtime pinned in
memory for the rest of the session, and a runtime that crashed part way through an answer left its
image waiting for good.
- **Cancelling one analysis no longer cancels another that was waiting for the same transcription.**
Two analyses of one recording share a transcription; cancelling the first failed the second as
cancelled, and cancelling the second did nothing until the transcription ended. Each now follows
its own cancel.
- **The smart-folder watcher checks again before it analyzes.** A file queued while a Discover run
held the queue was analyzed a second time although the run had just analyzed it, and a file saved
while it was being analyzed was recorded as up to date with the old content. The watcher now asks
the history when a file's turn comes and records the file as it was when the analysis started. An
image batch (up to 20 images) also pauses when a Discover run starts, instead of finishing beside
it, and a reanalysis you asked for is no longer turned into a plain update by a change to the same
file.
- **A batch replaced as stale is stopped,** instead of running beside its replacement, and when it
ends late it no longer makes the replacement impossible to cancel.
- **Quieter logs.** A model load refused because another model is in use is no longer logged as an
unclassified failure with a stack trace, waiting for a model that is in use warns once instead of
every two seconds, and the batch concurrency in the memory-pressure warning is the one that
applies.
- **A file's status is no longer put back by a later change.** Each change to the list of file
statuses was worked out from the list as the page last drew it. A second change in the same
moment, or one made after you had left the page (a Discover run ends when its last file is let go,
which can be minutes later), put that older list back over everything changed since. Every change
in Discover and Organize now starts from the current list.
- **"Analysis paused" no longer appears after Stop.** Progress reaches the screen at most every 80
ms, and an update that arrived inside that window was written when it ended, also when the
analysis had been stopped or reset in between. Counts without a running analysis read as a paused
one. The held update is now dropped when its analysis ends.
- **The progress of a run that waited for an earlier batch counts the whole run.** Such a run sends
only the files the earlier batch did not finish, and the count shown restarted with them: "1 of
10" on a run of 40 with 30 done. It now continues from what was done, "31 of 40".
- **A second Analyze right after the first no longer replaces it.** A run records its first progress
a moment after it starts, and until then it looked like a run that had stopped responding: a
second request in that moment reset it and took its place. The second request now waits its turn
behind the first.
- **Development builds: a resumed analysis keeps its activity signal.** Under React StrictMode the
run that was aborted while starting carried on as if it had started, reported the analysis as
started a second time, and switched off the 30-second activity signal of the run that replaced it
(and of every run after that), so a long first file could be taken for a stuck analysis. A run
replaced while starting now stops there, and a run only ever stops its own signal.
- **Analyzed files show up again after Discover remounts mid-start.** A run that Discover's unmount
(or React StrictMode in development) aborted during its first moments still sent its batch, then
ignored every result: the list stayed on "0 ready" with loading placeholders, and five minutes
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ tree.
`ci`) enforces a monotonic floor and blocks new .js under `COVERED_DIRS` (`src/shared`,
`src/preload`, `src/main/{ipc,services,analysis,errors,utils,workers,transports,core}`,
`src/renderer/{services,hooks,phases,store,utils,components}` today (every `.js` under `src/` is
now opted in — 489/489 as of 2026-11-09, and `GLOBAL_DIRECTIVE_FLOOR` matches at 489) — see
now opted in — 493/493 as of 2026-11-09, and `GLOBAL_DIRECTIVE_FLOOR` matches at 493) — see
`scripts/verify-checkjs-ratchet.js` for the authoritative list) from landing without the
directive. When adding a file to a covered dir: put `// @ts-check` on line 1 and fix the errors
before commit. When opting in a new file elsewhere: bump `GLOBAL_DIRECTIVE_FLOOR` in
Expand Down
2 changes: 1 addition & 1 deletion scripts/verify-checkjs-ratchet.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const COVERED_DIRS = [
'src/renderer/utils',
'src/renderer/components'
];
const GLOBAL_DIRECTIVE_FLOOR = 491;
const GLOBAL_DIRECTIVE_FLOOR = 493;
// ────────────────────────────────────────────────────────────────────────

const DIRECTIVE_SCAN_LINES = 5;
Expand Down
76 changes: 76 additions & 0 deletions src/main/analysis/analysisCancellation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// @ts-check
/**
* Ending an analysis the caller cancelled.
*
* The analyzers turn almost every failure into a filename fallback, so a file
* always gets some result. A cancellation is not a failure: the caller asked
* for the work to stop and will not use a result. Returned as a fallback, it
* was recorded in the analysis history and the ready queue as if the file had
* been analyzed.
*
* @module analysis/analysisCancellation
*/

const { isFallbackAnalysis } = require('../../shared/analysisProvenance');

/**
* @param {string} [message]
* @returns {Error & { code: string }}
*/
function createCancellationError(message = 'Analysis cancelled') {
return Object.assign(new Error(message), { code: 'OPERATION_CANCELLED' });
}

/**
* Whether the caller of an analyzer has cancelled: its abort signal fired, or
* its isCancelled() poll says so.
* @param {{ abortSignal?: AbortSignal | null, signal?: AbortSignal | null, isCancelled?: (() => boolean) | null } | null | undefined} options
* @returns {boolean}
*/
function callerCancelled(options) {
if (options?.abortSignal?.aborted || options?.signal?.aborted) return true;
if (typeof options?.isCancelled !== 'function') return false;
try {
return options.isCancelled() === true;
} catch {
return false;
}
}

/**
* The error to throw for a failure caught while the caller has cancelled:
* the failure itself when it already is a cancellation, otherwise a
* cancellation that names it.
* @param {any} error
* @returns {Error & { code?: string }}
*/
function asCancellationError(error) {
if (error?.code === 'OPERATION_CANCELLED') return error;
const cancellation = createCancellationError();
/** @type {any} */ (cancellation).cause = error;
return cancellation;
}

/**
* A fallback produced after the caller cancelled is the cancellation's doing
* (the engine reported the abort as an ordinary failure and the analyzer fell
* back): throw instead of returning it. A real analysis that finished anyway
* is kept, the work is done.
* @template T
* @param {T} result
* @param {Parameters<typeof callerCancelled>[0]} options
* @returns {T}
*/
function rejectFallbackOfCancelledRun(result, options) {
if (callerCancelled(options) && isFallbackAnalysis(/** @type {any} */ (result))) {
throw createCancellationError();
}
return result;
}

module.exports = {
createCancellationError,
callerCancelled,
asCancellationError,
rejectFallbackOfCancelledRun
};
14 changes: 9 additions & 5 deletions src/main/analysis/documentAnalysis.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const { TRUNCATION, TIMEOUTS } = require('../../shared/performanceConstants');
const { withTimeout, delay } = require('../../shared/promiseUtils');
const { createLogger } = require('../../shared/logger');
const { isMemoryBudgetExceeded } = require('../../shared/analysisProvenance');
const { toLocalCalendarDay } = require('../../shared/contentDateSanity');
const { getTextModel, getLlamaService } = require('../llamaUtils');
const { AppConfig } = require('./documentLlm');

Expand Down Expand Up @@ -56,6 +57,7 @@ const {
safeSuggestedName,
createFallbackAnalysis
} = require('./fallbackUtils');
const { createCancellationError } = require('./analysisCancellation');
const { analysisQueue } = require('./embeddingQueue/stageQueues');
const { globalDeduplicator } = require('../utils/llmOptimization');
const {
Expand Down Expand Up @@ -368,19 +370,21 @@ async function analyzeDocumentFile(filePath, smartFolders = [], options = {}) {
// Get file date from stats or default to today
// Some test fixtures (and some fs polyfills) may not provide a Date-valued mtime.
const fileDate = (() => {
const today = new Date().toISOString().split('T')[0];
// The day the user sees in the file manager, not the UTC day: a file
// saved in the evening west of Greenwich is not from tomorrow.
const today = toLocalCalendarDay(Date.now());
if (!fileStats) return today;

// Prefer Date-valued mtime when available
const { mtime } = fileStats;
if (mtime && typeof mtime.toISOString === 'function') {
return mtime.toISOString().split('T')[0];
if (mtime && typeof mtime.getTime === 'function' && Number.isFinite(mtime.getTime())) {
return toLocalCalendarDay(mtime.getTime());
}

// Fall back to numeric mtimeMs when present
const { mtimeMs } = fileStats;
if (typeof mtimeMs === 'number' && Number.isFinite(mtimeMs) && mtimeMs > 0) {
return new Date(mtimeMs).toISOString().split('T')[0];
return toLocalCalendarDay(mtimeMs);
}

return today;
Expand Down Expand Up @@ -908,7 +912,7 @@ async function analyzeDocumentFile(filePath, smartFolders = [], options = {}) {

// Check for cancellation before expensive LLM call
if (typeof options.isCancelled === 'function' && options.isCancelled()) {
throw new Error('Analysis cancelled');
throw createCancellationError();
}

// Backend Caching & Deduplication:
Expand Down
47 changes: 37 additions & 10 deletions src/main/analysis/imageAnalysis.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ const crypto = require('crypto');
const { getInstance: getLlamaService } = require('../services/LlamaService');
const { globalDeduplicator } = require('../utils/llmOptimization');
const { extractAndParseJSON } = require('../utils/jsonRepair');
const { isPlausibleContentDate } = require('../../shared/contentDateSanity');
const {
isPlausibleContentDate,
toLocalCalendarDay,
toWrittenCalendarDay
} = require('../../shared/contentDateSanity');
const {
attemptJsonRepairWithLlama,
attemptProseExtractionWithLlama
Expand All @@ -33,6 +37,12 @@ const {
safeSuggestedName,
createFallbackAnalysis
} = require('./fallbackUtils');
const {
createCancellationError,
callerCancelled,
asCancellationError,
rejectFallbackOfCancelledRun
} = require('./analysisCancellation');
const FolderMatchingService = require('../services/FolderMatchingService');
const { analysisQueue } = require('./embeddingQueue/stageQueues');
const embeddingQueueManager = require('./embeddingQueue/queueManager');
Expand Down Expand Up @@ -658,12 +668,12 @@ STRICT OUTPUT REQUIREMENT:

// Validate and structure the date
if (parsedJson.date) {
const dateObj = new Date(parsedJson.date);
if (isNaN(dateObj.getTime())) {
const writtenDay = toWrittenCalendarDay(parsedJson.date);
if (!writtenDay) {
delete parsedJson.date;
logger.warn('AI engine returned an invalid date for image, omitting.');
} else {
parsedJson.date = dateObj.toISOString().split('T')[0];
parsedJson.date = writtenDay;
// Same plausibility gate as documentLlm: a hallucinated year
// would poison the timeline sort and stats-card year range.
if (!isPlausibleContentDate(parsedJson.date)) {
Expand Down Expand Up @@ -1275,11 +1285,24 @@ async function preprocessImageBuffer(imageBuffer, fileExtension, options = {}) {
// ============================================================================

/**
* Analyze an image. Rejects with OPERATION_CANCELLED when the caller
* cancelled (abort signal or isCancelled) and no analysis was completed;
* every other failure resolves to a filename fallback.
* @param {any} filePath
* @param {any[]} [smartFolders]
* @param {{ bypassCache?: boolean, isCancelled?: () => boolean, abortSignal?: AbortSignal, signal?: AbortSignal, smartFolderId?: string | null, visionModel?: string }} [options]
*/
async function analyzeImageFile(filePath, smartFolders = [], options = {}) {
const result = await runImageAnalysis(filePath, smartFolders, options);
return rejectFallbackOfCancelledRun(result, options);
}

/**
* @param {any} filePath
* @param {any[]} smartFolders
* @param {{ bypassCache?: boolean, isCancelled?: () => boolean, abortSignal?: AbortSignal, signal?: AbortSignal, smartFolderId?: string | null, visionModel?: string }} options
*/
async function runImageAnalysis(filePath, smartFolders, options) {
logger.info(`Analyzing image file`, { path: filePath });
const bypassCache = Boolean(options?.bypassCache);
const fileExtension = path.extname(filePath).toLowerCase();
Expand Down Expand Up @@ -1428,7 +1451,7 @@ async function analyzeImageFile(filePath, smartFolders = [], options = {}) {
// "By filename and date" means the date too: the file's modification
// time is in hand from the stat above, and without it the Timeline
// filed a whole photo library under the day the batch ran.
const fileDay = new Date(stats.mtimeMs || Date.now()).toISOString().split('T')[0];
const fileDay = toLocalCalendarDay(stats.mtimeMs || Date.now());
const undecodable = createVisionUnavailableFallback(
{
fileName,
Expand Down Expand Up @@ -1590,9 +1613,7 @@ async function analyzeImageFile(filePath, smartFolders = [], options = {}) {
logger.debug('[IMAGE] Failed to get naming context', { error: ncError.message });
}

const fileDateForText = exifDate
? exifDate
: new Date(stats.mtimeMs || Date.now()).toISOString().split('T')[0];
const fileDateForText = exifDate ? exifDate : toLocalCalendarDay(stats.mtimeMs || Date.now());
const attemptTextFallback = async () => {
let ocrText = extractedText;
if (!ocrText || ocrText.length < 20) {
Expand Down Expand Up @@ -1650,8 +1671,8 @@ async function analyzeImageFile(filePath, smartFolders = [], options = {}) {
};

// Check for cancellation before expensive vision/LLM call
if (typeof options.isCancelled === 'function' && options.isCancelled()) {
throw new Error('Analysis cancelled');
if (callerCancelled(options)) {
throw createCancellationError();
}

/** @type {AnalysisResult | undefined} */
Expand Down Expand Up @@ -1819,6 +1840,7 @@ async function analyzeImageFile(filePath, smartFolders = [], options = {}) {
analysis = retriedAnalysis;
}
} catch (retryError) {
if (retryError?.code === 'OPERATION_CANCELLED') throw retryError;
logger.warn('[IMAGE] Vision retry failed', {
filePath,
error: retryError?.message || String(retryError)
Expand Down Expand Up @@ -2287,6 +2309,11 @@ async function analyzeImageFile(filePath, smartFolders = [], options = {}) {
) {
throw error;
}
// A cancellation is not a failed analysis: the caller stops, and no
// filename fallback is recorded for a file nobody analyzed.
if (error?.code === 'OPERATION_CANCELLED' || callerCancelled(options)) {
throw asCancellationError(error);
}
logger.error('Error processing image', { filePath, error: error.message });
const fallback = createFallbackAnalysis({
fileName,
Expand Down
Loading
Loading