Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
61866ec
fix: the watcher drops its own read echo, skips empty vision batches;…
iLevyTate Nov 10, 2026
401f4c3
fix: content dates reject odd years, keep a day written with Z; searc…
iLevyTate Nov 10, 2026
0610c05
fix(watcher): an embedding retry keeps a queued request; recordings g…
iLevyTate Nov 10, 2026
fdddbdb
fix(analysis): a batch can be stopped while its paths are checked, en…
iLevyTate Nov 10, 2026
461c206
fix: vision and media results that stood in for a model that did not …
iLevyTate Nov 10, 2026
f3eac43
fix(discover): a run that stops making progress says why it ended
iLevyTate Nov 10, 2026
dea1f6e
fix(memory): a text model that is still loading counts as holding the…
iLevyTate Nov 10, 2026
ed454e6
fix(organize): a boosted match no longer shows as 1% confidence
iLevyTate Nov 10, 2026
6580ea6
fix(memory): an oversized text load stops waiting once nothing else h…
iLevyTate Nov 10, 2026
827cebf
fix: review follow-ups (invoke timeouts, numeric dates, Whisper besid…
iLevyTate Nov 10, 2026
6bb723f
fix(analysis): a batch that ran to its end sends its closing progress…
iLevyTate Nov 10, 2026
db9899c
fix(watcher): a transcript the text model gave no answer for is retried
iLevyTate Nov 10, 2026
a475f33
fix(watcher): an embedding retry neither loses its file nor embeds st…
iLevyTate Nov 10, 2026
6cc451a
fix(vision): an image that could not be decoded is not sent to the vi…
iLevyTate Nov 10, 2026
d6c252b
fix(vision): an image in a batch waits for a text request to let go o…
iLevyTate Nov 10, 2026
f07432b
fix(embedding): an embedding load in a vision batch no longer evicts …
iLevyTate Nov 10, 2026
cf3ef00
test: the day a result and a cluster show is checked in every time zone
iLevyTate Nov 10, 2026
6040398
docs(changelog): record the regression-hunt fixes
iLevyTate Nov 10, 2026
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
53 changes: 53 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,59 @@ Changes you will notice:
and an older result can no longer overwrite a newer one.
- Test runs that failed with every test passing: a log-file error arriving after logging had shut
down was written to a console that was already gone. Such late errors are no longer printed.
- **Organize no longer shows "1% confidence" on its strongest matches.** A match boosted by its
cluster got a score above 100%, which the card read as a percentage and divided by 100 again. The
card and the details panel now show the same value, capped at 100%.
- **Target-folder descriptions in Organize keep the letter s at both ends.** "sales reports" was
shown as "ales report".
- **A file the watcher is already analyzing is not queued again by the watcher's own read of it.**
On Windows, reading a file updates its last-access time, which was reported as a change.
- **An image batch whose files are all analyzed already no longer swaps models** to analyze nothing.
- **Content dates:** a written year of 0000–0099 is no longer read as 1900–1999, a year of five or
six digits is rejected, a date written with `Z` keeps its day west of Greenwich, and a number from
the model is a year or nothing. Search results and cluster time ranges show a document's own day,
not the day before.
- **Smart-folder watcher, embedding retries.** A retry that only embeds a finished result no longer
replaces a reanalysis or a change waiting for the same file, no longer leaves the file without an
embedding when the waiting item is skipped, does not embed a result for a file that has changed
since, and no longer hides a real edit made while it runs.
- **Smart-folder watcher:** a recording gets the three hours a Discover run allows instead of ten
minutes; a file deleted or renamed during its analysis gets no history entry or notification; and
"Reanalyze all" can no longer start the same file beside itself.
- **A transcript the text model gave no answer for is tried again** by the watcher, and is recorded
as provisional instead of as a finished transcription with confidence 0. The same holds when the
model is not installed or its answer cannot be read.
- **Stop works while a batch is still checking its files,** when it was answered "unknown batch"
before. A batch also ends when the window that asked for it is closed or reloaded, and a batch
that has ended or was cancelled sends no late progress.
- **A batch that ran to its end sends its last progress report,** so the search and embedding counts
that refresh on it are current.
- **The history keeps the size and date a file had when its analysis started,** not those of a
version saved while the model worked.
- **A Discover run that stops making progress says so.** A silent five-minute check ended the run
before the ten-minute check that would have shown a notice. There is one check now, and it shows
the notice without stating a duration it cannot know. On the one-file-at-a-time path a slow file
fails alone instead of ending the run.
- **Stop no longer turns vision off.** Five cancelled vision requests opened the breaker and every
image got OCR only for five minutes. A request the user stopped is no longer counted; one that
timed out still is.
- **An OCR result that stands in for the vision model is never cached,** whatever made the vision
model unavailable, so the next analysis asks the vision model again.
- **An image that cannot be decoded is not sent to the vision model.** An SVG, a TIFF or a damaged
WebP whose conversion failed went to the model as if it were a PNG, was rejected and retried, and
each rejection counted toward turning vision off for the images after it. Such a file is now
analyzed from its text if it has any, else from its name, and the reason shown names the file.
Text from the vision runtime's error log no longer appears in error messages.
- **In an image batch, an image waits for a document that is still using the text model** (up to 45
s) instead of losing its vision analysis, and an image that arrives while the text model is still
loading no longer starts the vision runtime on top of it.
- **Embedding models and a small GPU.** An embedding load that starts while the text model is still
loading runs on the CPU instead of waiting and then unloading the text model. Inside an image
batch, an embedding load no longer unloads the text model that took the vision runtime's place.
- **A text model larger than the memory budget loads without waiting out the full timeout** once
nothing else is left to unload.
- **Whisper runs on the CPU while the text model is still being loaded** onto a GPU that cannot hold
both.

## [5.1.2] - 2026-11-01

Expand Down
4 changes: 2 additions & 2 deletions docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,8 +274,8 @@ organized into categories:
| `AI_ANALYSIS_MEDIUM` | 60,000 | Medium AI analysis timeout |
| `AI_ANALYSIS_LONG` | 300,000 | Long AI analysis timeout (5 min; CPU vision) |
| `AI_ANALYSIS_BATCH` | 1,800,000 | Batch analysis timeout (30 min) |
| `ANALYSIS_LOCK` | 300,000 | Max lock duration before force release |
| `GLOBAL_ANALYSIS` | 600,000 | Max total analysis time (10 min) |
| `ANALYSIS_LOCK` | 300,000 | A Discover run with no progress for this long is stopped and its lock released (5 min) |
| `GLOBAL_ANALYSIS` | 600,000 | Watcher limit for one document or image (10 min); base of the Discover run cap (6x, at least 60 min) |
| `WINDOW_SHOW_FALLBACK` | 4,000 | Show the main window if `ready-to-show` has not fired |
| `FIRST_PAINT_GATE` | 5,100 | Longest wait for the first paint before AI bring-up starts; outlasts `WINDOW_SHOW_FALLBACK` plus the ~100 ms show delay |
| `COORDINATED_STARTUP_SOFT` | 45,000 | After this, coordinated startup is reported as degraded ("still starting" warning) but keeps running |
Expand Down
68 changes: 56 additions & 12 deletions src/main/analysis/imageAnalysis.js
Original file line number Diff line number Diff line change
Expand Up @@ -1280,6 +1280,22 @@ async function preprocessImageBuffer(imageBuffer, fileExtension, options = {}) {
}
}

