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

### Changed

- The star/sparkle icon is gone from every screen; each place now uses an icon for what it does
(analysis, suggestion, description, embeddings, recommended model and so on).
- **StratoSort no longer registers itself as a viewer for PDF, Word, Excel, PowerPoint, text and
Markdown files.** Opening a file that way did nothing, so the entry in "Open with" is gone.
Existing installs drop it on the next install or update.
Expand Down Expand Up @@ -201,6 +203,25 @@ Changes you will notice:

### Fixed

- **Files are no longer analyzed two or three times at once.** Leaving Discover and coming back
while a batch ran started a new run that the window's still-running batch refused; the new run
then analyzed the same files again file by file, in parallel, and restarted itself when it ended.
That doubled GPU work, showed each "analyzed" notification more than once, kept the progress count
low, and could leave no room for the vision model, so an image fell back to OCR. A new run now
waits for the earlier batch and reuses its cached results.
- **An image whose vision step failed is no longer cached with its OCR-only result.** The next
analysis of that image (within the 30-minute cache window) tries the vision model again.
- **The smart-folder watcher waits for files you are analyzing in Discover** instead of analyzing
them at the same time, then skips them once the analysis history has them.
- **On a GPU too small for both models, the text model stays loaded between files.** Loading the
~200 MB embedding model used to evict a 7B text model on a 6 GB card and reload it for the next
file, about 4-5 s per file. The embedding model now runs on the CPU beside it.
- **Turning on "Describe video keyframes" no longer asks for cloud consent because of a deleted
smart folder.** A deleted folder's AI provider override stayed in settings and counted as a cloud
route; only overrides of folders that still exist count now.
- **Target Smart Folders** wraps long folder paths inside each card instead of running past it, and
a description the user typed in quotes is no longer shown in doubled quotes.

- **An index file too large to load can no longer be overwritten by an automatic repair.** It stays
protected until you reset or rebuild that index. A session without the compression library no
longer saves over the compressed index or its vectors either.
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 = 489;
const GLOBAL_DIRECTIVE_FLOOR = 491;
// ────────────────────────────────────────────────────────────────────────

