diff --git a/package.json b/package.json index ecdb70b..309ebb1 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "name": "stratosort-core", + "productName": "StratoSort Core", "version": "2.0.1", "description": "StratoSort Core - Local AI Document Organizer. Privacy-focused document organization using in-process AI (node-llama-cpp + Orama). Zero external dependencies.", "keywords": [ diff --git a/scripts/patch-electron-mac.js b/scripts/patch-electron-mac.js new file mode 100644 index 0000000..f0509d4 --- /dev/null +++ b/scripts/patch-electron-mac.js @@ -0,0 +1,158 @@ +#!/usr/bin/env node +/** + * Patch Electron.app Info.plist for macOS development + * + * During development, macOS reads the dock label and Activity Monitor process + * name from the Electron binary's Info.plist, which defaults to "Electron". + * This script patches CFBundleName and CFBundleDisplayName so the dock shows + * "StratoSort Core" instead. + * + * This is safe and idempotent — re-running produces the same result. + * Only affects node_modules (dev-only); production builds use electron-builder + * which sets these values correctly from electron-builder.json. + * + * Usage: + * node scripts/patch-electron-mac.js # Patch Info.plist + * node scripts/patch-electron-mac.js --check # Check current values (no changes) + */ + +const fs = require('fs'); +const path = require('path'); + +const APP_NAME = 'StratoSort Core'; +const BUNDLE_ID = 'com.stratosort.app'; + +/** + * Locate the Electron.app Info.plist inside node_modules. + * Returns null if not found (e.g. running on non-macOS or Electron not installed). + */ +function findInfoPlist() { + const candidates = [ + // Standard npm install location + path.join( + __dirname, + '..', + 'node_modules', + 'electron', + 'dist', + 'Electron.app', + 'Contents', + 'Info.plist' + ), + // Hoisted or alternative layouts + path.join(__dirname, '..', '..', 'electron', 'dist', 'Electron.app', 'Contents', 'Info.plist') + ]; + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + return null; +} + +/** + * Replace a plist string value. + * Matches: KEY\n\tOLD + * and replaces OLD with NEW. + */ +function replacePlistValue(content, key, newValue) { + // Match the key followed by a string value (handles various whitespace) + const regex = new RegExp(`(${escapeRegex(key)}\\s*)(.*?)()`, 'g'); + return content.replace(regex, `$1${newValue}$3`); +} + +function escapeRegex(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function readPlistValue(content, key) { + const regex = new RegExp(`${escapeRegex(key)}\\s*(.*?)`); + const match = content.match(regex); + return match ? match[1] : null; +} + +function main() { + if (process.platform !== 'darwin') { + console.log('[patch-electron-mac] Skipped — not macOS'); + return 0; + } + + const plistPath = findInfoPlist(); + if (!plistPath) { + console.log('[patch-electron-mac] Skipped — Electron.app not found'); + return 0; + } + + const content = fs.readFileSync(plistPath, 'utf8'); + + const currentName = readPlistValue(content, 'CFBundleName'); + const currentDisplayName = readPlistValue(content, 'CFBundleDisplayName'); + const currentBundleId = readPlistValue(content, 'CFBundleIdentifier'); + + // --check mode: report current values without modifying + if (process.argv.includes('--check')) { + console.log('[patch-electron-mac] Info.plist:', plistPath); + console.log(` CFBundleName: ${currentName}`); + console.log(` CFBundleDisplayName: ${currentDisplayName}`); + console.log(` CFBundleIdentifier: ${currentBundleId}`); + + const isPatched = currentName === APP_NAME; + console.log( + ` Status: ${isPatched ? '✅ Patched' : '⚠️ Not patched (shows "Electron" in dock)'}` + ); + return isPatched ? 0 : 1; + } + + // Check if already patched + if (currentName === APP_NAME && currentDisplayName === APP_NAME) { + console.log('[patch-electron-mac] Already patched — dock will show "StratoSort Core"'); + return 0; + } + + // Apply patches + let patched = content; + patched = replacePlistValue(patched, 'CFBundleName', APP_NAME); + + // CFBundleDisplayName may not exist in the original; add it if missing + if (currentDisplayName !== null) { + patched = replacePlistValue(patched, 'CFBundleDisplayName', APP_NAME); + } else { + // Insert after CFBundleName + patched = patched.replace( + /(CFBundleName<\/key>\s*.*?<\/string>)/, + `$1\n\tCFBundleDisplayName\n\t${APP_NAME}` + ); + } + + // Patch bundle identifier so macOS associates dock state correctly + if (currentBundleId && currentBundleId !== BUNDLE_ID) { + patched = replacePlistValue(patched, 'CFBundleIdentifier', BUNDLE_ID); + } + + fs.writeFileSync(plistPath, patched, 'utf8'); + + // Verify + const verify = fs.readFileSync(plistPath, 'utf8'); + const verifiedName = readPlistValue(verify, 'CFBundleName'); + if (verifiedName !== APP_NAME) { + console.error('[patch-electron-mac] ❌ Patch failed — CFBundleName is still:', verifiedName); + return 1; + } + + console.log('[patch-electron-mac] ✅ Patched Electron.app Info.plist'); + console.log(` CFBundleName: "${currentName}" → "${APP_NAME}"`); + console.log(` CFBundleDisplayName: "${currentDisplayName || '(missing)'}" → "${APP_NAME}"`); + if (currentBundleId && currentBundleId !== BUNDLE_ID) { + console.log(` CFBundleIdentifier: "${currentBundleId}" → "${BUNDLE_ID}"`); + } + console.log(' The macOS dock will now show "StratoSort Core" in development.'); + + return 0; +} + +if (require.main === module) { + process.exit(main()); +} + +module.exports = { main }; diff --git a/scripts/postinstall.js b/scripts/postinstall.js index ef29573..0786e5d 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -53,6 +53,17 @@ function main( } } + // On macOS, patch the Electron.app Info.plist so the dock label and Activity + // Monitor show "StratoSort Core" instead of "Electron" during development. + if (platform === 'darwin') { + try { + const { main: patchMac } = require('./patch-electron-mac'); + patchMac(); + } catch (e) { + log.warn(`[postinstall] macOS Electron patch skipped: ${e.message}`); + } + } + log.log('\n[StratoSort] Setup complete!'); log.log(' Run: npm run dev'); log.log(' The app will use local AI engine (node-llama-cpp + Orama).\n'); diff --git a/src/main/services/OramaVectorService.js b/src/main/services/OramaVectorService.js index 836c16e..a1c489f 100644 --- a/src/main/services/OramaVectorService.js +++ b/src/main/services/OramaVectorService.js @@ -1176,8 +1176,54 @@ class OramaVectorService extends EventEmitter { } async _fallbackQuerySimilarFiles(queryEmbedding, topK) { - const docs = await this._getAllFileDocuments(); + const normalizedTopK = Number.isFinite(topK) && topK > 0 ? Math.max(1, Math.floor(topK)) : 10; + const embStore = this._embeddingStore?.files; const scored = []; + + // Fast path: use in-memory sidecar vectors and only fetch metadata for top candidates. + if (embStore instanceof Map && embStore.size > 0) { + for (const [docId, rawVector] of embStore.entries()) { + const emb = this._normalizeEmbeddingVector(rawVector, queryEmbedding.length); + if (!Array.isArray(emb) || emb.length !== queryEmbedding.length) continue; + if (emb[0] === 0 && emb.every((v) => v === 0)) continue; + const score = this._cosineSimilarity(queryEmbedding, emb); + if (!Number.isFinite(score) || score <= 0) continue; + scored.push({ id: docId, score }); + } + + scored.sort((a, b) => b.score - a.score); + const candidates = scored.slice(0, Math.max(normalizedTopK * 3, normalizedTopK)); + const results = []; + + for (const candidate of candidates) { + const doc = await getByID(this._databases.files, candidate.id); + if (!doc || doc.isOrphaned || doc.hasVector === false) continue; + results.push({ + id: doc.id, + score: candidate.score, + distance: 1 - candidate.score, + metadata: { + path: doc.filePath, + filePath: doc.filePath, + fileName: doc.fileName, + fileType: doc.fileType, + analyzedAt: doc.analyzedAt, + suggestedName: doc.suggestedName, + keywords: doc.keywords, + tags: doc.tags + } + }); + if (results.length >= normalizedTopK) break; + } + + if (results.length > 0) { + return results; + } + } + + // Slow path fallback for edge cases where sidecar map is unavailable. + const docs = await this._getAllFileDocuments(); + const scoredDocs = []; for (const doc of docs) { if (doc?.isOrphaned) continue; const emb = this._normalizeEmbeddingVector( @@ -1188,10 +1234,10 @@ class OramaVectorService extends EventEmitter { if (emb[0] === 0 && emb.every((v) => v === 0)) continue; const score = this._cosineSimilarity(queryEmbedding, emb); if (!Number.isFinite(score) || score <= 0) continue; - scored.push({ doc, score }); + scoredDocs.push({ doc, score }); } - scored.sort((a, b) => b.score - a.score); - return scored.slice(0, topK).map(({ doc, score }) => ({ + scoredDocs.sort((a, b) => b.score - a.score); + return scoredDocs.slice(0, normalizedTopK).map(({ doc, score }) => ({ id: doc.id, score, distance: 1 - score, @@ -1522,7 +1568,17 @@ class OramaVectorService extends EventEmitter { ); return await this._fallbackQuerySimilarFiles(queryEmbedding, topK); } - return []; + + // Even if the self-check passes, some Orama states can still return empty vector hits + // for regular queries. Use a cosine fallback scorer so semantic retrieval remains useful. + logger.debug( + '[OramaVectorService] Primary vector query returned no hits; using fallback scorer', + { + topK, + health + } + ); + return await this._fallbackQuerySimilarFiles(queryEmbedding, topK); } catch (error) { throw attachErrorCode(error, ERROR_CODES.VECTOR_DB_QUERY_FAILED); } @@ -2726,6 +2782,78 @@ class OramaVectorService extends EventEmitter { }; } + /** + * Diagnose eligibility for primary file-level vector search. + * Helps explain cases where file embeddings exist but vector retrieval returns no hits. + * + * @param {{ sampleSize?: number }} [options] + * @returns {Promise} + */ + async getFileVectorDiagnostics(options = {}) { + await this.initialize(); + + const sampleSize = Math.max(1, Math.min(Number(options?.sampleSize) || 5, 20)); + const docs = await this._getAllFileDocuments(); + const embStore = this._embeddingStore?.files; + const sidecarSize = embStore instanceof Map ? embStore.size : 0; + + let hasVectorTrue = 0; + let hasVectorFalse = 0; + let orphaned = 0; + let missingEmbedding = 0; + let placeholderVectors = 0; + let eligibleForPrimaryVector = 0; + const ineligibleSample = []; + + for (const doc of docs) { + const hasVector = doc?.hasVector === true; + const isOrphaned = doc?.isOrphaned === true; + + if (hasVector) hasVectorTrue++; + else hasVectorFalse++; + if (isOrphaned) orphaned++; + + const normalizedEmbedding = this._normalizeEmbeddingVector( + (embStore instanceof Map ? embStore.get(doc?.id) : null) || doc?.embedding, + this._dimension + ); + const hasEmbedding = + Array.isArray(normalizedEmbedding) && normalizedEmbedding.length === this._dimension; + const isPlaceholder = + hasEmbedding && + normalizedEmbedding[0] === 0 && + normalizedEmbedding.every((value) => value === 0); + + if (!hasEmbedding) missingEmbedding++; + if (isPlaceholder) placeholderVectors++; + + const eligible = hasVector && !isOrphaned && hasEmbedding && !isPlaceholder; + if (eligible) { + eligibleForPrimaryVector++; + } else if (ineligibleSample.length < sampleSize) { + ineligibleSample.push({ + id: doc?.id, + hasVector, + isOrphaned, + hasEmbedding, + isPlaceholder + }); + } + } + + return { + totalFiles: docs.length, + eligibleForPrimaryVector, + hasVectorTrue, + hasVectorFalse, + orphaned, + missingEmbedding, + placeholderVectors, + sidecarEmbeddings: sidecarSize, + ineligibleSample + }; + } + getVectorHealth() { return this._getVectorHealthSnapshot(); } diff --git a/src/main/services/SearchService.js b/src/main/services/SearchService.js index b73d3f6..9a42812 100644 --- a/src/main/services/SearchService.js +++ b/src/main/services/SearchService.js @@ -141,6 +141,10 @@ class SearchService { // Maximum cache size in bytes (50MB) to prevent unbounded growth this._maxCacheSize = 50 * 1024 * 1024; + + // Throttle noisy diagnostics when primary vector retrieval returns no hits. + this._lastZeroVectorDiagnosticsAt = 0; + this._ZERO_VECTOR_DIAGNOSTICS_INTERVAL_MS = 60 * 1000; } /** @@ -681,6 +685,11 @@ class SearchService { return []; } + if (vectorResults.length === 0) { + await this._logZeroVectorDiagnostics(query, topK); + return []; + } + // Extract query words for tag/category matching const queryWords = query .toLowerCase() @@ -739,6 +748,41 @@ class SearchService { } } + async _logZeroVectorDiagnostics(query, topK) { + const now = Date.now(); + if (now - this._lastZeroVectorDiagnosticsAt < this._ZERO_VECTOR_DIAGNOSTICS_INTERVAL_MS) { + return; + } + this._lastZeroVectorDiagnosticsAt = now; + + try { + const stats = + typeof this.vectorDb?.getStats === 'function' ? await this.vectorDb.getStats() : null; + const diagnostics = + typeof this.vectorDb?.getFileVectorDiagnostics === 'function' + ? await this.vectorDb.getFileVectorDiagnostics({ sampleSize: 3 }) + : null; + const vectorHealth = + stats?.vectorHealth || + (typeof this.vectorDb?.getVectorHealth === 'function' + ? this.vectorDb.getVectorHealth() + : null); + + logger.warn('[SearchService] Vector search returned zero results', { + query: typeof query === 'string' ? query.slice(0, 120) : '', + topK, + fileEmbeddings: stats?.files ?? null, + chunkEmbeddings: stats?.fileChunks ?? null, + vectorHealth, + diagnostics + }); + } catch (error) { + logger.debug('[SearchService] Failed to gather zero-vector diagnostics', { + error: error?.message + }); + } + } + /** * Chunk search: query against extractedText chunk embeddings. * diff --git a/src/main/services/ServiceIntegration.js b/src/main/services/ServiceIntegration.js index ec09296..0c96575 100644 --- a/src/main/services/ServiceIntegration.js +++ b/src/main/services/ServiceIntegration.js @@ -616,6 +616,7 @@ class ServiceIntegration { vectorDbService: c.resolve(ServiceIds.ORAMA_VECTOR), analysisHistoryService: c.resolve(ServiceIds.ANALYSIS_HISTORY), parallelEmbeddingService: c.resolve(ServiceIds.PARALLEL_EMBEDDING), + llamaService: c.tryResolve(ServiceIds.LLAMA_SERVICE), relationshipIndexService: c.tryResolve(ServiceIds.RELATIONSHIP_INDEX) }); }); diff --git a/src/main/simple-main.js b/src/main/simple-main.js index fdd5543..311ed98 100644 --- a/src/main/simple-main.js +++ b/src/main/simple-main.js @@ -8,6 +8,14 @@ try { const { app, BrowserWindow, ipcMain, dialog, shell, crashReporter } = require('electron'); +// Set the application name as early as possible. +// On macOS this controls the dock label and application menu title. +// Electron defaults to package.json "productName" → "name", but the raw Electron +// binary in development reports itself as "Electron" to the OS. An explicit +// assignment ensures the correct name in all contexts (dock, Activity Monitor menu +// label, window title fallback) regardless of dev vs packaged mode. +app.name = 'StratoSort Core'; + const isDev = process.env.NODE_ENV === 'development'; // Logging utility @@ -543,6 +551,28 @@ if (gotTheLock || process.env.STRATOSORT_FORCE_LAUNCH === '1') { // Initialize services after app is ready app.whenReady().then(async () => { logger.info('[STARTUP] app.whenReady resolved'); + + // macOS: Set the dock icon to the StratoSort logo so it replaces the default + // Electron atom icon during development. In production (packaged) builds + // electron-builder bakes the icon into the .app bundle so this is a no-op. + if (process.platform === 'darwin' && app.dock) { + try { + const { nativeImage } = require('electron'); + const iconPath = path.join(__dirname, '..', 'assets', 'icons', 'png', '512x512.png'); + // Resolve from repo root in dev, or from resources in packaged build + const resolvedIcon = app.isPackaged + ? path.join(process.resourcesPath, 'assets', 'icons', 'png', '512x512.png') + : path.resolve(iconPath); + const fsSync = require('fs'); + if (fsSync.existsSync(resolvedIcon)) { + app.dock.setIcon(nativeImage.createFromPath(resolvedIcon)); + logger.info('[DOCK] Set macOS dock icon from', resolvedIcon); + } + } catch (dockErr) { + logger.debug('[DOCK] Could not set macOS dock icon:', dockErr?.message); + } + } + // FIX: Create a referenced interval to keep the event loop alive during startup // This prevents premature exit when async operations use unreferenced timeouts const startupKeepalive = trackInterval(() => {}, 1000); diff --git a/src/renderer/components/NavigationBar.jsx b/src/renderer/components/NavigationBar.jsx index e8d2f4b..f05dd2e 100644 --- a/src/renderer/components/NavigationBar.jsx +++ b/src/renderer/components/NavigationBar.jsx @@ -231,10 +231,10 @@ const NavTab = memo(function NavTab({ focus:outline-none focus-visible:ring-2 focus-visible:ring-stratosort-blue focus-visible:ring-offset-2 ${ isActive - ? 'bg-white text-stratosort-blue shadow-sm border border-system-gray-200' + ? 'phase-nav-tab-active' : canNavigate - ? 'text-system-gray-600 hover:text-system-gray-900 hover:bg-white/60 border border-transparent' - : 'text-system-gray-400 cursor-not-allowed border border-transparent' + ? 'phase-nav-tab-interactive' + : 'phase-nav-tab-disabled' } `} aria-label={metadata?.title} @@ -631,7 +631,10 @@ function NavigationBar() { >
{/* Left: Brand */} -
+
@@ -640,7 +643,7 @@ function NavigationBar() { {/* the inner