Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
fbba974
fix(organize): a slow cross-drive move runs to its end instead of bei…
iLevyTate Nov 10, 2026
ea650c5
fix(undo): an entry whose file is gone leaves history and errors keep…
iLevyTate Nov 10, 2026
5edd053
fix(suggestions): feedback notes no longer write the ranking score in…
iLevyTate Nov 10, 2026
88b6e32
fix(vectors): a file moved onto a leftover row's path keeps its own s…
iLevyTate Nov 10, 2026
3e27399
fix(organize-ui): notification focus, saved bulk category, real count…
iLevyTate Nov 10, 2026
b6aa857
fix(session): graph preferences survive an expired session and the no…
iLevyTate Nov 10, 2026
cdd4e84
fix(ipc): network-share paths reach the mover and free text with a pa…
iLevyTate Nov 10, 2026
3309cdd
fix(discover): recordings analyzed one by one wait their turn instead…
iLevyTate Nov 10, 2026
092c7e2
fix(models): early-closed downloads fail and resume, missing projecto…
iLevyTate Nov 10, 2026
4ef6bf2
fix(vision): images go to the one-request runtime one at a time
iLevyTate Nov 10, 2026
2349cb3
fix(analysis): extension tokens, instruction-aware caches, archive ke…
iLevyTate Nov 10, 2026
5194026
fix(watcher): app moves keep history, Explorer renames are matched, r…
iLevyTate Nov 10, 2026
f6f6e52
fix(lifecycle): a second quit waits for cleanup and a failed check ke…
iLevyTate Nov 10, 2026
6beef87
fix(search-ui): Timeline list measures its container and the hidden A…
iLevyTate Nov 10, 2026
052906b
docs(changelog): record the systematic-sweep fixes
iLevyTate Nov 10, 2026
1618643
fix(organize): Stop ends a batch stuck on a drive that stopped answering
iLevyTate Nov 10, 2026
3fc85c9
fix(organize): a batch still moving files is not cut off or rolled ba…
iLevyTate Nov 10, 2026
4f8f7b8
fix(discover): the recording busy-wait works through the bridge and a…
iLevyTate Nov 10, 2026
2612944
fix(settings): a failed settings read never saves defaults over store…
iLevyTate Nov 10, 2026
8bebb89
fix(chat): scope drop, reopened answers, retry keeps the question, la…
iLevyTate Nov 10, 2026
be8bfe0
fix(embedding): failed items are retried on their own timer
iLevyTate Nov 10, 2026
f8cf511
fix(history): a path a file has left no longer addresses that file's …
iLevyTate Nov 10, 2026
c1b0790
fix(setup): no false stopped warning, engine failure is shown, a miss…
iLevyTate Nov 10, 2026
f6164bb
docs(changelog): record the second sweep fixes
iLevyTate Nov 10, 2026
847ef38
perf(organize): a bulk category change is saved in one store update
iLevyTate Nov 10, 2026
ff11cdd
test(e2e): the quick start and clusters-toggle tests follow what the …
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
67 changes: 67 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,73 @@ Changes you will notice:
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.
- **A slow move in Organize is no longer reported as failed while it still finishes.** A move to
another drive that took longer than 30 seconds was marked failed, but the file moved anyway, with
no undo entry and stale search paths. The move now runs to its end, and Stop cancels a cross-drive
move before the copy is published. If the drive has stopped answering, Stop ends the batch after
15 seconds; should the move go through later, the file is put back.
- **An undo that can never work no longer blocks the ones before it.** When the file was deleted or
moved outside the app (or the backup of a deleted file is gone), the entry is removed from history
and the message says so. A file on a drive that is unplugged or ejected keeps its entry. Undo and
redo errors now carry their reason to the screen.
- **Feedback notes no longer raise Organize confidence on their own.** With any note saved, a 69%
match could be shown and auto-organized as 76%, because the ranking score was written into the
confidence. A note now changes confidence only for the folders it is about.
- **A file moved onto a path that an earlier, deleted file used keeps its own search entry** instead
of answering with the old file's content.
- **Organize: smaller fixes.** Clicking an Organize notification focuses the file again; a bulk
category change is saved; "Done! Organized N" and the Complete screen count the files and folders
that were really used; the progress highlight follows a renamed file; counts of one read "1 file",
not "1 files".
- **Smart folders on a network share (`\\server\share\…`) can be organized.** The paths were refused
before they reached the mover. Device paths and administrative shares (`C$`) stay refused.
- **A question, search or description that mentions a Windows path is no longer blanked** before it
is sent (for example "What is in D:\Docs?").
- **Graph preferences survive a session that expired**, and the "session expired" notice shows on
whichever screen opens first.
- **Several recordings analyzed one by one no longer fail with "busy";** they wait their turn.
- **Model downloads.** A download whose connection closes early fails and resumes instead of
hanging. A vision model whose image projector did not download is reported with a warning, not as
installed. When "Change & rebuild now" fails, Settings goes back to the previous embedding model,
as the app already had.
- **A timed-out image no longer restarts the vision runtime under another image** that is being
analyzed at the same time; images go to the runtime one at a time.
- **Analysis results.** The filename fallback no longer picks a folder because its description
contains the extension's letters inside a word ("ai" in "email"). Changing the organization
instructions re-analyzes files instead of returning the cached answer. Archives that cannot be
listed get keywords, and `.tgz` and `.bz2` count as archives. A document confidence the model gave
as a fraction (0.9) is stored as 90%, not 1%.
- **Smart-folder watcher.** A file the app itself moved out of a watched folder keeps its analysis
history. A file renamed in Explorer keeps its analysis instead of being analyzed again. Turning
the watcher back on while it is still stopping leaves it on.
- **Quitting twice, or closing the window during shutdown, no longer ends the app before cleanup is
done.**
- **"Update ready" stays after a later update check fails.**
- **Search window.** The Timeline list fills the window instead of staying 400 px tall, and the
hidden Atlas tab is no longer reachable with the Tab key.
- **Organizing many or large files to another drive no longer stops after five minutes.** The batch
was cut off, a timeout was shown and the files already moved could be moved back. A batch that is
still moving files now runs until it is done or you press Stop.
- **A failed settings read can no longer replace your saved settings with the defaults.** Settings
now says the read failed and offers Try again; nothing is saved until the real settings have
loaded.
- **Chat.** Dragging files or folders onto the Document scope panel adds them, and says so when
nothing could be added. A reopened chat shows each answer as it first appeared. Retry after a
failed request saves the question with the answer. Chats scoped to more than 200 documents are
saved and keep their memory.
- **Files whose search indexing hit a temporary error are retried on their own** after a short wait,
also after a restart, instead of waiting until another file is analyzed.
- **Analysis history no longer mixes up two files that used the same path.** Analyzing a new file
where an organized file used to be, or moving or deleting a file from there, no longer removes or
re-points the organized file's history.
- **Setup wizard.** No false "Required model downloads stopped" right after clicking Download. When
the AI engine cannot start, the wizard says so with the reason instead of listing installed models
as missing. An image model whose projector file did not download is shown as failed, and the retry
fetches (and Cancel stops) only that file.
- **Dropping a folder whose files are already in the Discover queue no longer analyzes them again;**
only new files are added.
- **Recordings queued behind one that timed out now really wait their turn.** The earlier fix did
not take effect in the packaged app.

