@allansantos-dev/smart-tool 0.9.0 → 0.9.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 (41) hide show
  1. package/CHANGELOG.md +50 -23
  2. package/NOTICE +32 -32
  3. package/README.md +117 -116
  4. package/atomic_io.py +94 -94
  5. package/bin/smart-tool.mjs +5 -1
  6. package/client_hooks.py +192 -192
  7. package/code_graph_java.mjs +49 -49
  8. package/code_graph_js.cjs +135 -135
  9. package/duplicates.py +313 -313
  10. package/embedding_cache.py +99 -99
  11. package/endpoint_sync.py +179 -179
  12. package/gateway.py +220 -220
  13. package/hook_decision.py +96 -96
  14. package/index_inventory.py +75 -75
  15. package/indexer.py +878 -878
  16. package/install.py +113 -11
  17. package/install_runtime.py +101 -35
  18. package/integration_metrics.py +82 -82
  19. package/local_embedder.py +172 -172
  20. package/model_defaults.py +70 -70
  21. package/package.json +35 -35
  22. package/project_insights_ui.py +38 -38
  23. package/project_monitor.py +204 -204
  24. package/project_store.py +220 -220
  25. package/projects_ui.py +146 -146
  26. package/requirements.txt +8 -8
  27. package/research_cache.py +171 -171
  28. package/research_metrics.py +110 -110
  29. package/router.py +582 -582
  30. package/secret_store.py +121 -121
  31. package/setup_ui.py +1011 -986
  32. package/smart_tool_daemon.py +3604 -3604
  33. package/version.py +1 -1
  34. package/web_adapters/browser/fetch_camoufox.py +69 -0
  35. package/web_adapters/node/ddg-search.mjs +89 -89
  36. package/web_adapters/node/open-websearch-adapter.mjs +77 -77
  37. package/web_adapters/node/package.json +12 -12
  38. package/web_adapters/node/wikipedia-search.mjs +44 -44
  39. package/web_search_adapters.py +193 -193
  40. package/web_search_external.py +288 -288
  41. package/web_search_health.py +88 -88
