sentinel-ai-os 1.0__py3-none-any.whl

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 (48) hide show
  1. sentinel/__init__.py +0 -0
  2. sentinel/auth.py +40 -0
  3. sentinel/cli.py +9 -0
  4. sentinel/core/__init__.py +0 -0
  5. sentinel/core/agent.py +298 -0
  6. sentinel/core/audit.py +48 -0
  7. sentinel/core/cognitive.py +94 -0
  8. sentinel/core/config.py +99 -0
  9. sentinel/core/llm.py +143 -0
  10. sentinel/core/registry.py +351 -0
  11. sentinel/core/scheduler.py +61 -0
  12. sentinel/core/schema.py +11 -0
  13. sentinel/core/setup.py +101 -0
  14. sentinel/core/ui.py +112 -0
  15. sentinel/main.py +110 -0
  16. sentinel/paths.py +77 -0
  17. sentinel/tools/__init__.py +0 -0
  18. sentinel/tools/apps.py +462 -0
  19. sentinel/tools/audio.py +30 -0
  20. sentinel/tools/browser.py +66 -0
  21. sentinel/tools/calendar_ops.py +163 -0
  22. sentinel/tools/clock.py +25 -0
  23. sentinel/tools/context.py +40 -0
  24. sentinel/tools/desktop.py +116 -0
  25. sentinel/tools/email_ops.py +62 -0
  26. sentinel/tools/factory.py +125 -0
  27. sentinel/tools/file_ops.py +81 -0
  28. sentinel/tools/flights.py +62 -0
  29. sentinel/tools/gmail_auth.py +47 -0
  30. sentinel/tools/indexer.py +156 -0
  31. sentinel/tools/installer.py +69 -0
  32. sentinel/tools/macros.py +58 -0
  33. sentinel/tools/memory_ops.py +281 -0
  34. sentinel/tools/navigation.py +109 -0
  35. sentinel/tools/notes.py +78 -0
  36. sentinel/tools/office.py +67 -0
  37. sentinel/tools/organizer.py +150 -0
  38. sentinel/tools/smart_index.py +76 -0
  39. sentinel/tools/sql_index.py +186 -0
  40. sentinel/tools/system_ops.py +86 -0
  41. sentinel/tools/vision.py +94 -0
  42. sentinel/tools/weather_ops.py +59 -0
  43. sentinel_ai_os-1.0.dist-info/METADATA +282 -0
  44. sentinel_ai_os-1.0.dist-info/RECORD +48 -0
  45. sentinel_ai_os-1.0.dist-info/WHEEL +5 -0
  46. sentinel_ai_os-1.0.dist-info/entry_points.txt +2 -0
  47. sentinel_ai_os-1.0.dist-info/licenses/LICENSE +21 -0
  48. sentinel_ai_os-1.0.dist-info/top_level.txt +1 -0
