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.
package/core/index.js CHANGED
@@ -27,6 +27,7 @@ const CureEngine = require('./engine/cure-engine');
27
27
  const ApplyEngine = require('./engine/apply-engine');
28
28
  const PreviewEngine = require('./engine/preview-engine');
29
29
  const DedupeEngine = require('./engine/dedupe-engine');
30
+ const PARAEngine = require('./engine/para-engine');
30
31
  const MetadataStore = require('./metadata-store');
31
32
  const strategist = require('./ai/strategist');
32
33
  const sentry = require('./ai/sentry');
@@ -119,6 +120,7 @@ module.exports = {
119
120
  ApplyEngine,
120
121
  PreviewEngine,
121
122
  DedupeEngine,
123
+ PARAEngine,
122
124
  MetadataStore,
123
125
 
124
126
  // Hardened Runtime
package/core/watcher.js CHANGED
@@ -80,8 +80,15 @@ class FileWatcher extends EventEmitter {
80
80
  maxLogEntries: options.maxLogEntries || 10000,
81
81
  autoOrganize: options.autoOrganize || false,
82
82
  organizeOptions: options.organizeOptions || {},
83
+ // PARA mode (v4.0.9): route each new file into Projects/Areas/
84
+ // Resources/Archives by actionability as it lands. Journaled.
85
+ paraMode: options.paraMode || false,
86
+ paraConfig: options.paraConfig || {},
83
87
  };
84
88
 
89
+ this._fmfs = null; // lazy — shared journal session for this watcher
90
+ this._paraEngine = null; // lazy — built on first PARA routing
91
+
85
92
  this._watchers = new Map();
86
93
  this._debounceTimers = new Map();
87
94
  this._running = false;
@@ -338,6 +345,11 @@ class FileWatcher extends EventEmitter {
338
345
  break; // First match wins
339
346
  }
340
347
 
348
+ // PARA mode — route the new file into its actionability bucket
349
+ if (this.options.paraMode && eventType === 'rename') {
350
+ this._routePara(fullPath, filename, watchDir, stats);
351
+ }
352
+
341
353
  // Auto-organize if enabled
342
354
  if (this.options.autoOrganize && eventType === 'rename') {
343
355
  // A new file appeared — organize the directory
@@ -353,6 +365,65 @@ class FileWatcher extends EventEmitter {
353
365
  }
354
366
  }
355
367
 
368
+ /** Lazy shared FileMayorFS — one journal session per watcher run. */
369
+ _getFmfs() {
370
+ if (!this._fmfs) {
371
+ const FileMayorFS = require('./fs-abstraction');
372
+ this._fmfs = new FileMayorFS();
373
+ }
374
+ return this._fmfs;
375
+ }
376
+
377
+ /**
378
+ * PARA mode: classify one new file by actionability and move it into
379
+ * Projects/Areas/Resources/Archives. Skips files already inside a
380
+ * bucket (no loops — the move itself fires another watch event).
381
+ */
382
+ _routePara(fullPath, filename, watchDir, stats) {
383
+ try {
384
+ if (!this._paraEngine) {
385
+ const PARAEngine = require('./engine/para-engine');
386
+ this._paraEngine = new PARAEngine(this.options.paraConfig);
387
+ }
388
+ const engine = this._paraEngine;
389
+ const folders = engine.config.folders;
390
+
391
+ const rel = path.relative(watchDir, fullPath);
392
+ const firstSegment = rel.split(path.sep)[0].toLowerCase();
393
+ const bucketNames = new Set(Object.values(folders).map(f => f.toLowerCase()));
394
+ if (bucketNames.has(firstSegment)) return; // already sorted
395
+
396
+ const fileInfo = {
397
+ name: filename,
398
+ path: fullPath,
399
+ category: categorize(path.extname(filename).toLowerCase()),
400
+ size: stats.size,
401
+ modified: stats.mtime,
402
+ };
403
+ const { bucket, reason } = engine._classify(fileInfo, rel, Date.now());
404
+ const destination = path.join(watchDir, folders[bucket], filename);
405
+
406
+ this._getFmfs().move(fullPath, destination)
407
+ .then(() => {
408
+ this._stats.filesActioned++;
409
+ this.emit('action', {
410
+ action: 'para-route',
411
+ source: fullPath,
412
+ destination,
413
+ bucket,
414
+ reason,
415
+ });
416
+ })
417
+ .catch((err) => {
418
+ this._stats.errors++;
419
+ this.emit('error', { action: 'para-route', path: fullPath, error: err.message });
420
+ });
421
+ } catch (err) {
422
+ this._stats.errors++;
423
+ this.emit('error', { action: 'para-route', path: fullPath, error: err.message });
424
+ }
425
+ }
426
+
356
427
  /**
357
428
  * Execute a matched rule
358
429
  */
@@ -400,13 +471,20 @@ class FileWatcher extends EventEmitter {
400
471
  break;
401
472
 
402
473
  case 'delete':
403
- fs.unlinkSync(event.path);
404
- this._stats.filesActioned++;
405
- this.emit('action', {
406
- action: 'delete',
407
- source: event.path,
408
- rule
409
- });
474
+ // Trash-based (v4.0.9): journaled and reversible with undo.
475
+ this._getFmfs().delete(event.path)
476
+ .then(() => {
477
+ this._stats.filesActioned++;
478
+ this.emit('action', {
479
+ action: 'delete',
480
+ source: event.path,
481
+ rule
482
+ });
483
+ })
484
+ .catch((err) => {
485
+ this._stats.errors++;
486
+ this.emit('error', { action: 'delete', path: event.path, error: err.message, rule });
487
+ });
410
488
  break;
411
489
 
412
490
  case 'log':
package/index.js CHANGED
@@ -51,13 +51,14 @@ const { formatBytes } = require('./core/scanner');
51
51
  const { getCategories } = require('./core/categories');
52
52
  const { checkPermissions } = require('./core/security');
53
53
  const { activateLicense, deactivateLicense, getLicenseInfo, checkProFeature, checkBulkLimit } = require('./core/license');
54
- const { ExplainEngine, CureEngine, ApplyEngine, DedupeEngine } = require('./core/index');
54
+ const { ExplainEngine, CureEngine, ApplyEngine, DedupeEngine, PARAEngine } = require('./core/index');
55
55
  const { ping: telemetryPing } = require('./core/telemetry');
56
56
 
57
57
  const { c, banner, Spinner, success, error, warn, info } = reporter;
58
58
 
59
59
  // ─── Version ──────────────────────────────────────────────────────
60
- const VERSION = '4.0.5';
60
+ // Derived from package.json so it can never drift out of sync.
61
+ const VERSION = require('./package.json').version;
61
62
 
62
63
  // ─── Path Helpers ─────────────────────────────────────────────────
63
64
 
@@ -117,6 +118,10 @@ function parseArgs(argv) {
117
118
  args.flags.depth = parseInt(raw[++i], 10);
118
119
  } else if (arg === '--naming' && raw[i + 1]) {
119
120
  args.flags.naming = raw[++i];
121
+ } else if (arg === '--archive-after' && raw[i + 1]) {
122
+ args.flags.archiveAfter = parseInt(raw[++i], 10);
123
+ } else if (arg === '--para') {
124
+ args.flags.para = true;
120
125
  } else if (arg === '--output' && raw[i + 1]) {
121
126
  args.flags.outputDir = raw[++i];
122
127
  } else if (arg === '--config' && raw[i + 1]) {
@@ -173,19 +178,20 @@ ${banner()}
173
178
  ${c('cyan', 'filemayor')} ${c('yellow', '<command>')} ${c('dim', '[path]')} ${c('dim', '[options]')}
174
179
 
175
180
  ${c('yellow', 'explain')} ${c('dim', '<path>')} Diagnose folder health
176
- ${c('yellow', 'cure')} ${c('dim', '<path>')} Plan treatment using AI
181
+ ${c('yellow', 'cure')} ${c('dim', '<path>')} Plan treatment using AI (--para for PARA buckets)
177
182
  ${c('yellow', 'apply')} Execute the cure plan
178
- ${c('yellow', 'duplicates')} ${c('dim', '<path>')} Find duplicate files
179
- ${c('yellow', 'dedupe')} ${c('dim', '<path>')} Remove duplicate files
183
+ ${c('yellow', 'duplicates')} ${c('dim', '<path>')} Find duplicates (content-hash)
184
+ ${c('yellow', 'dedupe')} ${c('dim', '<path>')} Trash duplicates (reversible with undo)
180
185
 
181
186
  ${c('bold', 'COMMANDS')}
182
187
  ${c('yellow', 'scan')} ${c('dim', '<path>')} Scan directory and report contents
183
188
  ${c('yellow', 'analyze')} ${c('dim', '<path>')} Deep analysis (duplicates, bloat, savings)
184
189
  ${c('yellow', 'organize')} ${c('dim', '<path>')} Organize files into categories
190
+ ${c('yellow', 'para')} ${c('dim', '<path>')} Sort by actionability — PARA method (then apply)
185
191
  ${c('yellow', 'clean')} ${c('dim', '<path>')} Find and remove junk files
186
- ${c('yellow', 'watch')} ${c('dim', '<path>')} Watch directory for changes
187
- ${c('yellow', 'init')} Create .filemayor.yml config
188
- ${c('yellow', 'undo')} ${c('dim', '<path>')} Undo last organization
192
+ ${c('yellow', 'watch')} ${c('dim', '<path>')} Watch directory (--para: auto-route new files)
193
+ ${c('yellow', 'init')} ${c('dim', '[--para]')} Create .filemayor.yml (--para scaffolds PARA folders)
194
+ ${c('yellow', 'undo')} Undo last session (--list · --session <id> · --all)
189
195
  ${c('yellow', 'info')} System info and version
190
196
  ${c('yellow', 'license')} ${c('dim', '<action>')} Manage license (activate|status|deactivate)
191
197
 
@@ -201,6 +207,8 @@ ${banner()}
201
207
  ${c('dim', '--config <path>')} Custom config file
202
208
  ${c('dim', '--depth <n>')} Max recursion depth
203
209
  ${c('dim', '--naming <type>')} Naming: original|category_prefix|date_prefix|clean
210
+ ${c('dim', '--archive-after <n>')} PARA: months of inactivity before a file → Archives
211
+ ${c('dim', '--para')} init: scaffold PARA folders + preset config
204
212
  ${c('dim', '--duplicates <s>')} Duplicates: rename|skip|overwrite
205
213
  ${c('dim', '--extensions <e>')} Filter by extensions (comma-separated)
206
214
  ${c('dim', '--output <path>')} Output directory for organized files
@@ -214,6 +222,8 @@ ${banner()}
214
222
  ${c('dim', '$')} filemayor scan ~/Downloads
215
223
  ${c('dim', '$')} filemayor organize ~/Downloads --dry-run
216
224
  ${c('dim', '$')} filemayor organize ~/Downloads --naming category_prefix
225
+ ${c('dim', '$')} filemayor init --para ~/Documents
226
+ ${c('dim', '$')} filemayor para ~/Documents --archive-after 18
217
227
  ${c('dim', '$')} filemayor clean /var/tmp --yes
218
228
  ${c('dim', '$')} filemayor scan . --json | jq '.files | length'
219
229
  ${c('dim', '$')} filemayor watch ~/Downloads
@@ -372,7 +382,7 @@ async function cmdClean(target, flags, config) {
372
382
  if (format === 'table' && !flags.quiet) spinner.start();
373
383
 
374
384
  try {
375
- const result = clean(targetPath, {
385
+ const result = await clean(targetPath, {
376
386
  dryRun,
377
387
  categories: flags.category || config.clean.categories,
378
388
  maxDepth: flags.depth || config.clean.maxDepth,
@@ -414,8 +424,14 @@ async function cmdWatch(target, flags, config) {
414
424
  naming: config.organize.naming,
415
425
  duplicateStrategy: config.organize.duplicates,
416
426
  },
427
+ paraMode: !!flags.para,
428
+ paraConfig: config.para || {},
417
429
  });
418
430
 
431
+ if (flags.para) {
432
+ console.log(info('PARA mode: new files route to Projects / Areas / Resources / Archives (journaled — undo works).'));
433
+ }
434
+
419
435
  watcher.on('watching', (data) => {
420
436
  console.log(success(`Watching: ${data.directory}`));
421
437
  });
@@ -454,6 +470,23 @@ async function cmdWatch(target, flags, config) {
454
470
 
455
471
  async function cmdInit(flags) {
456
472
  try {
473
+ if (flags.para) {
474
+ // PARA preset: write a tuned config AND scaffold the four folders
475
+ // with teaching READMEs so a first-timer understands the method.
476
+ const configPath = createConfigFile(process.cwd(), '.filemayor.yml', 'para');
477
+ console.log(success(`Created ${c('cyan', configPath)}`));
478
+
479
+ const engine = new PARAEngine();
480
+ const { created, folders } = engine.scaffold(process.cwd());
481
+ console.log(success(`Scaffolded PARA folders: ${c('cyan', Object.values(folders).join(', '))}`));
482
+ if (created.length) {
483
+ console.log(c('dim', ` ${created.length} item(s) created (folders + READMEs)`));
484
+ }
485
+ console.log('');
486
+ console.log(c('dim', ' Next: ') + c('cyan', 'filemayor para .') + c('dim', ' then ') + c('cyan', 'filemayor apply'));
487
+ return;
488
+ }
489
+
457
490
  const configPath = createConfigFile(process.cwd());
458
491
  console.log(success(`Created ${c('cyan', configPath)}`));
459
492
  console.log(c('dim', ' Edit this file to customize FileMayor settings'));
@@ -463,24 +496,100 @@ async function cmdInit(flags) {
463
496
  }
464
497
  }
465
498
 
499
+ // ─── PARA: sort by actionability (Projects/Areas/Resources/Archives) ──
500
+ async function cmdPara(target, flags, config) {
501
+ const targetPath = path.resolve(target || '.');
502
+
503
+ // Per-run override for the archive threshold.
504
+ const paraConfig = { ...(config.para || {}) };
505
+ if (Number.isFinite(flags.archiveAfter)) {
506
+ paraConfig.archiveAfterMonths = flags.archiveAfter;
507
+ }
508
+
509
+ const spinner = new Spinner(`Sorting ${c('cyan', targetPath)} by actionability (PARA)...`);
510
+ if (!flags.quiet) spinner.start();
511
+
512
+ try {
513
+ const engine = new PARAEngine(paraConfig);
514
+ const result = engine.plan(targetPath);
515
+ spinner.stop();
516
+
517
+ if (flags.format === 'json') {
518
+ console.log(JSON.stringify(result, null, 2));
519
+ return;
520
+ }
521
+
522
+ // Cache the plan so `filemayor apply` can execute it (same path as cure).
523
+ const cachePath = path.join(os.tmpdir(), 'filemayor-plan.json');
524
+ fs.writeFileSync(cachePath, JSON.stringify(result), 'utf8');
525
+
526
+ console.log(reporter.formatCureReport(result));
527
+
528
+ // PARA-specific breakdown — the bucket is the whole point.
529
+ const b = result.summary.buckets;
530
+ console.log(c('bold', ' Into PARA buckets:'));
531
+ console.log(` ${c('cyan', 'Projects')} ${b.projects} ${c('cyan', 'Areas')} ${b.areas} ${c('cyan', 'Resources')} ${b.resources} ${c('cyan', 'Archives')} ${b.archives}`);
532
+ for (const wn of (result.warnings || [])) {
533
+ console.log(c('dim', ` ⚠ ${wn}`));
534
+ }
535
+
536
+ if (result.plan.length === 0) {
537
+ console.log(info('Nothing to reorganise — run with a messier folder, or it is already PARA-sorted.'));
538
+ } else {
539
+ console.log('');
540
+ console.log(c('dim', ' Review the plan above, then run ') + c('cyan', 'filemayor apply') + c('dim', ' to execute (reversible with ') + c('cyan', 'filemayor undo') + c('dim', ').'));
541
+ }
542
+ } catch (err) {
543
+ spinner.fail(err.message);
544
+ process.exit(1);
545
+ }
546
+ }
547
+
466
548
  async function cmdUndo(target, flags) {
467
- const { JOURNAL_PATH, rollback: fsRollback } = require('./core/fs-abstraction');
549
+ const { JOURNAL_PATH, rollback: fsRollback, listSessions } = require('./core/fs-abstraction');
468
550
 
469
551
  if (!fs.existsSync(JOURNAL_PATH)) {
470
552
  console.log(info('No operations to undo (journal is empty)'));
471
553
  return;
472
554
  }
473
555
 
474
- const spinner = new Spinner('Undoing operations...');
556
+ // undo --list: show the undoable session history, newest first.
557
+ if (flags.list) {
558
+ const sessions = listSessions(JOURNAL_PATH);
559
+ if (sessions.length === 0) {
560
+ console.log(info('No undoable sessions — the journal has nothing left to reverse.'));
561
+ return;
562
+ }
563
+ console.log(c('bold', '\n UNDO HISTORY') + c('dim', ` — ${sessions.length} session${sessions.length === 1 ? '' : 's'}\n`));
564
+ for (const s of sessions) {
565
+ const when = s.end.replace('T', ' ').slice(0, 19);
566
+ const parts = [];
567
+ if (s.moves) parts.push(`${s.moves} move${s.moves === 1 ? '' : 's'}`);
568
+ if (s.deletes) parts.push(`${s.deletes} delete${s.deletes === 1 ? '' : 's'}`);
569
+ console.log(` ${c('cyan', s.sessionId.padEnd(10))} ${c('dim', when)} ${parts.join(', ')}`);
570
+ }
571
+ console.log(c('dim', "\n Reverse one: filemayor undo --session <id>"));
572
+ console.log(c('dim', ' Reverse all: filemayor undo --all\n'));
573
+ return;
574
+ }
575
+
576
+ const scope = flags.session
577
+ ? `session ${flags.session}`
578
+ : flags.all ? 'all sessions' : 'the most recent session';
579
+ const spinner = new Spinner(`Undoing ${scope}...`);
475
580
  spinner.start();
476
581
 
477
582
  try {
478
- const result = await fsRollback(JOURNAL_PATH);
583
+ const result = await fsRollback(JOURNAL_PATH, {
584
+ all: !!flags.all,
585
+ sessionId: typeof flags.session === 'string' ? flags.session : undefined,
586
+ });
479
587
  spinner.stop();
480
588
  if (result.count === 0) {
481
589
  console.log(info('Nothing to undo — no completed operations found in the journal'));
482
590
  } else {
483
- console.log(success(`Undone ${result.count} operation${result.count === 1 ? '' : 's'}`));
591
+ const which = result.sessionId && !flags.all ? c('dim', ` (session ${result.sessionId})`) : '';
592
+ console.log(success(`Undone ${result.count} operation${result.count === 1 ? '' : 's'}`) + which);
484
593
  }
485
594
  } catch (err) {
486
595
  spinner.fail(err.message);
@@ -510,18 +619,33 @@ async function cmdExplain(target, flags) {
510
619
 
511
620
  async function cmdCure(target, flags) {
512
621
  const targetPath = path.resolve(target || '.');
513
- const prompt = flags.prompt || 'Clean up this folder and group similar files.';
514
-
515
- if (!process.env.GEMINI_API_KEY) {
516
- console.log(error('GEMINI_API_KEY environment variable is required for AI curative features.'));
517
- process.exit(1);
622
+ const prompt = flags.prompt || (flags.para
623
+ ? 'Sort this folder by actionability using the PARA method (Projects, Areas, Resources, Archives).'
624
+ : 'Clean up this folder and group similar files.');
625
+
626
+ // Multi-provider (v4.0.9): first key present wins — Anthropic, Gemini,
627
+ // then OpenAI. With no key at all, the core planner falls back to the
628
+ // free FileMayor proxy, so there is nothing to hard-fail on here.
629
+ const providers = [
630
+ ['ANTHROPIC_API_KEY', 'anthropic'],
631
+ ['GEMINI_API_KEY', 'gemini'],
632
+ ['OPENAI_API_KEY', 'openai'],
633
+ ];
634
+ let apiKey = '';
635
+ for (const [envVar, provider] of providers) {
636
+ if (process.env[envVar]) {
637
+ process.env.FM_AI_PROVIDER = provider;
638
+ process.env.FM_AI_API_KEY = process.env[envVar];
639
+ apiKey = process.env[envVar];
640
+ break;
641
+ }
518
642
  }
519
643
 
520
644
  const spinner = new Spinner(`Consulting AI for ${c('cyan', targetPath)}...`);
521
645
  spinner.start();
522
646
 
523
647
  try {
524
- const engine = new CureEngine(targetPath, process.env.GEMINI_API_KEY);
648
+ const engine = new CureEngine(targetPath, apiKey);
525
649
  activePlanState = await engine.generatePlan(prompt);
526
650
  spinner.stop();
527
651
 
@@ -603,7 +727,7 @@ async function cmdDuplicates(target, flags) {
603
727
 
604
728
  try {
605
729
  const engine = new DedupeEngine();
606
- const result = engine.find(targetPath);
730
+ const result = await engine.find(targetPath);
607
731
  spinner.stop();
608
732
  console.log(reporter.formatDedupeReport(result));
609
733
  } catch (err) {
@@ -619,14 +743,15 @@ async function cmdDedupe(target, flags) {
619
743
 
620
744
  try {
621
745
  const engine = new DedupeEngine();
622
- const report = engine.find(targetPath);
746
+ const report = await engine.find(targetPath);
623
747
  if (report.sets === 0) {
624
748
  spinner.succeed('Total order restored: No duplicates found.');
625
749
  return;
626
750
  }
627
751
 
628
752
  const result = await engine.clean(report);
629
- spinner.succeed(`Purged ${result.deleted} duplicates. Reclaimed ${result.freedHuman}.`);
753
+ spinner.succeed(`Trashed ${result.deleted} duplicates. Reclaimed ${result.freedHuman}.`);
754
+ console.log(c('dim', ` Recoverable: files moved to FileMayor trash — 'filemayor undo' restores them.`));
630
755
  } catch (err) {
631
756
  spinner.fail(err.message);
632
757
  process.exit(1);
@@ -837,6 +962,11 @@ async function main() {
837
962
  await cmdOrganize(args.target, args.flags, config);
838
963
  break;
839
964
 
965
+ case 'para':
966
+ case 'pm':
967
+ await cmdPara(args.target, args.flags, config);
968
+ break;
969
+
840
970
  case 'clean':
841
971
  case 'cl':
842
972
  case 'c':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "filemayor",
3
- "version": "4.0.7",
3
+ "version": "4.0.9",
4
4
  "description": "FileMayor — Your intelligent filesystem clerk. CLI + Desktop + PWA.",
5
5
  "main": "index.js",
6
6
  "bin": {