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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand Down
158 changes: 158 additions & 0 deletions scripts/patch-electron-mac.js
Original file line number Diff line number Diff line change
@@ -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>KEY</key>\n\t<string>OLD</string>
* 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(`(<key>${escapeRegex(key)}</key>\\s*<string>)(.*?)(</string>)`, 'g');
return content.replace(regex, `$1${newValue}$3`);
}

function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

function readPlistValue(content, key) {
const regex = new RegExp(`<key>${escapeRegex(key)}</key>\\s*<string>(.*?)</string>`);
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(
/(<key>CFBundleName<\/key>\s*<string>.*?<\/string>)/,
`$1\n\t<key>CFBundleDisplayName</key>\n\t<string>${APP_NAME}</string>`
);
}

// 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 };
11 changes: 11 additions & 0 deletions scripts/postinstall.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
138 changes: 133 additions & 5 deletions src/main/services/OramaVectorService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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<Object>}
*/
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();
}
Expand Down
Loading