deepparallel 0.5.7__tar.gz → 0.7.0__tar.gz

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 (100) hide show
  1. {deepparallel-0.5.7 → deepparallel-0.7.0}/PKG-INFO +2 -1
  2. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/__init__.py +1 -1
  3. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/agent.py +19 -5
  4. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/branding.py +80 -0
  5. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/cli.py +240 -24
  6. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/licensing.py +89 -24
  7. deepparallel-0.7.0/deepparallel/mesh.py +165 -0
  8. deepparallel-0.7.0/deepparallel/research/compound_discovery.py +321 -0
  9. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/serve.py +150 -2
  10. deepparallel-0.7.0/deepparallel/session.py +132 -0
  11. deepparallel-0.7.0/deepparallel/tui/__init__.py +7 -0
  12. deepparallel-0.7.0/deepparallel/tui/app.py +156 -0
  13. deepparallel-0.7.0/deepparallel/tui/renderer.py +136 -0
  14. deepparallel-0.7.0/deepparallel/tui/widgets/animations.py +171 -0
  15. deepparallel-0.7.0/deepparallel/tui/widgets/confirm.py +85 -0
  16. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel.egg-info/PKG-INFO +2 -1
  17. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel.egg-info/SOURCES.txt +11 -0
  18. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel.egg-info/requires.txt +1 -0
  19. {deepparallel-0.5.7 → deepparallel-0.7.0}/pyproject.toml +2 -1
  20. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_cli.py +5 -0
  21. deepparallel-0.7.0/tests/test_cli_licensing.py +297 -0
  22. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_crowe_gateway_backend.py +0 -2
  23. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_crowe_payment_required.py +0 -1
  24. deepparallel-0.7.0/tests/test_licensing_files.py +128 -0
  25. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_serve.py +22 -16
  26. deepparallel-0.7.0/tests/test_serve_session.py +40 -0
  27. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_tools_mcp.py +1 -1
  28. {deepparallel-0.5.7 → deepparallel-0.7.0}/README.md +0 -0
  29. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/backend.py +0 -0
  30. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/cockpit.py +0 -0
  31. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/cockpit_observe.py +0 -0
  32. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/cockpit_panel.py +0 -0
  33. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/cockpit_sim.py +0 -0
  34. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/config.py +0 -0
  35. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/crowe_id.py +0 -0
  36. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/dsml.py +0 -0
  37. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/fusion.py +0 -0
  38. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/memory.py +0 -0
  39. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/registry.json +0 -0
  40. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/renderer.py +0 -0
  41. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/research/__init__.py +0 -0
  42. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/research/conduit.py +0 -0
  43. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/research/provider.py +0 -0
  44. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/routing.example.json +0 -0
  45. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/routing.py +0 -0
  46. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/supply_chain.py +0 -0
  47. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/system_prompt.txt +0 -0
  48. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/tools/__init__.py +0 -0
  49. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/tools/codeast.py +0 -0
  50. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/tools/edit.py +0 -0
  51. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/tools/files.py +0 -0
  52. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/tools/git_ops.py +0 -0
  53. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/tools/mcp.py +0 -0
  54. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/tools/memory.py +0 -0
  55. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/tools/registry.py +0 -0
  56. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/tools/sandbox.py +0 -0
  57. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/tools/search.py +0 -0
  58. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/tools/shell.py +0 -0
  59. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/tools/vision.py +0 -0
  60. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/tools/web.py +0 -0
  61. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel/userinput.py +0 -0
  62. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel.egg-info/dependency_links.txt +0 -0
  63. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel.egg-info/entry_points.txt +0 -0
  64. {deepparallel-0.5.7 → deepparallel-0.7.0}/deepparallel.egg-info/top_level.txt +0 -0
  65. {deepparallel-0.5.7 → deepparallel-0.7.0}/setup.cfg +0 -0
  66. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_agent.py +0 -0
  67. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_backend.py +0 -0
  68. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_backend_chat.py +0 -0
  69. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_backend_stream.py +0 -0
  70. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_branding.py +0 -0
  71. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_cockpit.py +0 -0
  72. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_cockpit_panel.py +0 -0
  73. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_cockpit_sim.py +0 -0
  74. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_config.py +0 -0
  75. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_config_file.py +0 -0
  76. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_crowe_backend.py +0 -0
  77. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_crowe_id_auth.py +0 -0
  78. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_dsml.py +0 -0
  79. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_fusion.py +0 -0
  80. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_git_ops.py +0 -0
  81. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_issuer_signer.py +0 -0
  82. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_licensing.py +0 -0
  83. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_memory.py +0 -0
  84. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_renderer.py +0 -0
  85. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_research.py +0 -0
  86. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_research_provider.py +0 -0
  87. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_routing.py +0 -0
  88. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_spinner_color.py +0 -0
  89. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_supply_chain.py +0 -0
  90. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_tool_registry.py +0 -0
  91. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_tools_codeast.py +0 -0
  92. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_tools_edit.py +0 -0
  93. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_tools_files.py +0 -0
  94. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_tools_sandbox.py +0 -0
  95. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_tools_search.py +0 -0
  96. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_tools_shell.py +0 -0
  97. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_tools_vision.py +0 -0
  98. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_tools_web.py +0 -0
  99. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_userinput.py +0 -0
  100. {deepparallel-0.5.7 → deepparallel-0.7.0}/tests/test_userinput_paste.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepparallel
3
- Version: 0.5.7
3
+ Version: 0.7.0
4
4
  Summary: DeepParallel - a multi-model agentic coding CLI with cross-model Guardian review, served via Crowe Logic.
5
5
  Author-email: Michael Crowe <michael@crowelogic.com>
6
6
  License: Apache-2.0
@@ -16,6 +16,7 @@ Requires-Dist: python-dotenv>=1.0.0
16
16
  Requires-Dist: tree-sitter>=0.25.0
17
17
  Requires-Dist: tree-sitter-language-pack>=1.8.0
18
18
  Requires-Dist: cryptography>=42.0.0
19
+ Requires-Dist: textual>=0.52.0
19
20
  Provides-Extra: dev
20
21
  Requires-Dist: pytest>=8.0.0; extra == "dev"
21
22
  Requires-Dist: ruff>=0.6.0; extra == "dev"
@@ -1,3 +1,3 @@
1
1
  """DeepParallel CLI package."""
2
2
 
3
- __version__ = "0.5.7"
3
+ __version__ = "0.7.0"
@@ -255,16 +255,29 @@ def _supply_chain_note(name: str, args: dict) -> str | None:
255
255
  return None
256
256
 
257
257
 
258
- def _approved(name, args, interactive, auto_approve, renderer, guardian=None) -> bool:
258
+ def _approved(name, args, interactive, auto_approve, renderer, guardian=None, mesh_fn=None) -> bool:
259
259
  forced = name in _GATED_PATH_TOOLS and _outside_cwd(args)
260
260
  sc_note = _supply_chain_note(name, args) if name in _EDIT_TOOLS else None
261
- # A hallucinated dependency overrides auto-approve: always surface it.
262
- if auto_approve and not forced and not sc_note:
261
+ # The verification mesh reviews every edit. A BUG verdict blocks the action
262
+ # and overrides auto-approve, exactly like a hallucinated dependency does.
263
+ mesh_result = None
264
+ if mesh_fn is not None and name in _EDIT_TOOLS:
265
+ try:
266
+ mesh_result = mesh_fn(_guardian_review_content(name, args))
267
+ except Exception: # noqa: BLE001 - the gate is best-effort, never fatal
268
+ mesh_result = None
269
+ blocked = bool(mesh_result and mesh_result.get("severity") == "bug")
270
+ # A hallucinated dependency or a BUG verdict overrides auto-approve.
271
+ if auto_approve and not forced and not sc_note and not blocked:
263
272
  return True
264
273
  if not interactive:
265
274
  return False
266
275
  title, detail = _describe(name, args)
267
- if guardian is not None and name in _EDIT_TOOLS:
276
+ if mesh_result is not None:
277
+ from deepparallel.mesh import format_panel
278
+
279
+ detail = f"{detail}\n\nVerification mesh:\n{format_panel(mesh_result)}"
280
+ elif guardian is not None and name in _EDIT_TOOLS:
268
281
  verdict = _guardian_verdict(guardian, name, args)
269
282
  if verdict:
270
283
  detail = f"{detail}\n\nGuardian: {verdict}"
@@ -307,6 +320,7 @@ def run_agent(
307
320
  max_steps: int | None = None,
308
321
  stream: bool = False,
309
322
  guardian=None,
323
+ mesh_fn=None,
310
324
  on_event=None,
311
325
  ) -> str:
312
326
  steps = max_steps if max_steps is not None else settings.max_steps
@@ -346,7 +360,7 @@ def run_agent(
346
360
  elif "__parse_error__" in args:
347
361
  result = json.dumps({"error": "invalid JSON arguments"})
348
362
  elif meta.dangerous and not _approved(
349
- name, args, interactive, auto_approve, renderer, guardian
363
+ name, args, interactive, auto_approve, renderer, guardian, mesh_fn
350
364
  ):
351
365
  result = json.dumps({"error": "denied by user"})
352
366
  else:
@@ -173,6 +173,86 @@ def info(msg: str) -> None:
173
173
  console.print(f"[{DIM}]{msg}[/]")
174
174
 
175
175
 
176
+ def upgrade_card(feature: str, current_tier: str, needed_tier: str, unlocks: str, url: str) -> None:
177
+ body = Text()
178
+ body.append(f"{feature} ", style=f"bold {DP_ACCENT}")
179
+ body.append("is a ", style="white")
180
+ body.append(needed_tier, style=f"bold {CROWE_ACCENT}")
181
+ body.append(" feature. You are on ", style="white")
182
+ body.append(f"{current_tier}", style=AMBER_HEX)
183
+ body.append(".\n\n", style="white")
184
+ body.append("Unlocks ", style=DIM)
185
+ body.append(f"{unlocks}\n\n", style="white")
186
+ body.append(f"{ARROW} ", style=CROWE_ACCENT)
187
+ body.append("dp upgrade", style=f"bold {CROWE_ACCENT}")
188
+ body.append(f" {url}", style=DIM)
189
+ console.print(
190
+ _gutter(
191
+ Panel(
192
+ body,
193
+ title=Text(f"{MARK} Upgrade to {needed_tier}", style=DP_ACCENT),
194
+ title_align="left",
195
+ border_style=AMBER_HEX,
196
+ box=box.ROUNDED,
197
+ )
198
+ )
199
+ )
200
+
201
+
202
+ def license_panel(
203
+ title: str,
204
+ header_lines: list[tuple[str, str]],
205
+ rows: list[tuple[bool, str]],
206
+ footer_lines: list[str] | None = None,
207
+ ) -> None:
208
+ body = Text()
209
+ for label, value in header_lines:
210
+ body.append(f"{label:<9}", style=DIM)
211
+ body.append(f"{value}\n", style="white")
212
+ if rows:
213
+ body.append("\n")
214
+ for unlocked, desc in rows:
215
+ if unlocked:
216
+ body.append(f"{CHECK} ", style=GREEN_HEX)
217
+ body.append(f"{desc}\n", style="white")
218
+ else:
219
+ body.append(f"{CROSS} ", style=RED_HEX)
220
+ body.append(f"{desc}\n", style=DIM)
221
+ if footer_lines:
222
+ body.append("\n")
223
+ for line in footer_lines:
224
+ body.append(f"{line}\n", style=DIM)
225
+ console.print(
226
+ _gutter(
227
+ Panel(
228
+ body,
229
+ title=Text(f"{MARK} {title}", style=DP_ACCENT),
230
+ title_align="left",
231
+ border_style=DP_ACCENT,
232
+ box=box.ROUNDED,
233
+ )
234
+ )
235
+ )
236
+
237
+
238
+ def first_run_banner() -> None:
239
+ body = Text()
240
+ body.append("You're on ", style=DIM)
241
+ body.append("Free", style=f"bold {AMBER_HEX}")
242
+ body.append(". Unlock Guardian, fusion, and the ", style=DIM)
243
+ body.append("--deep", style="white")
244
+ body.append(" council: ", style=DIM)
245
+ body.append("dp upgrade", style=f"bold {CROWE_ACCENT}")
246
+ console.print(_gutter(Panel(body, border_style=DP_ACCENT, box=box.ROUNDED)))
247
+
248
+
249
+ def upgrade_link(url: str, opened: bool) -> None:
250
+ if opened:
251
+ console.print(f"[{DIM}]Opening your browser to DeepParallel pricing:[/] {url}")
252
+ else:
253
+ console.print(f"[{DIM}]Upgrade at[/] {url}")
254
+
255
+
176
256
  def error(msg: str) -> None:
177
257
  console.print(f"[bold red]error[/] {msg}")
178
258
 
@@ -58,6 +58,27 @@ from deepparallel.cockpit import Cockpit
58
58
  from deepparallel.cockpit_observe import make_observer
59
59
 
60
60
 
61
+ _soft_cards_shown: set[str] = set()
62
+
63
+
64
+ def require_feature(feature: str, *, hard: bool, exit_code: int = 3) -> bool:
65
+ have = licensing.resolve_tier()
66
+ if licensing.check_feature(feature, have)[0]:
67
+ return True
68
+ if hard or feature not in _soft_cards_shown:
69
+ branding.upgrade_card(
70
+ feature,
71
+ have.label,
72
+ licensing.feature_tier(feature).label,
73
+ licensing.feature_unlocks(feature),
74
+ licensing.upgrade_url(),
75
+ )
76
+ if hard:
77
+ sys.exit(exit_code)
78
+ _soft_cards_shown.add(feature)
79
+ return False
80
+
81
+
61
82
  def _build_messages(history: list[tuple[str, str]], system: str, user_msg: str) -> list[dict]:
62
83
  msgs: list[dict] = [{"role": "system", "content": system}]
63
84
  for role, content in history:
@@ -82,9 +103,7 @@ def _effective_backend(base: Backend, settings: Settings, mode: str) -> Backend:
82
103
  """
83
104
  if mode not in ("reason", "escalate"):
84
105
  return base
85
- ok, msg = licensing.check_feature("fusion")
86
- if not ok:
87
- branding.info(msg)
106
+ if not require_feature("fusion", hard=False):
88
107
  return base
89
108
  reasoner = backend_for_deployment(settings, settings.reasoner_deployment)
90
109
  if mode == "reason":
@@ -100,10 +119,7 @@ def _wrap_fusion(backend: Backend, settings: Settings) -> Backend:
100
119
  def _run_deep(settings: Settings, messages: list[dict]) -> None:
101
120
  """Heavy multi-model fan-out + judge, one-shot. Chains are shown with
102
121
  generic labels (no raw model names) per the brand rule. Paid (Pro+)."""
103
- ok, msg = licensing.check_feature("deep")
104
- if not ok:
105
- branding.error(msg)
106
- sys.exit(2)
122
+ require_feature("deep", hard=True, exit_code=2)
107
123
  chains = [
108
124
  (f"candidate-{i + 1}", backend_for_deployment(settings, dep))
109
125
  for i, dep in enumerate(settings.parallel_deployments)
@@ -135,10 +151,7 @@ def _run_deep(settings: Settings, messages: list[dict]) -> None:
135
151
 
136
152
 
137
153
  def _run_dual(settings: Settings, messages: list[dict], dual: str, synth: bool) -> None:
138
- ok, msg = licensing.check_feature("dual")
139
- if not ok:
140
- branding.error(msg)
141
- sys.exit(2)
154
+ require_feature("dual", hard=True, exit_code=2)
142
155
  names = [s.strip() for s in dual.split(",") if s.strip()] if dual else []
143
156
  if len(names) >= 2:
144
157
  la, ra, lname, rname = names[0], names[1], names[0], names[1]
@@ -170,13 +183,26 @@ def _build_guardian(settings: Settings) -> Backend | None:
170
183
  """
171
184
  if not settings.guardian_enabled:
172
185
  return None
173
- ok, msg = licensing.check_feature("guardian")
174
- if not ok:
175
- branding.info(msg)
186
+ if not require_feature("guardian", hard=False):
176
187
  return None
177
188
  return backend_for_deployment(settings, settings.guardian_deployment)
178
189
 
179
190
 
191
+ def _build_mesh(settings: Settings):
192
+ """The verification mesh: a parallel reviewer panel that gates every edit.
193
+
194
+ Supersedes the single guardian when available. Paid (Pro+) feature; returns
195
+ a callable ``mesh_fn(content) -> result`` or None on Free / when disabled.
196
+ """
197
+ if not settings.guardian_enabled:
198
+ return None
199
+ if not licensing.check_feature("mesh")[0]:
200
+ return None
201
+ from deepparallel import mesh
202
+
203
+ return lambda content: mesh.mesh_review(settings, content)
204
+
205
+
180
206
  def _system_prompt() -> str:
181
207
  """System prompt with the long-term memory index injected (recall tier 1)."""
182
208
  return load_system_prompt() + memory.index_block()
@@ -293,7 +319,8 @@ def _agent_repl(backend: Backend, settings: Settings, renderer: Renderer) -> Non
293
319
  """Interactive agentic loop: tools enabled, conversation persists. The dial
294
320
  (/fast //fuse //escalate //deep) swaps the active fusion mode live."""
295
321
  registry = get_registry()
296
- guardian = _build_guardian(settings)
322
+ mesh_fn = _build_mesh(settings)
323
+ guardian = None if mesh_fn else _build_guardian(settings)
297
324
  system = _system_prompt()
298
325
  messages: list[dict] = [{"role": "system", "content": system}]
299
326
  mode = settings.fusion_mode if settings.fusion_mode in ("reason", "escalate") else "off"
@@ -385,6 +412,7 @@ def _agent_repl(backend: Backend, settings: Settings, renderer: Renderer) -> Non
385
412
  auto_approve=auto,
386
413
  stream=True,
387
414
  guardian=guardian,
415
+ mesh_fn=mesh_fn,
388
416
  on_event=observer if cockpit_on else None,
389
417
  )
