@buda-ai/connector 0.1.1 → 0.1.2
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/README.md +22 -1
- package/agent-templates/scripts/skill-scanner.sh +125 -0
- package/agent-templates/shared/AGENTS.md +220 -0
- package/agent-templates/shared/BOOTSTRAP.md +15 -0
- package/agent-templates/shared/HEARTBEAT.md +5 -0
- package/agent-templates/shared/IDENTITY.md +29 -0
- package/agent-templates/shared/MEMORY.md +19 -0
- package/agent-templates/shared/SOUL.md +43 -0
- package/agent-templates/shared/TOOLS.md +85 -0
- package/agent-templates/shared/USER.md +23 -0
- package/agent-templates/system-skills/app-builder/README.md +8 -0
- package/agent-templates/system-skills/app-builder/SKILL.md +678 -0
- package/agent-templates/system-skills/app-builder/buda.app.schema.json +165 -0
- package/agent-templates/system-skills/app-builder/evals/evals.json +23 -0
- package/agent-templates/system-skills/app-builder/scripts/check-preview-url.mjs +184 -0
- package/agent-templates/system-skills/buda-automations/SKILL.md +175 -0
- package/agent-templates/system-skills/buda-automations/scripts/create-automation.sh +226 -0
- package/agent-templates/system-skills/buda-automations/scripts/delete-automation.sh +73 -0
- package/agent-templates/system-skills/buda-automations/scripts/list-automations.sh +77 -0
- package/agent-templates/system-skills/buda-far/SKILL.md +221 -0
- package/agent-templates/system-skills/buda-far/far_gen.py +1499 -0
- package/agent-templates/system-skills/buda-machine-info/SKILL.md +67 -0
- package/agent-templates/system-skills/buda-machine-info/scripts/get-info.sh +25 -0
- package/agent-templates/system-skills/buda-machine-info/scripts/send-file.mjs +62 -0
- package/agent-templates/system-skills/buda-web-preview/SKILL.md +462 -0
- package/agent-templates/system-skills/find-skills/SKILL.md +142 -0
- package/agent-templates/system-skills/skill-creator/LICENSE.txt +202 -0
- package/agent-templates/system-skills/skill-creator/SKILL.md +479 -0
- package/agent-templates/system-skills/skill-creator/agents/analyzer.md +274 -0
- package/agent-templates/system-skills/skill-creator/agents/comparator.md +202 -0
- package/agent-templates/system-skills/skill-creator/agents/grader.md +223 -0
- package/agent-templates/system-skills/skill-creator/assets/eval_review.html +146 -0
- package/agent-templates/system-skills/skill-creator/eval-viewer/generate_review.py +471 -0
- package/agent-templates/system-skills/skill-creator/eval-viewer/viewer.html +1325 -0
- package/agent-templates/system-skills/skill-creator/references/schemas.md +430 -0
- package/agent-templates/system-skills/skill-creator/scripts/__init__.py +0 -0
- package/agent-templates/system-skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
- package/agent-templates/system-skills/skill-creator/scripts/generate_report.py +326 -0
- package/agent-templates/system-skills/skill-creator/scripts/improve_description.py +248 -0
- package/agent-templates/system-skills/skill-creator/scripts/package_skill.py +136 -0
- package/agent-templates/system-skills/skill-creator/scripts/quick_validate.py +103 -0
- package/agent-templates/system-skills/skill-creator/scripts/run_eval.py +310 -0
- package/agent-templates/system-skills/skill-creator/scripts/run_loop.py +332 -0
- package/agent-templates/system-skills/skill-creator/scripts/utils.py +47 -0
- package/dist/cli.js +2747 -180
- package/package.json +10 -6
|
@@ -0,0 +1,1499 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import argparse
|
|
5
|
+
import hashlib
|
|
6
|
+
import mimetypes
|
|
7
|
+
import subprocess
|
|
8
|
+
import datetime
|
|
9
|
+
import zipfile
|
|
10
|
+
import xml.etree.ElementTree as ET
|
|
11
|
+
import urllib.request
|
|
12
|
+
import urllib.error
|
|
13
|
+
import json
|
|
14
|
+
import base64
|
|
15
|
+
import tempfile
|
|
16
|
+
import shutil
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
# --- Configuration ---
|
|
20
|
+
SKILL_VERSION = "1.0.0"
|
|
21
|
+
PIPELINE_ID = "far_gen_v14"
|
|
22
|
+
MAX_DIR_SUMMARY_FILES = 50 # Max files to list in .dir.meta summary
|
|
23
|
+
FFMPEG_BIN = "/home/linuxbrew/.linuxbrew/bin/ffmpeg"
|
|
24
|
+
FFPROBE_BIN = "/home/linuxbrew/.linuxbrew/bin/ffprobe"
|
|
25
|
+
OPENAI_MODEL_AUDIO = "whisper-1"
|
|
26
|
+
OPENAI_MODEL_IMAGE = "gpt-4o-mini" # Use a cost-effective model for vision
|
|
27
|
+
MAX_AUDIO_MB = 25 # OpenAI API limit
|
|
28
|
+
LOG_FILE = os.path.expanduser("~/far.log")
|
|
29
|
+
|
|
30
|
+
def log(msg, level="INFO"):
|
|
31
|
+
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
32
|
+
formatted_msg = f"[{timestamp}] [{level}] {msg}"
|
|
33
|
+
print(formatted_msg)
|
|
34
|
+
try:
|
|
35
|
+
with open(LOG_FILE, "a") as f:
|
|
36
|
+
f.write(formatted_msg + "\n")
|
|
37
|
+
except: pass
|
|
38
|
+
|
|
39
|
+
def load_env(env_path):
|
|
40
|
+
"""Simple .env parser to avoid external dependencies."""
|
|
41
|
+
if os.path.exists(env_path):
|
|
42
|
+
with open(env_path, 'r') as f:
|
|
43
|
+
for line in f:
|
|
44
|
+
line = line.strip()
|
|
45
|
+
if not line or line.startswith('#'): continue
|
|
46
|
+
if '=' in line:
|
|
47
|
+
key, value = line.split('=', 1)
|
|
48
|
+
# Strip quotes if present
|
|
49
|
+
value = value.strip().strip("'").strip('"')
|
|
50
|
+
os.environ[key.strip()] = value
|
|
51
|
+
|
|
52
|
+
# Load .env from skill directory or user home
|
|
53
|
+
SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__))
|
|
54
|
+
load_env(os.path.join(SCRIPT_DIR, ".env"))
|
|
55
|
+
load_env(os.path.expanduser("~/.far.env"))
|
|
56
|
+
|
|
57
|
+
def get_openai_key():
|
|
58
|
+
return os.environ.get("OPENAI_API_KEY")
|
|
59
|
+
|
|
60
|
+
def get_openai_base():
|
|
61
|
+
return os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1")
|
|
62
|
+
|
|
63
|
+
def get_buda_api_url():
|
|
64
|
+
return os.environ.get("NEXT_PUBLIC_APP_URL", "").rstrip("/")
|
|
65
|
+
|
|
66
|
+
def get_agent_key():
|
|
67
|
+
return os.environ.get("AGENT_KEY")
|
|
68
|
+
|
|
69
|
+
def get_sha256(filepath):
|
|
70
|
+
"""Calculate SHA256 checksum of a file."""
|
|
71
|
+
sha256_hash = hashlib.sha256()
|
|
72
|
+
try:
|
|
73
|
+
with open(filepath, "rb") as f:
|
|
74
|
+
for byte_block in iter(lambda: f.read(4096), b""):
|
|
75
|
+
sha256_hash.update(byte_block)
|
|
76
|
+
return sha256_hash.hexdigest()
|
|
77
|
+
except (PermissionError, FileNotFoundError):
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
def get_mime_type(filepath):
|
|
81
|
+
"""Guess MIME type."""
|
|
82
|
+
mime_type, _ = mimetypes.guess_type(filepath)
|
|
83
|
+
if mime_type is None:
|
|
84
|
+
try:
|
|
85
|
+
result = subprocess.run(['file', '--mime-type', '-b', filepath], capture_output=True, text=True)
|
|
86
|
+
mime_type = result.stdout.strip()
|
|
87
|
+
except FileNotFoundError:
|
|
88
|
+
mime_type = "application/octet-stream"
|
|
89
|
+
return mime_type
|
|
90
|
+
|
|
91
|
+
# --- AI Extractors (Zero Dependency HTTP) ---
|
|
92
|
+
|
|
93
|
+
def buda_whisper_transcribe(filepath):
|
|
94
|
+
"""Transcribe audio/video via Buda's first-party whisper tool."""
|
|
95
|
+
api_url = get_buda_api_url()
|
|
96
|
+
agent_key = get_agent_key()
|
|
97
|
+
if not api_url or not agent_key:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
payload = {
|
|
102
|
+
"file_path": os.path.abspath(filepath),
|
|
103
|
+
"response_format": "json",
|
|
104
|
+
}
|
|
105
|
+
body = json.dumps(payload).encode("utf-8")
|
|
106
|
+
req = urllib.request.Request(
|
|
107
|
+
f"{api_url}/api/agent/tools/whisper",
|
|
108
|
+
data=body,
|
|
109
|
+
headers={
|
|
110
|
+
"Authorization": f"Bearer {agent_key}",
|
|
111
|
+
"Content-Type": "application/json",
|
|
112
|
+
},
|
|
113
|
+
)
|
|
114
|
+
with urllib.request.urlopen(req, timeout=180) as response:
|
|
115
|
+
result = json.load(response)
|
|
116
|
+
text = result.get("text")
|
|
117
|
+
if not text and isinstance(result.get("data"), dict):
|
|
118
|
+
text = result["data"].get("text")
|
|
119
|
+
if text:
|
|
120
|
+
return f"[AI Transcription (Buda whisper)]:\n{text}"
|
|
121
|
+
return f"[AI Transcription (Buda whisper)]:\n{json.dumps(result, ensure_ascii=False)}"
|
|
122
|
+
except Exception as e:
|
|
123
|
+
log(f"Buda whisper tool failed, falling back to local OpenAI Whisper path: {e}", "WARN")
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
def openai_transcribe(filepath):
|
|
127
|
+
"""Transcribe audio/video using OpenAI Whisper API via standard library."""
|
|
128
|
+
buda_result = buda_whisper_transcribe(filepath)
|
|
129
|
+
if buda_result:
|
|
130
|
+
return buda_result
|
|
131
|
+
|
|
132
|
+
api_key = get_openai_key()
|
|
133
|
+
if not api_key: return None
|
|
134
|
+
|
|
135
|
+
# Check file size
|
|
136
|
+
file_size_mb = os.path.getsize(filepath) / (1024 * 1024)
|
|
137
|
+
|
|
138
|
+
# If file is too large or video, try to extract audio first using ffmpeg
|
|
139
|
+
process_file = filepath
|
|
140
|
+
temp_audio = None
|
|
141
|
+
|
|
142
|
+
if file_size_mb > MAX_AUDIO_MB or get_mime_type(filepath).startswith('video/'):
|
|
143
|
+
# Extract/Compress audio
|
|
144
|
+
temp_audio = filepath + ".far_temp.mp3"
|
|
145
|
+
ffmpeg_cmd = [FFMPEG_BIN if os.path.exists(FFMPEG_BIN) else 'ffmpeg', '-y', '-i', filepath, '-vn', '-ar', '16000', '-ac', '1', '-b:a', '32k', temp_audio]
|
|
146
|
+
try:
|
|
147
|
+
subprocess.run(ffmpeg_cmd, check=True, capture_output=True)
|
|
148
|
+
process_file = temp_audio
|
|
149
|
+
# Check size again
|
|
150
|
+
if os.path.getsize(process_file) / (1024 * 1024) > MAX_AUDIO_MB:
|
|
151
|
+
if temp_audio: os.remove(temp_audio)
|
|
152
|
+
return "[Error: Audio file too large for API even after compression]"
|
|
153
|
+
except subprocess.CalledProcessError:
|
|
154
|
+
if temp_audio and os.path.exists(temp_audio): os.remove(temp_audio)
|
|
155
|
+
return None # Fallback to local metadata if ffmpeg fails
|
|
156
|
+
except FileNotFoundError:
|
|
157
|
+
return None # ffmpeg not found
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
boundary = '----WebKitFormBoundary7MA4YWxkTrZu0gW'
|
|
161
|
+
data = []
|
|
162
|
+
data.append(f'--{boundary}')
|
|
163
|
+
data.append(f'Content-Disposition: form-data; name="model"')
|
|
164
|
+
data.append('')
|
|
165
|
+
data.append(OPENAI_MODEL_AUDIO)
|
|
166
|
+
data.append(f'--{boundary}')
|
|
167
|
+
data.append(f'Content-Disposition: form-data; name="file"; filename="{os.path.basename(process_file)}"')
|
|
168
|
+
data.append('Content-Type: application/octet-stream')
|
|
169
|
+
data.append('')
|
|
170
|
+
|
|
171
|
+
with open(process_file, 'rb') as f:
|
|
172
|
+
file_data = f.read()
|
|
173
|
+
|
|
174
|
+
body = b'\r\n'.join([x.encode('utf-8') for x in data]) + b'\r\n' + file_data + b'\r\n' + f'--{boundary}--'.encode('utf-8') + b'\r\n'
|
|
175
|
+
|
|
176
|
+
req = urllib.request.Request(
|
|
177
|
+
f"{get_openai_base()}/audio/transcriptions",
|
|
178
|
+
data=body,
|
|
179
|
+
headers={
|
|
180
|
+
'Authorization': f'Bearer {api_key}',
|
|
181
|
+
'Content-Type': f'multipart/form-data; boundary={boundary}'
|
|
182
|
+
}
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
with urllib.request.urlopen(req) as response:
|
|
186
|
+
result = json.load(response)
|
|
187
|
+
return f"[AI Transcription ({OPENAI_MODEL_AUDIO})]:\n{result.get('text', '')}"
|
|
188
|
+
|
|
189
|
+
except Exception as e:
|
|
190
|
+
return f"[AI Transcription Error: {e}]"
|
|
191
|
+
finally:
|
|
192
|
+
if temp_audio and os.path.exists(temp_audio):
|
|
193
|
+
os.remove(temp_audio)
|
|
194
|
+
|
|
195
|
+
def openai_vision(filepath):
|
|
196
|
+
"""Describe image using OpenAI Vision API."""
|
|
197
|
+
api_key = get_openai_key()
|
|
198
|
+
if not api_key: return None
|
|
199
|
+
|
|
200
|
+
# Simple check: skip large images to save bandwidth/tokens
|
|
201
|
+
if os.path.getsize(filepath) > 10 * 1024 * 1024: return None
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
with open(filepath, "rb") as image_file:
|
|
205
|
+
base64_image = base64.b64encode(image_file.read()).decode('utf-8')
|
|
206
|
+
|
|
207
|
+
headers = {
|
|
208
|
+
"Content-Type": "application/json",
|
|
209
|
+
"Authorization": f"Bearer {api_key}"
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
payload = {
|
|
213
|
+
"model": OPENAI_MODEL_IMAGE,
|
|
214
|
+
"messages": [
|
|
215
|
+
{
|
|
216
|
+
"role": "user",
|
|
217
|
+
"content": [
|
|
218
|
+
{"type": "text", "text": "Describe this image in detail for a blind user. If it contains text, transcribe it."},
|
|
219
|
+
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{base64_image}"}}
|
|
220
|
+
]
|
|
221
|
+
}
|
|
222
|
+
],
|
|
223
|
+
"max_tokens": 500
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
req = urllib.request.Request(
|
|
227
|
+
f"{get_openai_base()}/chat/completions",
|
|
228
|
+
data=json.dumps(payload).encode('utf-8'),
|
|
229
|
+
headers=headers
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
with urllib.request.urlopen(req) as response:
|
|
233
|
+
result = json.load(response)
|
|
234
|
+
content = result['choices'][0]['message']['content']
|
|
235
|
+
return f"[AI Vision Description ({OPENAI_MODEL_IMAGE})]:\n{content}"
|
|
236
|
+
|
|
237
|
+
except Exception as e:
|
|
238
|
+
return f"[AI Vision Error: {e}]"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _is_enabled(env_key, default="1"):
|
|
242
|
+
val = os.environ.get(env_key, default).strip().lower()
|
|
243
|
+
return val not in ("0", "false", "no", "off")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _run_swift_json(swift_code, args, timeout=25):
|
|
247
|
+
if shutil.which('swift') is None:
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
script_path = None
|
|
251
|
+
try:
|
|
252
|
+
with tempfile.NamedTemporaryFile('w', suffix='.swift', delete=False, encoding='utf-8') as tf:
|
|
253
|
+
tf.write(swift_code)
|
|
254
|
+
script_path = tf.name
|
|
255
|
+
|
|
256
|
+
result = subprocess.run(
|
|
257
|
+
['swift', script_path] + list(args),
|
|
258
|
+
capture_output=True,
|
|
259
|
+
text=True,
|
|
260
|
+
timeout=timeout
|
|
261
|
+
)
|
|
262
|
+
if result.returncode != 0:
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
output = (result.stdout or '').strip()
|
|
266
|
+
if not output:
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
# Swift tools may print extra lines. Parse the last non-empty JSON line.
|
|
270
|
+
for line in reversed([l.strip() for l in output.splitlines() if l.strip()]):
|
|
271
|
+
if line.startswith('{') and line.endswith('}'):
|
|
272
|
+
try:
|
|
273
|
+
return json.loads(line)
|
|
274
|
+
except Exception:
|
|
275
|
+
continue
|
|
276
|
+
return None
|
|
277
|
+
except Exception:
|
|
278
|
+
return None
|
|
279
|
+
finally:
|
|
280
|
+
if script_path and os.path.exists(script_path):
|
|
281
|
+
try:
|
|
282
|
+
os.remove(script_path)
|
|
283
|
+
except Exception:
|
|
284
|
+
pass
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def apple_vision_json(filepath):
|
|
288
|
+
"""Return Apple Vision structured analysis for an image (macOS only)."""
|
|
289
|
+
if sys.platform != 'darwin':
|
|
290
|
+
return None
|
|
291
|
+
if not _is_enabled("FAR_USE_APPLE_VISION", "1"):
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
swift_code = r'''
|
|
295
|
+
import Foundation
|
|
296
|
+
import Vision
|
|
297
|
+
import ImageIO
|
|
298
|
+
import CoreImage
|
|
299
|
+
|
|
300
|
+
func jsonString(_ obj: Any) {
|
|
301
|
+
if let data = try? JSONSerialization.data(withJSONObject: obj, options: []),
|
|
302
|
+
let str = String(data: data, encoding: .utf8) {
|
|
303
|
+
print(str)
|
|
304
|
+
} else {
|
|
305
|
+
print("{}")
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
guard CommandLine.arguments.count > 1 else {
|
|
310
|
+
jsonString(["error": "Missing image path"])
|
|
311
|
+
exit(0)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
let imagePath = CommandLine.arguments[1]
|
|
315
|
+
let imageURL = URL(fileURLWithPath: imagePath)
|
|
316
|
+
|
|
317
|
+
guard let src = CGImageSourceCreateWithURL(imageURL as CFURL, nil),
|
|
318
|
+
let cgImage = CGImageSourceCreateImageAtIndex(src, 0, nil) else {
|
|
319
|
+
jsonString(["error": "Cannot decode image"])
|
|
320
|
+
exit(0)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
var payload: [String: Any] = [:]
|
|
324
|
+
payload["width"] = cgImage.width
|
|
325
|
+
payload["height"] = cgImage.height
|
|
326
|
+
|
|
327
|
+
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
|
|
328
|
+
|
|
329
|
+
// OCR
|
|
330
|
+
let textRequest = VNRecognizeTextRequest()
|
|
331
|
+
textRequest.recognitionLevel = .accurate
|
|
332
|
+
textRequest.usesLanguageCorrection = true
|
|
333
|
+
textRequest.recognitionLanguages = ["en-US", "zh-Hans"]
|
|
334
|
+
do {
|
|
335
|
+
try handler.perform([textRequest])
|
|
336
|
+
let lines = (textRequest.results ?? []).compactMap { obs in
|
|
337
|
+
obs.topCandidates(1).first?.string
|
|
338
|
+
}
|
|
339
|
+
if !lines.isEmpty {
|
|
340
|
+
payload["ocr"] = lines.joined(separator: "\n")
|
|
341
|
+
}
|
|
342
|
+
} catch {
|
|
343
|
+
payload["ocr_error"] = String(describing: error)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Labels
|
|
347
|
+
if #available(macOS 11.0, *) {
|
|
348
|
+
let clsRequest = VNClassifyImageRequest()
|
|
349
|
+
do {
|
|
350
|
+
try handler.perform([clsRequest])
|
|
351
|
+
let labels = (clsRequest.results ?? [])
|
|
352
|
+
.filter { $0.confidence >= 0.10 }
|
|
353
|
+
.prefix(8)
|
|
354
|
+
.map { ["label": $0.identifier, "confidence": Double($0.confidence)] }
|
|
355
|
+
if !labels.isEmpty {
|
|
356
|
+
payload["labels"] = labels
|
|
357
|
+
}
|
|
358
|
+
} catch {
|
|
359
|
+
payload["label_error"] = String(describing: error)
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Faces
|
|
364
|
+
let faceRequest = VNDetectFaceRectanglesRequest()
|
|
365
|
+
do {
|
|
366
|
+
try handler.perform([faceRequest])
|
|
367
|
+
payload["face_count"] = faceRequest.results?.count ?? 0
|
|
368
|
+
} catch {
|
|
369
|
+
payload["face_error"] = String(describing: error)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Barcodes / QR
|
|
373
|
+
let barcodeRequest = VNDetectBarcodesRequest()
|
|
374
|
+
do {
|
|
375
|
+
try handler.perform([barcodeRequest])
|
|
376
|
+
let codes = (barcodeRequest.results ?? []).prefix(12).map { obs in
|
|
377
|
+
[
|
|
378
|
+
"symbology": obs.symbology.rawValue,
|
|
379
|
+
"payload": obs.payloadStringValue ?? ""
|
|
380
|
+
]
|
|
381
|
+
}
|
|
382
|
+
if !codes.isEmpty {
|
|
383
|
+
payload["barcodes"] = codes
|
|
384
|
+
}
|
|
385
|
+
} catch {
|
|
386
|
+
payload["barcode_error"] = String(describing: error)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Body pose count
|
|
390
|
+
if #available(macOS 10.15, *) {
|
|
391
|
+
let poseRequest = VNDetectHumanBodyPoseRequest()
|
|
392
|
+
do {
|
|
393
|
+
try handler.perform([poseRequest])
|
|
394
|
+
payload["body_pose_count"] = poseRequest.results?.count ?? 0
|
|
395
|
+
} catch {
|
|
396
|
+
payload["body_pose_error"] = String(describing: error)
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Feature print embedding fingerprint (hash only)
|
|
401
|
+
if #available(macOS 10.15, *) {
|
|
402
|
+
let fpRequest = VNGenerateImageFeaturePrintRequest()
|
|
403
|
+
do {
|
|
404
|
+
try handler.perform([fpRequest])
|
|
405
|
+
if let obs = fpRequest.results?.first as? NSObject {
|
|
406
|
+
var fp: [String: Any] = [:]
|
|
407
|
+
|
|
408
|
+
let selCount = NSSelectorFromString("elementCount")
|
|
409
|
+
if obs.responds(to: selCount), let out = obs.perform(selCount) {
|
|
410
|
+
if let n = out.takeUnretainedValue() as? NSNumber {
|
|
411
|
+
fp["dimensions"] = n.intValue
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
let selData = NSSelectorFromString("data")
|
|
416
|
+
if obs.responds(to: selData), let out = obs.perform(selData) {
|
|
417
|
+
if let data = out.takeUnretainedValue() as? Data {
|
|
418
|
+
fp["data_b64"] = data.base64EncodedString()
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if !fp.isEmpty {
|
|
423
|
+
payload["feature_print"] = fp
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
} catch {
|
|
427
|
+
payload["feature_print_error"] = String(describing: error)
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
jsonString(payload)
|
|
432
|
+
'''
|
|
433
|
+
|
|
434
|
+
timeout = int(os.environ.get("FAR_APPLE_VISION_TIMEOUT", "25"))
|
|
435
|
+
data = _run_swift_json(swift_code, [filepath], timeout=timeout)
|
|
436
|
+
if isinstance(data, dict):
|
|
437
|
+
fp = data.get('feature_print')
|
|
438
|
+
if isinstance(fp, dict) and fp.get('data_b64'):
|
|
439
|
+
try:
|
|
440
|
+
raw = base64.b64decode(fp.get('data_b64'))
|
|
441
|
+
fp['sha256'] = hashlib.sha256(raw).hexdigest()
|
|
442
|
+
except Exception:
|
|
443
|
+
pass
|
|
444
|
+
fp.pop('data_b64', None)
|
|
445
|
+
return data
|
|
446
|
+
return None
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def format_apple_vision(data):
|
|
450
|
+
if not isinstance(data, dict):
|
|
451
|
+
return None
|
|
452
|
+
|
|
453
|
+
lines = []
|
|
454
|
+
width = data.get('width')
|
|
455
|
+
height = data.get('height')
|
|
456
|
+
if isinstance(width, int) and isinstance(height, int):
|
|
457
|
+
lines.append(f"[Apple Vision Image Size]: {width}x{height}")
|
|
458
|
+
|
|
459
|
+
labels = data.get('labels') or []
|
|
460
|
+
if labels:
|
|
461
|
+
formatted = []
|
|
462
|
+
for item in labels:
|
|
463
|
+
try:
|
|
464
|
+
label = item.get('label', '')
|
|
465
|
+
confidence = float(item.get('confidence', 0))
|
|
466
|
+
if label:
|
|
467
|
+
formatted.append(f"{label} ({confidence:.2f})")
|
|
468
|
+
except Exception:
|
|
469
|
+
continue
|
|
470
|
+
if formatted:
|
|
471
|
+
lines.append("[Apple Vision Labels]: " + ", ".join(formatted))
|
|
472
|
+
|
|
473
|
+
ocr = (data.get('ocr') or '').strip()
|
|
474
|
+
if ocr:
|
|
475
|
+
lines.append(f"[Apple Vision OCR]:\n{ocr}")
|
|
476
|
+
|
|
477
|
+
face_count = data.get('face_count')
|
|
478
|
+
if isinstance(face_count, int):
|
|
479
|
+
lines.append(f"[Apple Vision Faces]: {face_count}")
|
|
480
|
+
|
|
481
|
+
body_pose_count = data.get('body_pose_count')
|
|
482
|
+
if isinstance(body_pose_count, int):
|
|
483
|
+
lines.append(f"[Apple Vision Body Pose Detections]: {body_pose_count}")
|
|
484
|
+
|
|
485
|
+
barcodes = data.get('barcodes') or []
|
|
486
|
+
if barcodes:
|
|
487
|
+
barcode_lines = []
|
|
488
|
+
for b in barcodes:
|
|
489
|
+
payload = (b.get('payload') or '').strip()
|
|
490
|
+
sym = b.get('symbology') or 'Unknown'
|
|
491
|
+
if payload:
|
|
492
|
+
barcode_lines.append(f"- {sym}: {payload}")
|
|
493
|
+
else:
|
|
494
|
+
barcode_lines.append(f"- {sym}")
|
|
495
|
+
if barcode_lines:
|
|
496
|
+
lines.append("[Apple Vision Barcodes]:\n" + "\n".join(barcode_lines))
|
|
497
|
+
|
|
498
|
+
fp = data.get('feature_print') or {}
|
|
499
|
+
if isinstance(fp, dict) and fp:
|
|
500
|
+
dims = fp.get('dimensions')
|
|
501
|
+
fhash = fp.get('sha256')
|
|
502
|
+
fp_line = "[Apple Vision Feature Print]"
|
|
503
|
+
details = []
|
|
504
|
+
if isinstance(dims, int):
|
|
505
|
+
details.append(f"dims={dims}")
|
|
506
|
+
if isinstance(fhash, str) and fhash:
|
|
507
|
+
details.append(f"sha256={fhash[:16]}…")
|
|
508
|
+
if details:
|
|
509
|
+
fp_line += ": " + ", ".join(details)
|
|
510
|
+
lines.append(fp_line)
|
|
511
|
+
|
|
512
|
+
return "\n\n".join(lines) if lines else None
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def apple_vision(filepath):
|
|
516
|
+
"""Describe image using Apple's on-device Vision framework (macOS only)."""
|
|
517
|
+
return format_apple_vision(apple_vision_json(filepath))
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def summarize_apple_vision_frames(frames):
|
|
521
|
+
"""Aggregate Apple Vision analysis across video frames (macOS only)."""
|
|
522
|
+
if sys.platform != 'darwin' or not _is_enabled("FAR_USE_APPLE_VISION", "1"):
|
|
523
|
+
return None
|
|
524
|
+
if not frames:
|
|
525
|
+
return None
|
|
526
|
+
|
|
527
|
+
try:
|
|
528
|
+
max_frames = max(1, int(os.environ.get("FAR_APPLE_VISION_MAX_FRAMES", "6")))
|
|
529
|
+
except Exception:
|
|
530
|
+
max_frames = 6
|
|
531
|
+
|
|
532
|
+
selected = frames[::max(1, len(frames)//max_frames)][:max_frames]
|
|
533
|
+
|
|
534
|
+
label_scores = {}
|
|
535
|
+
seen_text = set()
|
|
536
|
+
barcode_hits = set()
|
|
537
|
+
max_faces = 0
|
|
538
|
+
body_pose_frames = 0
|
|
539
|
+
analyzed = 0
|
|
540
|
+
|
|
541
|
+
for frame in selected:
|
|
542
|
+
data = apple_vision_json(str(frame))
|
|
543
|
+
if not data:
|
|
544
|
+
continue
|
|
545
|
+
analyzed += 1
|
|
546
|
+
|
|
547
|
+
for item in (data.get('labels') or []):
|
|
548
|
+
label = item.get('label')
|
|
549
|
+
try:
|
|
550
|
+
score = float(item.get('confidence', 0))
|
|
551
|
+
except Exception:
|
|
552
|
+
score = 0.0
|
|
553
|
+
if label:
|
|
554
|
+
label_scores[label] = max(score, label_scores.get(label, 0.0))
|
|
555
|
+
|
|
556
|
+
ocr = (data.get('ocr') or '').strip()
|
|
557
|
+
if ocr:
|
|
558
|
+
for line in ocr.splitlines():
|
|
559
|
+
clean = line.strip()
|
|
560
|
+
if len(clean) > 2:
|
|
561
|
+
seen_text.add(clean)
|
|
562
|
+
|
|
563
|
+
try:
|
|
564
|
+
max_faces = max(max_faces, int(data.get('face_count', 0)))
|
|
565
|
+
except Exception:
|
|
566
|
+
pass
|
|
567
|
+
|
|
568
|
+
try:
|
|
569
|
+
if int(data.get('body_pose_count', 0)) > 0:
|
|
570
|
+
body_pose_frames += 1
|
|
571
|
+
except Exception:
|
|
572
|
+
pass
|
|
573
|
+
|
|
574
|
+
for b in (data.get('barcodes') or []):
|
|
575
|
+
payload = (b.get('payload') or '').strip()
|
|
576
|
+
sym = (b.get('symbology') or '').strip()
|
|
577
|
+
if payload:
|
|
578
|
+
barcode_hits.add(f"{sym}: {payload}" if sym else payload)
|
|
579
|
+
elif sym:
|
|
580
|
+
barcode_hits.add(sym)
|
|
581
|
+
|
|
582
|
+
if analyzed == 0:
|
|
583
|
+
return None
|
|
584
|
+
|
|
585
|
+
lines = [f"## Apple Vision Video Summary (On-device)\nAnalyzed {analyzed} frame(s)."]
|
|
586
|
+
|
|
587
|
+
if label_scores:
|
|
588
|
+
top_labels = sorted(label_scores.items(), key=lambda x: x[1], reverse=True)[:10]
|
|
589
|
+
lines.append("**Top labels:** " + ", ".join(f"{l} ({s:.2f})" for l, s in top_labels))
|
|
590
|
+
|
|
591
|
+
if seen_text:
|
|
592
|
+
text_preview = list(sorted(seen_text))[:20]
|
|
593
|
+
lines.append("**Text spotted:**\n" + "\n".join(f"- {t}" for t in text_preview))
|
|
594
|
+
|
|
595
|
+
lines.append(f"**Max faces in frame:** {max_faces}")
|
|
596
|
+
lines.append(f"**Frames with human pose detected:** {body_pose_frames}")
|
|
597
|
+
|
|
598
|
+
if barcode_hits:
|
|
599
|
+
lines.append("**Barcodes/QR:**\n" + "\n".join(f"- {b}" for b in sorted(barcode_hits)[:12]))
|
|
600
|
+
|
|
601
|
+
return "\n\n".join(lines)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def extract_macos_metadata(filepath):
|
|
605
|
+
"""Extract Spotlight metadata via mdls (macOS only)."""
|
|
606
|
+
if sys.platform != 'darwin' or not _is_enabled("FAR_USE_MACOS_METADATA", "1"):
|
|
607
|
+
return ""
|
|
608
|
+
if shutil.which('mdls') is None:
|
|
609
|
+
return ""
|
|
610
|
+
|
|
611
|
+
keys = [
|
|
612
|
+
'kMDItemKind', 'kMDItemContentType', 'kMDItemFSSize',
|
|
613
|
+
'kMDItemFSCreationDate', 'kMDItemFSContentChangeDate',
|
|
614
|
+
'kMDItemTitle', 'kMDItemAuthors', 'kMDItemWhereFroms',
|
|
615
|
+
'kMDItemPixelWidth', 'kMDItemPixelHeight', 'kMDItemDurationSeconds',
|
|
616
|
+
'kMDItemAudioSampleRate', 'kMDItemCodecs', 'kMDItemGPSLatitude', 'kMDItemGPSLongitude'
|
|
617
|
+
]
|
|
618
|
+
|
|
619
|
+
cmd = ['mdls']
|
|
620
|
+
for k in keys:
|
|
621
|
+
cmd.extend(['-name', k])
|
|
622
|
+
cmd.append(filepath)
|
|
623
|
+
|
|
624
|
+
try:
|
|
625
|
+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=8)
|
|
626
|
+
if r.returncode != 0 or not r.stdout.strip():
|
|
627
|
+
return ""
|
|
628
|
+
|
|
629
|
+
rows = []
|
|
630
|
+
for raw in r.stdout.splitlines():
|
|
631
|
+
line = raw.strip()
|
|
632
|
+
if '=' not in line or not line.startswith('kMDItem'):
|
|
633
|
+
continue
|
|
634
|
+
k, v = line.split('=', 1)
|
|
635
|
+
key = k.strip()
|
|
636
|
+
val = v.strip()
|
|
637
|
+
if val in ('(null)', '"(null)"', '""'):
|
|
638
|
+
continue
|
|
639
|
+
rows.append(f"- **{key}**: {val}")
|
|
640
|
+
|
|
641
|
+
if not rows:
|
|
642
|
+
return ""
|
|
643
|
+
return "## macOS Spotlight Metadata\n" + "\n".join(rows)
|
|
644
|
+
except Exception:
|
|
645
|
+
return ""
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
# --- Local Extractors ---
|
|
649
|
+
|
|
650
|
+
def extract_pdf(filepath):
|
|
651
|
+
try:
|
|
652
|
+
result = subprocess.run(['pdftotext', '-layout', filepath, '-'], capture_output=True, text=True)
|
|
653
|
+
text = result.stdout
|
|
654
|
+
|
|
655
|
+
# Heuristic: If very little text, try OCR all pages
|
|
656
|
+
if len(text.strip()) < 50:
|
|
657
|
+
try:
|
|
658
|
+
import tempfile
|
|
659
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
660
|
+
subprocess.run(['pdftoppm', '-png', filepath, os.path.join(tmpdir, 'page')], capture_output=True)
|
|
661
|
+
pages = sorted(Path(tmpdir).glob('*.png'))
|
|
662
|
+
ocr_parts = []
|
|
663
|
+
for i, page in enumerate(pages, 1):
|
|
664
|
+
r = subprocess.run(['tesseract', str(page), '-', '-l', 'eng+chi_sim'], capture_output=True, text=True)
|
|
665
|
+
if r.returncode == 0 and r.stdout.strip():
|
|
666
|
+
ocr_parts.append(f"[Page {i}]:\n{r.stdout.strip()}")
|
|
667
|
+
if ocr_parts:
|
|
668
|
+
text = f"{text}\n\n[OCR Extraction]:\n" + "\n\n".join(ocr_parts)
|
|
669
|
+
except FileNotFoundError:
|
|
670
|
+
pass
|
|
671
|
+
|
|
672
|
+
# Extract embedded images from PDF
|
|
673
|
+
images_text = extract_pdf_images(filepath)
|
|
674
|
+
if images_text:
|
|
675
|
+
text = f"{text}\n\n{images_text}"
|
|
676
|
+
|
|
677
|
+
return text if result.returncode == 0 else f"[Error: {result.stderr}]"
|
|
678
|
+
except FileNotFoundError:
|
|
679
|
+
return "[Error: pdftotext not installed]"
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def extract_pdf_images(filepath):
|
|
683
|
+
"""Extract and OCR embedded images from a PDF using pdfimages."""
|
|
684
|
+
try:
|
|
685
|
+
import tempfile
|
|
686
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
687
|
+
result = subprocess.run(
|
|
688
|
+
['pdfimages', '-png', filepath, os.path.join(tmpdir, 'img')],
|
|
689
|
+
capture_output=True
|
|
690
|
+
)
|
|
691
|
+
if result.returncode != 0:
|
|
692
|
+
return ""
|
|
693
|
+
images = sorted(Path(tmpdir).glob('*.png'))
|
|
694
|
+
if not images:
|
|
695
|
+
return ""
|
|
696
|
+
parts = ["## Embedded Images"]
|
|
697
|
+
for i, img in enumerate(images[:10], 1): # cap at 10 images
|
|
698
|
+
ocr = ""
|
|
699
|
+
try:
|
|
700
|
+
r = subprocess.run(['tesseract', str(img), '-', '-l', 'eng+chi_sim'], capture_output=True, text=True)
|
|
701
|
+
if r.returncode == 0 and r.stdout.strip():
|
|
702
|
+
ocr = r.stdout.strip()
|
|
703
|
+
except FileNotFoundError:
|
|
704
|
+
pass
|
|
705
|
+
apple = apple_vision(str(img))
|
|
706
|
+
ai = openai_vision(str(img))
|
|
707
|
+
if ai or ocr or apple:
|
|
708
|
+
parts.append(f"### Image {i}")
|
|
709
|
+
if apple:
|
|
710
|
+
parts.append(apple)
|
|
711
|
+
if ai:
|
|
712
|
+
parts.append(ai)
|
|
713
|
+
if ocr:
|
|
714
|
+
parts.append(f"[OCR]: {ocr}")
|
|
715
|
+
return "\n".join(parts) if len(parts) > 1 else ""
|
|
716
|
+
except FileNotFoundError:
|
|
717
|
+
return "" # pdfimages not installed, skip silently
|
|
718
|
+
|
|
719
|
+
def extract_docx(filepath):
|
|
720
|
+
try:
|
|
721
|
+
result = subprocess.run(['docx2txt', filepath, '-'], capture_output=True, text=True)
|
|
722
|
+
return result.stdout if result.returncode == 0 else f"[Error: {result.stderr}]"
|
|
723
|
+
except FileNotFoundError:
|
|
724
|
+
return "[Error: docx2txt not installed]"
|
|
725
|
+
|
|
726
|
+
def extract_doc(filepath):
|
|
727
|
+
try:
|
|
728
|
+
result = subprocess.run(['antiword', filepath], capture_output=True, text=True)
|
|
729
|
+
return result.stdout if result.returncode == 0 else f"[Error: {result.stderr}]"
|
|
730
|
+
except FileNotFoundError:
|
|
731
|
+
return "[Error: antiword not installed]"
|
|
732
|
+
|
|
733
|
+
def extract_xlsx(filepath):
|
|
734
|
+
text_content = []
|
|
735
|
+
try:
|
|
736
|
+
with zipfile.ZipFile(filepath, 'r') as z:
|
|
737
|
+
shared_strings = []
|
|
738
|
+
if 'xl/sharedStrings.xml' in z.namelist():
|
|
739
|
+
with z.open('xl/sharedStrings.xml') as f:
|
|
740
|
+
tree = ET.parse(f)
|
|
741
|
+
root = tree.getroot()
|
|
742
|
+
for t in root.iter():
|
|
743
|
+
if t.tag.endswith('t'):
|
|
744
|
+
if t.text:
|
|
745
|
+
shared_strings.append(t.text)
|
|
746
|
+
|
|
747
|
+
sheet_files = [f for f in z.namelist() if f.startswith('xl/worksheets/sheet') and f.endswith('.xml')]
|
|
748
|
+
sheet_files.sort()
|
|
749
|
+
|
|
750
|
+
for file in sheet_files:
|
|
751
|
+
sheet_name = file.split('/')[-1]
|
|
752
|
+
text_content.append(f"## {sheet_name}")
|
|
753
|
+
|
|
754
|
+
with z.open(file) as f:
|
|
755
|
+
tree = ET.parse(f)
|
|
756
|
+
root = tree.getroot()
|
|
757
|
+
rows = []
|
|
758
|
+
for row in root.iter():
|
|
759
|
+
if row.tag.endswith('row'):
|
|
760
|
+
row_data = []
|
|
761
|
+
for cell in row.iter():
|
|
762
|
+
if cell.tag.endswith('c'):
|
|
763
|
+
cell_type = cell.get('t')
|
|
764
|
+
val = None
|
|
765
|
+
for v in cell.iter():
|
|
766
|
+
if v.tag.endswith('v'):
|
|
767
|
+
val = v.text
|
|
768
|
+
break
|
|
769
|
+
if val:
|
|
770
|
+
if cell_type == 's':
|
|
771
|
+
try:
|
|
772
|
+
idx = int(val)
|
|
773
|
+
if idx < len(shared_strings):
|
|
774
|
+
val = shared_strings[idx]
|
|
775
|
+
except ValueError: pass
|
|
776
|
+
row_data.append(str(val))
|
|
777
|
+
if row_data:
|
|
778
|
+
rows.append(" | ".join(row_data))
|
|
779
|
+
if rows:
|
|
780
|
+
text_content.append("\n".join(rows))
|
|
781
|
+
else:
|
|
782
|
+
text_content.append("(Empty Sheet)")
|
|
783
|
+
text_content.append("\n")
|
|
784
|
+
return "\n".join(text_content)
|
|
785
|
+
except Exception as e:
|
|
786
|
+
return f"[Error extracting XLSX (native mode): {e}]"
|
|
787
|
+
|
|
788
|
+
def extract_pptx(filepath):
|
|
789
|
+
text_content = []
|
|
790
|
+
try:
|
|
791
|
+
with zipfile.ZipFile(filepath, 'r') as z:
|
|
792
|
+
slides = [f for f in z.namelist() if f.startswith('ppt/slides/slide') and f.endswith('.xml')]
|
|
793
|
+
slides.sort(key=lambda x: int(''.join(filter(str.isdigit, x)) or 0))
|
|
794
|
+
|
|
795
|
+
for i, slide in enumerate(slides):
|
|
796
|
+
text_content.append(f"## Slide {i+1}")
|
|
797
|
+
slide_text = []
|
|
798
|
+
with z.open(slide) as f:
|
|
799
|
+
tree = ET.parse(f)
|
|
800
|
+
root = tree.getroot()
|
|
801
|
+
for elem in root.iter():
|
|
802
|
+
if elem.tag.endswith('t'):
|
|
803
|
+
if elem.text:
|
|
804
|
+
slide_text.append(elem.text)
|
|
805
|
+
if slide_text:
|
|
806
|
+
text_content.append("\n".join(slide_text))
|
|
807
|
+
else:
|
|
808
|
+
text_content.append("(No text)")
|
|
809
|
+
text_content.append("\n")
|
|
810
|
+
return "\n".join(text_content)
|
|
811
|
+
except Exception as e:
|
|
812
|
+
return f"[Error extracting PPTX (native mode): {e}]"
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def extract_epub(filepath):
|
|
816
|
+
"""Extract text from EPUB by reading HTML content files."""
|
|
817
|
+
try:
|
|
818
|
+
import re
|
|
819
|
+
parts = []
|
|
820
|
+
with zipfile.ZipFile(filepath, 'r') as z:
|
|
821
|
+
opf_files = [f for f in z.namelist() if f.endswith('.opf')]
|
|
822
|
+
spine_items = []
|
|
823
|
+
if opf_files:
|
|
824
|
+
with z.open(opf_files[0]) as f:
|
|
825
|
+
tree = ET.parse(f)
|
|
826
|
+
root = tree.getroot()
|
|
827
|
+
manifest = {item.get('id'): item.get('href')
|
|
828
|
+
for item in root.findall('.//{http://www.idpf.org/2007/opf}item')}
|
|
829
|
+
for itemref in root.findall('.//{http://www.idpf.org/2007/opf}itemref'):
|
|
830
|
+
idref = itemref.get('idref')
|
|
831
|
+
if idref in manifest:
|
|
832
|
+
spine_items.append(manifest[idref])
|
|
833
|
+
if not spine_items:
|
|
834
|
+
spine_items = [f for f in z.namelist() if f.endswith(('.html', '.xhtml', '.htm'))]
|
|
835
|
+
base = (os.path.dirname(opf_files[0]) + '/') if opf_files else ''
|
|
836
|
+
for item in spine_items[:50]:
|
|
837
|
+
path = (base + item).lstrip('/')
|
|
838
|
+
if path not in z.namelist():
|
|
839
|
+
path = item
|
|
840
|
+
if path not in z.namelist():
|
|
841
|
+
continue
|
|
842
|
+
with z.open(path) as f:
|
|
843
|
+
content = f.read().decode('utf-8', errors='ignore')
|
|
844
|
+
text = re.sub(r'<[^>]+>', ' ', content)
|
|
845
|
+
text = re.sub(r'\s+', ' ', text).strip()
|
|
846
|
+
if text:
|
|
847
|
+
parts.append(text)
|
|
848
|
+
return "\n\n".join(parts) if parts else "[Empty EPUB]"
|
|
849
|
+
except Exception as e:
|
|
850
|
+
return f"[Error extracting EPUB: {e}]"
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def extract_zip(filepath):
|
|
854
|
+
"""List contents of a ZIP/archive file."""
|
|
855
|
+
try:
|
|
856
|
+
with zipfile.ZipFile(filepath, 'r') as z:
|
|
857
|
+
entries = z.infolist()
|
|
858
|
+
lines = [f"## Archive Contents ({len(entries)} files)\n"]
|
|
859
|
+
for entry in entries[:200]:
|
|
860
|
+
size = f"{entry.file_size:,} bytes" if entry.file_size else "dir"
|
|
861
|
+
lines.append(f"- `{entry.filename}` ({size})")
|
|
862
|
+
if len(entries) > 200:
|
|
863
|
+
lines.append(f"\n*({len(entries)} total, showing first 200)*")
|
|
864
|
+
return "\n".join(lines)
|
|
865
|
+
except Exception as e:
|
|
866
|
+
return f"[Error reading archive: {e}]"
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def extract_csv(filepath):
|
|
870
|
+
try:
|
|
871
|
+
import csv
|
|
872
|
+
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
|
|
873
|
+
reader = csv.reader(f)
|
|
874
|
+
rows = list(reader)
|
|
875
|
+
if not rows:
|
|
876
|
+
return "(Empty CSV)"
|
|
877
|
+
header = rows[0]
|
|
878
|
+
lines = ["| " + " | ".join(header) + " |",
|
|
879
|
+
"| " + " | ".join(["---"] * len(header)) + " |"]
|
|
880
|
+
for row in rows[1:101]: # cap at 100 rows
|
|
881
|
+
lines.append("| " + " | ".join(str(c) for c in row) + " |")
|
|
882
|
+
if len(rows) > 101:
|
|
883
|
+
lines.append(f"\n*({len(rows) - 1} rows total, showing first 100)*")
|
|
884
|
+
return "\n".join(lines)
|
|
885
|
+
except Exception as e:
|
|
886
|
+
return f"[Error extracting CSV: {e}]"
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def extract_ipynb(filepath):
|
|
890
|
+
"""Extract Jupyter Notebook: markdown cells + code cells + outputs."""
|
|
891
|
+
try:
|
|
892
|
+
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
|
|
893
|
+
nb = json.load(f)
|
|
894
|
+
parts = []
|
|
895
|
+
for i, cell in enumerate(nb.get('cells', [])):
|
|
896
|
+
ctype = cell.get('cell_type', '')
|
|
897
|
+
source = ''.join(cell.get('source', []))
|
|
898
|
+
if ctype == 'markdown':
|
|
899
|
+
parts.append(source)
|
|
900
|
+
elif ctype == 'code':
|
|
901
|
+
parts.append(f"```python\n{source}\n```")
|
|
902
|
+
for output in cell.get('outputs', []):
|
|
903
|
+
otype = output.get('output_type', '')
|
|
904
|
+
if otype in ('stream', 'execute_result', 'display_data'):
|
|
905
|
+
text = output.get('text') or output.get('data', {}).get('text/plain', [])
|
|
906
|
+
out = ''.join(text) if isinstance(text, list) else str(text)
|
|
907
|
+
if out.strip():
|
|
908
|
+
parts.append(f"**Output:**\n```\n{out.strip()}\n```")
|
|
909
|
+
return "\n\n".join(parts)
|
|
910
|
+
except Exception as e:
|
|
911
|
+
return f"[Error extracting notebook: {e}]"
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def extract_media_metadata(filepath, mime_type):
|
|
915
|
+
"""Extract duration, format info, and process Video/Audio based on FAR_VIDEO_MODE."""
|
|
916
|
+
mode = os.environ.get("FAR_VIDEO_MODE", "A").upper()
|
|
917
|
+
if mode == "ALL": mode = "D"
|
|
918
|
+
|
|
919
|
+
parts = []
|
|
920
|
+
|
|
921
|
+
# 1. Base ffprobe metadata
|
|
922
|
+
local_info = ""
|
|
923
|
+
import shutil
|
|
924
|
+
try:
|
|
925
|
+
if not os.path.exists(FFPROBE_BIN) and shutil.which('ffprobe') is None:
|
|
926
|
+
local_info = "[ffprobe not found]"
|
|
927
|
+
else:
|
|
928
|
+
cmd = [FFPROBE_BIN, '-v', 'error', '-show_entries', 'format=duration,size,bit_rate:stream=codec_name,width,height', '-of', 'default=noprint_wrappers=1:nokey=1', filepath]
|
|
929
|
+
if not os.path.exists(FFPROBE_BIN): cmd[0] = 'ffprobe'
|
|
930
|
+
|
|
931
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
932
|
+
if result.returncode == 0:
|
|
933
|
+
local_info = f"Media Info (ffprobe):\n{result.stdout}"
|
|
934
|
+
except Exception as e:
|
|
935
|
+
local_info = f"[Media extraction error: {e}]"
|
|
936
|
+
parts.append(local_info)
|
|
937
|
+
|
|
938
|
+
# 2. Audio Processing (Option B) - Applies to both audio/video files
|
|
939
|
+
if mode in ["B", "D"] or mime_type.startswith('audio/'):
|
|
940
|
+
ai_transcript = openai_transcribe(filepath)
|
|
941
|
+
if ai_transcript:
|
|
942
|
+
parts.append(f"## Audio Transcript (Option B)\n{ai_transcript}")
|
|
943
|
+
|
|
944
|
+
# 3. Video Processing (Option A & C)
|
|
945
|
+
if mime_type.startswith('video/') and mode in ["A", "C", "D"]:
|
|
946
|
+
import tempfile
|
|
947
|
+
try:
|
|
948
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
949
|
+
# Extract 1 frame per 3 seconds (max 20 frames to avoid hanging)
|
|
950
|
+
cmd = [FFMPEG_BIN if os.path.exists(FFMPEG_BIN) else 'ffmpeg', '-y', '-i', filepath, '-vf', 'fps=1/3', '-vframes', '20', os.path.join(tmpdir, 'frame_%04d.png')]
|
|
951
|
+
subprocess.run(cmd, capture_output=True)
|
|
952
|
+
|
|
953
|
+
frames = sorted(Path(tmpdir).glob('*.png'))
|
|
954
|
+
if frames:
|
|
955
|
+
# Option A: Local OCR (Default)
|
|
956
|
+
if mode in ["A", "D"]:
|
|
957
|
+
ocr_texts = []
|
|
958
|
+
seen_lines = set()
|
|
959
|
+
for f in frames:
|
|
960
|
+
try:
|
|
961
|
+
r = subprocess.run(['tesseract', str(f), 'stdout', '-l', 'chi_sim+eng'], capture_output=True, text=True)
|
|
962
|
+
if r.returncode == 0:
|
|
963
|
+
text = r.stdout.strip()
|
|
964
|
+
lines = [l.strip() for l in text.split("\n") if len(l.strip()) > 2]
|
|
965
|
+
new_lines = []
|
|
966
|
+
for l in lines:
|
|
967
|
+
if l not in seen_lines:
|
|
968
|
+
seen_lines.add(l)
|
|
969
|
+
new_lines.append(l)
|
|
970
|
+
if new_lines:
|
|
971
|
+
ocr_texts.append("\n".join(new_lines))
|
|
972
|
+
except Exception:
|
|
973
|
+
pass
|
|
974
|
+
if ocr_texts:
|
|
975
|
+
parts.append("## Video Frame OCR (Option A)\n" + "\n...\n".join(ocr_texts))
|
|
976
|
+
|
|
977
|
+
apple_video_summary = summarize_apple_vision_frames(frames)
|
|
978
|
+
if apple_video_summary:
|
|
979
|
+
parts.append(apple_video_summary)
|
|
980
|
+
|
|
981
|
+
# Option C: Vision API
|
|
982
|
+
if mode in ["C", "D"]:
|
|
983
|
+
api_key = get_openai_key()
|
|
984
|
+
if api_key:
|
|
985
|
+
selected_frames = frames[::max(1, len(frames)//5)][:5] # Up to 5 evenly spaced frames
|
|
986
|
+
images_content = []
|
|
987
|
+
for f in selected_frames:
|
|
988
|
+
with open(f, "rb") as image_file:
|
|
989
|
+
base64_image = base64.b64encode(image_file.read()).decode('utf-8')
|
|
990
|
+
images_content.append({"type": "image_url", "image_url": {"url": f"data:image/png;base64,{base64_image}"}})
|
|
991
|
+
|
|
992
|
+
try:
|
|
993
|
+
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"}
|
|
994
|
+
payload = {
|
|
995
|
+
"model": OPENAI_MODEL_IMAGE,
|
|
996
|
+
"messages": [{"role": "user", "content": [{"type": "text", "text": "Describe the sequence of events and any text visible in these video frames."}] + images_content}],
|
|
997
|
+
"max_tokens": 1000
|
|
998
|
+
}
|
|
999
|
+
import urllib.request
|
|
1000
|
+
import json
|
|
1001
|
+
req = urllib.request.Request(f"{get_openai_base()}/chat/completions", data=json.dumps(payload).encode('utf-8'), headers=headers)
|
|
1002
|
+
with urllib.request.urlopen(req) as response:
|
|
1003
|
+
res = json.load(response)
|
|
1004
|
+
content_vision = res['choices'][0]['message']['content']
|
|
1005
|
+
parts.append(f"## AI Vision Summary (Option C)\n{content_vision}")
|
|
1006
|
+
except Exception as e:
|
|
1007
|
+
parts.append(f"[AI Vision Error: {e}]")
|
|
1008
|
+
except Exception as e:
|
|
1009
|
+
parts.append(f"[Video frame processing error: {e}]")
|
|
1010
|
+
|
|
1011
|
+
return "\n\n".join(parts)
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def extract_image_ocr(filepath):
|
|
1015
|
+
"""Extract image content via local OCR, Apple Vision (macOS), and optional OpenAI Vision."""
|
|
1016
|
+
parts = []
|
|
1017
|
+
|
|
1018
|
+
# Local OCR
|
|
1019
|
+
try:
|
|
1020
|
+
subprocess.run(['tesseract', '--version'], capture_output=True, check=False)
|
|
1021
|
+
result = subprocess.run(['tesseract', filepath, '-', '-l', 'eng+chi_sim'], capture_output=True, text=True)
|
|
1022
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
1023
|
+
parts.append(f"[Local OCR]:\n{result.stdout}")
|
|
1024
|
+
except FileNotFoundError:
|
|
1025
|
+
parts.append("[Error: tesseract not installed]")
|
|
1026
|
+
|
|
1027
|
+
# Apple Vision (on-device, macOS only)
|
|
1028
|
+
apple = apple_vision(filepath)
|
|
1029
|
+
if apple:
|
|
1030
|
+
parts.append(apple)
|
|
1031
|
+
|
|
1032
|
+
# OpenAI Vision (optional, API)
|
|
1033
|
+
ai_vision = openai_vision(filepath)
|
|
1034
|
+
if ai_vision:
|
|
1035
|
+
parts.append(ai_vision)
|
|
1036
|
+
|
|
1037
|
+
return "\n\n".join(parts).strip()
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def extract_tar(filepath):
|
|
1041
|
+
"""List contents of a tar/tar.gz/tar.bz2 archive."""
|
|
1042
|
+
try:
|
|
1043
|
+
import tarfile
|
|
1044
|
+
with tarfile.open(filepath, 'r:*') as t:
|
|
1045
|
+
members = t.getmembers()
|
|
1046
|
+
lines = [f"## Archive Contents ({len(members)} entries)\n"]
|
|
1047
|
+
for m in members[:200]:
|
|
1048
|
+
size = f"{m.size:,} bytes" if m.isfile() else "dir"
|
|
1049
|
+
lines.append(f"- `{m.name}` ({size})")
|
|
1050
|
+
if len(members) > 200:
|
|
1051
|
+
lines.append(f"\n*({len(members)} total, showing first 200)*")
|
|
1052
|
+
return "\n".join(lines)
|
|
1053
|
+
except Exception as e:
|
|
1054
|
+
return f"[Error reading archive: {e}]"
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def extract_eml(filepath):
|
|
1058
|
+
"""Extract email content: headers + body + attachment list."""
|
|
1059
|
+
try:
|
|
1060
|
+
import email
|
|
1061
|
+
with open(filepath, 'rb') as f:
|
|
1062
|
+
msg = email.message_from_bytes(f.read())
|
|
1063
|
+
parts = []
|
|
1064
|
+
parts.append(f"**From:** {msg.get('From', '')}")
|
|
1065
|
+
parts.append(f"**To:** {msg.get('To', '')}")
|
|
1066
|
+
parts.append(f"**Subject:** {msg.get('Subject', '')}")
|
|
1067
|
+
parts.append(f"**Date:** {msg.get('Date', '')}")
|
|
1068
|
+
parts.append("")
|
|
1069
|
+
attachments = []
|
|
1070
|
+
for part in msg.walk():
|
|
1071
|
+
ct = part.get_content_type()
|
|
1072
|
+
cd = str(part.get('Content-Disposition', ''))
|
|
1073
|
+
if 'attachment' in cd:
|
|
1074
|
+
attachments.append(part.get_filename() or 'unnamed')
|
|
1075
|
+
elif ct == 'text/plain' and 'attachment' not in cd:
|
|
1076
|
+
payload = part.get_payload(decode=True)
|
|
1077
|
+
if payload:
|
|
1078
|
+
parts.append(payload.decode('utf-8', errors='ignore'))
|
|
1079
|
+
elif ct == 'text/html' and 'attachment' not in cd:
|
|
1080
|
+
payload = part.get_payload(decode=True)
|
|
1081
|
+
if payload:
|
|
1082
|
+
import re
|
|
1083
|
+
text = re.sub(r'<[^>]+>', ' ', payload.decode('utf-8', errors='ignore'))
|
|
1084
|
+
text = re.sub(r'\s+', ' ', text).strip()
|
|
1085
|
+
parts.append(text)
|
|
1086
|
+
if attachments:
|
|
1087
|
+
parts.append(f"\n**Attachments:** {', '.join(attachments)}")
|
|
1088
|
+
return "\n".join(parts)
|
|
1089
|
+
except Exception as e:
|
|
1090
|
+
return f"[Error extracting email: {e}]"
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
def extract_rtf(filepath):
|
|
1094
|
+
"""Extract plain text from RTF by stripping control words."""
|
|
1095
|
+
try:
|
|
1096
|
+
import re
|
|
1097
|
+
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
|
|
1098
|
+
content = f.read()
|
|
1099
|
+
# Remove RTF control words and groups
|
|
1100
|
+
text = re.sub(r'\\[a-z]+[-]?\d*[ ]?', '', content)
|
|
1101
|
+
text = re.sub(r'[{}\\]', '', text)
|
|
1102
|
+
text = re.sub(r'\s+', ' ', text).strip()
|
|
1103
|
+
return text
|
|
1104
|
+
except Exception as e:
|
|
1105
|
+
return f"[Error extracting RTF: {e}]"
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
def extract_sqlite(filepath):
|
|
1109
|
+
"""Extract table schemas and recent rows from SQLite (up to 20 rows per table)."""
|
|
1110
|
+
MAX_ROWS = 20
|
|
1111
|
+
try:
|
|
1112
|
+
import sqlite3
|
|
1113
|
+
con = sqlite3.connect(filepath)
|
|
1114
|
+
cur = con.cursor()
|
|
1115
|
+
cur.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
|
1116
|
+
tables = [r[0] for r in cur.fetchall()]
|
|
1117
|
+
parts = [f"## SQLite Database\n\n**Tables:** {len(tables)}"]
|
|
1118
|
+
for table in tables:
|
|
1119
|
+
try:
|
|
1120
|
+
cur.execute(f"PRAGMA table_info(`{table}`)")
|
|
1121
|
+
col_info = cur.fetchall()
|
|
1122
|
+
cols = [r[1] for r in col_info]
|
|
1123
|
+
col_types = [f"`{r[1]}` {r[2]}" for r in col_info]
|
|
1124
|
+
cur.execute(f"SELECT COUNT(*) FROM `{table}`")
|
|
1125
|
+
count = cur.fetchone()[0]
|
|
1126
|
+
parts.append(f"### {table} ({count:,} rows)\n**Schema:** " + ", ".join(col_types))
|
|
1127
|
+
# Fetch most recent rows by rowid desc
|
|
1128
|
+
cur.execute(f"SELECT * FROM `{table}` ORDER BY rowid DESC LIMIT {MAX_ROWS}")
|
|
1129
|
+
rows = cur.fetchall()
|
|
1130
|
+
if rows:
|
|
1131
|
+
header = "| " + " | ".join(cols) + " |"
|
|
1132
|
+
sep = "| " + " | ".join(["---"] * len(cols)) + " |"
|
|
1133
|
+
lines = [header, sep]
|
|
1134
|
+
for row in reversed(rows): # show oldest-first within the sample
|
|
1135
|
+
lines.append("| " + " | ".join(str(v) if v is not None else "" for v in row) + " |")
|
|
1136
|
+
parts.append("\n".join(lines))
|
|
1137
|
+
if count > MAX_ROWS:
|
|
1138
|
+
parts.append(f"*({count:,} rows total, showing latest {MAX_ROWS})*")
|
|
1139
|
+
except Exception as ex:
|
|
1140
|
+
parts.append(f"### {table} (error: {ex})")
|
|
1141
|
+
con.close()
|
|
1142
|
+
return "\n\n".join(parts)
|
|
1143
|
+
except Exception as e:
|
|
1144
|
+
return f"[Error extracting SQLite: {e}]"
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
def extract_parquet(filepath):
|
|
1148
|
+
"""[Metadata only] Extract schema and row count from Parquet file."""
|
|
1149
|
+
try:
|
|
1150
|
+
import struct
|
|
1151
|
+
# Read Parquet footer magic to confirm format, then use pyarrow if available
|
|
1152
|
+
try:
|
|
1153
|
+
import pyarrow.parquet as pq
|
|
1154
|
+
pf = pq.read_metadata(filepath)
|
|
1155
|
+
schema = pq.read_schema(filepath)
|
|
1156
|
+
fields = [f"`{f.name}` {f.type}" for f in schema]
|
|
1157
|
+
parts = [
|
|
1158
|
+
f"## Parquet File",
|
|
1159
|
+
f"**Rows:** {pf.num_rows:,} **Row groups:** {pf.num_row_groups} **Columns:** {len(fields)}",
|
|
1160
|
+
"\n### Schema\n" + "\n".join(f"- {f}" for f in fields),
|
|
1161
|
+
"\n> [Metadata only] — row content not extracted.",
|
|
1162
|
+
]
|
|
1163
|
+
return "\n\n".join(parts)
|
|
1164
|
+
except ImportError:
|
|
1165
|
+
return "[Metadata only] Parquet file detected. Install `pyarrow` for schema extraction."
|
|
1166
|
+
except Exception as e:
|
|
1167
|
+
return f"[Error extracting Parquet: {e}]"
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
def extract_design_metadata(filepath):
|
|
1171
|
+
"""[Metadata only] Extract basic metadata from .fig / .sketch / .xd design files."""
|
|
1172
|
+
try:
|
|
1173
|
+
stat = os.stat(filepath)
|
|
1174
|
+
ext = Path(filepath).suffix.lower()
|
|
1175
|
+
name = Path(filepath).name
|
|
1176
|
+
size_kb = stat.st_size / 1024
|
|
1177
|
+
# .sketch and .fig are zip-based, try to list internal structure
|
|
1178
|
+
try:
|
|
1179
|
+
with zipfile.ZipFile(filepath, 'r') as z:
|
|
1180
|
+
entries = z.namelist()
|
|
1181
|
+
pages = [e for e in entries if 'page' in e.lower() or 'canvas' in e.lower()]
|
|
1182
|
+
parts = [
|
|
1183
|
+
f"## {name}",
|
|
1184
|
+
f"**Format:** {ext[1:].upper()} **Size:** {size_kb:.1f} KB",
|
|
1185
|
+
f"**Internal files:** {len(entries)}",
|
|
1186
|
+
]
|
|
1187
|
+
if pages:
|
|
1188
|
+
parts.append(f"**Pages/Canvases:** {len(pages)}")
|
|
1189
|
+
parts.append("\n> [Metadata only] — design content requires native app to render.")
|
|
1190
|
+
return "\n\n".join(parts)
|
|
1191
|
+
except Exception:
|
|
1192
|
+
return (f"## {name}\n\n**Format:** {ext[1:].upper()} **Size:** {size_kb:.1f} KB"
|
|
1193
|
+
"\n\n> [Metadata only] — design content requires native app to render.")
|
|
1194
|
+
except Exception as e:
|
|
1195
|
+
return f"[Error reading design file: {e}]"
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
# --- Ignorer ---
|
|
1199
|
+
|
|
1200
|
+
def load_farignore(root_dir):
|
|
1201
|
+
ignore_patterns = ['.git', '.meta', '.DS_Store', 'node_modules']
|
|
1202
|
+
ignore_path = os.path.join(root_dir, '.farignore')
|
|
1203
|
+
if os.path.exists(ignore_path):
|
|
1204
|
+
with open(ignore_path, 'r') as f:
|
|
1205
|
+
for line in f:
|
|
1206
|
+
line = line.strip()
|
|
1207
|
+
if line and not line.startswith('#'):
|
|
1208
|
+
ignore_patterns.append(line)
|
|
1209
|
+
return ignore_patterns
|
|
1210
|
+
|
|
1211
|
+
def should_ignore(path, root_dir, ignore_patterns):
|
|
1212
|
+
import fnmatch
|
|
1213
|
+
rel_path = os.path.relpath(path, root_dir).replace(os.sep, '/')
|
|
1214
|
+
parts = rel_path.split('/')
|
|
1215
|
+
for pattern in ignore_patterns:
|
|
1216
|
+
p = pattern.rstrip('/')
|
|
1217
|
+
# Match any path component
|
|
1218
|
+
if p in parts:
|
|
1219
|
+
return True
|
|
1220
|
+
# Full glob match
|
|
1221
|
+
if fnmatch.fnmatch(rel_path, pattern) or fnmatch.fnmatch(rel_path, pattern.rstrip('/') + '/*'):
|
|
1222
|
+
return True
|
|
1223
|
+
# Match each component
|
|
1224
|
+
for part in parts:
|
|
1225
|
+
if fnmatch.fnmatch(part, p):
|
|
1226
|
+
return True
|
|
1227
|
+
return False
|
|
1228
|
+
|
|
1229
|
+
# --- Generators ---
|
|
1230
|
+
|
|
1231
|
+
def generate_file_meta(filepath, root_dir, ignore_patterns, force=False):
|
|
1232
|
+
if should_ignore(filepath, root_dir, ignore_patterns):
|
|
1233
|
+
return None
|
|
1234
|
+
|
|
1235
|
+
file_path = Path(filepath)
|
|
1236
|
+
meta_path = file_path.with_suffix(file_path.suffix + ".meta")
|
|
1237
|
+
|
|
1238
|
+
# 1. Fast Check: Mtime & Size
|
|
1239
|
+
try:
|
|
1240
|
+
stat = os.stat(filepath)
|
|
1241
|
+
current_mtime = stat.st_mtime
|
|
1242
|
+
current_size = stat.st_size
|
|
1243
|
+
except FileNotFoundError:
|
|
1244
|
+
log(f"Error: File not found {filepath}", "ERROR")
|
|
1245
|
+
return None
|
|
1246
|
+
|
|
1247
|
+
if not force and meta_path.exists():
|
|
1248
|
+
try:
|
|
1249
|
+
with open(meta_path, 'r', encoding='utf-8') as f:
|
|
1250
|
+
content = f.read()
|
|
1251
|
+
if (f"mtime: {current_mtime}" in content
|
|
1252
|
+
and f"size: {current_size}" in content
|
|
1253
|
+
and f"pipeline: {PIPELINE_ID}" in content):
|
|
1254
|
+
return meta_path
|
|
1255
|
+
except Exception:
|
|
1256
|
+
pass
|
|
1257
|
+
|
|
1258
|
+
# 2. Slow Check: SHA256 (if mtime/size/pipeline changed)
|
|
1259
|
+
current_hash = get_sha256(filepath)
|
|
1260
|
+
if not current_hash:
|
|
1261
|
+
log(f"Error: Cannot hash {filepath}", "ERROR")
|
|
1262
|
+
return None
|
|
1263
|
+
|
|
1264
|
+
# Check SHA256 even if mtime changed (content may be same)
|
|
1265
|
+
if not force and meta_path.exists():
|
|
1266
|
+
try:
|
|
1267
|
+
with open(meta_path, 'r', encoding='utf-8') as f:
|
|
1268
|
+
content = f.read()
|
|
1269
|
+
if (f"sha256: {current_hash}" in content
|
|
1270
|
+
and f"pipeline: {PIPELINE_ID}" in content):
|
|
1271
|
+
# Content unchanged, just update mtime in meta
|
|
1272
|
+
updated = content.replace(
|
|
1273
|
+
next(l for l in content.splitlines() if l.strip().startswith('mtime:')),
|
|
1274
|
+
f" mtime: {current_mtime}"
|
|
1275
|
+
)
|
|
1276
|
+
with open(meta_path, 'w', encoding='utf-8') as f:
|
|
1277
|
+
f.write(updated)
|
|
1278
|
+
return meta_path
|
|
1279
|
+
except Exception:
|
|
1280
|
+
pass
|
|
1281
|
+
|
|
1282
|
+
# Extract
|
|
1283
|
+
log(f"Processing: {filepath} ({current_hash[:8]}...)")
|
|
1284
|
+
mime_type = get_mime_type(filepath)
|
|
1285
|
+
ext = file_path.suffix.lower()
|
|
1286
|
+
extracted_text = ""
|
|
1287
|
+
layout = {}
|
|
1288
|
+
start_time = datetime.datetime.now()
|
|
1289
|
+
|
|
1290
|
+
if ext == '.pdf':
|
|
1291
|
+
extracted_text = extract_pdf(filepath)
|
|
1292
|
+
# Count pages
|
|
1293
|
+
try:
|
|
1294
|
+
r = subprocess.run(['pdfinfo', filepath], capture_output=True, text=True)
|
|
1295
|
+
for line in r.stdout.splitlines():
|
|
1296
|
+
if line.startswith('Pages:'):
|
|
1297
|
+
layout['pages'] = int(line.split(':')[1].strip())
|
|
1298
|
+
except Exception:
|
|
1299
|
+
pass
|
|
1300
|
+
elif ext == '.docx': extracted_text = extract_docx(filepath)
|
|
1301
|
+
elif ext == '.doc': extracted_text = extract_doc(filepath)
|
|
1302
|
+
elif ext == '.xlsx':
|
|
1303
|
+
extracted_text = extract_xlsx(filepath)
|
|
1304
|
+
# Count sheets
|
|
1305
|
+
try:
|
|
1306
|
+
with zipfile.ZipFile(filepath, 'r') as z:
|
|
1307
|
+
sheets = [f for f in z.namelist() if f.startswith('xl/worksheets/sheet') and f.endswith('.xml')]
|
|
1308
|
+
layout['sheets'] = len(sheets)
|
|
1309
|
+
except Exception:
|
|
1310
|
+
pass
|
|
1311
|
+
elif ext == '.pptx':
|
|
1312
|
+
extracted_text = extract_pptx(filepath)
|
|
1313
|
+
try:
|
|
1314
|
+
with zipfile.ZipFile(filepath, 'r') as z:
|
|
1315
|
+
slides = [f for f in z.namelist() if f.startswith('ppt/slides/slide') and f.endswith('.xml')]
|
|
1316
|
+
layout['slides'] = len(slides)
|
|
1317
|
+
except Exception:
|
|
1318
|
+
pass
|
|
1319
|
+
elif ext == '.csv': extracted_text = extract_csv(filepath)
|
|
1320
|
+
elif ext == '.ipynb': extracted_text = extract_ipynb(filepath)
|
|
1321
|
+
elif ext == '.epub': extracted_text = extract_epub(filepath)
|
|
1322
|
+
elif ext in ('.zip', '.jar', '.whl', '.apk'): extracted_text = extract_zip(filepath)
|
|
1323
|
+
elif ext in ('.tar', '.gz', '.bz2', '.xz', '.tgz'): extracted_text = extract_tar(filepath)
|
|
1324
|
+
elif ext in ('.eml', '.msg'): extracted_text = extract_eml(filepath)
|
|
1325
|
+
elif ext == '.rtf': extracted_text = extract_rtf(filepath)
|
|
1326
|
+
elif ext in ('.db', '.sqlite', '.sqlite3'): extracted_text = extract_sqlite(filepath)
|
|
1327
|
+
elif ext == '.parquet': extracted_text = extract_parquet(filepath)
|
|
1328
|
+
elif ext in ('.fig', '.sketch', '.xd'): extracted_text = extract_design_metadata(filepath)
|
|
1329
|
+
elif mime_type.startswith('image/'): extracted_text = extract_image_ocr(filepath)
|
|
1330
|
+
elif mime_type.startswith('video/') or mime_type.startswith('audio/'): extracted_text = extract_media_metadata(filepath, mime_type)
|
|
1331
|
+
elif mime_type.startswith('text/') or ext in ['.txt', '.md', '.json', '.yml', '.py', '.sh', '.meta', '.js', '.css', '.html', '.xml']:
|
|
1332
|
+
try:
|
|
1333
|
+
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
|
|
1334
|
+
extracted_text = f.read()
|
|
1335
|
+
except: extracted_text = "[Read Error]"
|
|
1336
|
+
else:
|
|
1337
|
+
extracted_text = f"[Binary: {mime_type}]"
|
|
1338
|
+
|
|
1339
|
+
macos_meta = extract_macos_metadata(filepath)
|
|
1340
|
+
if macos_meta:
|
|
1341
|
+
if extracted_text.strip():
|
|
1342
|
+
extracted_text = f"{extracted_text}\n\n{macos_meta}"
|
|
1343
|
+
else:
|
|
1344
|
+
extracted_text = macos_meta
|
|
1345
|
+
|
|
1346
|
+
duration = (datetime.datetime.now() - start_time).total_seconds()
|
|
1347
|
+
log(f"Done: {filepath} (Time: {duration:.2f}s)")
|
|
1348
|
+
|
|
1349
|
+
|
|
1350
|
+
# Write .meta
|
|
1351
|
+
timestamp = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
1352
|
+
layout_yaml = ""
|
|
1353
|
+
if layout:
|
|
1354
|
+
layout_yaml = "layout:\n" + "".join(f" {k}: {v}\n" for k, v in layout.items())
|
|
1355
|
+
meta_content = f"""--far_version: 1
|
|
1356
|
+
source:
|
|
1357
|
+
sha256: {current_hash}
|
|
1358
|
+
mime: {mime_type}
|
|
1359
|
+
size: {current_size}
|
|
1360
|
+
mtime: {current_mtime}
|
|
1361
|
+
extract:
|
|
1362
|
+
pipeline: {PIPELINE_ID}
|
|
1363
|
+
extracted_at: {timestamp}
|
|
1364
|
+
deterministic: true
|
|
1365
|
+
{layout_yaml}---
|
|
1366
|
+
# {file_path.name}
|
|
1367
|
+
|
|
1368
|
+
{extracted_text}
|
|
1369
|
+
"""
|
|
1370
|
+
with open(meta_path, 'w', encoding='utf-8') as f:
|
|
1371
|
+
f.write(meta_content)
|
|
1372
|
+
|
|
1373
|
+
log(f"Generated meta: {meta_path}", "DEBUG")
|
|
1374
|
+
return meta_path
|
|
1375
|
+
|
|
1376
|
+
def generate_dir_meta(dirpath, root_dir, ignore_patterns, files_in_dir):
|
|
1377
|
+
if should_ignore(dirpath, root_dir, ignore_patterns):
|
|
1378
|
+
return
|
|
1379
|
+
|
|
1380
|
+
meta_path = os.path.join(dirpath, ".dir.meta")
|
|
1381
|
+
|
|
1382
|
+
# Deterministic ordering avoids churn between runs/platforms
|
|
1383
|
+
files_in_dir = sorted(files_in_dir, key=lambda x: x[0])
|
|
1384
|
+
|
|
1385
|
+
summary_lines = []
|
|
1386
|
+
summary_lines.append(f"# Directory: {os.path.basename(dirpath)}")
|
|
1387
|
+
summary_lines.append(f"Contains {len(files_in_dir)} files processed by FAR.\n")
|
|
1388
|
+
|
|
1389
|
+
count = 0
|
|
1390
|
+
for file, f_meta_path in files_in_dir:
|
|
1391
|
+
if count >= MAX_DIR_SUMMARY_FILES:
|
|
1392
|
+
summary_lines.append(f"\n... and {len(files_in_dir) - count} more files.")
|
|
1393
|
+
break
|
|
1394
|
+
|
|
1395
|
+
snippet = ""
|
|
1396
|
+
try:
|
|
1397
|
+
with open(f_meta_path, 'r', encoding='utf-8') as f:
|
|
1398
|
+
content = f.read()
|
|
1399
|
+
parts = content.split('\n---\n')
|
|
1400
|
+
if len(parts) >= 2:
|
|
1401
|
+
body = parts[-1].strip()
|
|
1402
|
+
lines = body.splitlines()
|
|
1403
|
+
if lines and lines[0].startswith('# '):
|
|
1404
|
+
body = "\n".join(lines[1:]).strip()
|
|
1405
|
+
clean_body = " ".join(body.split())[:150]
|
|
1406
|
+
if clean_body:
|
|
1407
|
+
snippet = clean_body + "..."
|
|
1408
|
+
except Exception:
|
|
1409
|
+
pass
|
|
1410
|
+
|
|
1411
|
+
summary_lines.append(f"- **{file}**: {snippet}")
|
|
1412
|
+
count += 1
|
|
1413
|
+
|
|
1414
|
+
body_content = "\n".join(summary_lines)
|
|
1415
|
+
|
|
1416
|
+
# Skip rewrite if summary body unchanged and pipeline unchanged.
|
|
1417
|
+
# This prevents noisy .dir.meta timestamp churn in scheduled runs.
|
|
1418
|
+
if os.path.exists(meta_path):
|
|
1419
|
+
try:
|
|
1420
|
+
with open(meta_path, 'r', encoding='utf-8') as f:
|
|
1421
|
+
existing = f.read()
|
|
1422
|
+
existing_parts = existing.split('\n---\n', 1)
|
|
1423
|
+
existing_body = existing_parts[1].strip() if len(existing_parts) == 2 else existing.strip()
|
|
1424
|
+
if existing_body == body_content.strip() and f"pipeline: {PIPELINE_ID}" in existing:
|
|
1425
|
+
log(f"Skip dir.meta unchanged: {meta_path}", "DEBUG")
|
|
1426
|
+
return
|
|
1427
|
+
except Exception:
|
|
1428
|
+
pass
|
|
1429
|
+
|
|
1430
|
+
timestamp = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
1431
|
+
meta_content = f"""---
|
|
1432
|
+
far_version: 1
|
|
1433
|
+
type: directory
|
|
1434
|
+
extract:
|
|
1435
|
+
pipeline: {PIPELINE_ID}
|
|
1436
|
+
extracted_at: {timestamp}
|
|
1437
|
+
---
|
|
1438
|
+
{body_content}
|
|
1439
|
+
"""
|
|
1440
|
+
with open(meta_path, 'w', encoding='utf-8') as f:
|
|
1441
|
+
f.write(meta_content)
|
|
1442
|
+
log(f"Generated dir.meta: {meta_path}", "DEBUG")
|
|
1443
|
+
|
|
1444
|
+
def main():
|
|
1445
|
+
parser = argparse.ArgumentParser(description="Generate .meta sidecar files for AI readability (FAR protocol).")
|
|
1446
|
+
parser.add_argument("path", nargs="?", default=".", help="Target directory or file to scan")
|
|
1447
|
+
parser.add_argument("--force", action="store_true", help="Force regenerate .meta files even if unchanged")
|
|
1448
|
+
args = parser.parse_args()
|
|
1449
|
+
|
|
1450
|
+
target_path = os.path.abspath(args.path)
|
|
1451
|
+
|
|
1452
|
+
# Handle single file case
|
|
1453
|
+
if os.path.isfile(target_path):
|
|
1454
|
+
root_dir = os.path.dirname(target_path)
|
|
1455
|
+
ignore_patterns = load_farignore(root_dir)
|
|
1456
|
+
generate_file_meta(target_path, root_dir, ignore_patterns, force=args.force)
|
|
1457
|
+
return
|
|
1458
|
+
|
|
1459
|
+
if not os.path.isdir(target_path):
|
|
1460
|
+
log(f"Error: {target_path} is not a directory or file", "ERROR")
|
|
1461
|
+
sys.exit(1)
|
|
1462
|
+
|
|
1463
|
+
ignore_patterns = load_farignore(target_path)
|
|
1464
|
+
log(f"FAR scanning: {target_path} (Ignore: {ignore_patterns})")
|
|
1465
|
+
|
|
1466
|
+
for root, dirs, files in os.walk(target_path, topdown=False):
|
|
1467
|
+
if should_ignore(root, target_path, ignore_patterns): continue
|
|
1468
|
+
|
|
1469
|
+
processed_files = []
|
|
1470
|
+
|
|
1471
|
+
files_sorted = sorted(files)
|
|
1472
|
+
|
|
1473
|
+
# --- Cleanup orphaned .meta files ---
|
|
1474
|
+
for file in files_sorted:
|
|
1475
|
+
if file.endswith('.meta') and file != '.dir.meta':
|
|
1476
|
+
original_file = file[:-5]
|
|
1477
|
+
if original_file not in files_sorted:
|
|
1478
|
+
orphan_path = os.path.join(root, file)
|
|
1479
|
+
try:
|
|
1480
|
+
os.remove(orphan_path)
|
|
1481
|
+
log(f"Cleaned up orphaned meta: {orphan_path}")
|
|
1482
|
+
except Exception:
|
|
1483
|
+
pass
|
|
1484
|
+
# ------------------------------------
|
|
1485
|
+
|
|
1486
|
+
for file in files_sorted:
|
|
1487
|
+
if file.endswith('.meta') or file.startswith('.'):
|
|
1488
|
+
continue
|
|
1489
|
+
|
|
1490
|
+
file_path = os.path.join(root, file)
|
|
1491
|
+
meta_path = generate_file_meta(file_path, target_path, ignore_patterns, force=args.force)
|
|
1492
|
+
if meta_path:
|
|
1493
|
+
processed_files.append((file, meta_path))
|
|
1494
|
+
|
|
1495
|
+
if processed_files:
|
|
1496
|
+
generate_dir_meta(root, target_path, ignore_patterns, processed_files)
|
|
1497
|
+
|
|
1498
|
+
if __name__ == "__main__":
|
|
1499
|
+
main()
|