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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +32 -0
  4. package/README.md +116 -2
  5. package/atomic_io.py +94 -0
  6. package/autostart.py +199 -0
  7. package/bin/smart-tool.mjs +86 -0
  8. package/browser_service_client.py +232 -0
  9. package/capabilities.py +63 -0
  10. package/client_hooks.py +192 -0
  11. package/code_graph.py +403 -0
  12. package/code_graph_java.mjs +49 -0
  13. package/code_graph_js.cjs +135 -0
  14. package/config.py +241 -0
  15. package/daemon_launcher.py +165 -0
  16. package/directory_watch.py +131 -0
  17. package/document_text.py +69 -0
  18. package/duplicates.py +313 -0
  19. package/embedding_cache.py +99 -0
  20. package/endpoint_sync.py +179 -0
  21. package/folder_picker.py +73 -0
  22. package/freshness.py +278 -0
  23. package/gateway.py +220 -0
  24. package/hook_decision.py +96 -0
  25. package/hooks/pretool_router.py +45 -0
  26. package/index_inventory.py +75 -0
  27. package/index_profile.py +167 -0
  28. package/index_scope.py +458 -0
  29. package/index_views.py +121 -0
  30. package/indexer.py +878 -0
  31. package/install.py +353 -0
  32. package/install_runtime.py +283 -0
  33. package/integration_metrics.py +82 -0
  34. package/local_embedder.py +172 -0
  35. package/model_adapters/__init__.py +0 -0
  36. package/model_adapters/openai_compatible.py +57 -0
  37. package/model_catalog.py +74 -0
  38. package/model_client.py +38 -0
  39. package/model_defaults.py +70 -0
  40. package/package.json +33 -4
  41. package/paths.py +6 -0
  42. package/project_identity.py +42 -0
  43. package/project_insights_ui.py +38 -0
  44. package/project_maps_ui.py +96 -0
  45. package/project_monitor.py +204 -0
  46. package/project_scheduler.py +61 -0
  47. package/project_storage.py +165 -0
  48. package/project_store.py +220 -0
  49. package/projects_ui.py +146 -0
  50. package/requirements.txt +8 -0
  51. package/research_cache.py +171 -0
  52. package/research_metrics.py +110 -0
  53. package/router.py +582 -0
  54. package/secret_store.py +121 -0
  55. package/setup_ui.py +986 -0
  56. package/site_memory.py +53 -0
  57. package/smart_tool_daemon.py +3604 -0
  58. package/tray.py +245 -0
  59. package/update_check.py +22 -0
  60. package/usage_meter.py +124 -0
  61. package/version.py +18 -0
  62. package/view_compare.py +68 -0
  63. package/web_adapters/browser/browser_service.py +302 -0
  64. package/web_adapters/browser/crawl4ai_search.py +236 -0
  65. package/web_adapters/browser/fetch_camoufox.py +28 -0
  66. package/web_adapters/browser/requirements.txt +2 -0
  67. package/web_adapters/node/adapter-common.mjs +40 -0
  68. package/web_adapters/node/ddg-search.mjs +89 -0
  69. package/web_adapters/node/open-websearch-adapter.mjs +77 -0
  70. package/web_adapters/node/package-lock.json +2720 -0
  71. package/web_adapters/node/package.json +12 -0
  72. package/web_adapters/node/wikipedia-search.mjs +44 -0
  73. package/web_document_graph.py +71 -0
  74. package/web_fetch.py +242 -0
  75. package/web_search_adapters.py +193 -0
  76. package/web_search_ddgs.py +42 -0
  77. package/web_search_external.py +288 -0
  78. package/web_search_health.py +88 -0