390
418
  if mode in ("reason", "escalate"):
@@ -421,6 +449,33 @@ def _chat_loop(settings: Settings) -> None:
421
449
  _stream_repl(_wrap_fusion(backend, settings), settings)
422
450
 
423
451
 
452
+ _LICENSE_COMMANDS = {"activate", "deactivate", "upgrade", "account", "whoami"}
453
+
454
+
455
+ def _is_interactive() -> bool:
456
+ return sys.stdout.isatty() and not _bool_env("DEEPPARALLEL_PLAIN", False)
457
+
458
+
459
+ def _first_run_marker() -> Path:
460
+ return licensing.license_file_path().parent / ".first_run_shown"
461
+
462
+
463
+ def _maybe_first_run_nudge() -> None:
464
+ if not _is_interactive():
465
+ return
466
+ if licensing.resolve_tier() is not licensing.Tier.FREE:
467
+ return
468
+ marker = _first_run_marker()
469
+ if marker.exists():
470
+ return
471
+ try:
472
+ marker.parent.mkdir(parents=True, exist_ok=True)
473
+ marker.write_text("shown\n")
474
+ except OSError:
475
+ pass
476
+ branding.first_run_banner()
477
+
478
+
424
479
  @click.group(
425
480
  invoke_without_command=True,
426
481
  context_settings={"help_option_names": ["-h", "--help"]},
@@ -441,6 +496,8 @@ def main(ctx: click.Context, temperature: float | None, assume_yes: bool) -> Non
441
496
  if assume_yes:
442
497
  settings = replace(settings, auto_approve=True)
443
498
  ctx.obj["settings"] = settings
499
+ if ctx.invoked_subcommand not in _LICENSE_COMMANDS:
500
+ _maybe_first_run_nudge()
444
501
  if ctx.invoked_subcommand is None:
445
502
  _chat_loop(settings)
446
503
 
@@ -463,6 +520,14 @@ def chat(ctx: click.Context, no_tools: bool, assume_yes: bool, fuse: str | None)
463
520
  _chat_loop(settings)
464
521
 
465
522
 
523
+ @main.command()
524
+ def tui() -> None:
525
+ """Launch the DeepParallel TUI (persistent multi-panel terminal interface)."""
526
+ from deepparallel.tui.app import run_tui
527
+
528
+ run_tui()
529
+
530
+
466
531
  @main.command()
467
532
  @click.option("--no-tools", is_flag=True, help="Disable tools (plain chat).")
468
533
  @click.option("--yes", "-y", "assume_yes", is_flag=True, help="Auto-approve tool actions.")
@@ -518,6 +583,7 @@ def run(
518
583
  renderer,
519
584
  interactive=False,
520
585
  auto_approve=settings.auto_approve,
586
+ mesh_fn=_build_mesh(settings),
521
587
  )
522
588
  except Exception as e: # noqa: BLE001 - surface as friendly message
523
589
  branding.error(_translate_error(e))
@@ -598,10 +664,7 @@ def review(ctx: click.Context, as_diff: bool, path: str | None) -> None:
598
664
  """
599
665
  settings: Settings = ctx.obj["settings"]
600
666
  if not settings.byok:
601
- ok, msg = licensing.check_feature("review")
602
- if not ok:
603
- branding.error(msg)
604
- sys.exit(3)
667
+ require_feature("review", hard=True, exit_code=3)
605
668
  if as_diff:
606
669
  content = sys.stdin.read()
607
670
  elif path:
@@ -614,10 +677,25 @@ def review(ctx: click.Context, as_diff: bool, path: str | None) -> None:
614
677
  branding.error("provide a PATH or --diff (with a diff on stdin)")
615
678
  sys.exit(3)
616
679
  _require_ready(settings) # validates creds / exits if missing
680
+ glyphs = {"safe": branding.CHECK, "risky": "!", "bug": branding.CROSS}
681
+ mesh_ok, _ = licensing.check_feature("mesh")
682
+ if mesh_ok and settings.guardian_enabled:
683
+ # Pro: the full verification mesh, one independent reviewer per lens.
684
+ from deepparallel import mesh as _mesh
685
+
686
+ result = _mesh.mesh_review(settings, content)
687
+ for r in result["lenses"]:
688
+ g = glyphs.get(r["severity"], "?")
689
+ console.print(f" {g} [dim]{r['label']}[/] {r['verdict'] or '(no verdict)'}")
690
+ verdict = result["verdict"]
691
+ glyph = glyphs.get(result["severity"], "?")
692
+ console.print(f"[bold]{glyph} {result['severity'].upper()}[/] {verdict or '(no verdict)'}")
693
+ sys.exit(verdict_exit_code(verdict))
694
+ # Free / BYOK: a single independent reviewer.
617
695
  guardian = backend_for_deployment(settings, settings.guardian_deployment)
618
696
  verdict = guardian_review(guardian, content[:8000])
619
697
  severity = verdict_severity(verdict)
620
- glyph = {"safe": branding.CHECK, "risky": "!", "bug": branding.CROSS}.get(severity, "?")
698
+ glyph = glyphs.get(severity, "?")
621
699
  console.print(f"[bold]{glyph} {severity.upper()}[/] {verdict or '(no verdict)'}")
622
700
  sys.exit(verdict_exit_code(verdict))
623
701
 
@@ -648,10 +726,7 @@ def audit(ctx: click.Context, path: str) -> None:
648
726
  seen) in source files and manifests. Exit code: 0 clean, 2 if a likely
649
727
  hallucinated dependency is found - so it can gate a commit or PR. Paid (Pro+).
650
728
  """
651
- ok, msg = licensing.check_feature("audit")
652
- if not ok:
653
- branding.error(msg)
654
- sys.exit(3)
729
+ require_feature("audit", hard=True, exit_code=3)
655
730
  try:
656
731
  content = Path(path).expanduser().read_text(encoding="utf-8")
657
732
  except OSError as e:
@@ -726,6 +801,47 @@ def research_conduit() -> None:
726
801
  )
