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.
- package/core/ai/planner.js +13 -2
- package/core/ai/strategist.js +15 -1
- package/core/ai/validator.js +9 -3
- package/core/cleaner.js +19 -14
- package/core/config.js +3 -3
- package/core/engine/dedupe-engine.js +111 -25
- package/core/fs-abstraction.js +147 -54
- package/core/watcher.js +85 -7
- package/index.js +68 -19
- package/package.json +1 -1
package/core/ai/planner.js
CHANGED
|
@@ -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
|
-
|
|
36
|
-
sentryData.clusters,
|
|
46
|
+
prompt,
|
|
47
|
+
sentryData.clusters,
|
|
37
48
|
context
|
|
38
49
|
);
|
|
39
50
|
}
|
package/core/ai/strategist.js
CHANGED
|
@@ -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
|
-
|
|
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')) {
|
package/core/ai/validator.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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 +
|
|
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
|
|
538
|
-
*
|
|
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
|
|
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 —
|
|
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
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/core/fs-abstraction.js
CHANGED
|
@@ -2,8 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* ═══════════════════════════════════════════════════════════════════
|
|
5
|
-
* FILEMAYOR FS
|
|
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:
|
|
26
|
-
journalPath:
|
|
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
|
-
|
|
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
|
|
118
|
+
* Safe Delete → Trash (journaled, undoable)
|
|
113
119
|
* -------------------------*/
|
|
114
120
|
async delete(target) {
|
|
115
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
150
|
+
return { target, trashFile, status: 'success' };
|
|
151
|
+
}
|
|
130
152
|
|
|
131
|
-
|
|
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
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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')
|
|
241
|
-
if (rec.status === 'done')
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
404
|
-
this.
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
|
184
|
-
${c('yellow', 'dedupe')} ${c('dim', '<path>')}
|
|
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
|
|
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')}
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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,
|
|
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(`
|
|
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);
|