sentinel/tools/apps.py ADDED
@@ -0,0 +1,462 @@
1
+ import os
2
+ from sentinel.tools.smart_index import index_file
3
+ import shutil
4
+ import platform
5
+ import subprocess
6
+ import webbrowser
7
+ import difflib
8
+ import logging
9
+
10
+ # Ensure APP_CACHE exists (from your global scope)
11
+ APP_CACHE = globals().get("APP_CACHE", {})
12
+
13
+ def _native_open(target):
14
+ """
15
+ Cross-platform helper to open a file, folder, or URI using the OS default handler.
16
+ """
17
+ system = platform.system()
18
+ try:
19
+ if system == "Windows":
20
+ os.startfile(target)
21
+ elif system == "Darwin": # macOS
22
+ subprocess.Popen(["open", target])
23
+ else: # Linux / Unix
24
+ subprocess.Popen(["xdg-open", target])
25
+ return True
26
+ except Exception as e:
27
+ logging.error(f"Native open failed for {target}: {e}")
28
+ return False
29
+
30
+
31
+ def _run_command(cmd_str):
32
+ """
33
+ Safely runs a command string cross-platform.
34
+ """
35
+ try:
36
+ # On Windows, shell=True is often needed for system aliases (like 'dir' or 'start')
37
+ # On Unix, we prefer passing a list, but for complex aliases we use shell=True safely.
38
+ subprocess.Popen(cmd_str, shell=True)
39
+ return True
40
+ except Exception as e:
41
+ logging.error(f"Command failed {cmd_str}: {e}")
42
+ return False
43
+
44
+
45
+ def _get_os_aliases():
46
+ """
47
+ Returns a dictionary of app aliases specific to the current operating system.
48
+ """
49
+ system = platform.system()
50
+
51
+ # 1. Common Aliases (Work if binary is in PATH)
52
+ aliases = {
53
+ "code": "code",
54
+ "python": "python",
55
+ "git": "git",
56
+ "firefox": "firefox",
57
+ "vlc": "vlc",
58
+ "discord": "discord",
59
+ "spotify": "spotify",
60
+ }
61
+
62
+ # 2. Windows Specifics
63
+ if system == "Windows":
64
+ return {**aliases, **{
65
+ # --- BROWSERS ---
66
+ "chrome": "chrome",
67
+ "google chrome": "chrome",
68
+ "firefox": "firefox",
69
+ "edge": "msedge",
70
+ "microsoft edge": "msedge",
71
+ "brave": "brave",
72
+ "opera": "opera",
73
+
74
+ # --- MICROSOFT OFFICE ---
75
+ "word": "winword",
76
+ "excel": "excel",
77
+ "powerpoint": "powerpnt",
78
+ "ppt": "powerpnt",
79
+ "outlook": "outlook",
80
+ "onenote": "onenote",
81
+ "teams": "ms-teams:", # Deep link is often more reliable than exe
82
+ "access": "msaccess",
83
+
84
+ # --- SYSTEM TOOLS ---
85
+ "explorer": "explorer",
86
+ "file explorer": "explorer",
87
+ "settings": "start ms-settings:",
88
+ "control panel": "control",
89
+ "task manager": "taskmgr",
90
+ "cmd": "start cmd", # 'start' ensures it opens a NEW window
91
+ "command prompt": "start cmd",
92
+ "terminal": "wt", # Windows Terminal
93
+ "powershell": "start powershell",
94
+ "system info": "msinfo32",
95
+ "registry": "regedit",
96
+ "services": "services.msc",
97
+
98
+ # --- DIRECTORY SHORTCUTS ---
99
+ "downloads": "explorer shell:downloads",
100
+ "documents": "explorer shell:personal",
101
+ "desktop": "explorer shell:desktop",
102
+ "pictures": "explorer shell:my pictures",
103
+ "startup": "explorer shell:startup",
104
+
105
+ # --- DEV TOOLS ---
106
+ "code": "code",
107
+ "vscode": "code",
108
+ "visual studio": "devenv",
109
+ "pycharm": "pycharm64",
110
+ "intellij": "idea64",
111
+ "docker": "start \"Docker Desktop\" \"C:\\Program Files\\Docker\\Docker\\Docker Desktop.exe\"",
112
+ # Hard path often needed
113
+ "wsl": "wsl",
114
+ "git": "git-bash",
115
+
116
+ # --- MEDIA & UTILITIES ---
117
+ "spotify": "spotify",
118
+ "vlc": "vlc",
119
+ "notepad": "notepad",
120
+ "paint": "mspaint",
121
+ "calc": "calc",
122
+ "calculator": "calc",
123
+ "snipping tool": "snippingtool",
124
+ "screenshot": "snippingtool", # Logic alias
125
+ "photos": "ms-photos:",
126
+ "camera": "microsoft.windows.camera:",
127
+ "clock": "ms-clock:",
128
+ "alarms": "ms-clock:",
129
+ "weather": "ms-weather:",
130
+ "maps": "bingmaps:",
131
+
132
+ # --- COMMUNICATION ---
133
+ "discord": "discord",
134
+ "slack": "slack",
135
+ "whatsapp": "start whatsapp:",
136
+ "zoom": "zoom",
137
+ "telegram": "telegram",
138
+
139
+ # --- STORE & UWP ---
140
+ "store": "ms-windows-store:",
141
+ "mail": "outlookmail:",
142
+ "calendar": "outlookcal:",
143
+ "xbox": "xbox:",
144
+ }}
145
+
146
+ # 3. macOS Specifics (Darwin)
147
+ # 3. macOS Specifics (Darwin)
148
+ elif system == "Darwin":
149
+ return {**aliases, **{
150
+ # --- BROWSERS ---
151
+ "chrome": "open -a 'Google Chrome'",
152
+ "google chrome": "open -a 'Google Chrome'",
153
+ "firefox": "open -a Firefox",
154
+ "safari": "open -a Safari",
155
+ "edge": "open -a 'Microsoft Edge'",
156
+ "brave": "open -a 'Brave Browser'",
157
+ "opera": "open -a Opera",
158
+ "arc": "open -a Arc",
159
+
160
+ # --- MICROSOFT OFFICE ---
161
+ "word": "open -a 'Microsoft Word'",
162
+ "excel": "open -a 'Microsoft Excel'",
163
+ "powerpoint": "open -a 'Microsoft PowerPoint'",
164
+ "onenote": "open -a 'Microsoft OneNote'",
165
+ "outlook": "open -a 'Microsoft Outlook'",
166
+ "teams": "open -a 'Microsoft Teams'",
167
+
168
+ # --- DEV TOOLS ---
169
+ "vscode": "open -a 'Visual Studio Code'",
170
+ "code": "open -a 'Visual Studio Code'", # Fallback if 'code' CLI isn't in PATH
171
+ "sublime": "open -a 'Sublime Text'",
172
+ "iterm": "open -a iTerm",
173
+ "terminal": "open -a Terminal",
174
+ "docker": "open -a Docker",
175
+ "pycharm": "open -a 'PyCharm CE'", # Or 'PyCharm' depending on edition
176
+ "intellij": "open -a 'IntelliJ IDEA CE'",
177
+ "xcode": "open -a Xcode",
178
+
179
+ # --- COMMUNICATION ---
180
+ "slack": "open -a Slack",
181
+ "discord": "open -a Discord",
182
+ "whatsapp": "open -a WhatsApp",
183
+ "telegram": "open -a Telegram",
184
+ "signal": "open -a Signal",
185
+ "zoom": "open -a zoom.us", # Zoom internal name is often zoom.us
186
+ "messages": "open -a Messages",
187
+ "imessage": "open -a Messages",
188
+ "mail": "open -a Mail",
189
+
190
+ # --- CREATIVE & MEDIA ---
191
+ "spotify": "open -a Spotify",
192
+ "vlc": "open -a VLC",
193
+ "quicktime": "open -a 'QuickTime Player'",
194
+ "preview": "open -a Preview",
195
+ "photos": "open -a Photos",
196
+ "photoshop": "open -a 'Adobe Photoshop'",
197
+ "premiere": "open -a 'Adobe Premiere Pro'",
198
+
199
+ # --- PRODUCTIVITY ---
200
+ "notion": "open -a Notion",
201
+ "obsidian": "open -a Obsidian",
202
+ "evernote": "open -a Evernote",
203
+ "notes": "open -a Notes",
204
+ "reminders": "open -a Reminders",
205
+ "calendar": "open -a Calendar",
206
+ "contacts": "open -a Contacts",
207
+ "maps": "open -a Maps",
208
+
209
+ # --- SYSTEM UTILITIES ---
210
+ "finder": "open .",
211
+ "explorer": "open .", # For Windows muscle memory
212
+ "settings": "open -b com.apple.systempreferences", # Robust bundle ID method
213
+ "preferences": "open -b com.apple.systempreferences",
214
+ "app store": "open -a 'App Store'",
215
+ "activity monitor": "open -a 'Activity Monitor'",
216
+ "task manager": "open -a 'Activity Monitor'", # Windows muscle memory
217
+ "disk utility": "open -a 'Disk Utility'",
218
+ "calculator": "open -a Calculator",
219
+ "calc": "open -a Calculator",
220
+ "screenshot": "open -a Screenshot",
221
+ "textedit": "open -a TextEdit",
222
+ "facetime": "open -a FaceTime",
223
+ }}
224
+
225
+ # 4. Linux Specifics
226
+ else:
227
+ return {**aliases, **{
228
+ # --- BROWSERS ---
229
+ "chrome": "google-chrome",
230
+ "chromium": "chromium-browser",
231
+ "firefox": "firefox",
232
+ "brave": "brave-browser",
233
+ "edge": "microsoft-edge", # Yes, it exists on Linux!
234
+ "opera": "opera",
235
+
236
+ # --- OFFICE (LibreOffice Suite) ---
237
+ "word": "libreoffice --writer",
238
+ "writer": "libreoffice --writer",
239
+ "excel": "libreoffice --calc",
240
+ "calc": "libreoffice --calc",
241
+ "powerpoint": "libreoffice --impress",
242
+ "impress": "libreoffice --impress",
243
+ "teams": "teams-for-linux", # Common community wrapper
244
+
245
+ # --- DEV TOOLS ---
246
+ "vscode": "code",
247
+ "code": "code",
248
+ "sublime": "subl",
249
+ "vim": "vim",
250
+ "nano": "nano",
251
+ "docker": "docker",
252
+ # "terminal" usually defaults to x-terminal-emulator,
253
+ # but we explicitly try common ones if that fails:
254
+ "terminal": "gnome-terminal",
255
+ "konsole": "konsole",
256
+
257
+ # --- COMMUNICATION ---
258
+ "discord": "discord",
259
+ "slack": "slack",
260
+ "telegram": "telegram-desktop",
261
+ "signal": "signal-desktop",
262
+ "zoom": "zoom",
263
+ "skype": "skypeforlinux",
264
+
265
+ # --- MEDIA ---
266
+ "vlc": "vlc",
267
+ "spotify": "spotify",
268
+ "rhythmbox": "rhythmbox",
269
+ "mpv": "mpv",
270
+ "photos": "eog", # Eye of GNOME (common image viewer)
271
+ "gimp": "gimp",
272
+ "obs": "obs",
273
+
274
+ # --- SYSTEM UTILITIES ---
275
+ "explorer": "xdg-open .",
276
+ "finder": "xdg-open .",
277
+ "nautilus": "nautilus", # GNOME Files
278
+ "dolphin": "dolphin", # KDE Files
279
+ "thunar": "thunar", # XFCE Files
280
+
281
+ # "Settings" varies wildly, but these covers 80% of users:
282
+ "settings": "gnome-control-center",
283
+ "control panel": "gnome-control-center",
284
+
285
+ # Task Manager equivalents
286
+ "task manager": "gnome-system-monitor",
287
+ "system monitor": "gnome-system-monitor",
288
+ "htop": "x-terminal-emulator -e htop", # Opens htop in a new window
289
+
290
+ "calculator": "gnome-calculator",
291
+ "screenshot": "gnome-screenshot",
292
+ "textedit": "gedit",
293
+ "gedit": "gedit",
294
+ }}
295
+
296
+ def refresh_app_cache():
297
+ """
298
+ Cross-platform indexer.
299
+ Scans system directories to build a map of {app_name: path}.
300
+ """
301
+ global APP_CACHE
302
+ APP_CACHE = {}
303
+ system = platform.system()
304
+
305
+ # --- WINDOWS INDEXING ---
306
+ if system == "Windows":
307
+ paths = [
308
+ r"C:\ProgramData\Microsoft\Windows\Start Menu\Programs",
309
+ os.path.expandvars(r"%APPDATA%\Microsoft\Windows\Start Menu\Programs")
310
+ ]
311
+ for path in paths:
312
+ if not os.path.exists(path): continue
313
+ for root, _, files in os.walk(path):
314
+ for file in files:
315
+ if file.lower().endswith(".lnk"):
316
+ name = file.lower().replace(".lnk", "")
317
+ APP_CACHE[name] = os.path.join(root, file)
318
+
319
+ # --- MACOS INDEXING ---
320
+ elif system == "Darwin":
321
+ # Scan standard application folders
322
+ app_dirs = ["/Applications", "/System/Applications", os.path.expanduser("~/Applications")]
323
+ for app_dir in app_dirs:
324
+ if not os.path.exists(app_dir): continue
325
+ try:
326
+ # macOS apps are folders ending in .app
327
+ for item in os.listdir(app_dir):
328
+ if item.endswith(".app"):
329
+ name = item.replace(".app", "").lower()
330
+ full_path = os.path.join(app_dir, item)
331
+ APP_CACHE[name] = full_path
332
+ except PermissionError:
333
+ continue
334
+
335
+ # --- LINUX INDEXING ---
336
+ elif system == "Linux":
337
+ # Scan for .desktop files
338
+ desktop_dirs = ["/usr/share/applications", os.path.expanduser("~/.local/share/applications")]
339
+ for d_dir in desktop_dirs:
340
+ if not os.path.exists(d_dir): continue
341
+ try:
342
+ for item in os.listdir(d_dir):
343
+ if item.endswith(".desktop"):
344
+ name = item.replace(".desktop", "").lower()
345
+ full_path = os.path.join(d_dir, item)
346
+ # We map the simple name to the .desktop file
347
+ # xdg-open handles .desktop files automatically
348
+ APP_CACHE[name] = full_path
349
+ except PermissionError:
350
+ continue
351
+
352
+
353
+ # Run indexing on import
354
+ refresh_app_cache()
355
+
356
+
357
+ def list_all_apps():
358
+ """Returns a list of all indexed applications."""
359
+ if not APP_CACHE:
360
+ return "No apps found in index. (Is this Windows?)"
361
+
362
+ # Sort alphabetically
363
+ sorted_apps = sorted(APP_CACHE.keys())
364
+ return "Indexed Apps:\n" + "\n".join([f"- {app}" for app in sorted_apps])
365
+
366
+
367
+ def open_app(name):
368
+ """
369
+ Smart Launcher (Cross-Platform).
370
+ Priority:
371
+ 1. Direct Path (Absolute paths)
372
+ 2. System Aliases (OS-specific shortcuts)
373
+ 3. Dynamic App Cache (Fuzzy Match)
374
+ 4. Web URL fallback
375
+ """
376
+ name = name.strip()
377
+ lower_name = name.lower()
378
+
379
+ # --- 1. DIRECT PATH ---
380
+ # If the user provides a full path that exists
381
+ if os.path.exists(name):
382
+ # Optional: index_file(name) if available in your scope
383
+ if 'index_file' in globals(): globals()['index_file'](name)
384
+
385
+ if _native_open(name):
386
+ return f"Launched file: {name}"
387
+
388
+ # --- 2. SYSTEM ALIASES ---
389
+ aliases = _get_os_aliases()
390
+
391
+ if lower_name in aliases:
392
+ cmd = aliases[lower_name]
393
+ if _run_command(cmd):
394
+ return f"Launched system tool: {lower_name}"
395
+ return f"Failed to launch alias: {lower_name}"
396
+
397
+ # --- 3. DYNAMIC SEARCH (Fuzzy Match) ---
398
+ # Works if APP_CACHE is populated (Windows mostly, unless you add Linux/Mac indexers)
399
+ if APP_CACHE:
400
+ # Exact Match
401
+ if lower_name in APP_CACHE:
402
+ path = APP_CACHE[lower_name]
403
+ _native_open(path)
404
+ return f"Launched {lower_name} from Index."
405
+
406
+ # Fuzzy Match
407
+ matches = difflib.get_close_matches(lower_name, APP_CACHE.keys(), n=1, cutoff=0.6)
408
+ if matches:
409
+ best_match = matches[0]
410
+ path = APP_CACHE[best_match]
411
+ _native_open(path)
412
+ return f"Launched {best_match} (matched to '{name}')"
413
+
414
+ # --- 4. EXECUTABLE IN PATH ---
415
+ # Check if 'name' is a command available in the system PATH (e.g., 'npm', 'docker')
416
+ if shutil.which(lower_name):
417
+ _run_command(lower_name)
418
+ return f"Executed command: {lower_name}"
419
+
420
+ # --- 5. WEB URL FALLBACK ---
421
+ # If it looks like a URL or domain
422
+ if "." in name and " " not in name:
423
+ url = name if name.startswith(("http://", "https://")) else f"https://{name}"
424
+ webbrowser.open(url)
425
+ return f"Opened website: {url}"
426
+
427
+ return f"Could not find app, file, or command: '{name}'"
428
+
429
+ def play_music(song_name):
430
+ """Plays music on YouTube Music."""
431
+ query = song_name.replace(" ", "+")
432
+ webbrowser.open(f"https://music.youtube.com/search?q={query}")
433
+ return f"Playing {song_name}..."
434
+
435
+ def close_app(name):
436
+ """
437
+ Attempts to close a running application by name.
438
+ Uses graceful terminate first, then force kill if needed.
439
+ """
440
+ name = name.lower().strip()
441
+
442
+ system = platform.system()
443
+
444
+ try:
445
+ if system == "Windows":
446
+ # Try graceful close first
447
+ subprocess.call(f"taskkill /IM {name}.exe", shell=True)
448
+ return f"Closed application: {name}"
449
+
450
+ elif system == "Darwin": # macOS
451
+ subprocess.call(["pkill", "-f", name])
452
+ return f"Closed application: {name}"
453
+
454
+ elif system == "Linux":
455
+ subprocess.call(["pkill", "-f", name])
456
+ return f"Closed application: {name}"
457
+
458
+ else:
459
+ return "Unsupported OS."
460
+
461
+ except Exception as e:
462
+ return f"Failed to close app '{name}': {e}"
@@ -0,0 +1,30 @@
1
+ import speech_recognition as sr
2
+ import threading
3
+
4
+
5
+ def listen(timeout=5):
6
+ def _listen():
7
+ recognizer = sr.Recognizer()
8
+ with sr.Microphone() as source:
9
+ recognizer.adjust_for_ambient_noise(source, duration=0.5)
10
+ audio = recognizer.listen(source, timeout=timeout, phrase_time_limit=8)
11
+ return recognizer.recognize_google(audio)
12
+
13
+ try:
14
+ import concurrent.futures
15
+ with concurrent.futures.ThreadPoolExecutor() as ex:
16
+ future = ex.submit(_listen)
17
+ return f"User said: '{future.result(timeout=timeout+2)}'"
18
+ except concurrent.futures.TimeoutError:
19
+ return "Voice timeout."
20
+ except Exception as e:
21
+ return f"Voice error: {e}"
22
+
23
+
24
+
25
+ def listen_background(callback):
26
+ """
27
+ Starts a background listener for a hotword (Advanced).
28
+ """
29
+ # This requires a loop and is best left for Phase 2
30
+ pass
@@ -0,0 +1,66 @@
1
+ # FILE: tools/browser.py
2
+ import requests
3
+ from bs4 import BeautifulSoup
4
+ from ddgs import DDGS
5
+ from sentinel.core.config import ConfigManager
6
+
7
+ cfg = ConfigManager()
8
+
9
+
10
+ def search_web(query):
11
+ """
12
+ Smart Search: Tries Tavily (Advanced RAG), falls back to DuckDuckGo.
13
+ """
14
+ # 1. Try Tavily
15
+ tavily_key = cfg.get_key("tavily")
16
+ if tavily_key:
17
+ try:
18
+ from tavily import TavilyClient
19
+ client = TavilyClient(api_key=tavily_key)
20
+ # 'search_context' returns optimized text for AI
21
+ response = client.get_search_context(query=query, search_depth="basic", max_tokens=1500)
22
+ return f"[Source: Tavily]\n{response}"
23
+ except ImportError:
24
+ pass
25
+ except Exception:
26
+ pass
27
+
28
+ # 2. Fallback to DuckDuckGo
29
+ try:
30
+ results = DDGS().text(query, max_results=4)
31
+ summary = []
32
+ for r in results:
33
+ summary.append(f"Title: {r.get('title')}\nLink: {r.get('href')}\nSnippet: {r.get('body')}\n")
34
+
35
+ if not summary:
36
+ return "No results found on DuckDuckGo."
37
+ return "[Source: DuckDuckGo]\n" + "\n".join(summary)
38
+ except Exception as e:
39
+ return f"Search completely failed: {e}"
40
+
41
+
42
+ def open_url(url):
43
+ """Scrapes text content from a URL."""
44
+ try:
45
+ headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'}
46
+ resp = requests.get(url, headers=headers, timeout=10)
47
+
48
+ if resp.status_code != 200:
49
+ return f"Error: Status code {resp.status_code}"
50
+
51
+ soup = BeautifulSoup(resp.content, 'html.parser')
52
+ for script in soup(["script", "style", "nav", "footer"]):
53
+ script.extract()
54
+
55
+ text = soup.get_text()
56
+ lines = (line.strip() for line in text.splitlines())
57
+ chunks = (phrase.strip() for line in lines for phrase in line.split(" "))
58
+ clean_text = '\n'.join(chunk for chunk in chunks if chunk)
59
+
60
+ return clean_text[:4000] + "..."
61
+ except Exception as e:
62
+ return f"Error reading page: {e}"
63
+
64
+
65
+ def read_webpage(url):
66
+ return open_url(url)