shellmate-cli 3.0.0__py3-none-any.whl
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.
- shellmate/__init__.py +0 -0
- shellmate/adapters/__init__.py +67 -0
- shellmate/adapters/bash_adapter.sh +7 -0
- shellmate/adapters/ble_adapter.sh +350 -0
- shellmate/adapters/powershell_adapter.ps1 +417 -0
- shellmate/adapters/powershell_predictor.ps1 +97 -0
- shellmate/adapters/zsh_adapter.zsh +8 -0
- shellmate/agents/__init__.py +23 -0
- shellmate/agents/base.py +248 -0
- shellmate/agents/chain_patterns.yaml +98 -0
- shellmate/agents/command_chain.py +322 -0
- shellmate/agents/context_gatherer.py +175 -0
- shellmate/agents/environment.py +363 -0
- shellmate/agents/librarian.py +502 -0
- shellmate/agents/query.py +415 -0
- shellmate/agents/repomix_backend.py +171 -0
- shellmate/agents/watcher.py +266 -0
- shellmate/cache/__init__.py +0 -0
- shellmate/cache/semantic_cache.py +41 -0
- shellmate/cli/__init__.py +0 -0
- shellmate/cli/commands/__init__.py +27 -0
- shellmate/cli/commands/analyze.py +5 -0
- shellmate/cli/commands/config.py +319 -0
- shellmate/cli/commands/configure.py +118 -0
- shellmate/cli/commands/disable.py +11 -0
- shellmate/cli/commands/enable.py +49 -0
- shellmate/cli/commands/init.py +412 -0
- shellmate/cli/commands/install.py +242 -0
- shellmate/cli/commands/migrate.py +147 -0
- shellmate/cli/commands/status.py +31 -0
- shellmate/cli/commands/uninstall.py +78 -0
- shellmate/cli/main.py +114 -0
- shellmate/cli/parser.py +184 -0
- shellmate/cli/version.py +1 -0
- shellmate/completion/__init__.py +4 -0
- shellmate/completion/bash_adapter.py +145 -0
- shellmate/config/__init__.py +33 -0
- shellmate/config/credentials.py +120 -0
- shellmate/config/defaults.py +88 -0
- shellmate/config/manager.py +94 -0
- shellmate/config/paths.py +77 -0
- shellmate/config/secrets.py +66 -0
- shellmate/daemon/__init__.py +0 -0
- shellmate/daemon/lifecycle.py +308 -0
- shellmate/daemon/observability.py +82 -0
- shellmate/daemon/pipeline.py +119 -0
- shellmate/daemon/server.py +605 -0
- shellmate/daemon/session.py +170 -0
- shellmate/daemon/startup_ui.py +43 -0
- shellmate/environment/__init__.py +0 -0
- shellmate/environment/detector.py +130 -0
- shellmate/ipc/__init__.py +0 -0
- shellmate/ipc/client.py +210 -0
- shellmate/ipc/ps_client.py +190 -0
- shellmate/orchestration/__init__.py +3 -0
- shellmate/orchestration/analysis_graph.py +1274 -0
- shellmate/orchestration/state.py +35 -0
- shellmate/parser/__init__.py +0 -0
- shellmate/parser/command_parser.py +111 -0
- shellmate/plugins/__init__.py +0 -0
- shellmate/plugins/base.py +80 -0
- shellmate/plugins/loader.py +41 -0
- shellmate/providers/__init__.py +0 -0
- shellmate/providers/base.py +97 -0
- shellmate/providers/groq_command_guide.py +108 -0
- shellmate/providers/groq_provider.py +127 -0
- shellmate/providers/groq_supervisor.py +78 -0
- shellmate/providers/openrouter_provider.py +149 -0
- shellmate/providers/router.py +272 -0
- shellmate/providers/validate_keys.py +98 -0
- shellmate/providers/xai_provider.py +142 -0
- shellmate/rules/__init__.py +0 -0
- shellmate/rules/catastrophic.yaml +96 -0
- shellmate/rules/catastrophic_matcher.py +167 -0
- shellmate/rules/definitions/__init__.py +0 -0
- shellmate/rules/definitions/file_deletion.yaml +54 -0
- shellmate/rules/definitions/filesystem.yaml +43 -0
- shellmate/rules/definitions/network_execution.yaml +46 -0
- shellmate/rules/definitions/permissions.yaml +54 -0
- shellmate/rules/definitions/process_control.yaml +37 -0
- shellmate/rules/definitions/system_destruction.yaml +67 -0
- shellmate/rules/engine.py +200 -0
- shellmate/sanitizer/__init__.py +0 -0
- shellmate/sanitizer/typo_detector.py +114 -0
- shellmate/ui/__init__.py +10 -0
- shellmate/ui/analysis.py +164 -0
- shellmate/ui/annotations.py +232 -0
- shellmate/ui/banner.py +49 -0
- shellmate/ui/progress.py +126 -0
- shellmate/ui/runtime/__init__.py +15 -0
- shellmate/ui/runtime/console_factory.py +65 -0
- shellmate/ui/runtime/live_scene.py +116 -0
- shellmate/ui/runtime/motion_policy.py +69 -0
- shellmate/ui/runtime/tokens.py +47 -0
- shellmate/ui/scenes/__init__.py +24 -0
- shellmate/ui/scenes/enable.py +316 -0
- shellmate/ui/scenes/help.py +53 -0
- shellmate/ui/scenes/stages.py +128 -0
- shellmate/ui/scenes/status.py +181 -0
- shellmate/ui/status.py +3 -0
- shellmate_cli-3.0.0.dist-info/METADATA +352 -0
- shellmate_cli-3.0.0.dist-info/RECORD +106 -0
- shellmate_cli-3.0.0.dist-info/WHEEL +5 -0
- shellmate_cli-3.0.0.dist-info/entry_points.txt +3 -0
- shellmate_cli-3.0.0.dist-info/licenses/LICENSE +21 -0
- shellmate_cli-3.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1274 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Dict, Optional, List
|
|
6
|
+
from langgraph.graph import StateGraph, END
|
|
7
|
+
from langgraph.checkpoint.memory import MemorySaver
|
|
8
|
+
from shellmate.orchestration.state import AnalysisState
|
|
9
|
+
from shellmate.parser.command_parser import parse_command
|
|
10
|
+
from shellmate.sanitizer.typo_detector import sanitize
|
|
11
|
+
from shellmate.rules.engine import load_rules, match_rule
|
|
12
|
+
from shellmate.cache.semantic_cache import cache_lookup, cache_store
|
|
13
|
+
from shellmate.agents.watcher import get_watcher_agent
|
|
14
|
+
from shellmate.agents.librarian import get_librarian_agent
|
|
15
|
+
from shellmate.agents.repomix_backend import get_repomix_backend
|
|
16
|
+
from shellmate.providers.router import analyze_risk, generate_remediation
|
|
17
|
+
from shellmate.providers.groq_provider import _format_context
|
|
18
|
+
from shellmate.providers.groq_supervisor import route_command, SupervisorError
|
|
19
|
+
from shellmate.providers.groq_command_guide import generate_command_guide, CommandGuideError, alternatives_from_guide
|
|
20
|
+
from shellmate.config.defaults import get_default_config
|
|
21
|
+
from shellmate.config.secrets import redact_dict
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("shellmate.orchestration")
|
|
24
|
+
|
|
25
|
+
CONTEXT_TIMEOUT = 0.5
|
|
26
|
+
|
|
27
|
+
_rules = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _get_rules():
|
|
31
|
+
global _rules
|
|
32
|
+
if _rules is None:
|
|
33
|
+
_rules = load_rules()
|
|
34
|
+
return _rules
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _merge_stages(state: AnalysisState, key: str, elapsed: float) -> dict:
|
|
38
|
+
existing = state.get("stages", {})
|
|
39
|
+
merged = dict(existing)
|
|
40
|
+
merged[key] = elapsed
|
|
41
|
+
return merged
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def sanitize_node(state: AnalysisState) -> dict:
|
|
45
|
+
start = time.monotonic()
|
|
46
|
+
command = state["command"]
|
|
47
|
+
sanitized = sanitize(command)
|
|
48
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
49
|
+
return {
|
|
50
|
+
"sanitized": sanitized,
|
|
51
|
+
"stages": _merge_stages(state, "sanitizer", elapsed),
|
|
52
|
+
"errors": [],
|
|
53
|
+
"agent_latency": {"watcher": 0, "librarian": 0},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def parse_node(state: AnalysisState) -> dict:
|
|
58
|
+
start = time.monotonic()
|
|
59
|
+
sanitized = state.get("sanitized", {})
|
|
60
|
+
normalized = sanitized.get("normalized", state["command"])
|
|
61
|
+
parsed = parse_command(normalized)
|
|
62
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
63
|
+
return {
|
|
64
|
+
"parsed": parsed if isinstance(parsed, dict) else {},
|
|
65
|
+
"stages": _merge_stages(state, "parser", elapsed),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def command_chain_node(state: AnalysisState) -> dict:
|
|
70
|
+
"""Query command chain history and detect patterns."""
|
|
71
|
+
start = time.monotonic()
|
|
72
|
+
current_command = state.get("command", "")
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
result = await _query_command_chain_agent(current_command)
|
|
76
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
"command_chain": result.get("recent_commands", []),
|
|
80
|
+
"chain_patterns": result.get("patterns", {}),
|
|
81
|
+
"stages": _merge_stages(state, "command_chain", elapsed),
|
|
82
|
+
}
|
|
83
|
+
except Exception as e:
|
|
84
|
+
logger.warning(f"Command chain node error: {e}")
|
|
85
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
86
|
+
return {
|
|
87
|
+
"command_chain": [],
|
|
88
|
+
"chain_patterns": {},
|
|
89
|
+
"stages": _merge_stages(state, "command_chain", elapsed),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def query_node(state: AnalysisState) -> dict:
|
|
94
|
+
"""Process user queries if present."""
|
|
95
|
+
queries = state.get("user_queries", [])
|
|
96
|
+
|
|
97
|
+
if not queries:
|
|
98
|
+
return {} # No queries to process
|
|
99
|
+
|
|
100
|
+
start = time.monotonic()
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
from shellmate.agents.query import get_query_agent
|
|
104
|
+
|
|
105
|
+
query_agent = get_query_agent()
|
|
106
|
+
|
|
107
|
+
# Process all queries with timeout
|
|
108
|
+
response = await asyncio.wait_for(
|
|
109
|
+
query_agent.send_request(
|
|
110
|
+
"process_queries",
|
|
111
|
+
data={"queries": queries, "state": state},
|
|
112
|
+
timeout=0.1 # 100ms timeout per config
|
|
113
|
+
),
|
|
114
|
+
timeout=0.15 # Total timeout with buffer
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
responses = response.data.get("responses", {}) if response and response.status == "ok" else {}
|
|
118
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
"query_responses": responses,
|
|
122
|
+
"stages": _merge_stages(state, "query", elapsed),
|
|
123
|
+
}
|
|
124
|
+
except asyncio.TimeoutError:
|
|
125
|
+
responses = {q: "Unable to answer (timeout)" for q in queries}
|
|
126
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
127
|
+
return {
|
|
128
|
+
"query_responses": responses,
|
|
129
|
+
"stages": _merge_stages(state, "query", elapsed),
|
|
130
|
+
}
|
|
131
|
+
except Exception as e:
|
|
132
|
+
logger.warning(f"Query node error: {e}")
|
|
133
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
134
|
+
return {
|
|
135
|
+
"query_responses": {},
|
|
136
|
+
"stages": _merge_stages(state, "query", elapsed),
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def rules_node(state: AnalysisState) -> dict:
|
|
141
|
+
start = time.monotonic()
|
|
142
|
+
sanitized = state.get("sanitized", {})
|
|
143
|
+
normalized = sanitized.get("normalized", state["command"])
|
|
144
|
+
rules = _get_rules()
|
|
145
|
+
rule_result = match_rule(normalized, rules)
|
|
146
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
147
|
+
return {
|
|
148
|
+
"rule_match": rule_result if rule_result["matched"] else None,
|
|
149
|
+
"stages": _merge_stages(state, "rule_engine", elapsed),
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def cache_node(state: AnalysisState) -> dict:
|
|
154
|
+
start = time.monotonic()
|
|
155
|
+
sanitized = state.get("sanitized", {})
|
|
156
|
+
normalized = sanitized.get("normalized", state["command"])
|
|
157
|
+
cached = cache_lookup(normalized)
|
|
158
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
159
|
+
return {
|
|
160
|
+
"cached": cached,
|
|
161
|
+
"stages": _merge_stages(state, "cache", elapsed),
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def early_exit_router(state: AnalysisState) -> str:
|
|
166
|
+
sanitized = state.get("sanitized", {})
|
|
167
|
+
if sanitized:
|
|
168
|
+
if sanitized.get("correction"):
|
|
169
|
+
return "early_exit_sanitizer"
|
|
170
|
+
if not sanitized.get("syntax", {}).get("valid", True):
|
|
171
|
+
return "early_exit_sanitizer"
|
|
172
|
+
rule_match = state.get("rule_match")
|
|
173
|
+
if rule_match and rule_match.get("confidence", 0) >= 0.9:
|
|
174
|
+
return "early_exit_rule"
|
|
175
|
+
cached = state.get("cached")
|
|
176
|
+
if cached:
|
|
177
|
+
return "early_exit_cache"
|
|
178
|
+
return "needs_context"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _needs_context(command: str) -> bool:
|
|
182
|
+
tokens = command.split()
|
|
183
|
+
if not tokens:
|
|
184
|
+
return False
|
|
185
|
+
cmd = tokens[0].lower()
|
|
186
|
+
if cmd in ("rm", "mv", "cp", "cat", "less", "nano", "vim", "vi", "code", "open"):
|
|
187
|
+
return True
|
|
188
|
+
for token in tokens:
|
|
189
|
+
if token.startswith(("./", "../", "/", ".\\", "..\\")):
|
|
190
|
+
return True
|
|
191
|
+
if cmd in ("git", "docker", "docker-compose", "pip", "npm", "yarn", "pnpm"):
|
|
192
|
+
return True
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
async def supervisor_node(state: AnalysisState) -> dict:
|
|
197
|
+
start = time.monotonic()
|
|
198
|
+
command = state["command"]
|
|
199
|
+
decision = {"run_watcher": False, "run_librarian": False, "command": command}
|
|
200
|
+
|
|
201
|
+
if os.environ.get("GROQ_API_KEY"):
|
|
202
|
+
cfg = get_default_config()
|
|
203
|
+
sup = cfg.get("supervisor", {})
|
|
204
|
+
try:
|
|
205
|
+
routed = await route_command(
|
|
206
|
+
command,
|
|
207
|
+
model=sup.get("model"),
|
|
208
|
+
timeout_ms=sup.get("timeout_ms", 500),
|
|
209
|
+
)
|
|
210
|
+
decision["run_watcher"] = routed["run_watcher"]
|
|
211
|
+
decision["run_librarian"] = routed["run_librarian"]
|
|
212
|
+
except SupervisorError as e:
|
|
213
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
214
|
+
return {
|
|
215
|
+
"needs_context": False,
|
|
216
|
+
"supervisor_decision": decision,
|
|
217
|
+
"supervisor_fallback": True,
|
|
218
|
+
"supervisor_warning": "Context skipped: supervisor unavailable",
|
|
219
|
+
"stages": _merge_stages(state, "supervisor", elapsed),
|
|
220
|
+
"errors": [str(e)],
|
|
221
|
+
}
|
|
222
|
+
else:
|
|
223
|
+
needs = _needs_context(command)
|
|
224
|
+
decision["run_watcher"] = needs
|
|
225
|
+
decision["run_librarian"] = needs
|
|
226
|
+
|
|
227
|
+
needs_context = decision["run_watcher"] or decision["run_librarian"]
|
|
228
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
229
|
+
return {
|
|
230
|
+
"needs_context": needs_context,
|
|
231
|
+
"supervisor_decision": decision,
|
|
232
|
+
"stages": _merge_stages(state, "supervisor", elapsed),
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def supervisor_router(state: AnalysisState) -> str:
|
|
237
|
+
if state.get("supervisor_fallback"):
|
|
238
|
+
return "skip_context"
|
|
239
|
+
if state.get("needs_context"):
|
|
240
|
+
return "context_worker"
|
|
241
|
+
return "skip_context"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
async def env_context_node(state: AnalysisState) -> dict:
|
|
245
|
+
"""
|
|
246
|
+
Lightweight environment lookup for autocomplete / skip_context path.
|
|
247
|
+
|
|
248
|
+
Watcher and Librarian are skipped; EnvironmentAgent is still queried so
|
|
249
|
+
production badges and blast-radius context remain available (<50ms cached).
|
|
250
|
+
"""
|
|
251
|
+
start = time.monotonic()
|
|
252
|
+
result = await _query_environment_agent(state.get("overrides", {}) or {})
|
|
253
|
+
env_context = {k: v for k, v in (result or {}).items() if k != "_latency_ms"}
|
|
254
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
255
|
+
agent_latency = dict(state.get("agent_latency") or {})
|
|
256
|
+
agent_latency["environment"] = (result or {}).get("_latency_ms", round(elapsed, 1))
|
|
257
|
+
return {
|
|
258
|
+
"environment_context": env_context,
|
|
259
|
+
"agent_latency": agent_latency,
|
|
260
|
+
"stages": _merge_stages(state, "environment", elapsed),
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _query_router(state: AnalysisState) -> str:
|
|
265
|
+
"""Route to query_node if queries present, else END."""
|
|
266
|
+
if state.get("user_queries"):
|
|
267
|
+
return "query"
|
|
268
|
+
return "end"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
async def context_worker_node(state: AnalysisState) -> dict:
|
|
272
|
+
start = time.monotonic()
|
|
273
|
+
command = state["command"]
|
|
274
|
+
agent_latency = {"watcher": 0, "librarian": 0, "environment": 0}
|
|
275
|
+
|
|
276
|
+
decision = state.get("supervisor_decision") or {}
|
|
277
|
+
run_watcher = decision.get("run_watcher", state.get("needs_context", True))
|
|
278
|
+
run_librarian = decision.get("run_librarian", state.get("needs_context", True))
|
|
279
|
+
|
|
280
|
+
watcher_context = {}
|
|
281
|
+
librarian_context = {"matches": []}
|
|
282
|
+
environment_context = {}
|
|
283
|
+
partial = False
|
|
284
|
+
|
|
285
|
+
tasks = {}
|
|
286
|
+
if run_watcher:
|
|
287
|
+
tasks["watcher"] = asyncio.create_task(_query_watcher(command))
|
|
288
|
+
if run_librarian:
|
|
289
|
+
tasks["librarian"] = asyncio.create_task(_query_librarian(command))
|
|
290
|
+
tasks["repomix"] = asyncio.create_task(_query_repomix(command))
|
|
291
|
+
|
|
292
|
+
# NEW: Add EnvironmentAgent
|
|
293
|
+
tasks["environment"] = asyncio.create_task(
|
|
294
|
+
_query_environment_agent(state.get("overrides", {}))
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
if not tasks:
|
|
298
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
299
|
+
return {
|
|
300
|
+
"watcher_context": watcher_context,
|
|
301
|
+
"librarian_context": librarian_context,
|
|
302
|
+
"environment_context": environment_context,
|
|
303
|
+
"merged_context": {"watcher": watcher_context, "librarian": librarian_context, "partial": False},
|
|
304
|
+
"agent_latency": agent_latency,
|
|
305
|
+
"stages": _merge_stages(state, "context", elapsed),
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
done, pending = await asyncio.wait(
|
|
309
|
+
tasks.values(),
|
|
310
|
+
timeout=CONTEXT_TIMEOUT,
|
|
311
|
+
return_when=asyncio.ALL_COMPLETED,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
for name, task in tasks.items():
|
|
315
|
+
if task in pending:
|
|
316
|
+
task.cancel()
|
|
317
|
+
partial = True
|
|
318
|
+
continue
|
|
319
|
+
try:
|
|
320
|
+
result = await task
|
|
321
|
+
if name == "watcher":
|
|
322
|
+
watcher_context = result or {}
|
|
323
|
+
agent_latency["watcher"] = result.get("_latency_ms", 0) if result else 0
|
|
324
|
+
elif name == "librarian":
|
|
325
|
+
librarian_context = result if result else {"matches": []}
|
|
326
|
+
agent_latency["librarian"] = result.get("_latency_ms", 0) if result else 0
|
|
327
|
+
elif name == "repomix":
|
|
328
|
+
if result and result.get("matches"):
|
|
329
|
+
existing = librarian_context.get("matches", [])
|
|
330
|
+
existing.extend(result["matches"])
|
|
331
|
+
librarian_context["matches"] = existing
|
|
332
|
+
elif name == "environment":
|
|
333
|
+
environment_context = result or {}
|
|
334
|
+
agent_latency["environment"] = result.get("_latency_ms", 0) if result else 0
|
|
335
|
+
except asyncio.CancelledError:
|
|
336
|
+
partial = True
|
|
337
|
+
except Exception as e:
|
|
338
|
+
logger.warning(f"Context worker {name} error: {e}")
|
|
339
|
+
partial = True
|
|
340
|
+
|
|
341
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
342
|
+
return {
|
|
343
|
+
"watcher_context": watcher_context,
|
|
344
|
+
"librarian_context": librarian_context,
|
|
345
|
+
"environment_context": environment_context,
|
|
346
|
+
"merged_context": {"watcher": watcher_context, "librarian": librarian_context, "partial": partial},
|
|
347
|
+
"agent_latency": agent_latency,
|
|
348
|
+
"stages": _merge_stages(state, "context", elapsed),
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
async def _query_watcher(command: str) -> dict:
|
|
353
|
+
start = time.monotonic()
|
|
354
|
+
try:
|
|
355
|
+
watcher = get_watcher_agent()
|
|
356
|
+
if watcher.status.value != "ready":
|
|
357
|
+
return {}
|
|
358
|
+
response = await watcher.send_request("get_context", timeout=CONTEXT_TIMEOUT)
|
|
359
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
360
|
+
if response.status == "ok":
|
|
361
|
+
result = redact_dict(response.data)
|
|
362
|
+
result["_latency_ms"] = round(elapsed, 1)
|
|
363
|
+
return result
|
|
364
|
+
return {}
|
|
365
|
+
except Exception as e:
|
|
366
|
+
logger.debug(f"Watcher query failed: {e}")
|
|
367
|
+
return {}
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
async def _query_librarian(command: str) -> dict:
|
|
371
|
+
start = time.monotonic()
|
|
372
|
+
try:
|
|
373
|
+
librarian = get_librarian_agent()
|
|
374
|
+
if not librarian.is_indexed:
|
|
375
|
+
return {"matches": []}
|
|
376
|
+
response = await librarian.send_request("search", data={"query": command}, timeout=CONTEXT_TIMEOUT)
|
|
377
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
378
|
+
if response.status == "ok":
|
|
379
|
+
data = response.data
|
|
380
|
+
data["_latency_ms"] = round(elapsed, 1)
|
|
381
|
+
return data
|
|
382
|
+
return {"matches": []}
|
|
383
|
+
except Exception as e:
|
|
384
|
+
logger.debug(f"Librarian query failed: {e}")
|
|
385
|
+
return {"matches": []}
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
async def _query_repomix(command: str) -> dict:
|
|
389
|
+
try:
|
|
390
|
+
backend = get_repomix_backend()
|
|
391
|
+
return await backend.search(command)
|
|
392
|
+
except Exception as e:
|
|
393
|
+
logger.debug(f"Repomix query failed: {e}")
|
|
394
|
+
return {"matches": []}
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
async def _query_environment_agent(overrides: dict) -> dict:
|
|
398
|
+
"""Query EnvironmentAgent for context."""
|
|
399
|
+
start = time.monotonic()
|
|
400
|
+
try:
|
|
401
|
+
from shellmate.agents.environment import get_environment_agent
|
|
402
|
+
|
|
403
|
+
agent = get_environment_agent()
|
|
404
|
+
response = await agent.send_request(
|
|
405
|
+
"detect_context",
|
|
406
|
+
data={"overrides": overrides},
|
|
407
|
+
timeout=0.05 # 50ms timeout
|
|
408
|
+
)
|
|
409
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
410
|
+
if response and response.status == "ok":
|
|
411
|
+
result = response.data or {}
|
|
412
|
+
result["_latency_ms"] = round(elapsed, 1)
|
|
413
|
+
return result
|
|
414
|
+
return {}
|
|
415
|
+
except Exception as e:
|
|
416
|
+
logger.debug(f"Environment agent query failed: {e}")
|
|
417
|
+
return {}
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
async def _query_command_chain_agent(current_command: str) -> dict:
|
|
421
|
+
"""Query CommandChainAgent for recent commands and patterns."""
|
|
422
|
+
start = time.monotonic()
|
|
423
|
+
try:
|
|
424
|
+
from shellmate.agents.command_chain import get_command_chain_agent
|
|
425
|
+
|
|
426
|
+
agent = get_command_chain_agent()
|
|
427
|
+
|
|
428
|
+
# Get recent commands and patterns in one request
|
|
429
|
+
response = await agent.send_request(
|
|
430
|
+
"get_chain_patterns",
|
|
431
|
+
data={"command": current_command, "count": 10, "minutes": 30},
|
|
432
|
+
timeout=0.01 # 10ms timeout
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
436
|
+
|
|
437
|
+
if response and response.status == "ok":
|
|
438
|
+
return {
|
|
439
|
+
"recent_commands": response.data.get("command_chain", []),
|
|
440
|
+
"patterns": response.data.get("chain_patterns", {}),
|
|
441
|
+
"_latency_ms": elapsed,
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return {"recent_commands": [], "patterns": {}, "_latency_ms": elapsed}
|
|
445
|
+
except Exception as e:
|
|
446
|
+
logger.debug(f"Command chain query failed: {e}")
|
|
447
|
+
return {"recent_commands": [], "patterns": {}}
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
async def risk_analyzer_node(state: AnalysisState) -> dict:
|
|
451
|
+
start = time.monotonic()
|
|
452
|
+
command = state["command"]
|
|
453
|
+
context = state.get("merged_context", {})
|
|
454
|
+
result = await analyze_risk(command, context=context)
|
|
455
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
456
|
+
return {
|
|
457
|
+
"risk_analysis": result,
|
|
458
|
+
"stages": _merge_stages(state, "risk_agent", elapsed),
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
async def consequence_builder_node(state: AnalysisState) -> dict:
|
|
463
|
+
"""
|
|
464
|
+
Build structured consequence preview from parsed command, risk, environment, and chain context.
|
|
465
|
+
|
|
466
|
+
Transforms abstract risk levels into concrete "what will happen in YOUR environment"
|
|
467
|
+
statements that are actionable and understandable.
|
|
468
|
+
"""
|
|
469
|
+
start = time.monotonic()
|
|
470
|
+
|
|
471
|
+
try:
|
|
472
|
+
parsed = state.get("parsed", {}) or {}
|
|
473
|
+
risk_analysis = state.get("risk_analysis", {}) or {}
|
|
474
|
+
env_context = state.get("environment_context", {}) or {}
|
|
475
|
+
chain_patterns = state.get("chain_patterns", {}) or {}
|
|
476
|
+
|
|
477
|
+
risk_tier = risk_analysis.get("risk", "MEDIUM")
|
|
478
|
+
severity = _map_risk_to_severity(risk_tier)
|
|
479
|
+
|
|
480
|
+
what_happens = _build_what_happens(parsed, risk_analysis, env_context)
|
|
481
|
+
scope = _build_scope(env_context, chain_patterns)
|
|
482
|
+
reversible = _is_reversible(parsed, risk_analysis)
|
|
483
|
+
affected_count = env_context.get("blast_radius", {}).get("estimated_users")
|
|
484
|
+
suggested_fix = None # remediation_node fills this later
|
|
485
|
+
|
|
486
|
+
consequence_preview = {
|
|
487
|
+
"what_happens": what_happens,
|
|
488
|
+
"scope": scope,
|
|
489
|
+
"reversible": reversible,
|
|
490
|
+
"affected_count": affected_count,
|
|
491
|
+
"suggested_fix": suggested_fix,
|
|
492
|
+
"severity": severity,
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
496
|
+
return {
|
|
497
|
+
"consequence_preview": consequence_preview,
|
|
498
|
+
"stages": _merge_stages(state, "consequence_builder", elapsed),
|
|
499
|
+
}
|
|
500
|
+
except Exception as e:
|
|
501
|
+
logger.warning(f"Consequence builder error: {e}")
|
|
502
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
503
|
+
return {
|
|
504
|
+
"consequence_preview": {
|
|
505
|
+
"what_happens": "Analysis complete",
|
|
506
|
+
"scope": "unknown",
|
|
507
|
+
"reversible": True,
|
|
508
|
+
"affected_count": None,
|
|
509
|
+
"suggested_fix": None,
|
|
510
|
+
"severity": "warning",
|
|
511
|
+
},
|
|
512
|
+
"stages": _merge_stages(state, "consequence_builder", elapsed),
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _map_risk_to_severity(risk_tier: str) -> str:
|
|
517
|
+
"""Map risk tier to consequence severity for UI rendering."""
|
|
518
|
+
mapping = {
|
|
519
|
+
"CRITICAL": "blocking",
|
|
520
|
+
"HIGH": "warning",
|
|
521
|
+
"MEDIUM": "warning",
|
|
522
|
+
"LOW": "info",
|
|
523
|
+
"SAFE": "info",
|
|
524
|
+
}
|
|
525
|
+
return mapping.get(risk_tier, "warning")
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _build_what_happens(parsed: dict, risk_analysis: dict, env_context: dict) -> str:
|
|
529
|
+
"""Build plain-English description of what the command will do."""
|
|
530
|
+
if not parsed or not parsed.get("program"):
|
|
531
|
+
reason = risk_analysis.get("reason", "Unknown consequence")
|
|
532
|
+
return reason[:100] if reason else "Consequence unclear"
|
|
533
|
+
|
|
534
|
+
program = parsed.get("program", "unknown")
|
|
535
|
+
risk_reason = risk_analysis.get("reason", "")
|
|
536
|
+
|
|
537
|
+
consequence = _describe_command_consequence(program, parsed, risk_reason)
|
|
538
|
+
|
|
539
|
+
env_type = env_context.get("type", "")
|
|
540
|
+
if env_type and env_type != "development":
|
|
541
|
+
consequence = f"{consequence} in {env_type} environment"
|
|
542
|
+
|
|
543
|
+
return consequence
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _describe_command_consequence(program: str, parsed: dict, risk_reason: str) -> str:
|
|
547
|
+
"""Describe specific consequences based on command type."""
|
|
548
|
+
if program in ("rm", "rmdir"):
|
|
549
|
+
args = parsed.get("arguments", [])
|
|
550
|
+
flags = [f.get("flag") for f in parsed.get("flags", [])]
|
|
551
|
+
if "/" in str(args) or any(f in ("-rf", "-r") for f in flags):
|
|
552
|
+
targets = ", ".join(str(a) for a in args[:2]) if args else "filesystem paths"
|
|
553
|
+
return f"Will permanently delete {targets}"
|
|
554
|
+
return "Will delete files"
|
|
555
|
+
|
|
556
|
+
if program == "git" and parsed.get("subcommand") == "push":
|
|
557
|
+
flags = [f.get("flag") for f in parsed.get("flags", [])]
|
|
558
|
+
if any(f in ("--force", "-f", "--force-with-lease") for f in flags):
|
|
559
|
+
branch = next((str(a) for a in parsed.get("arguments", [])), "current branch")
|
|
560
|
+
return f"Will force-push and rewrite history on {branch}"
|
|
561
|
+
return "Will push changes to remote"
|
|
562
|
+
|
|
563
|
+
if program == "docker":
|
|
564
|
+
subcommand = parsed.get("subcommand", "")
|
|
565
|
+
if subcommand in ("rm", "stop"):
|
|
566
|
+
containers = parsed.get("arguments", [])
|
|
567
|
+
count = len(containers) if containers else "multiple"
|
|
568
|
+
return f"Will stop/remove {count} container(s)"
|
|
569
|
+
if subcommand == "run":
|
|
570
|
+
return "Will start new container with specified config"
|
|
571
|
+
|
|
572
|
+
if program == "sudo":
|
|
573
|
+
return "Will execute with elevated privileges"
|
|
574
|
+
|
|
575
|
+
if program == "chmod":
|
|
576
|
+
targets = parsed.get("arguments", [])
|
|
577
|
+
target_str = ", ".join(str(t) for t in targets[:2]) if targets else "files"
|
|
578
|
+
return f"Will change permissions on {target_str}"
|
|
579
|
+
|
|
580
|
+
if program == "mkfs":
|
|
581
|
+
targets = parsed.get("arguments", [])
|
|
582
|
+
device = str(targets[0]) if targets else "device"
|
|
583
|
+
return f"Will format and erase all data on {device}"
|
|
584
|
+
|
|
585
|
+
if risk_reason:
|
|
586
|
+
return risk_reason[:80]
|
|
587
|
+
|
|
588
|
+
return f"Will execute {program} command"
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _build_scope(env_context: dict, chain_patterns: dict) -> str:
|
|
592
|
+
"""Build scope description: how many resources/users/systems affected."""
|
|
593
|
+
parts = []
|
|
594
|
+
|
|
595
|
+
blast = env_context.get("blast_radius", {}) or {}
|
|
596
|
+
if blast.get("estimated_size_gb"):
|
|
597
|
+
parts.append(f"{blast['estimated_size_gb']}GB")
|
|
598
|
+
|
|
599
|
+
if blast.get("num_directories"):
|
|
600
|
+
parts.append(f"{blast['num_directories']} directories")
|
|
601
|
+
|
|
602
|
+
if blast.get("num_files"):
|
|
603
|
+
parts.append(f"{blast['num_files']} files")
|
|
604
|
+
|
|
605
|
+
if blast.get("estimated_users"):
|
|
606
|
+
parts.append(f"{blast['estimated_users']} users affected")
|
|
607
|
+
|
|
608
|
+
if chain_patterns.get("dangerous_escalations"):
|
|
609
|
+
parts.append(f"{len(chain_patterns['dangerous_escalations'])} escalation(s)")
|
|
610
|
+
|
|
611
|
+
if chain_patterns.get("broken", []):
|
|
612
|
+
parts.append("broken workflow detected")
|
|
613
|
+
|
|
614
|
+
env_type = env_context.get("type", "")
|
|
615
|
+
if env_type == "production":
|
|
616
|
+
parts.append("production")
|
|
617
|
+
elif env_type == "staging":
|
|
618
|
+
parts.append("staging")
|
|
619
|
+
|
|
620
|
+
if not parts:
|
|
621
|
+
return "scope unclear"
|
|
622
|
+
|
|
623
|
+
return " · ".join(parts)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _is_reversible(parsed: dict, risk_analysis: dict) -> bool:
|
|
627
|
+
"""Determine if the command's effects can be undone."""
|
|
628
|
+
program = parsed.get("program", "")
|
|
629
|
+
subcommand = parsed.get("subcommand", "")
|
|
630
|
+
flags = [f.get("flag") for f in parsed.get("flags", [])]
|
|
631
|
+
|
|
632
|
+
irreversible_cmds = {
|
|
633
|
+
"rm", "rmdir", "mkfs", "mkfs.ext4", "mkfs.btrfs",
|
|
634
|
+
"shred", "wipe", "fdisk", "parted",
|
|
635
|
+
}
|
|
636
|
+
if program in irreversible_cmds:
|
|
637
|
+
return False
|
|
638
|
+
|
|
639
|
+
if program == "kill":
|
|
640
|
+
if any(f in ("-9", "-KILL", "-SIGKILL") for f in flags):
|
|
641
|
+
return False
|
|
642
|
+
|
|
643
|
+
if program == "git" and subcommand == "push":
|
|
644
|
+
if any(f in ("--force", "-f", "--force-with-lease") for f in flags):
|
|
645
|
+
return False
|
|
646
|
+
|
|
647
|
+
if program in ("mysql", "psql", "sqlite3", "mongo"):
|
|
648
|
+
if any("drop" in str(f).lower() for f in flags):
|
|
649
|
+
return False
|
|
650
|
+
|
|
651
|
+
if program == "sudo" and parsed.get("arguments"):
|
|
652
|
+
first_arg = str(parsed["arguments"][0]).lower()
|
|
653
|
+
if any(cmd in first_arg for cmd in ("rm", "mkfs", "dd")):
|
|
654
|
+
return False
|
|
655
|
+
|
|
656
|
+
return True
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
async def remediation_node(state: AnalysisState) -> dict:
|
|
660
|
+
start = time.monotonic()
|
|
661
|
+
command = state["command"]
|
|
662
|
+
risk = state.get("risk_analysis", {})
|
|
663
|
+
context = state.get("merged_context", {})
|
|
664
|
+
result = await generate_remediation(command, risk, context=context)
|
|
665
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
666
|
+
out = {
|
|
667
|
+
"remediation": result,
|
|
668
|
+
"stages": _merge_stages(state, "remediation_agent", elapsed),
|
|
669
|
+
}
|
|
670
|
+
if result.get("command_guide"):
|
|
671
|
+
out["command_guide"] = result["command_guide"]
|
|
672
|
+
return out
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
async def command_guide_node(state: AnalysisState) -> dict:
|
|
676
|
+
if _detect_early_exit(state):
|
|
677
|
+
return {}
|
|
678
|
+
if state.get("command_guide"):
|
|
679
|
+
return {}
|
|
680
|
+
from shellmate.providers.router import get_groq_provider
|
|
681
|
+
if not get_groq_provider().is_available():
|
|
682
|
+
return {}
|
|
683
|
+
start = time.monotonic()
|
|
684
|
+
try:
|
|
685
|
+
merged_context = state.get("merged_context")
|
|
686
|
+
context_str = _format_context(merged_context)
|
|
687
|
+
risk = state.get("risk_analysis") or {}
|
|
688
|
+
guide = await generate_command_guide(
|
|
689
|
+
state["command"],
|
|
690
|
+
context=context_str,
|
|
691
|
+
risk_reason=risk.get("reason", ""),
|
|
692
|
+
)
|
|
693
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
694
|
+
return {"command_guide": guide, "stages": _merge_stages(state, "command_guide", elapsed)}
|
|
695
|
+
except Exception as e:
|
|
696
|
+
logger.debug(f"Command guide skipped: {e}")
|
|
697
|
+
return {}
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
RISK_ORDER = {"SAFE": 0, "LOW": 1, "MEDIUM": 2, "HIGH": 3, "CRITICAL": 4}
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _calculate_risk_factors(state: AnalysisState) -> dict:
|
|
704
|
+
"""Calculate weighted risk factor breakdown."""
|
|
705
|
+
factors = {
|
|
706
|
+
"rule": 0.3, # 30% from rule match
|
|
707
|
+
"environment": 0.5, # 50% from environment
|
|
708
|
+
"chain": 0.2, # 20% from command chain
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
# Adjust if components missing
|
|
712
|
+
if not state.get("rule_match"):
|
|
713
|
+
factors["rule"] = 0.0
|
|
714
|
+
factors["environment"] = 0.6
|
|
715
|
+
factors["chain"] = 0.4
|
|
716
|
+
|
|
717
|
+
if not state.get("chain_patterns", {}).get("detected"):
|
|
718
|
+
factors["chain"] = 0.0
|
|
719
|
+
factors["rule"] += 0.1
|
|
720
|
+
factors["environment"] += 0.1
|
|
721
|
+
|
|
722
|
+
return factors
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _adjust_risk_for_environment(base_risk: str, env_context: dict) -> str:
|
|
726
|
+
"""Adjust risk level based on environment context."""
|
|
727
|
+
current_level = RISK_ORDER.get(base_risk, 2)
|
|
728
|
+
|
|
729
|
+
env_type = env_context.get("type", "unknown")
|
|
730
|
+
time_ctx = env_context.get("time", {})
|
|
731
|
+
blast_ctx = env_context.get("blast_radius", {})
|
|
732
|
+
resources = env_context.get("resources", {})
|
|
733
|
+
|
|
734
|
+
# Environment type adjustments
|
|
735
|
+
if env_type == "production":
|
|
736
|
+
current_level += 2
|
|
737
|
+
elif env_type == "staging":
|
|
738
|
+
current_level += 1
|
|
739
|
+
elif env_type == "development":
|
|
740
|
+
current_level -= 1
|
|
741
|
+
|
|
742
|
+
# Time adjustments
|
|
743
|
+
if time_ctx.get("is_business_hours"):
|
|
744
|
+
current_level += 1
|
|
745
|
+
|
|
746
|
+
# Blast radius adjustments
|
|
747
|
+
if blast_ctx.get("is_multi_user"):
|
|
748
|
+
current_level += 1
|
|
749
|
+
|
|
750
|
+
# Resource adjustments
|
|
751
|
+
if resources.get("disk_low") or resources.get("memory_high"):
|
|
752
|
+
current_level += 1
|
|
753
|
+
|
|
754
|
+
# Clamp to valid range
|
|
755
|
+
current_level = max(0, min(4, current_level))
|
|
756
|
+
|
|
757
|
+
# Convert back to label
|
|
758
|
+
for label, level in RISK_ORDER.items():
|
|
759
|
+
if level == current_level:
|
|
760
|
+
return label
|
|
761
|
+
return base_risk
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _adjust_risk_for_chain(base_risk: str, chain_patterns: dict) -> str:
|
|
765
|
+
"""Adjust risk level based on command chain patterns."""
|
|
766
|
+
current_level = RISK_ORDER.get(base_risk, 2)
|
|
767
|
+
|
|
768
|
+
# Apply adjustments from detected patterns (expected workflows lower risk)
|
|
769
|
+
for pattern in chain_patterns.get("detected", []):
|
|
770
|
+
current_level += pattern.get("risk_adjustment", 0)
|
|
771
|
+
|
|
772
|
+
# Apply adjustments from broken workflows (increase risk)
|
|
773
|
+
for pattern in chain_patterns.get("broken", []):
|
|
774
|
+
current_level += pattern.get("risk_adjustment", 0)
|
|
775
|
+
|
|
776
|
+
# Apply adjustments from dangerous escalations (significant increase)
|
|
777
|
+
for pattern in chain_patterns.get("dangerous_escalations", []):
|
|
778
|
+
current_level += pattern.get("risk_adjustment", 0)
|
|
779
|
+
|
|
780
|
+
# Clamp to valid range
|
|
781
|
+
current_level = max(0, min(4, current_level))
|
|
782
|
+
|
|
783
|
+
# Convert back to label
|
|
784
|
+
for label, level in RISK_ORDER.items():
|
|
785
|
+
if level == current_level:
|
|
786
|
+
return label
|
|
787
|
+
return base_risk
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def format_node(state: AnalysisState) -> dict:
|
|
791
|
+
sanitized = state.get("sanitized", {}) or {}
|
|
792
|
+
rule_match = state.get("rule_match")
|
|
793
|
+
cached = state.get("cached")
|
|
794
|
+
risk_analysis = state.get("risk_analysis")
|
|
795
|
+
remediation = state.get("remediation")
|
|
796
|
+
merged_context = state.get("merged_context")
|
|
797
|
+
environment_context = state.get("environment_context", {})
|
|
798
|
+
stages = state.get("stages", {})
|
|
799
|
+
chain_patterns = state.get("chain_patterns", {}) or {}
|
|
800
|
+
consequence_preview = _ensure_consequence_preview(state)
|
|
801
|
+
|
|
802
|
+
detected_early_exit = _detect_early_exit(state)
|
|
803
|
+
|
|
804
|
+
if detected_early_exit == "sanitizer":
|
|
805
|
+
if sanitized.get("correction"):
|
|
806
|
+
output = _build_output(
|
|
807
|
+
risk="LOW",
|
|
808
|
+
confidence=1.0,
|
|
809
|
+
message=sanitized["correction"],
|
|
810
|
+
explanation=sanitized.get("trailing_char", {}).get("message", ""),
|
|
811
|
+
alternatives=[sanitized["correction"]],
|
|
812
|
+
stages=stages,
|
|
813
|
+
early_exit="sanitizer",
|
|
814
|
+
consequence_preview=consequence_preview,
|
|
815
|
+
environment_context=environment_context,
|
|
816
|
+
chain_patterns=chain_patterns,
|
|
817
|
+
)
|
|
818
|
+
return {"output": output, "consequence_preview": consequence_preview}
|
|
819
|
+
output = _build_output(
|
|
820
|
+
risk="MEDIUM",
|
|
821
|
+
confidence=0.9,
|
|
822
|
+
message="Syntax error detected",
|
|
823
|
+
explanation="; ".join(sanitized.get("syntax", {}).get("errors", [])),
|
|
824
|
+
alternatives=["Fix the syntax and try again."],
|
|
825
|
+
stages=stages,
|
|
826
|
+
early_exit="sanitizer",
|
|
827
|
+
consequence_preview=consequence_preview,
|
|
828
|
+
environment_context=environment_context,
|
|
829
|
+
chain_patterns=chain_patterns,
|
|
830
|
+
)
|
|
831
|
+
return {"output": output, "consequence_preview": consequence_preview}
|
|
832
|
+
|
|
833
|
+
if detected_early_exit == "rule" and rule_match:
|
|
834
|
+
output = _build_output(
|
|
835
|
+
risk=rule_match.get("severity", rule_match.get("risk", "MEDIUM")),
|
|
836
|
+
confidence=rule_match.get("confidence", 0.9),
|
|
837
|
+
message=rule_match.get("message", ""),
|
|
838
|
+
explanation=rule_match.get("description", ""),
|
|
839
|
+
rule_id=rule_match.get("rule_id"),
|
|
840
|
+
category=rule_match.get("category"),
|
|
841
|
+
stages=stages,
|
|
842
|
+
early_exit="rule_engine",
|
|
843
|
+
consequence_preview=consequence_preview,
|
|
844
|
+
environment_context=environment_context,
|
|
845
|
+
chain_patterns=chain_patterns,
|
|
846
|
+
)
|
|
847
|
+
return {"output": output, "consequence_preview": consequence_preview}
|
|
848
|
+
|
|
849
|
+
if detected_early_exit == "cache" and cached:
|
|
850
|
+
output = _build_output(
|
|
851
|
+
risk=cached.get("risk", "MEDIUM"),
|
|
852
|
+
confidence=cached.get("confidence", 0.9),
|
|
853
|
+
message=cached.get("explanation", ""),
|
|
854
|
+
explanation=cached.get("explanation", ""),
|
|
855
|
+
alternatives=cached.get("alternatives", []),
|
|
856
|
+
cached=True,
|
|
857
|
+
stages=stages,
|
|
858
|
+
early_exit="cache",
|
|
859
|
+
consequence_preview=consequence_preview or cached.get("consequence_preview"),
|
|
860
|
+
environment_context=environment_context or cached.get("environment_context"),
|
|
861
|
+
chain_patterns=chain_patterns or cached.get("chain_patterns"),
|
|
862
|
+
)
|
|
863
|
+
return {"output": output, "consequence_preview": consequence_preview}
|
|
864
|
+
|
|
865
|
+
context_used = {
|
|
866
|
+
"watcher": bool(merged_context and merged_context.get("watcher")),
|
|
867
|
+
"librarian": bool(merged_context and merged_context.get("librarian", {}).get("matches")),
|
|
868
|
+
} if merged_context else {"watcher": False, "librarian": False}
|
|
869
|
+
|
|
870
|
+
# Get base risk from analysis
|
|
871
|
+
base_risk = risk_analysis.get("risk", "MEDIUM") if risk_analysis else "MEDIUM"
|
|
872
|
+
if rule_match and rule_match.get("matched"):
|
|
873
|
+
rule_severity = rule_match.get("severity", rule_match.get("risk", "MEDIUM"))
|
|
874
|
+
if RISK_ORDER.get(rule_severity, 0) > RISK_ORDER.get(base_risk, 0):
|
|
875
|
+
base_risk = rule_severity
|
|
876
|
+
|
|
877
|
+
# NEW v3: Adjust risk based on environment context and command chain
|
|
878
|
+
risk_level = base_risk
|
|
879
|
+
risk_factors = {}
|
|
880
|
+
|
|
881
|
+
if environment_context:
|
|
882
|
+
risk_level = _adjust_risk_for_environment(base_risk, environment_context)
|
|
883
|
+
|
|
884
|
+
# Apply chain-based risk adjustments
|
|
885
|
+
if chain_patterns:
|
|
886
|
+
risk_level = _adjust_risk_for_chain(risk_level, chain_patterns)
|
|
887
|
+
|
|
888
|
+
# Calculate risk factor breakdown
|
|
889
|
+
if environment_context or chain_patterns:
|
|
890
|
+
risk_factors = _calculate_risk_factors(state)
|
|
891
|
+
|
|
892
|
+
if risk_analysis and risk_analysis.get("provider") != "fallback":
|
|
893
|
+
normalized = sanitized.get("normalized", state["command"])
|
|
894
|
+
cache_store(normalized, risk_analysis)
|
|
895
|
+
|
|
896
|
+
alternatives = remediation.get("alternatives", []) if remediation else []
|
|
897
|
+
command_guide = state.get("command_guide")
|
|
898
|
+
if command_guide and _is_generic_remediation(alternatives):
|
|
899
|
+
alternatives = alternatives_from_guide(command_guide) or alternatives
|
|
900
|
+
|
|
901
|
+
output = _build_output(
|
|
902
|
+
risk=risk_level,
|
|
903
|
+
confidence=risk_analysis.get("confidence", 0.5) if risk_analysis else 0.5,
|
|
904
|
+
message=risk_analysis.get("reason", remediation.get("explanation", "")) if risk_analysis or remediation else "Analysis completed",
|
|
905
|
+
explanation=remediation.get("explanation", "") if remediation else "",
|
|
906
|
+
alternatives=alternatives,
|
|
907
|
+
context_used=context_used,
|
|
908
|
+
rule_match=rule_match if rule_match and rule_match.get("matched") else None,
|
|
909
|
+
risk_analysis=risk_analysis,
|
|
910
|
+
stages=stages,
|
|
911
|
+
agent_latency=state.get("agent_latency", {}),
|
|
912
|
+
supervisor_fallback=state.get("supervisor_fallback", False),
|
|
913
|
+
supervisor_warning=state.get("supervisor_warning"),
|
|
914
|
+
command_guide=command_guide,
|
|
915
|
+
failed_provider=(risk_analysis or {}).get("failed_provider")
|
|
916
|
+
or (remediation or {}).get("failed_provider"),
|
|
917
|
+
failed_credential=(risk_analysis or {}).get("failed_credential")
|
|
918
|
+
or (remediation or {}).get("failed_credential"),
|
|
919
|
+
consequence_preview=consequence_preview,
|
|
920
|
+
environment_context=environment_context,
|
|
921
|
+
chain_patterns=chain_patterns,
|
|
922
|
+
)
|
|
923
|
+
return {"output": output, "consequence_preview": consequence_preview}
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def _ensure_consequence_preview(state: AnalysisState) -> Optional[Dict[str, Any]]:
|
|
927
|
+
"""Return existing consequence_preview or synthesize for early-exit paths."""
|
|
928
|
+
existing = state.get("consequence_preview")
|
|
929
|
+
if existing and existing.get("what_happens"):
|
|
930
|
+
return existing
|
|
931
|
+
|
|
932
|
+
parsed = state.get("parsed") or {}
|
|
933
|
+
risk_analysis = state.get("risk_analysis") or {}
|
|
934
|
+
if not risk_analysis:
|
|
935
|
+
rule_match = state.get("rule_match") or {}
|
|
936
|
+
if rule_match.get("matched"):
|
|
937
|
+
risk_analysis = {
|
|
938
|
+
"risk": rule_match.get("severity", rule_match.get("risk", "MEDIUM")),
|
|
939
|
+
"reason": rule_match.get("message") or rule_match.get("description") or "",
|
|
940
|
+
}
|
|
941
|
+
cached = state.get("cached") or {}
|
|
942
|
+
if cached and not risk_analysis:
|
|
943
|
+
risk_analysis = {
|
|
944
|
+
"risk": cached.get("risk", "MEDIUM"),
|
|
945
|
+
"reason": cached.get("explanation", ""),
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
# Need at least a program or risk reason to build something useful
|
|
949
|
+
if not parsed.get("program") and not risk_analysis.get("reason"):
|
|
950
|
+
return existing
|
|
951
|
+
|
|
952
|
+
env_context = state.get("environment_context") or {}
|
|
953
|
+
chain_patterns = state.get("chain_patterns") or {}
|
|
954
|
+
return {
|
|
955
|
+
"what_happens": _build_what_happens(parsed, risk_analysis, env_context),
|
|
956
|
+
"scope": _build_scope(env_context, chain_patterns),
|
|
957
|
+
"reversible": _is_reversible(parsed, risk_analysis),
|
|
958
|
+
"affected_count": (env_context.get("blast_radius") or {}).get("estimated_users"),
|
|
959
|
+
"suggested_fix": None,
|
|
960
|
+
"severity": _map_risk_to_severity(risk_analysis.get("risk", "MEDIUM")),
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
_GENERIC_REMEDIATION = frozenset({
|
|
965
|
+
"Review the command carefully before executing.",
|
|
966
|
+
"Fix the syntax and try again.",
|
|
967
|
+
})
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def _is_generic_remediation(alternatives: list) -> bool:
|
|
971
|
+
if not alternatives:
|
|
972
|
+
return True
|
|
973
|
+
return all(alt in _GENERIC_REMEDIATION for alt in alternatives)
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def _detect_early_exit(state: AnalysisState) -> str:
|
|
977
|
+
sanitized = state.get("sanitized", {}) or {}
|
|
978
|
+
if sanitized.get("correction"):
|
|
979
|
+
return "sanitizer"
|
|
980
|
+
if not sanitized.get("syntax", {}).get("valid", True):
|
|
981
|
+
return "sanitizer"
|
|
982
|
+
rule_match = state.get("rule_match")
|
|
983
|
+
if rule_match and rule_match.get("matched") and rule_match.get("confidence", 0) >= 0.9:
|
|
984
|
+
return "rule"
|
|
985
|
+
cached = state.get("cached")
|
|
986
|
+
if cached:
|
|
987
|
+
return "cache"
|
|
988
|
+
return ""
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def _build_output(
|
|
992
|
+
risk="MEDIUM",
|
|
993
|
+
confidence=0.5,
|
|
994
|
+
message="",
|
|
995
|
+
explanation="",
|
|
996
|
+
alternatives=None,
|
|
997
|
+
context_used=None,
|
|
998
|
+
rule_id=None,
|
|
999
|
+
category=None,
|
|
1000
|
+
cached=False,
|
|
1001
|
+
rule_match=None,
|
|
1002
|
+
risk_analysis=None,
|
|
1003
|
+
stages=None,
|
|
1004
|
+
agent_latency=None,
|
|
1005
|
+
early_exit=None,
|
|
1006
|
+
supervisor_fallback=False,
|
|
1007
|
+
supervisor_warning=None,
|
|
1008
|
+
command_guide=None,
|
|
1009
|
+
failed_provider=None,
|
|
1010
|
+
failed_credential=None,
|
|
1011
|
+
consequence_preview=None,
|
|
1012
|
+
environment_context=None,
|
|
1013
|
+
chain_patterns=None,
|
|
1014
|
+
):
|
|
1015
|
+
result = {
|
|
1016
|
+
"risk": risk,
|
|
1017
|
+
"confidence": confidence,
|
|
1018
|
+
"message": message,
|
|
1019
|
+
"explanation": explanation,
|
|
1020
|
+
"alternatives": alternatives or [],
|
|
1021
|
+
"cached": cached,
|
|
1022
|
+
}
|
|
1023
|
+
if context_used:
|
|
1024
|
+
result["context_used"] = context_used
|
|
1025
|
+
if rule_id:
|
|
1026
|
+
result["rule_id"] = rule_id
|
|
1027
|
+
if category:
|
|
1028
|
+
result["category"] = category
|
|
1029
|
+
if early_exit:
|
|
1030
|
+
result["early_exit"] = early_exit
|
|
1031
|
+
if rule_match:
|
|
1032
|
+
result["rule_match"] = rule_match
|
|
1033
|
+
if risk_analysis:
|
|
1034
|
+
result["risk_analysis"] = risk_analysis
|
|
1035
|
+
if stages:
|
|
1036
|
+
result["latency"] = {k: round(v, 2) for k, v in stages.items()}
|
|
1037
|
+
result["total_ms"] = round(sum(stages.values()), 2)
|
|
1038
|
+
if agent_latency:
|
|
1039
|
+
result["agent_latency_ms"] = agent_latency
|
|
1040
|
+
if supervisor_fallback:
|
|
1041
|
+
result["supervisor_fallback"] = True
|
|
1042
|
+
if supervisor_warning:
|
|
1043
|
+
result["supervisor_warning"] = supervisor_warning
|
|
1044
|
+
if command_guide:
|
|
1045
|
+
result["command_guide"] = command_guide
|
|
1046
|
+
if failed_provider:
|
|
1047
|
+
result["failed_provider"] = failed_provider
|
|
1048
|
+
if failed_credential:
|
|
1049
|
+
result["failed_credential"] = failed_credential
|
|
1050
|
+
if consequence_preview:
|
|
1051
|
+
result["consequence_preview"] = consequence_preview
|
|
1052
|
+
if environment_context:
|
|
1053
|
+
result["environment_context"] = environment_context
|
|
1054
|
+
if chain_patterns:
|
|
1055
|
+
result["chain_patterns"] = chain_patterns
|
|
1056
|
+
return result
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
class AnalysisGraph:
|
|
1060
|
+
def __init__(self):
|
|
1061
|
+
self._compiled = None
|
|
1062
|
+
|
|
1063
|
+
def build(self):
|
|
1064
|
+
workflow = StateGraph(AnalysisState)
|
|
1065
|
+
|
|
1066
|
+
workflow.add_node("sanitize", sanitize_node)
|
|
1067
|
+
workflow.add_node("parse", parse_node)
|
|
1068
|
+
workflow.add_node("command_chain", command_chain_node)
|
|
1069
|
+
workflow.add_node("rules", rules_node)
|
|
1070
|
+
workflow.add_node("cache", cache_node)
|
|
1071
|
+
workflow.add_node("supervisor", supervisor_node)
|
|
1072
|
+
workflow.add_node("context_worker", context_worker_node)
|
|
1073
|
+
workflow.add_node("env_context", env_context_node)
|
|
1074
|
+
workflow.add_node("risk_analyzer", risk_analyzer_node)
|
|
1075
|
+
workflow.add_node("consequence_builder", consequence_builder_node)
|
|
1076
|
+
workflow.add_node("remediation", remediation_node)
|
|
1077
|
+
workflow.add_node("command_guide", command_guide_node)
|
|
1078
|
+
workflow.add_node("format", format_node)
|
|
1079
|
+
workflow.add_node("query", query_node) # NEW v3
|
|
1080
|
+
|
|
1081
|
+
workflow.set_entry_point("sanitize")
|
|
1082
|
+
|
|
1083
|
+
workflow.add_edge("sanitize", "parse")
|
|
1084
|
+
workflow.add_edge("parse", "command_chain")
|
|
1085
|
+
workflow.add_edge("command_chain", "rules")
|
|
1086
|
+
workflow.add_edge("rules", "cache")
|
|
1087
|
+
|
|
1088
|
+
workflow.add_conditional_edges(
|
|
1089
|
+
"cache",
|
|
1090
|
+
early_exit_router,
|
|
1091
|
+
{
|
|
1092
|
+
"early_exit_sanitizer": "format",
|
|
1093
|
+
"early_exit_rule": "format",
|
|
1094
|
+
"early_exit_cache": "format",
|
|
1095
|
+
"needs_context": "supervisor",
|
|
1096
|
+
},
|
|
1097
|
+
)
|
|
1098
|
+
|
|
1099
|
+
workflow.add_conditional_edges(
|
|
1100
|
+
"supervisor",
|
|
1101
|
+
supervisor_router,
|
|
1102
|
+
{
|
|
1103
|
+
"context_worker": "context_worker",
|
|
1104
|
+
# Autocomplete / skip path: still fetch EnvironmentAgent for badges
|
|
1105
|
+
"skip_context": "env_context",
|
|
1106
|
+
},
|
|
1107
|
+
)
|
|
1108
|
+
|
|
1109
|
+
workflow.add_edge("context_worker", "risk_analyzer")
|
|
1110
|
+
workflow.add_edge("env_context", "risk_analyzer")
|
|
1111
|
+
workflow.add_edge("risk_analyzer", "consequence_builder")
|
|
1112
|
+
workflow.add_edge("consequence_builder", "remediation")
|
|
1113
|
+
workflow.add_edge("remediation", "command_guide")
|
|
1114
|
+
workflow.add_edge("command_guide", "format")
|
|
1115
|
+
|
|
1116
|
+
# NEW v3: Conditional query node
|
|
1117
|
+
workflow.add_conditional_edges(
|
|
1118
|
+
"format",
|
|
1119
|
+
_query_router,
|
|
1120
|
+
{
|
|
1121
|
+
"query": "query",
|
|
1122
|
+
"end": END,
|
|
1123
|
+
},
|
|
1124
|
+
)
|
|
1125
|
+
workflow.add_edge("query", END)
|
|
1126
|
+
|
|
1127
|
+
self._compiled = workflow.compile(checkpointer=MemorySaver())
|
|
1128
|
+
return self._compiled
|
|
1129
|
+
|
|
1130
|
+
async def ainvoke(self, command: str, user_queries=None, overrides=None, skip_context=False) -> dict:
|
|
1131
|
+
if self._compiled is None:
|
|
1132
|
+
self.build()
|
|
1133
|
+
|
|
1134
|
+
initial_state: AnalysisState = {
|
|
1135
|
+
"command": command,
|
|
1136
|
+
"sanitized": None,
|
|
1137
|
+
"parsed": None,
|
|
1138
|
+
"rule_match": None,
|
|
1139
|
+
"cached": None,
|
|
1140
|
+
"needs_context": not skip_context, # NEW: Disable context if skip_context=True
|
|
1141
|
+
"watcher_context": None,
|
|
1142
|
+
"librarian_context": None,
|
|
1143
|
+
"merged_context": None,
|
|
1144
|
+
"risk_analysis": None,
|
|
1145
|
+
"remediation": None,
|
|
1146
|
+
"output": None,
|
|
1147
|
+
"errors": [],
|
|
1148
|
+
"stages": {},
|
|
1149
|
+
"agent_latency": {"watcher": 0, "librarian": 0},
|
|
1150
|
+
"early_exit": None,
|
|
1151
|
+
"supervisor_decision": None,
|
|
1152
|
+
"supervisor_fallback": skip_context, # NEW: Mark as fallback if skipping context
|
|
1153
|
+
"supervisor_warning": "Fast mode: context skipped" if skip_context else None,
|
|
1154
|
+
"command_guide": None,
|
|
1155
|
+
# NEW v3 fields
|
|
1156
|
+
"environment_context": None,
|
|
1157
|
+
"command_chain": [],
|
|
1158
|
+
"chain_patterns": {},
|
|
1159
|
+
"user_queries": user_queries or [],
|
|
1160
|
+
"query_responses": {},
|
|
1161
|
+
"overrides": overrides or {},
|
|
1162
|
+
"risk_factors": {},
|
|
1163
|
+
"consequence_preview": None,
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
try:
|
|
1167
|
+
result = await self._compiled.ainvoke(initial_state, {"configurable": {"thread_id": "main"}})
|
|
1168
|
+
output = result.get("output", {})
|
|
1169
|
+
if not output:
|
|
1170
|
+
output = _build_output(
|
|
1171
|
+
risk="MEDIUM",
|
|
1172
|
+
confidence=0.5,
|
|
1173
|
+
message="Analysis completed with no output",
|
|
1174
|
+
)
|
|
1175
|
+
# query_responses are set by query_node which runs after format_node;
|
|
1176
|
+
# merge them into output so callers receive them.
|
|
1177
|
+
query_responses = result.get("query_responses", {})
|
|
1178
|
+
if query_responses:
|
|
1179
|
+
output["query_responses"] = query_responses
|
|
1180
|
+
# consequence_preview is built before format; ensure callers always see it
|
|
1181
|
+
# even if format_node took an early-exit path that omitted it.
|
|
1182
|
+
consequence_preview = result.get("consequence_preview")
|
|
1183
|
+
if consequence_preview and "consequence_preview" not in output:
|
|
1184
|
+
output["consequence_preview"] = consequence_preview
|
|
1185
|
+
if result.get("environment_context") and "environment_context" not in output:
|
|
1186
|
+
output["environment_context"] = result["environment_context"]
|
|
1187
|
+
if result.get("chain_patterns") and "chain_patterns" not in output:
|
|
1188
|
+
output["chain_patterns"] = result["chain_patterns"]
|
|
1189
|
+
return output
|
|
1190
|
+
except Exception as e:
|
|
1191
|
+
logger.error(f"Analysis graph error: {e}", exc_info=True)
|
|
1192
|
+
return _build_output(
|
|
1193
|
+
risk="MEDIUM",
|
|
1194
|
+
confidence=0.5,
|
|
1195
|
+
message=f"Analysis error: {e}",
|
|
1196
|
+
explanation="An error occurred during analysis.",
|
|
1197
|
+
)
|
|
1198
|
+
|
|
1199
|
+
async def analyze_command(self, command: str, user_queries=None, overrides=None, skip_context=False) -> dict:
|
|
1200
|
+
"""
|
|
1201
|
+
Analyze command through LangGraph pipeline.
|
|
1202
|
+
|
|
1203
|
+
Args:
|
|
1204
|
+
command: The command to analyze
|
|
1205
|
+
user_queries: Optional list of user queries
|
|
1206
|
+
overrides: Optional environment overrides
|
|
1207
|
+
skip_context: If True, skip context gathering (for fast autocomplete)
|
|
1208
|
+
"""
|
|
1209
|
+
return await self.ainvoke(command, user_queries, overrides, skip_context)
|
|
1210
|
+
|
|
1211
|
+
async def gather_context(self, command: str) -> dict:
|
|
1212
|
+
if self._compiled is None:
|
|
1213
|
+
self.build()
|
|
1214
|
+
|
|
1215
|
+
initial_state: AnalysisState = {
|
|
1216
|
+
"command": command,
|
|
1217
|
+
"sanitized": None,
|
|
1218
|
+
"parsed": None,
|
|
1219
|
+
"rule_match": None,
|
|
1220
|
+
"cached": None,
|
|
1221
|
+
"needs_context": True,
|
|
1222
|
+
"watcher_context": None,
|
|
1223
|
+
"librarian_context": None,
|
|
1224
|
+
"merged_context": None,
|
|
1225
|
+
"risk_analysis": None,
|
|
1226
|
+
"remediation": None,
|
|
1227
|
+
"output": None,
|
|
1228
|
+
"errors": [],
|
|
1229
|
+
"stages": {},
|
|
1230
|
+
"agent_latency": {"watcher": 0, "librarian": 0},
|
|
1231
|
+
"early_exit": None,
|
|
1232
|
+
"supervisor_decision": None,
|
|
1233
|
+
"supervisor_fallback": False,
|
|
1234
|
+
"supervisor_warning": None,
|
|
1235
|
+
"command_guide": None,
|
|
1236
|
+
# NEW v3 fields
|
|
1237
|
+
"environment_context": None,
|
|
1238
|
+
"command_chain": [],
|
|
1239
|
+
"chain_patterns": {},
|
|
1240
|
+
"user_queries": [],
|
|
1241
|
+
"query_responses": {},
|
|
1242
|
+
"overrides": {},
|
|
1243
|
+
"risk_factors": {},
|
|
1244
|
+
"consequence_preview": None,
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
try:
|
|
1248
|
+
context_result = await context_worker_node(initial_state)
|
|
1249
|
+
return {
|
|
1250
|
+
"watcher": context_result.get("watcher_context", {}),
|
|
1251
|
+
"librarian": context_result.get("librarian_context", {"matches": []}),
|
|
1252
|
+
"partial": context_result.get("merged_context", {}).get("partial", False),
|
|
1253
|
+
"latency_ms": context_result.get("stages", {}).get("context", 0),
|
|
1254
|
+
"agent_latency_ms": context_result.get("agent_latency", {"watcher": 0, "librarian": 0}),
|
|
1255
|
+
}
|
|
1256
|
+
except Exception as e:
|
|
1257
|
+
logger.error(f"Context gather error: {e}")
|
|
1258
|
+
return {
|
|
1259
|
+
"watcher": {},
|
|
1260
|
+
"librarian": {"matches": []},
|
|
1261
|
+
"partial": True,
|
|
1262
|
+
"latency_ms": 0,
|
|
1263
|
+
"agent_latency_ms": {"watcher": 0, "librarian": 0},
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
|
|
1267
|
+
_analysis_graph: Optional[AnalysisGraph] = None
|
|
1268
|
+
|
|
1269
|
+
|
|
1270
|
+
def get_analysis_graph() -> AnalysisGraph:
|
|
1271
|
+
global _analysis_graph
|
|
1272
|
+
if _analysis_graph is None:
|
|
1273
|
+
_analysis_graph = AnalysisGraph()
|
|
1274
|
+
return _analysis_graph
|