filemayor 4.0.7 → 4.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -30,10 +30,21 @@ class CurativePlanner {
30
30
  cwd: process.cwd()
31
31
  };
32
32
 
33
+ // PARA (v4.0.9): give every provider the same hard contract so the
34
+ // plan lands in the four buckets and survives validation.
35
+ let prompt = rawPrompt;
36
+ if (intent.strategy === 'para_actionability') {
37
+ prompt = `${rawPrompt}\n\nSort by ACTIONABILITY using the PARA method. HARD RULES:\n` +
38
+ `- Every destination path MUST begin with exactly one of: Projects/, Areas/, Resources/, Archives/.\n` +
39
+ `- Projects = active efforts with an end; Areas = ongoing responsibilities (finances, health, home); ` +
40
+ `Resources = reference material; Archives = anything inactive or untouched for 12+ months.\n` +
41
+ `- Keep project folders intact (never split a code or media project).`;
42
+ }
43
+
33
44
  // Standardized Bridge: Telemetry is passed as clusters
34
45
  return await this.interpreter.interpret(
35
- rawPrompt,
36
- sentryData.clusters,
46
+ prompt,
47
+ sentryData.clusters,
37
48
  context
38
49
  );
39
50
  }
@@ -67,6 +67,14 @@ const ARCHETYPES = {
67
67
  nameSignals: ['book', 'chapter', 'isbn', 'edition', 'vol_', 'author'],
68
68
  strategy: 'ancestry_matching',
69
69
  hierarchy: 'Genre / Subject → Author → Title'
70
+ },
71
+ para: {
72
+ label: 'PARA (actionability)',
73
+ extSignals: [],
74
+ nameSignals: ['para'],
75
+ strategy: 'para_actionability',
76
+ hierarchy: 'Projects / Areas / Resources / Archives',
77
+ folders: ['Projects', 'Areas', 'Resources', 'Archives']
70
78
  }
71
79
  };
72
80
 
