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.
@@ -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
- if (!this.apiKey) {
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
- if (!this.apiKey) {
34
- throw new Error('API Key missing. Cannot interpret intent.');
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
- const telemetry = this._summarizeFiles(clusters);
38
- const systemPrompt = this._getSystemPrompt(context);
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
- const response = await this._callGemini(systemPrompt, userPrompt, telemetry);
41
- const curativePlan = this._parseResponse(response);
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
- const data = await response.json();
128
- return data?.candidates?.[0]?.content?.parts?.[0]?.text || '';
271
+ return summary;
129
272
  }
130
273
 
131
274
  /**