@allansantos-dev/smart-tool 0.0.0-stage → 0.9.1

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 (78) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +32 -0
  4. package/README.md +116 -2
  5. package/atomic_io.py +94 -0
  6. package/autostart.py +199 -0
  7. package/bin/smart-tool.mjs +86 -0
  8. package/browser_service_client.py +232 -0
  9. package/capabilities.py +63 -0
  10. package/client_hooks.py +192 -0
  11. package/code_graph.py +403 -0
  12. package/code_graph_java.mjs +49 -0
  13. package/code_graph_js.cjs +135 -0
  14. package/config.py +241 -0
  15. package/daemon_launcher.py +165 -0
  16. package/directory_watch.py +131 -0
  17. package/document_text.py +69 -0
  18. package/duplicates.py +313 -0
  19. package/embedding_cache.py +99 -0
  20. package/endpoint_sync.py +179 -0
  21. package/folder_picker.py +73 -0
  22. package/freshness.py +278 -0
  23. package/gateway.py +220 -0
  24. package/hook_decision.py +96 -0
  25. package/hooks/pretool_router.py +45 -0
  26. package/index_inventory.py +75 -0
  27. package/index_profile.py +167 -0
  28. package/index_scope.py +458 -0
  29. package/index_views.py +121 -0
  30. package/indexer.py +878 -0
  31. package/install.py +353 -0
  32. package/install_runtime.py +283 -0
  33. package/integration_metrics.py +82 -0
  34. package/local_embedder.py +172 -0
  35. package/model_adapters/__init__.py +0 -0
  36. package/model_adapters/openai_compatible.py +57 -0
  37. package/model_catalog.py +74 -0
  38. package/model_client.py +38 -0
  39. package/model_defaults.py +70 -0
  40. package/package.json +33 -4
  41. package/paths.py +6 -0
  42. package/project_identity.py +42 -0
  43. package/project_insights_ui.py +38 -0
  44. package/project_maps_ui.py +96 -0
  45. package/project_monitor.py +204 -0
  46. package/project_scheduler.py +61 -0
  47. package/project_storage.py +165 -0
  48. package/project_store.py +220 -0
  49. package/projects_ui.py +146 -0
  50. package/requirements.txt +8 -0
  51. package/research_cache.py +171 -0
  52. package/research_metrics.py +110 -0
  53. package/router.py +582 -0
  54. package/secret_store.py +121 -0
  55. package/setup_ui.py +986 -0
  56. package/site_memory.py +53 -0
  57. package/smart_tool_daemon.py +3604 -0
  58. package/tray.py +245 -0
  59. package/update_check.py +22 -0
  60. package/usage_meter.py +124 -0
  61. package/version.py +18 -0
  62. package/view_compare.py +68 -0
  63. package/web_adapters/browser/browser_service.py +302 -0
  64. package/web_adapters/browser/crawl4ai_search.py +236 -0
  65. package/web_adapters/browser/fetch_camoufox.py +28 -0
  66. package/web_adapters/browser/requirements.txt +2 -0
  67. package/web_adapters/node/adapter-common.mjs +40 -0
  68. package/web_adapters/node/ddg-search.mjs +89 -0
  69. package/web_adapters/node/open-websearch-adapter.mjs +77 -0
  70. package/web_adapters/node/package-lock.json +2720 -0
  71. package/web_adapters/node/package.json +12 -0
  72. package/web_adapters/node/wikipedia-search.mjs +44 -0
  73. package/web_document_graph.py +71 -0
  74. package/web_fetch.py +242 -0
  75. package/web_search_adapters.py +193 -0
  76. package/web_search_ddgs.py +42 -0
  77. package/web_search_external.py +288 -0
  78. package/web_search_health.py +88 -0
