@buda-ai/connector 0.1.0 → 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.
Files changed (56) hide show
  1. package/README.md +22 -1
  2. package/agent-templates/scripts/skill-scanner.sh +125 -0
  3. package/agent-templates/shared/AGENTS.md +220 -0
  4. package/agent-templates/shared/BOOTSTRAP.md +15 -0
  5. package/agent-templates/shared/HEARTBEAT.md +5 -0
  6. package/agent-templates/shared/IDENTITY.md +29 -0
  7. package/agent-templates/shared/MEMORY.md +19 -0
  8. package/agent-templates/shared/SOUL.md +43 -0
  9. package/agent-templates/shared/TOOLS.md +85 -0
  10. package/agent-templates/shared/USER.md +23 -0
  11. package/agent-templates/system-skills/app-builder/README.md +8 -0
  12. package/agent-templates/system-skills/app-builder/SKILL.md +678 -0
  13. package/agent-templates/system-skills/app-builder/buda.app.schema.json +165 -0
  14. package/agent-templates/system-skills/app-builder/evals/evals.json +23 -0
  15. package/agent-templates/system-skills/app-builder/scripts/check-preview-url.mjs +184 -0
  16. package/agent-templates/system-skills/buda-automations/SKILL.md +175 -0
  17. package/agent-templates/system-skills/buda-automations/scripts/create-automation.sh +226 -0
  18. package/agent-templates/system-skills/buda-automations/scripts/delete-automation.sh +73 -0
  19. package/agent-templates/system-skills/buda-automations/scripts/list-automations.sh +77 -0
  20. package/agent-templates/system-skills/buda-far/SKILL.md +221 -0
  21. package/agent-templates/system-skills/buda-far/far_gen.py +1499 -0
  22. package/agent-templates/system-skills/buda-machine-info/SKILL.md +67 -0
  23. package/agent-templates/system-skills/buda-machine-info/scripts/get-info.sh +25 -0
  24. package/agent-templates/system-skills/buda-machine-info/scripts/send-file.mjs +62 -0
  25. package/agent-templates/system-skills/buda-web-preview/SKILL.md +462 -0
  26. package/agent-templates/system-skills/find-skills/SKILL.md +142 -0
  27. package/agent-templates/system-skills/skill-creator/LICENSE.txt +202 -0
  28. package/agent-templates/system-skills/skill-creator/SKILL.md +479 -0
  29. package/agent-templates/system-skills/skill-creator/agents/analyzer.md +274 -0
  30. package/agent-templates/system-skills/skill-creator/agents/comparator.md +202 -0
  31. package/agent-templates/system-skills/skill-creator/agents/grader.md +223 -0
  32. package/agent-templates/system-skills/skill-creator/assets/eval_review.html +146 -0
  33. package/agent-templates/system-skills/skill-creator/eval-viewer/generate_review.py +471 -0
  34. package/agent-templates/system-skills/skill-creator/eval-viewer/viewer.html +1325 -0
  35. package/agent-templates/system-skills/skill-creator/references/schemas.md +430 -0
  36. package/agent-templates/system-skills/skill-creator/scripts/__init__.py +0 -0
  37. package/agent-templates/system-skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
  38. package/agent-templates/system-skills/skill-creator/scripts/generate_report.py +326 -0
  39. package/agent-templates/system-skills/skill-creator/scripts/improve_description.py +248 -0
  40. package/agent-templates/system-skills/skill-creator/scripts/package_skill.py +136 -0
  41. package/agent-templates/system-skills/skill-creator/scripts/quick_validate.py +103 -0
  42. package/agent-templates/system-skills/skill-creator/scripts/run_eval.py +310 -0
  43. package/agent-templates/system-skills/skill-creator/scripts/run_loop.py +332 -0
  44. package/agent-templates/system-skills/skill-creator/scripts/utils.py +47 -0
  45. package/dist/cli.js +2747 -180
  46. package/package.json +14 -6
  47. package/src/bunny-daemon.ts +0 -52
  48. package/src/cli.ts +0 -97
  49. package/src/cloud-client.ts +0 -134
  50. package/src/config.ts +0 -122
  51. package/src/logger.ts +0 -36
  52. package/src/relay.ts +0 -96
  53. package/src/runtime.ts +0 -59
  54. package/src/types.ts +0 -37
  55. package/tsconfig.json +0 -44
  56. package/tsup.config.ts +0 -20
@@ -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()