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.
Files changed (42) hide show
  1. agent_trace/__init__.py +3 -0
  2. agent_trace/blame.py +471 -0
  3. agent_trace/blame_git.py +133 -0
  4. agent_trace/blame_meta.py +167 -0
  5. agent_trace/cli.py +2680 -0
  6. agent_trace/commit_link.py +226 -0
  7. agent_trace/config.py +137 -0
  8. agent_trace/context.py +385 -0
  9. agent_trace/conversations.py +358 -0
  10. agent_trace/git_notes.py +491 -0
  11. agent_trace/hooks/__init__.py +163 -0
  12. agent_trace/hooks/base.py +233 -0
  13. agent_trace/hooks/claude.py +483 -0
  14. agent_trace/hooks/codex.py +232 -0
  15. agent_trace/hooks/cursor.py +278 -0
  16. agent_trace/hooks/git.py +144 -0
  17. agent_trace/ledger.py +955 -0
  18. agent_trace/models.py +618 -0
  19. agent_trace/record.py +417 -0
  20. agent_trace/registry.py +230 -0
  21. agent_trace/remote.py +673 -0
  22. agent_trace/rewrite.py +176 -0
  23. agent_trace/rules.py +209 -0
  24. agent_trace/schemas/commit-link.schema.json +34 -0
  25. agent_trace/schemas/git-note.schema.json +88 -0
  26. agent_trace/schemas/ledger.schema.json +97 -0
  27. agent_trace/schemas/remotes.schema.json +30 -0
  28. agent_trace/schemas/sync-state.schema.json +49 -0
  29. agent_trace/schemas/trace-record.schema.json +130 -0
  30. agent_trace/session.py +136 -0
  31. agent_trace/storage.py +229 -0
  32. agent_trace/summary.py +465 -0
  33. agent_trace/summary_presets.py +188 -0
  34. agent_trace/sync.py +1182 -0
  35. agent_trace/telemetry.py +142 -0
  36. agent_trace/trace.py +460 -0
  37. agent_trace_cli-0.1.0.dist-info/METADATA +535 -0
  38. agent_trace_cli-0.1.0.dist-info/RECORD +42 -0
  39. agent_trace_cli-0.1.0.dist-info/WHEEL +5 -0
  40. agent_trace_cli-0.1.0.dist-info/entry_points.txt +2 -0
  41. agent_trace_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
  42. 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