/**
* Whether these are bytes the vision runtime and Tesseract decode without
* our preprocessing: PNG, JPEG, GIF or BMP by their signature.
* @param {Buffer | null | undefined} buffer
* @returns {boolean}
*/
function decodesWithoutPreprocessing(buffer) {
if (!buffer || buffer.length < 4) return false;
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
return true;
}
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return true;
const head = buffer.toString('latin1', 0, 4);
return head === 'GIF8' || head.startsWith('BM');
}

// ============================================================================
// Main Analysis Function
// ============================================================================
Expand Down Expand Up @@ -1474,6 +1490,8 @@ async function runImageAnalysis(filePath, smartFolders, options) {
// Handle TOCTOU race condition - file could be deleted between stat() and readFile()
let imageBuffer;
let imageBufferPreprocessed = false;
/** @type {string | null} why the image cannot go to the vision model */
let undecodableReason = null;
try {
imageBuffer = predecodedBuffer || (await fs.readFile(filePath));
} catch (readError) {
Expand Down Expand Up @@ -1515,10 +1533,21 @@ async function runImageAnalysis(filePath, smartFolders, options) {
imageBuffer = await preprocessImageBuffer(imageBuffer, effectiveExtension);
imageBufferPreprocessed = true;
} catch (preErr) {
logger.warn('[IMAGE] Preprocessing failed, continuing with original buffer', {
path: filePath,
error: preErr.message
});
if (decodesWithoutPreprocessing(imageBuffer)) {
logger.warn('[IMAGE] Preprocessing failed, continuing with original buffer', {
path: filePath,
error: preErr.message
});
} else {
// SVG markup, a TIFF, a damaged WebP: the runtime was sent these
// labelled image/png and rejected each one, and every rejection
// counted toward shutting vision off for the images after it.
undecodableReason = String(preErr?.message || 'unknown error').slice(0, 120);
logger.warn('[IMAGE] Preprocessing failed; not sending these bytes to the vision model', {
path: filePath,
error: preErr.message
});
}
}

// Validate buffer is not empty
Expand Down Expand Up @@ -1667,6 +1696,10 @@ async function runImageAnalysis(filePath, smartFolders, options) {
extractedText = result._ocrText;
delete result._ocrText;
}
// Usable, but only what OCR text could tell, and every caller is here
// because the vision model did not answer: never serve it from the
// cache, so the next analysis of this image tries the vision model again.
if (result) result._skipCache = true;
return result;
};

Expand All @@ -1685,10 +1718,13 @@ async function runImageAnalysis(filePath, smartFolders, options) {
visionSupported = false;
}

if (!visionSupported) {
logger.warn('[IMAGE] Vision input unsupported, falling back to OCR/text', {
filePath
});
if (undecodableReason || !visionSupported) {
logger.warn(
undecodableReason
? '[IMAGE] Image could not be decoded, falling back to its text'
: '[IMAGE] Vision input unsupported, falling back to OCR/text',
{ filePath }
);
const fallbackAnalysis = await tryTextFallback();
if (fallbackAnalysis) {
analysis = fallbackAnalysis;
Expand All @@ -1697,7 +1733,11 @@ async function runImageAnalysis(filePath, smartFolders, options) {
{
fileName,
fileExtension,
reason: `Vision model '${visionModelName}' not loaded`,
// The file's own fault is said as such: "vision analysis
// unavailable" sent people looking for a model problem.
reason: undecodableReason
? `Image could not be decoded (${undecodableReason})`
: `Vision model '${visionModelName}' not loaded`,
smartFolders,
confidence: 55,
type: 'image'
Expand Down Expand Up @@ -1791,9 +1831,6 @@ async function runImageAnalysis(filePath, smartFolders, options) {
const fallbackAnalysis = await tryTextFallback();
if (fallbackAnalysis) {
fallbackAnalysis.analysisWarning = analysis.error;
// Usable, but only what OCR text could tell: never serve it from the
// cache, so the next analysis of this image tries the vision model again.
fallbackAnalysis._skipCache = true;
analysis = fallbackAnalysis;
} else {
analysis = createVisionUnavailableFallback(
Expand Down Expand Up @@ -2375,6 +2412,13 @@ async function extractTextFromImage(filePath, options = {}) {
maxDimension: OCR_MAX_DIMENSION
});
} catch (preprocessError) {
if (!decodesWithoutPreprocessing(imageBuffer)) {
logger.warn('[IMAGE-OCR] Preprocessing failed; no OCR on bytes that were not decoded', {
filePath,
error: preprocessError.message
});
return null;
}
logger.warn('[IMAGE-OCR] Preprocessing failed, continuing OCR with original buffer', {
filePath,
error: preprocessError.message
Expand Down
35 changes: 33 additions & 2 deletions src/main/analysis/mediaAnalysis.js
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,8 @@ async function analyzeMediaFile(filePath, dependencies = {}, options = {}) {
const hasUsableTranscript = transcriptForLlm.trim().length > 0;

let llmResult = cloudAnalysis;
/** @type {string|null} Why the text model gave no answer for a transcript it was sent. */
let llmFailure = null;
if (!llmResult && hasUsableTranscript) {
try {
// analyzeTextWithLlama is positional:
Expand All @@ -630,6 +632,21 @@ async function analyzeMediaFile(filePath, dependencies = {}, options = {}) {
smartFolderId,
fileType: kind
});
// The text model reports a failure it survived (engine error, model
// not installed, an answer it could not parse) by returning
// `{ error }`, not by throwing. Spread into the result below, that
// object was stored as a finished transcription with confidence 0.
if (llmResult?.error) {
if (isMemoryBudgetExceeded(llmResult.error)) {
const budgetError = /** @type {Error & { code?: string }} */ (
new Error(String(llmResult.error))
);
budgetError.code = 'MODEL_MEMORY_BUDGET_EXCEEDED';
throw budgetError;
}
llmFailure = String(llmResult.error);
llmResult = null;
}
} catch (err) {
// A cancel, or a model load refused while another model holds the
// memory, is not a result. Storing the confidence-60 stub below would
Expand All @@ -642,9 +659,12 @@ async function analyzeMediaFile(filePath, dependencies = {}, options = {}) {
) {
throw err;
}
llmFailure = String(err?.message || 'AI analysis failed for the transcript.');
}
if (llmFailure) {
logger.warn('[Media] LLM transcript analysis failed', {
file: fileName,
error: err?.message
error: llmFailure
});
}
}
Expand Down Expand Up @@ -685,7 +705,18 @@ async function analyzeMediaFile(filePath, dependencies = {}, options = {}) {
: 'Spoken content transcribed via whisper.cpp'
: 'Media file with no detectable speech'),
contentLength: transcriptForLlm.length,
extractionMethod
extractionMethod,
// The transcript is real, the category and keywords are placeholders:
// provisional, as a document whose text was read but not analyzed, so
// it is retried when the model answers again and not embedded as is.
...(llmFailure
? {
error: llmFailure,
fallbackReason: 'ai_analysis_failed',
isFallback: true,
_isFallback: true
}
: {})
},
baseFallback
);
Expand Down
Loading
Loading