package/project_store.py CHANGED
@@ -1,220 +1,220 @@
1
- """Cadastro de projetos e histórico de jobs. Consultas/resultados ficam sob DPAPI."""
2
- import json
3
- import os
4
- import paths
5
- import re
6
- import sqlite3
7
- import threading
8
- import time
9
- from contextlib import contextmanager
10
-
11
- import project_identity
12
- import secret_store
13
-
14
- DB_PATH = os.path.join(paths.DATA_DIR, "projects.sqlite3")
15
- _LOCK = threading.RLock()
16
- MAX_JOBS = 500
17
-
18
-
19
- @contextmanager
20
- def _db():
21
- os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
22
- db = sqlite3.connect(DB_PATH, timeout=10)
23
- try:
24
- db.execute("PRAGMA secure_delete=ON")
25
- db.execute("CREATE TABLE IF NOT EXISTS projects(id TEXT PRIMARY KEY, root TEXT NOT NULL, data TEXT NOT NULL, updated_at REAL NOT NULL)")
26
- db.execute("CREATE TABLE IF NOT EXISTS jobs(id TEXT PRIMARY KEY, project_id TEXT, status TEXT NOT NULL, updated_at REAL NOT NULL, payload TEXT NOT NULL)")
27
- db.execute("CREATE INDEX IF NOT EXISTS jobs_project ON jobs(project_id,updated_at)")
28
- with db:
29
- yield db
30
- finally:
31
- db.close()
32
-
33
-
34
- def register(root, watch=None):
35
- display = project_identity.display_root(root)
36
- if not os.path.isdir(display):
37
- raise ValueError("The project folder does not exist or is not accessible.")
38
- key = project_identity.project_id(display)
39
- with _LOCK, _db() as db:
40
- row = db.execute("SELECT data FROM projects WHERE id=?", (key,)).fetchone()
41
- data = json.loads(row[0]) if row else {
42
- "id": key, "root": display, "name": os.path.basename(display) or display,
43
- "status": "registered", "watch": False, "paused": False, "enabled": False,
44
- "update_mode": "on_search",
45
- "dirty_seq": 0, "indexed_seq": 0, "created_at": time.time(),
46
- "last_indexed": None, "last_checked": None, "last_error": "",
47
- "last_job_id": None, "watch_backend": "off", "next_retry_at": 0,
48
- }
49
- data["root"] = display
50
- if watch is not None:
51
- data["watch"] = bool(watch)
52
- db.execute("INSERT OR REPLACE INTO projects VALUES(?,?,?,?)",
53
- (key, display, json.dumps(data, ensure_ascii=False), time.time()))
54
- return data
55
-
56
-
57
- def get(project_id):
58
- with _LOCK, _db() as db:
59
- row = db.execute("SELECT data FROM projects WHERE id=?", (project_id,)).fetchone()
60
- return json.loads(row[0]) if row else None
61
-
62
-
63
- def all_projects():
64
- with _LOCK, _db() as db:
65
- rows = db.execute("SELECT data FROM projects ORDER BY root COLLATE NOCASE").fetchall()
66
- return [json.loads(row[0]) for row in rows]
67
-
68
-
69
- def update(project_id, **fields):
70
- with _LOCK, _db() as db:
71
- row = db.execute("SELECT data FROM projects WHERE id=?", (project_id,)).fetchone()
72
- if not row:
73
- return None
74
- data = json.loads(row[0])
75
- fields.pop("id", None)
76
- fields.pop("root", None)
77
- data.update(fields)
78
- db.execute("UPDATE projects SET data=?,updated_at=? WHERE id=?",
79
- (json.dumps(data, ensure_ascii=False), time.time(), project_id))
80
- return data
81
-
82
-
83
- def mark_dirty(project_id, reason="Arquivos alterados."):
84
- with _LOCK:
85
- project = get(project_id)
86
- if not project:
87
- return None
88
- return update(project_id, dirty_seq=project.get("dirty_seq", 0) + 1,
89
- status="dirty" if not project.get("paused") else "paused",
90
- dirty_reason=reason, last_event=time.time())
91
-
92
-
93
- def record_changes(project_id, paths=(), full=False, scope=False):
94
- with _LOCK:
95
- project = get(project_id)
96
- if not project:
97
- return
98
- merged = set(project.get('dirty_paths', [])) | set(paths)
99
- full = full or project.get('needs_full_check', False) or len(merged) > 1000
100
- update(project_id, dirty_paths=[] if full else sorted(merged), needs_full_check=full,
101
- scope_dirty=scope or project.get('scope_dirty', False))
102
-
103
-
104
- def confirm_changes(project_id, sequence):
105
- with _LOCK:
106
- project = get(project_id)
107
- if project and project.get('dirty_seq', 0) == sequence:
108
- update(project_id, dirty_paths=[], needs_full_check=False, scope_dirty=False)
109
-
110
-
111
- def save_job(job_id, payload):
112
- text = json.dumps(payload, ensure_ascii=False)
113
- protected = secret_store.protect(text)
114
- if not secret_store.is_protected(protected):
115
- raise RuntimeError("Could not protect the job history on this Windows system.")
116
- with _LOCK, _db() as db:
117
- db.execute("INSERT OR REPLACE INTO jobs VALUES(?,?,?,?,?)",
118
- (job_id, payload.get("project_id"), payload.get("status", "pending"), time.time(), protected))
119
- db.execute("DELETE FROM jobs WHERE status!='pending' AND id NOT IN "
120
- "(SELECT id FROM jobs ORDER BY updated_at DESC LIMIT ?)", (MAX_JOBS,))
121
-
122
-
123
- def _decode(payload):
124
- try:
125
- if not secret_store.is_protected(payload):
126
- return None
127
- value = json.loads(secret_store.unprotect(payload))
128
- return value if isinstance(value, dict) else None
129
- except (ValueError, secret_store.SecretProtectionError):
130
- return None
131
-
132
-
133
- def job(job_id):
134
- with _LOCK, _db() as db:
135
- row = db.execute("SELECT payload FROM jobs WHERE id=?", (job_id,)).fetchone()
136
- return _decode(row[0]) if row else None
137
-
138
-
139
- def jobs(project_id=None, limit=30):
140
- with _LOCK, _db() as db:
141
- if project_id:
142
- rows = db.execute("SELECT id,payload FROM jobs WHERE project_id=? ORDER BY updated_at DESC LIMIT ?",
143
- (project_id, limit)).fetchall()
144
- else:
145
- rows = db.execute("SELECT id,payload FROM jobs ORDER BY updated_at DESC LIMIT ?", (limit,)).fetchall()
146
- return [{"job_id": key, **data} for key, payload in rows if (data := _decode(payload))]
147
-
148
-
149
- def recover_interrupted():
150
- with _LOCK, _db() as db:
151
- rows = db.execute("SELECT id,payload FROM jobs WHERE status='pending'").fetchall()
152
- for key, payload in rows:
153
- data = _decode(payload) or {}
154
- data.update(status="interrupted", phase="interrupted", finished_at=time.time(),
155
- error="The daemon restarted. Resume the job; files already committed will be reused.")
156
- save_job(key, data)
157
- if data.get("project_id"):
158
- update(data["project_id"], status="interrupted", last_error=data["error"], last_job_id=key)
159
- return len(rows)
160
-
161
-
162
- def forget(project_id):
163
- with _LOCK, _db() as db:
164
- db.execute("DELETE FROM jobs WHERE project_id=?", (project_id,))
165
- db.execute("DELETE FROM projects WHERE id=?", (project_id,))
166
- for table in ('usage_daily','usage_index_jobs','usage_imports'):
167
- if db.execute('SELECT 1 FROM sqlite_master WHERE name=?',(table,)).fetchone():
168
- db.execute(f'DELETE FROM {table} WHERE project_id=?',(project_id,))
169
-
170
-
171
- def relocate(project_id, root):
172
- """Reassocia cadastro e histórico após a cópia do índice; não move código-fonte."""
173
- display = project_identity.display_root(root)
174
- key = project_identity.project_id(display)
175
- with _LOCK, _db() as db:
176
- if db.execute("SELECT 1 FROM projects WHERE id=?", (key,)).fetchone():
177
- raise ValueError("The target folder is already registered.")
178
- row = db.execute("SELECT data FROM projects WHERE id=?", (project_id,)).fetchone()
179
- if not row:
180
- raise ValueError("Project not registered.")
181
- data = json.loads(row[0])
182
- data.update(id=key, root=display, name=os.path.basename(display) or display,
183
- previous_root=data["root"], status="dirty", needs_full_check=True,
184
- dirty_seq=data.get("dirty_seq", 0) + 1, watch_backend="off", last_error="")
185
- db.execute("INSERT INTO projects VALUES(?,?,?,?)", (key, display, json.dumps(data, ensure_ascii=False), time.time()))
186
- for job_id, payload in db.execute("SELECT id,payload FROM jobs WHERE project_id=?", (project_id,)).fetchall():
187
- job_data = _decode(payload)
188
- if job_data:
189
- job_data["project_id"] = key
190
- job_data.setdefault("original_project_root", data["previous_root"])
191
- if job_data.get("arguments"):
192
- job_data["arguments"]["project_root"] = display
193
- payload = secret_store.protect(json.dumps(job_data, ensure_ascii=False))
194
- db.execute("UPDATE jobs SET project_id=?,payload=? WHERE id=?", (key, payload, job_id))
195
- db.execute("DELETE FROM projects WHERE id=?", (project_id,))
196
- for table in ('usage_daily','usage_index_jobs','usage_imports'):
197
- if db.execute('SELECT 1 FROM sqlite_master WHERE name=?',(table,)).fetchone():
198
- db.execute(f'UPDATE {table} SET project_id=? WHERE project_id=?',(key,project_id))
199
- return data
200
-
201
-
202
- def import_known_legacy(index_dir):
203
- """Somente raízes documentadas nos registros antigos; não adivinha diretórios."""
204
- import indexer
205
- candidates = set()
206
- for name in ("daemon.log", "daemon.log.1"):
207
- try:
208
- with open(os.path.join(index_dir, name), encoding="utf-8", errors="replace") as f:
209
- for line in f:
210
- match = re.search(r"reindex (.+?): \d+ no escopo", line)
211
- if match and os.path.isdir(match.group(1)):
212
- candidates.add(match.group(1))
213
- except OSError:
214
- pass
215
- for root in candidates:
216
- key = project_identity.project_id(root)
217
- if get(key) or not indexer.existing_db_path(root):
218
- continue
219
- register(root, watch=False)
220
- update(key, enabled=True, status="migration_required", legacy_discovered=True)
1
+ """Cadastro de projetos e histórico de jobs. Consultas/resultados ficam sob DPAPI."""
2
+ import json
3
+ import os
4
+ import paths
5
+ import re
6
+ import sqlite3
7
+ import threading
8
+ import time
9
+ from contextlib import contextmanager
10
+
11
+ import project_identity
12
+ import secret_store
13
+
14
+ DB_PATH = os.path.join(paths.DATA_DIR, "projects.sqlite3")
15
+ _LOCK = threading.RLock()
16
+ MAX_JOBS = 500
17
+
18
+
19
+ @contextmanager
20
+ def _db():
21
+ os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
22
+ db = sqlite3.connect(DB_PATH, timeout=10)
23
+ try:
24
+ db.execute("PRAGMA secure_delete=ON")
25
+ db.execute("CREATE TABLE IF NOT EXISTS projects(id TEXT PRIMARY KEY, root TEXT NOT NULL, data TEXT NOT NULL, updated_at REAL NOT NULL)")
26
+ db.execute("CREATE TABLE IF NOT EXISTS jobs(id TEXT PRIMARY KEY, project_id TEXT, status TEXT NOT NULL, updated_at REAL NOT NULL, payload TEXT NOT NULL)")
27
+ db.execute("CREATE INDEX IF NOT EXISTS jobs_project ON jobs(project_id,updated_at)")
28
+ with db:
29
+ yield db
30
+ finally:
31
+ db.close()
32
+
33
+
34
+ def register(root, watch=None):
35
+ display = project_identity.display_root(root)
36
+ if not os.path.isdir(display):
37
+ raise ValueError("The project folder does not exist or is not accessible.")
38
+ key = project_identity.project_id(display)
39
+ with _LOCK, _db() as db:
40
+ row = db.execute("SELECT data FROM projects WHERE id=?", (key,)).fetchone()
41
+ data = json.loads(row[0]) if row else {
42
+ "id": key, "root": display, "name": os.path.basename(display) or display,
43
+ "status": "registered", "watch": False, "paused": False, "enabled": False,
44
+ "update_mode": "on_search",
45
+ "dirty_seq": 0, "indexed_seq": 0, "created_at": time.time(),
46
+ "last_indexed": None, "last_checked": None, "last_error": "",
47
+ "last_job_id": None, "watch_backend": "off", "next_retry_at": 0,
48
+ }
49
+ data["root"] = display
50
+ if watch is not None:
51
+ data["watch"] = bool(watch)
52
+ db.execute("INSERT OR REPLACE INTO projects VALUES(?,?,?,?)",
53
+ (key, display, json.dumps(data, ensure_ascii=False), time.time()))
54
+ return data
55
+
56
+
57
+ def get(project_id):
58
+ with _LOCK, _db() as db:
59
+ row = db.execute("SELECT data FROM projects WHERE id=?", (project_id,)).fetchone()
60
+ return json.loads(row[0]) if row else None
61
+
62
+
63
+ def all_projects():
64
+ with _LOCK, _db() as db:
65
+ rows = db.execute("SELECT data FROM projects ORDER BY root COLLATE NOCASE").fetchall()
66
+ return [json.loads(row[0]) for row in rows]
67
+
68
+
69
+ def update(project_id, **fields):
70
+ with _LOCK, _db() as db:
71
+ row = db.execute("SELECT data FROM projects WHERE id=?", (project_id,)).fetchone()
72
+ if not row:
73
+ return None
74
+ data = json.loads(row[0])
75
+ fields.pop("id", None)
76
+ fields.pop("root", None)
77
+ data.update(fields)
78
+ db.execute("UPDATE projects SET data=?,updated_at=? WHERE id=?",
79
+ (json.dumps(data, ensure_ascii=False), time.time(), project_id))
80
+ return data
81
+
82
+
83
+ def mark_dirty(project_id, reason="Arquivos alterados."):
84
+ with _LOCK:
85
+ project = get(project_id)
86
+ if not project:
87
+ return None
88
+ return update(project_id, dirty_seq=project.get("dirty_seq", 0) + 1,
89
+ status="dirty" if not project.get("paused") else "paused",
90
+ dirty_reason=reason, last_event=time.time())
91
+
92
+
93
+ def record_changes(project_id, paths=(), full=False, scope=False):
94
+ with _LOCK:
95
+ project = get(project_id)
96
+ if not project:
97
+ return
98
+ merged = set(project.get('dirty_paths', [])) | set(paths)
99
+ full = full or project.get('needs_full_check', False) or len(merged) > 1000
100
+ update(project_id, dirty_paths=[] if full else sorted(merged), needs_full_check=full,
101
+ scope_dirty=scope or project.get('scope_dirty', False))
102
+
103
+
104
+ def confirm_changes(project_id, sequence):
105
+ with _LOCK:
106
+ project = get(project_id)
107
+ if project and project.get('dirty_seq', 0) == sequence:
108
+ update(project_id, dirty_paths=[], needs_full_check=False, scope_dirty=False)
109
+
110
+
111
+ def save_job(job_id, payload):
112
+ text = json.dumps(payload, ensure_ascii=False)
113
+ protected = secret_store.protect(text)
114
+ if not secret_store.is_protected(protected):
115
+ raise RuntimeError("Could not protect the job history on this Windows system.")
116
+ with _LOCK, _db() as db:
117
+ db.execute("INSERT OR REPLACE INTO jobs VALUES(?,?,?,?,?)",
118
+ (job_id, payload.get("project_id"), payload.get("status", "pending"), time.time(), protected))
119
+ db.execute("DELETE FROM jobs WHERE status!='pending' AND id NOT IN "
120
+ "(SELECT id FROM jobs ORDER BY updated_at DESC LIMIT ?)", (MAX_JOBS,))
121
+
122
+
123
+ def _decode(payload):
124
+ try:
125
+ if not secret_store.is_protected(payload):
126
+ return None
127
+ value = json.loads(secret_store.unprotect(payload))
128
+ return value if isinstance(value, dict) else None
129
+ except (ValueError, secret_store.SecretProtectionError):
130
+ return None
131
+
132
+
133
+ def job(job_id):
134
+ with _LOCK, _db() as db:
135
+ row = db.execute("SELECT payload FROM jobs WHERE id=?", (job_id,)).fetchone()
136
+ return _decode(row[0]) if row else None
137
+
138
+
139
+ def jobs(project_id=None, limit=30):
140
+ with _LOCK, _db() as db:
141
+ if project_id:
142
+ rows = db.execute("SELECT id,payload FROM jobs WHERE project_id=? ORDER BY updated_at DESC LIMIT ?",
143
+ (project_id, limit)).fetchall()
144
+ else:
145
+ rows = db.execute("SELECT id,payload FROM jobs ORDER BY updated_at DESC LIMIT ?", (limit,)).fetchall()
146
+ return [{"job_id": key, **data} for key, payload in rows if (data := _decode(payload))]
147
+
148
+
149
+ def recover_interrupted():
150
+ with _LOCK, _db() as db:
151
+ rows = db.execute("SELECT id,payload FROM jobs WHERE status='pending'").fetchall()
152
+ for key, payload in rows:
153
+ data = _decode(payload) or {}
154
+ data.update(status="interrupted", phase="interrupted", finished_at=time.time(),
155
+ error="The daemon restarted. Resume the job; files already committed will be reused.")
156
+ save_job(key, data)
157
+ if data.get("project_id"):
158
+ update(data["project_id"], status="interrupted", last_error=data["error"], last_job_id=key)
159
+ return len(rows)
160
+
161
+
162
+ def forget(project_id):
163
+ with _LOCK, _db() as db:
164
+ db.execute("DELETE FROM jobs WHERE project_id=?", (project_id,))
165
+ db.execute("DELETE FROM projects WHERE id=?", (project_id,))
166
+ for table in ('usage_daily','usage_index_jobs','usage_imports'):
167
+ if db.execute('SELECT 1 FROM sqlite_master WHERE name=?',(table,)).fetchone():
168
+ db.execute(f'DELETE FROM {table} WHERE project_id=?',(project_id,))
169
+
170
+
171
+ def relocate(project_id, root):
172
+ """Reassocia cadastro e histórico após a cópia do índice; não move código-fonte."""
173
+ display = project_identity.display_root(root)
174
+ key = project_identity.project_id(display)
175
+ with _LOCK, _db() as db:
176
+ if db.execute("SELECT 1 FROM projects WHERE id=?", (key,)).fetchone():
177
+ raise ValueError("The target folder is already registered.")
178
+ row = db.execute("SELECT data FROM projects WHERE id=?", (project_id,)).fetchone()
179
+ if not row:
180
+ raise ValueError("Project not registered.")
181
+ data = json.loads(row[0])
182
+ data.update(id=key, root=display, name=os.path.basename(display) or display,
183
+ previous_root=data["root"], status="dirty", needs_full_check=True,
184
+ dirty_seq=data.get("dirty_seq", 0) + 1, watch_backend="off", last_error="")
185
+ db.execute("INSERT INTO projects VALUES(?,?,?,?)", (key, display, json.dumps(data, ensure_ascii=False), time.time()))
186
+ for job_id, payload in db.execute("SELECT id,payload FROM jobs WHERE project_id=?", (project_id,)).fetchall():
187
+ job_data = _decode(payload)
188
+ if job_data:
189
+ job_data["project_id"] = key
190
+ job_data.setdefault("original_project_root", data["previous_root"])
191
+ if job_data.get("arguments"):
192
+ job_data["arguments"]["project_root"] = display
193
+ payload = secret_store.protect(json.dumps(job_data, ensure_ascii=False))
194
+ db.execute("UPDATE jobs SET project_id=?,payload=? WHERE id=?", (key, payload, job_id))
195
+ db.execute("DELETE FROM projects WHERE id=?", (project_id,))
196
+ for table in ('usage_daily','usage_index_jobs','usage_imports'):
197
+ if db.execute('SELECT 1 FROM sqlite_master WHERE name=?',(table,)).fetchone():
198
+ db.execute(f'UPDATE {table} SET project_id=? WHERE project_id=?',(key,project_id))
199
+ return data
200
+
201
+
202
+ def import_known_legacy(index_dir):
203
+ """Somente raízes documentadas nos registros antigos; não adivinha diretórios."""
204
+ import indexer
205
+ candidates = set()
206
+ for name in ("daemon.log", "daemon.log.1"):
207
+ try:
208
+ with open(os.path.join(index_dir, name), encoding="utf-8", errors="replace") as f:
209
+ for line in f:
210
+ match = re.search(r"reindex (.+?): \d+ no escopo", line)
211
+ if match and os.path.isdir(match.group(1)):
212
+ candidates.add(match.group(1))
213
+ except OSError:
214
+ pass
215
+ for root in candidates:
216
+ key = project_identity.project_id(root)
217
+ if get(key) or not indexer.existing_db_path(root):
218
+ continue
219
+ register(root, watch=False)
220
+ update(key, enabled=True, status="migration_required", legacy_discovered=True)