filemayor 4.0.8 → 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
@@ -534,14 +534,14 @@ security:
534
534
 
535
535
  /**
536
536
  * Generate a PARA-method .filemayor.yml template.
537
- * PARA (Projects, Areas, Resources, Archives) is a method by Tiago Forte;
538
- * FileMayor automates it — this preset tunes the actionability sorter.
537
+ * PARA (Projects, Areas, Resources, Archives) is an actionability-based
538
+ * organizing method; this preset tunes the sorter.
539
539
  * @returns {string} YAML config template
540
540
  */
541
541
  function generateParaTemplate() {
542
542
  return `# ═══════════════════════════════════════════════════════════
543
543
  # FileMayor Configuration — PARA preset
544
- # PARA = Projects · Areas · Resources · Archives (method by Tiago Forte).
544
+ # PARA = Projects · Areas · Resources · Archives.
545
545
  # FileMayor automates it by sorting files on actionability signals.
546
546
  # Run: filemayor para . (preview the plan)
547
547
  # filemayor apply (execute, fully reversible)
@@ -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
  }
@@ -2,8 +2,13 @@
2
2
 
3
3
  /**
4
4
  * ═══════════════════════════════════════════════════════════════════
5
- * FILEMAYOR FS v3.0 — INTENT-READY FILESYSTEM ABSTRACTION
5
+ * FILEMAYOR FS v4.1 — INTENT-READY FILESYSTEM ABSTRACTION
6
6
  * Async, safe, transactional, and fully rollbackable.
7
+ *
8
+ * v4.0.9: deletes are trash-based and journaled (undo restores them),
9
+ * the trash lives in the user's home dir (survives reboots — it used
10
+ * to sit in os.tmpdir()), and every journal entry carries a sessionId
11
+ * so `undo` can target a specific past session.
7
12
  * ═══════════════════════════════════════════════════════════════════
8
13
  */
9
14
 
@@ -17,15 +22,23 @@ const crypto = require('crypto');
17
22
  const { validatePath, isFileSafe, isDirSafe, canRead, canWrite } = require('./security');
18
23
  const { formatBytes } = require('./scanner');
19
24
 
25
+ const JOURNAL_PATH = path.join(os.homedir(), '.filemayor-master-journal.ndjson');
26
+ // Durable, FileMayor-managed trash. NOT os.tmpdir() — that gets wiped on
27
+ // reboot, which would silently break the "deletions are reversible" promise.
28
+ const TRASH_PATH = path.join(os.homedir(), '.filemayor-trash');
29
+
20
30
  class FileMayorFS {
21
31
  constructor(options = {}) {
22
32
  this.options = {
23
33
  useJournal: true,
24
34
  dryRun: false,
25
- trashPath: path.join(os.tmpdir(), 'filemayor_trash'),
26
- journalPath: path.join(os.homedir(), '.filemayor-master-journal.ndjson'),
35
+ trashPath: TRASH_PATH,
36
+ journalPath: JOURNAL_PATH,
27
37
  ...options
28
38
  };
39
+ // One session per FileMayorFS instance — every CLI/MCP/desktop
40
+ // operation batch gets its own id, so undo can address it later.
41
+ this.sessionId = crypto.randomUUID().slice(0, 8);
29
42
  this.sessionJournal = [];
30
43
  this.stats = {
31
44
  opsSucceeded: 0,
@@ -81,14 +94,7 @@ class FileMayorFS {
81
94
  const opId = crypto.randomUUID();
82
95
  await this._journalPending(opId, source, destination, 'move', snapshot);
83
96
 
84
- try {
85
- await fs.rename(source, destination);
86
- } catch (err) {
87
- if (err.code === 'EXDEV') {
88
- await fs.copyFile(source, destination);
89
- await fs.unlink(source);
90
- } else throw err;
91
- }
97
+ await this._rename(source, destination);
92
98
 
93
99
  // WAL: record completion AFTER the operation
94
100
  await this._journalDone(opId);
@@ -109,26 +115,56 @@ class FileMayorFS {
109
115
  }
110
116
 
111
117
  /** ------------------------
112
- * Safe Delete with Trash
118
+ * Safe Delete Trash (journaled, undoable)
113
119
  * -------------------------*/
114
120
  async delete(target) {
115
- const check = isFileSafe(target);
121
+ let isDir = false;
122
+ try {
123
+ isDir = (await fs.stat(target)).isDirectory();
124
+ } catch { /* stat below via snapshot; safety checks will surface errors */ }
125
+
126
+ const check = isDir ? isDirSafe(target) : isFileSafe(target);
116
127
  if (!check.safe) throw new Error(`Target unsafe: ${check.reason}`);
117
128
 
118
129
  const snapshot = await this.createSnapshot(target);
119
130
 
120
131
  if (this.options.dryRun) return { target, status: 'dry-run' };
121
132
 
122
- const trashFile = path.join(this.options.trashPath, path.basename(target) + '-' + Date.now());
123
- await fs.rename(target, trashFile);
133
+ // Unique trash destination timestamp + random slug prevents
134
+ // collisions when many same-named files are trashed in one batch.
135
+ const slug = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
136
+ const trashFile = path.join(this.options.trashPath, `${slug}-${path.basename(target)}`);
137
+
138
+ // WAL: journal BEFORE the move so a crash mid-delete is recoverable.
139
+ // Journaled as a normal source→destination pair: rollback's generic
140
+ // "rename(destination, source)" restores the file from the trash.
141
+ const opId = crypto.randomUUID();
142
+ await this._journalPending(opId, target, trashFile, 'delete', snapshot);
143
+ await this._rename(target, trashFile);
144
+ await this._journalDone(opId);
124
145
 
125
146
  this.stats.opsSucceeded++;
126
147
  this.stats.filesDeleted++;
127
148
  this.stats.totalBytesProcessed += snapshot.size || 0;
128
149
 
129
- await this._logToJournal(target, trashFile, 'delete', snapshot);
150
+ return { target, trashFile, status: 'success' };
151
+ }
130
152
 
131
- return { target, status: 'success' };
153
+ /** Rename with cross-device fallback (files and directories). */
154
+ async _rename(source, destination) {
155
+ try {
156
+ await fs.rename(source, destination);
157
+ } catch (err) {
158
+ if (err.code !== 'EXDEV') throw err;
159
+ const st = await fs.stat(source);
160
+ if (st.isDirectory()) {
161
+ await fs.cp(source, destination, { recursive: true });
162
+ await fs.rm(source, { recursive: true, force: true });
163
+ } else {
164
+ await fs.copyFile(source, destination);
165
+ await fs.unlink(source);
166
+ }
167
+ }
132
168
  }
133
169
 
134
170
  /** ------------------------
@@ -144,15 +180,10 @@ class FileMayorFS {
144
180
  }
145
181
  }
146
182
 
147
- async _logToJournal(source, destination, action, snapshot) {
148
- // WAL: record intent BEFORE operation (called pre-move),
149
- // then update to 'done' AFTER (called post-move).
150
- // This is handled by _journalPending / _journalDone.
151
- }
152
-
153
183
  async _journalPending(id, source, destination, action, snapshot) {
154
184
  const entry = {
155
185
  id,
186
+ sessionId: this.sessionId,
156
187
  timestamp: new Date().toISOString(),
157
188
  action,
158
189
  source,
@@ -170,32 +201,20 @@ class FileMayorFS {
170
201
  }
171
202
 
172
203
  /** ------------------------
173
- * Rollback
204
+ * Crash-recovery rollback: reverses operations that never completed
205
+ * (pending but not done). For undoing COMPLETED sessions, use the
206
+ * standalone rollback()/listSessions() below.
174
207
  * -------------------------*/
175
208
  async rollback() {
176
- const entries = new Map();
177
- const done = new Set();
178
-
179
- if (fssync.existsSync(this.options.journalPath)) {
180
- const lines = fssync.readFileSync(this.options.journalPath, 'utf8').split('\n');
181
- for (const line of lines) {
182
- if (!line.trim()) continue;
183
- try {
184
- const rec = JSON.parse(line);
185
- if (rec.status === 'pending') entries.set(rec.id, rec);
186
- if (rec.status === 'done') done.add(rec.id);
187
- } catch { /* skip malformed lines */ }
188
- }
189
- }
209
+ const { entries, done, rolledBack } = _readJournal(this.options.journalPath);
190
210
 
191
211
  // Add session-only pending entries not yet on disk
192
212
  for (const e of this.sessionJournal) {
193
213
  if (!entries.has(e.id)) entries.set(e.id, e);
194
214
  }
195
215
 
196
- // Reverse-order rollback of moves that never completed
197
216
  const toRollback = [...entries.values()]
198
- .filter(e => !done.has(e.id))
217
+ .filter(e => !done.has(e.id) && !rolledBack.has(e.id))
199
218
  .sort((a, b) => b.timestamp.localeCompare(a.timestamp));
200
219
 
201
220
  console.log(`[SYS] Rolling back ${toRollback.length} incomplete operations...`);
@@ -220,16 +239,11 @@ class FileMayorFS {
220
239
  }
221
240
  }
222
241
 
223
- const JOURNAL_PATH = path.join(os.homedir(), '.filemayor-master-journal.ndjson');
224
-
225
- /**
226
- * Standalone rollback function that reads the master journal on disk
227
- * and reverses all completed operations (status === 'done').
228
- * Returns { reversed: Array<entry>, count: number }.
229
- */
230
- async function rollback(journalPath) {
242
+ /** Parse the NDJSON master journal into pending entries + status sets. */
243
+ function _readJournal(journalPath) {
231
244
  const entries = new Map();
232
245
  const done = new Set();
246
+ const rolledBack = new Set();
233
247
 
234
248
  if (fssync.existsSync(journalPath)) {
235
249
  const lines = fssync.readFileSync(journalPath, 'utf8').split('\n');
@@ -237,21 +251,76 @@ async function rollback(journalPath) {
237
251
  if (!line.trim()) continue;
238
252
  try {
239
253
  const rec = JSON.parse(line);
240
- if (rec.status === 'pending') entries.set(rec.id, rec);
241
- if (rec.status === 'done') done.add(rec.id);
254
+ if (rec.status === 'pending') entries.set(rec.id, rec);
255
+ if (rec.status === 'done') done.add(rec.id);
256
+ if (rec.status === 'rolled-back') rolledBack.add(rec.id);
242
257
  } catch { /* skip malformed lines */ }
243
258
  }
244
259
  }
260
+ return { entries, done, rolledBack };
261
+ }
245
262
 
246
- // Rollback moves that completed successfully (status === 'done')
247
- const toRollback = [...entries.values()]
248
- .filter(e => done.has(e.id))
263
+ /** Completed-and-not-yet-undone entries, newest first. */
264
+ function _completedEntries(journalPath) {
265
+ const { entries, done, rolledBack } = _readJournal(journalPath);
266
+ return [...entries.values()]
267
+ .filter(e => done.has(e.id) && !rolledBack.has(e.id))
249
268
  .sort((a, b) => b.timestamp.localeCompare(a.timestamp));
269
+ }
270
+
271
+ /**
272
+ * List undoable sessions from the master journal, newest first.
273
+ * Entries written before v4.0.9 (no sessionId) group under 'legacy'.
274
+ * @returns {Array<{sessionId, start, end, ops, moves, deletes}>}
275
+ */
276
+ function listSessions(journalPath = JOURNAL_PATH) {
277
+ const sessions = new Map();
278
+ for (const e of _completedEntries(journalPath)) {
279
+ const sid = e.sessionId || 'legacy';
280
+ if (!sessions.has(sid)) {
281
+ sessions.set(sid, { sessionId: sid, start: e.timestamp, end: e.timestamp, ops: 0, moves: 0, deletes: 0 });
282
+ }
283
+ const s = sessions.get(sid);
284
+ s.ops++;
285
+ if (e.action === 'delete') s.deletes++;
286
+ else s.moves++;
287
+ if (e.timestamp < s.start) s.start = e.timestamp;
288
+ if (e.timestamp > s.end) s.end = e.timestamp;
289
+ }
290
+ return [...sessions.values()].sort((a, b) => b.end.localeCompare(a.end));
291
+ }
292
+
293
+ /**
294
+ * Undo completed operations from the master journal.
295
+ * @param {string} journalPath
296
+ * @param {Object} [options]
297
+ * @param {boolean} [options.all] Undo every completed session (pre-4.0.9 behaviour).
298
+ * @param {string} [options.sessionId] Undo one specific session.
299
+ * Default (no options): undo only the MOST RECENT session.
300
+ * Returns { reversed, count, sessionId }.
301
+ */
302
+ async function rollback(journalPath = JOURNAL_PATH, options = {}) {
303
+ let toRollback = _completedEntries(journalPath);
304
+ let targetSession = null;
305
+
306
+ if (options.sessionId) {
307
+ targetSession = options.sessionId;
308
+ toRollback = toRollback.filter(e => (e.sessionId || 'legacy') === options.sessionId);
309
+ } else if (!options.all) {
310
+ // Latest session only — "undo" means "undo what just happened",
311
+ // not "unwind everything FileMayor has ever done".
312
+ const sessions = listSessions(journalPath);
313
+ if (sessions.length > 0) {
314
+ targetSession = sessions[0].sessionId;
315
+ toRollback = toRollback.filter(e => (e.sessionId || 'legacy') === targetSession);
316
+ }
317
+ }
250
318
 
251
319
  const reversed = [];
252
320
  for (const entry of toRollback) {
253
321
  try {
254
322
  if (fssync.existsSync(entry.destination)) {
323
+ await fs.mkdir(path.dirname(entry.source), { recursive: true });
255
324
  await fs.rename(entry.destination, entry.source);
256
325
  fssync.appendFileSync(journalPath,
257
326
  JSON.stringify({ id: entry.id, status: 'rolled-back', rolledBackAt: new Date().toISOString() }) + '\n',
@@ -263,9 +332,33 @@ async function rollback(journalPath) {
263
332
  }
264
333
  }
265
334
 
266
- return { reversed, count: reversed.length };
335
+ return { reversed, count: reversed.length, sessionId: targetSession };
336
+ }
337
+
338
+ /**
339
+ * Permanently remove trashed files older than `olderThanDays`.
340
+ * Never wired to an automatic timer — explicit calls only.
341
+ */
342
+ async function emptyTrash(olderThanDays = 30, trashPath = TRASH_PATH) {
343
+ let removed = 0;
344
+ const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1000;
345
+ if (!fssync.existsSync(trashPath)) return { removed };
346
+ for (const name of await fs.readdir(trashPath)) {
347
+ const full = path.join(trashPath, name);
348
+ try {
349
+ const st = await fs.stat(full);
350
+ if (st.mtimeMs < cutoff) {
351
+ await fs.rm(full, { recursive: true, force: true });
352
+ removed++;
353
+ }
354
+ } catch { /* skip entries we cannot stat */ }
355
+ }
356
+ return { removed };
267
357
  }
268
358
 
269
359
  module.exports = FileMayorFS;
270
360
  module.exports.JOURNAL_PATH = JOURNAL_PATH;
361
+ module.exports.TRASH_PATH = TRASH_PATH;
271
362
  module.exports.rollback = rollback;
363
+ module.exports.listSessions = listSessions;
364
+ module.exports.emptyTrash = emptyTrash;
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
@@ -178,10 +178,10 @@ ${banner()}
178
178
  ${c('cyan', 'filemayor')} ${c('yellow', '<command>')} ${c('dim', '[path]')} ${c('dim', '[options]')}
179
179
 
180
180
  ${c('yellow', 'explain')} ${c('dim', '<path>')} Diagnose folder health
181
- ${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)
182
182
  ${c('yellow', 'apply')} Execute the cure plan
183
- ${c('yellow', 'duplicates')} ${c('dim', '<path>')} Find duplicate files
184
- ${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)
185
185
 
186
186
  ${c('bold', 'COMMANDS')}
187
187
  ${c('yellow', 'scan')} ${c('dim', '<path>')} Scan directory and report contents
@@ -189,9 +189,9 @@ ${banner()}
189
189
  ${c('yellow', 'organize')} ${c('dim', '<path>')} Organize files into categories
190
190
  ${c('yellow', 'para')} ${c('dim', '<path>')} Sort by actionability — PARA method (then apply)
191
191
  ${c('yellow', 'clean')} ${c('dim', '<path>')} Find and remove junk files
192
- ${c('yellow', 'watch')} ${c('dim', '<path>')} Watch directory for changes
192
+ ${c('yellow', 'watch')} ${c('dim', '<path>')} Watch directory (--para: auto-route new files)
193
193
  ${c('yellow', 'init')} ${c('dim', '[--para]')} Create .filemayor.yml (--para scaffolds PARA folders)
194
- ${c('yellow', 'undo')} ${c('dim', '<path>')} Undo last organization
194
+ ${c('yellow', 'undo')} Undo last session (--list · --session <id> · --all)
195
195
  ${c('yellow', 'info')} System info and version
196
196
  ${c('yellow', 'license')} ${c('dim', '<action>')} Manage license (activate|status|deactivate)
197
197
 
@@ -382,7 +382,7 @@ async function cmdClean(target, flags, config) {
382
382
  if (format === 'table' && !flags.quiet) spinner.start();
383
383
 
384
384
  try {
385
- const result = clean(targetPath, {
385
+ const result = await clean(targetPath, {
386
386
  dryRun,
387
387
  categories: flags.category || config.clean.categories,
388
388
  maxDepth: flags.depth || config.clean.maxDepth,
@@ -424,8 +424,14 @@ async function cmdWatch(target, flags, config) {
424
424
  naming: config.organize.naming,
425
425
  duplicateStrategy: config.organize.duplicates,
426
426
  },
427
+ paraMode: !!flags.para,
428
+ paraConfig: config.para || {},
427
429
  });
428
430
 
431
+ if (flags.para) {
432
+ console.log(info('PARA mode: new files route to Projects / Areas / Resources / Archives (journaled — undo works).'));
433
+ }
434
+
429
435
  watcher.on('watching', (data) => {
430
436
  console.log(success(`Watching: ${data.directory}`));
431
437
  });
@@ -540,23 +546,50 @@ async function cmdPara(target, flags, config) {
540
546
  }
541
547
 
542
548
  async function cmdUndo(target, flags) {
543
- const { JOURNAL_PATH, rollback: fsRollback } = require('./core/fs-abstraction');
549
+ const { JOURNAL_PATH, rollback: fsRollback, listSessions } = require('./core/fs-abstraction');
544
550
 
545
551
  if (!fs.existsSync(JOURNAL_PATH)) {
546
552
  console.log(info('No operations to undo (journal is empty)'));
547
553
  return;
548
554
  }
549
555
 
550
- 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}...`);
551
580
  spinner.start();
