crowsnest 0.0.2__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.
- crowsnest/__init__.py +52 -0
- crowsnest/__main__.py +234 -0
- crowsnest/activity.py +353 -0
- crowsnest/data/agents/crowsnest-scout.md +63 -0
- crowsnest/data/skills/crowsnest/SKILL.md +123 -0
- crowsnest/registry.py +218 -0
- crowsnest/skills.py +158 -0
- crowsnest/tools.py +108 -0
- crowsnest/watch.py +140 -0
- crowsnest-0.0.2.dist-info/METADATA +118 -0
- crowsnest-0.0.2.dist-info/RECORD +14 -0
- crowsnest-0.0.2.dist-info/WHEEL +4 -0
- crowsnest-0.0.2.dist-info/entry_points.txt +2 -0
- crowsnest-0.0.2.dist-info/licenses/LICENSE +21 -0
crowsnest/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""One session that watches the others.
|
|
2
|
+
|
|
3
|
+
A machine running many Claude Code sessions has a question nobody answers: *what are they
|
|
4
|
+
all doing, and which of them needs me?* Each session knows only itself, the terminal tabs
|
|
5
|
+
are silent until you click them, and the answer lives in forty scrollbacks.
|
|
6
|
+
|
|
7
|
+
``crowsnest`` reads what Claude Code already writes -- the registry it keeps for every
|
|
8
|
+
running session, and the transcript each session appends to -- and answers in three
|
|
9
|
+
tiers, cheapest first:
|
|
10
|
+
|
|
11
|
+
1. **The roster** (:func:`crowsnest.tools.roster`): who is alive, busy, idle or waiting,
|
|
12
|
+
where, since when. Instant; no transcript is read.
|
|
13
|
+
2. **The activity** (:func:`crowsnest.tools.show`, :func:`crowsnest.tools.turns`): what a
|
|
14
|
+
session was last asked, what it last said, the tool it is running now, the question
|
|
15
|
+
it is waiting on -- read from the tail of its transcript, which costs the watched
|
|
16
|
+
session nothing and never interrupts it. ``turns`` pages further back when the tail is
|
|
17
|
+
not enough.
|
|
18
|
+
3. **The ask**: a running session can be *messaged* and will answer from its own
|
|
19
|
+
context. That is a Claude Code feature, not a Python one, so it lives in the shipped
|
|
20
|
+
skill (``crowsnest/data/skills/crowsnest/SKILL.md``) rather than here -- with the rule
|
|
21
|
+
that says when it is worth a turn of someone else's context and when it is not.
|
|
22
|
+
|
|
23
|
+
And one stream: :func:`crowsnest.watch.events` yields a line every time a session starts,
|
|
24
|
+
exits, finishes a turn, or starts waiting on its human, so a monitor is told rather than
|
|
25
|
+
made to poll.
|
|
26
|
+
|
|
27
|
+
Everything here is read-only. Nothing sends, spawns, kills, or writes into another
|
|
28
|
+
session; the one write in the package is the skill installer, and it writes symlinks.
|
|
29
|
+
|
|
30
|
+
>>> from crowsnest import live_sessions, roster
|
|
31
|
+
>>> live_sessions(home='/nonexistent-dir-for-doctest')
|
|
32
|
+
[]
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from crowsnest.activity import Activity, Turn, read_activity, read_turns
|
|
36
|
+
from crowsnest.registry import LiveSession, live_sessions
|
|
37
|
+
from crowsnest.tools import resolve, roster, show, turns
|
|
38
|
+
from crowsnest.watch import events
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"Activity",
|
|
42
|
+
"LiveSession",
|
|
43
|
+
"Turn",
|
|
44
|
+
"events",
|
|
45
|
+
"live_sessions",
|
|
46
|
+
"read_activity",
|
|
47
|
+
"read_turns",
|
|
48
|
+
"resolve",
|
|
49
|
+
"roster",
|
|
50
|
+
"show",
|
|
51
|
+
"turns",
|
|
52
|
+
]
|
crowsnest/__main__.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""The ``crowsnest`` command: the one surface v0 builds.
|
|
2
|
+
|
|
3
|
+
Every verb is a thin renderer over a function in :mod:`crowsnest.tools`, the single list
|
|
4
|
+
all surfaces dispatch from. The core prints nothing and exits nothing; the formatting is
|
|
5
|
+
here so that a later MCP or HTTP adapter needs no change to the core.
|
|
6
|
+
|
|
7
|
+
Bare ``crowsnest`` prints the roster, because the fewest keystrokes have to produce the
|
|
8
|
+
useful thing.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
# PYTHON_ARGCOMPLETE_OK
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json as _json
|
|
16
|
+
import sys
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
|
|
19
|
+
from crowsnest import skills as _skills
|
|
20
|
+
from crowsnest import tools
|
|
21
|
+
from crowsnest import watch as _watch
|
|
22
|
+
|
|
23
|
+
__all__ = ["main"]
|
|
24
|
+
|
|
25
|
+
DEFAULT_COMMAND = "roster"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _age(epoch: float | None) -> str:
|
|
29
|
+
if not epoch:
|
|
30
|
+
return "?"
|
|
31
|
+
seconds = max(0.0, datetime.now(timezone.utc).timestamp() - epoch)
|
|
32
|
+
for size, unit in ((86400, "d"), (3600, "h"), (60, "m")):
|
|
33
|
+
if seconds >= size:
|
|
34
|
+
return f"{seconds / size:.0f}{unit}"
|
|
35
|
+
return f"{seconds:.0f}s"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _one_line(text: str, limit: int) -> str:
|
|
39
|
+
text = " ".join((text or "").split())
|
|
40
|
+
return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _local(stamp: str) -> str:
|
|
44
|
+
"""An ISO timestamp as local ``HH:MM``, or the raw value when unparseable."""
|
|
45
|
+
try:
|
|
46
|
+
return (
|
|
47
|
+
datetime.fromisoformat(stamp.replace("Z", "+00:00"))
|
|
48
|
+
.astimezone()
|
|
49
|
+
.strftime("%H:%M")
|
|
50
|
+
)
|
|
51
|
+
except ValueError:
|
|
52
|
+
return stamp
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _row_detail(row: dict, limit: int) -> str:
|
|
56
|
+
act = row.get("activity") or {}
|
|
57
|
+
status = row["status"]
|
|
58
|
+
if status == "waiting":
|
|
59
|
+
cause = act.get("pending_question") or "; ".join(act.get("in_flight") or ())
|
|
60
|
+
parts = [
|
|
61
|
+
row.get("waiting_for") or "waiting",
|
|
62
|
+
cause or act.get("last_assistant_text", ""),
|
|
63
|
+
]
|
|
64
|
+
return _one_line(" · ".join(p for p in parts if p), limit)
|
|
65
|
+
if status == "busy":
|
|
66
|
+
running = "; ".join(act.get("in_flight") or ())
|
|
67
|
+
if running:
|
|
68
|
+
return _one_line("→ " + running, limit)
|
|
69
|
+
return _one_line("asked: " + act.get("last_user_prompt", ""), limit)
|
|
70
|
+
said = act.get("last_assistant_text", "")
|
|
71
|
+
mark = "⚠ " if act.get("errored") else ""
|
|
72
|
+
return _one_line(f'{mark}"{said}"' if said else "", limit)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def roster(*, home: str | None = None, brief: bool = False, width: int = 110):
|
|
76
|
+
"""Who is alive, most urgent first: waiting on you, then busy, then idle.
|
|
77
|
+
|
|
78
|
+
`--brief` answers from the registry alone, without reading any transcript.
|
|
79
|
+
"""
|
|
80
|
+
result = tools.roster(home=home, activity=not brief)
|
|
81
|
+
lines = []
|
|
82
|
+
for row in result["sessions"]:
|
|
83
|
+
head = f"{row['status']:<8}{_age(row['status_since']):>4} {row['label'][:26]:<27}{row['project'][:16]:<17}"
|
|
84
|
+
detail = "" if brief else _row_detail(row, max(20, width - len(head)))
|
|
85
|
+
lines.append((head + detail).rstrip())
|
|
86
|
+
counts = result["counts"]
|
|
87
|
+
summary = ", ".join(f"{n} {k}" for k, n in counts.items() if n)
|
|
88
|
+
lines.append(f"-- {len(result['sessions'])} live: {summary or 'none'}")
|
|
89
|
+
return "\n".join(lines)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def show(session: str, *, home: str | None = None, recent: int = 8, json: bool = False):
|
|
93
|
+
"""One session in full: what it was asked, what it said, what it is running now.
|
|
94
|
+
|
|
95
|
+
`session` is a registry name, a unique prefix of one, a session-id prefix, or a pid.
|
|
96
|
+
"""
|
|
97
|
+
result = tools.show(session, home=home, recent=recent)
|
|
98
|
+
if json:
|
|
99
|
+
return _json.dumps(result, indent=2)
|
|
100
|
+
s, act = result["session"], result["activity"]
|
|
101
|
+
since = _age(s["status_since"])
|
|
102
|
+
out = [
|
|
103
|
+
f"# {s['label']} ({s['status']} for {since}"
|
|
104
|
+
+ (f", {s['waiting_for']}" if s["waiting_for"] else "")
|
|
105
|
+
+ ")"
|
|
106
|
+
]
|
|
107
|
+
out.append(
|
|
108
|
+
f"pid {s['pid']} · session {s['session_id'][:8]} · {s['cwd']}"
|
|
109
|
+
+ (f" · branch {act['git_branch']}" if act["git_branch"] else "")
|
|
110
|
+
+ (" · remote control on" if s["remote_control"] else "")
|
|
111
|
+
)
|
|
112
|
+
if act["pending_question"]:
|
|
113
|
+
out += ["", "## Waiting on you", act["pending_question"]]
|
|
114
|
+
if act["in_flight"]:
|
|
115
|
+
out += ["", "## In flight", *[f"- {t}" for t in act["in_flight"]]]
|
|
116
|
+
out += [
|
|
117
|
+
"",
|
|
118
|
+
f"## Last asked ({_local(act['last_prompt_at'])})",
|
|
119
|
+
act["last_user_prompt"] or "(none in the tail)",
|
|
120
|
+
]
|
|
121
|
+
out += [
|
|
122
|
+
"",
|
|
123
|
+
f"## Last said ({_local(act['last_text_at'])})",
|
|
124
|
+
act["last_assistant_text"] or "(none in the tail)",
|
|
125
|
+
]
|
|
126
|
+
if act["recent_tools"]:
|
|
127
|
+
out += ["", "## Recent tools", *[f"- {t}" for t in act["recent_tools"]]]
|
|
128
|
+
flags = [k for k in ("turn_open", "errored") if act[k]]
|
|
129
|
+
if flags or not act["tail_complete"]:
|
|
130
|
+
out += [
|
|
131
|
+
"",
|
|
132
|
+
"flags: "
|
|
133
|
+
+ ", ".join(flags + ([] if act["tail_complete"] else ["tail only"])),
|
|
134
|
+
]
|
|
135
|
+
return "\n".join(out)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def turns(
|
|
139
|
+
session: str,
|
|
140
|
+
*,
|
|
141
|
+
last: int = 5,
|
|
142
|
+
before: int | None = None,
|
|
143
|
+
home: str | None = None,
|
|
144
|
+
json: bool = False,
|
|
145
|
+
):
|
|
146
|
+
"""The last few turns of a session, oldest first. `--before N` pages back from turn N."""
|
|
147
|
+
result = tools.turns(session, last=last, before=before, home=home)
|
|
148
|
+
if json:
|
|
149
|
+
return _json.dumps(result, indent=2)
|
|
150
|
+
out = [f"# {result['session']['label']} — turns"]
|
|
151
|
+
for t in result["turns"]:
|
|
152
|
+
out += [
|
|
153
|
+
"",
|
|
154
|
+
f"## turn {t['index']} ({_local(t['prompt_at'])})",
|
|
155
|
+
f"> {t['prompt']}",
|
|
156
|
+
]
|
|
157
|
+
if t["tools"]:
|
|
158
|
+
out.append(
|
|
159
|
+
f"tools ({len(t['tools'])}): "
|
|
160
|
+
+ "; ".join(t["tools"][:8])
|
|
161
|
+
+ (" …" if len(t["tools"]) > 8 else "")
|
|
162
|
+
)
|
|
163
|
+
out.append(t["reply"] or "(no final text)")
|
|
164
|
+
if not result["turns"]:
|
|
165
|
+
out.append("(no turns)")
|
|
166
|
+
return "\n".join(out)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def watch(
|
|
170
|
+
*, interval: float = _watch.DFLT_INTERVAL, home: str | None = None, json: bool = False
|
|
171
|
+
):
|
|
172
|
+
"""Print one line per change, forever: started, exited, idle, busy, waiting, error.
|
|
173
|
+
|
|
174
|
+
Built for Claude Code's `Monitor` tool: each line becomes a notification in the
|
|
175
|
+
watching session. Stop with Ctrl-C.
|
|
176
|
+
"""
|
|
177
|
+
try:
|
|
178
|
+
for event in _watch.events(interval=interval, home=home):
|
|
179
|
+
if json:
|
|
180
|
+
line = _json.dumps(event)
|
|
181
|
+
else:
|
|
182
|
+
when = _local(event["at"])
|
|
183
|
+
line = f"{when} {event['kind']:<8} {event['name']} ({event['project']})"
|
|
184
|
+
if event["detail"]:
|
|
185
|
+
line += f" — {event['detail']}"
|
|
186
|
+
print(line, flush=True)
|
|
187
|
+
except KeyboardInterrupt:
|
|
188
|
+
pass
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def install_skills(
|
|
192
|
+
*,
|
|
193
|
+
target: str | None = None,
|
|
194
|
+
only: str | None = None,
|
|
195
|
+
force: bool = False,
|
|
196
|
+
dry_run: bool = False,
|
|
197
|
+
):
|
|
198
|
+
"""Link the bundled skill and subagent into ~/.claude (or `--target`). Idempotent."""
|
|
199
|
+
names = [n for n in (only or "").split(",") if n.strip()] or None
|
|
200
|
+
plan = _skills.install_skills(target=target, only=names, force=force, dry_run=dry_run)
|
|
201
|
+
lines = [f"{'would install' if dry_run else 'installed'} into {plan['target']}"]
|
|
202
|
+
for row in plan["actions"]:
|
|
203
|
+
how = f" ({row['method']})" if row["method"] else ""
|
|
204
|
+
lines.append(
|
|
205
|
+
f"{row['action']:<9}{row['kind']:<7}{row['name']:<18}{row['reason']}{how}"
|
|
206
|
+
)
|
|
207
|
+
return "\n".join(lines)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
_commands = [roster, show, turns, watch, install_skills]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def main(argv: list[str] | None = None) -> None:
|
|
214
|
+
"""Dispatch the ``crowsnest`` command. Bare ``crowsnest`` runs :func:`roster`."""
|
|
215
|
+
import cw
|
|
216
|
+
|
|
217
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
218
|
+
if not argv or argv[0].startswith("-") and argv[0] not in ("-h", "--help"):
|
|
219
|
+
argv = [DEFAULT_COMMAND, *argv]
|
|
220
|
+
parser = cw.mk_parser(
|
|
221
|
+
_commands, prog="crowsnest", description=__doc__.splitlines()[0]
|
|
222
|
+
)
|
|
223
|
+
try:
|
|
224
|
+
code = cw.run(parser, argv)
|
|
225
|
+
except (ValueError, KeyError) as exc:
|
|
226
|
+
message = exc.args[0] if exc.args else str(exc)
|
|
227
|
+
print(f"crowsnest: {message}", file=sys.stderr)
|
|
228
|
+
sys.exit(2)
|
|
229
|
+
if code:
|
|
230
|
+
raise SystemExit(code)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
if __name__ == "__main__":
|
|
234
|
+
main()
|
crowsnest/activity.py
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""What a session is doing right now, read from the tail of its transcript.
|
|
2
|
+
|
|
3
|
+
A transcript is append-only and can run to megabytes; the part that says what a session
|
|
4
|
+
is doing *now* is its last few kilobytes. So :func:`read_activity` reads from the end,
|
|
5
|
+
widening the window only until it has seen one human prompt, and reports what it found:
|
|
6
|
+
what the session was last asked, what it last said, the tools it ran most recently, the
|
|
7
|
+
tool call that has not returned yet, and -- the case a monitor exists for -- a question it
|
|
8
|
+
has put to its human that nobody has answered.
|
|
9
|
+
|
|
10
|
+
What the transcript's *content* means is :mod:`openloops.transcripts`'s business -- which
|
|
11
|
+
``user`` line is a person speaking and which is tooling, what the assistant's last words
|
|
12
|
+
were, whether the turn ended -- and :func:`openloops.transcripts.parse_session` is called
|
|
13
|
+
on the tail rather than that logic being written a second time. What this module adds is
|
|
14
|
+
the tool-level view that a dated digest has no use for and a live monitor cannot do
|
|
15
|
+
without.
|
|
16
|
+
|
|
17
|
+
:func:`read_turns` is the deep path. It reads the whole file and pages backwards through
|
|
18
|
+
turns, for when the tail did not carry enough context and the alternative is spending a
|
|
19
|
+
turn of the watched session's own context asking it.
|
|
20
|
+
|
|
21
|
+
>>> read_activity('/nonexistent-file-for-doctest').last_user_prompt
|
|
22
|
+
''
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
from collections.abc import Callable
|
|
29
|
+
from dataclasses import asdict, dataclass
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from openloops.transcripts import parse_session
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"QUESTION_TOOL",
|
|
36
|
+
"RECENT_TOOLS",
|
|
37
|
+
"TAIL_BYTES",
|
|
38
|
+
"Activity",
|
|
39
|
+
"Turn",
|
|
40
|
+
"describe_tool",
|
|
41
|
+
"load_records",
|
|
42
|
+
"read_activity",
|
|
43
|
+
"read_turns",
|
|
44
|
+
"tail_records",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
#: How much of the file's end is read first. A turn of tool calls is a few kilobytes; a
|
|
48
|
+
#: quarter megabyte covers the last several turns of nearly every session and costs a
|
|
49
|
+
#: few milliseconds. The window doubles until it holds a human prompt.
|
|
50
|
+
TAIL_BYTES = 256 * 1024
|
|
51
|
+
|
|
52
|
+
#: How many recent tool calls an :class:`Activity` carries.
|
|
53
|
+
RECENT_TOOLS = 6
|
|
54
|
+
|
|
55
|
+
#: The tool Claude Code uses to put a structured question to its human. A call to it with
|
|
56
|
+
#: no result yet is a session waiting on a person.
|
|
57
|
+
QUESTION_TOOL = "AskUserQuestion"
|
|
58
|
+
|
|
59
|
+
#: Which input field best says what a tool call was about, first match wins. A
|
|
60
|
+
#: ``description`` is written for a human; a path or a target is the next best thing.
|
|
61
|
+
_ARG_KEYS = (
|
|
62
|
+
"description",
|
|
63
|
+
"file_path",
|
|
64
|
+
"path",
|
|
65
|
+
"pattern",
|
|
66
|
+
"to",
|
|
67
|
+
"query",
|
|
68
|
+
"url",
|
|
69
|
+
"prompt",
|
|
70
|
+
"command",
|
|
71
|
+
"message",
|
|
72
|
+
)
|
|
73
|
+
_PATH_KEYS = ("file_path", "path")
|
|
74
|
+
_ARG_LIMIT = 80
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _parse_lines(data: bytes) -> list[dict]:
|
|
78
|
+
records = []
|
|
79
|
+
for line in data.splitlines():
|
|
80
|
+
line = line.strip()
|
|
81
|
+
if not line:
|
|
82
|
+
continue
|
|
83
|
+
try:
|
|
84
|
+
rec = json.loads(line)
|
|
85
|
+
except ValueError:
|
|
86
|
+
continue
|
|
87
|
+
if isinstance(rec, dict):
|
|
88
|
+
records.append(rec)
|
|
89
|
+
return records
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_records(path: str | Path) -> list[dict]:
|
|
93
|
+
"""Every record in a transcript, tolerating blank and malformed lines."""
|
|
94
|
+
try:
|
|
95
|
+
return _parse_lines(Path(path).read_bytes())
|
|
96
|
+
except OSError:
|
|
97
|
+
return []
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _has_prompt(records: list[dict]) -> bool:
|
|
101
|
+
return bool(parse_session(records).last_user_prompt)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def tail_records(
|
|
105
|
+
path: str | Path,
|
|
106
|
+
*,
|
|
107
|
+
tail_bytes: int = TAIL_BYTES,
|
|
108
|
+
enough: Callable[[list[dict]], bool] = _has_prompt,
|
|
109
|
+
) -> tuple[list[dict], bool]:
|
|
110
|
+
"""The records in the last ``tail_bytes`` of a transcript, widening until ``enough``.
|
|
111
|
+
|
|
112
|
+
Returns the records and whether the window reached the start of the file. The first
|
|
113
|
+
line of a window that starts mid-file is a fragment and is dropped.
|
|
114
|
+
|
|
115
|
+
>>> tail_records('/nonexistent-file-for-doctest')
|
|
116
|
+
([], True)
|
|
117
|
+
"""
|
|
118
|
+
path = Path(path)
|
|
119
|
+
try:
|
|
120
|
+
size = path.stat().st_size
|
|
121
|
+
except OSError:
|
|
122
|
+
return [], True
|
|
123
|
+
window = max(1, tail_bytes)
|
|
124
|
+
while True:
|
|
125
|
+
start = max(0, size - window)
|
|
126
|
+
try:
|
|
127
|
+
with path.open("rb") as f:
|
|
128
|
+
f.seek(start)
|
|
129
|
+
data = f.read()
|
|
130
|
+
except OSError:
|
|
131
|
+
return [], True
|
|
132
|
+
if start > 0:
|
|
133
|
+
data = data.partition(b"\n")[2]
|
|
134
|
+
records = _parse_lines(data)
|
|
135
|
+
if start == 0 or enough(records):
|
|
136
|
+
return records, start == 0
|
|
137
|
+
window *= 2
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _blocks(record: dict) -> list[dict]:
|
|
141
|
+
content = (record.get("message") or {}).get("content")
|
|
142
|
+
if isinstance(content, list):
|
|
143
|
+
return [b for b in content if isinstance(b, dict)]
|
|
144
|
+
return []
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _stamp(record: dict) -> str:
|
|
148
|
+
return str(record.get("timestamp") or "")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _main_thread(records: list[dict]) -> list[dict]:
|
|
152
|
+
return sorted((r for r in records if not r.get("isSidechain")), key=_stamp)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def describe_tool(name: str, inputs: dict | None, *, limit: int = _ARG_LIMIT) -> str:
|
|
156
|
+
"""One line naming a tool call: the tool, and the argument that says what it did.
|
|
157
|
+
|
|
158
|
+
>>> describe_tool('Bash', {'command': 'ls -la', 'description': 'List files'})
|
|
159
|
+
'Bash: List files'
|
|
160
|
+
>>> describe_tool('Read', {'file_path': '/a/b/c.py'})
|
|
161
|
+
'Read: c.py'
|
|
162
|
+
>>> describe_tool('AskUserQuestion', {'questions': [{'question': 'Ship it?'}]})
|
|
163
|
+
'AskUserQuestion: Ship it?'
|
|
164
|
+
>>> describe_tool('ListAgents', {})
|
|
165
|
+
'ListAgents'
|
|
166
|
+
"""
|
|
167
|
+
inputs = inputs if isinstance(inputs, dict) else {}
|
|
168
|
+
arg = ""
|
|
169
|
+
if name == QUESTION_TOOL:
|
|
170
|
+
questions = inputs.get("questions") or []
|
|
171
|
+
first = questions[0] if questions and isinstance(questions[0], dict) else {}
|
|
172
|
+
arg = str(first.get("question") or "")
|
|
173
|
+
else:
|
|
174
|
+
for key in _ARG_KEYS:
|
|
175
|
+
value = inputs.get(key)
|
|
176
|
+
if isinstance(value, str) and value.strip():
|
|
177
|
+
arg = Path(value).name if key in _PATH_KEYS else value
|
|
178
|
+
break
|
|
179
|
+
arg = " ".join(arg.split())
|
|
180
|
+
if len(arg) > limit:
|
|
181
|
+
arg = arg[: limit - 1].rstrip() + "…"
|
|
182
|
+
return f"{name}: {arg}" if arg else name
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _tool_calls(records: list[dict]) -> tuple[list[tuple[str, str, dict]], set[str]]:
|
|
186
|
+
"""Every tool call on the main thread in order, and the ids that got a result."""
|
|
187
|
+
calls: list[tuple[str, str, dict]] = []
|
|
188
|
+
answered: set[str] = set()
|
|
189
|
+
for rec in records:
|
|
190
|
+
if rec.get("type") == "assistant":
|
|
191
|
+
for b in _blocks(rec):
|
|
192
|
+
if b.get("type") == "tool_use":
|
|
193
|
+
calls.append(
|
|
194
|
+
(
|
|
195
|
+
str(b.get("id") or ""),
|
|
196
|
+
str(b.get("name") or ""),
|
|
197
|
+
b.get("input") or {},
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
elif rec.get("type") == "user":
|
|
201
|
+
for b in _blocks(rec):
|
|
202
|
+
if b.get("type") == "tool_result" and b.get("tool_use_id"):
|
|
203
|
+
answered.add(str(b["tool_use_id"]))
|
|
204
|
+
return calls, answered
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@dataclass(frozen=True)
|
|
208
|
+
class Activity:
|
|
209
|
+
"""What one session is doing, as its transcript tail reads. Flat and JSON-shaped.
|
|
210
|
+
|
|
211
|
+
``in_flight`` lists tool calls with no result yet, oldest first: normally one, several
|
|
212
|
+
when calls were issued in parallel. ``pending_question`` is the first question of an
|
|
213
|
+
in-flight :data:`QUESTION_TOOL` call -- a session waiting on a person.
|
|
214
|
+
``tail_complete`` says whether the window reached the start of the file, which is what
|
|
215
|
+
makes the difference between "no prompt in the tail" and "no prompt at all".
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
session_id: str = ""
|
|
219
|
+
last_event_at: str = ""
|
|
220
|
+
last_user_prompt: str = ""
|
|
221
|
+
last_prompt_at: str = ""
|
|
222
|
+
last_assistant_text: str = ""
|
|
223
|
+
last_text_at: str = ""
|
|
224
|
+
recent_tools: tuple[str, ...] = ()
|
|
225
|
+
in_flight: tuple[str, ...] = ()
|
|
226
|
+
pending_question: str = ""
|
|
227
|
+
turn_open: bool = False
|
|
228
|
+
errored: bool = False
|
|
229
|
+
git_branch: str = ""
|
|
230
|
+
tail_complete: bool = True
|
|
231
|
+
|
|
232
|
+
def as_dict(self) -> dict:
|
|
233
|
+
return asdict(self)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def read_activity(
|
|
237
|
+
path: str | Path,
|
|
238
|
+
*,
|
|
239
|
+
session_id: str = "",
|
|
240
|
+
tail_bytes: int = TAIL_BYTES,
|
|
241
|
+
recent: int = RECENT_TOOLS,
|
|
242
|
+
) -> Activity:
|
|
243
|
+
"""Read the tail of a transcript into an :class:`Activity`.
|
|
244
|
+
|
|
245
|
+
Costs the watched session nothing: the file is opened read-only and the session is
|
|
246
|
+
never signalled, messaged or otherwise made aware.
|
|
247
|
+
"""
|
|
248
|
+
records, complete = tail_records(path, tail_bytes=tail_bytes)
|
|
249
|
+
main = _main_thread(records)
|
|
250
|
+
session = parse_session(main, key=session_id)
|
|
251
|
+
calls, answered = _tool_calls(main)
|
|
252
|
+
in_flight = [
|
|
253
|
+
(name, inputs) for call_id, name, inputs in calls if call_id not in answered
|
|
254
|
+
]
|
|
255
|
+
question = next((describe_tool(n, i) for n, i in in_flight if n == QUESTION_TOOL), "")
|
|
256
|
+
stamps = [_stamp(r) for r in main if _stamp(r)]
|
|
257
|
+
return Activity(
|
|
258
|
+
session_id=session.key or session_id,
|
|
259
|
+
last_event_at=max(stamps) if stamps else "",
|
|
260
|
+
last_user_prompt=session.last_user_prompt,
|
|
261
|
+
last_prompt_at=session.last_prompt_at,
|
|
262
|
+
last_assistant_text=session.last_assistant_text,
|
|
263
|
+
last_text_at=session.last_turn_at,
|
|
264
|
+
recent_tools=tuple(describe_tool(n, i) for _, n, i in calls[-recent:])
|
|
265
|
+
if recent
|
|
266
|
+
else (),
|
|
267
|
+
in_flight=tuple(describe_tool(n, i) for n, i in in_flight),
|
|
268
|
+
pending_question=question.partition(": ")[2] if question else "",
|
|
269
|
+
turn_open=session.ended_mid_turn,
|
|
270
|
+
errored=session.ended_with_error,
|
|
271
|
+
git_branch=session.git_branch,
|
|
272
|
+
tail_complete=complete,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@dataclass(frozen=True)
|
|
277
|
+
class Turn:
|
|
278
|
+
"""One exchange: a human prompt, what the assistant did, and its last words."""
|
|
279
|
+
|
|
280
|
+
index: int
|
|
281
|
+
prompt: str
|
|
282
|
+
prompt_at: str
|
|
283
|
+
reply: str
|
|
284
|
+
reply_at: str
|
|
285
|
+
tools: tuple[str, ...]
|
|
286
|
+
|
|
287
|
+
def as_dict(self) -> dict:
|
|
288
|
+
return asdict(self)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _opens_a_turn(record: dict) -> bool:
|
|
292
|
+
"""A ``user`` record that could be a human prompt: not a tool result, not injected."""
|
|
293
|
+
if (
|
|
294
|
+
record.get("type") != "user"
|
|
295
|
+
or record.get("isMeta")
|
|
296
|
+
or record.get("isCompactSummary")
|
|
297
|
+
):
|
|
298
|
+
return False
|
|
299
|
+
return not any(b.get("type") == "tool_result" for b in _blocks(record))
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _turn_chunks(records: list[dict]) -> list[list[dict]]:
|
|
303
|
+
"""Split main-thread records at each human prompt.
|
|
304
|
+
|
|
305
|
+
A ``user`` record that opens a chunk but carries no human text once its tooling
|
|
306
|
+
wrappers are stripped -- a system reminder, a task notification -- was not a turn, and
|
|
307
|
+
its chunk is folded into the turn before it. That judgement is
|
|
308
|
+
:func:`openloops.transcripts.parse_session`'s, made on the chunk, not repeated here.
|
|
309
|
+
"""
|
|
310
|
+
chunks: list[list[dict]] = [[]]
|
|
311
|
+
for rec in records:
|
|
312
|
+
if _opens_a_turn(rec):
|
|
313
|
+
chunks.append([rec])
|
|
314
|
+
else:
|
|
315
|
+
chunks[-1].append(rec)
|
|
316
|
+
turns: list[list[dict]] = []
|
|
317
|
+
for chunk in chunks:
|
|
318
|
+
if chunk and parse_session(chunk).last_user_prompt:
|
|
319
|
+
turns.append(chunk)
|
|
320
|
+
elif turns:
|
|
321
|
+
turns[-1].extend(chunk)
|
|
322
|
+
return turns
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def read_turns(
|
|
326
|
+
path: str | Path,
|
|
327
|
+
*,
|
|
328
|
+
last: int = 5,
|
|
329
|
+
before: int | None = None,
|
|
330
|
+
) -> list[Turn]:
|
|
331
|
+
"""The last ``last`` turns of a transcript, oldest first; ``before=N`` pages back.
|
|
332
|
+
|
|
333
|
+
Reads the whole file. This is the deep path, taken on request when the tail did not
|
|
334
|
+
carry enough context -- still cheaper than a turn of the watched session's own.
|
|
335
|
+
"""
|
|
336
|
+
chunks = _turn_chunks(_main_thread(load_records(path)))
|
|
337
|
+
turns = []
|
|
338
|
+
for index, chunk in enumerate(chunks, start=1):
|
|
339
|
+
session = parse_session(chunk)
|
|
340
|
+
calls, _ = _tool_calls(chunk)
|
|
341
|
+
turns.append(
|
|
342
|
+
Turn(
|
|
343
|
+
index=index,
|
|
344
|
+
prompt=session.last_user_prompt,
|
|
345
|
+
prompt_at=session.last_prompt_at,
|
|
346
|
+
reply=session.last_assistant_text,
|
|
347
|
+
reply_at=session.last_turn_at,
|
|
348
|
+
tools=tuple(describe_tool(n, i) for _, n, i in calls),
|
|
349
|
+
)
|
|
350
|
+
)
|
|
351
|
+
if before is not None:
|
|
352
|
+
turns = [t for t in turns if t.index < before]
|
|
353
|
+
return turns[-last:] if last > 0 else turns
|