const DIRECTIVE_SCAN_LINES = 5;
Expand Down
14 changes: 11 additions & 3 deletions src/main/analysis/imageAnalysis.js
Original file line number Diff line number Diff line change
Expand Up @@ -1770,6 +1770,9 @@ async function analyzeImageFile(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 @@ -2143,7 +2146,7 @@ async function analyzeImageFile(filePath, smartFolders = [], options = {}) {
if (extractedTextForStorage) {
result.extractedText = extractedTextForStorage;
}
if (!bypassCache && !result?._isFallback && !result?.isFallback) {
if (!bypassCache && !result?._isFallback && !result?.isFallback && !result?._skipCache) {
try {
setImageCache(signature, result);
} catch (cacheError) {
Expand Down Expand Up @@ -2223,7 +2226,12 @@ async function analyzeImageFile(filePath, smartFolders = [], options = {}) {
extractionMethod: 'vision'
});

if (!bypassCache && !normalized?._isFallback && !normalized?.isFallback) {
if (
!bypassCache &&
!normalized?._isFallback &&
!normalized?.isFallback &&
!normalized?._skipCache
) {
try {
setImageCache(signature, normalized);
} catch (cacheError) {
Expand Down Expand Up @@ -2258,7 +2266,7 @@ async function analyzeImageFile(filePath, smartFolders = [], options = {}) {
if (extractedTextForStorage) {
result.extractedText = extractedTextForStorage;
}
if (!bypassCache && !result?._isFallback && !result?.isFallback) {
if (!bypassCache && !result?._isFallback && !result?.isFallback && !result?._skipCache) {
try {
setImageCache(signature, result);
} catch (cacheError) {
Expand Down
96 changes: 96 additions & 0 deletions src/main/analysis/userAnalysisInFlight.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// @ts-check
/**
* Files a user-started analysis (Discover's batch or per-file requests) is
* working on right now.
*
* The smart-folder watcher analyzes files it finds in smart folders on its
* own. When the user analyzes the same files from Discover at the same time,
* both ran a full analysis: twice the GPU work, and an "analyzed" notification
* from each. The watcher now defers a file listed here and checks it again
* afterwards, when the analysis history already has it.
*
* Counts, not a set: two overlapping requests for one file must both finish
* before the file is free again.
*
* @module analysis/userAnalysisInFlight
*/

const { normalizePathForIndex } = require('../../shared/pathSanitization');

/** @type {Map<string, number>} */
const counts = new Map();

/**
* The registry only steers the watcher; it must never break an analysis, so
* an unusable normalizer falls back to the path as given.
* @param {string} filePath
*/
function keyOf(filePath) {
try {
return (
(typeof normalizePathForIndex === 'function' && normalizePathForIndex(filePath)) || filePath
);
} catch {
return filePath;
}
}

/**
* Mark files as being analyzed by the user. Returns the release function;
* calling it more than once is harmless.
* @param {string | string[]} filePaths
* @returns {() => void}
*/
function claimUserAnalysis(filePaths) {
const keys = (Array.isArray(filePaths) ? filePaths : [filePaths])
.filter((p) => typeof p === 'string' && p)
.map(keyOf);
for (const key of keys) counts.set(key, (counts.get(key) || 0) + 1);
let released = false;
return () => {
if (released) return;
released = true;
for (const key of keys) {
const next = (counts.get(key) || 0) - 1;
if (next > 0) counts.set(key, next);
else counts.delete(key);
}
};
}

/**
* @param {string} filePath
* @returns {boolean}
*/
function isUserAnalysisInFlight(filePath) {
if (typeof filePath !== 'string' || !filePath) return false;
return counts.has(keyOf(filePath));
}

/**
* Wrap one analysis call so its file is marked for the call's duration.
* @template T
* @param {string} filePath
* @param {() => Promise<T>} run
* @returns {Promise<T>}
*/
async function withUserAnalysis(filePath, run) {
const release = claimUserAnalysis(filePath);
try {
return await run();
} finally {
release();
}
}

/** Test seam. */
function _resetUserAnalysisInFlight() {
counts.clear();
}

module.exports = {
claimUserAnalysis,
isUserAnalysisInFlight,
withUserAnalysis,
_resetUserAnalysisInFlight
};
17 changes: 14 additions & 3 deletions src/main/ipc/analysis.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const {
getFolderCategories
} = require('./analysisUtils');
const BatchAnalysisService = require('../services/BatchAnalysisService');
const { claimUserAnalysis, withUserAnalysis } = require('../analysis/userAnalysisInFlight');
const { sendOperationProgress, sendFileComplete } = require('./files/batchProgressReporter');
const { createMediaAnalyzerAdapter } = require('../services/mediaAnalyzerAdapter');
const { getInstance: getWhisperService } = require('../services/WhisperService');
Expand Down Expand Up @@ -554,7 +555,9 @@ function registerAnalysisIpc(servicesOrParams) {
schema: analyzeFilePayloadSchema,
handler: (event, payload) => {
const { filePath, namingOverride } = normalizeAnalyzeFilePayload(payload);
return performDocumentAnalysis(filePath, event?.abortSignal, namingOverride);
return withUserAnalysis(filePath, () =>
performDocumentAnalysis(filePath, event?.abortSignal, namingOverride)
);
}
});

Expand Down Expand Up @@ -674,7 +677,9 @@ function registerAnalysisIpc(servicesOrParams) {
schema: analyzeFilePayloadSchema,
handler: (event, payload) => {
const { filePath, namingOverride } = normalizeAnalyzeFilePayload(payload);
return performImageAnalysis(filePath, event?.abortSignal, namingOverride);
return withUserAnalysis(filePath, () =>
performImageAnalysis(filePath, event?.abortSignal, namingOverride)
);
}
});

Expand Down Expand Up @@ -800,7 +805,9 @@ function registerAnalysisIpc(servicesOrParams) {
schema: analyzeFilePayloadSchema,
handler: (event, payload) => {
const { filePath, namingOverride } = normalizeAnalyzeFilePayload(payload);
return performMediaAnalysis(filePath, event?.abortSignal, namingOverride);
return withUserAnalysis(filePath, () =>
performMediaAnalysis(filePath, event?.abortSignal, namingOverride)
);
}
});

Expand Down Expand Up @@ -982,6 +989,9 @@ function registerAnalysisIpc(servicesOrParams) {
}
}

// The smart-folder watcher defers these files until the batch is done,
// so a file it also sees is not analyzed twice at once.
const releaseUserAnalysis = claimUserAnalysis(validatedPaths);
try {
// BatchAnalysisService.analyzeFiles' JSDoc enumerates only a subset of
// the options it consumes and elides successful/cancelled from its
Expand Down Expand Up @@ -1220,6 +1230,7 @@ function registerAnalysisIpc(servicesOrParams) {
batchId
};
} finally {
releaseUserAnalysis();
removeAbortListener?.();
activeBatchRuns.delete(batchId);
}
Expand Down
30 changes: 28 additions & 2 deletions src/main/services/LlamaService.js
Original file line number Diff line number Diff line change
Expand Up @@ -1754,7 +1754,8 @@ class LlamaService extends EventEmitter {
}
}

const loadOptions = this._loadOptionsBesideVisionBatch(modelType);
const loadOptions =
this._loadOptionsBesideVisionBatch(modelType) || this._loadOptionsBesideText(modelType);
// A text request that reaches here during a vision batch is either the
// batch's own work or one that already waited out the batch once. Let
// the memory manager evict the runtime between images if the text model
Expand All @@ -1766,7 +1767,9 @@ class LlamaService extends EventEmitter {
// Set after the load, too: a batch that ended while this load was in
// flight could not unload a model that was not registered yet, so the
// next embedding load must still move it back to the GPU.
if (loadOptions?.cpuByDesign) this._embeddingCpuForBatch = true;
if (loadOptions?.cpuByDesign && loadOptions.besideResident === VISION_RUNTIME_MEMORY_TYPE) {
this._embeddingCpuForBatch = true;
}
return context;
} finally {
restorePin?.();
Expand Down Expand Up @@ -4234,6 +4237,29 @@ class LlamaService extends EventEmitter {
}
}

/**
* Load options for the embedding model while the text model holds a GPU
* too small for both (a 7B text model on a 6 GB card). Without them every
* file evicted the text model to load the ~200 MB embedding model and then
* reloaded the text model, about 4-5 s per file. The embedding model runs
* on the CPU beside it instead, without the "running on CPU" warning; it
* moves to the GPU again the next time it loads with room to spare.
* @private
* @param {string} modelType
* @returns {Record<string, any> | undefined}
*/
_loadOptionsBesideText(modelType) {
if (modelType !== 'embedding') return undefined;
if (!this._gpuBackend || this._gpuBackend === 'cpu') return undefined;
const memory = this._modelMemoryManager;
if (!memory?.getLoadedContext?.('text') || memory.getLoadedContext?.('embedding')) {
return undefined;
}
// Both fit on the GPU: load the embedding model there as usual.
if (memory.canLoadModel?.('embedding') !== false) return undefined;
return { gpuLayersOverride: 0, besideResident: 'text', keepBeside: true, cpuByDesign: true };
}

/**
* Load options for a model while a vision batch holds the runtime: the
* embedding model runs on the CPU beside it instead of evicting it.
Expand Down
8 changes: 7 additions & 1 deletion src/main/services/ModelMemoryManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,13 @@ class ModelMemoryManager {
typeof besideType === 'string' && /** @type {any} */ (options)?.gpuLayersOverride === 0
? this._loadedModels.get(besideType)
: null;
const besidePinned = Boolean(besideEntry && (this._activeRefs.get(besideType) || 0) > 0);
// keepBeside: the caller chose this CPU load so an idle resident (the
// text model between files) stays; without it only a pinned one does.
const besidePinned = Boolean(
besideEntry &&
((this._activeRefs.get(besideType) || 0) > 0 ||
/** @type {any} */ (options)?.keepBeside === true)
);
const fits = () =>
besidePinned
? this._currentMemoryUsage - (besideEntry?.sizeBytes || 0) + estimatedSize <
Expand Down
21 changes: 20 additions & 1 deletion src/main/services/SmartFolderWatcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ const {
resolveRealPaths
} = require('../../shared/pathSanitization');
const { isImagePath, isMediaPath } = require('../../shared/fileIdUtils');
const { isUserAnalysisInFlight } = require('../analysis/userAnalysisInFlight');

// How long a file Discover is analyzing right now waits before the watcher
// checks it again.
const USER_ANALYSIS_DEFER_MS = 15000;
const { isUNCPath } = require('../../shared/crossPlatformUtils');
const { normalizeKeywords } = require('../../shared/normalization');
const {
Expand Down Expand Up @@ -2600,6 +2605,20 @@ class SmartFolderWatcher {
return;
}

// The user is analyzing this same file from Discover right now. Running
// our own analysis beside it doubled the GPU work and the "analyzed"
// notification. Check again after it finishes; by then the history
// normally has the file and the re-check below skips it.
if (!isReanalyze && isUserAnalysisInFlight(filePath)) {
logger.debug('[SMART-FOLDER-WATCHER] Deferring a file Discover is analyzing', { filePath });
this._enqueueAnalysisItem({
...item,
recheck: true,
notBefore: Date.now() + USER_ANALYSIS_DEFER_MS
});
return;
}

this.processingFiles.add(processingKey);

try {
Expand All @@ -2625,7 +2644,7 @@ class SmartFolderWatcher {
// spending a full LLM pass. Done lazily here, not at restore time, so a
// 50k-item backlog doesn't stat and hash every file up front. Explicit
// reanalyze requests intentionally bypass the gate.
if (item.restored && !isReanalyze) {
if ((item.restored || item.recheck) && !isReanalyze) {
const stillNeedsAnalysis = await this._needsAnalysis(filePath, item.mtime, fileStats);
if (!stillNeedsAnalysis) {
logger.debug(
Expand Down
2 changes: 1 addition & 1 deletion src/main/services/liveLogActivity.js
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@ const folderSuggestion = (input) => {
const file = baseName(input.data?.fileName);
return activity(
'folder-suggestion',
'sparkles',
'lightbulb',
'info',
file ? `Suggested a new folder "${folder}" for ${file}` : `Suggested a new folder "${folder}"`,
file
Expand Down
4 changes: 2 additions & 2 deletions src/renderer/components/AnalysisDetails.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { memo, useState, useRef, useEffect } from 'react';
import PropTypes from 'prop-types';
import { FileText, Info, Sparkles, Lightbulb, Tag, AlignLeft, Film } from 'lucide-react';
import { FileText, Info, Lightbulb, Tag, AlignLeft, Film, ScrollText } from 'lucide-react';
import { Text, Code, Caption } from './ui/Typography';
import { Stack } from './layout';
import StatusBadge from './ui/StatusBadge';
Expand Down Expand Up @@ -379,7 +379,7 @@ const AnalysisDetails = memo(function AnalysisDetails({

{analysis.summary && (
<Collapsible
title={<SectionHeader title="Summary" icon={Sparkles} />}
title={<SectionHeader title="Summary" icon={ScrollText} />}
defaultOpen
className="rounded-xl border border-border-soft/70 bg-gradient-to-br from-system-gray-50/80 to-system-gray-100"
collapsedPreview={buildCollapsedPreview(analysis.summary, { maxLength: 220 })}
Expand Down
Loading
Loading