deepparallel 0.5.2__tar.gz → 0.5.4__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 (85) hide show
  1. {deepparallel-0.5.2 → deepparallel-0.5.4}/PKG-INFO +1 -1
  2. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/__init__.py +1 -1
  3. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/agent.py +6 -0
  4. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/cli.py +72 -0
  5. deepparallel-0.5.4/deepparallel/cockpit.py +143 -0
  6. deepparallel-0.5.4/deepparallel/cockpit_observe.py +58 -0
  7. deepparallel-0.5.4/deepparallel/cockpit_panel.py +320 -0
  8. deepparallel-0.5.4/deepparallel/cockpit_sim.py +258 -0
  9. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/system_prompt.txt +4 -0
  10. deepparallel-0.5.4/deepparallel/tools/web.py +187 -0
  11. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel.egg-info/PKG-INFO +1 -1
  12. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel.egg-info/SOURCES.txt +7 -0
  13. {deepparallel-0.5.2 → deepparallel-0.5.4}/pyproject.toml +1 -1
  14. deepparallel-0.5.4/tests/test_cockpit.py +241 -0
  15. deepparallel-0.5.4/tests/test_cockpit_panel.py +147 -0
  16. deepparallel-0.5.4/tests/test_cockpit_sim.py +158 -0
  17. deepparallel-0.5.4/tests/test_tools_web.py +140 -0
  18. deepparallel-0.5.2/deepparallel/tools/web.py +0 -82
  19. deepparallel-0.5.2/tests/test_tools_web.py +0 -97
  20. {deepparallel-0.5.2 → deepparallel-0.5.4}/README.md +0 -0
  21. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/backend.py +0 -0
  22. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/branding.py +0 -0
  23. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/config.py +0 -0
  24. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/crowe_id.py +0 -0
  25. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/dsml.py +0 -0
  26. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/fusion.py +0 -0
  27. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/licensing.py +0 -0
  28. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/registry.json +0 -0
  29. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/renderer.py +0 -0
  30. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/research/__init__.py +0 -0
  31. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/research/conduit.py +0 -0
  32. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/research/provider.py +0 -0
  33. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/routing.example.json +0 -0
  34. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/routing.py +0 -0
  35. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/serve.py +0 -0
  36. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/supply_chain.py +0 -0
  37. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/tools/__init__.py +0 -0
  38. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/tools/codeast.py +0 -0
  39. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/tools/edit.py +0 -0
  40. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/tools/files.py +0 -0
  41. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/tools/mcp.py +0 -0
  42. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/tools/registry.py +0 -0
  43. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/tools/sandbox.py +0 -0
  44. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/tools/search.py +0 -0
  45. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/tools/shell.py +0 -0
  46. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/tools/vision.py +0 -0
  47. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/userinput.py +0 -0
  48. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel.egg-info/dependency_links.txt +0 -0
  49. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel.egg-info/entry_points.txt +0 -0
  50. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel.egg-info/requires.txt +0 -0
  51. {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel.egg-info/top_level.txt +0 -0
  52. {deepparallel-0.5.2 → deepparallel-0.5.4}/setup.cfg +0 -0
  53. {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_agent.py +0 -0
  54. {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_backend.py +0 -0
  55. {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_backend_chat.py +0 -0
  56. {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_backend_stream.py +0 -0
  57. {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_branding.py +0 -0
  58. {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_cli.py +0 -0
  59. {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_config.py +0 -0
  60. {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_crowe_backend.py +0 -0
  61. {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_crowe_gateway_backend.py +0 -0
  62. {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_crowe_id_auth.py +0 -0
  63. {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_crowe_payment_required.py +0 -0
  64. {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_dsml.py +0 -0
  65. {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_fusion.py +0 -0
  66. {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_issuer_signer.py +0 -0
  67. {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_licensing.py +0 -0
  68. {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_renderer.py +0 -0
  69. {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_research.py +0 -0
  70. {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_research_provider.py +0 -0
  71. {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_routing.py +0 -0
  72. {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_serve.py +0 -0
  73. {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_spinner_color.py +0 -0
  74. {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_supply_chain.py +0 -0
  75. {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_tool_registry.py +0 -0
  76. {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_tools_codeast.py +0 -0
  77. {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_tools_edit.py +0 -0
  78. {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_tools_files.py +0 -0
  79. {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_tools_mcp.py +0 -0
  80. {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_tools_sandbox.py +0 -0
  81. {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_tools_search.py +0 -0
  82. {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_tools_shell.py +0 -0
  83. {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_tools_vision.py +0 -0
  84. {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_userinput.py +0 -0
  85. {deepparallel-0.5.2 → deepparallel-0.5.4}/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.2
3
+ Version: 0.5.4
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
@@ -1,3 +1,3 @@
1
1
  """DeepParallel CLI package."""
2
2
 
3
- __version__ = "0.5.2"
3
+ __version__ = "0.5.4"
@@ -307,6 +307,7 @@ def run_agent(
307
307
  max_steps: int | None = None,
308
308
  stream: bool = False,
309
309
  guardian=None,
310
+ on_event=None,
310
311
  ) -> str:
311
312
  steps = max_steps if max_steps is not None else settings.max_steps
312
313
  schemas = registry.schemas()
@@ -354,6 +355,11 @@ def run_agent(
354
355
  except Exception as e: # noqa: BLE001 - surface tool failure to model
355
356
  result = json.dumps({"error": f"{type(e).__name__}: {e}"})
356
357
  result = str(result)[:_MAX_TOOL_RESULT]
358
+ if on_event is not None:
359
+ try:
360
+ on_event(name, args, result)
361
+ except Exception: # noqa: BLE001 - observation must never break the loop
362
+ pass
357
363
  ok = '"error"' not in result[:30]
358
364
  renderer.tool_result(ok, _summarize_result(name, result), time.monotonic() - start)
359
365
  messages.append({"role": "tool", "tool_call_id": tc["id"], "content": result})
@@ -16,6 +16,8 @@ apply to chat and run.
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
+ import os
20
+ import subprocess
19
21
  import sys
20
22
  from dataclasses import replace
21
23
  from pathlib import Path
@@ -53,6 +55,8 @@ from deepparallel.fusion import (
53
55
  )
54
56
  from deepparallel.renderer import PlainRenderer, Renderer, RichRenderer
55
57
  from deepparallel.tools import get_registry
58
+ from deepparallel.cockpit import Cockpit
59
+ from deepparallel.cockpit_observe import make_observer
56
60
 
57
61
 
58
62
  def _build_messages(history: list[tuple[str, str]], system: str, user_msg: str) -> list[dict]:
@@ -486,6 +490,74 @@ def run(
486
490
  sys.exit(1)
487
491
 
488
492
 
493
+ @main.command()
494
+ @click.option("--yes", "-y", "assume_yes", is_flag=True, help="Auto-approve tool calls.")
495
+ @click.argument("prompt", nargs=-1, required=True)
496
+ @click.pass_context
497
+ def cockpit(ctx: click.Context, assume_yes: bool, prompt: tuple[str, ...]) -> None:
498
+ """Run a task in cockpit mode: a live data window plus WaveTerm render blocks.
499
+
500
+ The terminal streams the agent while an adjacent block shows a live data
501
+ window (sources, citations, claims) and pages the agent reads open as web
502
+ blocks. Outside WaveTerm it runs normally and still logs the event stream.
503
+ """
504
+ settings = _apply_flags(ctx.obj["settings"], False, assume_yes)
505
+ backend = _require_ready(settings)
506
+ events_path = os.path.abspath(os.path.join(".cockpit", "events.jsonl"))
507
+ os.makedirs(os.path.dirname(events_path), exist_ok=True)
508
+ open(events_path, "w").close()
509
+ cp = Cockpit(events_path)
510
+ cp.status("start", "cockpit online")
511
+ if cp.in_waveterm():
512
+ try:
513
+ subprocess.Popen(
514
+ ["wsh", "run", "--", "dp", "_cockpit-panel", events_path],
515
+ stdout=subprocess.DEVNULL,
516
+ stderr=subprocess.DEVNULL,
517
+ )
518
+ except Exception: # noqa: BLE001 - the panel block is best-effort
519
+ pass
520
+ backend = _wrap_fusion(backend, settings)
521
+ system = load_system_prompt()
522
+ messages = _build_messages([], system, " ".join(prompt))
523
+ renderer = _make_renderer(force_plain=False, assume_yes=settings.auto_approve)
524
+ try:
525
+ run_agent(
526
+ backend,
527
+ get_registry(),
528
+ messages,
529
+ settings,
530
+ renderer,
531
+ interactive=True,
532
+ auto_approve=settings.auto_approve,
533
+ stream=True,
534
+ on_event=make_observer(cp),
535
+ )
536
+ except Exception as e: # noqa: BLE001 - surface as friendly message
537
+ renderer.error(_translate_error(e))
538
+ failed = True
539
+ else:
540
+ failed = False
541
+ cp.status("done", "cockpit complete")
542
+ cp.notify("DeepParallel cockpit run complete")
543
+ if failed:
544
+ sys.exit(1)
545
+
546
+
547
+ @main.command(name="_cockpit-panel", hidden=True)
548
+ @click.argument("events_path")
549
+ def _cockpit_panel(events_path: str) -> None:
550
+ """Internal: render the live cockpit data window for an events log."""
551
+ from deepparallel.cockpit_panel import run_panel
552
+
553
+ def _complete(state) -> bool:
554
+ # cli.cockpit emits status(phase="done", message="cockpit complete")
555
+ # at the end of a run; stop the panel so its process does not leak.
556
+ return "cockpit complete" in getattr(state, "status", "")
557
+
558
+ run_panel(events_path, stop_when=_complete)
559
+
560
+
489
561
  @main.command()
490
562
  @click.option("--diff", "as_diff", is_flag=True, help="Read a unified diff from stdin.")
491
563
  @click.argument("path", required=False)
@@ -0,0 +1,143 @@
1
+ # Copyright 2026, Crowe Logic Inc.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """WaveTerm orchestration and event-log layer for the DeepParallel cockpit.
5
+
6
+ The terminal (WaveTerm) is the control and DATA WINDOW; the agent spawns
7
+ adjacent RENDER blocks (web, file preview) via WaveTerm's `wsh` CLI. Everything
8
+ flows through ONE append-only event log: a `.jsonl` file, one JSON object per
9
+ line, default `./.cockpit/events.jsonl`. Producers append; consumers tail.
10
+
11
+ Event shape:
12
+ {"t": <float epoch seconds>, "type": <string>, "data": <object>}
13
+
14
+ `Cockpit` is the single producer surface. `emit` appends a contract event line
15
+ (creating the parent dir, never raising). `open_web` / `view_file` shell out to
16
+ `wsh`, parse the "created block <id>" line, emit a matching "block" event, and
17
+ return the block id. Off WaveTerm (env `WAVETERM` / `WAVETERM_BLOCKID` unset) or
18
+ on any failure these block helpers are graceful no-ops returning None, while
19
+ `emit` still records events so the log stays useful in plain terminals.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+ import re
27
+ import subprocess
28
+ import time
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ # Bound every shell-out so a hung `wsh` can never stall the agent loop.
33
+ _WSH_TIMEOUT_S = 5.0
34
+
35
+ # `wsh web open` / `wsh view` print a line like "created block <blockid>".
36
+ _BLOCK_RE = re.compile(r"created block\s+(\S+)", re.IGNORECASE)
37
+
38
+
39
+ class Cockpit:
40
+ """Append-only event log plus WaveTerm `wsh` block orchestration.
41
+
42
+ Args:
43
+ events_path: path to the append-only `.jsonl` event log.
44
+ """
45
+
46
+ def __init__(self, events_path: str = ".cockpit/events.jsonl"):
47
+ self.events_path = Path(events_path)
48
+
49
+ # --- environment -------------------------------------------------------
50
+
51
+ def in_waveterm(self) -> bool:
52
+ """True when running inside a WaveTerm block (env signals present)."""
53
+ return bool(os.environ.get("WAVETERM") or os.environ.get("WAVETERM_BLOCKID"))
54
+
55
+ # --- event log ---------------------------------------------------------
56
+
57
+ def emit(self, type: str, **data: Any) -> None:
58
+ """Append one contract event line. Creates the parent dir. Never raises."""
59
+ event = {"t": time.time(), "type": type, "data": data}
60
+ try:
61
+ self.events_path.parent.mkdir(parents=True, exist_ok=True)
62
+ line = json.dumps(event, ensure_ascii=False, default=str)
63
+ with self.events_path.open("a", encoding="utf-8") as fh:
64
+ fh.write(line + "\n")
65
+ except Exception:
66
+ # The event log is best-effort; a failed write must not break the
67
+ # agent loop or any producer.
68
+ pass
69
+
70
+ # --- wsh block orchestration ------------------------------------------
71
+
72
+ def _run_wsh(self, args: list[str]) -> str | None:
73
+ """Run `wsh <args>` time-bounded; return stdout or None on any failure."""
74
+ if not self.in_waveterm():
75
+ return None
76
+ try:
77
+ proc = subprocess.run(
78
+ ["wsh", *args],
79
+ capture_output=True,
80
+ text=True,
81
+ timeout=_WSH_TIMEOUT_S,
82
+ check=False,
83
+ )
84
+ except Exception:
85
+ return None
86
+ if proc.returncode != 0:
87
+ return None
88
+ return proc.stdout
89
+
90
+ @staticmethod
91
+ def _parse_blockid(stdout: str | None) -> str | None:
92
+ """Extract the block id from a `wsh` "created block <id>" line."""
93
+ if not stdout:
94
+ return None
95
+ match = _BLOCK_RE.search(stdout)
96
+ return match.group(1) if match else None
97
+
98
+ def open_web(self, url: str) -> str | None:
99
+ """Open a WaveTerm web block for `url`; emit a "block" event; return id.
100
+
101
+ No-op returning None off WaveTerm or on any `wsh` failure.
102
+ """
103
+ blockid = self._parse_blockid(self._run_wsh(["web", "open", url]))
104
+ if blockid is not None:
105
+ self.emit("block", action="open", kind="web", target=url, blockid=blockid)
106
+ return blockid
107
+
108
+ def view_file(self, path: str) -> str | None:
109
+ """Open a WaveTerm preview block for `path`; emit a "block" event; return id.
110
+
111
+ No-op returning None off WaveTerm or on any `wsh` failure.
112
+ """
113
+ blockid = self._parse_blockid(self._run_wsh(["view", path]))
114
+ if blockid is not None:
115
+ self.emit("block", action="open", kind="file", target=path, blockid=blockid)
116
+ return blockid
117
+
118
+ def notify(self, message: str) -> None:
119
+ """Best-effort `wsh notify`. Silent no-op off WaveTerm or on failure."""
120
+ self._run_wsh(["notify", message])
121
+
122
+ # --- contract event helpers -------------------------------------------
123
+
124
+ def status(self, phase: str, message: str) -> None:
125
+ """Emit a "status" event."""
126
+ self.emit("status", phase=phase, message=message)
127
+
128
+ def source(self, url: str, title: str, provider: str) -> None:
129
+ """Emit a "source" event for a grounded source fetched."""
130
+ self.emit("source", url=url, title=title, provider=provider)
131
+
132
+ def citation(self, ref: int, url: str, title: str) -> None:
133
+ """Emit a "citation" event for a cited reference."""
134
+ self.emit("citation", ref=ref, url=url, title=title)
135
+
136
+ def claim(self, text: str, grounded: bool, confidence: float) -> None:
137
+ """Emit a "claim" event. Confidence is clamped to the 0..1 contract range."""
138
+ confidence = min(1.0, max(0.0, confidence))
139
+ self.emit("claim", text=text, grounded=grounded, confidence=confidence)
140
+
141
+ def sim(self, step: int, arm: str, metric: str, value: float) -> None:
142
+ """Emit a "sim" event for one A/B simulation datapoint."""
143
+ self.emit("sim", step=step, arm=arm, metric=metric, value=value)
@@ -0,0 +1,58 @@
1
+ # Copyright 2026, Crowe Logic Inc.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Translate agent tool results into cockpit events and render blocks.
5
+
6
+ `make_observer(cockpit)` returns a callback the agent loop invokes after every
7
+ tool call as `observe(name, args, result)`. It reads grounding tool results
8
+ (web_search, web_fetch, mcp_search) and emits the matching contract events so
9
+ the live data window updates, and opens the pages the agent reads as adjacent
10
+ WaveTerm web blocks (capped, so a long run does not spawn dozens of blocks).
11
+ Observation is best-effort: it never raises into the agent loop.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from typing import Any, Callable
18
+
19
+ _MAX_WEB_BLOCKS = 4 # cap render blocks opened from fetched pages
20
+
21
+
22
+ def make_observer(cockpit) -> Callable[[str, dict, str], None]:
23
+ """Return an observe(name, args, result) callback bound to `cockpit`."""
24
+ counters = {"ref": 0, "blocks": 0}
25
+
26
+ def observe(name: str, args: dict, result: str) -> None:
27
+ try:
28
+ obj: Any = json.loads(result)
29
+ except (ValueError, TypeError):
30
+ obj = None
31
+ try:
32
+ if isinstance(obj, dict) and "error" not in obj:
33
+ if name == "web_search":
34
+ provider = obj.get("provider", "web")
35
+ for r in (obj.get("results") or [])[:8]:
36
+ url = r.get("url")
37
+ if not url:
38
+ continue
39
+ counters["ref"] += 1
40
+ cockpit.source(url=url, title=r.get("title", ""), provider=provider)
41
+ cockpit.citation(ref=counters["ref"], url=url, title=r.get("title", ""))
42
+ answer = obj.get("answer")
43
+ if answer:
44
+ cockpit.claim(text=str(answer)[:240], grounded=True, confidence=0.7)
45
+ elif name == "web_fetch":
46
+ url = obj.get("url") or (args or {}).get("url", "")
47
+ if url:
48
+ cockpit.source(url=url, title=obj.get("title", ""), provider="fetch")
49
+ if counters["blocks"] < _MAX_WEB_BLOCKS and cockpit.open_web(url):
50
+ counters["blocks"] += 1
51
+ elif name == "mcp_search":
52
+ for s in (obj.get("servers") or [])[:5]:
53
+ cockpit.source(url="", title=s.get("name", ""), provider="mcp")
54
+ cockpit.status("tool", name)
55
+ except Exception: # noqa: BLE001 - observation must never break the loop
56
+ pass
57
+
58
+ return observe
@@ -0,0 +1,320 @@
1
+ """Live terminal DATA WINDOW for the DeepParallel cockpit.
2
+
3
+ The cockpit's terminal pane (WaveTerm) is the control surface and data window;
4
+ the agent spawns adjacent RENDER blocks for the visual output. This module owns
5
+ the data window: it tails the shared append-only event log (one JSON object per
6
+ line, see the cockpit contract) and drives a dense, no-chrome Rich panel that
7
+ tallies sources, citations, claims, and the A/B simulation metrics in real time.
8
+
9
+ It is UI-only and side-effect-free apart from tailing the log: state is a plain
10
+ dataclass, `apply_event` mutates it from one contract event, and `render`
11
+ returns a Rich renderable. `run_panel` wires those into a `rich.live.Live` at a
12
+ calm refresh rate and is safe to start before the log file exists.
13
+
14
+ Aesthetic follows the DeepParallel CLI family (see `branding`): cyan identity
15
+ accent, the shared glyph alphabet, status hues for grounded vs unverified, no
16
+ emojis and no em dashes in any user-facing string.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import time
23
+ from dataclasses import dataclass, field
24
+ from typing import Callable
25
+
26
+ from rich import box
27
+ from rich.console import Group
28
+ from rich.panel import Panel
29
+ from rich.table import Table
30
+ from rich.text import Text
31
+
32
+ from deepparallel import branding
33
+
34
+ # Refresh cadence for the Live region. ~6 fps is dense enough to feel live while
35
+ # leaving the terminal idle between event bursts.
36
+ _FPS = 6
37
+ _POLL_SECONDS = 1.0 / _FPS
38
+ # How many of the most recent sources to show; older ones collapse into a count.
39
+ _SOURCES_SHOWN = 4
40
+
41
+
42
+ @dataclass
43
+ class CockpitState:
44
+ """Accumulated view of the cockpit event stream.
45
+
46
+ Append-only collections mirror the log; `sim` keeps only the latest value
47
+ per (arm, metric) so the A/B table stays current rather than historical.
48
+ """
49
+
50
+ sources: list[dict] = field(default_factory=list)
51
+ citations: list[dict] = field(default_factory=list)
52
+ claims: list[dict] = field(default_factory=list)
53
+ sim: dict[tuple[str, str], float] = field(default_factory=dict)
54
+ status: str = "idle"
55
+
56
+ @property
57
+ def grounded_claims(self) -> int:
58
+ return sum(1 for c in self.claims if c.get("grounded"))
59
+
60
+ @property
61
+ def unverified_claims(self) -> int:
62
+ return sum(1 for c in self.claims if not c.get("grounded"))
63
+
64
+
65
+ def apply_event(state: CockpitState, event: dict) -> None:
66
+ """Mutate `state` from one contract event. Unknown or malformed events are
67
+ ignored so a single bad line never derails the data window."""
68
+ if not isinstance(event, dict):
69
+ return
70
+ etype = event.get("type")
71
+ data = event.get("data")
72
+ if not isinstance(data, dict):
73
+ return
74
+
75
+ if etype == "status":
76
+ phase = str(data.get("phase", "")).strip()
77
+ message = str(data.get("message", "")).strip()
78
+ state.status = " ".join(p for p in (phase, message) if p) or state.status
79
+ elif etype == "source":
80
+ state.sources.append(
81
+ {
82
+ "url": str(data.get("url", "")),
83
+ "title": str(data.get("title", "")),
84
+ "provider": str(data.get("provider", "")),
85
+ }
86
+ )
87
+ elif etype == "citation":
88
+ state.citations.append(
89
+ {
90
+ "ref": data.get("ref"),
91
+ "url": str(data.get("url", "")),
92
+ "title": str(data.get("title", "")),
93
+ }
94
+ )
95
+ elif etype == "claim":
96
+ state.claims.append(
97
+ {
98
+ "text": str(data.get("text", "")),
99
+ "grounded": bool(data.get("grounded", False)),
100
+ "confidence": min(1.0, max(0.0, _as_float(data.get("confidence"), 0.0))),
101
+ }
102
+ )
103
+ elif etype == "sim":
104
+ arm = str(data.get("arm", ""))
105
+ metric = str(data.get("metric", ""))
106
+ if arm and metric:
107
+ state.sim[(arm, metric)] = _as_float(data.get("value"), 0.0)
108
+ # "block" events drive render blocks elsewhere; the data window ignores them.
109
+
110
+
111
+ def _as_float(value: object, default: float) -> float:
112
+ try:
113
+ return float(value) # type: ignore[arg-type]
114
+ except (TypeError, ValueError):
115
+ return default
116
+
117
+
118
+ def _counter(label: str, value: object, accent: str) -> Text:
119
+ """A compact 'label value' counter chip."""
120
+ t = Text()
121
+ t.append(f"{label} ", style=branding.DIM)
122
+ t.append(str(value), style=f"bold {accent}")
123
+ return t
124
+
125
+
126
+ def _sources_section(state: CockpitState) -> Group:
127
+ header = Text()
128
+ header.append(f"{branding.MARK} Sources ", style=f"bold {branding.DP_ACCENT}")
129
+ header.append(str(len(state.sources)), style=f"bold {branding.BLUE_HEX}")
130
+ if not state.sources:
131
+ return Group(header, Text(" (none yet)", style=branding.DIM))
132
+
133
+ table = Table.grid(padding=(0, 1))
134
+ table.add_column(justify="right", style=branding.DIM, no_wrap=True)
135
+ table.add_column(style="white", no_wrap=True, max_width=44)
136
+ table.add_column(style=branding.DIM, no_wrap=True)
137
+ recent = state.sources[-_SOURCES_SHOWN:]
138
+ start = len(state.sources) - len(recent) + 1
139
+ for i, src in enumerate(recent, start=start):
140
+ title = src.get("title") or src.get("url") or "(untitled)"
141
+ provider = src.get("provider") or ""
142
+ table.add_row(f"{i}.", _ellipsize(title, 44), provider)
143
+ return Group(header, table)
144
+
145
+
146
+ def _claims_section(state: CockpitState) -> Group:
147
+ total = len(state.claims)
148
+ header = Text()
149
+ header.append(f"{branding.MARK} Claims ", style=f"bold {branding.DP_ACCENT}")
150
+ header.append(str(total), style="bold white")
151
+
152
+ line = Text(" ")
153
+ line.append(f"{branding.CHECK} grounded ", style=branding.GREEN_HEX)
154
+ line.append(str(state.grounded_claims), style=f"bold {branding.GREEN_HEX}")
155
+ line.append(f" {branding.DOT} ", style=branding.DIM)
156
+ line.append(f"{branding.CROSS} unverified ", style=branding.AMBER_HEX)
157
+ line.append(str(state.unverified_claims), style=f"bold {branding.AMBER_HEX}")
158
+ return Group(header, line)
159
+
160
+
161
+ def _sim_table(state: CockpitState) -> Table:
162
+ """A/B simulation metrics: one row per metric, latest A vs B values, with the
163
+ leading arm highlighted. Arms beyond A/B are tolerated but not columned."""
164
+ table = Table(
165
+ box=box.SIMPLE_HEAD,
166
+ padding=(0, 1),
167
+ title=Text(f"{branding.MARK} Sim metrics", style=f"bold {branding.DP_ACCENT}"),
168
+ title_justify="left",
169
+ expand=False,
170
+ )
171
+ table.add_column("metric", style=branding.DIM, no_wrap=True)
172
+ table.add_column("arm A", justify="right", no_wrap=True)
173
+ table.add_column("arm B", justify="right", no_wrap=True)
174
+
175
+ metrics = sorted({metric for (_arm, metric) in state.sim})
176
+ if not metrics:
177
+ table.add_row("(none yet)", "-", "-")
178
+ return table
179
+ for metric in metrics:
180
+ a = state.sim.get(("A", metric))
181
+ b = state.sim.get(("B", metric))
182
+ table.add_row(metric, _arm_cell(a, a, b), _arm_cell(b, a, b))
183
+ return table
184
+
185
+
186
+ def _arm_cell(value: float | None, a: float | None, b: float | None) -> Text:
187
+ """Format one arm cell, bolding the leader when both arms are present."""
188
+ if value is None:
189
+ return Text("-", style=branding.DIM)
190
+ leading = a is not None and b is not None and value == max(a, b) and a != b
191
+ style = f"bold {branding.GREEN_HEX}" if leading else "white"
192
+ return Text(f"{value:.4g}", style=style)
193
+
194
+
195
+ def _status_line(state: CockpitState) -> Text:
196
+ line = Text()
197
+ line.append(f"{branding.ARROW} ", style=branding.DP_ACCENT)
198
+ line.append("status ", style=branding.DIM)
199
+ line.append(state.status or "idle", style=f"bold {branding.DP_ACCENT}")
200
+ return line
201
+
202
+
203
+ def render(state: CockpitState):
204
+ """Build the data-window panel: counters, sources, claims, sim, status.
205
+
206
+ Returns a Rich renderable (a titled `Panel`) so callers can print it once or
207
+ feed it to a `Live` region.
208
+ """
209
+ counters = Table.grid(padding=(0, 3))
210
+ counters.add_column()
211
+ counters.add_column()
212
+ counters.add_column()
213
+ counters.add_row(
214
+ _counter("sources", len(state.sources), branding.BLUE_HEX),
215
+ _counter("citations", len(state.citations), branding.DP_ACCENT),
216
+ _counter("claims", len(state.claims), "white"),
217
+ )
218
+
219
+ body = Group(
220
+ counters,
221
+ Text(branding.RULE * 40, style=branding.DIM),
222
+ _sources_section(state),
223
+ Text(),
224
+ _claims_section(state),
225
+ Text(),
226
+ _sim_table(state),
227
+ Text(branding.RULE * 40, style=branding.DIM),
228
+ _status_line(state),
229
+ )
230
+
231
+ title = Text()
232
+ title.append("DeepParallel ", style=f"bold {branding.DP_ACCENT}")
233
+ title.append("data window", style=branding.DIM)
234
+ return Panel(
235
+ body,
236
+ title=title,
237
+ title_align="left",
238
+ border_style=branding.DP_ACCENT,
239
+ box=box.ROUNDED,
240
+ padding=(0, 1),
241
+ )
242
+
243
+
244
+ def _ellipsize(text: str, width: int) -> str:
245
+ text = text.replace("\n", " ").strip()
246
+ if len(text) <= width:
247
+ return text
248
+ return text[: max(1, width - 1)] + branding.DOT
249
+
250
+
251
+ def _read_new_lines(path: str, offset: int) -> tuple[list[str], int]:
252
+ """Read any lines appended past `offset`. Returns (lines, new_offset). Safe
253
+ when the file is missing or was truncated/rotated (offset resets to 0)."""
254
+ try:
255
+ size = _file_size(path)
256
+ if size < offset: # truncated or rotated: start over
257
+ offset = 0
258
+ with open(path, "r", encoding="utf-8") as fh:
259
+ fh.seek(offset)
260
+ chunk = fh.read()
261
+ new_offset = fh.tell()
262
+ except FileNotFoundError:
263
+ return [], offset
264
+ except OSError:
265
+ return [], offset
266
+ if not chunk:
267
+ return [], new_offset
268
+ # Keep only complete lines; a trailing partial line stays for the next poll.
269
+ if not chunk.endswith("\n"):
270
+ last_nl = chunk.rfind("\n")
271
+ if last_nl == -1:
272
+ return [], offset # no complete line yet; do not advance
273
+ new_offset = offset + len(chunk[: last_nl + 1].encode("utf-8"))
274
+ chunk = chunk[: last_nl + 1]
275
+ return [ln for ln in chunk.splitlines() if ln.strip()], new_offset
276
+
277
+
278
+ def _file_size(path: str) -> int:
279
+ import os
280
+
281
+ return os.path.getsize(path)
282
+
283
+
284
+ def run_panel(
285
+ events_path: str,
286
+ stop_when: Callable[[CockpitState], bool] | None = None,
287
+ *,
288
+ console=None,
289
+ poll_seconds: float = _POLL_SECONDS,
290
+ sleep: Callable[[float], None] = time.sleep,
291
+ ) -> CockpitState:
292
+ """Tail `events_path` and drive a Live data window at ~6 fps.
293
+
294
+ Appended events are applied to a fresh `CockpitState` and the panel
295
+ re-rendered each tick. Returns the final state. Stops when `stop_when(state)`
296
+ is truthy (checked after each batch) or on KeyboardInterrupt; if `stop_when`
297
+ is None it runs until interrupted. Safe to start before the file exists.
298
+ """
299
+ from rich.live import Live
300
+
301
+ con = console or branding.console
302
+ state = CockpitState()
303
+ offset = 0
304
+
305
+ with Live(render(state), console=con, refresh_per_second=_FPS, transient=False) as live:
306
+ try:
307
+ while True:
308
+ lines, offset = _read_new_lines(events_path, offset)
309
+ for line in lines:
310
+ try:
311
+ apply_event(state, json.loads(line))
312
+ except (json.JSONDecodeError, ValueError):
313
+ continue # tolerate a partial or malformed line
314
+ live.update(render(state))
315
+ if stop_when is not None and stop_when(state):
316
+ break
317
+ sleep(poll_seconds)
318
+ except KeyboardInterrupt:
319
+ pass
320
+ return state