@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
@@ -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
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env python3
2
+ """Trilha de calibração da memória (cosseno + rerank) e do teto de pesquisa:
3
+ `site_memory_similarity_threshold` pré-filtra tópicos, o reranker confirma o
4
+ reaproveitamento, e `research_max_iterations` limita o retorno por rodada. Write-only, como
5
+ `router-metrics.jsonl` — nada no daemon lê isto de volta; quem lê é
6
+ `.token-guard/analyze_research.py`.
7
+
8
+ Grava só o que a calibração usa: textos de consulta/tópico truncados e números. Nunca
9
+ snippets nem páginas, que são conteúdo de terceiros e não ajudam a decidir o valor.
10
+ """
11
+ import json
12
+ import os
13
+ import paths
14
+ import threading
15
+ import time
16
+
17
+ import atomic_io
18
+
19
+ METRICS_PATH = os.path.join(paths.DATA_DIR, "research-metrics.jsonl")
20
+ METRICS_MAX_BYTES = 2 * 1024 * 1024
21
+ MAX_TEXT_CHARS = 200
22
+ MAX_PAIRS = 10
23
+
24
+ # O daemon é multi-thread (fan-out síncrono e jobs de pesquisa em pools próprios): sem
25
+ # lock, dois appends grandes podem se intercalar na mesma linha.
26
+ _LOCK = threading.Lock()
27
+
28
+
29
+ def _short(text):
30
+ text = str(text or "")
31
+ if len(text) > MAX_TEXT_CHARS:
32
+ return text[:MAX_TEXT_CHARS] + "..."
33
+ return text
34
+
35
+
36
+ def _append(record):
37
+ record = {"ts": time.time(), **record}
38
+ try:
39
+ line = json.dumps(record, ensure_ascii=False) + "\n"
40
+ with _LOCK:
41
+ os.makedirs(os.path.dirname(METRICS_PATH), exist_ok=True)
42
+ atomic_io.rotate(METRICS_PATH, METRICS_MAX_BYTES)
43
+ with open(METRICS_PATH, "a", encoding="utf-8") as f:
44
+ f.write(line)
45
+ except (OSError, TypeError, ValueError):
46
+ # Métrica é para calibrar depois: disco cheio ou arquivo travado não pode derrubar
47
+ # uma pesquisa.
48
+ pass
49
+
50
+
51
+ def log_site_memory(query, threshold, scored_topics, recalled, rerank_scores=None, rerank_threshold=None):
52
+ """Uma correlação da memória do dia. `scored_topics`: `[(tópico, similaridade)]` de
53
+ todos os tópicos comparados — só os `MAX_PAIRS` mais altos vão pro disco, que são os
54
+ que decidem onde o limiar corta."""
55
+ try:
56
+ ranked = sorted(scored_topics, key=lambda pair: pair[1], reverse=True)
57
+ record = {
58
+ "kind": "site_memory",
59
+ "query": _short(query),
60
+ "threshold": threshold,
61
+ "topics": len(ranked),
62
+ "matched": sum(1 for _, score in ranked if score >= threshold),
63
+ "recalled": recalled,
64
+ "pairs": [
65
+ {
66
+ "topic": _short(topic), "score": round(float(score), 4),
67
+ **({"rerank_score": round(float(rerank_scores[topic]), 4)}
68
+ if rerank_scores is not None and topic in rerank_scores else {}),
69
+ }
70
+ for topic, score in ranked[:MAX_PAIRS]
71
+ ],
72
+ }
73
+ if rerank_scores is not None:
74
+ record["rerank_threshold"] = rerank_threshold
75
+ record["rerank_matched"] = sum(
76
+ score >= rerank_threshold for score in rerank_scores.values()
77
+ )
78
+ except Exception:
79
+ return
80
+ _append(record)
81
+
82
+
83
+ def log_research_job(topic, max_iterations, rounds, stopped_by, sources, elapsed_s):
84
+ """Um job `depth='research'` concluído. `rounds` já traz, por rodada, quanto ela
85
+ acrescentou (`new_urls`, `findings`) — é a curva de retorno marginal que decide o
86
+ teto."""
87
+ try:
88
+ record = {
89
+ "kind": "research",
90
+ "topic": _short(topic),
91
+ "max_iterations": max_iterations,
92
+ "stopped_by": stopped_by,
93
+ "sources": sources,
94
+ "elapsed_s": round(elapsed_s, 1),
95
+ "rounds": [
96
+ {
97
+ "query": _short(r.get("query")),
98
+ "results": r.get("results_count", 0),
99
+ "new_urls": r.get("new_urls", 0),
100
+ "findings": r.get("findings", 0),
101
+ "gaps": r.get("gaps", 0),
102
+ "continue": bool(r.get("continue")),
103
+ "reason": _short(r.get("reason")),
104
+ }
105
+ for r in rounds
106
+ ],
107
+ }
108
+ except Exception:
109
+ return
110
+ _append(record)