filemayor 3.6.0 → 4.0.5
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/LICENSE +90 -90
- package/README.md +96 -96
- package/core/analyzer.js +163 -163
- package/core/categories.js +235 -235
- package/core/cleaner.js +527 -527
- package/core/config.js +562 -562
- package/core/fs-abstraction.js +110 -38
- package/core/index.js +135 -135
- package/core/intent-interpreter.js +209 -66
- package/core/license.js +403 -339
- package/core/organizer.js +414 -414
- package/core/reporter.js +783 -783
- package/core/scanner.js +466 -466
- package/core/security.js +370 -370
- package/core/sop-parser.js +564 -564
- package/core/telemetry.js +74 -0
- package/core/vault.js +83 -69
- package/core/watcher.js +478 -478
- package/index.js +895 -877
- package/package.json +2 -2
|
@@ -10,16 +10,187 @@
|
|
|
10
10
|
'use strict';
|
|
11
11
|
|
|
12
12
|
const path = require('path');
|
|
13
|
+
const os = require('os');
|
|
14
|
+
const crypto = require('crypto');
|
|
15
|
+
const { getLicenseInfo } = require('./license');
|
|
13
16
|
|
|
14
17
|
const GEMINI_MODEL = 'gemini-2.0-flash';
|
|
15
18
|
const GEMINI_ENDPOINT = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent`;
|
|
19
|
+
const PROXY_URL = process.env.FM_AI_PROXY_URL || 'https://filemayor.com/api/ai/plan';
|
|
20
|
+
|
|
21
|
+
// Stable device ID based on hostname + username (no PII sent to server)
|
|
22
|
+
function getDeviceId() {
|
|
23
|
+
const raw = `${os.hostname()}-${os.userInfo().username}`;
|
|
24
|
+
return crypto.createHash('sha256').update(raw).digest('hex').slice(0, 32);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getSystemPrompt() {
|
|
28
|
+
return `You are FileMayor's AI planning engine. Given a description of a messy folder and a user's intent, output a structured JSON plan.
|
|
29
|
+
|
|
30
|
+
Respond ONLY with valid JSON in this exact format:
|
|
31
|
+
{
|
|
32
|
+
"summary": "one sentence describing what the plan does",
|
|
33
|
+
"operations": [
|
|
34
|
+
{ "action": "move", "from": "relative/path/file.ext", "to": "Category/file.ext" },
|
|
35
|
+
{ "action": "rename", "from": "old_name.ext", "to": "new_name.ext" },
|
|
36
|
+
{ "action": "delete", "path": "junk_file.tmp" }
|
|
37
|
+
],
|
|
38
|
+
"warnings": ["optional list of things user should review"]
|
|
39
|
+
}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function callAi(prompt, context) {
|
|
43
|
+
const provider = process.env.FM_AI_PROVIDER || 'proxy';
|
|
44
|
+
const apiKey = process.env.FM_AI_API_KEY || process.env.GEMINI_API_KEY || '';
|
|
45
|
+
|
|
46
|
+
if (provider === 'gemini' && apiKey) {
|
|
47
|
+
return callGeminiDirect(prompt, context, apiKey);
|
|
48
|
+
}
|
|
49
|
+
if (provider === 'openai' && apiKey) {
|
|
50
|
+
return callOpenAI(prompt, context, apiKey);
|
|
51
|
+
}
|
|
52
|
+
if (provider === 'anthropic' && apiKey) {
|
|
53
|
+
return callAnthropic(prompt, context, apiKey);
|
|
54
|
+
}
|
|
55
|
+
if (provider === 'ollama') {
|
|
56
|
+
return callOllama(prompt, context);
|
|
57
|
+
}
|
|
58
|
+
// Default: use the FileMayor proxy
|
|
59
|
+
return callProxy(prompt, context);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function callProxy(prompt, context) {
|
|
63
|
+
const licenseInfo = getLicenseInfo();
|
|
64
|
+
const token = licenseInfo?.key || getDeviceId();
|
|
65
|
+
|
|
66
|
+
const res = await fetch(PROXY_URL, {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: {
|
|
69
|
+
'Content-Type': 'application/json',
|
|
70
|
+
'Authorization': `Bearer ${token}`,
|
|
71
|
+
},
|
|
72
|
+
body: JSON.stringify({
|
|
73
|
+
prompt,
|
|
74
|
+
context,
|
|
75
|
+
deviceId: getDeviceId(),
|
|
76
|
+
}),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
if (res.status === 429) {
|
|
80
|
+
const body = await res.json();
|
|
81
|
+
throw new Error(body.error || 'Monthly AI limit reached. Upgrade to Pro for unlimited access.');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!res.ok) {
|
|
85
|
+
const body = await res.json().catch(() => ({}));
|
|
86
|
+
throw new Error(body.error || `AI service error (${res.status})`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const body = await res.json();
|
|
90
|
+
if (body.error && !body.plan) throw new Error(body.error);
|
|
91
|
+
return body.plan;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function callGeminiDirect(prompt, context, apiKey) {
|
|
95
|
+
const systemPrompt = getSystemPrompt();
|
|
96
|
+
|
|
97
|
+
const body = {
|
|
98
|
+
contents: [{
|
|
99
|
+
parts: [{
|
|
100
|
+
text: `${systemPrompt}\n\nUser request: ${prompt}\n\nFolder context: ${JSON.stringify(context)}`
|
|
101
|
+
}]
|
|
102
|
+
}],
|
|
103
|
+
generationConfig: {
|
|
104
|
+
temperature: 0.1,
|
|
105
|
+
topP: 0.95,
|
|
106
|
+
maxOutputTokens: 2048,
|
|
107
|
+
responseMimeType: 'application/json',
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const url = `${GEMINI_ENDPOINT}?key=${apiKey}`;
|
|
112
|
+
const response = await fetch(url, {
|
|
113
|
+
method: 'POST',
|
|
114
|
+
headers: { 'Content-Type': 'application/json' },
|
|
115
|
+
body: JSON.stringify(body),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if (!response.ok) {
|
|
119
|
+
const err = await response.text();
|
|
120
|
+
throw new Error(`Gemini API Error: ${response.status} - ${err}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const data = await response.json();
|
|
124
|
+
const rawText = data?.candidates?.[0]?.content?.parts?.[0]?.text || '';
|
|
125
|
+
const jsonMatch = rawText.match(/\{[\s\S]*\}/);
|
|
126
|
+
if (!jsonMatch) throw new Error('No JSON found in AI response');
|
|
127
|
+
return JSON.parse(jsonMatch[0]);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function callOpenAI(prompt, context, apiKey) {
|
|
131
|
+
const systemPrompt = getSystemPrompt();
|
|
132
|
+
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
133
|
+
method: 'POST',
|
|
134
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
|
|
135
|
+
body: JSON.stringify({
|
|
136
|
+
model: 'gpt-4o',
|
|
137
|
+
response_format: { type: 'json_object' },
|
|
138
|
+
messages: [
|
|
139
|
+
{ role: 'system', content: systemPrompt },
|
|
140
|
+
{ role: 'user', content: `User request: ${prompt}\n\nFolder context: ${JSON.stringify(context)}` }
|
|
141
|
+
]
|
|
142
|
+
})
|
|
143
|
+
});
|
|
144
|
+
if (!res.ok) throw new Error(`OpenAI error: ${res.status}`);
|
|
145
|
+
const data = await res.json();
|
|
146
|
+
return JSON.parse(data.choices[0].message.content);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function callAnthropic(prompt, context, apiKey) {
|
|
150
|
+
const systemPrompt = getSystemPrompt();
|
|
151
|
+
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
152
|
+
method: 'POST',
|
|
153
|
+
headers: {
|
|
154
|
+
'Content-Type': 'application/json',
|
|
155
|
+
'x-api-key': apiKey,
|
|
156
|
+
'anthropic-version': '2023-06-01'
|
|
157
|
+
},
|
|
158
|
+
body: JSON.stringify({
|
|
159
|
+
model: 'claude-3-5-haiku-20241022',
|
|
160
|
+
max_tokens: 1024,
|
|
161
|
+
system: systemPrompt,
|
|
162
|
+
messages: [{ role: 'user', content: `User request: ${prompt}\n\nFolder context: ${JSON.stringify(context)}` }]
|
|
163
|
+
})
|
|
164
|
+
});
|
|
165
|
+
if (!res.ok) throw new Error(`Anthropic error: ${res.status}`);
|
|
166
|
+
const data = await res.json();
|
|
167
|
+
const text = data.content[0].text;
|
|
168
|
+
return JSON.parse(text.replace(/```json\n?|\n?```/g, '').trim());
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function callOllama(prompt, context) {
|
|
172
|
+
const model = process.env.FM_OLLAMA_MODEL || 'llama3';
|
|
173
|
+
const baseUrl = process.env.FM_OLLAMA_BASE_URL || 'http://localhost:11434';
|
|
174
|
+
const systemPrompt = getSystemPrompt();
|
|
175
|
+
const res = await fetch(`${baseUrl}/api/generate`, {
|
|
176
|
+
method: 'POST',
|
|
177
|
+
headers: { 'Content-Type': 'application/json' },
|
|
178
|
+
body: JSON.stringify({
|
|
179
|
+
model,
|
|
180
|
+
prompt: `${systemPrompt}\n\nUser request: ${prompt}\n\nFolder context: ${JSON.stringify(context)}`,
|
|
181
|
+
stream: false,
|
|
182
|
+
format: 'json'
|
|
183
|
+
})
|
|
184
|
+
});
|
|
185
|
+
if (!res.ok) throw new Error(`Ollama error: ${res.status}`);
|
|
186
|
+
const data = await res.json();
|
|
187
|
+
return JSON.parse(data.response);
|
|
188
|
+
}
|
|
16
189
|
|
|
17
190
|
class IntentInterpreter {
|
|
18
191
|
constructor(apiKey) {
|
|
19
192
|
this.apiKey = apiKey || process.env.GEMINI_API_KEY;
|
|
20
|
-
|
|
21
|
-
console.warn('[WARN] GEMINI_API_KEY not found. AI features will be disabled.');
|
|
22
|
-
}
|
|
193
|
+
// No warning needed — proxy works without a key
|
|
23
194
|
}
|
|
24
195
|
|
|
25
196
|
/**
|
|
@@ -30,15 +201,42 @@ class IntentInterpreter {
|
|
|
30
201
|
* @returns {Promise<Object>} The Curative Plan
|
|
31
202
|
*/
|
|
32
203
|
async interpret(userPrompt, clusters, context = {}) {
|
|
33
|
-
|
|
34
|
-
|
|
204
|
+
const telemetry = this._summarizeFiles(clusters);
|
|
205
|
+
const promptWithTelemetry = `USER INTENT: "${userPrompt}"\n\nFILES:\n${JSON.stringify(telemetry, null, 2)}`;
|
|
206
|
+
|
|
207
|
+
const proxyContext = {
|
|
208
|
+
...context,
|
|
209
|
+
fileCount: Object.values(clusters).reduce((sum, cat) => sum + (cat.count || 0), 0),
|
|
210
|
+
categories: Object.keys(clusters),
|
|
211
|
+
topFiles: Object.values(clusters).flatMap(cat => (cat.samples || []).slice(0, 3).map(s => s.name)),
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// If apiKey was explicitly passed (non-env), inject it temporarily for the direct path
|
|
215
|
+
const prevKey = process.env.GEMINI_API_KEY;
|
|
216
|
+
const prevAiKey = process.env.FM_AI_API_KEY;
|
|
217
|
+
if (this.apiKey && this.apiKey !== prevKey) {
|
|
218
|
+
process.env.FM_AI_API_KEY = this.apiKey;
|
|
35
219
|
}
|
|
36
220
|
|
|
37
|
-
|
|
38
|
-
|
|
221
|
+
let curativePlan;
|
|
222
|
+
try {
|
|
223
|
+
curativePlan = await callAi(promptWithTelemetry, proxyContext);
|
|
224
|
+
} finally {
|
|
225
|
+
// Restore original env key if we temporarily set one
|
|
226
|
+
if (this.apiKey && this.apiKey !== prevKey) {
|
|
227
|
+
if (prevAiKey === undefined) {
|
|
228
|
+
delete process.env.FM_AI_API_KEY;
|
|
229
|
+
} else {
|
|
230
|
+
process.env.FM_AI_API_KEY = prevAiKey;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
39
234
|
|
|
40
|
-
|
|
41
|
-
|
|
235
|
+
// If the proxy returned a raw plan object rather than a full curative plan shape,
|
|
236
|
+
// wrap it so downstream code always sees { narrative, plan, confidence }
|
|
237
|
+
if (curativePlan && !curativePlan.plan && !curativePlan.narrative) {
|
|
238
|
+
curativePlan = { narrative: '', plan: curativePlan, confidence: 80 };
|
|
239
|
+
}
|
|
42
240
|
|
|
43
241
|
// [Production Grade] Confidence Gating
|
|
44
242
|
if (curativePlan.confidence < 60) {
|
|
@@ -57,7 +255,7 @@ class IntentInterpreter {
|
|
|
57
255
|
*/
|
|
58
256
|
_summarizeFiles(clusters) {
|
|
59
257
|
const summary = {};
|
|
60
|
-
|
|
258
|
+
|
|
61
259
|
for (const cat in clusters) {
|
|
62
260
|
summary[cat] = {
|
|
63
261
|
count: clusters[cat].count,
|
|
@@ -69,63 +267,8 @@ class IntentInterpreter {
|
|
|
69
267
|
}))
|
|
70
268
|
};
|
|
71
269
|
}
|
|
72
|
-
|
|
73
|
-
return summary;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
_getSystemPrompt(context) {
|
|
77
|
-
return `You are FileMayor v3.2, the "Intuition & Dependency Engine".
|
|
78
|
-
Your goal is to organize files while strictly preserving the integrity of projects and collections.
|
|
79
|
-
|
|
80
|
-
INPUT:
|
|
81
|
-
1. User Intent: "${context.intent}" (Strategy: "${context.strategy}")
|
|
82
|
-
2. Telemetry: Clustered file metadata including 'ancestry' and 'bundleId'.
|
|
83
|
-
|
|
84
|
-
GOLDEN RULES:
|
|
85
|
-
- RULE OF ANCESTRY: Respect parent directory names in 'ancestry'. If a file is in /Books/ or a project folder, DO NOT scatter it based on extension alone.
|
|
86
|
-
- ATOMIC BUNDLING: Files sharing a 'bundleId' (like music stems with a project file) MUST stay together.
|
|
87
|
-
- REFINE, DON'T DESTROY: A "Refine" intent means improving structure WITHIN a domain, not extracting files OUT of it.
|
|
88
|
-
- FORMAT: Return a VALID JSON object. Use absolute paths for source.
|
|
89
|
-
|
|
90
|
-
OUTPUT SCHEMA:
|
|
91
|
-
{
|
|
92
|
-
"narrative": "A curative explanation of the structural refinement.",
|
|
93
|
-
"plan": [
|
|
94
|
-
{ "source": "absolute/path", "destination": "suggested/target", "reason": "Why?" }
|
|
95
|
-
],
|
|
96
|
-
"confidence": 0-100
|
|
97
|
-
}`;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
async _callGemini(systemPrompt, userPrompt, files) {
|
|
101
|
-
const body = {
|
|
102
|
-
contents: [{
|
|
103
|
-
parts: [{
|
|
104
|
-
text: `${systemPrompt}\n\nUSER INTENT: "${userPrompt}"\n\nFILES:\n${JSON.stringify(files, null, 2)}`
|
|
105
|
-
}]
|
|
106
|
-
}],
|
|
107
|
-
generationConfig: {
|
|
108
|
-
temperature: 0.1, // Even lower for higher determinism
|
|
109
|
-
topP: 0.95,
|
|
110
|
-
maxOutputTokens: 2048,
|
|
111
|
-
responseMimeType: "application/json" // [Production Grade] Force JSON Mode
|
|
112
|
-
}
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
const url = `${GEMINI_ENDPOINT}?key=${this.apiKey}`;
|
|
116
|
-
const response = await fetch(url, {
|
|
117
|
-
method: 'POST',
|
|
118
|
-
headers: { 'Content-Type': 'application/json' },
|
|
119
|
-
body: JSON.stringify(body),
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
if (!response.ok) {
|
|
123
|
-
const err = await response.text();
|
|
124
|
-
throw new Error(`Gemini API Error: ${response.status} - ${err}`);
|
|
125
|
-
}
|
|
126
270
|
|
|
127
|
-
|
|
128
|
-
return data?.candidates?.[0]?.content?.parts?.[0]?.text || '';
|
|
271
|
+
return summary;
|
|
129
272
|
}
|
|
130
273
|
|
|
131
274
|
/**
|