@@ -0,0 +1,49 @@
1
+ // Análise estática conservadora de Java a partir de snapshots; nenhum código é executado.
2
+ import fs from 'node:fs';
3
+ import {parse} from './web_adapters/node/node_modules/java-parser/src/index.js';
4
+ const input=JSON.parse(fs.readFileSync(0,'utf8'));
5
+ const symbols=[],dependencies=[],calls=[],unresolved=[],diagnostics=[],units=[],classes=new Map(),methodNodes=new Map();
6
+ const MAX_SYMBOLS=8000,MAX_EDGES=20000;
7
+ function children(n){return n?.children?Object.values(n.children).flat().filter(x=>x.children):[]}
8
+ function all(n,name,stop=false){const result=[];function visit(node){if(node?.name===name){result.push(node);if(stop)return}for(const child of children(node))visit(child)}visit(n);return result}
9
+ function first(n,name){return all(n,name,true)[0]}
10
+ function identifiers(n){const out=[];function visit(node){if(node?.children)for(const [key,items] of Object.entries(node.children))for(const child of items){if(child.children)visit(child);else if(key==='Identifier')out.push(child)}}visit(n);return out.sort((a,b)=>a.startOffset-b.startOffset)}
11
+ function text(u,n){return n?.location?u.text.slice(n.location.startOffset,n.location.endOffset+1):''}
12
+ function addSymbol(u,n,name,kind){if(symbols.length>=MAX_SYMBOLS)return null;const s={id:u.path+'::'+name+'@'+n.location.startLine+':'+n.location.startOffset,path:u.path,name,kind,language:'java',start_line:n.location.startLine,end_line:n.location.endLine};symbols.push(s);return s}
13
+ const classKinds=new Set(['normalClassDeclaration','normalInterfaceDeclaration','enumDeclaration','recordDeclaration']);
14
+ for(const file of input.files){try{const tree=parse(file.text),pkg=first(tree,'packageDeclaration'),unit={...file,tree,package:pkg?identifiers(pkg).map(t=>t.image).join('.'): '',imports:[],classes:[]};
15
+ for(const n of all(tree,'importDeclaration')){const raw=text(unit,n).replace(/^import\s+/,'').replace(/;\s*$/,'').trim(),stat=raw.startsWith('static '),spec=(stat?raw.slice(7):raw).replace(/\s+/g,'');unit.imports.push({spec,static:stat,line:n.location.startLine})}
16
+ function scan(n,parent=null){let current=parent;if(classKinds.has(n.name)){const id=first(n,'typeIdentifier')||first(n,'typeName'),short=identifiers(id||n)[0]?.image;if(!short)return;const local=(parent?parent.local+'.':'')+short,fqn=(unit.package?unit.package+'.':'')+local;const symbol=addSymbol(unit,n,local,n.name==='normalInterfaceDeclaration'?'interface':'class');if(!symbol)return;current={unit,fqn,local,short,symbol,node:n,body:n.children.classBody?.[0]||n.children.interfaceBody?.[0]||n.children.recordBody?.[0]||n.children.enumBody?.[0],methods:[],fields:new Map(),extends:text(unit,n.children.classExtends?.[0]).replace(/^extends\s+/, '').trim()};classes.set(fqn,classes.has(fqn)?null:current);unit.classes.push(current)}
17
+ if(current&&n.name==='classBody'&&n!==current.body)return;
18
+ if(current&&['methodDeclaration','interfaceMethodDeclaration','constructorDeclaration','compactConstructorDeclaration'].includes(n.name)){const declarator=first(n,'methodDeclarator')||first(n,'constructorDeclarator'),ctor=n.name.includes('Constructor')||n.name==='constructorDeclaration',name=ctor?current.short:(declarator?.children.Identifier?.[0]?.image);if(name){const params=all(declarator||n,'formalParameter'),types=params.map(p=>text(unit,first(p,'unannType'))||'?');const signature=name+'('+types.join(',')+')';const sym=addSymbol(unit,n,current.local+'.'+signature,ctor?'constructor':'method');if(sym){sym.signature=signature;const method={symbol:sym,name,arity:params.length,types,node:n,cls:current,params,concrete:!!first(n,'block')||ctor};current.methods.push(method);methodNodes.set(n,method)}}}
19
+ for(const child of children(n))scan(child,current)}scan(tree);units.push(unit)
20
+ }catch{diagnostics.push({path:file.path,reason:'Invalid or fragmented Java, or syntax not supported by the parser.'})}}
21
+ function resolveType(unit,raw,context){let name=raw.replace(/@[\w.]+(?:\([^)]*\))?/g,'').replace(/<.*>/g,'').replace(/\[\]|\.\.\./g,'').trim();if(!name||['void','boolean','byte','short','int','long','float','double','char','var'].includes(name))return null;if(classes.has(name))return classes.get(name);const candidates=new Set();const local=[context?context.fqn+'.'+name:'',unit.package?unit.package+'.'+name:name];for(const fq of local)if(classes.has(fq))candidates.add(fq);for(const item of unit.imports.filter(i=>!i.static)){if(item.spec.endsWith('.'+name)&&classes.has(item.spec))candidates.add(item.spec);if(item.spec.endsWith('.*')){const fq=item.spec.slice(0,-1)+name;if(classes.has(fq))candidates.add(fq)}}return candidates.size===1?classes.get([...candidates][0]):null}
22
+ function lookupMethod(cls,name,arity,visited=new Set()){if(!cls||visited.has(cls.fqn))return null;visited.add(cls.fqn);const methods=cls.methods.filter(m=>m.name===name&&m.arity===arity&&m.concrete);if(methods.length===1)return methods[0].symbol;if(methods.length>1)return null;return cls.extends?lookupMethod(resolveType(cls.unit,cls.extends,cls),name,arity,visited):null}
23
+ function putBinding(map,name,type){if(name)map.set(name,map.has(name)&&map.get(name)!==type?null:type)}
24
+ function scoped(node,name){const out=[];function scan(n){if(n!==node&&(classKinds.has(n.name)||n.name==='lambdaExpression'||n.name==='classBody'))return;if(n.name===name)out.push(n);for(const c of children(n))scan(c)}scan(node);return out}
25
+ function collectVariables(unit,node,map){for(const parentName of ['fieldDeclaration','localVariableDeclaration','variableParaRegularParameter','variableArityParameter'])for(const declaration of scoped(node,parentName)){const type=text(unit,first(declaration,'unannType'));for(const id of all(declaration,'variableDeclaratorId'))putBinding(map,identifiers(id)[0]?.image,type)}}
26
+ for(const unit of units){for(const cl of unit.classes){const fieldNodes=scoped(cl.body,'fieldDeclaration');for(const node of fieldNodes){const type=text(unit,first(node,'unannType'));for(const id of all(node,'variableDeclaratorId'))putBinding(cl.fields,identifiers(id)[0]?.image,type)}}}
27
+ for(const unit of units){
28
+ for(const item of unit.imports){const spec=item.spec;let target=classes.get(item.static?spec.split('.').slice(0,-1).join('.'):spec);dependencies.push({source:unit.path,target:target?.unit.path||null,specifier:spec,line:item.line,kind:item.static?'java_static_import':'import',resolution:target?'resolved':'external'})}
29
+ function walk(n,cls=null,method=null,vars=new Map(),owner=null){if(classKinds.has(n.name))cls=unit.classes.find(c=>c.node===n)||cls;if(cls&&n.name==='classBody'&&n!==cls.body)return;
30
+ if(methodNodes.has(n)){method=methodNodes.get(n);vars=new Map(cls.fields);collectVariables(unit,n,vars);owner=method.symbol}
31
+ if(n.name==='lambdaExpression'){vars=new Map(vars);for(const param of identifiers(n.children.lambdaParameters?.[0]))vars.set(param.image,null);owner=addSymbol(unit,n,(cls?.local||'')+'.lambda@'+n.location.startLine,'function')||owner}
32
+ if(cls&&['unannType','classType','interfaceType'].includes(n.name)){const type=resolveType(unit,text(unit,n),cls);if(type&&type.unit.path!==unit.path&&dependencies.length<MAX_EDGES)dependencies.push({source:unit.path,target:type.unit.path,specifier:type.fqn,line:n.location.startLine,kind:'java_type',resolution:'resolved'})}
33
+ if(cls&&n.name==='primary'){
34
+ const prefix=n.children.primaryPrefix?.[0],prefixText=text(unit,prefix).trim(),suffixes=n.children.primarySuffix||[];let chain=prefixText,fluent=false;
35
+ const newMatch=/^new\s+([\w.$]+)/.exec(prefixText);let constructed=newMatch?resolveType(unit,newMatch[1],cls):null;
36
+ if(constructed&&calls.length<MAX_EDGES)calls.push({source:(owner||cls.symbol).id,target:constructed.symbol.id,line:n.location.startLine,kind:'construct',resolution:'static'});
37
+ for(const suffix of suffixes){const invoke=suffix.children.methodInvocationSuffix?.[0];if(!invoke){chain+=text(unit,suffix);continue}const args=invoke.children.argumentList?.[0],arity=args?(args.children.expression||[]).length:0;const match=/^(?:([\w.$]+)\.)?([\w$]+)$/.exec(chain);let target=null;
38
+ if(match&&!fluent){const receiver=match[1],name=match[2];let type=null;if(!receiver||receiver==='this')type=cls;else if(receiver==='super')type=resolveType(unit,cls.extends,cls);else if(receiver.startsWith('this.'))type=resolveType(unit,cls.fields.get(receiver.slice(5))||'',cls);else if(vars.has(receiver))type=resolveType(unit,vars.get(receiver)||'',cls);else type=resolveType(unit,receiver,cls);target=lookupMethod(type,name,arity);if(!target&&!receiver){const candidates=unit.imports.filter(i=>i.static&&(i.spec.endsWith('.'+name)||i.spec.endsWith('.*'))).map(i=>lookupMethod(classes.get(i.spec.split('.').slice(0,-1).join('.')),name,arity)).filter(Boolean);if(candidates.length===1)target=candidates[0]}}
39
+ if(constructed&&!fluent){const methodName=/\.([\w$]+)$/.exec(chain)?.[1];if(methodName)target=lookupMethod(constructed,methodName,arity)}
40
+ if(target&&calls.length<MAX_EDGES)calls.push({source:(owner||cls.symbol).id,target:target.id,line:invoke.location.startLine,kind:'call',resolution:'static'});
41
+ else if(unresolved.length<1000)unresolved.push({path:unit.path,line:invoke.location.startLine,expression:chain.slice(0,140),reason:'External/dynamic type, interface without implementation, or ambiguous overload in the Java snapshot.'});
42
+ fluent=true;chain+='<result>';
43
+ }
44
+ }
45
+ for(const child of children(n))walk(child,cls,method,vars,owner)
46
+ }walk(unit.tree)
47
+ }
48
+ const ids=new Set(symbols.map(s=>s.id));
49
+ process.stdout.write(JSON.stringify({symbols,dependencies:dependencies.slice(0,MAX_EDGES),calls:calls.filter(c=>ids.has(c.source)&&ids.has(c.target)),unresolved,diagnostics,parsed:units.map(u=>u.path),truncated:symbols.length>=MAX_SYMBOLS||dependencies.length>MAX_EDGES||calls.length>=MAX_EDGES}));
@@ -0,0 +1,135 @@
1
+ /* Análise estática de snapshots JS/TS. Não executa código nem lê arquivos do projeto. */
2
+ const fs = require('node:fs');
3
+ const path = require('node:path').posix;
4
+ const ts = require('./web_adapters/node/node_modules/typescript');
5
+ const input = JSON.parse(fs.readFileSync(0, 'utf8'));
6
+ const clean = name => path.normalize(name.replaceAll('\\', '/')).replace(/^\.\//, '');
7
+ const docs = new Map(input.files.map(f => ['/snapshot/' + clean(f.path), f]));
8
+ const knownFiles = new Set([...docs.keys(),...(input.paths||[]).map(p=>'/snapshot/'+clean(p))]);
9
+ const names = [...docs.keys()].filter(n => /\.(?:[cm]?[jt]s|[jt]sx)$/.test(n));
10
+ const options = {allowJs:true, checkJs:true, jsx:ts.JsxEmit.Preserve, target:ts.ScriptTarget.Latest,
11
+ module:ts.ModuleKind.ESNext, moduleResolution:ts.ModuleResolutionKind.Bundler,
12
+ noLib:true, noEmit:true, skipLibCheck:true, allowImportingTsExtensions:true};
13
+ let configPath = docs.has('/snapshot/tsconfig.json') ? '/snapshot/tsconfig.json' : '/snapshot/jsconfig.json';
14
+ const configSeen = new Set();
15
+ function readConfig(name, depth=0) {
16
+ if (depth>5 || configSeen.has(name) || !docs.has(name)) return {};
17
+ configSeen.add(name);
18
+ const raw = ts.parseConfigFileTextToJson(name, docs.get(name).text).config || {};
19
+ const base = typeof raw.extends==='string' && raw.extends.startsWith('.') ? readConfig(clean(path.join(path.dirname(name),raw.extends.endsWith('.json')?raw.extends:raw.extends+'.json')),depth+1) : {};
20
+ return {...base,...raw.compilerOptions};
21
+ }
22
+ const config=readConfig(configPath);
23
+ const aliases=config.paths||{},baseUrl=clean(path.join(path.dirname(configPath),config.baseUrl||'.'));
24
+ function candidate(base) {
25
+ const variants=[base,base.replace(/\.[cm]?jsx?$/,'.ts'),base.replace(/\.jsx?$/,'.tsx'),
26
+ ...['.ts','.tsx','.js','.jsx','.mts','.cts','.mjs','.cjs'].map(e=>base+e),
27
+ ...['.ts','.tsx','.js','.jsx'].map(e=>base+'/index'+e)];
28
+ return variants.find(name=>knownFiles.has(clean(name)));
29
+ }
30
+ function resolve(spec, from) {
31
+ if (spec.startsWith('.')) return candidate(clean(path.join(path.dirname(from),spec)));
32
+ for (const [pattern, targets] of Object.entries(aliases)) {
33
+ const [prefix,suffix='']=pattern.split('*');
34
+ if (pattern.includes('*') ? spec.startsWith(prefix)&&spec.endsWith(suffix) : spec===pattern) {
35
+ const middle=pattern.includes('*')?spec.slice(prefix.length,suffix? -suffix.length:undefined):'';
36
+ for (const target of Array.isArray(targets)?targets:[]) {
37
+ const found=candidate(clean(path.join(baseUrl,target.replace('*',middle))));
38
+ if(found)return found;
39
+ }
40
+ }
41
+ }
42
+ if(config.baseUrl)return candidate(clean(path.join(baseUrl,spec)));
43
+ return undefined;
44
+ }
45
+ const host={
46
+ getSourceFile:(name,version)=>docs.has(clean(name))?ts.createSourceFile(clean(name),docs.get(clean(name)).text,version,true):undefined,
47
+ getDefaultLibFileName:()=>'/lib.d.ts',writeFile:()=>{},getCurrentDirectory:()=>'/snapshot',
48
+ getDirectories:()=>[],fileExists:name=>docs.has(clean(name)),readFile:name=>docs.get(clean(name))?.text,
49
+ getCanonicalFileName:name=>clean(name),useCaseSensitiveFileNames:()=>true,getNewLine:()=> '\n',
50
+ resolveModuleNames:(modules,containing)=>modules.map(spec=>{const resolved=resolve(spec,containing);return resolved&&docs.has(resolved)&&/\.(?:[cm]?[jt]s|[jt]sx|json)$/.test(resolved)?{resolvedFileName:resolved,extension:ts.extensionFromPath(resolved),isExternalLibraryImport:false}:undefined;})
51
+ };
52
+ const program=ts.createProgram(names,options,host),checker=program.getTypeChecker();
53
+ const symbols=[],dependencies=[],calls=[],unresolved=[],diagnostics=[],declarations=new Map(),moduleNodes=new Map();
54
+ const maxSymbols=8000,maxEdges=20000;
55
+ function filePath(source){return source.fileName.replace('/snapshot/','');}
56
+ function line(source,pos){return source.getLineAndCharacterOfPosition(pos).line+1;}
57
+ function displayName(node) {
58
+ if(node.name)return node.name.getText().replace(/^['"]|['"]$/g,'');
59
+ if(ts.isConstructorDeclaration(node))return 'constructor';
60
+ if(node.parent && ts.isVariableDeclaration(node.parent))return node.parent.name.getText();
61
+ if(node.parent && ts.isPropertyAssignment(node.parent))return node.parent.name.getText();
62
+ if(node.parent && ts.isPropertyDeclaration(node.parent))return node.parent.name.getText();
63
+ if(node.parent && ts.isExportAssignment(node.parent))return 'default';
64
+ return 'callback';
65
+ }
66
+ function isFunction(node){return !!node.body && (ts.isFunctionDeclaration(node)||ts.isMethodDeclaration(node)||ts.isFunctionExpression(node)||ts.isArrowFunction(node)||ts.isConstructorDeclaration(node)||ts.isGetAccessor(node)||ts.isSetAccessor(node));}
67
+ function addSymbol(node,source,owner){
68
+ if(symbols.length>=maxSymbols)return null;
69
+ let name=displayName(node),parent=node.parent;
70
+ const classParent=parent&&ts.isPropertyDeclaration(parent)?parent.parent:parent;
71
+ const cls=classParent && (ts.isClassDeclaration(classParent)||ts.isClassExpression(classParent)) ? classParent.name?.getText() : null;
72
+ if(cls)name=cls+'.'+name;
73
+ const start=line(source,node.getStart(source)),id=filePath(source)+'::'+name+'@'+start+':'+node.pos;
74
+ const symbol={id,path:filePath(source),name,start_line:start,end_line:line(source,node.end),kind:cls?'method':'function',language:'typescript',parent:owner?.id||null};
75
+ symbols.push(symbol);declarations.set(node,symbol);return symbol;
76
+ }
77
+ const parsed=[];
78
+ for(const source of program.getSourceFiles()){
79
+ if(!docs.has(source.fileName))continue;
80
+ if(source.parseDiagnostics.length){diagnostics.push({path:filePath(source),reason:'Incomplete or invalid syntax in the snapshot.',line:line(source,source.parseDiagnostics[0].start||0)});continue;}
81
+ parsed.push(source);
82
+ function collect(node,owner){const current=isFunction(node)?addSymbol(node,source,owner):owner;ts.forEachChild(node,child=>collect(child,current));}
83
+ collect(source,null);
84
+ }
85
+ function moduleSymbol(source){if(!moduleNodes.has(source.fileName)){const s={id:filePath(source)+'::<module>',path:filePath(source),name:'<module>',start_line:1,end_line:1,kind:'module',language:'typescript'};moduleNodes.set(source.fileName,s);symbols.push(s);}return moduleNodes.get(source.fileName);}
86
+ function targetFor(node){
87
+ const signature=checker.getResolvedSignature(node);let target=declarations.get(signature?.declaration);
88
+ if(target)return target;
89
+ let symbol=checker.getSymbolAtLocation(node.expression);
90
+ if(symbol?.flags & ts.SymbolFlags.Alias)symbol=checker.getAliasedSymbol(symbol);
91
+ for(const declaration of symbol?.declarations||[]){
92
+ if(declarations.has(declaration))return declarations.get(declaration);
93
+ if(ts.isVariableDeclaration(declaration)&&declarations.has(declaration.initializer))return declarations.get(declaration.initializer);
94
+ }
95
+ return null;
96
+ }
97
+ for(const source of parsed){
98
+ function visit(node,owner){
99
+ if(isFunction(node) && !declarations.has(node))return;
100
+ const active=declarations.get(node)||owner;
101
+ if((ts.isImportDeclaration(node)||ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
102
+ const spec=node.moduleSpecifier.text,target=resolve(spec,source.fileName);
103
+ if(dependencies.length<maxEdges)dependencies.push({source:filePath(source),target:target?.replace('/snapshot/','')||null,specifier:spec,line:line(source,node.getStart(source)),kind:node.importClause?.isTypeOnly||node.isTypeOnly?'type_import':'import',resolution:target?'resolved':spec.startsWith('.')?'unresolved':'external'});
104
+ }
105
+ if(ts.isCallExpression(node)||ts.isNewExpression(node)){
106
+ const expression=node.expression,at=line(source,node.getStart(source));
107
+ if(ts.isCallExpression(node) && (expression.kind===ts.SyntaxKind.ImportKeyword || ts.isIdentifier(expression)&&expression.text==='require') && node.arguments?.length && ts.isStringLiteral(node.arguments[0])){
108
+ const spec=node.arguments[0].text,target=resolve(spec,source.fileName);
109
+ if(dependencies.length<maxEdges)dependencies.push({source:filePath(source),target:target?.replace('/snapshot/','')||null,specifier:spec,line:at,kind:'dynamic_import',resolution:target?'resolved':spec.startsWith('.')?'unresolved':'external'});
110
+ }else{
111
+ const target=targetFor(node);
112
+ if(target && calls.length<maxEdges)calls.push({source:(active||moduleSymbol(source)).id,target:target.id,line:at,kind:ts.isNewExpression(node)?'construct':'call',resolution:'static'});
113
+ else if(unresolved.length<1000)unresolved.push({path:filePath(source),line:at,expression:expression.getText(source).slice(0,120),reason:'External or dynamic target, or no resolved implementation in the snapshot.'});
114
+ }
115
+ }
116
+ ts.forEachChild(node,child=>visit(child,active));
117
+ }
118
+ visit(source,null);
119
+ }
120
+ // Metadados Angular são lidos da AST; templates são analisados pelo compilador,
121
+ // sem inicializar a aplicação, resolver DI em runtime ou executar seus decorators.
122
+ const components=[],pipes=[],templatePaths=new Set();
123
+ for(const source of parsed){
124
+ const angularAliases=new Map(),angularNamespaces=new Set();
125
+ for(const statement of source.statements){if(ts.isImportDeclaration(statement)&&statement.moduleSpecifier.text==='@angular/core'){const bindings=statement.importClause?.namedBindings;if(bindings&&ts.isNamedImports(bindings))for(const spec of bindings.elements)angularAliases.set(spec.name.text,(spec.propertyName||spec.name).text);else if(bindings&&ts.isNamespaceImport(bindings))angularNamespaces.add(bindings.name.text)}}
126
+ function visitClasses(node){if(ts.isClassDeclaration(node))for(const decorator of ts.canHaveDecorators(node)?ts.getDecorators(node)||[]:[]){const call=decorator.expression;if(!ts.isCallExpression(call)||!call.arguments.length||!ts.isObjectLiteralExpression(call.arguments[0]))continue;let type=null;if(ts.isIdentifier(call.expression)){const symbol=checker.getSymbolAtLocation(call.expression);if(symbol?.declarations?.some(ts.isImportSpecifier))type=angularAliases.get(call.expression.text)}else if(ts.isPropertyAccessExpression(call.expression)&&angularNamespaces.has(call.expression.expression.getText(source)))type=call.expression.name.text;if(!['Component','Pipe'].includes(type))continue;const metadata=new Map(call.arguments[0].properties.filter(ts.isPropertyAssignment).map(p=>[p.name.getText(source).replace(/^['"]|['"]$/g,''),p.initializer]));const methods=new Map();for(const member of node.members){const symbol=declarations.get(member)||declarations.get(member.initializer);if(symbol&&member.name)methods.set(member.name.getText(source).replace(/^['"]|['"]$/g,''),symbol)}const literal=n=>n&&ts.isStringLiteralLike(n)?n.text:null;const entry={source,node,name:node.name?.text||'<component>',methods,selector:literal(metadata.get('selector'))};if(type==='Pipe'){entry.pipeName=literal(metadata.get('name'));pipes.push(entry);continue}const template=metadata.get('template'),url=literal(metadata.get('templateUrl'));entry.template=literal(template);entry.templatePath=source.fileName;entry.inlineLine=template?line(source,template.getStart(source)):1;if(url){const target=resolve(url.startsWith('.')?url:'./'+url,source.fileName);entry.templatePath=target;entry.template=target?docs.get(target)?.text:null;entry.inlineLine=1;dependencies.push({source:filePath(source),target:target?.replace('/snapshot/','')||null,specifier:url,line:line(source,node.getStart(source)),kind:'angular_template',resolution:target?'resolved':'unresolved'})}for(const key of ['styleUrl','styleUrls']){const value=metadata.get(key),values=value&&ts.isArrayLiteralExpression(value)?value.elements:[value];for(const item of values){const style=literal(item);if(!style)continue;const target=resolve(style.startsWith('.')?style:'./'+style,source.fileName);dependencies.push({source:filePath(source),target:target?.replace('/snapshot/','')||null,specifier:style,line:line(source,item.getStart(source)),kind:'angular_style',resolution:target?'resolved':'unresolved'})}}components.push(entry)}ts.forEachChild(node,visitClasses)}visitClasses(source)
127
+ }
128
+ if(components.some(c=>c.template!=null)){
129
+ try{
130
+ const {parseTemplate}=require('./web_adapters/node/node_modules/@angular/compiler/fesm2022/compiler.mjs');
131
+ for(const component of components){if(component.template==null||!component.templatePath)continue;const pathName=component.templatePath.replace('/snapshot/',''),tree=parseTemplate(component.template,pathName);if(tree.errors?.length){diagnostics.push({path:pathName,reason:'Invalid Angular template or syntax not supported in this version.'});continue}templatePaths.add(pathName);const owner={id:pathName+'::'+filePath(component.source)+'::'+component.name+'.template@'+component.inlineLine,path:pathName,name:component.name+'.template',kind:'template',language:'angular',start_line:component.inlineLine,end_line:component.inlineLine+component.template.split('\n').length-1,location_kind:component.templatePath===component.source.fileName?'inline_template':'source_line'};if(symbols.length>=maxSymbols)break;symbols.push(owner);const shadow=new Set(),seen=new WeakSet();function walk(n,fn){if(!n||typeof n!=='object'||seen.has(n))return;seen.add(n);fn(n);for(const [key,value] of Object.entries(n)){if(['sourceSpan','span','startSourceSpan','endSourceSpan','keySpan','valueSpan','parent'].includes(key))continue;if(Array.isArray(value))value.forEach(x=>walk(x,fn));else if(value&&typeof value==='object')walk(value,fn)}}walk(tree.nodes,n=>{if(['Variable','Reference','LetDeclaration'].includes(n.constructor.name)&&n.name)shadow.add(n.name);if(n.constructor.name==='ForLoopBlock'&&n.item?.name)shadow.add(n.item.name)});const seen2=new WeakSet();function inspect(n){if(!n||typeof n!=='object'||seen2.has(n))return;seen2.add(n);const start=n.sourceSpan?.start,at=(typeof start==='number'?component.template.slice(0,start).split('\n').length-1:(start?.line??0))+component.inlineLine;if(n.constructor.name==='Call'||n.constructor.name==='SafeCall'){const receiver=n.receiver;if(receiver?.constructor.name==='PropertyRead'&&['ImplicitReceiver','ThisReceiver'].includes(receiver.receiver?.constructor.name)){const target=shadow.has(receiver.name)?null:component.methods.get(receiver.name);if(target&&calls.length<maxEdges)calls.push({source:owner.id,target:target.id,line:at,kind:'template_call',resolution:'static'});else if(unresolved.length<1000)unresolved.push({path:pathName,line:at,expression:receiver.name+'()',reason:'Unresolved template method, or a local variable that shadows the component.'})}}if(n.constructor.name==='Element'){const matches=components.filter(c=>c.selector===n.name);if(matches.length===1&&filePath(matches[0].source)!==pathName)dependencies.push({source:pathName,target:filePath(matches[0].source),specifier:n.name,line:at,kind:'angular_component',resolution:'resolved'})}if(n.constructor.name==='BindingPipe'){const matches=pipes.filter(p=>p.pipeName===n.name&&p.methods.has('transform'));if(matches.length===1){const pipe=matches[0];dependencies.push({source:pathName,target:filePath(pipe.source),specifier:n.name,line:at,kind:'angular_pipe',resolution:'resolved'});calls.push({source:owner.id,target:pipe.methods.get('transform').id,line:at,kind:'template_pipe',resolution:'static'})}}for(const [key,value] of Object.entries(n)){if(['sourceSpan','span','startSourceSpan','endSourceSpan','keySpan','valueSpan','parent'].includes(key))continue;if(Array.isArray(value))value.forEach(inspect);else if(value&&typeof value==='object')inspect(value)}}inspect(tree.nodes)
132
+ }
133
+ }catch{diagnostics.push({path:'Angular',reason:'The Angular template analyzer did not finish. Check the local runtime.'})}
134
+ }
135
+ process.stdout.write(JSON.stringify({symbols,dependencies:dependencies.slice(0,maxEdges),calls:calls.slice(0,maxEdges),unresolved,diagnostics,parsed:[...parsed.map(filePath),...templatePaths],truncated:symbols.length>=maxSymbols||calls.length>=maxEdges||dependencies.length>=maxEdges}));
package/config.py ADDED
@@ -0,0 +1,241 @@
1
+ #!/usr/bin/env python3
2
+ """Configuração de modelos do Smart Tool, compartilhada por todos os hosts MCP.
3
+
4
+ Chaves de API ficam em `api_keys` sempre cifradas com DPAPI; texto em claro é recusado na gravação.
5
+ """
6
+ import json
7
+ import os
8
+ import paths
9
+ import threading
10
+
11
+ import atomic_io
12
+ import secret_store
13
+
14
+ # Alias de compatibilidade: a implementação mora em `atomic_io`.
15
+ atomic_replace = atomic_io.atomic_replace
16
+
17
+ CONFIG_DIR = paths.HOME_DIR
18
+ CONFIG_PATH = os.path.join(CONFIG_DIR, "config.json")
19
+
20
+ # RLock: update_config() chama load/save sob o mesmo lock; Lock travaria a própria thread.
21
+ _LOCK = threading.RLock()
22
+
23
+ DEFAULT_CONFIG = {
24
+ "router_model": "",
25
+ "scope_model": "",
26
+ "embedding_model": "",
27
+ "rerank_model": "",
28
+ "research_model": "",
29
+ "research_max_iterations": 3,
30
+ "quick_search_timeout_s": 120,
31
+ "site_memory_similarity_threshold": 0.35,
32
+ "local_models": "fallback",
33
+ "hook_mode": "redirect",
34
+ "contact": "",
35
+ "model_adapter": "",
36
+ "model_adapter_options": {},
37
+ }
38
+
39
+ # Local models from the mcp-memory sidecar: fallback = only when the gateway has no model; off = never;
40
+ # prefer = even when the gateway has one (embedding quality measured equal on the rerank gold set).
41
+ LOCAL_MODEL_MODES = ("fallback", "off", "prefer")
42
+ # PreToolUse hook: redirect = deny the native tool and point to Smart Tool; advise = let it run and add the tip to the
43
+ # agent's context; off = no routing at all.
44
+ HOOK_MODES = ("redirect", "advise", "off")
45
+
46
+
47
+ def hook_mode(cfg=None):
48
+ value = (cfg if cfg is not None else load_config()).get("hook_mode") or DEFAULT_CONFIG["hook_mode"]
49
+ if value not in HOOK_MODES:
50
+ raise ValueError(f"Invalid hook_mode ({value!r}) in {CONFIG_PATH}: use redirect, advise or off.")
51
+ return value
52
+
53
+
54
+ def local_models_mode(cfg=None):
55
+ value = (cfg if cfg is not None else load_config()).get("local_models") or DEFAULT_CONFIG["local_models"]
56
+ if value not in LOCAL_MODEL_MODES:
57
+ raise ValueError(f"Invalid local_models ({value!r}) in {CONFIG_PATH}: use fallback, off or prefer.")
58
+ return value
59
+
60
+
61
+ # Único lugar com os limites/recomendados de research_max_iterations e
62
+ # site_memory_similarity_threshold — configure.py, setup_ui.py e smart_tool_daemon.py leem
63
+ # daqui em vez de cada um definir seu próprio intervalo (mesmo padrão de fonte única de
64
+ # web_search_adapters.py pros tiers de busca).
65
+ RESEARCH_MAX_ITERATIONS_RANGE = (1, 10)
66
+ QUICK_SEARCH_TIMEOUT_RANGE = (30, 360)
67
+ SITE_MEMORY_SIMILARITY_THRESHOLD_RANGE = (0.0, 1.0)
68
+ WEB_PROVIDER_NAMES = ("exa", "parallel", "keenable", "tavily", "firecrawl")
69
+
70
+
71
+ def _within(value, cast, bounds, key):
72
+ lo, hi = bounds
73
+ try:
74
+ n = cast(value)
75
+ except (TypeError, ValueError):
76
+ return DEFAULT_CONFIG[key]
77
+ return n if lo <= n <= hi else DEFAULT_CONFIG[key]
78
+
79
+
80
+ def clamp_research_max_iterations(value):
81
+ return _within(value, int, RESEARCH_MAX_ITERATIONS_RANGE, "research_max_iterations")
82
+
83
+
84
+ def clamp_quick_search_timeout(value):
85
+ return _within(value, int, QUICK_SEARCH_TIMEOUT_RANGE, "quick_search_timeout_s")
86
+
87
+
88
+ def clamp_site_memory_similarity_threshold(value):
89
+ return _within(value, float, SITE_MEMORY_SIMILARITY_THRESHOLD_RANGE, "site_memory_similarity_threshold")
90
+
91
+
92
+ class ConfigCorruptedError(Exception):
93
+ """O arquivo existe mas não é JSON de objeto legível. Distinto de "não existe":
94
+ `load_config()` pode cair pros defaults num arquivo ausente, mas gravar sobre
95
+ um arquivo ilegível apagaria configurações que ainda poderiam ser recuperadas."""
96
+
97
+
98
+ def _read_stored():
99
+ try:
100
+ with open(CONFIG_PATH, "r", encoding="utf-8") as f:
101
+ stored = json.load(f)
102
+ except FileNotFoundError:
103
+ return None
104
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
105
+ raise ConfigCorruptedError(
106
+ f"{CONFIG_PATH} is not valid JSON ({exc}); fix or delete the file manually."
107
+ )
108
+ if not isinstance(stored, dict):
109
+ raise ConfigCorruptedError(f"{CONFIG_PATH} does not contain a JSON object; fix or delete the file manually.")
110
+ return stored
111
+
112
+
113
+ def load_config():
114
+ """Defaults mesclados com o que está em disco. Um arquivo corrompido devolve os
115
+ defaults em vez de levantar: leitura é caminho quente (todo tool call passa por
116
+ aqui) e não pode derrubar o daemon — quem for gravar usa `update_config`, que
117
+ recusa escrever nesse estado."""
118
+ with _LOCK:
119
+ try:
120
+ stored = _read_stored() or {}
121
+ except ConfigCorruptedError:
122
+ stored = {}
123
+ merged = dict(DEFAULT_CONFIG)
124
+ if "web_provider_keys" in stored:
125
+ raise ConfigCorruptedError(f"{CONFIG_PATH} uses the old 'web_provider_keys' format: delete that field and "
126
+ "enter the keys again in the setup screen.")
127
+ merged.update(stored)
128
+ return merged
129
+
130
+
131
+ def save_config(config):
132
+ with _LOCK:
133
+ os.makedirs(CONFIG_DIR, exist_ok=True)
134
+ # Mantém o gravador atômico: a instalação antiga guardava segredo aqui.
135
+ clean = dict(config)
136
+ provider_keys = clean.get("api_keys") or {}
137
+ if (not isinstance(provider_keys, dict) or any(
138
+ not _known_key(name) or not secret_store.is_protected(value)
139
+ for name, value in provider_keys.items()
140
+ )):
141
+ raise ValueError("Keys must be protected by DPAPI.")
142
+ atomic_io.write_secret_text(
143
+ CONFIG_PATH, json.dumps(clean, indent=2, ensure_ascii=False)
144
+ )
145
+
146
+
147
+ def corrupted_reason():
148
+ """Motivo pelo qual o arquivo em disco é ilegível, ou `None` se está legível/ausente.
149
+ `load_config` cai nos defaults nesse caso, então sem isto uma config truncada é
150
+ indistinguível de uma config vazia na hora de explicar a falha ao usuário."""
151
+ with _LOCK:
152
+ try:
153
+ _read_stored()
154
+ except ConfigCorruptedError as exc:
155
+ return str(exc)
156
+ return None
157
+
158
+
159
+ def update_config(mutate):
160
+ """Load + mutate + save como uma única seção crítica, pra dois handlers HTTP
161
+ concorrentes nunca perderem a escrita um do outro. Levanta `ConfigCorruptedError`
162
+ se o arquivo em disco estiver ilegível, em vez de substituí-lo pelos defaults."""
163
+ with _LOCK:
164
+ stored = _read_stored() or {}
165
+ cfg = dict(DEFAULT_CONFIG)
166
+ cfg.update(stored)
167
+ # Serializado (não `dict(cfg)`): a comparação tem que enxergar mutação in-place
168
+ # de um valor aninhado, que uma cópia rasa esconderia. Mesmo contrato de
169
+ # serialização de `save_config`, pra um valor que só a comparação aceitaria não
170
+ # passar da guarda e estourar na gravação.
171
+ before = json.dumps(cfg, sort_keys=True)
172
+ mutate(cfg)
173
+ # Mutação que não muda nada não regrava.
174
+ if json.dumps(cfg, sort_keys=True) == before and os.path.isfile(CONFIG_PATH):
175
+ return cfg
176
+ save_config(cfg)
177
+ return cfg
178
+
179
+
180
+ def _known_key(name):
181
+ """Web providers by name; model adapter secrets as "adapter:<adapter>:<field>" (see gateway.py)."""
182
+ return name in WEB_PROVIDER_NAMES or (isinstance(name, str) and name.startswith("adapter:"))
183
+
184
+
185
+ def _provider_name(name):
186
+ if not _known_key(name):
187
+ raise ValueError(f"Unknown key: {name!r}")
188
+ return name
189
+
190
+
191
+ def get_provider_api_key(name):
192
+ """Devolve a chave opcional em claro só no processo que fará a chamada HTTP."""
193
+ name = _provider_name(name)
194
+ stored = (load_config().get("api_keys") or {}).get(name) or ""
195
+ if not stored:
196
+ return ""
197
+ if not secret_store.is_protected(stored):
198
+ raise secret_store.SecretProtectionError("Provider key is not protected by DPAPI.")
199
+ return secret_store.unprotect(stored)
200
+
201
+
202
+ def provider_key_status(name):
203
+ """Estado para a UI, sem revelar a chave nem o blob DPAPI."""
204
+ name = _provider_name(name)
205
+ stored = (load_config().get("api_keys") or {}).get(name) or ""
206
+ if not stored:
207
+ return "keyless"
208
+ try:
209
+ get_provider_api_key(name)
210
+ except secret_store.SecretProtectionError:
211
+ return "unreadable"
212
+ return "configured"
213
+
214
+
215
+ def check_provider_key_format(key):
216
+ if len(key) > 4096 or any(ord(char) < 33 for char in key) or secret_store.is_protected(key):
217
+ raise ValueError("Paste the provider's original API key, without spaces or line breaks.")
218
+
219
+
220
+ def set_provider_api_key(name, key):
221
+ """Grava/remove chave externa sob DPAPI; nunca persiste texto puro."""
222
+ name = _provider_name(name)
223
+ if key is not None and not isinstance(key, str):
224
+ raise ValueError("API key must be text.")
225
+ key = (key or "").strip()
226
+ if key:
227
+ check_provider_key_format(key)
228
+ protected = secret_store.protect(key) if key else ""
229
+
230
+ def mutate(cfg):
231
+ keys = dict(cfg.get("api_keys") or {})
232
+ if protected:
233
+ keys[name] = protected
234
+ else:
235
+ keys.pop(name, None)
236
+ if keys:
237
+ cfg["api_keys"] = keys
238
+ else:
239
+ cfg.pop("api_keys", None)
240
+
241
+ update_config(mutate)
@@ -0,0 +1,165 @@
1
+ #!/usr/bin/env python3
2
+ """Lifecycle do daemon HTTP do Smart Tool: self-start com lock. Ao contrário do Brain
3
+ Server (client-pure, nunca spawna, depende de instalador externo — o daemon que está
4
+ caído neste ambiente por isso mesmo), este cliente sobe o processo sozinho se estiver
5
+ fora do ar, protegido por lockfile contra corrida entre sessões simultâneas.
6
+
7
+ Contrato de registry (mesmo formato usado pelo probe de grafo em `capabilities.py`):
8
+ `~/.smart-tool/data/daemon.json` = `{"url": "http://127.0.0.1:<porta>"}` —
9
+ `url` é a base do daemon; o endpoint MCP em si é `<url>/mcp`.
10
+ """
11
+ import json
12
+ import os
13
+ import paths
14
+ import subprocess
15
+ import sys
16
+ import tempfile
17
+ import time
18
+ import urllib.error
19
+ import urllib.request
20
+ from pathlib import Path
21
+
22
+ RUN_DIR = paths.DATA_DIR
23
+ REGISTRY_PATH = os.path.join(RUN_DIR, "daemon.json")
24
+ LOCK_PATH = os.path.join(RUN_DIR, "daemon.lock")
25
+ LOCK_STALE_SECONDS = 30
26
+ DEFAULT_PORT = int(os.environ.get("SMART_TOOL_PORT", "8765"))
27
+ DAEMON_SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "smart_tool_daemon.py")
28
+
29
+
30
+ def read_registry():
31
+ try:
32
+ with open(REGISTRY_PATH, "r", encoding="utf-8") as f:
33
+ info = json.load(f)
34
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
35
+ return None
36
+ if isinstance(info, dict) and info.get("url"):
37
+ return info
38
+ return None
39
+
40
+
41
+ def health(url, timeout=1):
42
+ """Timeout curto porque só se fala com o loopback: um daemon vivo responde `/health`
43
+ em milissegundos (o servidor é multi-thread, indexação em curso não bloqueia), e o
44
+ caso lento é justamente o daemon morto cuja URL ainda está no registry — tempo que
45
+ toda tool call do agente paga antes de seguir."""
46
+ try:
47
+ with urllib.request.urlopen(url.rstrip("/") + "/health", timeout=timeout) as resp:
48
+ return resp.status == 200
49
+ except (urllib.error.URLError, OSError, ValueError):
50
+ return False
51
+
52
+
53
+ def _acquire_lock():
54
+ os.makedirs(RUN_DIR, exist_ok=True)
55
+ try:
56
+ if time.time() - os.path.getmtime(LOCK_PATH) > LOCK_STALE_SECONDS:
57
+ os.remove(LOCK_PATH)
58
+ except OSError:
59
+ pass
60
+ try:
61
+ fd = os.open(LOCK_PATH, os.O_CREAT | os.O_EXCL | os.O_RDWR)
62
+ os.close(fd)
63
+ return True
64
+ except FileExistsError:
65
+ return False
66
+
67
+
68
+ def _release_lock():
69
+ try:
70
+ os.remove(LOCK_PATH)
71
+ except OSError:
72
+ pass
73
+
74
+
75
+ def _spawn_daemon():
76
+ executable = Path(sys.executable)
77
+ if sys.platform == "win32":
78
+ # O venv pythonw.exe pode morrer sem iniciar o daemon. O python.exe iniciado
79
+ # direto abre uma aba no Windows Terminal; fechá-la mata o serviço e o tray
80
+ # tenta subir outra a cada polling. WScript cria um processo independente com
81
+ # a janela oculta, como no Passtrough.
82
+ if executable.name.lower() == "pythonw.exe":
83
+ executable = executable.with_name("python.exe")
84
+ fd, launcher = tempfile.mkstemp(prefix="smart-tool-hidden-", suffix=".vbs")
85
+ try:
86
+ command = (f'Chr(34) & "{executable}" & Chr(34) & " " & '
87
+ f'Chr(34) & "{DAEMON_SCRIPT}" & Chr(34)')
88
+ with os.fdopen(fd, "w", encoding="utf-16") as stream:
89
+ stream.write('Set sh = CreateObject("WScript.Shell")\n')
90
+ stream.write(f'sh.CurrentDirectory = "{Path(DAEMON_SCRIPT).parent}"\n')
91
+ stream.write(f'sh.Run {command}, 0, False\n')
92
+ result = subprocess.run(
93
+ ["wscript.exe", "//B", "//NoLogo", launcher],
94
+ stdin=subprocess.DEVNULL, capture_output=True, timeout=10,
95
+ creationflags=subprocess.CREATE_NO_WINDOW,
96
+ )
97
+ if result.returncode:
98
+ raise RuntimeError("Windows could not start the Smart Tool daemon without a window.")
99
+ finally:
100
+ Path(launcher).unlink(missing_ok=True)
101
+ return
102
+ subprocess.Popen(
103
+ [str(executable), DAEMON_SCRIPT],
104
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
105
+ stderr=subprocess.DEVNULL, close_fds=True, start_new_session=True,
106
+ )
107
+
108
+
109
+ def ensure_daemon_running(wait_seconds=10):
110
+ """Retorna a URL base do daemon (sem `/mcp`), subindo o processo se necessário."""
111
+ info = read_registry()
112
+ if info and health(info["url"]):
113
+ return info["url"]
114
+
115
+ if not _acquire_lock():
116
+ url = _wait_for_daemon(wait_seconds)
117
+ if url:
118
+ return url
119
+ raise RuntimeError("Timed out waiting for another session to start the Smart Tool daemon")
120
+
121
+ try:
122
+ _spawn_daemon()
123
+ url = _wait_for_daemon(wait_seconds)
124
+ if url:
125
+ return url
126
+ raise RuntimeError("Timed out starting the Smart Tool daemon")
127
+ finally:
128
+ _release_lock()
129
+
130
+
131
+ def daemon_url_if_up():
132
+ info = read_registry()
133
+ if info and health(info["url"], timeout=0.5):
134
+ return info["url"]
135
+ return None
136
+
137
+
138
+ def start_daemon_if_down():
139
+ """`True` se o daemon já responde; `False` se acabou de disparar o start, sem esperar.
140
+
141
+ Para quem está num caminho sensível a latência (o hook PreToolUse roda antes de toda
142
+ Grep/Glob/Read/Bash): esperar o processo subir pode custar segundos — spawn de
143
+ processo em máquina com EDR varia muito — e quem chama tem um desfecho válido sem o
144
+ daemon. A garantia de daemon único não depende do lock ser mantido aqui: um segundo
145
+ processo falha no bind exclusivo da porta e morre sozinho."""
146
+ if daemon_url_if_up():
147
+ return True
148
+ if _acquire_lock():
149
+ # O lock NÃO é liberado aqui: ele é o freio de taxa. Expirando por idade
150
+ # (`LOCK_STALE_SECONDS`), um daemon que não consegue subir — porta tomada, import
151
+ # quebrado — resulta em uma tentativa a cada 30s, e não num processo órfão por
152
+ # tool call. Quem perde a corrida do bind morre sozinho (`allow_reuse_address`
153
+ # False), então soltar o lock não acrescentaria segurança nenhuma.
154
+ _spawn_daemon()
155
+ return False
156
+
157
+
158
+ def _wait_for_daemon(wait_seconds, poll_interval=0.1):
159
+ deadline = time.time() + wait_seconds
160
+ while time.time() < deadline:
161
+ info = read_registry()
162
+ if info and health(info["url"], timeout=0.5):
163
+ return info["url"]
164
+ time.sleep(poll_interval)
165
+ return None