@@ -83,7 +91,13 @@ class IntentStrategist {
83
91
 
84
92
  const isRefine = p.includes('refine') || p.includes('structure') || p.includes('within');
85
93
 
86
- if (p.includes('clean') || p.includes('space') || p.includes('junk')) {
94
+ // PARA must be detected FIRST — its prompts contain "projects",
95
+ // which the technical/codebase branch below would otherwise claim.
96
+ if (p.includes('para') || p.includes('actionability') || (p.includes('areas') && p.includes('archives'))) {
97
+ intent = 'para_sort';
98
+ strategy = 'para_actionability';
99
+ archetype = 'para';
100
+ } else if (p.includes('clean') || p.includes('space') || p.includes('junk')) {
87
101
  intent = 'cleanup';
88
102
  strategy = 'retention';
89
103
  } else if (p.includes('tax') || p.includes('invoice') || p.includes('receipt')) {
@@ -29,6 +29,11 @@ class SecurityArchitect {
29
29
  }
30
30
 
31
31
  const domainTriggerFolders = ['books', 'library', 'projects', 'music', 'work', 'downloads'];
32
+ // PARA buckets (v4.0.9): a deliberate re-filing INTO one of the four
33
+ // actionability buckets is not "domain scattering" — it is the point
34
+ // of a PARA plan. Destinations rooted in a bucket bypass only the
35
+ // domain-preservation rule; path-safety and bundle locks still apply.
36
+ const paraBuckets = new Set(['projects', 'areas', 'resources', 'archives']);
32
37
  const clusters = sentryData?.clusters || {};
33
38
  const allSamples = Object.values(clusters).flatMap(c => c.samples || []);
34
39
  const homeParts = this._homePrefixParts;
@@ -54,14 +59,15 @@ class SecurityArchitect {
54
59
  const destParts = normalize(step.destination);
55
60
 
56
61
  const triggerFound = srcParts.find(p => domainTriggerFolders.includes(p));
62
+ const destIsParaBucket = destParts.some(p => paraBuckets.has(p));
57
63
 
58
- if (triggerFound) {
64
+ if (triggerFound && !destIsParaBucket) {
59
65
  // A move is valid if the EXACT trigger folder is present in the destination path
60
66
  const preserved = destParts.includes(triggerFound);
61
67
 
62
- if (!preserved && !step.reason.includes('COLLECTION_MOVE')) {
68
+ if (!preserved && !(step.reason || '').includes('COLLECTION_MOVE')) {
63
69
  console.warn(`[ARCHITECTURE] Blocked domain scattering for ${step.source} -> ${step.destination}`);
64
- return false;
70
+ return false;
65
71
  }
66
72
  }
67
73
 
package/core/cleaner.js CHANGED
@@ -394,18 +394,24 @@ class Cleaner {
394
394
  // ─── Deletion ─────────────────────────────────────────────────────
395
395
 
396
396
  /**
397
- * Delete junk files and directories
397
+ * Delete junk files and directories — trash-based and journaled, so
398
+ * `filemayor undo` restores anything removed by mistake (v4.0.9; this
399
+ * used to unlink/rmSync permanently).
398
400
  * @param {Object[]} junkItems - Items from findJunk
399
401
  * @param {Object} options - Deletion options
400
- * @returns {{ deleted: number, freed: number, freedHuman: string, errors: Object[] }}
402
+ * @returns {Promise<{ deleted: number, freed: number, freedHuman: string, errors: Object[] }>}
401
403
  */
402
- function deleteJunk(junkItems, options = {}) {
404
+ async function deleteJunk(junkItems, options = {}) {
403
405
  const {
404
406
  onProgress = null,
405
407
  onError = null,
406
408
  filterCategories = null, // Only delete these categories
409
+ fmfs = null, // Shared FileMayorFS (one undo session per clean run)
407
410
  } = options;
408
411
 
412
+ const FileMayorFS = require('./fs-abstraction');
413
+ const trashFs = fmfs || new FileMayorFS();
414
+
409
415
  let deleted = 0;
410
416
  let freed = 0;
411
417
  const errors = [];
@@ -437,7 +443,7 @@ function deleteJunk(junkItems, options = {}) {
437
443
  if (!canWrite(path.dirname(item.path))) {
438
444
  throw new Error('Permission denied');
439
445
  }
440
- fs.unlinkSync(item.path);
446
+ await trashFs.delete(item.path);
441
447
  deleted++;
442
448
  freed += item.size;
443
449
  } catch (err) {
@@ -447,7 +453,7 @@ function deleteJunk(junkItems, options = {}) {
447
453
  }
448
454
  }
449
455
 
450
- // Delete directories (sort by depth descending — deepest first)
456
+ // Trash directories (sort by depth descending — deepest first)
451
457
  const sortedDirs = dirs.sort((a, b) => {
452
458
  const depthA = a.path.split(path.sep).length;
453
459
  const depthB = b.path.split(path.sep).length;
@@ -456,11 +462,7 @@ function deleteJunk(junkItems, options = {}) {
456
462
 
457
463
  for (const dir of sortedDirs) {
458
464
  try {
459
- if (dir.type === 'empty_directory') {
460
- fs.rmdirSync(dir.path);
461
- } else {
462
- fs.rmSync(dir.path, { recursive: true, force: true });
463
- }
465
+ await trashFs.delete(dir.path);
464
466
  deleted++;
465
467
  freed += dir.size;
466
468
  } catch (err) {
@@ -473,7 +475,10 @@ function deleteJunk(junkItems, options = {}) {
473
475
  freed,
474
476
  freedHuman: formatBytes(freed),
475
477
  errors,
476
- total: toDelete.length
478
+ total: toDelete.length,
479
+ reversible: true,
480
+ trashPath: trashFs.options.trashPath,
481
+ sessionId: trashFs.sessionId
477
482
  };
478
483
  }
479
484
 
@@ -488,9 +493,9 @@ function findJunk(dirPath, options = {}) {
488
493
  }
489
494
 
490
495
  /**
491
- * Quick clean: scan + delete in one call
496
+ * Quick clean: scan + trash in one call (reversible with undo)
492
497
  */
493
- function clean(dirPath, options = {}) {
498
+ async function clean(dirPath, options = {}) {
494
499
  const { dryRun = false, filterCategories = null, ...scanOptions } = options;
495
500
 
496
501
  const result = findJunk(dirPath, scanOptions);
@@ -504,7 +509,7 @@ function clean(dirPath, options = {}) {
504
509
  };
505
510
  }
506
511
 
507
- const deleteResult = deleteJunk(result.junk, {
512
+ const deleteResult = await deleteJunk(result.junk, {
508
513
  filterCategories,
509
514
  onProgress: scanOptions.onProgress,
510
515
  onError: scanOptions.onError,
package/core/config.js CHANGED
@@ -532,18 +532,78 @@ security:
532
532
  `;
533
533
  }
534
534
 
535
+ /**
536
+ * Generate a PARA-method .filemayor.yml template.
537
+ * PARA (Projects, Areas, Resources, Archives) is an actionability-based
538
+ * organizing method; this preset tunes the sorter.
539
+ * @returns {string} YAML config template
540
+ */
541
+ function generateParaTemplate() {
542
+ return `# ═══════════════════════════════════════════════════════════
543
+ # FileMayor Configuration — PARA preset
544
+ # PARA = Projects · Areas · Resources · Archives.
545
+ # FileMayor automates it by sorting files on actionability signals.
546
+ # Run: filemayor para . (preview the plan)
547
+ # filemayor apply (execute, fully reversible)
548
+ # ═══════════════════════════════════════════════════════════
549
+
550
+ version: 1
551
+
552
+ # ─── PARA Actionability Sorter ─────────────────────────────
553
+ para:
554
+ # Files not modified in this many months are moved to Archives.
555
+ archiveAfterMonths: 12
556
+
557
+ # Files modified within this window count as active → Projects.
558
+ activeWithinMonths: 3
559
+
560
+ # Where files with no clear signal land: projects|areas|resources|archives
561
+ defaultBucket: resources
562
+
563
+ # Keep the top-level subfolder name as a grouping inside each bucket.
564
+ preserveSubfolders: true
565
+
566
+ # Bucket folder names (rename or localize freely).
567
+ folders:
568
+ projects: Projects
569
+ areas: Areas
570
+ resources: Resources
571
+ archives: Archives
572
+
573
+ # Folder/name words that signal an ongoing responsibility (Areas).
574
+ areaKeywords: [finance, finances, tax, taxes, invoice, invoices, receipt, insurance, health, medical, home, car, contract, contracts, legal, admin, hr, utilities]
575
+
576
+ # File categories that are reference material by nature (Resources).
577
+ resourceCategories: [books, fonts, design]
578
+
579
+ # Folder/name words that signal reference material (Resources).
580
+ resourceKeywords: [reference, template, templates, manual, manuals, docs, research, notes, inspiration, assets, library, guide, guides]
581
+
582
+ # Folder/name words that signal active efforts (Projects).
583
+ projectKeywords: [project, projects, client, launch, campaign, sprint, deliverable, proposal, draft]
584
+
585
+ # ─── Security ──────────────────────────────────────────────
586
+ security:
587
+ confirmDestructive: true
588
+ journalEnabled: true
589
+ journalPath: .filemayor-journal.json
590
+ `;
591
+ }
592
+
535
593
  /**
536
594
  * Create a config file in the specified directory
537
595
  * @param {string} dir - Directory to create config in
538
596
  * @param {string} filename - Config filename
597
+ * @param {string} preset - Template preset: 'default' | 'para'
539
598
  * @returns {string} Path to created file
540
599
  */
541
- function createConfigFile(dir = process.cwd(), filename = '.filemayor.yml') {
600
+ function createConfigFile(dir = process.cwd(), filename = '.filemayor.yml', preset = 'default') {
542
601
  const configPath = path.join(dir, filename);
543
602
  if (fs.existsSync(configPath)) {
544
603
  throw new Error(`Config file already exists: ${configPath}`);
545
604
  }
546
- fs.writeFileSync(configPath, generateTemplate(), 'utf8');
605
+ const template = preset === 'para' ? generateParaTemplate() : generateTemplate();
606
+ fs.writeFileSync(configPath, template, 'utf8');
547
607
  return configPath;
548
608
  }
549
609
 
@@ -558,5 +618,6 @@ module.exports = {
558
618
  expandVariables,
559
619
  parseSimpleYaml,
560
620
  generateTemplate,
621
+ generateParaTemplate,
561
622
  createConfigFile
562
623
  };
@@ -3,43 +3,125 @@
3
3
  const { scan, formatBytes } = require('../scanner');
4
4
  const crypto = require('crypto');
5
5
  const fs = require('fs');
6
+ const fsp = require('fs').promises;
7
+ const FileMayorFS = require('../fs-abstraction');
8
+
9
+ const PARTIAL_BYTES = 64 * 1024; // head sample for the cheap pass
10
+ const HASH_CONCURRENCY = 8; // parallel file reads for hashing
11
+
12
+ /** Run an async mapper over items with bounded concurrency. */
13
+ async function pooled(items, limit, fn) {
14
+ const results = new Array(items.length);
15
+ let next = 0;
16
+ async function worker() {
17
+ while (next < items.length) {
18
+ const i = next++;
19
+ results[i] = await fn(items[i], i);
20
+ }
21
+ }
22
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
23
+ return results;
24
+ }
25
+
26
+ /** SHA-1 of the first PARTIAL_BYTES of a file (cheap collision filter). */
27
+ async function partialHash(filePath) {
28
+ const fd = await fsp.open(filePath, 'r');
29
+ try {
30
+ const buf = Buffer.alloc(PARTIAL_BYTES);
31
+ const { bytesRead } = await fd.read(buf, 0, PARTIAL_BYTES, 0);
32
+ return crypto.createHash('sha1').update(buf.subarray(0, bytesRead)).digest('hex');
33
+ } finally {
34
+ await fd.close();
35
+ }
36
+ }
37
+
38
+ /** Streaming SHA-1 of the whole file — constant memory at any size. */
39
+ function fullHash(filePath) {
40
+ return new Promise((resolve, reject) => {
41
+ const hash = crypto.createHash('sha1');
42
+ fs.createReadStream(filePath)
43
+ .on('data', (chunk) => hash.update(chunk))
44
+ .on('end', () => resolve(hash.digest('hex')))
45
+ .on('error', reject);
46
+ });
47
+ }
6
48
 
7
49
  /**
8
- * DedupeEngine — specialized engine for finding and removing duplicates
50
+ * DedupeEngine — finds and removes duplicate files.
51
+ *
52
+ * v4.0.9: detection is genuinely content-based (it used to match on
53
+ * size+name, despite the docs saying "hash-based"). Three-stage pipeline
54
+ * keeps it fast on huge folders:
55
+ * 1. size buckets — only sizes shared by 2+ files go further
56
+ * 2. partial hash — first 64 KB, eliminates most collisions cheaply
57
+ * 3. full hash — streamed, only for files whose heads collide
58
+ * Removal is trash-based and journaled: `filemayor undo` restores.
9
59
  */
10
60
  class DedupeEngine {
11
- constructor() {}
61
+ constructor(options = {}) {
62
+ this.fmfs = options.fmfs || new FileMayorFS(options.fsOptions || {});
63
+ }
12
64
 
13
65
  /**
14
- * Find duplicates in a directory
15
- * @param {string} dirPath
16
- * @returns {Object} Duplicate report
66
+ * Find duplicates in a directory (content-hash based).
67
+ * @param {string} dirPath
68
+ * @returns {Promise<Object>} Duplicate report
17
69
  */
18
- find(dirPath) {
70
+ async find(dirPath) {
19
71
  const result = scan(dirPath, { maxDepth: 10 });
20
- const map = new Map();
21
- const duplicates = [];
22
- let totalWasted = 0;
23
72
 
73
+ // Stage 1 — size buckets. A file can only duplicate a same-sized file.
74
+ const bySize = new Map();
24
75
  for (const file of result.files) {
25
- // Heuristic-based match (size + name)
26
- // In a real pro version, we might do checksums here,
27
- // but for a fast CLI, this is the "Viral" speed way.
28
- const key = `${file.size}-${file.name}`;
29
- if (map.has(key)) {
30
- const original = map.get(key);
31
- duplicates.push({
32
- original,
33
- duplicate: file
34
- });
35
- totalWasted += file.size;
36
- } else {
37
- map.set(key, file);
76
+ if (!file.size) continue; // zero-byte files: skip (all "identical", rarely useful)
77
+ if (!bySize.has(file.size)) bySize.set(file.size, []);
78
+ bySize.get(file.size).push(file);
79
+ }
80
+ const candidates = [...bySize.values()].filter(g => g.length > 1).flat();
81
+
82
+ // Stage 2 — partial hash over candidates only.
83
+ const partials = await pooled(candidates, HASH_CONCURRENCY, async (f) => {
84
+ try { return `${f.size}:${await partialHash(f.path)}`; } catch { return null; }
85
+ });
86
+ const byPartial = new Map();
87
+ candidates.forEach((f, i) => {
88
+ const key = partials[i];
89
+ if (!key) return;
90
+ if (!byPartial.has(key)) byPartial.set(key, []);
91
+ byPartial.get(key).push(f);
92
+ });
93
+ const finalists = [...byPartial.values()].filter(g => g.length > 1).flat();
94
+
95
+ // Stage 3 — full streamed hash for head-colliders.
96
+ const fulls = await pooled(finalists, HASH_CONCURRENCY, async (f) => {
97
+ try { return `${f.size}:${await fullHash(f.path)}`; } catch { return null; }
98
+ });
99
+ const byFull = new Map();
100
+ finalists.forEach((f, i) => {
101
+ const key = fulls[i];
102
+ if (!key) return;
103
+ if (!byFull.has(key)) byFull.set(key, []);
104
+ byFull.get(key).push(f);
105
+ });
106
+
107
+ const duplicates = [];
108
+ let totalWasted = 0;
109
+ for (const group of byFull.values()) {
110
+ if (group.length < 2) continue;
111
+ // Keep the oldest copy as the original; the rest are duplicates.
112
+ group.sort((a, b) => new Date(a.modified) - new Date(b.modified));
113
+ const original = group[0];
114
+ for (const dupe of group.slice(1)) {
115
+ duplicates.push({ original, duplicate: dupe });
116
+ totalWasted += dupe.size;
38
117
  }
39
118
  }
40
119
 
41
120
  return {
42
121
  path: dirPath,
122
+ method: 'content-hash',
123
+ scanned: result.files.length,
124
+ hashed: finalists.length,
43
125
  sets: duplicates.length,
44
126
  totalWasted,
45
127
  totalWastedHuman: formatBytes(totalWasted),
@@ -48,7 +130,8 @@ class DedupeEngine {
48
130
  }
49
131
 
50
132
  /**
51
- * Remove duplicates based on a report
133
+ * Remove duplicates from a report. Trash-based: every removal is
134
+ * journaled and reversible with `filemayor undo`.
52
135
  */
53
136
  async clean(report) {
54
137
  let deleted = 0;
@@ -57,7 +140,7 @@ class DedupeEngine {
57
140
 
58
141
  for (const set of report.duplicates) {
59
142
  try {
60
- fs.unlinkSync(set.duplicate.path);
143
+ await this.fmfs.delete(set.duplicate.path);
61
144
  deleted++;
62
145
  freed += set.duplicate.size;
63
146
  } catch (err) {
@@ -69,7 +152,10 @@ class DedupeEngine {
69
152
  deleted,
70
153
  freed,
71
154
  freedHuman: formatBytes(freed),
72
- errors
155
+ errors,
156
+ reversible: true,
157
+ trashPath: this.fmfs.options.trashPath,
158
+ sessionId: this.fmfs.sessionId
73
159
  };
74
160
  }
75
161
  }