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.
Files changed (106) hide show
  1. shellmate/__init__.py +0 -0
  2. shellmate/adapters/__init__.py +67 -0
  3. shellmate/adapters/bash_adapter.sh +7 -0
  4. shellmate/adapters/ble_adapter.sh +350 -0
  5. shellmate/adapters/powershell_adapter.ps1 +417 -0
  6. shellmate/adapters/powershell_predictor.ps1 +97 -0
  7. shellmate/adapters/zsh_adapter.zsh +8 -0
  8. shellmate/agents/__init__.py +23 -0
  9. shellmate/agents/base.py +248 -0
  10. shellmate/agents/chain_patterns.yaml +98 -0
  11. shellmate/agents/command_chain.py +322 -0
  12. shellmate/agents/context_gatherer.py +175 -0
  13. shellmate/agents/environment.py +363 -0
  14. shellmate/agents/librarian.py +502 -0
  15. shellmate/agents/query.py +415 -0
  16. shellmate/agents/repomix_backend.py +171 -0
  17. shellmate/agents/watcher.py +266 -0
  18. shellmate/cache/__init__.py +0 -0
  19. shellmate/cache/semantic_cache.py +41 -0
  20. shellmate/cli/__init__.py +0 -0
  21. shellmate/cli/commands/__init__.py +27 -0
  22. shellmate/cli/commands/analyze.py +5 -0
  23. shellmate/cli/commands/config.py +319 -0
  24. shellmate/cli/commands/configure.py +118 -0
  25. shellmate/cli/commands/disable.py +11 -0
  26. shellmate/cli/commands/enable.py +49 -0
  27. shellmate/cli/commands/init.py +412 -0
  28. shellmate/cli/commands/install.py +242 -0
  29. shellmate/cli/commands/migrate.py +147 -0
  30. shellmate/cli/commands/status.py +31 -0
  31. shellmate/cli/commands/uninstall.py +78 -0
  32. shellmate/cli/main.py +114 -0
  33. shellmate/cli/parser.py +184 -0
  34. shellmate/cli/version.py +1 -0
  35. shellmate/completion/__init__.py +4 -0
  36. shellmate/completion/bash_adapter.py +145 -0
  37. shellmate/config/__init__.py +33 -0
  38. shellmate/config/credentials.py +120 -0
  39. shellmate/config/defaults.py +88 -0
  40. shellmate/config/manager.py +94 -0
  41. shellmate/config/paths.py +77 -0
  42. shellmate/config/secrets.py +66 -0
  43. shellmate/daemon/__init__.py +0 -0
  44. shellmate/daemon/lifecycle.py +308 -0
  45. shellmate/daemon/observability.py +82 -0
  46. shellmate/daemon/pipeline.py +119 -0
  47. shellmate/daemon/server.py +605 -0
  48. shellmate/daemon/session.py +170 -0
  49. shellmate/daemon/startup_ui.py +43 -0
  50. shellmate/environment/__init__.py +0 -0
  51. shellmate/environment/detector.py +130 -0
  52. shellmate/ipc/__init__.py +0 -0
  53. shellmate/ipc/client.py +210 -0
  54. shellmate/ipc/ps_client.py +190 -0
  55. shellmate/orchestration/__init__.py +3 -0
  56. shellmate/orchestration/analysis_graph.py +1274 -0
  57. shellmate/orchestration/state.py +35 -0
  58. shellmate/parser/__init__.py +0 -0
  59. shellmate/parser/command_parser.py +111 -0
  60. shellmate/plugins/__init__.py +0 -0
  61. shellmate/plugins/base.py +80 -0
  62. shellmate/plugins/loader.py +41 -0
  63. shellmate/providers/__init__.py +0 -0
  64. shellmate/providers/base.py +97 -0
  65. shellmate/providers/groq_command_guide.py +108 -0
  66. shellmate/providers/groq_provider.py +127 -0
  67. shellmate/providers/groq_supervisor.py +78 -0
  68. shellmate/providers/openrouter_provider.py +149 -0
  69. shellmate/providers/router.py +272 -0
  70. shellmate/providers/validate_keys.py +98 -0
  71. shellmate/providers/xai_provider.py +142 -0
  72. shellmate/rules/__init__.py +0 -0
  73. shellmate/rules/catastrophic.yaml +96 -0
  74. shellmate/rules/catastrophic_matcher.py +167 -0
  75. shellmate/rules/definitions/__init__.py +0 -0
  76. shellmate/rules/definitions/file_deletion.yaml +54 -0
  77. shellmate/rules/definitions/filesystem.yaml +43 -0
  78. shellmate/rules/definitions/network_execution.yaml +46 -0
  79. shellmate/rules/definitions/permissions.yaml +54 -0
  80. shellmate/rules/definitions/process_control.yaml +37 -0
  81. shellmate/rules/definitions/system_destruction.yaml +67 -0
  82. shellmate/rules/engine.py +200 -0
  83. shellmate/sanitizer/__init__.py +0 -0
  84. shellmate/sanitizer/typo_detector.py +114 -0
  85. shellmate/ui/__init__.py +10 -0
  86. shellmate/ui/analysis.py +164 -0
  87. shellmate/ui/annotations.py +232 -0
  88. shellmate/ui/banner.py +49 -0
  89. shellmate/ui/progress.py +126 -0
  90. shellmate/ui/runtime/__init__.py +15 -0
  91. shellmate/ui/runtime/console_factory.py +65 -0
  92. shellmate/ui/runtime/live_scene.py +116 -0
  93. shellmate/ui/runtime/motion_policy.py +69 -0
  94. shellmate/ui/runtime/tokens.py +47 -0
  95. shellmate/ui/scenes/__init__.py +24 -0
  96. shellmate/ui/scenes/enable.py +316 -0
  97. shellmate/ui/scenes/help.py +53 -0
  98. shellmate/ui/scenes/stages.py +128 -0
  99. shellmate/ui/scenes/status.py +181 -0
  100. shellmate/ui/status.py +3 -0
  101. shellmate_cli-3.0.0.dist-info/METADATA +352 -0
  102. shellmate_cli-3.0.0.dist-info/RECORD +106 -0
  103. shellmate_cli-3.0.0.dist-info/WHEEL +5 -0
  104. shellmate_cli-3.0.0.dist-info/entry_points.txt +3 -0
  105. shellmate_cli-3.0.0.dist-info/licenses/LICENSE +21 -0
  106. 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