552
581
 
553
582
  try {
554
- 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
+ });
555
587
  spinner.stop();
556
588
  if (result.count === 0) {
557
589
  console.log(info('Nothing to undo — no completed operations found in the journal'));
558
590
  } else {
559
- 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);
560
593
  }
561
594
  } catch (err) {
562
595
  spinner.fail(err.message);
@@ -586,18 +619,33 @@ async function cmdExplain(target, flags) {
586
619
 
587
620
  async function cmdCure(target, flags) {
588
621
  const targetPath = path.resolve(target || '.');
589
- const prompt = flags.prompt || 'Clean up this folder and group similar files.';
590
-
591
- if (!process.env.GEMINI_API_KEY) {
592
- console.log(error('GEMINI_API_KEY environment variable is required for AI curative features.'));
593
- 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
+ }
594
642
  }
595
643
 
596
644
  const spinner = new Spinner(`Consulting AI for ${c('cyan', targetPath)}...`);
597
645
  spinner.start();
598
646
 
599
647
  try {
600
- const engine = new CureEngine(targetPath, process.env.GEMINI_API_KEY);
648
+ const engine = new CureEngine(targetPath, apiKey);
601
649
  activePlanState = await engine.generatePlan(prompt);
602
650
  spinner.stop();
603
651
 
@@ -679,7 +727,7 @@ async function cmdDuplicates(target, flags) {
679
727
 
680
728
  try {
681
729
  const engine = new DedupeEngine();
682
- const result = engine.find(targetPath);
730
+ const result = await engine.find(targetPath);
683
731
  spinner.stop();
684
732
  console.log(reporter.formatDedupeReport(result));
685
733
  } catch (err) {
@@ -695,14 +743,15 @@ async function cmdDedupe(target, flags) {
695
743
 
696
744
  try {
697
745
  const engine = new DedupeEngine();
698
- const report = engine.find(targetPath);
746
+ const report = await engine.find(targetPath);
699
747
  if (report.sets === 0) {
700
748
  spinner.succeed('Total order restored: No duplicates found.');
701
749
  return;
702
750
  }
703
751
 
704
752
  const result = await engine.clean(report);
705
- 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.`));
706
755
  } catch (err) {
707
756
  spinner.fail(err.message);
708
757
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "filemayor",
3
- "version": "4.0.8",
3
+ "version": "4.0.9",
4
4
  "description": "FileMayor — Your intelligent filesystem clerk. CLI + Desktop + PWA.",
5
5
  "main": "index.js",
6
6
  "bin": {