727
802
 
728
803
 
804
+ @research.command("compound-discovery")
805
+ @click.option("--target", default="EGFR", help="Biological target (gene symbol).")
806
+ @click.option(
807
+ "--compounds",
808
+ default="erlotinib,gefitinib,osimertinib",
809
+ help="Comma-separated compound names.",
810
+ )
811
+ @click.option("--dry-run", is_flag=True, help="Print the MCP tool call manifest.")
812
+ @click.option("--output", default=None, help="Output report path (Markdown + JSON).")
813
+ def research_compound_discovery(
814
+ target: str, compounds: str, dry_run: bool, output: str | None
815
+ ) -> None:
816
+ """PubChem compound discovery workflow across 10 stages.
817
+
818
+ Generates a full intelligence report: target assays, compound profiling,
819
+ bioactivity, safety, interactions, 3D structure, cross-references, and
820
+ analog discovery. Uses PubChem MCP tools when available; --dry-run prints
821
+ the tool call manifest without executing.
822
+ """
823
+ from deepparallel.research.compound_discovery import (
824
+ generate_markdown_report,
825
+ run_dry_run,
826
+ run_workflow,
827
+ )
828
+
829
+ compound_list = [c.strip() for c in compounds.split(",")]
830
+ if dry_run:
831
+ console.print(run_dry_run(target, compound_list))
832
+ return
833
+ result = run_workflow(target, compound_list)
834
+ report = generate_markdown_report(result)
835
+ print(report)
836
+ if output:
837
+ path = Path(output)
838
+ path.write_text(report)
839
+ json_path = path.with_suffix(".json")
840
+ json_path.write_text(result.to_json())
841
+ branding.info(f"Report written to {path}")
842
+ branding.info(f"JSON data written to {json_path}")
843
+
844
+
729
845
  @main.command(name="tools")
