Skip to content

Commit fe01a30

Browse files
authored
Merge pull request #13 from ptkvaibhav/v5
2 parents 294ebcb + 2274232 commit fe01a30

11 files changed

Lines changed: 241 additions & 202 deletions

File tree

src/core/entity-detector.js

Lines changed: 57 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,72 @@
11
import fs from "node:fs/promises";
22
import path from "node:path";
3+
import { askAI } from "./ai.js";
34

4-
const ENTITY_MARKERS = [
5-
".git",
6-
"node_modules",
7-
"package.json",
8-
"Cargo.toml",
9-
"requirements.txt",
10-
".vbox",
11-
"AppxManifest.xml",
12-
"Contents/Info.plist", // macOS apps
13-
"bin/Release",
14-
"Release"
15-
];
16-
17-
const APPLICATION_NAME_PATTERNS = [
18-
/kali-linux/i,
19-
/juice-shop/i,
20-
/flutter_windows/i,
21-
/apache-maven/i,
22-
/DS4Windows/i,
23-
/DVWA/i,
24-
/VMware/i,
25-
/Ollama/i
26-
];
5+
// In-memory cache to prevent spamming the LLM with identical directory structures.
6+
const entityCache = new Map();
277

288
/**
29-
* Checks if a directory should be treated as a single cohesive entity
30-
* (like an app or project) rather than a collection of individual files.
9+
* Uses the local AI to dynamically determine if a directory should be treated
10+
* as a single cohesive entity (like an app, project, cache, or backup)
11+
* rather than a collection of individual files.
3112
*/
3213
export async function detectCohesiveEntity(absoluteDirPath) {
3314
try {
34-
const entries = await fs.readdir(absoluteDirPath);
35-
const entrySet = new Set(entries);
15+
const dirName = path.basename(absoluteDirPath);
16+
17+
// Ignore root drives or empty names
18+
if (!dirName || dirName.length <= 1) return { isEntity: false };
3619

37-
// Check for file markers
38-
for (const marker of ENTITY_MARKERS) {
39-
if (entrySet.has(marker)) {
40-
return {
41-
isEntity: true,
42-
type: "software_project",
43-
marker
44-
};
45-
}
20+
const entries = await fs.readdir(absoluteDirPath, { withFileTypes: true });
21+
22+
// Sample contents (up to 12 items) to give the AI context
23+
const sampleItems = entries.slice(0, 12).map(e => e.isDirectory() ? `[DIR] ${e.name}` : e.name);
24+
const sample = sampleItems.length > 0 ? sampleItems.join(", ") : "(empty)";
25+
26+
// Cache key based on directory name and a sample of its contents
27+
const cacheKey = `${dirName}::${sample}`;
28+
if (entityCache.has(cacheKey)) {
29+
return entityCache.get(cacheKey);
4630
}
4731

48-
// Check for application name patterns in the folder itself
49-
const dirName = path.basename(absoluteDirPath);
50-
for (const pattern of APPLICATION_NAME_PATTERNS) {
51-
if (pattern.test(dirName)) {
52-
return {
53-
isEntity: true,
54-
type: "application",
55-
pattern: pattern.toString()
56-
};
57-
}
58-
}
32+
const prompt = `You are a deterministic file system analysis AI. Your job is to decide if a directory is a "Cohesive Entity".
33+
A Cohesive Entity is a folder that should NOT have its internal files separated or moved individually.
34+
Examples of Cohesive Entities:
35+
- Software projects (contain source code, config files)
36+
- Installed applications or games
37+
- System caches, temporary folders, or metadata folders (e.g., .thumbnails, .cache, .git)
38+
- Device backups or OS images
5939
60-
return { isEntity: false };
61-
} catch {
40+
Directory Name: "${dirName}"
41+
Contents Sample: ${sample}
42+
43+
Determine if this directory is a Cohesive Entity.
44+
Return ONLY a raw JSON string matching this exact structure: {"isEntity": true/false, "type": "software|app|cache|backup|none", "reasoning": "Brief explanation"}`;
45+
46+
const response = await askAI(prompt, "You are a deterministic system AI returning valid JSON only. Do not use markdown blocks like ```json. Return raw JSON.");
47+
48+
// Clean JSON if the AI hallucinates markdown
49+
const clean = response.replace(/```(?:json)?\s*([\s\S]*?)\s*```/g, "$1").trim();
50+
51+
let result;
52+
try {
53+
result = JSON.parse(clean);
54+
} catch {
55+
// If parsing fails, use a safe default but don't cache the failure globally
56+
return { isEntity: false };
57+
}
58+
59+
const finalResult = {
60+
isEntity: !!result.isEntity,
61+
type: result.type || "unknown",
62+
marker: "AI Determined",
63+
reasoning: result.reasoning || "AI classified based on name and contents."
64+
};
65+
66+
entityCache.set(cacheKey, finalResult);
67+
return finalResult;
68+
} catch (error) {
69+
console.error("AI Entity Detection failed for", absoluteDirPath, error.message);
6270
return { isEntity: false };
6371
}
6472
}

src/organization/local-audit.js

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Catalog } from "../core/catalog.js";
22
import { fingerprintFile } from "../core/fingerprint.js";
33
import { loadEngagement } from "../engagement/parser.js";
4+
import path from "node:path";
45
import { findDuplicateGroups } from "./duplicates.js";
56
import { findIrrelevanceFindings } from "./irrelevance.js";
67
import { buildOrganizationProposals } from "./proposals.js";
@@ -57,14 +58,30 @@ export async function buildLocalAudit({
5758
continue;
5859
}
5960

60-
const profile = await fingerprintFile(scannedFile.absolutePath);
61-
62-
// Extract content for deep intelligence (PDFs, txt, etc.)
63-
if (skippedFiles.includes(scannedFile.absolutePath)) {
64-
profile.extractedText = "";
61+
let profile;
62+
if (scannedFile.isEntity) {
63+
// For folders (entities), we don't read bytes.
64+
// We use a deterministic hash based on path and type.
65+
profile = {
66+
absolutePath: scannedFile.absolutePath,
67+
baseName: path.basename(scannedFile.absolutePath),
68+
extension: "",
69+
sizeBytes: 0,
70+
modifiedAt: scannedFile.modifiedAt,
71+
sha256: `entity:${scannedFile.entityType}:${scannedFile.absolutePath}`,
72+
extractedText: ""
73+
};
6574
} else {
66-
profile.extractedText = await extractContent(scannedFile.absolutePath);
75+
profile = await fingerprintFile(scannedFile.absolutePath);
76+
77+
// Extract content for deep intelligence (PDFs, txt, etc.)
78+
if (skippedFiles.includes(scannedFile.absolutePath)) {
79+
profile.extractedText = "";
80+
} else {
81+
profile.extractedText = await extractContent(scannedFile.absolutePath);
82+
}
6783
}
84+
6885
const passwordRequired = profile.extractedText === "[[PASSWORD_REQUIRED]]";
6986

7087
if (passwordRequired) {

src/organization/purpose-rules.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,8 @@ export const PURPOSE_RULES = [
7272
}
7373
];
7474

75-
// Supports: _v1, -v1, .v1, (1), - Copy, - Copy (1)
76-
export const VERSION_PATTERN = /([._-]v(\d+))|(\((\d+)\))|([- ]Copy( \((\d+)\))?)$/i;
75+
// Supports: _v1, -v1, .v1, (1), - Copy, - Copy (1), 2024.4, 1.2.3
76+
export const VERSION_PATTERN = /([._-]v?(\d+([._]\d+)*))|(\((\d+)\))|([- ]Copy( \((\d+)\))?)$/i;
7777

7878
export const CODE_EXTENSIONS = new Set([
7979
".js", ".ts", ".jsx", ".tsx", ".py", ".java", ".c", ".cpp", ".h", ".hpp", ".cs", ".go", ".rs", ".rb", ".php", ".html", ".css", ".sql", ".sh", ".bat", ".ps1", ".yml", ".yaml", ".json", ".xml", ".md", ".sol"

src/organization/versions.js

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,11 @@ export function identifyVersionGroups(files) {
2424
archivable: []
2525
};
2626

27-
for (const [key, groupFiles] of groups.entries()) {
27+
for (const groupFiles of groups.values()) {
2828
if (groupFiles.length <= 1) continue;
2929

30-
// Sort by version descending
31-
groupFiles.sort((a, b) => b.extractedVersion - a.extractedVersion);
30+
// Sort by version descending using semantic comparison
31+
groupFiles.sort((a, b) => compareVersions(b.extractedVersion, a.extractedVersion));
3232

3333
const [latest, ...older] = groupFiles;
3434
findings.latest.push(latest);
@@ -45,23 +45,41 @@ function parseVersionInfo(fileName) {
4545
}
4646

4747
const versionSegment = match[0];
48-
let versionNumber = 0;
48+
let version = "0";
4949

50-
// Group 2: v(\d+)
50+
// Group 2: v?(\d+(\.\d+)*)
5151
if (match[2]) {
52-
versionNumber = parseInt(match[2], 10);
52+
version = match[2].replaceAll("_", ".");
5353
}
5454
// Group 4: (\d+) from (1)
5555
else if (match[4]) {
56-
versionNumber = parseInt(match[4], 10);
56+
version = match[4];
5757
}
5858
// Group 5: Copy suffix
5959
else if (match[5]) {
6060
// Group 8: (\d+) from Copy (1)
61-
versionNumber = match[8] ? parseInt(match[8], 10) : 1;
61+
version = match[8] ? match[8] : "1";
6262
}
6363

6464
const baseIdentity = fileName.replace(versionSegment, "").trim();
6565

66-
return { baseIdentity, version: versionNumber };
66+
return { baseIdentity, version };
67+
}
68+
69+
/**
70+
* Compares two version strings (e.g. "2024.4" vs "1.2.3").
71+
* Returns > 0 if v1 > v2, < 0 if v1 < v2, 0 if equal.
72+
*/
73+
function compareVersions(v1, v2) {
74+
const parts1 = String(v1).split(".").map(Number);
75+
const parts2 = String(v2).split(".").map(Number);
76+
const maxLen = Math.max(parts1.length, parts2.length);
77+
78+
for (let i = 0; i < maxLen; i++) {
79+
const num1 = parts1[i] || 0;
80+
const num2 = parts2[i] || 0;
81+
if (num1 > num2) return 1;
82+
if (num2 > num1) return -1;
83+
}
84+
return 0;
6785
}

src/server.js

Lines changed: 52 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,52 @@ export async function startServer({ port = 3030, dbPath = DEFAULT_DB_PATH } = {}
1818

1919
// API Routes
2020

21-
let currentScanProgress = { current: 0, total: 0, file: "" };
21+
let currentScanProgress = { current: 0, total: 0, file: "", status: "idle" };
22+
let scanAbortController = null;
2223

2324
app.get("/api/scan/progress", (req, res) => {
2425
res.json(currentScanProgress);
2526
});
2627

28+
app.post("/api/scan/start", async (req, res) => {
29+
const { directory, skippedFiles } = req.body;
30+
if (!directory) return res.status(400).json({ error: "Directory path required" });
31+
32+
// Prevent multiple concurrent scans for now
33+
if (currentScanProgress.status === "running") {
34+
return res.status(409).json({ error: "A scan is already in progress" });
35+
}
36+
37+
currentScanProgress = { current: 0, total: 0, file: "", status: "running" };
38+
39+
// Fire and forget the scan job
40+
(async () => {
41+
try {
42+
const audit = await buildLocalAudit({
43+
dbPath,
44+
targetDirectory: directory,
45+
skippedFiles: skippedFiles || [],
46+
onProgress: (current, total, file) => {
47+
currentScanProgress = { current, total, file, status: "running" };
48+
}
49+
});
50+
51+
if (audit.needsPassword) {
52+
currentScanProgress.status = "needs_password";
53+
currentScanProgress.passwordFile = audit.passwordFile;
54+
} else {
55+
currentScanProgress.status = "complete";
56+
}
57+
} catch (error) {
58+
console.error("Background Scan Error:", error);
59+
currentScanProgress.status = "failed";
60+
currentScanProgress.error = error.message;
61+
}
62+
})();
63+
64+
res.json({ success: true, message: "Scan started in background" });
65+
});
66+
2767
app.get("/api/select-directory", async (req, res) => {
2868
try {
2969
const { execSync } = await import("node:child_process");
@@ -42,74 +82,6 @@ export async function startServer({ port = 3030, dbPath = DEFAULT_DB_PATH } = {}
4282
}
4383
});
4484

45-
app.post("/api/add-password", async (req, res) => {
46-
try {
47-
const { password, filePath } = req.body;
48-
49-
// Test the password immediately if filePath is provided
50-
if (filePath) {
51-
const fs = await import("node:fs/promises");
52-
const pdf = (await import("pdf-parse")).default;
53-
const dataBuffer = await fs.readFile(filePath);
54-
55-
let isValid = false;
56-
57-
const originalWarn = console.warn;
58-
console.warn = () => {};
59-
try {
60-
await pdf(dataBuffer, { password });
61-
isValid = true;
62-
} catch (e) {
63-
isValid = false;
64-
} finally {
65-
console.warn = originalWarn;
66-
}
67-
68-
if (!isValid) {
69-
return res.json({ success: false, error: "Incorrect password for this file" });
70-
}
71-
}
72-
73-
const { loadConfig, saveConfig } = await import("./core/config.js");
74-
const { config, configPath } = await loadConfig();
75-
if (!config.pdfPasswords) config.pdfPasswords = [];
76-
if (!config.pdfPasswords.includes(password)) {
77-
config.pdfPasswords.push(password);
78-
await saveConfig(config, configPath);
79-
}
80-
res.json({ success: true });
81-
} catch (e) {
82-
res.status(500).json({ error: e.message });
83-
}
84-
});
85-
86-
// Step 1 & 2: Start Scan
87-
app.post("/api/scan/start", async (req, res) => {
88-
try {
89-
const { directory, skippedFiles } = req.body;
90-
if (!directory) return res.status(400).json({ error: "Directory path required" });
91-
92-
currentScanProgress = { current: 0, total: 0, file: "" };
93-
const audit = await buildLocalAudit({
94-
dbPath,
95-
targetDirectory: directory,
96-
skippedFiles: skippedFiles || [],
97-
onProgress: (current, total, file) => {
98-
currentScanProgress = { current, total, file };
99-
}
100-
});
101-
102-
if (audit.needsPassword) {
103-
res.json({ success: true, message: "Password required", needsPassword: true, passwordFile: audit.passwordFile });
104-
return;
105-
}
106-
107-
res.json({ success: true, message: "Scan complete", needsPassword: false });
108-
} catch (error) {
109-
res.status(500).json({ error: error.message });
110-
}
111-
});
112-
11385
function cleanJSON(str) {
11486
try {
11587
const match = str.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
@@ -198,6 +170,17 @@ Return ONLY a raw JSON string like {"proposedName": "file.pdf", "reasoning": "wh
198170
}
199171
});
200172

173+
app.post("/api/items/:id/reject", async (req, res) => {
174+
try {
175+
const { id } = req.params;
176+
catalog.db.prepare("UPDATE review_items SET status = 'rejected', approved = 0, updated_at = ? WHERE id = ?")
177+
.run(new Date().toISOString(), id);
178+
res.json({ success: true });
179+
} catch (error) {
180+
res.status(500).json({ error: error.message });
181+
}
182+
});
183+
201184
app.post("/api/apply", async (req, res) => {
202185
try {
203186
const result = await applyApprovedReview({ catalog });

ui/dist/assets/index-CiiTxZ18.js

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ui/dist/assets/index-Dbuct-A1.css

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ui/dist/assets/index-Ma2OvLvk.js

Lines changed: 0 additions & 17 deletions
This file was deleted.

ui/dist/assets/index-xYV3nBDd.css

Lines changed: 0 additions & 2 deletions
This file was deleted.

ui/dist/index.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
66
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
77
<title>ui</title>
8-
<script type="module" crossorigin src="/assets/index-Ma2OvLvk.js"></script>
9-
<link rel="stylesheet" crossorigin href="/assets/index-xYV3nBDd.css">
8+
<script type="module" crossorigin src="/assets/index-CiiTxZ18.js"></script>
9+
<link rel="stylesheet" crossorigin href="/assets/index-Dbuct-A1.css">
1010
</head>
1111
<body>
1212
<div id="root"></div>

0 commit comments

Comments
 (0)