@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.
- package/CHANGELOG.md +35 -0
- package/LICENSE +202 -0
- package/NOTICE +32 -0
- package/README.md +116 -2
- package/atomic_io.py +94 -0
- package/autostart.py +199 -0
- package/bin/smart-tool.mjs +86 -0
- package/browser_service_client.py +232 -0
- package/capabilities.py +63 -0
- package/client_hooks.py +192 -0
- package/code_graph.py +403 -0
- package/code_graph_java.mjs +49 -0
- package/code_graph_js.cjs +135 -0
- package/config.py +241 -0
- package/daemon_launcher.py +165 -0
- package/directory_watch.py +131 -0
- package/document_text.py +69 -0
- package/duplicates.py +313 -0
- package/embedding_cache.py +99 -0
- package/endpoint_sync.py +179 -0
- package/folder_picker.py +73 -0
- package/freshness.py +278 -0
- package/gateway.py +220 -0
- package/hook_decision.py +96 -0
- package/hooks/pretool_router.py +45 -0
- package/index_inventory.py +75 -0
- package/index_profile.py +167 -0
- package/index_scope.py +458 -0
- package/index_views.py +121 -0
- package/indexer.py +878 -0
- package/install.py +353 -0
- package/install_runtime.py +283 -0
- package/integration_metrics.py +82 -0
- package/local_embedder.py +172 -0
- package/model_adapters/__init__.py +0 -0
- package/model_adapters/openai_compatible.py +57 -0
- package/model_catalog.py +74 -0
- package/model_client.py +38 -0
- package/model_defaults.py +70 -0
- package/package.json +33 -4
- package/paths.py +6 -0
- package/project_identity.py +42 -0
- package/project_insights_ui.py +38 -0
- package/project_maps_ui.py +96 -0
- package/project_monitor.py +204 -0
- package/project_scheduler.py +61 -0
- package/project_storage.py +165 -0
- package/project_store.py +220 -0
- package/projects_ui.py +146 -0
- package/requirements.txt +8 -0
- package/research_cache.py +171 -0
- package/research_metrics.py +110 -0
- package/router.py +582 -0
- package/secret_store.py +121 -0
- package/setup_ui.py +986 -0
- package/site_memory.py +53 -0
- package/smart_tool_daemon.py +3604 -0
- package/tray.py +245 -0
- package/update_check.py +22 -0
- package/usage_meter.py +124 -0
- package/version.py +18 -0
- package/view_compare.py +68 -0
- package/web_adapters/browser/browser_service.py +302 -0
- package/web_adapters/browser/crawl4ai_search.py +236 -0
- package/web_adapters/browser/fetch_camoufox.py +28 -0
- package/web_adapters/browser/requirements.txt +2 -0
- package/web_adapters/node/adapter-common.mjs +40 -0
- package/web_adapters/node/ddg-search.mjs +89 -0
- package/web_adapters/node/open-websearch-adapter.mjs +77 -0
- package/web_adapters/node/package-lock.json +2720 -0
- package/web_adapters/node/package.json +12 -0
- package/web_adapters/node/wikipedia-search.mjs +44 -0
- package/web_document_graph.py +71 -0
- package/web_fetch.py +242 -0
- package/web_search_adapters.py +193 -0
- package/web_search_ddgs.py +42 -0
- package/web_search_external.py +288 -0
- package/web_search_health.py +88 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Prévia e limpeza seletiva de visões locais; raiz ativa e fixadas são protegidas."""
|
|
2
|
+
import contextlib
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import secrets
|
|
8
|
+
import sqlite3
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import code_graph
|
|
13
|
+
import embedding_cache
|
|
14
|
+
import index_inventory
|
|
15
|
+
import index_scope
|
|
16
|
+
import indexer
|
|
17
|
+
import index_views
|
|
18
|
+
import project_identity
|
|
19
|
+
import project_store
|
|
20
|
+
|
|
21
|
+
_PLANS={}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _assets(root):
|
|
25
|
+
files={}
|
|
26
|
+
for key in dict.fromkeys([project_identity.project_id(root),*project_identity.legacy_ids(root)]):
|
|
27
|
+
pattern=re.compile(re.escape(key)+r'(?:\.v-[a-f0-9]{16})?(?:\.previous|\.building-[a-f0-9]+|\.vectors)?\.sqlite3$')
|
|
28
|
+
for path in Path(indexer.INDEX_DIR).glob(key+'*.sqlite3'):
|
|
29
|
+
if pattern.fullmatch(path.name):files[path.name]=str(path)
|
|
30
|
+
return files
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _stamp(path):
|
|
34
|
+
stat=os.stat(path);return [stat.st_size,stat.st_mtime_ns,stat.st_ino]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def inventory(project):
|
|
38
|
+
root=project['root'];views=index_inventory.views(root);assets=_assets(root)
|
|
39
|
+
active=indexer.existing_db_path(root)
|
|
40
|
+
for view in views:view['current']=bool(active and os.path.normcase(view['path'])==os.path.normcase(active))
|
|
41
|
+
pins=project.get('pinned_views',[])
|
|
42
|
+
fallback=None if any(v['current'] for v in views) else (max(views,key=lambda v:v.get('updated_at') or 0)['path'] if views else None)
|
|
43
|
+
rows=[]
|
|
44
|
+
for view in views:
|
|
45
|
+
row={**view,'storage_id':Path(view['path']).name,'pinned':view['view_id'] in pins}
|
|
46
|
+
row['protected_reason']='Active view' if view['current'] else 'Pinned view' if row['pinned'] else 'Last available view; the active branch has no index' if view['path']==fallback else ''
|
|
47
|
+
row['protected']=bool(row['protected_reason']);rows.append(row)
|
|
48
|
+
total=sum(os.path.getsize(path) for path in assets.values())
|
|
49
|
+
cache_path=Path(indexer.INDEX_DIR)/(project_identity.project_id(root)+'.vectors.sqlite3')
|
|
50
|
+
return {'views':rows,'total_bytes':total,'cache_bytes':cache_path.stat().st_size if cache_path.exists() else 0,
|
|
51
|
+
'auxiliary_bytes':max(0,total-sum(v['bytes'] for v in rows)-(cache_path.stat().st_size if cache_path.exists() else 0)),
|
|
52
|
+
'cleanup':'manual','source_files_untouched':True}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def pin(project,storage_id,pinned):
|
|
56
|
+
if type(pinned) is not bool:raise ValueError('pinned must be true or false.')
|
|
57
|
+
row=next((v for v in inventory(project)['views'] if v['storage_id']==storage_id),None)
|
|
58
|
+
if row is None:raise ValueError('View not found in this project.')
|
|
59
|
+
pins=set(project.get('pinned_views',[]))
|
|
60
|
+
if pinned:pins.add(row['view_id'])
|
|
61
|
+
else:pins.discard(row['view_id'])
|
|
62
|
+
project_store.update(project['id'],pinned_views=sorted(pins))
|
|
63
|
+
return inventory(project_store.get(project['id']))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _cache_unused(root,assets,removed):
|
|
67
|
+
cache=Path(indexer.INDEX_DIR)/(project_identity.project_id(root)+'.vectors.sqlite3')
|
|
68
|
+
if not cache.exists():return [],0,''
|
|
69
|
+
keep=set()
|
|
70
|
+
for name,path in assets.items():
|
|
71
|
+
if path in removed or name.endswith('.vectors.sqlite3'):continue
|
|
72
|
+
with contextlib.closing(indexer._readonly(path)) as db:
|
|
73
|
+
meta=indexer._metadata(db)
|
|
74
|
+
if not all(meta.get(k) for k in ('model_id','dimensions','chunker_version','chunk_lines')):
|
|
75
|
+
return [],0,'Cache kept: a generation without a known model contract exists.'
|
|
76
|
+
contract={'model':meta['model_id'],'dimensions':meta['dimensions'],'chunker':meta['chunker_version'],'chunk_lines':meta['chunk_lines'],'preprocessing':1}
|
|
77
|
+
prefix=json.dumps(contract,sort_keys=True,ensure_ascii=False)+'\0'
|
|
78
|
+
for text, in db.execute('SELECT text FROM chunks'):
|
|
79
|
+
keep.add(hashlib.sha256((prefix+text).encode('utf-8')).hexdigest())
|
|
80
|
+
if len(keep)>1000000:return [],0,'Cache kept: reference check limit reached.'
|
|
81
|
+
with contextlib.closing(indexer._readonly(str(cache))) as db:
|
|
82
|
+
unused=[(key,size) for key,size in db.execute('SELECT key,length(vector) FROM vectors') if key not in keep]
|
|
83
|
+
return [key for key,_ in unused],sum(size for _,size in unused),''
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def preview(project,storage_ids,clear_cache=True):
|
|
87
|
+
if not isinstance(storage_ids,list) or any(not isinstance(x,str) for x in storage_ids) or len(storage_ids)>200:
|
|
88
|
+
raise ValueError('Choose up to 200 views of this project.')
|
|
89
|
+
storage_ids=sorted(set(storage_ids))
|
|
90
|
+
if type(clear_cache) is not bool:raise ValueError('clear_cache must be true or false.')
|
|
91
|
+
current=inventory(project);lookup={v['storage_id']:v for v in current['views']}
|
|
92
|
+
assets=_assets(project['root']);removed=[];selected=[]
|
|
93
|
+
for storage_id in set(storage_ids):
|
|
94
|
+
row=lookup.get(storage_id)
|
|
95
|
+
if not row:raise ValueError('A selected view no longer belongs to the project.')
|
|
96
|
+
if row['protected']:raise ValueError(row['label']+': '+row['protected_reason']+'.')
|
|
97
|
+
selected.append(row)
|
|
98
|
+
stem=storage_id[:-8]
|
|
99
|
+
removed.extend(path for name,path in assets.items() if name==storage_id or re.fullmatch(re.escape(stem)+r'(?:\.previous|\.building-[a-f0-9]+)\.sqlite3',name))
|
|
100
|
+
if '.v-' in stem:
|
|
101
|
+
scope=Path(index_scope.SCOPE_DIR)/(stem+'.scope.json')
|
|
102
|
+
if scope.is_file():removed.append(str(scope))
|
|
103
|
+
if not selected:raise ValueError('Select at least one unprotected view.')
|
|
104
|
+
for path in removed:project_identity.state_path(path,indexer.INDEX_DIR)
|
|
105
|
+
keys,vector_bytes,note=_cache_unused(project['root'],assets,set(removed)) if clear_cache else ([],0,'Shared cache kept by user choice.')
|
|
106
|
+
signature={path:_stamp(path) for path in set(assets.values())|set(removed)}
|
|
107
|
+
token=secrets.token_urlsafe(24)
|
|
108
|
+
for old,value in list(_PLANS.items()):
|
|
109
|
+
if value['expires']<time.time():_PLANS.pop(old,None)
|
|
110
|
+
while len(_PLANS)>=8:_PLANS.pop(next(iter(_PLANS)))
|
|
111
|
+
result={'plan_id':token,'views':[{'storage_id':v['storage_id'],'label':v['label']} for v in selected],
|
|
112
|
+
'index_bytes':sum(os.path.getsize(p) for p in removed),'unused_vectors':len(keys),
|
|
113
|
+
'vector_bytes':vector_bytes,'note':note,'expires_in_s':900,'source_files_untouched':True}
|
|
114
|
+
_PLANS[token]={'project_id':project['id'],'expires':time.time()+900,'paths':removed,'signatures':signature,
|
|
115
|
+
'keys':keys,'storage_ids':storage_ids,'clear_cache':clear_cache,'result':result}
|
|
116
|
+
return result
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def commit(project,plan_id):
|
|
120
|
+
plan=_PLANS.pop(plan_id,None)
|
|
121
|
+
if not plan or plan['project_id']!=project['id'] or plan['expires']<time.time():
|
|
122
|
+
raise ValueError('Preview expired or invalid. Generate a new cleanup preview.')
|
|
123
|
+
# Atualiza a identidade antes de conferir proteções: um checkout recente não
|
|
124
|
+
# pode usar a visão anterior mantida pelo cache de descoberta durante 1 s.
|
|
125
|
+
view=index_views.describe(project['root'],fresh=True)
|
|
126
|
+
current={v['storage_id']:v for v in inventory(project)['views']}
|
|
127
|
+
index_views.assert_current(view)
|
|
128
|
+
for key in plan['storage_ids']:
|
|
129
|
+
if key not in current or current[key]['protected']:raise ValueError('View protection changed. Generate another preview.')
|
|
130
|
+
assets=_assets(project['root'])
|
|
131
|
+
if set(assets.values())!=set(p for p in plan['signatures'] if p.endswith('.sqlite3')):
|
|
132
|
+
raise ValueError('The set of indexes changed. Generate another preview.')
|
|
133
|
+
if any(not os.path.exists(path) or _stamp(path)!=stamp for path,stamp in plan['signatures'].items()):
|
|
134
|
+
raise ValueError('An index changed since the preview. Generate another preview.')
|
|
135
|
+
moved=[];warnings=[]
|
|
136
|
+
try:
|
|
137
|
+
for source in plan['paths']:
|
|
138
|
+
index_views.assert_current(view)
|
|
139
|
+
safe=project_identity.state_path(source,indexer.INDEX_DIR)
|
|
140
|
+
target=project_identity.state_path(source+'.cleanup-'+secrets.token_hex(6),indexer.INDEX_DIR)
|
|
141
|
+
os.replace(safe,target);moved.append((safe,target))
|
|
142
|
+
index_views.assert_current(view)
|
|
143
|
+
except (OSError,index_views.ViewChanged) as exc:
|
|
144
|
+
for source,target in reversed(moved):os.replace(target,source)
|
|
145
|
+
if isinstance(exc,index_views.ViewChanged):raise
|
|
146
|
+
raise RuntimeError('An index is open in another process. Close the external reader and generate a new preview.') from exc
|
|
147
|
+
cache=Path(indexer.INDEX_DIR)/(project_identity.project_id(project['root'])+'.vectors.sqlite3')
|
|
148
|
+
deleted_vectors=0
|
|
149
|
+
if plan['keys'] and cache.exists():
|
|
150
|
+
try:
|
|
151
|
+
with contextlib.closing(sqlite3.connect(cache,timeout=5)) as db:
|
|
152
|
+
db.execute('PRAGMA secure_delete=ON')
|
|
153
|
+
with db:db.executemany('DELETE FROM vectors WHERE key=?',[(key,) for key in plan['keys']])
|
|
154
|
+
deleted_vectors=len(plan['keys'])
|
|
155
|
+
try:db.execute('VACUUM')
|
|
156
|
+
except sqlite3.Error:warnings.append('Vectors removed; SQLite could not compact the cache right now.')
|
|
157
|
+
except sqlite3.Error:warnings.append('The views were removed; the cache in use by another process was kept.')
|
|
158
|
+
for _,path in moved:os.remove(project_identity.state_path(path,indexer.INDEX_DIR))
|
|
159
|
+
indexer.invalidate_cache(project['root'])
|
|
160
|
+
with code_graph._LOCK:
|
|
161
|
+
for key in list(code_graph._CACHE):
|
|
162
|
+
if key[0] in plan['paths']:code_graph._CACHE.pop(key,None)
|
|
163
|
+
return {'removed_views':len(plan['storage_ids']),'removed_index_bytes':plan['result']['index_bytes'],
|
|
164
|
+
'removed_vectors':deleted_vectors,'warnings':warnings,'source_files_untouched':True,
|
|
165
|
+
'storage':inventory(project_store.get(project['id']))}
|
package/project_store.py
ADDED
|
@@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>'''
|
package/requirements.txt
ADDED
|
@@ -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).
|