730
846
  def tools_cmd() -> None:
731
847
  """List the agent tools available to DeepParallel."""
@@ -787,5 +903,105 @@ def doctor(ctx: click.Context) -> None:
787
903
  sys.exit(1)
788
904
 
789
905
 
906
+ def _format_exp(payload: dict) -> str:
907
+ exp = int(payload.get("exp") or 0)
908
+ if not exp:
909
+ return "no expiry"
910
+ import datetime
911
+
912
+ return datetime.datetime.fromtimestamp(exp).strftime("%Y-%m-%d")
913
+
914
+
915
+ def _show_account(settings: Settings) -> None:
916
+ source, payload = licensing.active_license()
917
+ tier = licensing.resolve_tier()
918
+ if source == "env":
919
+ src_label = "DEEPPARALLEL_LICENSE env var"
920
+ elif source == "file":
921
+ src_label = str(licensing.license_file_path())
922
+ else:
923
+ src_label = "none"
924
+ if source != "none" and payload is None:
925
+ src_label += " (invalid or expired)"
926
+ header = [("Tier", tier.label), ("Source", src_label)]
927
+ if payload and payload.get("email"):
928
+ header.append(("Account", str(payload["email"])))
929
+ if payload:
930
+ header.append(("Expires", _format_exp(payload)))
931
+ rows = [(unlocked, desc) for _f, desc, _need, unlocked in licensing.features_matrix(tier)]
932
+ footer = None
933
+ if tier is licensing.Tier.FREE:
934
+ footer = ["Unlock Guardian, fusion, and the --deep council: dp upgrade"]
935
+ branding.license_panel("DeepParallel account", header, rows, footer)
936
+
937
+
938
+ @main.command()
939
+ @click.argument("key", required=True)
940
+ @click.pass_context
941
+ def activate(ctx: click.Context, key: str) -> None:
942
+ payload = licensing.verify_token(key.strip())
943
+ if not payload:
944
+ branding.error(
945
+ "that license key is invalid or expired. Copy it exactly from your "
946
+ "purchase email, or run dp upgrade to buy one."
947
+ )
948
+ sys.exit(2)
949
+ tier = licensing.tier_from_payload(payload)
950
+ try:
951
+ path = licensing.write_license(key)
952
+ except OSError as e:
953
+ branding.error(f"could not write the license file: {e}")
954
+ sys.exit(1)
955
+ header = [("Tier", tier.label)]
956
+ if payload.get("email"):
957
+ header.append(("Account", str(payload["email"])))
958
+ header.append(("Expires", _format_exp(payload)))
959
+ header.append(("License", str(path)))
960
+ rows = [(unlocked, desc) for _f, desc, _need, unlocked in licensing.features_matrix(tier)]
961
+ branding.license_panel("License activated", header, rows)
962
+
963
+
964
+ @main.command()
965
+ @click.pass_context
966
+ def account(ctx: click.Context) -> None:
967
+ _show_account(ctx.obj["settings"])
968
+
969
+
970
+ @main.command()
971
+ @click.pass_context
972
+ def whoami(ctx: click.Context) -> None:
973
+ _show_account(ctx.obj["settings"])
974
+
975
+
976
+ @main.command()
977
+ @click.option("--yes", "-y", "assume_yes", is_flag=True, help="Skip the confirmation prompt.")
978
+ @click.pass_context
979
+ def deactivate(ctx: click.Context, assume_yes: bool) -> None:
980
+ path = licensing.license_file_path()
981
+ if not path.exists():
982
+ branding.info("no stored license; already on Free.")
983
+ return
984
+ if not assume_yes and not click.confirm(f"Remove the license at {path} and revert to Free?"):
985
+ branding.info("kept the current license.")
986
+ return
987
+ if licensing.remove_license():
988
+ branding.info("license removed; you are now on Free.")
989
+ else:
990
+ branding.error("could not remove the license file.")
991
+ sys.exit(1)
992
+
993
+
994
+ @main.command()
995
+ def upgrade() -> None:
996
+ import webbrowser
997
+
998
+ url = licensing.upgrade_url()
999
+ try:
1000
+ opened = bool(webbrowser.open(url))
1001
+ except Exception:
1002
+ opened = False
1003
+ branding.upgrade_link(url, opened)
1004
+
1005
+
790
1006
  if __name__ == "__main__":
791
1007
  main()