package/tray.py ADDED
@@ -0,0 +1,245 @@
1
+ #!/usr/bin/env python3
2
+ """Ícone na bandeja do sistema do Smart Tool — visibilidade de que o daemon está de
3
+ pé, e ponto único de "se caiu, sobe de novo" que não depende de nenhum host MCP
4
+ cooperar. Processo separado do daemon (mesma relação que `daemon_launcher` já tem com
5
+ `smart_tool_daemon.py`): matar o tray não mata o daemon.
6
+
7
+ Uso: `pythonw.exe tray.py` (sem console) — entrypoint apontado pelo mecanismo de
8
+ autostart (Tarefa Agendada ou atalho de inicialização).
9
+ """
10
+ import json
11
+ import os
12
+ import sys
13
+ import threading
14
+ import time
15
+ import urllib.request
16
+ import webbrowser
17
+
18
+ from PIL import Image, ImageDraw
19
+ import pystray
20
+
21
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
22
+ import autostart
23
+ import daemon_launcher
24
+ import update_check
25
+
26
+ POLL_INTERVAL_S = 45
27
+ UPDATE_CHECK_INTERVAL_S = 24 * 3600
28
+ SETUP_URL = "http://127.0.0.1:8765/setup"
29
+
30
+ _STATE_LOCK = threading.Lock()
31
+ _state = {"health": "starting", "update": None}
32
+
33
+
34
+ def _make_icon_image(color):
35
+ size = 64
36
+ img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
37
+ draw = ImageDraw.Draw(img)
38
+ margin = 8
39
+ draw.ellipse((margin, margin, size - margin, size - margin), fill=color)
40
+ return img
41
+
42
+
43
+ _GREEN = _make_icon_image((47, 138, 76, 255))
44
+ _YELLOW = _make_icon_image((219, 164, 26, 255))
45
+ _RED = _make_icon_image((194, 59, 59, 255))
46
+ _ICONS = {
47
+ "ok": _GREEN,
48
+ "starting": _YELLOW,
49
+ "needs_key": _YELLOW,
50
+ "needs_models": _YELLOW,
51
+ "catalog_unavailable": _YELLOW,
52
+ "config_corrupt": _YELLOW,
53
+ "status_error": _YELLOW,
54
+ "down": _RED,
55
+ }
56
+
57
+ _TITLES = {
58
+ "ok": "Smart Tool - daemon running",
59
+ "starting": "Smart Tool - starting the daemon...",
60
+ "needs_key": "Smart Tool - model gateway unavailable",
61
+ "needs_models": "Smart Tool - choose the models in settings",
62
+ "catalog_unavailable": "Smart Tool - model catalog unavailable",
63
+ "config_corrupt": "Smart Tool - settings file has an error",
64
+ "status_error": "Smart Tool - failed to read the settings",
65
+ "down": "Smart Tool - daemon down",
66
+ }
67
+
68
+
69
+ def _set_state(health, icon=None):
70
+ with _STATE_LOCK:
71
+ if _state["health"] == health:
72
+ return
73
+ _state["health"] = health
74
+ if icon is not None:
75
+ icon.icon = _ICONS[health]
76
+ icon.title = _TITLES[health]
77
+
78
+
79
+ def _configuration_state(url):
80
+ with urllib.request.urlopen(url.rstrip("/") + "/setup/status", timeout=5) as response:
81
+ payload = json.load(response)
82
+ return payload["models_status"]
83
+
84
+
85
+ def _indicator_state(model_status):
86
+ if model_status == "ready":
87
+ return "ok"
88
+ return model_status if model_status in _TITLES else "status_error"
89
+
90
+
91
+ def _poll_loop(icon):
92
+ while True:
93
+ try:
94
+ url = daemon_launcher.read_registry()
95
+ # Timeout maior que o default: aqui um falso negativo custa um spawn inútil de
96
+ # daemon, e o polling é a cada 45s — nada espera por esta resposta.
97
+ if url and daemon_launcher.health(url["url"], timeout=3):
98
+ daemon_url = url["url"]
99
+ else:
100
+ _set_state("starting", icon)
101
+ daemon_url = daemon_launcher.ensure_daemon_running()
102
+ try:
103
+ status = _configuration_state(daemon_url)
104
+ _set_state(_indicator_state(status), icon)
105
+ except Exception:
106
+ _set_state("status_error", icon)
107
+ except Exception:
108
+ _set_state("down", icon)
109
+ time.sleep(POLL_INTERVAL_S)
110
+
111
+
112
+ def _update_loop(icon):
113
+ while True:
114
+ try:
115
+ latest = update_check.newer_version()
116
+ except Exception:
117
+ latest = None
118
+ if latest and latest != _state["update"]:
119
+ _state["update"] = latest
120
+ icon.update_menu()
121
+ icon.notify(f"Smart Tool {latest} is available. Update with: {update_check.UPDATE_COMMAND}", "Smart Tool")
122
+ time.sleep(UPDATE_CHECK_INTERVAL_S)
123
+
124
+
125
+ def _update_label(item):
126
+ return f"Update to {_state['update']} (copy command)"
127
+
128
+
129
+ def _copy_update_command(icon, item):
130
+ import subprocess
131
+ subprocess.run(["clip.exe"], input=update_check.UPDATE_COMMAND.encode("ascii"), check=True,
132
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
133
+ icon.notify(f"Copied: {update_check.UPDATE_COMMAND}. Paste it in a terminal.", "Smart Tool")
134
+
135
+
136
+ def _open_setup(icon, item):
137
+ registry = daemon_launcher.read_registry()
138
+ webbrowser.open((registry["url"].rstrip("/") + "/setup") if registry else SETUP_URL)
139
+
140
+
141
+ def _open_projects(icon, item):
142
+ def run():
143
+ try:
144
+ url = daemon_launcher.ensure_daemon_running()
145
+ webbrowser.open(url.rstrip("/") + "/projects")
146
+ except Exception as exc:
147
+ icon.notify(f"Could not open the projects: {exc}", "Smart Tool")
148
+ threading.Thread(target=run, daemon=True).start()
149
+
150
+
151
+ def _add_project(icon, item):
152
+ def run():
153
+ try:
154
+ import folder_picker
155
+ folder_picker.register_from_tray()
156
+ except Exception as exc:
157
+ icon.notify(f"Could not select the folder: {exc}", "Smart Tool")
158
+ threading.Thread(target=run, daemon=True).start()
159
+
160
+
161
+ def _autostart_checked(item):
162
+ try:
163
+ return autostart.status()["installed"]
164
+ except Exception:
165
+ return False
166
+
167
+
168
+ _autostart_busy = threading.Lock()
169
+
170
+
171
+ def _toggle_autostart(icon, item):
172
+ if not _autostart_busy.acquire(blocking=False):
173
+ return # já tem uma troca em andamento — clique duplo ignorado, não enfileirado
174
+
175
+ def _run():
176
+ try:
177
+ if autostart.status()["installed"]:
178
+ autostart.remove()
179
+ else:
180
+ result = autostart.install()
181
+ if result.get("method") == "startup_folder" and result.get("fallback_reason"):
182
+ icon.notify(
183
+ f"Scheduled Task unavailable ({result['fallback_reason']}); using the Startup folder.",
184
+ "Smart Tool",
185
+ )
186
+ except Exception as exc:
187
+ icon.notify(f"Failed to change automatic start: {exc}", "Smart Tool")
188
+ finally:
189
+ _autostart_busy.release()
190
+ icon.update_menu()
191
+
192
+ threading.Thread(target=_run, daemon=True).start()
193
+
194
+
195
+ def _quit(icon, item):
196
+ icon.stop()
197
+
198
+
199
+ def _single_instance_handle():
200
+ """Impede dois ícones quando o atalho e o autostart rodam juntos."""
201
+ if sys.platform != "win32":
202
+ return None
203
+ import ctypes
204
+
205
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
206
+ kernel32.CreateMutexW.argtypes = (ctypes.c_void_p, ctypes.c_int, ctypes.c_wchar_p)
207
+ kernel32.CreateMutexW.restype = ctypes.c_void_p
208
+ handle = kernel32.CreateMutexW(None, False, "Local\\SmartToolTray")
209
+ if not handle:
210
+ raise OSError(ctypes.get_last_error(), "Could not start the Smart Tool icon")
211
+ if ctypes.get_last_error() == 183: # ERROR_ALREADY_EXISTS
212
+ kernel32.CloseHandle(ctypes.c_void_p(handle))
213
+ return False
214
+ return handle
215
+
216
+
217
+ def main():
218
+ handle = _single_instance_handle()
219
+ if handle is False:
220
+ return
221
+ icon = pystray.Icon(
222
+ "smart-tool",
223
+ icon=_ICONS["starting"],
224
+ title=_TITLES["starting"],
225
+ menu=pystray.Menu(
226
+ pystray.MenuItem("Projects and indexing…", _open_projects, default=True),
227
+ pystray.MenuItem("Index a folder…", _add_project),
228
+ pystray.MenuItem("Open settings", _open_setup),
229
+ pystray.MenuItem("Start with Windows", _toggle_autostart, checked=_autostart_checked),
230
+ pystray.MenuItem(_update_label, _copy_update_command, visible=lambda item: bool(_state["update"])),
231
+ pystray.MenuItem("Quit", _quit),
232
+ ),
233
+ )
234
+ threading.Thread(target=_poll_loop, args=(icon,), daemon=True).start()
235
+ threading.Thread(target=_update_loop, args=(icon,), daemon=True).start()
236
+ try:
237
+ icon.run()
238
+ finally:
239
+ if handle:
240
+ import ctypes
241
+ ctypes.windll.kernel32.CloseHandle(ctypes.c_void_p(handle))
242
+
243
+
244
+ if __name__ == "__main__":
245
+ main()
@@ -0,0 +1,22 @@
1
+ """Newer Smart Tool release on npm, for the tray notice. Updating is running the install command again."""
2
+ import json
3
+ import urllib.request
4
+
5
+ import version
6
+
7
+ PACKAGE = "@allansantos-dev/smart-tool"
8
+ REGISTRY_URL = "https://registry.npmjs.org/@allansantos-dev%2Fsmart-tool/latest"
9
+ UPDATE_COMMAND = f"npx {PACKAGE}@latest install"
10
+
11
+
12
+ def _parts(value):
13
+ return tuple(int(part) for part in str(value).split("-")[0].split("."))
14
+
15
+
16
+ def newer_version(timeout=10):
17
+ """The latest version on npm when it is newer than the installed one, else None."""
18
+ request = urllib.request.Request(REGISTRY_URL, headers={"User-Agent": version.user_agent(),
19
+ "Accept": "application/json"})
20
+ with urllib.request.urlopen(request, timeout=timeout) as response:
21
+ latest = json.load(response)["version"]
22
+ return latest if _parts(latest) > _parts(version.VERSION) else None
package/usage_meter.py ADDED
@@ -0,0 +1,124 @@
1
+ """Consumo por projeto/visão. Só guarda contagens, modelos e horários, nunca conteúdo."""
2
+ import contextlib
3
+ import contextvars
4
+ import datetime
5
+ import json
6
+ import math
7
+ import time
8
+
9
+ import project_identity
10
+ import project_store
11
+
12
+ _CONTEXT=contextvars.ContextVar('smart_usage',default=None)
13
+ _PHASES={'scope':'scope','embedding_query':'query_embedding','model_probe':'embedding_probe',
14
+ 'indexing':'index_embedding','reranking':'rerank'}
15
+
16
+
17
+ @contextlib.contextmanager
18
+ def job(root,view,job_id):
19
+ value={'project_id':project_identity.project_id(root),'view_id':view['view_id'],
20
+ 'view_label':view['label'],'job_id':job_id,'purpose':'other'}
21
+ token=_CONTEXT.set(value)
22
+ try:yield
23
+ finally:_CONTEXT.reset(token)
24
+
25
+
26
+ def phase(name):
27
+ current=_CONTEXT.get()
28
+ if current and name in _PHASES:_CONTEXT.set({**current,'purpose':_PHASES[name]})
29
+
30
+
31
+ def _tables(db):
32
+ db.execute('CREATE TABLE IF NOT EXISTS usage_daily(day TEXT,project_id TEXT,view_id TEXT,view_label TEXT,purpose TEXT,model TEXT,attempts INTEGER,successes INTEGER,errors INTEGER,input_items INTEGER,input_characters INTEGER,prompt_tokens INTEGER,output_tokens INTEGER,token_reports INTEGER,reported_usd REAL,price_reports INTEGER,elapsed_s REAL,PRIMARY KEY(day,project_id,view_id,purpose,model))')
33
+ db.execute('CREATE TABLE IF NOT EXISTS usage_index_jobs(job_id TEXT PRIMARY KEY,project_id TEXT,view_id TEXT,view_label TEXT,at REAL,kind TEXT,embedded_chunks INTEGER,reused_chunks INTEGER,unchanged_files INTEGER,embedded_characters INTEGER)')
34
+ db.execute('CREATE TABLE IF NOT EXISTS usage_imports(project_id TEXT PRIMARY KEY)')
35
+
36
+
37
+ def _number(value):
38
+ return value if type(value) in (int,float) and math.isfinite(value) and value>=0 else None
39
+
40
+
41
+ def record(*args,**kwargs):
42
+ try:_record(*args,**kwargs)
43
+ except Exception:pass # Telemetria opcional não altera o resultado da chamada.
44
+
45
+
46
+ def _record(path,body,response=None,headers=None,error=None,elapsed=0):
47
+ current=_CONTEXT.get()
48
+ if not current or path not in ('/v1/embeddings','/v1/rerank','/v1/chat/completions'):
49
+ return
50
+ body=body if isinstance(body,dict) else {}
51
+ response=response if isinstance(response,dict) else {}
52
+ usage=response.get('usage') or (response.get('meta') or {}).get('tokens') or {}
53
+ if not isinstance(usage,dict):usage={}
54
+ prompt=_number(usage.get('prompt_tokens',usage.get('input_tokens')))
55
+ output=_number(usage.get('completion_tokens',usage.get('output_tokens')))
56
+ if prompt is None and path=='/v1/embeddings':prompt=_number(usage.get('total_tokens'))
57
+ texts=body.get('input') if path.endswith('embeddings') else body.get('documents') if path.endswith('rerank') else []
58
+ if isinstance(texts,str):texts=[texts]
59
+ texts=texts if isinstance(texts,list) else []
60
+ characters=sum(len(t) for t in texts if isinstance(t,str))
61
+ if path.endswith('rerank'):characters+=len(body.get('query') or '')
62
+ if path.endswith('completions'):
63
+ characters=sum(len(m.get('content','')) for m in body.get('messages',[]) if isinstance(m,dict) and isinstance(m.get('content'),str))
64
+ cost=None
65
+ try:cost=_number(float((headers or {}).get('x-litellm-response-cost')))
66
+ except (TypeError,ValueError):pass
67
+ day=datetime.datetime.now(datetime.timezone.utc).date().isoformat()
68
+ values=(day,current['project_id'],current['view_id'],current['view_label'],current['purpose'],str(body.get('model','')),
69
+ 1,int(error is None),int(error is not None),len(texts),characters,int(prompt or 0),int(output or 0),
70
+ int(prompt is not None or output is not None),cost or 0,int(cost is not None),round(elapsed,4))
71
+ # Telemetria opcional não pode derrubar a chamada do modelo.
72
+ try:
73
+ with project_store._LOCK,project_store._db() as db:
74
+ _tables(db)
75
+ fields=['attempts','successes','errors','input_items','input_characters','prompt_tokens','output_tokens','token_reports','reported_usd','price_reports','elapsed_s']
76
+ update=','.join(f'{name}={name}+excluded.{name}' for name in fields)
77
+ db.execute('INSERT INTO usage_daily VALUES('+','.join('?' for _ in values)+') ON CONFLICT(day,project_id,view_id,purpose,model) DO UPDATE SET '+update,values)
78
+ cutoff=(datetime.datetime.now(datetime.timezone.utc)-datetime.timedelta(days=366)).date().isoformat()
79
+ db.execute('DELETE FROM usage_daily WHERE day<?',(cutoff,))
80
+ except Exception:
81
+ pass
82
+
83
+
84
+ def record_job(job_id,payload):
85
+ try:_record_job(job_id,payload)
86
+ except Exception:pass
87
+
88
+
89
+ def _record_job(job_id,payload):
90
+ if payload.get('status')=='pending' or not isinstance(payload.get('stats'),dict):return
91
+ stats=payload['stats'];view=payload.get('view') or stats.get('view') or {'view_id':'legacy','label':'History without view'}
92
+ with project_store._LOCK,project_store._db() as db:
93
+ _tables(db)
94
+ db.execute('INSERT OR IGNORE INTO usage_index_jobs VALUES(?,?,?,?,?,?,?,?,?,?)',
95
+ (job_id,payload.get('project_id'),view.get('view_id','legacy'),view.get('label',''),payload.get('finished_at',time.time()),payload.get('kind',''),
96
+ stats.get('embedded_chunks',0),stats.get('reused_chunks',0),stats.get('unchanged',0),stats.get('embedded_characters',0)))
97
+ db.execute('DELETE FROM usage_index_jobs WHERE at<?',(time.time()-366*86400,))
98
+
99
+
100
+ def summary(project_id,days=30,view_id=None):
101
+ if type(days) is not int or not 1<=days<=366:raise ValueError('Choose a period from 1 to 366 days.')
102
+ with project_store._LOCK,project_store._db() as db:
103
+ _tables(db)
104
+ imported=db.execute('SELECT 1 FROM usage_imports WHERE project_id=?',(project_id,)).fetchone()
105
+ if not imported:
106
+ for data in project_store.jobs(project_id,limit=project_store.MAX_JOBS):record_job(data['job_id'],data)
107
+ with project_store._LOCK,project_store._db() as db:
108
+ db.execute('INSERT OR IGNORE INTO usage_imports VALUES(?)',(project_id,))
109
+ cutoff=(datetime.datetime.now(datetime.timezone.utc)-datetime.timedelta(days=days-1)).date().isoformat()
110
+ args=[project_id,cutoff];where='project_id=? AND day>=?'
111
+ if view_id:where+=' AND view_id=?';args.append(view_id)
112
+ with project_store._LOCK,project_store._db() as db:
113
+ _tables(db);db.row_factory=__import__('sqlite3').Row
114
+ rows=[dict(r) for r in db.execute('SELECT * FROM usage_daily WHERE '+where+' ORDER BY day DESC,view_label,purpose',args)]
115
+ job_args=[project_id,datetime.datetime.fromisoformat(cutoff).replace(tzinfo=datetime.timezone.utc).timestamp()];job_where='project_id=? AND at>=?'
116
+ if view_id:job_where+=' AND view_id=?';job_args.append(view_id)
117
+ jobs=[dict(r) for r in db.execute('SELECT view_id,view_label,count(*) AS jobs,sum(embedded_chunks) AS embedded_chunks,sum(reused_chunks) AS reused_chunks,sum(unchanged_files) AS unchanged_files,sum(embedded_characters) AS embedded_characters FROM usage_index_jobs WHERE '+job_where+' GROUP BY view_id,view_label',job_args)]
118
+ totals={key:sum(row[key] for row in rows) for key in ('attempts','successes','errors','input_items','input_characters','prompt_tokens','output_tokens','token_reports','reported_usd','price_reports','elapsed_s')}
119
+ if not totals['price_reports']:totals['reported_usd']=None
120
+ if not totals['token_reports']:totals['prompt_tokens']=totals['output_tokens']=None
121
+ return {'days':days,'daily':rows,'indexing':jobs,'totals':totals,'currency':'USD',
122
+ 'notes':['Calls and tokens are measured starting with this version. Earlier history only contains statistics that were already recorded.',
123
+ 'Monetary values appear only when reported by the model gateway; they are not an estimate of the full bill.',
124
+ 'Characters are not tokens. Cache reuse and unchanged files are counted separately.']}
package/version.py ADDED
@@ -0,0 +1,18 @@
1
+ """Single source of the Smart Tool version and of the User-Agent sent to public APIs."""
2
+ import re
3
+
4
+ VERSION = "0.9.1"
5
+ _CONTACT_RE = re.compile(r"^(?:[^\s@()<>;]+@[^\s@()<>;]+\.[^\s@()<>;]+|https?://[^\s()<>;]+)$")
6
+
7
+
8
+ def check_contact(value):
9
+ """Optional contact (e-mail or http(s) URL) that public APIs such as Wikimedia ask for in the User-Agent."""
10
+ value = str(value or "").strip()
11
+ if value and (len(value) > 200 or not _CONTACT_RE.match(value)):
12
+ raise ValueError("Contact must be an e-mail address or an http(s) URL, without spaces or parentheses.")
13
+ return value
14
+
15
+
16
+ def user_agent(contact=""):
17
+ contact = check_contact(contact)
18
+ return f"SmartTool/{VERSION} (local MCP search tool{'; ' + contact if contact else ''})"
@@ -0,0 +1,68 @@
1
+ """Compara snapshots guardados sem checkout, rede ou novos embeddings."""
2
+ import contextlib
3
+ import difflib
4
+ from pathlib import Path
5
+
6
+ import code_graph
7
+ import index_inventory
8
+ import indexer
9
+ import project_storage
10
+
11
+
12
+ def _files(path):
13
+ with contextlib.closing(indexer._readonly(path)) as db:
14
+ return dict(db.execute('SELECT path,hash FROM manifest'))
15
+
16
+
17
+ def _text(path,file_path):
18
+ with contextlib.closing(indexer._readonly(path)) as db:
19
+ rows=db.execute('SELECT start_line,end_line,text FROM chunks WHERE path=? ORDER BY start_line,id',(file_path,)).fetchall()
20
+ return code_graph.reconstruct(rows)
21
+
22
+
23
+ def compare(root,left_id,right_id,file_path=None,relations=True):
24
+ views=index_inventory.views(root);by_id={Path(v['path']).name:v for v in views}
25
+ left=by_id.get(left_id);right=by_id.get(right_id)
26
+ if not left or not right or left_id==right_id:raise ValueError('Choose two different views of the same project.')
27
+ stamps={v['path']:project_storage._stamp(v['path']) for v in (left,right)}
28
+ a,b=_files(left['path']),_files(right['path'])
29
+ added=set(b)-set(a);removed=set(a)-set(b)
30
+ old_hash={};new_hash={}
31
+ for name in removed:old_hash.setdefault(a[name],[]).append(name)
32
+ for name in added:new_hash.setdefault(b[name],[]).append(name)
33
+ renamed=[]
34
+ for digest,old in old_hash.items():
35
+ new=new_hash.get(digest,[])
36
+ if len(old)==len(new)==1:
37
+ renamed.append({'from':old[0],'to':new[0]});removed.remove(old[0]);added.remove(new[0])
38
+ changed=sorted(name for name in set(a)&set(b) if a[name]!=b[name])
39
+ result={'left':left,'right':right,'files':{'added':sorted(added),'removed':sorted(removed),'changed':changed,'renamed':renamed},
40
+ 'unchanged':sum(a[name]==b[name] for name in set(a)&set(b)),
41
+ 'notes':['Comparison of stored indexes. A missing item may come from a different scope, not only from a deletion in Git.'],
42
+ 'read_only':True}
43
+ if file_path is not None:
44
+ if file_path not in a and file_path not in b:raise ValueError('File not found in these views.')
45
+ old,error_a=_text(left['path'],file_path) if file_path in a else ('',None)
46
+ new,error_b=_text(right['path'],file_path) if file_path in b else ('',None)
47
+ if error_a or error_b:result['diff_error']=error_a or error_b
48
+ else:
49
+ lines=list(difflib.unified_diff(old.splitlines(),new.splitlines(),fromfile=left['label']+'/'+file_path,tofile=right['label']+'/'+file_path,lineterm='',n=3))
50
+ output='\n'.join(lines[:1000]);result['diff']=output[:128000];result['diff_truncated']=len(lines)>1000 or len(output)>128000
51
+ if relations:
52
+ ga=code_graph.build(root,storage_id=left_id);gb=code_graph.build(root,storage_id=right_id)
53
+ def relations_of(graph):
54
+ nodes={s['id']:s for s in graph['symbols']}
55
+ def key(id):
56
+ n=nodes[id]
57
+ return n['path']+'::'+n['name']+(':'+str(n['start_line']) if n['name']=='callback' else '')
58
+ imports={(e['source'],e['target'],e.get('kind','import')) for e in graph['dependencies'] if e.get('target')}
59
+ calls={(key(e['source']),key(e['target']),e['kind']) for e in graph['calls']}
60
+ return imports,calls
61
+ ai,ac=relations_of(ga);bi,bc=relations_of(gb)
62
+ result['relations']={name:{'added':[list(x) for x in sorted(y-x)[:500]],'removed':[list(z) for z in sorted(x-y)[:500]],
63
+ 'added_count':len(y-x),'removed_count':len(x-y)} for name,x,y in [('imports',ai,bi),('calls',ac,bc)]}
64
+ result['notes'].append('Named calls are compared by file/name; anonymous callbacks also use their location. Only resolved static relations are compared.')
65
+ result['analysis_partial']=ga['truncated'] or gb['truncated'] or bool(ga['diagnostics'] or gb['diagnostics'])
66
+ if any(project_storage._stamp(path)!=stamp for path,stamp in stamps.items()):
67
+ raise RuntimeError('A view was updated during the comparison. Compare again after the job finishes.')
68
+ return result