agent-trace-cli 0.1.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.
- agent_trace/__init__.py +3 -0
- agent_trace/blame.py +471 -0
- agent_trace/blame_git.py +133 -0
- agent_trace/blame_meta.py +167 -0
- agent_trace/cli.py +2680 -0
- agent_trace/commit_link.py +226 -0
- agent_trace/config.py +137 -0
- agent_trace/context.py +385 -0
- agent_trace/conversations.py +358 -0
- agent_trace/git_notes.py +491 -0
- agent_trace/hooks/__init__.py +163 -0
- agent_trace/hooks/base.py +233 -0
- agent_trace/hooks/claude.py +483 -0
- agent_trace/hooks/codex.py +232 -0
- agent_trace/hooks/cursor.py +278 -0
- agent_trace/hooks/git.py +144 -0
- agent_trace/ledger.py +955 -0
- agent_trace/models.py +618 -0
- agent_trace/record.py +417 -0
- agent_trace/registry.py +230 -0
- agent_trace/remote.py +673 -0
- agent_trace/rewrite.py +176 -0
- agent_trace/rules.py +209 -0
- agent_trace/schemas/commit-link.schema.json +34 -0
- agent_trace/schemas/git-note.schema.json +88 -0
- agent_trace/schemas/ledger.schema.json +97 -0
- agent_trace/schemas/remotes.schema.json +30 -0
- agent_trace/schemas/sync-state.schema.json +49 -0
- agent_trace/schemas/trace-record.schema.json +130 -0
- agent_trace/session.py +136 -0
- agent_trace/storage.py +229 -0
- agent_trace/summary.py +465 -0
- agent_trace/summary_presets.py +188 -0
- agent_trace/sync.py +1182 -0
- agent_trace/telemetry.py +142 -0
- agent_trace/trace.py +460 -0
- agent_trace_cli-0.1.0.dist-info/METADATA +535 -0
- agent_trace_cli-0.1.0.dist-info/RECORD +42 -0
- agent_trace_cli-0.1.0.dist-info/WHEEL +5 -0
- agent_trace_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agent_trace_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- agent_trace_cli-0.1.0.dist-info/top_level.txt +1 -0
agent_trace/summary.py
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Conversation summaries — pluggable summarization of transcripts referenced
|
|
3
|
+
by traces, keyed by ``conversation_id``.
|
|
4
|
+
|
|
5
|
+
For each session the transcript bytes live in the per-project
|
|
6
|
+
content-addressed cache (``<project>/conversations/<sha[:2]>/<sha>``).
|
|
7
|
+
Summary generation reads the latest cached snapshot referenced by traces
|
|
8
|
+
for a given conversation_id and pipes it to a user-configured command.
|
|
9
|
+
The command's stdout is stored keyed by ``conversation_id``.
|
|
10
|
+
|
|
11
|
+
Opt-in via ``project-config.json`` → ``summary.enabled`` and
|
|
12
|
+
``summary.command``. Storage: ``session-summaries.jsonl`` under the
|
|
13
|
+
project dir, one row per ``(conversation_id, summary)``. Failures never
|
|
14
|
+
raise through hooks.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import shlex
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from .conversations import (
|
|
28
|
+
compute_conversation_id,
|
|
29
|
+
latest_sha_for_conversation,
|
|
30
|
+
read_blob_from_cache,
|
|
31
|
+
)
|
|
32
|
+
from .storage import (
|
|
33
|
+
ensure_project_dir,
|
|
34
|
+
get_session_summaries_path,
|
|
35
|
+
get_traces_path,
|
|
36
|
+
resolve_project_id,
|
|
37
|
+
)
|
|
38
|
+
from .summary_presets import augment_path_env
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _log(msg: str) -> None:
|
|
42
|
+
print(f"agent-trace: {msg}", file=sys.stderr)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# -------------------------------------------------------------------
|
|
46
|
+
# Conversation id → latest content_sha256 lookup (from local traces)
|
|
47
|
+
# -------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
def _read_transcript_from_cache(
|
|
50
|
+
project_id: str, conversation_id: str,
|
|
51
|
+
) -> str | None:
|
|
52
|
+
"""Read the latest cached transcript bytes for a conversation_id."""
|
|
53
|
+
sha = latest_sha_for_conversation(project_id, conversation_id)
|
|
54
|
+
if not sha:
|
|
55
|
+
return None
|
|
56
|
+
data = read_blob_from_cache(project_id, sha)
|
|
57
|
+
if data is None:
|
|
58
|
+
return None
|
|
59
|
+
try:
|
|
60
|
+
return data.decode("utf-8", errors="replace")
|
|
61
|
+
except Exception:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# -------------------------------------------------------------------
|
|
66
|
+
# Summary command runner
|
|
67
|
+
# -------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
def generate_summary_text(
|
|
70
|
+
transcript_text: str,
|
|
71
|
+
command: str,
|
|
72
|
+
timeout_seconds: int = 30,
|
|
73
|
+
) -> str | None:
|
|
74
|
+
"""Run ``command`` with ``transcript_text`` on stdin; return trimmed stdout or ``None``."""
|
|
75
|
+
if not command.strip() or not transcript_text:
|
|
76
|
+
return None
|
|
77
|
+
try:
|
|
78
|
+
argv = shlex.split(command, posix=os.name != "nt")
|
|
79
|
+
except ValueError:
|
|
80
|
+
return None
|
|
81
|
+
if not argv:
|
|
82
|
+
return None
|
|
83
|
+
try:
|
|
84
|
+
r = subprocess.run(
|
|
85
|
+
argv,
|
|
86
|
+
input=transcript_text,
|
|
87
|
+
capture_output=True,
|
|
88
|
+
text=True,
|
|
89
|
+
timeout=timeout_seconds,
|
|
90
|
+
env=augment_path_env(),
|
|
91
|
+
)
|
|
92
|
+
except subprocess.TimeoutExpired:
|
|
93
|
+
_log(
|
|
94
|
+
f"summary command timed out after {timeout_seconds}s "
|
|
95
|
+
"(try: agent-trace config set summary.timeout-seconds 120)",
|
|
96
|
+
)
|
|
97
|
+
return None
|
|
98
|
+
except (OSError, ValueError) as e:
|
|
99
|
+
_log(f"summary command failed to start: {e}")
|
|
100
|
+
return None
|
|
101
|
+
if r.returncode != 0:
|
|
102
|
+
err = (r.stderr or "").strip()
|
|
103
|
+
if err:
|
|
104
|
+
_log(f"summary command stderr (exit {r.returncode}): {err[:1200]}")
|
|
105
|
+
else:
|
|
106
|
+
_log(f"summary command exited with {r.returncode}")
|
|
107
|
+
return None
|
|
108
|
+
out = (r.stdout or "").strip()
|
|
109
|
+
if not out:
|
|
110
|
+
err = (r.stderr or "").strip()
|
|
111
|
+
if err:
|
|
112
|
+
_log(f"summary command produced empty stdout; stderr: {err[:1200]}")
|
|
113
|
+
return None
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# -------------------------------------------------------------------
|
|
118
|
+
# session-summaries.jsonl I/O
|
|
119
|
+
# -------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
def append_summary(
|
|
122
|
+
project_id: str,
|
|
123
|
+
conversation_id: str,
|
|
124
|
+
summary: str,
|
|
125
|
+
*,
|
|
126
|
+
session_id: str | None = None,
|
|
127
|
+
created_at: str | None = None,
|
|
128
|
+
) -> dict[str, Any] | None:
|
|
129
|
+
"""Append one row to ``session-summaries.jsonl``: ``{conversation_id, summary, ...}``.
|
|
130
|
+
|
|
131
|
+
Returns the appended row dict (used by sync materialisation to track the
|
|
132
|
+
synthetic ``<conversation_id>:<created_at>`` key) or ``None`` on error.
|
|
133
|
+
"""
|
|
134
|
+
if not conversation_id or not summary:
|
|
135
|
+
return None
|
|
136
|
+
ensure_project_dir(project_id)
|
|
137
|
+
path = get_session_summaries_path(project_id)
|
|
138
|
+
row: dict[str, Any] = {
|
|
139
|
+
"conversation_id": conversation_id,
|
|
140
|
+
"summary": summary,
|
|
141
|
+
"created_at": created_at or datetime.now(timezone.utc).isoformat(),
|
|
142
|
+
}
|
|
143
|
+
if session_id:
|
|
144
|
+
row["session_id"] = session_id
|
|
145
|
+
try:
|
|
146
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
147
|
+
f.write(json.dumps(row, ensure_ascii=False) + "\n")
|
|
148
|
+
except OSError:
|
|
149
|
+
return None
|
|
150
|
+
return row
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def iter_summary_rows(project_id: str) -> list[dict[str, Any]]:
|
|
154
|
+
"""All summary rows for a project, in file order. Used by sync push."""
|
|
155
|
+
path = get_session_summaries_path(project_id)
|
|
156
|
+
if not path.exists():
|
|
157
|
+
return []
|
|
158
|
+
out: list[dict[str, Any]] = []
|
|
159
|
+
try:
|
|
160
|
+
for line in path.read_text().splitlines():
|
|
161
|
+
line = line.strip()
|
|
162
|
+
if not line:
|
|
163
|
+
continue
|
|
164
|
+
try:
|
|
165
|
+
row = json.loads(line)
|
|
166
|
+
except json.JSONDecodeError:
|
|
167
|
+
continue
|
|
168
|
+
if (
|
|
169
|
+
isinstance(row, dict)
|
|
170
|
+
and isinstance(row.get("conversation_id"), str)
|
|
171
|
+
and isinstance(row.get("summary"), str)
|
|
172
|
+
):
|
|
173
|
+
out.append(row)
|
|
174
|
+
except OSError:
|
|
175
|
+
pass
|
|
176
|
+
return out
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def latest_summary_by_id(project_id: str) -> dict[str, str]:
|
|
180
|
+
"""Map ``conversation_id → latest summary`` across all rows (file order)."""
|
|
181
|
+
out: dict[str, str] = {}
|
|
182
|
+
for row in iter_summary_rows(project_id):
|
|
183
|
+
cid = row.get("conversation_id")
|
|
184
|
+
s = row.get("summary")
|
|
185
|
+
if isinstance(cid, str) and isinstance(s, str) and cid and s:
|
|
186
|
+
out[cid] = s
|
|
187
|
+
return out
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def get_summary_for_id(project_dir: str, conversation_id: str) -> str | None:
|
|
191
|
+
pid = resolve_project_id(project_dir, create=False)
|
|
192
|
+
if not pid:
|
|
193
|
+
return None
|
|
194
|
+
return latest_summary_by_id(pid).get(conversation_id)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# -------------------------------------------------------------------
|
|
198
|
+
# Per-ledger / per-commit summary lookup (used by git notes)
|
|
199
|
+
# -------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
def _ledger_for_commit(project_id: str, commit_sha: str) -> dict[str, Any] | None:
|
|
202
|
+
from .storage import get_ledgers_path
|
|
203
|
+
|
|
204
|
+
path = get_ledgers_path(project_id)
|
|
205
|
+
if not path.exists():
|
|
206
|
+
return None
|
|
207
|
+
try:
|
|
208
|
+
for line in path.read_text().splitlines():
|
|
209
|
+
line = line.strip()
|
|
210
|
+
if not line:
|
|
211
|
+
continue
|
|
212
|
+
try:
|
|
213
|
+
led = json.loads(line)
|
|
214
|
+
except json.JSONDecodeError:
|
|
215
|
+
continue
|
|
216
|
+
if str(led.get("commit_sha", "")) == commit_sha:
|
|
217
|
+
return led if isinstance(led, dict) else None
|
|
218
|
+
except OSError:
|
|
219
|
+
pass
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _ids_in_ledger(ledger: dict[str, Any]) -> set[str]:
|
|
224
|
+
ids: set[str] = set()
|
|
225
|
+
files = ledger.get("files", {})
|
|
226
|
+
if not isinstance(files, dict):
|
|
227
|
+
return ids
|
|
228
|
+
for fl in files.values():
|
|
229
|
+
if not isinstance(fl, dict):
|
|
230
|
+
continue
|
|
231
|
+
for seg in fl.get("line_attributions", []):
|
|
232
|
+
if not isinstance(seg, dict):
|
|
233
|
+
continue
|
|
234
|
+
cid = seg.get("conversation_id")
|
|
235
|
+
if isinstance(cid, str) and cid:
|
|
236
|
+
ids.add(cid)
|
|
237
|
+
return ids
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def get_summary_for_commit(project_id: str, commit_sha: str) -> dict[str, str] | None:
|
|
241
|
+
"""``{conversation_id: summary}`` for every id referenced by the commit's ledger."""
|
|
242
|
+
led = _ledger_for_commit(project_id, commit_sha)
|
|
243
|
+
if not led:
|
|
244
|
+
return None
|
|
245
|
+
ids = _ids_in_ledger(led)
|
|
246
|
+
if not ids:
|
|
247
|
+
return None
|
|
248
|
+
by_id = latest_summary_by_id(project_id)
|
|
249
|
+
out = {c: by_id[c] for c in ids if c in by_id}
|
|
250
|
+
return out if out else None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def merge_note_summaries(
|
|
254
|
+
project_dir: str,
|
|
255
|
+
ledger: dict[str, Any],
|
|
256
|
+
static_summaries: dict[str, Any] | None = None, # accepted for caller compat; ignored
|
|
257
|
+
) -> dict[str, str] | None:
|
|
258
|
+
"""Resolve conversation_id → summary for the git note. Static map is ignored."""
|
|
259
|
+
del static_summaries
|
|
260
|
+
pid = resolve_project_id(project_dir, create=False)
|
|
261
|
+
if not pid:
|
|
262
|
+
return None
|
|
263
|
+
sha = str(ledger.get("commit_sha", ""))
|
|
264
|
+
if not sha:
|
|
265
|
+
return None
|
|
266
|
+
return get_summary_for_commit(pid, sha)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def all_session_conversations_for_ledger(
|
|
270
|
+
project_dir: str,
|
|
271
|
+
ledger: dict[str, Any],
|
|
272
|
+
) -> list[dict[str, Any]] | None:
|
|
273
|
+
"""Build ``all_session_conversations`` for a git note: every distinct
|
|
274
|
+
``conversation_id`` from traces in the same staging window as the ledger,
|
|
275
|
+
with the latest stored summary per id (if any).
|
|
276
|
+
"""
|
|
277
|
+
from .ledger import list_traces_in_staging_window
|
|
278
|
+
|
|
279
|
+
parent_sha = ledger.get("parent_sha")
|
|
280
|
+
parent_at = ledger.get("parent_committed_at")
|
|
281
|
+
committed_at = ledger.get("committed_at")
|
|
282
|
+
|
|
283
|
+
parents: list[tuple[str, str | None]] = []
|
|
284
|
+
if parent_sha:
|
|
285
|
+
parents.append((str(parent_sha), str(parent_at) if parent_at else None))
|
|
286
|
+
raw = list_traces_in_staging_window(
|
|
287
|
+
project_dir,
|
|
288
|
+
parents,
|
|
289
|
+
str(committed_at) if committed_at else None,
|
|
290
|
+
)
|
|
291
|
+
ids: list[str] = []
|
|
292
|
+
seen: set[str] = set()
|
|
293
|
+
for t in raw:
|
|
294
|
+
for fe in t.get("files") or []:
|
|
295
|
+
if not isinstance(fe, dict):
|
|
296
|
+
continue
|
|
297
|
+
for conv in fe.get("conversations") or []:
|
|
298
|
+
if not isinstance(conv, dict):
|
|
299
|
+
continue
|
|
300
|
+
cid = conv.get("id")
|
|
301
|
+
if isinstance(cid, str) and cid and cid not in seen:
|
|
302
|
+
seen.add(cid)
|
|
303
|
+
ids.append(cid)
|
|
304
|
+
if not ids:
|
|
305
|
+
return None
|
|
306
|
+
pid = resolve_project_id(project_dir, create=False)
|
|
307
|
+
if not pid:
|
|
308
|
+
return [{"conversation_id": c, "summary": None} for c in ids]
|
|
309
|
+
by_id = latest_summary_by_id(pid)
|
|
310
|
+
out: list[dict[str, Any]] = []
|
|
311
|
+
for cid in ids:
|
|
312
|
+
s = by_id.get(cid)
|
|
313
|
+
row: dict[str, Any] = {"conversation_id": cid}
|
|
314
|
+
row["summary"] = s if s is not None else None
|
|
315
|
+
out.append(row)
|
|
316
|
+
return out
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# -------------------------------------------------------------------
|
|
320
|
+
# Session-id → conversation_ids helper (for `summary generate --session-id`)
|
|
321
|
+
# -------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
def _conversation_ids_for_session(project_id: str, session_id: str) -> list[str]:
|
|
324
|
+
"""All distinct ``conversation_id``s referenced by traces in this session, in first-seen order."""
|
|
325
|
+
path = get_traces_path(project_id)
|
|
326
|
+
if not path.exists():
|
|
327
|
+
return []
|
|
328
|
+
seen: list[str] = []
|
|
329
|
+
seen_set: set[str] = set()
|
|
330
|
+
try:
|
|
331
|
+
for line in path.read_text().splitlines():
|
|
332
|
+
line = line.strip()
|
|
333
|
+
if not line:
|
|
334
|
+
continue
|
|
335
|
+
try:
|
|
336
|
+
row = json.loads(line)
|
|
337
|
+
except json.JSONDecodeError:
|
|
338
|
+
continue
|
|
339
|
+
meta = row.get("metadata") or {}
|
|
340
|
+
sid = meta.get("session_id") or meta.get("conversation_id")
|
|
341
|
+
if not sid or str(sid) != str(session_id):
|
|
342
|
+
continue
|
|
343
|
+
for fe in row.get("files", []) or []:
|
|
344
|
+
if not isinstance(fe, dict):
|
|
345
|
+
continue
|
|
346
|
+
for conv in fe.get("conversations", []) or []:
|
|
347
|
+
if not isinstance(conv, dict):
|
|
348
|
+
continue
|
|
349
|
+
cid = conv.get("id")
|
|
350
|
+
if isinstance(cid, str) and cid and cid not in seen_set:
|
|
351
|
+
seen.append(cid)
|
|
352
|
+
seen_set.add(cid)
|
|
353
|
+
except OSError:
|
|
354
|
+
pass
|
|
355
|
+
return seen
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# -------------------------------------------------------------------
|
|
359
|
+
# Summarize a single conversation_id (used by hooks + manual regenerate)
|
|
360
|
+
# -------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
def _summarize_id(
|
|
363
|
+
project_id: str,
|
|
364
|
+
conversation_id: str,
|
|
365
|
+
command: str,
|
|
366
|
+
timeout_seconds: int,
|
|
367
|
+
session_id: str | None,
|
|
368
|
+
) -> str | None:
|
|
369
|
+
text = _read_transcript_from_cache(project_id, conversation_id)
|
|
370
|
+
if not text:
|
|
371
|
+
return None
|
|
372
|
+
summary = generate_summary_text(text, command, timeout_seconds=timeout_seconds)
|
|
373
|
+
if summary:
|
|
374
|
+
append_summary(project_id, conversation_id, summary, session_id=session_id)
|
|
375
|
+
return summary
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def run_session_summary_hook(data: dict[str, Any]) -> None:
|
|
379
|
+
"""Called from ``record`` on stop / agent-response / Cursor ``sessionEnd``; never raises.
|
|
380
|
+
|
|
381
|
+
Reads the latest cached transcript bytes (the session-end dispatch in
|
|
382
|
+
``record`` snapshots the transcript before calling us) and, if
|
|
383
|
+
``summary.enabled``, pipes them to the configured summary command.
|
|
384
|
+
The result is stored keyed by ``conversation_id``.
|
|
385
|
+
"""
|
|
386
|
+
try:
|
|
387
|
+
from .config import get_project_config
|
|
388
|
+
from .record import project_dir_for_summary_hook, transcript_path_from_hook
|
|
389
|
+
|
|
390
|
+
cwd = project_dir_for_summary_hook(data)
|
|
391
|
+
cfg = get_project_config(project_dir=cwd)
|
|
392
|
+
if not cfg:
|
|
393
|
+
return
|
|
394
|
+
sm = cfg.get("summary")
|
|
395
|
+
if not isinstance(sm, dict) or not sm.get("enabled"):
|
|
396
|
+
return
|
|
397
|
+
command = sm.get("command")
|
|
398
|
+
if not command or not isinstance(command, str):
|
|
399
|
+
return
|
|
400
|
+
timeout = int(sm.get("timeout_seconds", 30))
|
|
401
|
+
|
|
402
|
+
session_id = str(
|
|
403
|
+
data.get("conversation_id") or data.get("session_id") or "",
|
|
404
|
+
).strip() or None
|
|
405
|
+
|
|
406
|
+
pid = resolve_project_id(cwd, create=False)
|
|
407
|
+
if not pid:
|
|
408
|
+
return
|
|
409
|
+
|
|
410
|
+
transcript_path = transcript_path_from_hook(data)
|
|
411
|
+
if not transcript_path:
|
|
412
|
+
return
|
|
413
|
+
|
|
414
|
+
cid = compute_conversation_id(transcript_path)
|
|
415
|
+
text = _read_transcript_from_cache(pid, cid)
|
|
416
|
+
if not text:
|
|
417
|
+
return
|
|
418
|
+
|
|
419
|
+
summary = generate_summary_text(text, command, timeout_seconds=timeout)
|
|
420
|
+
if summary:
|
|
421
|
+
append_summary(pid, cid, summary, session_id=session_id)
|
|
422
|
+
else:
|
|
423
|
+
_log("summary command failed or produced no output")
|
|
424
|
+
except Exception as exc:
|
|
425
|
+
_log(f"summary hook error: {exc}")
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def run_summary_generate(
|
|
429
|
+
project_dir: str,
|
|
430
|
+
*,
|
|
431
|
+
conversation_id: str | None = None,
|
|
432
|
+
session_id: str | None = None,
|
|
433
|
+
) -> dict[str, str] | None:
|
|
434
|
+
"""Manual regeneration. Pass ``conversation_id`` (one id) or ``session_id``
|
|
435
|
+
(every id referenced by that session's traces). Returns ``{conversation_id: summary}``."""
|
|
436
|
+
from .config import get_project_config
|
|
437
|
+
|
|
438
|
+
cfg = get_project_config(project_dir=project_dir)
|
|
439
|
+
if not cfg:
|
|
440
|
+
return None
|
|
441
|
+
sm = cfg.get("summary")
|
|
442
|
+
if not isinstance(sm, dict) or not sm.get("enabled"):
|
|
443
|
+
return None
|
|
444
|
+
command = sm.get("command")
|
|
445
|
+
if not command or not isinstance(command, str):
|
|
446
|
+
return None
|
|
447
|
+
timeout = int(sm.get("timeout_seconds", 30))
|
|
448
|
+
pid = resolve_project_id(project_dir, create=False)
|
|
449
|
+
if not pid:
|
|
450
|
+
return None
|
|
451
|
+
|
|
452
|
+
ids: list[str] = []
|
|
453
|
+
if conversation_id:
|
|
454
|
+
ids = [conversation_id]
|
|
455
|
+
elif session_id:
|
|
456
|
+
ids = _conversation_ids_for_session(pid, session_id)
|
|
457
|
+
if not ids:
|
|
458
|
+
return None
|
|
459
|
+
|
|
460
|
+
out: dict[str, str] = {}
|
|
461
|
+
for cid in ids:
|
|
462
|
+
s = _summarize_id(pid, cid, command, timeout, session_id)
|
|
463
|
+
if s:
|
|
464
|
+
out[cid] = s
|
|
465
|
+
return out or None
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Built-in summary command presets.
|
|
3
|
+
|
|
4
|
+
These presets are wrappers around local CLI tools:
|
|
5
|
+
- claude-summary -> ``claude -p "<prompt>"`` (prompt in argv; huge transcripts may hit OS limits)
|
|
6
|
+
- cursor-summary -> ``cursor agent --print --trust`` with the prompt on **stdin** (avoids argv limits)
|
|
7
|
+
- ollama-summary -> ``ollama run <model> --think=false`` with the prompt on **stdin**
|
|
8
|
+
|
|
9
|
+
The preset command executes as a separate `agent-trace` subprocess so the
|
|
10
|
+
existing summary pipeline can continue to treat summaries as "any command that
|
|
11
|
+
reads transcript on stdin and prints summary on stdout".
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
PRESET_ALIASES = ("claude-summary", "cursor-summary", "ollama-summary")
|
|
24
|
+
DEFAULT_OLLAMA_MODEL = "llama3.1:8b"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _build_prompt(transcript_text: str) -> str:
|
|
28
|
+
t = (transcript_text or "").strip()
|
|
29
|
+
if not t:
|
|
30
|
+
return ""
|
|
31
|
+
return (
|
|
32
|
+
"You are summarizing a coding-assistant conversation transcript.\n"
|
|
33
|
+
"Return concise plain text using this exact structure:\n"
|
|
34
|
+
"- Objective:\n"
|
|
35
|
+
"- Key changes:\n"
|
|
36
|
+
"- Files touched:\n"
|
|
37
|
+
"- Risks/follow-ups:\n"
|
|
38
|
+
"- Test status:\n\n"
|
|
39
|
+
"Keep it factual and avoid markdown code fences.\n\n"
|
|
40
|
+
"Transcript:\n"
|
|
41
|
+
f"{t}\n"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def augment_path_env() -> dict[str, str]:
|
|
46
|
+
"""Return a copy of the environment with common binary dirs prepended to ``PATH``.
|
|
47
|
+
|
|
48
|
+
Cursor / GUI hook processes often get a minimal ``PATH``, so ``ollama`` and
|
|
49
|
+
``cursor`` are not found even when installed (e.g. Homebrew under
|
|
50
|
+
``/opt/homebrew/bin``).
|
|
51
|
+
"""
|
|
52
|
+
env = dict(os.environ)
|
|
53
|
+
extra_dirs = [
|
|
54
|
+
"/opt/homebrew/bin",
|
|
55
|
+
"/usr/local/bin",
|
|
56
|
+
str(Path.home() / ".local" / "bin"),
|
|
57
|
+
]
|
|
58
|
+
prefix = os.pathsep.join(d for d in extra_dirs if Path(d).is_dir())
|
|
59
|
+
if not prefix:
|
|
60
|
+
return env
|
|
61
|
+
current = env.get("PATH", "")
|
|
62
|
+
env["PATH"] = prefix + os.pathsep + current if current else prefix
|
|
63
|
+
return env
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _which(name: str) -> str | None:
|
|
67
|
+
return shutil.which(name, path=augment_path_env().get("PATH"))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _emit_preset_error(tool: str, r: subprocess.CompletedProcess) -> None:
|
|
71
|
+
err = (r.stderr or "").strip()
|
|
72
|
+
out = (r.stdout or "").strip()
|
|
73
|
+
if r.returncode != 0:
|
|
74
|
+
detail = err or out or f"exit {r.returncode}"
|
|
75
|
+
else:
|
|
76
|
+
detail = err or "empty stdout"
|
|
77
|
+
print(f"agent-trace: {tool}: {detail[:1200]}", file=sys.stderr)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def list_summary_presets() -> list[dict[str, Any]]:
|
|
81
|
+
return [
|
|
82
|
+
{
|
|
83
|
+
"alias": "claude-summary",
|
|
84
|
+
"tool": "claude",
|
|
85
|
+
"description": "Run `claude -p <prompt>` locally.",
|
|
86
|
+
"needs_model": False,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"alias": "cursor-summary",
|
|
90
|
+
"tool": "cursor",
|
|
91
|
+
"description": "Run `cursor agent --print --trust` (prompt on stdin; optional --workspace from hook env).",
|
|
92
|
+
"needs_model": False,
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"alias": "ollama-summary",
|
|
96
|
+
"tool": "ollama",
|
|
97
|
+
"description": "Run `ollama run <model> --think=false` with the prompt on stdin.",
|
|
98
|
+
"needs_model": True,
|
|
99
|
+
"default_model": DEFAULT_OLLAMA_MODEL,
|
|
100
|
+
},
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def build_preset_command(alias: str, *, model: str | None = None) -> str:
|
|
105
|
+
"""Command string to store in `summary.command`."""
|
|
106
|
+
alias = (alias or "").strip()
|
|
107
|
+
if alias not in PRESET_ALIASES:
|
|
108
|
+
raise ValueError(f"unknown summary preset: {alias}")
|
|
109
|
+
if alias == "ollama-summary":
|
|
110
|
+
m = (model or "").strip() or DEFAULT_OLLAMA_MODEL
|
|
111
|
+
return f"agent-trace summary preset-run ollama-summary --model {m}"
|
|
112
|
+
return f"agent-trace summary preset-run {alias}"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def run_summary_preset(alias: str, transcript_text: str, *, model: str | None = None) -> int:
|
|
116
|
+
"""Read transcript text and print summary text to stdout. Returns exit code."""
|
|
117
|
+
alias = (alias or "").strip()
|
|
118
|
+
prompt = _build_prompt(transcript_text)
|
|
119
|
+
if not prompt:
|
|
120
|
+
return 1
|
|
121
|
+
|
|
122
|
+
env = augment_path_env()
|
|
123
|
+
|
|
124
|
+
if alias == "claude-summary":
|
|
125
|
+
exe = _which("claude")
|
|
126
|
+
if not exe:
|
|
127
|
+
print("agent-trace: claude-summary: `claude` not found on PATH", file=sys.stderr)
|
|
128
|
+
return 1
|
|
129
|
+
cmd = [exe, "-p", prompt]
|
|
130
|
+
try:
|
|
131
|
+
r = subprocess.run(cmd, capture_output=True, text=True, env=env)
|
|
132
|
+
except OSError:
|
|
133
|
+
return 1
|
|
134
|
+
elif alias == "cursor-summary":
|
|
135
|
+
exe = _which("cursor")
|
|
136
|
+
if not exe:
|
|
137
|
+
print("agent-trace: cursor-summary: `cursor` not found on PATH", file=sys.stderr)
|
|
138
|
+
return 1
|
|
139
|
+
# Do not pass the transcript as a CLI argument: ``-p`` is ``--print``, so
|
|
140
|
+
# ``cursor agent -p <transcript>`` puts the whole file in argv and fails with
|
|
141
|
+
# "argument list too long" for real sessions. Pipe the built prompt on stdin instead.
|
|
142
|
+
cmd = [exe, "agent", "--print", "--trust"]
|
|
143
|
+
ws = env.get("CURSOR_PROJECT_DIR") or env.get("CLAUDE_PROJECT_DIR")
|
|
144
|
+
if isinstance(ws, str) and ws.strip():
|
|
145
|
+
cmd.extend(["--workspace", ws.strip()])
|
|
146
|
+
try:
|
|
147
|
+
r = subprocess.run(
|
|
148
|
+
cmd,
|
|
149
|
+
input=prompt,
|
|
150
|
+
capture_output=True,
|
|
151
|
+
text=True,
|
|
152
|
+
env=env,
|
|
153
|
+
)
|
|
154
|
+
except OSError:
|
|
155
|
+
return 1
|
|
156
|
+
elif alias == "ollama-summary":
|
|
157
|
+
exe = _which("ollama")
|
|
158
|
+
if not exe:
|
|
159
|
+
print("agent-trace: ollama-summary: `ollama` not found on PATH", file=sys.stderr)
|
|
160
|
+
return 1
|
|
161
|
+
m = (model or "").strip() or DEFAULT_OLLAMA_MODEL
|
|
162
|
+
# Pass the prompt on stdin so large transcripts do not hit OS "argument list too long"
|
|
163
|
+
# (``ollama run MODEL PROMPT`` embeds the full transcript in argv).
|
|
164
|
+
# ``--think=false`` avoids qwen and similar models flooding stdout with thinking traces.
|
|
165
|
+
cmd = [exe, "run", m, "--think=false"]
|
|
166
|
+
try:
|
|
167
|
+
r = subprocess.run(
|
|
168
|
+
cmd,
|
|
169
|
+
input=prompt,
|
|
170
|
+
capture_output=True,
|
|
171
|
+
text=True,
|
|
172
|
+
env=env,
|
|
173
|
+
)
|
|
174
|
+
except OSError as e:
|
|
175
|
+
print(f"agent-trace: ollama-summary: {e}", file=sys.stderr)
|
|
176
|
+
return 1
|
|
177
|
+
else:
|
|
178
|
+
return 1
|
|
179
|
+
|
|
180
|
+
if r.returncode != 0:
|
|
181
|
+
_emit_preset_error(f"{alias}", r)
|
|
182
|
+
return r.returncode
|
|
183
|
+
out = (r.stdout or "").strip()
|
|
184
|
+
if not out:
|
|
185
|
+
_emit_preset_error(f"{alias}", r)
|
|
186
|
+
return 1
|
|
187
|
+
sys.stdout.write(out + "\n")
|
|
188
|
+
return 0
|