fleetwatcher 0.4.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.
fleetwatch/render.py ADDED
@@ -0,0 +1,157 @@
1
+ """Plain-text snapshot rendering for ``fleetwatch --once``.
2
+
3
+ This module is deliberately dependency-light: it produces a single multi-line
4
+ string from a list of :class:`~fleetwatch.models.SessionState`. The TUI uses
5
+ Textual, but ``--once`` (and any pipe-to-a-log usage) only needs text, so this
6
+ stays importable and runnable with nothing but the standard library.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from typing import Iterable, Optional
13
+
14
+ from .models import SessionState, State
15
+ from .palette import paint, state_glyph, state_style, vendor_style
16
+
17
+
18
+ # Order counts appear in the header line, matching the lifecycle in State.
19
+ _COUNT_ORDER = ("active", "waiting", "idle", "done", "error")
20
+
21
+
22
+ def humanize_age(last_activity: Optional[float], now: Optional[float] = None) -> str:
23
+ """Render a seconds-since-epoch timestamp as a compact age (e.g. "5m").
24
+
25
+ ``None`` -> "-". Buckets: <60s -> Ns, <1h -> Nm, <1d -> Nh, else Nd.
26
+ """
27
+ if last_activity is None:
28
+ return "-"
29
+ now = time.time() if now is None else now
30
+ delta = now - last_activity
31
+ if delta < 0:
32
+ delta = 0
33
+ if delta < 60:
34
+ return f"{int(delta)}s"
35
+ if delta < 3600:
36
+ return f"{int(delta // 60)}m"
37
+ if delta < 86400:
38
+ return f"{int(delta // 3600)}h"
39
+ return f"{int(delta // 86400)}d"
40
+
41
+
42
+ def _truncate(text: str, width: int) -> str:
43
+ text = (text or "").replace("\n", " ").strip()
44
+ if len(text) <= width:
45
+ return text
46
+ if width <= 1:
47
+ return text[:width]
48
+ return text[: width - 1] + "…" # ellipsis
49
+
50
+
51
+ def _header(counts: Optional[dict], now: float, color: bool = False) -> list[str]:
52
+ clock = time.strftime("%H:%M:%S", time.localtime(now))
53
+ head = paint("cyan", "fleetwatch", color)
54
+ clock_s = paint("grey58", clock, color)
55
+ if not counts:
56
+ return [f"{head} {clock_s}"]
57
+ parts = []
58
+ for name in _COUNT_ORDER:
59
+ if name in counts:
60
+ chunk = f"{name} {counts[name]}"
61
+ if name in State._value2member_map_:
62
+ chunk = paint(state_style(State(name)), chunk, color)
63
+ parts.append(chunk)
64
+ total = counts.get("total")
65
+ summary = " ".join(parts)
66
+ if total is not None:
67
+ total_s = paint("bold", f"total {total}", color)
68
+ summary = f"{summary} ({total_s})" if summary else total_s
69
+ return [f"{head} {clock_s} {summary}".rstrip()]
70
+
71
+
72
+ def render_snapshot(
73
+ sessions: Iterable[SessionState],
74
+ counts: Optional[dict] = None,
75
+ now: Optional[float] = None,
76
+ color: bool = False,
77
+ ) -> str:
78
+ """Return a clean multi-line text table for ``fleetwatch --once``.
79
+
80
+ One line per session: vendor, project, state, humanized idle age, a "!"
81
+ when the session needs attention, and the doing/needs text. A header with
82
+ counts is included when ``counts`` is provided. An empty list renders a
83
+ friendly placeholder.
84
+
85
+ With ``color=True`` the same state/vendor palette as the dashboard is
86
+ written as ANSI; the CLI turns it on only when stdout is a TTY, so piping
87
+ ``--once`` to a file or a log stays plain text. Column widths are computed
88
+ on the uncolored text, so alignment holds whether or not color is on.
89
+ """
90
+ sessions = list(sessions)
91
+ now = time.time() if now is None else now
92
+
93
+ lines = _header(counts, now, color)
94
+
95
+ if not sessions:
96
+ lines.append("")
97
+ lines.append("No active sessions.")
98
+ return "\n".join(lines)
99
+
100
+ # Only show a host column when watching more than one source, so the
101
+ # single-machine view stays uncluttered.
102
+ sources = {s.source for s in sessions}
103
+ show_host = len(sources) > 1
104
+
105
+ # Column widths sized to content, with sane caps.
106
+ host_w = min(10, max(4, *(len(s.source) for s in sessions))) if show_host else 0
107
+ vendor_w = max(6, *(len(s.vendor) for s in sessions))
108
+ project_w = min(24, max(7, *(len(s.project) for s in sessions)))
109
+ state_w = max(len(str(s.state)) for s in sessions)
110
+ state_w = max(state_w, 5)
111
+
112
+ host_head = f"{'HOST':<{host_w}} " if show_host else ""
113
+ header_row = (
114
+ f"{host_head}"
115
+ f"{'VENDOR':<{vendor_w}} "
116
+ f"{'PROJECT':<{project_w}} "
117
+ f" {'STATE':<{state_w}} " # two-space gutter under the status glyph
118
+ f"{'IDLE':>4} "
119
+ f"{'!':<1} "
120
+ f"WHAT"
121
+ )
122
+ lines.append("")
123
+ lines.append(header_row)
124
+ lines.append("─" * len(header_row))
125
+
126
+ for s in sessions:
127
+ age = humanize_age(s.last_activity, now)
128
+ bang = "!" if s.needs_attention else " "
129
+ # Prefer the human-facing "needs" reason when attention is wanted,
130
+ # otherwise the adapter's "doing" one-liner.
131
+ what = s.needs if (s.needs_attention and s.needs) else s.doing
132
+
133
+ # Pad to width first, then paint, so the ANSI bytes never throw the
134
+ # column alignment off.
135
+ vendor_c = paint(vendor_style(s.vendor),
136
+ f"{_truncate(s.vendor, vendor_w):<{vendor_w}}", color)
137
+ state_c = paint(state_style(s.state),
138
+ f"{state_glyph(s.state)} {str(s.state):<{state_w}}", color)
139
+ age_c = paint("grey58", f"{age:>4}", color)
140
+ bang_c = (paint("bold bright_red", f"{bang:<1}", color)
141
+ if s.needs_attention else f"{bang:<1}")
142
+ host_cell = ""
143
+ if show_host:
144
+ host_cell = paint("grey58",
145
+ f"{_truncate(s.source, host_w):<{host_w}}", color) + " "
146
+
147
+ lines.append(
148
+ f"{host_cell}"
149
+ f"{vendor_c} "
150
+ f"{_truncate(s.project, project_w):<{project_w}} "
151
+ f"{state_c} "
152
+ f"{age_c} "
153
+ f"{bang_c} "
154
+ f"{_truncate(what, 60)}"
155
+ )
156
+
157
+ return "\n".join(lines)
@@ -0,0 +1,141 @@
1
+ """Hybrid plain-language summaries.
2
+
3
+ Every session already carries a free, instant heuristic one-liner (``doing``,
4
+ set by its adapter). On top of that, this module asks a fast model (Haiku) for a
5
+ single plain-language sentence — but only for sessions that need attention, and
6
+ only once per distinct activity timestamp. Results are cached and written back
7
+ onto the SessionState in place, off the UI thread, so the dashboard never blocks
8
+ on the network and idle fleets cost nothing.
9
+
10
+ If there is no API key, or the model is disabled, this degrades silently to the
11
+ heuristic line (``summary`` simply stays ``None`` and the UI falls back to
12
+ ``doing``).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import threading
19
+ import time
20
+ from concurrent.futures import ThreadPoolExecutor
21
+ from typing import Optional
22
+
23
+ from . import config
24
+ from .models import SessionState
25
+
26
+ _SYSTEM = (
27
+ "You report the status of a developer's terminal coding session in ONE "
28
+ "plain-language sentence, 25 words max. Say what it is doing and, if it is "
29
+ "blocked, exactly what it needs from the user. No preamble, no markdown, no "
30
+ "quotes — just the sentence."
31
+ )
32
+
33
+
34
+ class Summarizer:
35
+ """Background, cached, best-effort model summaries."""
36
+
37
+ def __init__(self, model: Optional[str] = None, use_model: Optional[bool] = None):
38
+ self.model = model or config.SUMMARY_MODEL
39
+ use = config.USE_MODEL if use_model is None else use_model
40
+ self._client = None
41
+ self._pool: Optional[ThreadPoolExecutor] = None
42
+ self._cache: dict = {} # (key, last_activity) -> sentence
43
+ self._inflight: set = set()
44
+ self._lock = threading.Lock()
45
+
46
+ if use and os.environ.get("ANTHROPIC_API_KEY"):
47
+ try:
48
+ import anthropic
49
+
50
+ self._client = anthropic.Anthropic()
51
+ self._pool = ThreadPoolExecutor(
52
+ max_workers=2, thread_name_prefix="fw-summary"
53
+ )
54
+ except Exception:
55
+ self._client = None # any import/auth trouble → heuristics only
56
+
57
+ @property
58
+ def enabled(self) -> bool:
59
+ return self._client is not None
60
+
61
+ def cached(self, st: SessionState) -> Optional[str]:
62
+ """Return a cached sentence for this session's current activity, if any."""
63
+ with self._lock:
64
+ return self._cache.get((st.key, st.last_activity))
65
+
66
+ def request(self, st: SessionState, force: bool = False) -> None:
67
+ """Ensure a summary exists for ``st``, computing it in the background.
68
+
69
+ Applies a cached result immediately when available; otherwise schedules
70
+ one model call (deduplicated per activity timestamp unless ``force``).
71
+ """
72
+ if not self.enabled:
73
+ return
74
+ ckey = (st.key, st.last_activity)
75
+ with self._lock:
76
+ if ckey in self._cache:
77
+ st.summary = self._cache[ckey]
78
+ if not force:
79
+ return
80
+ if ckey in self._inflight:
81
+ return
82
+ if self._pool is None: # enabled implies a pool, but never assert in a thread
83
+ return
84
+ self._inflight.add(ckey)
85
+ self._pool.submit(self._run, st, ckey)
86
+
87
+ def drain(self, timeout: float = 6.0) -> None:
88
+ """Block until in-flight summaries finish (or timeout). Used by --once."""
89
+ if not self.enabled:
90
+ return
91
+ end = time.time() + timeout
92
+ while time.time() < end:
93
+ with self._lock:
94
+ if not self._inflight:
95
+ return
96
+ time.sleep(0.1)
97
+
98
+ # --- internals ---
99
+
100
+ def _run(self, st: SessionState, ckey) -> None:
101
+ text = None
102
+ try:
103
+ text = self._call_model(st)
104
+ except Exception:
105
+ text = None # network/model failure → leave heuristic in place
106
+ with self._lock:
107
+ self._inflight.discard(ckey)
108
+ if text:
109
+ self._cache[ckey] = text
110
+ if text:
111
+ st.summary = text
112
+
113
+ def _call_model(self, st: SessionState) -> Optional[str]:
114
+ msg = self._client.messages.create( # type: ignore[union-attr]
115
+ model=self.model,
116
+ max_tokens=120,
117
+ system=_SYSTEM,
118
+ messages=[{"role": "user", "content": self._build_prompt(st)}],
119
+ )
120
+ parts = [
121
+ b.text for b in msg.content if getattr(b, "type", None) == "text"
122
+ ]
123
+ # Collapse to a single clean line: a model that returns multiple text
124
+ # blocks or internal newlines must never read as concatenated summaries.
125
+ out = " ".join(" ".join(parts).split()).strip()
126
+ if len(out) > 240:
127
+ out = out[:239].rstrip() + "…"
128
+ return out or None
129
+
130
+ def _build_prompt(self, st: SessionState) -> str:
131
+ todos = "\n".join(f"- [{t.status}] {t.text}" for t in st.todos[:8])
132
+ return (
133
+ f"Vendor: {st.vendor}\n"
134
+ f"Project: {st.project}\n"
135
+ f"State: {st.state}\n"
136
+ f"Needs: {st.needs or '-'}\n"
137
+ f"Heuristic activity: {st.doing or '-'}\n"
138
+ f"Most recent user message: {st.last_user[:400] or '-'}\n"
139
+ f"Most recent agent message: {st.last_agent[:400] or '-'}\n"
140
+ f"Plan / todos:\n{todos or '-'}"
141
+ )
fleetwatch/tailer.py ADDED
@@ -0,0 +1,63 @@
1
+ """Bounded, crash-proof reads of append-only JSONL transcripts.
2
+
3
+ Session transcripts can be tens of megabytes (one on this machine is 25 MB) and
4
+ are written while we read them, so the final line is frequently a half-finished
5
+ write. Everything here reads only a bounded tail and never raises on a partial
6
+ final line or a missing file.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ from typing import Any
14
+
15
+ DEFAULT_TAIL_BYTES = 512_000 # ~0.5 MB of tail captures plenty of recent activity
16
+
17
+
18
+ def read_tail_lines(path: str, max_bytes: int = DEFAULT_TAIL_BYTES) -> list[str]:
19
+ """Return complete, non-empty text lines from the last ``max_bytes`` of a file.
20
+
21
+ If we did not start at byte 0, the first (possibly partial) line is dropped.
22
+ Returns ``[]`` for a missing, empty, or unreadable file instead of raising.
23
+ """
24
+ try:
25
+ size = os.path.getsize(path)
26
+ except OSError:
27
+ return []
28
+ if size == 0:
29
+ return []
30
+ start = max(0, size - max_bytes)
31
+ try:
32
+ with open(path, "rb") as fh:
33
+ fh.seek(start)
34
+ chunk = fh.read()
35
+ except OSError:
36
+ return []
37
+ text = chunk.decode("utf-8", errors="replace")
38
+ lines = text.split("\n")
39
+ if start > 0 and lines:
40
+ lines = lines[1:] # the leading line is truncated; drop it
41
+ return [ln for ln in lines if ln.strip()]
42
+
43
+
44
+ def parse_jsonl_lines(lines: list[str]) -> list[dict[str, Any]]:
45
+ """Parse lines into dicts, silently skipping any that do not parse.
46
+
47
+ The last line is often a partial live write, so unparseable lines are
48
+ expected and ignored rather than treated as errors.
49
+ """
50
+ out: list[dict[str, Any]] = []
51
+ for ln in lines:
52
+ try:
53
+ obj = json.loads(ln)
54
+ except (ValueError, TypeError):
55
+ continue
56
+ if isinstance(obj, dict):
57
+ out.append(obj)
58
+ return out
59
+
60
+
61
+ def read_tail_records(path: str, max_bytes: int = DEFAULT_TAIL_BYTES) -> list[dict[str, Any]]:
62
+ """Convenience: tail a file and parse its JSONL lines in one step."""
63
+ return parse_jsonl_lines(read_tail_lines(path, max_bytes))