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

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 (77) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +32 -0
  4. package/README.md +116 -3
  5. package/atomic_io.py +94 -0
  6. package/autostart.py +199 -0
  7. package/bin/smart-tool.mjs +82 -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 +251 -0
  32. package/install_runtime.py +249 -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 +35 -6
  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/requirements.txt +2 -0
  66. package/web_adapters/node/adapter-common.mjs +40 -0
  67. package/web_adapters/node/ddg-search.mjs +89 -0
  68. package/web_adapters/node/open-websearch-adapter.mjs +77 -0
  69. package/web_adapters/node/package-lock.json +2720 -0
  70. package/web_adapters/node/package.json +12 -0
  71. package/web_adapters/node/wikipedia-search.mjs +44 -0
  72. package/web_document_graph.py +71 -0
  73. package/web_fetch.py +242 -0
  74. package/web_search_adapters.py +193 -0
  75. package/web_search_ddgs.py +42 -0
  76. package/web_search_external.py +288 -0
  77. package/web_search_health.py +88 -0
@@ -0,0 +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)
package/projects_ui.py ADDED
@@ -0,0 +1,146 @@
1
+ """Tela local de projetos; todas as operações passam pelos jobs do daemon."""
2
+ import html
3
+ import re
4
+ import setup_ui
5
+ import project_maps_ui
6
+ import project_insights_ui
7
+
8
+
9
+ def render_projects_page():
10
+ return (_PAGE.replace("__TOKEN__", html.escape(setup_ui.SETUP_TOKEN, quote=True))
11
+ .replace("__MAP_STYLE__", project_maps_ui.STYLE)
12
+ .replace("__MAP_HTML__", project_maps_ui.HTML)
13
+ .replace("__MAP_SCRIPT__", project_maps_ui.SCRIPT)
14
+ .replace("__INSIGHTS_STYLE__", project_insights_ui.STYLE)
15
+ .replace("__INSIGHTS_HTML__", project_insights_ui.HTML)
16
+ .replace("__INSIGHTS_SCRIPT__", project_insights_ui.SCRIPT))
17
+
18
+
19
+ def render_map_window():
20
+ """Janela independente compartilha o componente e os controles da página principal."""
21
+ style = re.search(r'<style>(.*?)</style>', _PAGE, re.S).group(1)
22
+ values = {
23
+ '__TOKEN__': html.escape(setup_ui.SETUP_TOKEN, quote=True),
24
+ '__STYLE__': style.replace('__MAP_STYLE__', project_maps_ui.STYLE).replace('__INSIGHTS_STYLE__', project_insights_ui.STYLE),
25
+ '__MAP_HTML__': project_maps_ui.HTML,
26
+ '__MAP_SCRIPT__': project_maps_ui.SCRIPT,
27
+ }
28
+ return re.sub('|'.join(re.escape(key) for key in values), lambda m:values[m[0]], _MAP_WINDOW)
29
+
30
+
31
+ _MAP_WINDOW = r'''<!doctype html>
32
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
33
+ <meta name="setup-token" content="__TOKEN__"><title>Project maps · Smart Tool</title><style>
34
+ __STYLE__
35
+ body.map-window{padding:0}.map-window header,.map-window main{max-width:none}.map-window header{padding:22px 28px 14px}.map-window main{padding:0 20px 30px}.map-window h1{font-size:22px}.map-window #window-root{margin:6px 0 0;font-size:12px;overflow-wrap:anywhere}.map-window .maps{margin-top:0}.map-window .maps>summary{display:none}.map-window .maps-workspace{grid-template-columns:minmax(0,1fr) 285px}.map-window #maps-svg{height:calc(100vh - 360px);min-height:430px}.map-window .maps-tools #maps-new-window{display:none}.map-window #maps-color-legend{max-height:90px}.map-window #window-message:empty{display:none}.map-window #window-message{margin-bottom:14px;overflow-wrap:anywhere}
36
+ @media(max-width:850px){.map-window .maps-workspace{grid-template-columns:1fr}.map-window header{padding-inline:18px}.map-window main{padding-inline:12px}.map-window #maps-svg{height:450px;min-height:0}}
37
+ </style></head><body class="map-window"><header><div><h1 id="window-title">Project maps</h1><p id="window-root" class="muted">Loading project…</p></div><a id="window-project-link" href="/projects">Open project manager</a></header><main><div id="window-message" role="status" aria-live="polite"></div>__MAP_HTML__</main>
38
+ <script>
39
+ const parameters=Object.fromEntries(new URLSearchParams(location.hash.slice(1)));
40
+ const selected=parameters.project||'',token=document.querySelector('meta[name="setup-token"]').content;
41
+ const esc=value=>String(value??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
42
+ function message(text,error=false){const node=document.getElementById('window-message');node.textContent=text;node.style.color=error?'var(--bad)':'var(--good)'}
43
+ async function api(payload){const response=await fetch('/setup/projects',{method:'POST',headers:{'Content-Type':'application/json','X-Setup-Token':token},body:JSON.stringify(payload)});const data=await response.json();if(!response.ok)throw Error(data.error||'Could not query the project.');return data}
44
+ __MAP_SCRIPT__
45
+ document.getElementById('map-details').open=true;
46
+ (async()=>{if(!/^[a-f0-9]{16}$/.test(selected)){message('Open the project in the manager and use New window.',true);document.getElementById('load-map').disabled=true;return}try{const data=await api({action:'status',project_id:selected});document.getElementById('window-title').textContent='Maps · '+data.project.name;document.title=data.project.name+' · Maps · Smart Tool';document.getElementById('window-root').textContent=data.project.root;document.getElementById('window-project-link').href='/projects#'+selected;await window.SmartMaps.restore(parameters)}catch(e){message(e.message+' Open the manager to check the project.',true)}})();
47
+ </script></body></html>'''
48
+
49
+
50
+ _PAGE = r'''<!doctype html>
51
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
52
+ <meta name="setup-token" content="__TOKEN__">
53
+ <title>Projects and indexing · Smart Tool</title>
54
+ <style>
55
+ :root{color-scheme:light dark;--bg:#f6f7f9;--surface:#fff;--border:#dde1e6;--ink:#1b2430;--muted:#5b6472;--accent:#2f6fed;--good:#18723c;--bad:#b52c2c;--warn:#805300;--selected:#eaf1ff}
56
+ @media(prefers-color-scheme:dark){:root{--bg:#14171c;--surface:#1c2027;--border:#39414e;--ink:#e6e9ee;--muted:#a6afbb;--accent:#91baff;--good:#68dc93;--bad:#ff9696;--warn:#e0b565;--selected:#263751}}
57
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.55 "Segoe UI",sans-serif}a{color:var(--accent)}button,input,textarea,select{font:inherit}select{max-width:100%;padding:10px;border:1px solid var(--border);border-radius:6px;background:var(--surface);color:var(--ink)}button{min-height:40px;border:1px solid var(--border);border-radius:6px;background:var(--surface);color:var(--ink);padding:8px 14px;cursor:pointer}button:hover{border-color:var(--accent);background:var(--selected)}button.primary{background:var(--accent);color:#fff;border-color:transparent;font-weight:600}@media(prefers-color-scheme:dark){button.primary{color:#14171c}}button:disabled{opacity:.55;cursor:default}button.danger{color:var(--bad)}:focus-visible{outline:3px solid var(--accent);outline-offset:3px}input[type=text],textarea{width:100%;padding:10px;border:1px solid var(--border);border-radius:6px;background:var(--surface);color:var(--ink)}textarea{min-height:86px;resize:vertical;font:13px/1.5 Consolas,monospace}label{display:block;font-weight:600;margin:12px 0 5px}input[type=checkbox]{width:18px;height:18px;vertical-align:middle;margin:0 8px 0 0;accent-color:var(--accent)}header{max-width:1260px;margin:auto;padding:32px 32px 24px;display:flex;align-items:center;justify-content:space-between;gap:20px}header a{white-space:nowrap}h1{font-size:27px;line-height:1.25;margin:0 0 6px;letter-spacing:-.02em}h2{font-size:22px;line-height:1.3;margin:0 0 6px;overflow-wrap:anywhere}h3{font-size:16px;margin:0 0 12px}p{margin:6px 0 14px}small,.muted{color:var(--muted)}main{max-width:1260px;margin:auto;padding:0 32px 60px}.toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding-bottom:20px}.toolbar .count{margin-left:auto;color:var(--muted)}.workspace{display:grid;grid-template-columns:290px minmax(0,1fr);border:1px solid var(--border);border-radius:10px;overflow:hidden;background:var(--surface);min-height:570px}aside{border-right:1px solid var(--border);background:var(--bg);padding:18px 12px}.list-title{margin:0 10px 12px;color:var(--muted);font-size:13px;font-weight:600}.project-item{width:100%;text-align:left;border:1px solid transparent;background:transparent;margin:0 0 6px;padding:12px;display:block}.project-item[aria-current=true]{background:var(--selected);border-color:var(--accent)}.project-item strong{display:block;overflow-wrap:anywhere;font-size:14px}.project-item small{display:block;margin-top:4px}.detail{padding:28px 30px;min-width:0}.detail-head{display:flex;gap:12px;align-items:start;justify-content:space-between}.path{font:12px/1.6 Consolas,monospace;overflow-wrap:anywhere;color:var(--muted);margin:8px 0 18px}.state{font-size:12px;font-weight:600;white-space:nowrap}.state.good{color:var(--good)}.state.bad{color:var(--bad)}.state.warn{color:var(--warn)}.actions{display:flex;flex-wrap:wrap;gap:8px;margin:18px 0}.notice{border:1px solid var(--border);border-radius:6px;padding:12px 14px;margin:14px 0;color:var(--warn);overflow-wrap:anywhere}.notice.error{color:var(--bad)}.job{padding:14px 0;border-top:1px solid var(--border)}.job-line{display:flex;justify-content:space-between;gap:12px}.job small{display:block}.job p{margin:4px 0;font-size:13px;overflow-wrap:anywhere}progress{width:100%;height:8px;accent-color:var(--accent);margin-top:12px}dl{margin:24px 0}dl>div{display:grid;grid-template-columns:155px minmax(0,1fr);gap:12px;border-top:1px solid var(--border);padding:10px 0;font-size:13px}dt{color:var(--muted)}dd{margin:0;overflow-wrap:anywhere}code{font:12px/1.6 Consolas,monospace}details{border-top:1px solid var(--border);padding:16px 0}summary{cursor:pointer;font-weight:600}details p{font-size:13px;margin-top:12px}.scope-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.empty{padding:60px 20px;max-width:550px}.empty h2{margin-bottom:12px}.empty p{color:var(--muted)}#add-form{border:1px solid var(--border);border-radius:8px;padding:18px 22px;margin:0 0 20px;background:var(--surface)}#message{margin:0 0 18px;overflow-wrap:anywhere}#message:empty{display:none}#message.error{color:var(--bad)}#message.success{color:var(--good)}.storage{font-size:12px;color:var(--muted);margin-top:18px;overflow-wrap:anywhere}.preview-samples{max-height:190px;overflow:auto;padding-left:20px;font:12px/1.8 Consolas,monospace}.check-label{font-size:14px;font-weight:400;display:flex;align-items:center}.history{margin-top:12px}footer{padding-top:16px;font-size:12px;color:var(--muted)}[hidden]{display:none!important}
58
+ @media(max-width:780px){header{padding:24px 20px 18px;align-items:start;flex-direction:column;gap:12px}main{padding:0 20px 40px}.workspace{grid-template-columns:1fr}aside{border-right:0;border-bottom:1px solid var(--border);max-height:225px;overflow:auto}.detail{padding:22px 18px}.detail-head{flex-direction:column}.scope-grid{grid-template-columns:1fr;gap:0}dl>div{grid-template-columns:1fr;gap:4px}h1{font-size:24px}.toolbar .count{width:100%;margin-left:0}.empty{padding:25px 0}.job-line{flex-wrap:wrap}}
59
+ __MAP_STYLE__
60
+ __INSIGHTS_STYLE__
61
+ </style></head><body>
62
+ <header><div><h1>Projects and indexing</h1><p class="muted">Smart Tool · Folders, updates and code search.</p></div><a href="/setup">Settings and models</a></header>
63
+ <main>
64
+ <div class="toolbar"><button class="primary" id="pick">Select folder</button><button id="add-toggle" aria-expanded="false" aria-controls="add-form">Enter path</button><span id="count" class="count">Loading projects…</span></div>
65
+ <form id="add-form" hidden><label for="root">Full folder path</label><input id="root" type="text" placeholder="C:\Projects\my-project" required autocomplete="off"><div class="actions"><button type="submit" class="primary">Add folder</button><button type="button" id="add-close">Close</button></div><small>After adding it, check the preview and start indexing.</small></form>
66
+ <div id="message" role="status" aria-live="polite"></div>
67
+ <div class="workspace"><aside aria-label="Registered projects"><p class="list-title">YOUR FOLDERS</p><div id="project-list"></div></aside>
68
+ <section class="detail" aria-label="Selected project">
69
+ <div id="empty" class="empty"><h2>Your code, ready to search.</h2><p>Select a folder to check what will be indexed. Then turn on watching to keep the index up to date when files change.</p><p>Indexes are stored in your Windows profile, outside the project folders.</p></div>
70
+ <div id="project-detail" hidden>
71
+ <div class="detail-head"><h2 id="name"></h2><span id="state" class="state"></span></div><div id="path" class="path"></div><p id="git-view" class="muted"></p>
72
+ <div id="current-jobs"></div><p id="job-announcement" role="status" aria-live="polite"></p><div id="project-warning" class="notice" hidden></div>
73
+ <div class="actions"><button id="open-maps">Open the three maps</button><button id="preview">Check preview</button><button id="index" class="primary">Index now</button><button id="pause">Pause</button><button id="resume" hidden>Resume</button><button id="cancel" hidden>Cancel job</button></div>
74
+ <label class="check-label"><input id="watch" type="checkbox">Watch for changes automatically</label><p id="watch-note" class="muted"></p>
75
+ <label for="update-mode">When to generate new embeddings</label><select id="update-mode"><option value="on_search">On the next search — save calls</option><option value="eager">After changes — prepare ahead of time</option></select><p class="muted">Detecting changes uses local resources. The ahead-of-time option may call the model even without a later search. Index now and Resume run an explicit update.</p>
76
+ <dl id="facts"></dl>
77
+ __INSIGHTS_HTML__
78
+ __MAP_HTML__
79
+ <details id="preview-details"><summary>Preview and skipped files</summary><div id="preview-data"><p class="muted">Check the preview to see the scope before generating embeddings.</p></div></details>
80
+ <details id="scope-details"><summary>Adjust scope</summary><p>Use paths relative to the folder, one per line. Exclusions accept patterns such as <code>reports/*</code>. Credentials and non-indexable files stay blocked.</p><label class="check-label"><input id="manual" type="checkbox">Keep a manual scope, without automatic redefinition</label><div class="scope-grid"><div><label for="includes">Include folders or files</label><textarea id="includes" spellcheck="false"></textarea></div><div><label for="excludes">Exclude from scope</label><textarea id="excludes" spellcheck="false"></textarea></div></div><label for="user-excludes">My permanent exclusions</label><textarea id="user-excludes" spellcheck="false"></textarea><small>These exclusions are kept when the model reassesses the scope.</small><div class="actions"><button id="save-scope">Save scope</button><button id="rescope">Reassess with the model</button></div></details>
81
+ <details><summary>Job history</summary><div id="history" class="history"></div></details>
82
+ <details><summary>Index maintenance</summary><p>Rebuild creates a new index and keeps the previous generation until it finishes. Remove deletes the index and the registration; the folder files are kept.</p><div class="actions"><button id="rebuild">Rebuild index</button><button id="remove" class="danger">Remove index and registration</button></div><label for="moved-root">Did the folder move?</label><input id="moved-root" type="text" placeholder="New full path" autocomplete="off"><p class="muted">Reassign the index to the new path. The next update checks the content before reusing the embeddings.</p><div class="actions"><button id="relocate">Reassign folder</button></div><small id="project-id"></small></details>
83
+ </div></section></div>
84
+ <p id="storage" class="storage"></p><details><summary>Model diagnostics</summary><p>Run a real embedding and rerank call with a test text. The result shows availability right now.</p><button id="probe">Check models now</button><p id="gateway" class="muted">Not checked yet in this daemon session.</p></details><footer>Content and embeddings stay in the local index. Eligible chunks are sent to the model configured in the model gateway.</footer>
85
+ </main>
86
+ <script>
87
+ const token=document.querySelector('meta[name="setup-token"]').content;
88
+ const $=id=>document.getElementById(id), esc=value=>String(value??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
89
+ let projects=[],selected=location.hash.slice(1),scopeKey='',scopeDirty=false,loading=false,actionBusy=false,announcementKey='';
90
+ const states={registered:'Registered',queued:'Queued',ready:'Up to date',dirty:'Update pending',paused:'Paused',error:'Needs attention',interrupted:'Interrupted',needs_config:'Check settings',unavailable:'Folder unavailable',migration_required:'Format update',preview_ready:'Preview ready',degraded:'Search with caveats',pending:'In progress',done:'Done',cancelled:'Cancelled'};
91
+ const kindNames={smart_search:'Code search',project_index:'Indexing',project_preview:'Preview'};
92
+ const formatTime=t=>t?new Date(t*1000).toLocaleString('en-US'):'Not done yet';
93
+ function message(text,error=false){$('message').textContent=text;$('message').className=error?'error':'success'}
94
+ async function api(payload){const response=await fetch('/setup/projects',{method:payload?'POST':'GET',headers:{'Content-Type':'application/json','X-Setup-Token':token},...(payload?{body:JSON.stringify(payload)}:{})});const data=await response.json();if(!response.ok)throw Error(data.error||'Failed to reach the daemon.');return data}
95
+ function setAdd(open){const restore=$('add-form').contains(document.activeElement);$('add-form').hidden=!open;$('add-toggle').setAttribute('aria-expanded',String(open));if(open)$('root').focus();else if(restore)$('add-toggle').focus()}
96
+ function select(id){selected=id;scopeKey='';scopeDirty=false;window.SmartMaps?.reset();window.SmartInsights?.reset();history.replaceState(null,'','#'+id);render()}
97
+ function jobsHtml(rows){return rows.map(j=>`<div class="job"><div class="job-line"><strong>${esc(kindNames[j.kind]||j.kind)}</strong><span class="state ${j.status==='error'?'bad':''}">${esc(states[j.status]||j.status)}</span></div>${j.message?`<p>${esc(j.cancel_requested?'Cancellation requested; waiting for the current call to finish.':j.message)}</p>`:''}${j.status==='pending'?`<progress aria-label="Indexing progress" ${j.total_files>0&&j.processed_files!=null?`max="${j.total_files}" value="${j.processed_files}"`:''}></progress>`:''}${j.error?`<p class="state bad">${esc(j.error)}</p>`:''}${j.warning?`<p>${esc(j.warning)}</p>`:''}<small>${esc(j.job_id)}${j.elapsed_s!=null?' · '+j.elapsed_s+' s':''}</small></div>`).join('')}
98
+ function render(){
99
+ $('count').textContent=projects.length+' folder'+(projects.length===1?' registered':'s registered');
100
+ const list=$('project-list');
101
+ for(const node of [...list.children])if(!projects.some(p=>p.id===node.dataset.project))node.remove();
102
+ for(const p of projects){let button=list.querySelector(`[data-project="${p.id}"]`);if(!button){button=document.createElement('button');button.className='project-item';button.dataset.project=p.id;button.append(document.createElement('strong'),document.createElement('small'));list.append(button)}button.setAttribute('aria-current',String(p.id===selected));button.firstChild.textContent=p.name;button.lastChild.textContent=(states[p.status]||p.status)+(p.watch&&!p.paused?' · Watching':'')}
103
+ if(!projects.length&&!list.children.length){const empty=document.createElement('p');empty.className='muted';empty.textContent='No folders registered.';list.append(empty)}
104
+ const p=projects.find(p=>p.id===selected);$('empty').hidden=!!p;$('project-detail').hidden=!p;if(!p)return;
105
+ $('name').textContent=p.name;$('path').textContent=p.root;$('state').textContent=states[p.status]||p.status;$('state').className='state '+(['error','needs_config','unavailable'].includes(p.status)?'bad':p.status==='ready'?'good':'warn');
106
+ $('git-view').textContent=p.view?.git?'Active branch/view: '+p.view.label+' · HEAD '+(p.view.commit?.slice(0,10)||'no commit'):'Folder without Git: one view of the working directory.';
107
+ const active=p.jobs.filter(j=>j.status==='pending'), busy=active.length>0;
108
+ const announcement=p.id+p.status+JSON.stringify(active.map(j=>[j.job_id,j.phase,Math.floor((j.processed_files||0)/20),j.cancel_requested]));if(announcement!==announcementKey){$('job-announcement').textContent=active.length?active.map(j=>j.cancel_requested?'Cancellation requested.':j.message).join(' '):'Project: '+(states[p.status]||p.status)+'.';announcementKey=announcement}
109
+ $('current-jobs').innerHTML=jobsHtml(active);$('history').innerHTML=jobsHtml(p.jobs.filter(j=>j.status!=='pending'))||'<p class="muted">No finished operations.</p>';
110
+ const warning=p.last_error||p.last_warning||p.watch_error||'';$('project-warning').hidden=!warning;$('project-warning').textContent=warning;
111
+ $('index').textContent=p.index.exists?'Update index':'Index now';$('cancel').hidden=!busy;$('pause').hidden=p.paused||!p.enabled;$('resume').hidden=!p.paused&&!['error','interrupted','needs_config'].includes(p.status);
112
+ for(const id of ['index','preview','rebuild','remove','relocate','save-scope','rescope'])$(id).disabled=busy||actionBusy;
113
+ if(!actionBusy)$('watch').checked=p.watch;$('watch').disabled=actionBusy;
114
+ if(!actionBusy)$('update-mode').value=p.update_mode||'on_search';$('update-mode').disabled=actionBusy;
115
+ $('watch-note').textContent=p.paused?'Watching paused. A search still checks the content before answering.':!p.enabled?'Watching starts after the first indexing.':!p.watch?'No watcher: the next search or explicit update checks the files.':p.update_mode==='eager'?'Ahead-of-time update: events and reconciliation can start embeddings without a search.':'Local detection on. Changes stay pending; embeddings are updated on the next search.';
116
+ const meta=p.index,stats=p.last_stats||{};
117
+ const facts=[['Files and chunks',meta.exists?`${meta.files||0} files · ${meta.chunks||0} chunks`:'Index not created yet'],['Model',meta.model_id||'Validated on the first indexing'],['Dimensions',meta.dimensions||'Not checked yet'],['Last indexing',formatTime(p.last_indexed)],['Last check',formatTime(p.last_checked)],['Last update',Object.keys(stats).length?`${stats.added||0} new · ${stats.changed||0} changed · ${stats.removed||0} removed · ${stats.unchanged||0} unchanged`:'Not done yet'],['Storage',meta.path||'Created outside the project folder'],['Generation',meta.generation||'Waiting for indexing']];
118
+ $('facts').innerHTML=facts.map(([k,v])=>`<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`).join('');$('project-id').textContent='Project ID: '+p.id;
119
+ if(Object.hasOwn(stats,'embedded_chunks'))$('facts').insertAdjacentHTML('beforeend',`<div><dt>Embedding usage</dt><dd>${stats.embedded_chunks} chunks sent · ${stats.reused_chunks||0} reused from cache · ${stats.embedded_characters||0} characters sent</dd></div>`);
120
+ const preview=p.preview,skip=preview?.skipped||stats.skipped||{};
121
+ $('preview-data').innerHTML=preview?`<p><strong>${preview.eligible_files} eligible files</strong> out of ${preview.included_files} paths in scope. ${(preview.bytes/1024/1024).toFixed(2)} MiB of content.</p><p>Skipped: ${esc(Object.entries(skip).map(([reason,n])=>n+' '+reason).join('; ')||'none in the preview')}.</p><p class="muted">Sample of analyzed paths:</p><ul class="preview-samples">${preview.sample_paths.map(x=>'<li>'+esc(x)+'</li>').join('')}</ul>`:`<p class="muted">Check the preview to see the scope before generating embeddings.</p>${Object.keys(skip).length?'<p>Last indexing: '+esc(Object.entries(skip).map(([r,n])=>n+' '+r).join('; '))+'</p>':''}`;
122
+ const key=p.id+JSON.stringify(p.scope);if(key!==scopeKey&&!scopeDirty){const s=p.scope||{include:['.'],exclude:[]};$('manual').checked=!!s.manual;$('includes').value=(s.include||[]).join('\n');$('excludes').value=(s.exclude||[]).join('\n');$('user-excludes').value=(s.user_exclude||[]).join('\n');scopeKey=key}
123
+ }
124
+ async function refresh(){if(loading)return;loading=true;try{const data=await api();projects=data.projects;$('storage').textContent='Index directory: '+data.storage_dir;const g=data.gateway||{};if(g.checked_at)$('gateway').textContent=formatTime(g.checked_at)+' · '+(g.error||['embedding','rerank'].map(k=>k+': '+(g[k]?.status==='ok'?'available ('+g[k].elapsed_s+' s)':g[k]?.error||'not configured')).join(' · '));if(!projects.some(p=>p.id===selected)){window.SmartMaps?.reset();window.SmartInsights?.reset();selected=projects[0]?.id||'';}render()}catch(e){message(e.message+' Reopen this page if the daemon has restarted.',true)}finally{loading=false}}
125
+ async function act(action,extra={}){if(actionBusy)return;actionBusy=true;render();try{const data=await api({action,project_id:selected,...extra});if(data.cancelled){message('Folder selection cancelled.');return}if(data.project&&['register','pick','relocate'].includes(action)){if(selected!==data.project.id)window.SmartMaps?.reset();window.SmartInsights?.reset();selected=data.project.id;scopeDirty=false;scopeKey='';history.replaceState(null,'','#'+selected);setAdd(false)}if(action==='scope')scopeDirty=false;message(data.job?'Operation started. Progress shows up in the project.':action==='remove'?'Index and registration removed. Project files kept.':action==='pick'||action==='register'?'Folder added. Check the preview before indexing.':action==='probe'?'Check finished. See the model diagnostics.':'Change saved.');await refresh();if(action==='preview')$('preview-details').open=true;if(action==='remove')$('add-toggle').focus()}catch(e){message(e.message,true)}finally{actionBusy=false;render()}}
126
+ $('project-list').addEventListener('click',e=>{const b=e.target.closest('[data-project]');if(b)select(b.dataset.project)});
127
+ $('add-toggle').onclick=()=>setAdd($('add-form').hidden);$('add-close').onclick=()=>setAdd(false);
128
+ $('add-form').onsubmit=e=>{e.preventDefault();act('register',{project_root:$('root').value.trim()})};
129
+ $('pick').onclick=async()=>{$('pick').disabled=true;message('Select the folder in the Windows dialog.');await act('pick');$('pick').disabled=false};
130
+ for(const action of ['preview','index','pause','resume','cancel'])$(action).onclick=()=>act(action);
131
+ $('watch').onchange=()=>act('watch',{watch:$('watch').checked});
132
+ $('update-mode').onchange=()=>act('policy',{update_mode:$('update-mode').value});
133
+ for(const id of ['includes','excludes','user-excludes','manual'])$(id).addEventListener('input',()=>{scopeDirty=true});
134
+ const lines=id=>$(id).value.split('\n').map(s=>s.trim()).filter(Boolean);
135
+ $('save-scope').onclick=()=>act('scope',{manual:$('manual').checked,include:lines('includes'),exclude:lines('excludes'),user_exclude:lines('user-excludes')});
136
+ $('rescope').onclick=()=>act('preview',{force_scope:true});
137
+ $('rebuild').onclick=()=>{if(confirm('Rebuild the index of this project? Eligible content will be sent to the embedding model again.'))act('rebuild')};
138
+ $('remove').onclick=()=>{const p=projects.find(p=>p.id===selected);if(confirm('Remove the index and registration of '+p.name+'? The folder files will be kept.'))act('remove')};
139
+ $('relocate').onclick=()=>act('relocate',{project_root:$('moved-root').value.trim()});
140
+ $('probe').onclick=async()=>{$('probe').disabled=true;message('Checking the models with real calls…');await act('probe');$('probe').disabled=false};
141
+ __MAP_SCRIPT__
142
+ __INSIGHTS_SCRIPT__
143
+ $('open-maps').onclick=()=>{$('map-details').open=true;$('map-details').scrollIntoView({block:'start',behavior:'auto'});window.SmartMaps.load()};
144
+ window.addEventListener('hashchange',()=>select(location.hash.slice(1)));
145
+ refresh();setInterval(()=>{if(!document.hidden)refresh()},2500);
146
+ </script></body></html>'''
@@ -0,0 +1,8 @@
1
+ pystray
2
+ Pillow
3
+ PyYAML
4
+ ddgs==9.16.0
5
+ trafilatura==2.3.0
6
+ # Mapas JS/TS: TypeScript 5.9.3 no runtime Node (web_adapters/node/package-lock.json).
7
+ # Mapas Java/Angular: java-parser 3.0.1 e @angular/compiler 20.3.0 no mesmo runtime Node.
8
+ # DOCX: extração com zipfile + ElementTree (stdlib; sem Office/macros/rede).
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env python3
2
+ """Pesquisas reutilizáveis com conteúdo protegido por DPAPI no perfil Windows.
3
+
4
+ O SQLite guarda apenas hash da consulta, horários e um blob cifrado. Consultas,
5
+ trechos, achados e URLs ficam dentro do blob. Falha de leitura/gravação desliga o
6
+ reúso nesta chamada; a pesquisa web continua normalmente.
7
+ """
8
+ import array
9
+ import base64
10
+ import hashlib
11
+ import json
12
+ import os
13
+ import paths
14
+ import sqlite3
15
+ import threading
16
+ import time
17
+ from contextlib import contextmanager
18
+
19
+ import secret_store
20
+
21
+
22
+ CACHE_PATH = os.path.join(paths.DATA_DIR, "research-cache.sqlite3")
23
+ MAX_ENTRIES = 500
24
+ # Namespaces with their own quota stay out of MAX_ENTRIES, so web_fetch pages never evict web_search answers.
25
+ OWN_QUOTA = {"web_fetch": 300}
26
+ SCHEMA = 2
27
+ MAX_AGE_S = 30 * 86400
28
+ _LOCK = threading.RLock()
29
+
30
+
31
+ def _key(depth, query):
32
+ normalized = " ".join(str(query or "").casefold().split())
33
+ return hashlib.sha256(f"{depth}\0{normalized}".encode("utf-8")).hexdigest()
34
+
35
+
36
+ def _connect():
37
+ os.makedirs(os.path.dirname(CACHE_PATH), exist_ok=True)
38
+ connection = sqlite3.connect(CACHE_PATH, timeout=5)
39
+ connection.execute("""
40
+ CREATE TABLE IF NOT EXISTS entries (
41
+ id TEXT PRIMARY KEY,
42
+ depth TEXT NOT NULL,
43
+ payload TEXT NOT NULL,
44
+ saved_at REAL NOT NULL,
45
+ validated_at REAL NOT NULL
46
+ )
47
+ """)
48
+ return connection
49
+
50
+
51
+ @contextmanager
52
+ def _connection():
53
+ db = _connect()
54
+ try:
55
+ with db:
56
+ yield db
57
+ finally:
58
+ db.close()
59
+
60
+
61
+ def candidates(depth, query, limit=200):
62
+ """Entrada exata primeiro, depois recentes para confirmação semântica."""
63
+ if not secret_store.available():
64
+ return []
65
+ key = _key(depth, query)
66
+ try:
67
+ with _LOCK, _connection() as db:
68
+ db.execute("DELETE FROM entries WHERE saved_at < ?", (time.time() - MAX_AGE_S,))
69
+ rows = db.execute(
70
+ "SELECT id, payload, saved_at, validated_at FROM entries "
71
+ "WHERE depth = ? ORDER BY CASE WHEN id = ? THEN 0 ELSE 1 END, "
72
+ "validated_at DESC LIMIT ?", (depth, key, limit),
73
+ ).fetchall()
74
+ except (OSError, sqlite3.Error):
75
+ return []
76
+ result = []
77
+ for entry_id, protected, saved_at, validated_at in rows:
78
+ try:
79
+ if not secret_store.is_protected(protected):
80
+ continue
81
+ payload = json.loads(secret_store.unprotect(protected))
82
+ if not isinstance(payload, dict) or payload.get("schema") != SCHEMA:
83
+ continue
84
+ vector = payload.pop("topic_vector", None)
85
+ if isinstance(vector, dict):
86
+ values = array.array("f")
87
+ values.frombytes(base64.b64decode(vector["b64"]))
88
+ payload["topic_vector"] = {"model": vector["model"], "values": values}
89
+ result.append({"id": entry_id, "saved_at": saved_at,
90
+ "validated_at": validated_at, **payload})
91
+ except (ValueError, TypeError, KeyError, secret_store.SecretProtectionError):
92
+ continue
93
+ return result
94
+
95
+
96
+ def _packed_vector(model, vector):
97
+ if not model or not vector:
98
+ return None
99
+ return {"model": model, "b64": base64.b64encode(array.array("f", vector).tobytes()).decode("ascii")}
100
+
101
+
102
+ def put(depth, query, result, classification, topic_model=None, topic_vector=None, origin=None):
103
+ """Grava a pesquisa e devolve o horário da gravação, ou None se não gravou."""
104
+ if not secret_store.available():
105
+ return None
106
+ now = time.time()
107
+ saved_at, validated_at = (origin["saved_at"], origin["validated_at"]) if origin else (now, now)
108
+ payload = {"schema": SCHEMA, "query": query, "depth": depth,
109
+ "result": result, "classification": classification}
110
+ if origin:
111
+ payload["origin_query"] = origin.get("origin_query") or origin["query"]
112
+ packed = _packed_vector(topic_model, topic_vector)
113
+ if packed:
114
+ payload["topic_vector"] = packed
115
+ try:
116
+ protected = secret_store.protect(json.dumps(payload, ensure_ascii=False))
117
+ with _LOCK, _connection() as db:
118
+ db.execute(
119
+ "INSERT OR REPLACE INTO entries(id, depth, payload, saved_at, validated_at) "
120
+ "VALUES (?, ?, ?, ?, ?)", (_key(depth, query), depth, protected, saved_at, validated_at),
121
+ )
122
+ own = sorted(OWN_QUOTA)
123
+ marks = ",".join("?" * len(own))
124
+ if depth in OWN_QUOTA:
125
+ db.execute("DELETE FROM entries WHERE depth = ? AND id NOT IN "
126
+ "(SELECT id FROM entries WHERE depth = ? ORDER BY saved_at DESC LIMIT ?)",
127
+ (depth, depth, OWN_QUOTA[depth]))
128
+ else:
129
+ db.execute(f"DELETE FROM entries WHERE depth NOT IN ({marks}) AND id NOT IN "
130
+ f"(SELECT id FROM entries WHERE depth NOT IN ({marks}) ORDER BY saved_at DESC LIMIT ?)",
131
+ (*own, *own, MAX_ENTRIES))
132
+ return saved_at
133
+ except (OSError, ValueError, TypeError, sqlite3.Error, secret_store.SecretProtectionError):
134
+ return None
135
+
136
+
137
+ def complete(depth, query, saved_at, classification, topic_model=None, topic_vector=None):
138
+ """Completa a entrada gravada em saved_at; uma regravação posterior não é sobrescrita."""
139
+ try:
140
+ with _LOCK, _connection() as db:
141
+ row = db.execute("SELECT payload FROM entries WHERE id = ? AND saved_at = ?",
142
+ (_key(depth, query), saved_at)).fetchone()
143
+ if row is None:
144
+ return False
145
+ payload = json.loads(secret_store.unprotect(row[0]))
146
+ payload["classification"] = classification
147
+ packed = _packed_vector(topic_model, topic_vector)
148
+ if packed:
149
+ payload["topic_vector"] = packed
150
+ db.execute("UPDATE entries SET payload = ? WHERE id = ? AND saved_at = ?",
151
+ (secret_store.protect(json.dumps(payload, ensure_ascii=False)),
152
+ _key(depth, query), saved_at))
153
+ return True
154
+ except (OSError, ValueError, TypeError, sqlite3.Error, secret_store.SecretProtectionError):
155
+ return False
156
+
157
+
158
+ def mark_validated(entry_id):
159
+ try:
160
+ with _LOCK, _connection() as db:
161
+ db.execute("UPDATE entries SET validated_at = ? WHERE id = ?", (time.time(), entry_id))
162
+ except (OSError, sqlite3.Error):
163
+ pass
164
+
165
+
166
+ def discard(entry_id):
167
+ try:
168
+ with _LOCK, _connection() as db:
169
+ db.execute("DELETE FROM entries WHERE id = ?", (entry_id,))
170
+ except (OSError, sqlite3.Error):
171
+ pass