@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,96 @@
1
+ """Interface dos três mapas reais por projeto; sem bibliotecas de UI externas."""
2
+
3
+ STYLE = r'''
4
+ .maps{--map-blue-fill:#bfdbfe;--map-blue-stroke:#1d4ed8;--map-blue-ink:#172554;--map-teal-fill:#99f6e4;--map-teal-stroke:#0f766e;--map-teal-ink:#042f2e;--map-violet-fill:#ddd6fe;--map-violet-stroke:#6d28d9;--map-violet-ink:#2e1065;--map-amber-fill:#fde68a;--map-amber-stroke:#a16207;--map-amber-ink:#422006;--map-rose-fill:#fecdd3;--map-rose-stroke:#be123c;--map-rose-ink:#4c0519;--map-sky-fill:#bae6fd;--map-sky-stroke:#0369a1;--map-sky-ink:#082f49;--map-orange-fill:#fed7aa;--map-orange-stroke:#c2410c;--map-orange-ink:#431407;--map-green-fill:#bbf7d0;--map-green-stroke:#15803d;--map-green-ink:#052e16}
5
+ @media(prefers-color-scheme:dark){.maps{--map-blue-fill:#1e3a8a;--map-blue-stroke:#93c5fd;--map-blue-ink:#eff6ff;--map-teal-fill:#134e4a;--map-teal-stroke:#5eead4;--map-teal-ink:#f0fdfa;--map-violet-fill:#4c1d95;--map-violet-stroke:#c4b5fd;--map-violet-ink:#f5f3ff;--map-amber-fill:#713f12;--map-amber-stroke:#fcd34d;--map-amber-ink:#fffbeb;--map-rose-fill:#881337;--map-rose-stroke:#fda4af;--map-rose-ink:#fff1f2;--map-sky-fill:#0c4a6e;--map-sky-stroke:#7dd3fc;--map-sky-ink:#f0f9ff;--map-orange-fill:#7c2d12;--map-orange-stroke:#fdba74;--map-orange-ink:#fff7ed;--map-green-fill:#14532d;--map-green-stroke:#86efac;--map-green-ink:#f0fdf4}}
6
+ .maps-color-legend{padding:10px 20px;display:flex;align-items:center;gap:8px 16px;flex-wrap:wrap;max-height:115px;overflow:auto;font-size:12px;color:var(--muted);border-top:1px solid var(--border)}.maps-color-legend>span{display:inline-flex;align-items:center;gap:6px;max-width:100%;overflow-wrap:anywhere}.maps-swatch{display:inline-block;width:13px;height:13px;flex:0 0 13px;border-radius:3px;background:var(--swatch-fill);border:1px solid var(--swatch-stroke)}.maps-node.pending circle,.maps-node.pending rect,.maps-node.missing circle,.maps-node.missing rect{stroke-dasharray:6 3}
7
+ .maps-inspector{max-height:none!important;overflow:visible!important;background:var(--surface)}
8
+ .maps{margin:18px 0;border:1px solid var(--border);border-radius:10px;background:var(--surface);overflow:hidden}.maps>summary{padding:18px 20px}.maps[open]>summary{border-bottom:1px solid var(--border)}.maps-tabs{display:flex;gap:6px;flex-wrap:wrap;padding:16px 20px 0}.maps-tabs button[aria-selected=true]{background:var(--selected);border-color:var(--accent)}.maps-toolbar{padding:14px 20px;display:flex;gap:10px;align-items:end;flex-wrap:wrap}.maps-toolbar label{font-size:12px;margin:0 0 4px}.maps-toolbar input,.maps-toolbar select{min-height:40px;max-width:100%;margin:0}.maps-search{flex:1;min-width:150px}.maps-view{max-width:280px}.maps-status{padding:0 20px;font-size:13px;color:var(--muted);overflow-wrap:anywhere}.maps-workspace{display:grid;grid-template-columns:minmax(0,1fr) 245px;border-block:1px solid var(--border);margin-top:14px}.maps-canvas{min-width:0;background:var(--bg)}.maps-tools{display:flex;gap:6px;padding:10px 14px;align-items:center;flex-wrap:wrap;border-bottom:1px solid var(--border)}.maps-tools button{font-size:12px;padding:6px 10px;min-height:36px}.maps-tools span{margin-left:auto;font-size:12px;color:var(--muted)}#maps-svg{display:block;width:100%;height:480px;touch-action:none;user-select:none;cursor:grab}#maps-svg:active{cursor:grabbing}.maps-edge{stroke:var(--edge-color,var(--border));stroke-width:1.6;fill:none;opacity:.85}.maps-edge.active{stroke:var(--edge-color,var(--accent));stroke-width:2.8;opacity:1}.maps-node{cursor:pointer}.maps-node circle,.maps-node rect{fill:var(--node-fill,var(--surface));stroke:var(--node-stroke,var(--muted));stroke-width:2}.maps-node text{fill:var(--ink);font:12px 'Segoe UI',sans-serif;pointer-events:none}.maps-node .on-node,.maps-node .secondary{fill:var(--node-ink,var(--ink))}.maps-node .secondary{font-size:11px}.maps-node.active circle,.maps-node.active rect{fill:var(--node-fill,var(--selected));stroke:var(--ink)!important;stroke-width:4}.maps-node:focus-visible{outline:none}.maps-node:focus-visible circle,.maps-node:focus-visible rect{stroke:var(--ink)!important;stroke-width:4}.maps-folder-label{fill:var(--muted);font:600 12px 'Segoe UI',sans-serif}.maps-inspector{border-left:1px solid var(--border);padding:18px;min-width:0}.maps-inspector h3{overflow-wrap:anywhere}.maps-inspector code{display:block;overflow-wrap:anywhere;color:var(--muted);margin:8px 0}.maps-inspector p{font-size:13px}.maps-relations{padding:0;list-style:none;max-height:180px;overflow:auto}.maps-relations button{padding:5px 0;min-height:28px;border:0;font-size:12px;text-align:left;color:var(--accent);overflow-wrap:anywhere}.maps-relations button:hover{text-decoration:underline;background:transparent}.maps-bottom{padding:12px 20px;display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;font-size:12px;color:var(--muted)}.maps-legend{display:flex;gap:14px;flex-wrap:wrap}.maps-legend span{display:flex;gap:6px;align-items:center}.maps-dot{width:9px;height:9px;border-radius:50%;background:var(--good)}.maps-dot.pending{background:var(--warn)}.maps-dot.stored{background:var(--muted)}#map-files{padding:0 20px;max-height:220px;overflow:auto}#map-chunks{padding:0 20px 14px}#map-chunks pre{white-space:pre-wrap;overflow-wrap:anywhere;font:12px/1.65 Consolas,monospace}#maps-diagnostics{margin:0 20px}#maps-diagnostic-list{max-height:260px;overflow:auto;font-size:13px;overflow-wrap:anywhere}.maps:fullscreen{background:var(--surface);border:0;border-radius:0;overflow:auto;padding:12px;margin:0}.maps:fullscreen #maps-svg{height:calc(100vh - 350px);min-height:400px}.maps:fullscreen .maps-workspace{grid-template-columns:minmax(0,1fr) 300px}.maps-placeholder{padding:30px 20px;font-size:13px;color:var(--muted)}#maps-symbol-picker{max-width:100%;width:100%;margin:8px 0}
9
+ @media(max-width:1050px){.maps-workspace{grid-template-columns:1fr}.maps-inspector{border-left:0;border-top:1px solid var(--border)}#maps-svg{height:460px}.maps-view{max-width:100%;flex:1}}
10
+ @media(max-width:600px){.maps-tabs,.maps-toolbar{padding-inline:12px}.maps-toolbar>div{max-width:100%}.maps-view{width:100%;flex-basis:100%}.maps-toolbar select{width:100%}.maps-tabs button{flex:1;padding:8px;font-size:13px}#maps-svg{height:420px}.maps:fullscreen .maps-workspace{grid-template-columns:1fr}.maps-bottom{padding-inline:12px}#map-files,#map-chunks{padding-inline:12px}}
11
+ '''
12
+
13
+ HTML = r'''
14
+ <details id="map-details" class="maps">
15
+ <summary>Project maps and stored views</summary>
16
+ <div class="maps-tabs" role="tablist" aria-label="Project maps">
17
+ <button type="button" role="tab" data-map-mode="network" id="map-tab-network" aria-controls="maps-panel" aria-selected="true">A · Files</button>
18
+ <button type="button" role="tab" data-map-mode="coverage" id="map-tab-coverage" aria-controls="maps-panel" aria-selected="false" tabindex="-1">B · Coverage</button>
19
+ <button type="button" role="tab" data-map-mode="calls" id="map-tab-calls" aria-controls="maps-panel" aria-selected="false" tabindex="-1">C · Functions</button>
20
+ </div>
21
+ <div id="maps-panel" role="tabpanel" aria-labelledby="map-tab-network">
22
+ <div class="maps-toolbar">
23
+ <div class="maps-view"><label for="map-view">Stored view</label><select id="map-view"><option value="">Current view or available legacy</option></select></div>
24
+ <div class="maps-search"><label for="maps-search">Find file or function</label><input id="maps-search" type="text" placeholder="Name or path" autocomplete="off"></div>
25
+ <div><label for="maps-folder">Folder</label><select id="maps-folder"><option value="">All</option></select></div>
26
+ <div><label for="maps-color-mode">Color by</label><select id="maps-color-mode"><option value="module">Module / folder</option><option value="status">Index status</option></select></div>
27
+ <button type="button" id="load-map">Load maps</button>
28
+ </div>
29
+ <p class="maps-status" id="map-info" role="status" aria-live="polite">Local analysis of already indexed content, with no new embeddings.</p>
30
+ <p class="maps-status" id="maps-description">Arrows link the importing file to its resolved internal dependency.</p>
31
+ <div id="maps-color-legend" class="maps-color-legend" aria-label="Color legend"></div>
32
+ <div class="maps-workspace">
33
+ <div class="maps-canvas" id="map-graph"><div class="maps-tools"><button id="maps-minus" aria-label="Zoom out">−</button><button id="maps-plus" aria-label="Zoom in">+</button><button id="maps-fit">Fit</button><button id="maps-all">Show all</button><button id="maps-expand">Full screen</button><button id="maps-new-window">New window</button><a id="maps-popup-link" hidden target="_blank" rel="noopener noreferrer">Open map separately</a><label for="maps-limit">Nodes</label><select id="maps-limit" aria-label="Visible node limit"><option value="60">60</option><option value="120" selected>120</option><option value="250">250</option></select><span id="maps-count"></span></div>
34
+ <svg id="maps-svg" viewBox="0 0 1000 650" role="group" aria-label="Interactive project map"><defs><marker id="maps-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto" markerUnits="userSpaceOnUse"><path d="M0 0 L8 4 L0 8" fill="var(--accent)"></path></marker></defs><g id="maps-world"></g></svg>
35
+ <div id="maps-empty" class="maps-placeholder">Load a view to explore the three maps.</div></div>
36
+ <aside class="maps-inspector"><h3 id="maps-selected">Select an element</h3><code id="maps-path"></code><p id="maps-state"></p><p id="maps-facts"></p><label for="maps-symbol-picker">Open element by name</label><select id="maps-symbol-picker"><option value="">Select…</option></select><h3 id="maps-relation-title">Relations</h3><ul id="maps-relations" class="maps-relations"></ul><button id="maps-read" disabled>View file chunks</button></aside>
37
+ </div>
38
+ <div class="maps-bottom"><span id="maps-color-note">Color by module/folder · dashed border means changed or missing.</span><span>Drag nodes or the background; zoom in to read the details.</span></div>
39
+ <details id="maps-diagnostics"><summary>Analysis coverage and unresolved relations</summary><div id="maps-diagnostic-list"></div></details>
40
+ <div id="map-files"></div><div id="map-chunks"></div>
41
+ </div></details>
42
+ '''
43
+
44
+ SCRIPT = r'''
45
+ window.SmartMaps=(()=>{
46
+ const byId=id=>document.getElementById(id),ns='http://www.w3.org/2000/svg';
47
+ let data=null,project=null,mode='network',selectedNode=null,focus=false,positions=new Map(),zoom=1,panX=0,panY=0,drag=null,requestId=0,aborter=null;
48
+ let currentNodes=[],currentEdges=[],shown=[],shownEdges=[],globalLookup=new Map();
49
+ const description={network:'Arrows represent resolved local references: imports, Java types, templates, styles and document links. External targets are listed in the details.',coverage:'Area proportional to the lines stored in the index. Grouped by folder. The map covers the files present in this view.',calls:'Arrows represent resolved static calls between functions, methods and classes. They do not represent an execution trace.'};
50
+ const statusLabel={indexed:'Local metadata checked',pending:'File changed since indexing',missing:'File not found in the current directory',stored:'Content stored; local state not compared'};
51
+ const palette=['blue','teal','violet','amber','rose','sky','orange','green'];
52
+ const statePalette={indexed:'green',pending:'amber',missing:'rose',stored:'blue'};
53
+ function moduleKey(node){const file=node.file||node,path=file.path||node.path||'',folder=file.group||'(root)';if(folder==='(root)')return path.split('/').pop()||'(root)';const parts=folder.split('/');return parts.slice(0,parts[0]==='src'?2:1).join('/')}
54
+ function moduleColor(key){let hash=2166136261;for(const ch of key)hash=Math.imul(hash^ch.codePointAt(0),16777619);return palette[(hash>>>0)%palette.length]}
55
+ function paint(node){return byId('maps-color-mode').value==='status'?(statePalette[node.file?.status||node.status]||'blue'):moduleColor(moduleKey(node))}
56
+ function decorateNode(group,node){const shade=paint(node);for(const role of ['fill','stroke','ink'])group.style.setProperty('--node-'+role,`var(--map-${shade}-${role})`);group.classList.add(node.file?.status||node.status||'stored')}
57
+ function edgeElement(tag,attrs,edge){const shade=paint(globalLookup.get(edge.source)||{}),node=element(tag,{...attrs,'marker-end':`url(#maps-arrow-${shade})`});node.style.setProperty('--edge-color',`var(--map-${shade}-stroke)`);return node}
58
+ function renderLegend(){const host=byId('maps-color-legend'),byState=byId('maps-color-mode').value==='status',groups=new Map();host.replaceChildren();const intro=document.createElement('strong');intro.textContent=byState?'Index status:':'Modules / folders:';host.append(intro);for(const node of shown){const key=byState?(node.file?.status||node.status||'stored'):moduleKey(node);if(!groups.has(key))groups.set(key,{count:0,shade:paint(node)});groups.get(key).count++}for(const [key,item] of [...groups].sort((a,b)=>a[0].localeCompare(b[0]))){const label=document.createElement('span'),swatch=document.createElement('i');swatch.className='maps-swatch';swatch.setAttribute('aria-hidden','true');swatch.style.setProperty('--swatch-fill',`var(--map-${item.shade}-fill)`);swatch.style.setProperty('--swatch-stroke',`var(--map-${item.shade}-stroke)`);label.append(swatch,document.createTextNode((byState?statusLabel[key]:key)+' ('+item.count+')'));host.append(label)}byId('maps-color-note').textContent=byState?'Fill by status · dashed border means changed or missing.':'Fill by module/folder · dashed border means changed or missing.'}
59
+ function element(tag,attrs={},text){const node=document.createElementNS(ns,tag);for(const [k,v] of Object.entries(attrs))node.setAttribute(k,String(v));if(text!==undefined)node.textContent=text;return node}
60
+ function plainList(host,rows){host.replaceChildren();for(const text of rows){const p=document.createElement('p');p.textContent=text;host.append(p)}}
61
+ function clearSelection(){byId('maps-selected').textContent='Select an element';for(const id of ['maps-path','maps-state','maps-facts'])byId(id).textContent='';byId('maps-relation-title').textContent=mode==='calls'?'Static calls':'File dependencies';byId('maps-relations').replaceChildren();byId('maps-read').disabled=true}
62
+ function reset(){++requestId;aborter?.abort();data=null;project=null;selectedNode=null;positions.clear();currentNodes=[];currentEdges=[];shown=[];shownEdges=[];globalLookup.clear();byId('maps-world').replaceChildren();byId('maps-color-legend').replaceChildren();byId('map-files').replaceChildren();byId('map-chunks').replaceChildren();byId('maps-diagnostic-list').replaceChildren();byId('map-view').replaceChildren(new Option('Current view or available legacy',''));byId('maps-folder').replaceChildren(new Option('All',''));byId('maps-symbol-picker').replaceChildren(new Option('Select…',''));byId('maps-search').value='';byId('map-info').textContent='Load the maps of this project. The analysis is local and creates no embeddings.';byId('maps-count').textContent='';byId('maps-empty').hidden=false;byId('maps-empty').textContent='Load a view to explore the three maps.';byId('load-map').disabled=false;byId('maps-popup-link').hidden=true;byId('maps-popup-link').removeAttribute('href');clearSelection()}
63
+ async function load(view,restore=null){const key=selected,id=++requestId;aborter?.abort();aborter=new AbortController();byId('load-map').disabled=true;byId('map-info').textContent='Reading the snapshot and analyzing imports and calls…';try{const response=await fetch('/setup/projects',{method:'POST',headers:{'Content-Type':'application/json','X-Setup-Token':token},body:JSON.stringify({action:'graph',project_id:key,...(view?{view_id:view}:{})}),signal:aborter.signal});const result=await response.json();if(!response.ok)throw Error(result.error||'Could not load the maps.');if(id!==requestId||key!==selected)return;data=result;project=key;selectedNode=null;focus=false;positions.clear();byId('map-view').replaceChildren(...data.views.map(v=>new Option(v.label+(v.current?' (active)':'')+' · '+v.files+' files',v.view_id)));if(data.selected)byId('map-view').value=data.selected.view_id;byId('map-info').textContent=data.selected?`${data.selected.label} · ${data.counts.files} files · ${data.counts.parsed_files} analyzed. ${data.selected.current?'Indexed content; local changes may be pending.':'Stored snapshot. Current view: '+(data.current_view?.label||'not identified')+'.'}`:data.message;byId('maps-folder').replaceChildren(new Option('All',''),...Array.from(new Set(data.files.map(f=>f.group))).sort().map(f=>new Option(f,f)));byId('map-chunks').replaceChildren();renderDiagnostics();if(restore){byId('maps-search').value=restore.q||'';if([...byId('maps-folder').options].some(o=>o.value===restore.folder))byId('maps-folder').value=restore.folder;if(['60','120','250'].includes(restore.limit))byId('maps-limit').value=restore.limit;if(['module','status'].includes(restore.colors))byId('maps-color-mode').value=restore.colors}refreshGraph(true);if(restore?.node&&globalLookup.has(restore.node)){selectedNode=restore.node;focus=restore.focus==='1';refreshGraph(true)}}catch(e){if(e.name!=='AbortError'&&id===requestId){byId('map-info').textContent=e.message+' Use Load maps to try again.';message(e.message,true)}}finally{if(id===requestId)byId('load-map').disabled=false}}
64
+ function renderDiagnostics(){const c=data.counts||{},lines=[`Local analysis of Java, Angular, Python, JavaScript/TypeScript, HTML/CSS and Markdown links. TXT and DOCX show up in coverage and in text search. ${c.parsed_files||0} of ${c.files||0} files were analyzed. Other languages and incomplete snapshots remain available in coverage.`,`${c.resolved_imports||0} resolved internal references; ${c.external_imports||0} external references; ${c.calls||0} call relations. Unresolved targets are a limited sample (${c.unresolved_calls||0} records in the analysis).`];if(data.truncated)lines.push('The analysis limits were reached. Filter by folder to explore the available part.');lines.push(...(data.diagnostics||[]).slice(0,60).map(d=>d.path+': '+d.reason));lines.push(...(data.unresolved||[]).slice(0,40).map(u=>u.path+':'+u.line+' · '+u.expression+' — '+u.reason));plainList(byId('maps-diagnostic-list'),lines)}
65
+ function prepare(){const files=new Map((data?.files||[]).map(f=>[f.path,f]));currentNodes=mode==='calls'?(data?.symbols||[]).map(s=>({...s,file:files.get(s.path),label:s.name==='callback'?'callback :'+s.start_line:s.name})): (data?.files||[]).map(f=>({...f,file:f,label:/^(index|styles|__init__)\./.test(f.path.split('/').pop())?f.path.split('/').slice(-2).join('/'):f.path.split('/').pop()}));globalLookup=new Map(currentNodes.map(n=>[n.id,n]));currentEdges=(mode==='calls'?data?.calls:data?.dependencies)||[];currentEdges=currentEdges.filter(e=>e.target&&globalLookup.has(e.source)&&globalLookup.has(e.target));}
66
+ function chooseVisible(){const term=byId('maps-search').value.trim().toLocaleLowerCase(),folder=byId('maps-folder').value,limit=Number(byId('maps-limit').value);const degree=new Map();for(const e of currentEdges){degree.set(e.source,(degree.get(e.source)||0)+1);degree.set(e.target,(degree.get(e.target)||0)+1)}let list=currentNodes.filter(n=>(!folder||n.file?.group===folder)&&(!term||(n.path+' '+n.label).toLocaleLowerCase().includes(term)));if(focus&&selectedNode){const near=new Set([selectedNode]);for(const e of currentEdges){if(e.source===selectedNode)near.add(e.target);if(e.target===selectedNode)near.add(e.source)}list=currentNodes.filter(n=>near.has(n.id))}list.sort((a,b)=>mode==='coverage'?(b.lines||0)-(a.lines||0):((degree.get(b.id)||0)-(degree.get(a.id)||0)||a.path.localeCompare(b.path)));if(selectedNode&&globalLookup.has(selectedNode)&&list.some(n=>n.id===selectedNode))list=[globalLookup.get(selectedNode),...list.filter(n=>n.id!==selectedNode)];shown=list.slice(0,limit);const ids=new Set(shown.map(n=>n.id));shownEdges=currentEdges.filter(e=>ids.has(e.source)&&ids.has(e.target)).slice(0,1000);byId('maps-count').textContent=mode==='coverage'?`${shown.length} of ${list.length} files · area by lines`:`${shown.length} of ${list.length} nodes · ${shownEdges.length} visible relations${focus?' · selected neighborhood':''}`;byId('maps-empty').hidden=!!shown.length;byId('maps-empty').textContent=mode==='calls'?'No resolved functions in this selection. Check the diagnostics or choose another folder/view.':'No files match the filter.';byId('maps-symbol-picker').replaceChildren(new Option('Select…',''),...list.slice(0,1000).map(n=>new Option(n.label+' · '+n.path,n.id)));if(selectedNode)byId('maps-symbol-picker').value=selectedNode;}
67
+ function layout(){const groups=new Map();for(const n of shown){const pathGroup=n.file?.group||'(root)',parts=pathGroup.split('/'),g=parts.slice(0,parts[0]==='src'?2:1).join('/');if(!groups.has(g))groups.set(g,[]);groups.get(g).push(n)}const sorted=[...groups].sort((a,b)=>b[1].length-a[1].length);const cols=Math.max(1,Math.ceil(Math.sqrt(sorted.length*1.4)));let i=0;for(const [group,nodes] of sorted){const step=mode==='calls'?580:440,cx=210+(i%cols)*step,cy=210+Math.floor(i/cols)*step;nodes.forEach((n,j)=>{if(!positions.has(n.id)){const ring=Math.floor(Math.sqrt(j)),angle=j*2.399963,rad=nodes.length===1?0:mode==='calls'?90+ring*105:50+ring*65;positions.set(n.id,{x:cx+Math.cos(angle)*rad,y:cy+Math.sin(angle)*rad})}});i++}}
68
+ function bounds(){const points=shown.map(n=>positions.get(n.id)).filter(Boolean);if(!points.length)return {x:0,y:0,w:1000,h:650};const xs=points.map(p=>p.x),ys=points.map(p=>p.y);return {x:Math.min(...xs)-100,y:Math.min(...ys)-60,w:Math.max(...xs)-Math.min(...xs)+200,h:Math.max(...ys)-Math.min(...ys)+120}}
69
+ function transform(){byId('maps-world').setAttribute('transform',`translate(${panX} ${panY}) scale(${zoom})`)}
70
+ function fit(){if(mode==='coverage'){zoom=1;panX=panY=0}else{const b=bounds();zoom=Math.min(2.4,900/b.w,560/b.h);panX=500-(b.x+b.w/2)*zoom;panY=325-(b.y+b.h/2)*zoom}transform()}
71
+ function color(node){return ['pending','missing'].includes(node.file?.status)?'var(--warn)':node.file?.status==='indexed'?'var(--good)':'var(--muted)'}
72
+ function wire(group,node){decorateNode(group,node);group.dataset.graphNode=node.id;group.setAttribute('role','button');group.setAttribute('tabindex','0');group.setAttribute('aria-label',node.label+' — '+node.path);group.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();selectNode(node.id,true)}})}
73
+ function render(){renderLegend();const world=byId('maps-world');world.replaceChildren();if(mode==='coverage'){coverage(world);transform();return}for(const edge of shownEdges){const a=positions.get(edge.source),b=positions.get(edge.target);if(!a||!b)continue;if(edge.source===edge.target){const r=mode==='calls'?70:35;world.append(edgeElement('path',{d:`M${a.x+r/2} ${a.y-20} C${a.x+r*2} ${a.y-r*2},${a.x-r*2} ${a.y-r*2},${a.x-r/2} ${a.y-20}`,class:'maps-edge'+(edge.source===selectedNode?' active':''),'marker-end':'url(#maps-arrow)'},edge));continue}const dx=b.x-a.x,dy=b.y-a.y,len=Math.hypot(dx,dy)||1,r=mode==='calls'?Math.min(65/(Math.abs(dx)/len||.0001),20/(Math.abs(dy)/len||.0001))+8:22+Math.min(12,Math.sqrt(globalLookup.get(edge.target)?.chunks||1));world.append(edgeElement('line',{x1:a.x,y1:a.y,x2:b.x-dx/len*r,y2:b.y-dy/len*r,class:'maps-edge'+([edge.source,edge.target].includes(selectedNode)?' active':''),'marker-end':'url(#maps-arrow)'},edge))}for(const n of shown){const p=positions.get(n.id),g=element('g',{class:'maps-node'+(n.id===selectedNode?' active':'')});if(mode==='calls'){g.append(element('rect',{x:p.x-65,y:p.y-20,width:130,height:40,rx:6,stroke:color(n)}));g.append(element('text',{x:p.x,y:p.y+4,'text-anchor':'middle',class:'on-node'},n.label.length>18?n.label.slice(0,16)+'…':n.label))}else{g.append(element('circle',{cx:p.x,cy:p.y,r:14+Math.min(12,Math.sqrt(n.chunks||1)),stroke:color(n),'stroke-dasharray':['pending','missing'].includes(n.status)?'4 3':'none'}));g.append(element('text',{x:p.x,y:p.y+4,'text-anchor':'middle',class:'on-node'},String(n.chunks||0)));g.append(element('text',{x:p.x,y:p.y+39,'text-anchor':'middle'},n.label.length>24?n.label.slice(0,22)+'…':n.label))}g.append(element('title',{},n.path+' · '+n.label));wire(g,n);world.append(g)}transform()}
74
+ function partition(items,x,y,w,h,weight,paint){if(!items.length)return;if(items.length===1){paint(items[0],x,y,w,h);return}const total=items.reduce((s,n)=>s+weight(n),0);let sum=0,cut=1;for(let i=0;i<items.length-1;i++){sum+=weight(items[i]);cut=i+1;if(sum>=total/2)break}const r=sum/total;if(w>=h){partition(items.slice(0,cut),x,y,w*r,h,weight,paint);partition(items.slice(cut),x+w*r,y,w*(1-r),h,weight,paint)}else{partition(items.slice(0,cut),x,y,w,h*r,weight,paint);partition(items.slice(cut),x,y+h*r,w,h*(1-r),weight,paint)}}
75
+ function coverage(world){const groups=new Map();for(const n of shown){const key=n.file?.group||'(root)';if(!groups.has(key))groups.set(key,[]);groups.get(key).push(n)}const weight=n=>Math.max(n.lines||n.file?.lines||1,1);partition([...groups],10,10,980,620,g=>g[1].reduce((s,n)=>s+weight(n),0),(entry,x,y,w,h)=>{world.append(element('rect',{x:x+2,y:y+2,width:Math.max(w-4,1),height:Math.max(h-4,1),fill:'var(--surface)',stroke:'var(--border)',rx:5}));const title=entry[0];if(w>60&&h>35)world.append(element('text',{x:x+10,y:y+18,class:'maps-folder-label'},title.length>Math.floor(w/7)?title.slice(0,Math.floor(w/7)-2)+'…':title));partition(entry[1],x+5,y+25,Math.max(w-10,1),Math.max(h-30,1),weight,(n,fx,fy,fw,fh)=>{const g=element('g',{class:'maps-node'+(n.id===selectedNode?' active':'')});g.append(element('rect',{x:fx+2,y:fy+2,width:Math.max(fw-4,1),height:Math.max(fh-4,1),rx:3,stroke:color(n),'stroke-dasharray':['pending','missing'].includes(n.status)?'4 3':'none'}));if(fw>50&&fh>30){const max=Math.max(4,Math.floor((fw-16)/7));g.append(element('text',{x:fx+8,y:fy+20,class:'on-node'},n.label.length>max?n.label.slice(0,max-1)+'…':n.label));if(fh>50&&fw>100){const detail=`${n.lines} lines · ${n.chunks} chunks`;g.append(element('text',{x:fx+8,y:fy+39,class:'secondary'},detail.length>max?detail.slice(0,max-1)+'…':detail))}}g.append(element('title',{},n.path));wire(g,n);world.append(g)})})}
76
+ function inspector(){const n=globalLookup.get(selectedNode);if(!n){clearSelection();return}byId('maps-selected').textContent=n.label;byId('maps-path').textContent=n.path;byId('maps-state').textContent=statusLabel[n.file?.status]||'Indexed snapshot';byId('maps-facts').textContent=mode==='calls'?`${n.kind} · lines ${n.start_line}–${n.end_line} · ${n.language}`:`${n.lines||0} ${n.file?.location_kind==='extracted_text_line'?'extracted lines':'lines'} · ${n.chunks||0} chunks · ${n.size==null||n.size<0?'size not recorded':n.size+' bytes'}`;byId('maps-read').disabled=false;byId('maps-relations').replaceChildren();byId('maps-relation-title').textContent=mode==='calls'?'Static calls':'File dependencies';const edges=currentEdges.filter(e=>e.source===selectedNode||e.target===selectedNode);for(const e of edges.slice(0,40)){const id=e.source===selectedNode?e.target:e.source,node=globalLookup.get(id),li=document.createElement('li'),button=document.createElement('button');button.textContent=(e.source===selectedNode?'→ ':'← ')+(node?.label||id)+(e.count>1?' ('+e.count+' sites)':'');button.onclick=()=>selectNode(id);li.append(button);byId('maps-relations').append(li)}if(mode!=='calls')for(const e of (data.dependencies||[]).filter(e=>e.source===n.path&&!e.target).slice(0,15)){const li=document.createElement('li');li.textContent=e.specifier+' · '+(e.resolution==='external'?'external':'unresolved');byId('maps-relations').append(li)}if(!byId('maps-relations').children.length){const li=document.createElement('li');li.textContent='No resolved internal relations in this selection.';byId('maps-relations').append(li)}}
77
+ function renderFiles(){const term=byId('maps-search').value.trim().toLocaleLowerCase(),folder=byId('maps-folder').value;const files=(data?.files||[]).filter(f=>(!folder||f.group===folder)&&(!term||f.path.toLocaleLowerCase().includes(term))).slice(0,150);byId('map-files').innerHTML='<p class="muted">Files in the selection · up to 150 items. Filter by path to find others.</p>'+files.map(f=>`<button class="project-item" data-file="${esc(f.path)}"><strong>${esc(f.path)}</strong><small>${f.chunks} chunks · ${esc(statusLabel[f.status]||'Snapshot')}</small></button>`).join('')}
78
+ function publishWindowState(){if(document.body.classList.contains('map-window')&&data?.selected){const state=windowState();history.replaceState(null,'','/maps#'+state.toString())}}
79
+ function refreshGraph(reframe=false){prepare();chooseVisible();if(reframe)positions.clear();layout();render();if(reframe)fit();inspector();renderFiles();byId('maps-description').textContent=description[mode];publishWindowState()}
80
+ function selectNode(id,keyboard=false){selectedNode=id;focus=mode!=='coverage';refreshGraph(true);if(keyboard)byId('maps-world').querySelector('[data-graph-node="'+CSS.escape(id)+'"]')?.focus()}
81
+ async function readFile(path){if(!data?.selected||project!==selected)return;const key=selected,view=data.selected.view_id,request=requestId;try{const detail=await api({action:'inspect',project_id:key,view_id:view,file_path:path});if(key!==selected||request!==requestId)return;byId('map-chunks').innerHTML='<h3>'+esc(path)+'</h3><p class="muted">Text of the stored view; up to 100 chunks. May differ from the current file.</p>'+detail.chunks.map(c=>`<details><summary>${c.location_kind==='extracted_text_line'?'Extracted text lines':'Lines'} ${c.start_line}–${c.end_line} · ${c.dimensions} dimensions</summary><pre>${esc(c.text)}</pre></details>`).join('');byId('map-chunks').scrollIntoView({block:'nearest',behavior:'auto'})}catch(e){message(e.message,true)}}
82
+ function switchMode(value){mode=value;focus=false;selectedNode=null;positions.clear();for(const b of document.querySelectorAll('[data-map-mode]')){const active=b.dataset.mapMode===mode;b.setAttribute('aria-selected',String(active));b.tabIndex=active?0:-1}byId('maps-panel').setAttribute('aria-labelledby','map-tab-'+mode);if(data)refreshGraph(true);else byId('maps-description').textContent=description[mode];publishWindowState()}
83
+ document.querySelectorAll('[data-map-mode]').forEach(b=>{b.onclick=()=>switchMode(b.dataset.mapMode);b.onkeydown=e=>{if(['ArrowLeft','ArrowRight','Home','End'].includes(e.key)){e.preventDefault();const tabs=[...document.querySelectorAll('[data-map-mode]')],i=tabs.indexOf(b),j=e.key==='Home'?0:e.key==='End'?2:(i+(e.key==='ArrowRight'?1:2))%3;tabs[j].focus();switchMode(tabs[j].dataset.mapMode)}}});
84
+ byId('load-map').onclick=()=>load(byId('map-view').value||undefined);byId('map-view').onchange=()=>load(byId('map-view').value);byId('maps-search').oninput=()=>{focus=false;selectedNode=null;refreshGraph(true)};byId('maps-folder').onchange=()=>{focus=false;selectedNode=null;positions.clear();refreshGraph(true)};byId('maps-limit').onchange=()=>refreshGraph(true);byId('maps-all').onclick=()=>{focus=false;selectedNode=null;refreshGraph(true)};byId('maps-symbol-picker').onchange=()=>{if(byId('maps-symbol-picker').value)selectNode(byId('maps-symbol-picker').value)};byId('maps-read').onclick=()=>{const n=globalLookup.get(selectedNode);if(n)readFile(n.path)};byId('map-files').onclick=e=>{const b=e.target.closest('[data-file]');if(b)readFile(b.dataset.file)};
85
+ function point(e){const p=byId('maps-svg').createSVGPoint();p.x=e.clientX;p.y=e.clientY;return p.matrixTransform(byId('maps-svg').getScreenCTM().inverse())}
86
+ function scale(factor,p={x:500,y:325}){const next=Math.min(8,Math.max(.15,zoom*factor));panX=p.x-(p.x-panX)*next/zoom;panY=p.y-(p.y-panY)*next/zoom;zoom=next;transform()}
87
+ byId('maps-plus').onclick=()=>scale(1.3);byId('maps-minus').onclick=()=>scale(1/1.3);byId('maps-fit').onclick=fit;byId('maps-expand').onclick=async()=>{try{if(document.fullscreenElement)await document.exitFullscreen();else await byId('map-details').requestFullscreen()}catch{message('The browser did not open full screen; the map is still available on the page.',true)}};
88
+ const svg=byId('maps-svg');for(const shade of palette){const marker=element('marker',{id:'maps-arrow-'+shade,markerWidth:8,markerHeight:8,refX:7,refY:4,orient:'auto',markerUnits:'userSpaceOnUse'});marker.append(element('path',{d:'M0 0 L8 4 L0 8',fill:`var(--map-${shade}-stroke)`}));svg.querySelector('defs').append(marker)}svg.addEventListener('wheel',e=>{e.preventDefault();scale(e.deltaY<0?1.1:1/1.1,point(e))},{passive:false});svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const p=point(e),node=e.target.closest('[data-graph-node]');drag={id:node?.dataset.graphNode||null,start:p,last:p,moved:false};svg.setPointerCapture(e.pointerId)});svg.addEventListener('pointermove',e=>{if(!drag)return;const p=point(e),dx=p.x-drag.last.x,dy=p.y-drag.last.y;if(Math.abs(p.x-drag.start.x)+Math.abs(p.y-drag.start.y)>5)drag.moved=true;if(drag.moved){if(drag.id&&mode!=='coverage'){const pos=positions.get(drag.id);if(pos){pos.x+=dx/zoom;pos.y+=dy/zoom;render()}}else{panX+=dx;panY+=dy;transform()}}drag.last=p});svg.addEventListener('pointerup',e=>{const previous=drag;drag=null;if(previous&&!previous.moved&&previous.id)selectNode(previous.id);if(svg.hasPointerCapture(e.pointerId))svg.releasePointerCapture(e.pointerId)});svg.addEventListener('pointercancel',()=>drag=null);
89
+ function windowState(){return new URLSearchParams({project:selected,view:data?.selected?.view_id||byId('map-view').value||'',mode,q:byId('maps-search').value,folder:byId('maps-folder').value,limit:byId('maps-limit').value,colors:byId('maps-color-mode').value,node:selectedNode||'',focus:focus?'1':'0'})}
90
+ function openWindow(){const url=new URL('/maps',location.origin);url.hash=windowState().toString();byId('maps-popup-link').hidden=true;const popup=window.open('','_blank','popup,width=1440,height=960,resizable=yes,scrollbars=yes');if(popup){popup.opener=null;popup.location.replace(url.href)}else{const link=byId('maps-popup-link');link.href=url.href;link.hidden=false;message('The browser blocked the window. Use the Open map separately link.',true)}}
91
+ byId('maps-new-window').onclick=openWindow;
92
+ byId('maps-color-mode').onchange=()=>{render();publishWindowState()};
93
+ async function restore(state){if(['network','coverage','calls'].includes(state.mode))switchMode(state.mode);await load(state.view||undefined,state)}
94
+ return {reset,load,restore,openWindow};
95
+ })();
96
+ '''
@@ -0,0 +1,204 @@
1
+ """Eventos Windows + reconciliação: fila apenas para projetos habilitados pelo usuário."""
2
+ import os
3
+ import threading
4
+ import time
5
+
6
+ import config
7
+ import directory_watch
8
+ import indexer
9
+ import local_embedder
10
+ import index_scope
11
+ import project_store
12
+ import index_views
13
+
14
+ DEBOUNCE_S = 5
15
+ RECONCILE_S = 180
16
+ FULL_CHECK_S = 86400
17
+ MAX_EVENT_PATHS = 1000
18
+
19
+
20
+ def relevant_path(path):
21
+ parts = path.replace("\\", "/").strip("/").split("/")
22
+ return bool(parts and all(part not in ("", ".", "..") and not part.startswith(".")
23
+ and not index_scope.always_excluded(part) for part in parts))
24
+
25
+
26
+ def snapshot(root, scope):
27
+ result = {}
28
+ for rel in index_scope.resolve_included_files(root, scope):
29
+ stat, _reason = indexer._stat_candidate(root, rel)
30
+ if stat is not None:
31
+ result[rel] = stat
32
+ return result
33
+
34
+
35
+
36
+ def index_model(current=None):
37
+ return local_embedder.resolve(config.load_config().get("embedding_model"), current)
38
+
39
+
40
+ def needs_model_rebuild(meta, configured_model):
41
+ return meta.get("model_id") != configured_model or not meta.get("exists")
42
+
43
+ class ProjectMonitor:
44
+ def __init__(self, enqueue, watcher_factory=directory_watch.DirectoryWatcher):
45
+ self.enqueue = enqueue
46
+ self.watcher_factory = watcher_factory
47
+ self._watchers = {}
48
+ self._retry_watch = {}
49
+ self._checked = {}
50
+ self._git_seen = {}
51
+ self._git_checked = {}
52
+ self._pending = {}
53
+ self._lock = threading.RLock()
54
+ self._stop = threading.Event()
55
+ self._thread = None
56
+
57
+ def start(self):
58
+ self._thread = threading.Thread(target=self._run, name="project-monitor", daemon=True)
59
+ self._thread.start()
60
+
61
+ def stop(self):
62
+ self._stop.set()
63
+ if self._thread:
64
+ self._thread.join(timeout=3)
65
+ with self._lock:
66
+ watchers = list(self._watchers.values())
67
+ self._watchers.clear()
68
+ for watcher in watchers:
69
+ watcher.stop()
70
+
71
+ def refresh(self):
72
+ self._checked.clear()
73
+
74
+ def events(self, project_id, rows, overflow=False):
75
+ project = project_store.get(project_id)
76
+ if not project or project.get("paused") or not project.get("watch") or not project.get("enabled"):
77
+ return
78
+ paths = {row["path"] for row in rows if relevant_path(row.get("path", ""))}
79
+ if not paths and not overflow:
80
+ return
81
+ # Um diretório existente também recebe MODIFIED ao mudar um arquivo filho.
82
+ # Só uma estrutura nova ou documentação da raiz exige reavaliar o escopo.
83
+ scope = index_scope.load_scope(project["root"]) or {}
84
+ known_dirs = {p.casefold() for p in scope.get("structure", [])}
85
+ docs = {name.casefold() for name in index_scope._ROOT_DOC_NAMES}
86
+ force_scope = any(path.casefold() in docs for path in paths) or any(
87
+ row.get("action") in (1, 5) and row.get("path") in paths
88
+ and row["path"].casefold() not in known_dirs
89
+ and os.path.isdir(os.path.join(project["root"], row["path"])) for row in rows)
90
+ force_scope = force_scope and not scope.get("manual", False)
91
+ with self._lock:
92
+ first = project_id not in self._pending
93
+ state = self._pending.setdefault(project_id, {"paths": set(), "full": False, "force_scope": False, "revision": 0})
94
+ state["revision"] = state.get("revision", 0) + 1
95
+ state["paths"].update(paths)
96
+ state["last"] = time.monotonic()
97
+ state["full"] |= overflow or len(state["paths"]) > MAX_EVENT_PATHS
98
+ state["force_scope"] |= force_scope
99
+ if state["full"]:
100
+ state["paths"].clear()
101
+ project_store.mark_dirty(project_id, "Changes detected by the monitor.")
102
+ project_store.record_changes(project_id, paths, full=overflow, scope=force_scope)
103
+
104
+ def _watch_error(self, project_id, message):
105
+ project_store.update(project_id, watch_backend="reconciliation", watch_error=message[:300])
106
+ self._retry_watch[project_id] = time.monotonic() + RECONCILE_S
107
+ self.events(project_id, [], overflow=True)
108
+
109
+ def _sync_watchers(self, projects, now):
110
+ wanted = {p["id"]: p for p in projects if p.get("watch") and p.get("enabled")
111
+ and not p.get("paused") and os.path.isdir(p["root"])}
112
+ for key in list(self._watchers):
113
+ if key not in wanted:
114
+ watcher = self._watchers.pop(key)
115
+ watcher.stop()
116
+ project_store.update(key, watch_backend="off")
117
+ for key, project in wanted.items():
118
+ current = self._watchers.get(key)
119
+ if current and current.alive:
120
+ continue
121
+ if now < self._retry_watch.get(key, 0):
122
+ continue
123
+ watcher = self.watcher_factory(
124
+ project["root"], lambda rows, overflow, k=key: self.events(k, rows, overflow),
125
+ lambda error, k=key: self._watch_error(k, error),
126
+ )
127
+ self._watchers[key] = watcher
128
+ project_store.update(key, watch_backend="windows_events", watch_error="")
129
+ watcher.start()
130
+
131
+ def tick(self):
132
+ now = time.monotonic()
133
+ projects = project_store.all_projects()
134
+ self._sync_watchers(projects, now)
135
+ for project in projects:
136
+ key = project["id"]
137
+ if not project.get("watch") or not project.get("enabled") or project.get("paused"):
138
+ continue
139
+ if not os.path.isdir(project["root"]):
140
+ project_store.update(key, status="unavailable", last_error="The folder is not accessible.")
141
+ continue
142
+ if any(job.get("status") == "pending" for job in project_store.jobs(key, limit=50)):
143
+ continue # Não planejar outra geração com metadados de uma ainda em curso.
144
+ if now - self._git_checked.get(key, -5) >= 5:
145
+ view = index_views.describe(project['root'], fresh=True)
146
+ signature = (view['view_id'], view.get('commit'))
147
+ if key in self._git_seen and signature != self._git_seen[key]:
148
+ self.events(key, [], overflow=True)
149
+ self._git_seen[key] = signature
150
+ self._git_checked[key] = now
151
+ if now - self._checked.get(key, -RECONCILE_S) >= RECONCILE_S:
152
+ self._checked[key] = now
153
+ scope = index_scope.load_scope(project["root"])
154
+ meta = indexer.info(project["root"])
155
+ rescope = scope is None or index_scope.needs_rescan(project["root"], scope)
156
+ changed = rescope
157
+ if scope is not None:
158
+ changed |= snapshot(project["root"], scope) != indexer.manifest_snapshot(project["root"])
159
+ full = time.time() - meta.get("full_scan_at", 0) >= FULL_CHECK_S
160
+ changed |= needs_model_rebuild(meta, index_model(meta.get("model_id")))
161
+ project_store.update(key, last_checked=time.time())
162
+ if changed or full or project.get("dirty_seq", 0) > project.get("indexed_seq", 0):
163
+ with self._lock:
164
+ first = key not in self._pending
165
+ state = self._pending.setdefault(key, {"paths": set(), "last": now - DEBOUNCE_S,
166
+ "full": False, "force_scope": False, "revision": 0})
167
+ state["full"] |= full
168
+ state["force_scope"] |= rescope
169
+ if first:
170
+ project_store.mark_dirty(key, "Reconciliation found a pending update.")
171
+ project_store.record_changes(key, full=full, scope=rescope)
172
+ # Monitoramento local é independente do gasto com o modelo. O padrão
173
+ # somente registra pendência; a próxima busca executa o diff e embeddings.
174
+ if project.get('update_mode', 'on_search') != 'eager':
175
+ with self._lock:
176
+ self._pending.pop(key, None)
177
+ continue
178
+ with self._lock:
179
+ pending = self._pending.get(key)
180
+ if not pending or now - pending["last"] < DEBOUNCE_S:
181
+ continue
182
+ options = {"changed_paths": sorted(pending["paths"]), "full_check": pending["full"],
183
+ "force_scope": pending["force_scope"]}
184
+ revision = pending.get("revision", 0)
185
+ current = project_store.get(key)
186
+ if time.time() < current.get("next_retry_at", 0):
187
+ continue
188
+ try:
189
+ self.enqueue(key, **options)
190
+ except Exception as exc:
191
+ project_store.update(key, status="error", last_error=str(exc)[:300], next_retry_at=time.time() + 60)
192
+ continue
193
+ with self._lock:
194
+ # Um evento recebido durante o submit continua pendente para a próxima geração.
195
+ if self._pending.get(key) is pending and pending.get("revision", 0) == revision:
196
+ self._pending.pop(key, None)
197
+
198
+ def _run(self):
199
+ while not self._stop.is_set():
200
+ try:
201
+ self.tick()
202
+ except Exception:
203
+ pass # Falha opcional do monitor não derruba o daemon MCP.
204
+ self._stop.wait(1)
@@ -0,0 +1,61 @@
1
+ """Fila justa entre raízes; jobs aguardando a mesma pasta não ocupam workers."""
2
+ import collections
3
+ import concurrent.futures
4
+ import threading
5
+
6
+ import project_identity
7
+
8
+
9
+ class ProjectScheduler:
10
+ def __init__(self, workers=3):
11
+ self.workers = workers
12
+ self._pool = concurrent.futures.ThreadPoolExecutor(max_workers=workers, thread_name_prefix="project")
13
+ self._lock = threading.RLock()
14
+ self._queues = collections.OrderedDict()
15
+ self._running = set()
16
+ self._closed = False
17
+
18
+ def submit(self, root, function, *args):
19
+ key = project_identity.canonical_root(root)
20
+ future = concurrent.futures.Future()
21
+ with self._lock:
22
+ if self._closed:
23
+ raise RuntimeError("Project queue closed.")
24
+ self._queues.setdefault(key, collections.deque()).append((future, function, args))
25
+ self._dispatch()
26
+ return future
27
+
28
+ def _dispatch(self):
29
+ while len(self._running) < self.workers:
30
+ key = next((root for root in self._queues if root not in self._running), None)
31
+ if key is None:
32
+ break
33
+ task = self._queues[key].popleft()
34
+ if not self._queues[key]:
35
+ self._queues.pop(key)
36
+ else:
37
+ self._queues.move_to_end(key)
38
+ self._running.add(key)
39
+ self._pool.submit(self._run, key, task)
40
+
41
+ def _run(self, key, task):
42
+ future, function, args = task
43
+ try:
44
+ if future.set_running_or_notify_cancel():
45
+ try:
46
+ future.set_result(function(*args))
47
+ except BaseException as exc:
48
+ future.set_exception(exc)
49
+ finally:
50
+ with self._lock:
51
+ self._running.discard(key)
52
+ self._dispatch()
53
+
54
+ def shutdown(self, wait=True):
55
+ with self._lock:
56
+ self._closed = True
57
+ for queue in self._queues.values():
58
+ for future, _fn, _args in queue:
59
+ future.cancel()
60
+ self._queues.clear()
61
+ self._pool.shutdown(wait=wait, cancel_futures=True)
@@ -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']))}