## [5.1.2] - 2026-11-01

Expand Down
21 changes: 18 additions & 3 deletions src/main/analysis/documentAnalysis.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const {
TEXT_TRUNCATION_MARKER
} = require('./documentExtractors');
const { analyzeTextWithLlama, normalizeCategoryToSmartFolders } = require('./documentLlm');
const { getOrganizationGuidance } = require('./organizationGuidance');
const { getAnalysisLimits } = require('./analysisLimits');
const { normalizeAnalysisResult } = require('./utils');
const { normalizeExtractedTextForStorage } = require('./analysisTextUtils');
Expand Down Expand Up @@ -344,12 +345,20 @@ async function analyzeDocumentFile(filePath, smartFolders = [], options = {}) {
});
}

// The user's organization instructions shape the answer, so a result cached
// under other instructions must not be returned for this request.
const guidanceSig = crypto
.createHash('sha256')
.update(String((await getOrganizationGuidance()) || ''))
.digest('hex')
.slice(0, 16);

// Step 1: Attempt to compute file signature and check cache (non-fatal if fails)
let fileSignature = null;
let fileStats = null;
try {
fileStats = await fs.stat(filePath);
fileSignature = `${ANALYSIS_SIGNATURE_VERSION}|${modelName}|${smartFolderSig}|${filePath}|${fileStats.size}|${fileStats.mtimeMs}`;
fileSignature = `${ANALYSIS_SIGNATURE_VERSION}|${modelName}|${smartFolderSig}|${guidanceSig}|${filePath}|${fileStats.size}|${fileStats.mtimeMs}`;
if (!bypassCache) {
const cachedResult = getFileCache(fileSignature);
if (cachedResult) {
Expand Down Expand Up @@ -830,9 +839,14 @@ async function analyzeDocumentFile(filePath, smartFolders = [], options = {}) {
} else if (SUPPORTED_ARCHIVE_EXTENSIONS.includes(fileExtension)) {
// Archive metadata inspection (best-effort)
const archiveInfo = await tryExtractArchiveMetadata(filePath);
// An archive with no readable listing has an empty list, not a missing one.
const listedKeywords = Array.isArray(archiveInfo.keywords)
? archiveInfo.keywords.slice(0, TRUNCATION.KEYWORDS_MAX)
: [];
const keywords =
archiveInfo.keywords?.slice(0, TRUNCATION.KEYWORDS_MAX) ||
getIntelligentKeywords(fileName, fileExtension);
listedKeywords.length > 0
? listedKeywords
: getIntelligentKeywords(fileName, fileExtension);

const category = normalizeCategoryToSmartFolders('archive', smartFolders);

Expand Down Expand Up @@ -957,6 +971,7 @@ async function analyzeDocumentFile(filePath, smartFolders = [], options = {}) {
contentHash,
fileName,
task: 'analyzeTextWithLlama',
guidance: guidanceSig,
folders: Array.isArray(smartFolders)
? smartFolders.map((f) => f?.name || '').join(',')
: ''
Expand Down
10 changes: 7 additions & 3 deletions src/main/analysis/documentLlm.js
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,11 @@ async function analyzeTextWithLlama(
const namingContextKey = Array.isArray(namingContext)
? namingContext.slice(0, 5).join('|')
: '';
const cacheSeed = `${originalFileName || ''}|${fileDate || ''}|${namingContextKey}|${truncated}`;
// Global user organization instructions (empty string when unset). They go
// into the prompt, so they are part of the key: an answer given under other
// instructions is not an answer to this request.
const orgGuidance = String((await getOrganizationGuidance()) || '');
const cacheSeed = `${originalFileName || ''}|${fileDate || ''}|${namingContextKey}|${orgGuidance}|${truncated}`;
const cacheKey = cacheService.generateKey(cacheSeed, modelToUse, smartFolders);
if (!bypassCache) {
const cachedResult = cacheService.get(cacheKey);
Expand Down Expand Up @@ -399,8 +403,7 @@ async function analyzeTextWithLlama(

const fileDateContext = fileDate ? `\nDocument File Date: ${fileDate}` : '';

// Global user organization instructions (empty string when unset).
const orgGuidanceStr = formatOrganizationGuidanceBlock(await getOrganizationGuidance());
const orgGuidanceStr = formatOrganizationGuidanceBlock(orgGuidance);

// Build naming context string if available
let namingContextStr = '';
Expand Down Expand Up @@ -452,6 +455,7 @@ ${truncated}
contentLength: truncated.length, // Additional uniqueness for truncated content
text: truncated,
model: modelToUse,
guidance: orgGuidance,
folders: smartFolders
.map((f) => f?.name || '')
.filter(Boolean)
Expand Down
71 changes: 70 additions & 1 deletion src/main/analysis/embeddingQueue/EmbeddingQueueCore.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ const { processItemsInParallel } = require('./parallelProcessor');
const { createProgressTracker } = require('./progress');

const logger = createLogger('EmbeddingQueue');
// Shortest wait before the failed-item retry timer fires.
const FAILED_RETRY_MIN_DELAY_MS = 1000;
class EmbeddingQueue {
/**
* @param {{ persistenceFileName?: string, batchSize?: number, flushDelayMs?: number, parallelFlushConcurrency?: number, failedItemsPath?: string, deadLetterPath?: string }} [options]
Expand Down Expand Up @@ -163,6 +165,11 @@ class EmbeddingQueue {
// Track retry timers so they can be cleared on shutdown
this._retryTimers = new Set();

// Wakes the queue when the next failed item's backoff ends (see
// _scheduleFailedRetry). Cleared on shutdown.
/** @type {ReturnType<typeof setTimeout>|null} */
this._failedRetryTimer = null;

// Mutex for flush operation to prevent double-flush race conditions
this._flushMutex = Promise.resolve();

Expand Down Expand Up @@ -417,9 +424,12 @@ class EmbeddingQueue {
// latch if a previous initialize() attempt failed and reset() reran us).
this._persistBlocked = false;

if (this.queue.length > 0 || this._failedItemHandler.failedItems.size > 0) {
if (this.queue.length > 0) {
this.scheduleFlush();
}
// Restored failed items are re-queued by their own timer: a flush
// with an empty queue returns before it reaches them.
this._scheduleFailedRetry();
} catch (error) {
logger.error('[EmbeddingQueue] Initialization error:', error);
// The in-memory state is empty/partial while the on-disk state may
Expand Down Expand Up @@ -467,6 +477,7 @@ class EmbeddingQueue {
length: item.vector?.length
});
this._failedItemHandler.trackFailedItem(item, 'invalid_vector_format');
this._scheduleFailedRetry();
return { success: false, reason: 'invalid_vector_format' };
}

Expand Down Expand Up @@ -499,6 +510,7 @@ class EmbeddingQueue {
corruptionPercent: Math.round(corruptionRatio * 100)
});
this._failedItemHandler.trackFailedItem(item, 'invalid_vector_values');
this._scheduleFailedRetry();
return { success: false, reason: 'invalid_vector_values' };
}
item.vector = sanitizedVector;
Expand Down Expand Up @@ -570,6 +582,7 @@ class EmbeddingQueue {
// failed-items entry is written by the debounced persist. (This used to
// rewrite the whole full queue once per overflowing item.)
this._failedItemHandler.trackFailedItem(item, 'queue_overflow');
this._scheduleFailedRetry();
result.success = false;
result.reason = 'queue_overflow';
result.warnings.push('queue_overflow');
Expand Down Expand Up @@ -686,6 +699,53 @@ class EmbeddingQueue {
if (retryTimer.unref) retryTimer.unref();
}

/**
* Arm a timer for the moment the next failed item's backoff ends, then put
* the due items back on the queue and flush them. Without it a failed item
* was only retried from inside a later flush, and a flush with an empty
* queue returns early — so a failure in the last batch (or a database-offline
* give-up) waited, across restarts, until some other file was enqueued.
* Retry limits and backoff stay with the failed-item handler.
*/
_scheduleFailedRetry() {
if (this._isShuttingDown) return;
if (this._failedRetryTimer) {
clearTimeout(this._failedRetryTimer);
this._failedRetryTimer = null;
}
const delay = this._failedItemHandler.getNextRetryDelay(this.queue);
if (delay === null) return;

this._failedRetryTimer = setTimeout(
() => {
this._failedRetryTimer = null;
if (this._isShuttingDown) return;
// A running flush re-arms this timer when it ends.
if (this.isFlushing) return;
this._failedItemHandler
.retryFailedItems(this.queue, () => this._schedulePersist({ queue: true }))
.then(
/** @returns {undefined} */ () => {
if (this.queue.length > 0) this.scheduleFlush();
// Items whose backoff has not ended yet.
this._scheduleFailedRetry();
return undefined;
}
)
.catch((/** @type {any} */ err) =>
logger.warn('[EmbeddingQueue] Failed-item retry failed:', err?.message)
);
},
// The floor keeps a wake-up that re-queues nothing from spinning; the cap
// keeps a bad persisted timestamp from parking the retry for good.
Math.min(Math.max(delay, FAILED_RETRY_MIN_DELAY_MS), RETRY.BACKOFF_MAX_MS)
);
// Never keep the process alive for a retry; the items are on disk.
if (typeof this._failedRetryTimer.unref === 'function') {
this._failedRetryTimer.unref();
}
}

/**
* Persist queue to disk
*/
Expand Down Expand Up @@ -1020,6 +1080,10 @@ class EmbeddingQueue {
// forceFlush's `isFlushing && _activeFlush` read never sees a torn pair.
if (resolveActiveFlush) resolveActiveFlush();
this._activeFlush = null;
// Whatever this flush left in the failed-items map (per-item failures,
// an offline give-up) is retried when its backoff ends, even if the
// queue is now empty and nothing else is ever enqueued.
this._scheduleFailedRetry();
}
} finally {
// Always release the mutex
Expand Down Expand Up @@ -1560,6 +1624,11 @@ class EmbeddingQueue {
this._retryTimers.clear();
}

if (this._failedRetryTimer) {
clearTimeout(this._failedRetryTimer);
this._failedRetryTimer = null;
}

if (this._progressTracker?.clear) {
this._progressTracker.clear();
}
Expand Down
44 changes: 39 additions & 5 deletions src/main/analysis/embeddingQueue/failedItemHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,16 @@ function createFailedItemHandler(config) {
markDeadLetterChanged();
}

/**
* Exponential backoff per item: BASE_MS * 2^(retryCount-1), which is
* 5s, 10s, 20s for retryCount 1, 2, 3.
* @param {{ retryCount: number }} data - Failed-items entry
* @returns {number}
*/
function retryBackoffMs(data) {
return RETRY.BACKOFF_BASE_MS * 2 ** (data.retryCount - 1);
}

/**
* Get items whose exponential backoff has elapsed. Pure read: `lastAttempt`
* is stamped by retryFailedItems only for items it actually re-queues, so an
Expand All @@ -279,18 +289,41 @@ function createFailedItemHandler(config) {
const itemsToRetry = [];

for (const data of failedItems.values()) {
// Exponential backoff per item: 10s, 20s, 40s
// Formula: BASE_MS * 2^(retryCount-1) gives 10s, 20s, 40s for retryCount 1, 2, 3
const backoffMs = RETRY.BACKOFF_BASE_MS * 2 ** (data.retryCount - 1);

if (now - data.lastAttempt >= backoffMs) {
if (now - data.lastAttempt >= retryBackoffMs(data)) {
itemsToRetry.push(data.item);
}
}

return itemsToRetry;
}

/**
* How long until the next failed item may be retried, so the owner can arm a
* timer for it instead of waiting for unrelated work to trigger a flush.
* Items already back in the main queue are skipped: their next attempt is
* the queue's own flush.
* @param {any[]} queue - Main queue
* @param {number} [now]
* @returns {number|null} Milliseconds (0 when one is already due), or null when nothing waits
*/
function getNextRetryDelay(queue, now = Date.now()) {
if (failedItems.size === 0) return null;
const queuedIds = new Set(queue.map((item) => item?.id).filter(Boolean));
/** @type {number|null} */
let earliest = null;

for (const data of failedItems.values()) {
const id = data.item?.id;
if (!id || queuedIds.has(id)) continue;
const dueIn = data.lastAttempt + retryBackoffMs(data) - now;
if (!Number.isFinite(dueIn)) continue;
const delay = Math.max(0, dueIn);
if (earliest === null || delay < earliest) earliest = delay;
}

return earliest;
}

/**
* Retry failed items
* @param {any[]} queue - Main queue to add retries to
Expand Down Expand Up @@ -432,6 +465,7 @@ function createFailedItemHandler(config) {
trackFailedItem,
addToDeadLetterQueue,
getItemsToRetry,
getNextRetryDelay,
retryFailedItems,
getDeadLetterItems,
clearDeadLetterQueue,
Expand Down
Loading
Loading