Skip to content
Open
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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ tree.
`ci`) enforces a monotonic floor and blocks new .js under `COVERED_DIRS` (`src/shared`,
`src/preload`, `src/main/{ipc,services,analysis,errors,utils,workers,transports,core}`,
`src/renderer/{services,hooks,phases,store,utils,components}` today (every `.js` under `src/` is
now opted in — 493/493 as of 2026-11-09, and `GLOBAL_DIRECTIVE_FLOOR` matches at 493) — see
now opted in — 494/494 as of 2026-11-11, and `GLOBAL_DIRECTIVE_FLOOR` matches at 494) — see
`scripts/verify-checkjs-ratchet.js` for the authoritative list) from landing without the
directive. When adding a file to a covered dir: put `// @ts-check` on line 1 and fix the errors
before commit. When opting in a new file elsewhere: bump `GLOBAL_DIRECTIVE_FLOOR` in
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 = 493;
const GLOBAL_DIRECTIVE_FLOOR = 494;
// ────────────────────────────────────────────────────────────────────────

const DIRECTIVE_SCAN_LINES = 5;
Expand Down
73 changes: 68 additions & 5 deletions src/main/analysis/documentAnalysis.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,10 @@ const {
const { analyzeTextWithLlama, normalizeCategoryToSmartFolders } = require('./documentLlm');
const { getOrganizationGuidance } = require('./organizationGuidance');
const { getAnalysisLimits } = require('./analysisLimits');
const { normalizeAnalysisResult } = require('./utils');
const { normalizeAnalysisResult, dropPromptEchoes } = require('./utils');
const { normalizeExtractedTextForStorage } = require('./analysisTextUtils');
const {
FILENAME_ONLY_CONFIDENCE,
getIntelligentCategory,
getIntelligentKeywords,
safeSuggestedName,
Expand Down Expand Up @@ -733,7 +734,8 @@ async function analyzeDocumentFile(filePath, smartFolders = [], options = {}) {
const intelligentKeywords = getIntelligentKeywords(fileName, fileExtension);

let purpose = 'Office document (content extraction failed)';
const confidence = 70;
// The contents could not be read: a filename guess, flagged for review.
const confidence = FILENAME_ONLY_CONFIDENCE;
// A reader this build lacks, or a password, is not a capability a
// model install restores: stamp a method the watcher never
// retries (analysisProvenance.format_unsupported).
Expand Down Expand Up @@ -848,15 +850,27 @@ async function analyzeDocumentFile(filePath, smartFolders = [], options = {}) {
? listedKeywords
: getIntelligentKeywords(fileName, fileExtension);

const category = normalizeCategoryToSmartFolders('archive', smartFolders);
// File an archive by what it holds: a zip of .stl parts belongs with
// 3D prints, not under the generic 'archive' (which only ever reached
// Uncategorized). Taken only when the entries' dominant type lands on
// one of the user's own folders.
const contentFolder = archiveInfo.dominantExtension
? findSmartFolderName(
getIntelligentCategory(fileName, `.${archiveInfo.dominantExtension}`, smartFolders),
smartFolders
)
: null;
const category = contentFolder || normalizeCategoryToSmartFolders('archive', smartFolders);

return {
purpose: archiveInfo.summary || 'Archive file',
project: path.basename(fileName, fileExtension),
category,
date: fileDate,
keywords,
confidence: 70,
// Only the entry names were read. A folder matched on them is a fair
// guess; the generic 'archive' bucket is not one.
confidence: contentFolder ? 70 : FILENAME_ONLY_CONFIDENCE,
suggestedName: safeSuggestedName(fileName, fileExtension),
extractionMethod: 'archive'
};
Expand Down Expand Up @@ -1032,7 +1046,7 @@ async function analyzeDocumentFile(filePath, smartFolders = [], options = {}) {
});
const normalized = normalizeAnalysisResult(
{
...analysis,
...dropPromptEchoes(analysis, smartFolders),
llmSuggestedName: analysis.suggestedName || null,
contentLength: extractedTextLength,
extractionMethod: 'content',
Expand Down Expand Up @@ -1226,6 +1240,7 @@ async function tryExtractArchiveMetadata(filePath) {
const listing = await describeArchive(filePath);
if (listing) {
info.keywords = deriveKeywordsFromFilenames(listing.entryNames);
info.dominantExtension = dominantEntryExtension(listing.entryNames);
info.summary = listing.summary;
if (typeof listing.totalEntries === 'number') info.totalEntries = listing.totalEntries;
info.processedEntries = listing.entryNames.length;
Expand All @@ -1251,6 +1266,7 @@ async function tryExtractArchiveMetadata(filePath) {
const sampledEntries = allEntries.slice(0, 50);
const names = sampledEntries.map((/** @type {any} */ e) => e.entryName);
info.keywords = deriveKeywordsFromFilenames(names);
info.dominantExtension = dominantEntryExtension(names);
info.totalEntries = totalEntries;
info.processedEntries = sampledEntries.length;
info.summary = `ZIP archive with ${totalEntries} entries`;
Expand All @@ -1267,6 +1283,53 @@ async function tryExtractArchiveMetadata(filePath) {
return info;
}

/**
* The extension that at least half of an archive's file entries share, or
* null when no single type dominates. Folder entries are ignored.
* @param {string[]} names
* @returns {string|null}
*/
function dominantEntryExtension(names) {
/** @type {Record<string, number>} */
const counts = {};
let files = 0;
for (const name of names || []) {
const base = String(name).split(/[\\/]/).pop() || '';
if (!base) continue; // folder entry ("parts/")
files += 1;
const dot = base.lastIndexOf('.');
if (dot <= 0 || dot === base.length - 1) continue;
const ext = base.slice(dot + 1).toLowerCase();
counts[ext] = (counts[ext] || 0) + 1;
}
let best = null;
let bestCount = 0;
for (const [ext, count] of Object.entries(counts)) {
if (count > bestCount) {
best = ext;
bestCount = count;
}
}
return best && bestCount * 2 >= files ? best : null;
}

/**
* The user's folder whose name equals `candidate` (case-insensitive), or null.
* Uncategorized does not count: it is the absence of a match.
* @param {string|null|undefined} candidate
* @param {any[]} smartFolders
* @returns {string|null}
*/
function findSmartFolderName(candidate, smartFolders) {
if (typeof candidate !== 'string' || !candidate) return null;
const wanted = candidate.trim().toLowerCase();
if (!wanted || wanted === 'uncategorized') return null;
const folder = (smartFolders || []).find(
(f) => typeof f?.name === 'string' && f.name.trim().toLowerCase() === wanted
);
return folder ? folder.name : null;
}

/** @param {string[]} names */
function deriveKeywordsFromFilenames(names) {
/** @type {Record<string, number>} */
Expand Down
66 changes: 57 additions & 9 deletions src/main/analysis/fallbackUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,47 @@ const FolderMatchingService = require('../services/FolderMatchingService');
const { createLogger } = require('../../shared/logger');
const logger = createLogger('FallbackUtils');

/**
* Confidence for a result built from the file name alone (nothing inside the
* file was read). Below auto-organize's default threshold (75%) and in the
* history view's warning band (50-69%), so these files ask for a look instead
* of reading as settled. They were reported at 65-75, above real content
* analyses that scored lower.
*/
const FILENAME_ONLY_CONFIDENCE = 50;

/**
* Lowercased words of a file name, space-joined and space-padded so a phrase
* can be found as whole words: "AR+15_foregrip-stls.zip" -> " ar 15 foregrip stls zip ".
* @param {string} fileName
* @returns {string}
*/
function toWordString(fileName) {
const words = String(fileName || '')
.toLowerCase()
.split(/[^\p{L}\p{N}]+/u)
.filter(Boolean);
return ` ${words.join(' ')} `;
}

/**
* Whether `phrase` appears in the file name as whole words, allowing a plural
* on its last word ("invoice" matches "invoices"). Substring matching filed
* "network" under a "Work" folder and "barcode" under "code".
* @param {string} wordString - From toWordString
* @param {string} phrase - Lowercased word or phrase
* @returns {boolean}
*/
function nameHasWords(wordString, phrase) {
const words = String(phrase || '')
.toLowerCase()
.split(/[^\p{L}\p{N}]+/u)
.filter(Boolean);
if (words.length === 0) return false;
const joined = words.join(' ');
return [joined, `${joined}s`, `${joined}es`].some((form) => wordString.includes(` ${form} `));
}

const normalizeCategoryToSmartFolders =
typeof FolderMatchingService?.matchCategoryToFolder === 'function'
? FolderMatchingService.matchCategoryToFolder.bind(FolderMatchingService)
Expand Down Expand Up @@ -67,6 +108,9 @@ function getIntelligentCategory(fileName, extension, smartFolders = []) {
const extNoDot = lowerExtension.replace(/^\./, '');

if (smartFolders && smartFolders.length > 0) {
// Folder words are matched against the name's whole words (see
// nameHasWords); the generic pattern table further down is unchanged.
const nameWords = toWordString(fileName);
const validFolders = smartFolders.filter(
(f) => f && f.name && typeof f.name === 'string' && f.name.trim().length > 0
);
Expand All @@ -75,11 +119,11 @@ function getIntelligentCategory(fileName, extension, smartFolders = []) {
for (const folder of validFolders) {
const folderNameLower = folder.name.toLowerCase();
let score = 0;
if (lowerFileName.includes(folderNameLower)) score += 10;
if (nameHasWords(nameWords, folderNameLower)) score += 10;
const folderWords = folderNameLower
.split(/[\s_-]+/)
.filter((/** @type {string} */ w) => w.length > 2);
for (const word of folderWords) if (lowerFileName.includes(word)) score += 8;
for (const word of folderWords) if (nameHasWords(nameWords, word)) score += 8;

// SEMANTIC EXTENSION MATCHING: Understand that "3D printing" folder means .stl files belong there
// This checks folder NAME for semantic concepts (e.g., "3D Prints" folder → .stl files)
Expand All @@ -95,7 +139,7 @@ function getIntelligentCategory(fileName, extension, smartFolders = []) {
const descWords = descLower
.split(/[\s,.-]+/)
.filter((/** @type {string} */ w) => w.length > 3);
for (const word of descWords) if (lowerFileName.includes(word)) score += 6;
for (const word of descWords) if (nameHasWords(nameWords, word)) score += 6;

// SEMANTIC EXTENSION MATCHING: Understand that "models for my Ender 3" means .stl files
// This checks folder DESCRIPTION for semantic concepts
Expand All @@ -118,7 +162,7 @@ function getIntelligentCategory(fileName, extension, smartFolders = []) {
if (Array.isArray(folder.semanticTags)) {
for (const tag of folder.semanticTags) {
const tagLower = String(tag).toLowerCase();
if (lowerFileName.includes(tagLower)) score += 5;
if (nameHasWords(nameWords, tagLower)) score += 5;
// Extension match in semantic tags (e.g., semanticTags: ["stl", "3d-model"])
if (extNoDot && (tagLower === lowerExtension || tagLower === extNoDot)) {
score += 10; // High score for explicit extension in semantic tags
Expand All @@ -129,7 +173,7 @@ function getIntelligentCategory(fileName, extension, smartFolders = []) {
if (Array.isArray(folder.keywords)) {
for (const kw of folder.keywords) {
const kwLower = String(kw).toLowerCase();
if (lowerFileName.includes(kwLower)) score += 4;
if (nameHasWords(nameWords, kwLower)) score += 4;
// Extension match in keywords (e.g., keywords: [".stl", "3mf", "gcode"])
if (extNoDot && (kwLower === lowerExtension || kwLower === extNoDot)) {
score += 12; // High score for explicit extension in keywords
Expand All @@ -142,18 +186,18 @@ function getIntelligentCategory(fileName, extension, smartFolders = []) {
if (Array.isArray(pathParts)) {
const parts = pathParts.filter((p) => p && p.length > 2);
for (const part of parts) {
if (lowerFileName.includes(part)) score += 3;
if (nameHasWords(nameWords, part)) score += 3;
}
}
}
if (folder.category) {
for (const word of folder.category.toLowerCase().split(/[\s_-]+/)) {
if (word.length > 2 && lowerFileName.includes(word)) score += 2;
if (word.length > 2 && nameHasWords(nameWords, word)) score += 2;
}
}
if (Array.isArray(folder.relatedFolders)) {
for (const relatedName of folder.relatedFolders)
if (lowerFileName.includes(String(relatedName).toLowerCase())) score += 1;
if (nameHasWords(nameWords, String(relatedName))) score += 1;
}
if (folder.confidenceScore && folder.confidenceScore > 0.8) score *= 1.2;
if (score > bestScore) {
Expand Down Expand Up @@ -633,10 +677,13 @@ function createFallbackAnalysis(params) {
fileExtension,
reason,
smartFolders = [],
confidence = 65,
confidence: requestedConfidence = FILENAME_ONLY_CONFIDENCE,
type = 'document',
options = {}
} = params;
// Nothing but the name was read, so the result never claims more than a
// filename guess is worth, whatever the caller asked for.
const confidence = Math.min(requestedConfidence, FILENAME_ONLY_CONFIDENCE);

const {
extractionMethod = 'filename_fallback',
Expand Down Expand Up @@ -698,6 +745,7 @@ function createFallbackAnalysis(params) {
}

module.exports = {
FILENAME_ONLY_CONFIDENCE,
getIntelligentCategory,
getIntelligentKeywords,
safeSuggestedName,
Expand Down
4 changes: 2 additions & 2 deletions src/main/analysis/imageAnalysis.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const {
formatOrganizationGuidanceBlock
} = require('./organizationGuidance');
const { recognizeIfAvailable } = require('../utils/tesseractUtils');
const { normalizeAnalysisResult } = require('./utils');
const { normalizeAnalysisResult, dropPromptEchoes } = require('./utils');
const { normalizeExtractedTextForStorage } = require('./analysisTextUtils');
const {
getIntelligentCategory: getIntelligentImageCategory,
Expand Down Expand Up @@ -2268,7 +2268,7 @@ async function runImageAnalysis(filePath, smartFolders, options) {
const normalizedCategory = categoryMatch.folder;
const normalized = normalizeAnalysisResult(
{
...analysis,
...dropPromptEchoes(analysis, smartFolders),
category: normalizedCategory || analysis.category,
suggestedNewFolder:
categoryMatch.suggestedNewFolder || analysis.suggestedNewFolder || undefined,
Expand Down
19 changes: 11 additions & 8 deletions src/main/analysis/mediaAnalysis.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const crypto = require('crypto');
const { createLogger } = require('../../shared/logger');
const { ERROR_CODES } = require('../../shared/errorCodes');
const { attachErrorCode } = require('../../shared/errorHandlingUtils');
const { normalizeAnalysisResult } = require('./utils');
const { normalizeAnalysisResult, dropPromptEchoes } = require('./utils');
const { isMemoryBudgetExceeded } = require('../../shared/analysisProvenance');
const { analyzeVideoWithCloud, resolveCloudFrameProvider } = require('./cloudMedia');

Expand Down Expand Up @@ -686,17 +686,20 @@ async function analyzeMediaFile(filePath, dependencies = {}, options = {}) {
? 'whisper-transcription'
: 'no-speech';

// Fields the model copied out of its prompt are cleared (and the
// confidence capped) before anything below reads them.
const answer = dropPromptEchoes(llmResult, smartFolders);
const normalized = normalizeAnalysisResult(
{
...llmResult,
...answer,
type: kind,
category: llmResult?.category || baseFallback.category,
keywords: llmResult?.keywords || baseFallback.keywords,
confidence: llmResult?.confidence ?? baseFallback.confidence,
llmSuggestedName: llmResult?.suggestedName || null,
suggestedName: llmResult?.suggestedName,
category: answer?.category || baseFallback.category,
keywords: answer?.keywords || baseFallback.keywords,
confidence: answer?.confidence ?? baseFallback.confidence,
llmSuggestedName: answer?.suggestedName || null,
suggestedName: answer?.suggestedName,
purpose:
llmResult?.purpose ||
answer?.purpose ||
(usedCloudFrames
? 'Spoken content transcribed locally; video frames analyzed by the selected cloud provider'
: hasUsableTranscript
Expand Down
Loading
Loading