repr-cli 0.2.16__py3-none-any.whl → 0.2.17__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.
- repr/__init__.py +1 -1
- repr/api.py +363 -62
- repr/auth.py +47 -38
- repr/change_synthesis.py +478 -0
- repr/cli.py +4099 -280
- repr/config.py +119 -11
- repr/configure.py +889 -0
- repr/cron.py +419 -0
- repr/dashboard/__init__.py +9 -0
- repr/dashboard/build.py +126 -0
- repr/dashboard/dist/assets/index-BYFVbEev.css +1 -0
- repr/dashboard/dist/assets/index-BrrhyJFO.css +1 -0
- repr/dashboard/dist/assets/index-CcEg74ts.js +270 -0
- repr/dashboard/dist/assets/index-Cerc-iA_.js +377 -0
- repr/dashboard/dist/assets/index-CjVcBW2L.css +1 -0
- repr/dashboard/dist/assets/index-Dfl3mR5E.js +377 -0
- repr/dashboard/dist/favicon.svg +4 -0
- repr/dashboard/dist/index.html +14 -0
- repr/dashboard/manager.py +234 -0
- repr/dashboard/server.py +1298 -0
- repr/db.py +980 -0
- repr/hooks.py +3 -2
- repr/loaders/__init__.py +22 -0
- repr/loaders/base.py +156 -0
- repr/loaders/claude_code.py +287 -0
- repr/loaders/clawdbot.py +313 -0
- repr/loaders/gemini_antigravity.py +381 -0
- repr/mcp_server.py +1196 -0
- repr/models.py +503 -0
- repr/openai_analysis.py +25 -0
- repr/session_extractor.py +481 -0
- repr/storage.py +328 -0
- repr/story_synthesis.py +1296 -0
- repr/templates.py +68 -4
- repr/timeline.py +710 -0
- repr/tools.py +17 -8
- {repr_cli-0.2.16.dist-info → repr_cli-0.2.17.dist-info}/METADATA +48 -10
- repr_cli-0.2.17.dist-info/RECORD +52 -0
- {repr_cli-0.2.16.dist-info → repr_cli-0.2.17.dist-info}/WHEEL +1 -1
- {repr_cli-0.2.16.dist-info → repr_cli-0.2.17.dist-info}/entry_points.txt +1 -0
- repr_cli-0.2.16.dist-info/RECORD +0 -26
- {repr_cli-0.2.16.dist-info → repr_cli-0.2.17.dist-info}/licenses/LICENSE +0 -0
- {repr_cli-0.2.16.dist-info → repr_cli-0.2.17.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LLM-based context extraction from AI sessions.
|
|
3
|
+
|
|
4
|
+
Takes session transcripts and extracts structured context:
|
|
5
|
+
- Problem: What was the user trying to solve?
|
|
6
|
+
- Approach: What strategy/pattern was used?
|
|
7
|
+
- Decisions: Key decisions made
|
|
8
|
+
- Outcome: Did it work?
|
|
9
|
+
- Lessons: Gotchas and learnings
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, Field
|
|
18
|
+
|
|
19
|
+
from .models import (
|
|
20
|
+
ContentBlockType,
|
|
21
|
+
MessageRole,
|
|
22
|
+
Session,
|
|
23
|
+
SessionContext,
|
|
24
|
+
SessionMessage,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# =============================================================================
|
|
29
|
+
# Extraction Schema
|
|
30
|
+
# =============================================================================
|
|
31
|
+
|
|
32
|
+
class ExtractedContext(BaseModel):
|
|
33
|
+
"""Schema for LLM extraction output."""
|
|
34
|
+
problem: str = Field(description="What was the user trying to solve? One paragraph max.")
|
|
35
|
+
approach: str = Field(description="What strategy/pattern was used to solve it? Technical details.")
|
|
36
|
+
decisions: list[str] = Field(
|
|
37
|
+
default_factory=list,
|
|
38
|
+
description="Key decisions made (3-5 items). Format: 'Chose X over Y because Z'"
|
|
39
|
+
)
|
|
40
|
+
files_modified: list[str] = Field(
|
|
41
|
+
default_factory=list,
|
|
42
|
+
description="Files that were created or modified"
|
|
43
|
+
)
|
|
44
|
+
tools_used: list[str] = Field(
|
|
45
|
+
default_factory=list,
|
|
46
|
+
description="Main tools/commands used"
|
|
47
|
+
)
|
|
48
|
+
outcome: str = Field(description="Did it work? What was the result? One sentence.")
|
|
49
|
+
lessons: list[str] = Field(
|
|
50
|
+
default_factory=list,
|
|
51
|
+
description="Gotchas, learnings, things to remember (1-3 items)"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# =============================================================================
|
|
56
|
+
# Prompt Templates
|
|
57
|
+
# =============================================================================
|
|
58
|
+
|
|
59
|
+
EXTRACTION_SYSTEM_PROMPT = """You are a technical context extractor. Your job is to read AI coding assistant sessions and extract structured context that will help future AI agents understand this developer's work.
|
|
60
|
+
|
|
61
|
+
Extract the ESSENTIAL context that answers:
|
|
62
|
+
1. WHAT problem was being solved (the motivation, not just the task)
|
|
63
|
+
2. HOW it was approached (specific patterns, architectures, strategies)
|
|
64
|
+
3. WHAT decisions were made and WHY (alternatives considered, tradeoffs made)
|
|
65
|
+
4. WHAT worked, what didn't, what to watch out for
|
|
66
|
+
|
|
67
|
+
Write for a technical audience. Be SPECIFIC:
|
|
68
|
+
- Name exact technologies, libraries, patterns used
|
|
69
|
+
- Quote actual decisions from the conversation ("chose X because...")
|
|
70
|
+
- Include gotchas and learnings that would help someone working on similar code
|
|
71
|
+
|
|
72
|
+
DO NOT:
|
|
73
|
+
- Use generic descriptions ("improved the system", "fixed the issue")
|
|
74
|
+
- Include filler or marketing language
|
|
75
|
+
- Describe the extraction process itself
|
|
76
|
+
- Make up information not present in the session
|
|
77
|
+
- Leave fields empty - extract something meaningful or say "Not discussed"
|
|
78
|
+
|
|
79
|
+
Output valid JSON matching the schema provided."""
|
|
80
|
+
|
|
81
|
+
EXTRACTION_USER_PROMPT = """Extract structured context from this AI coding session.
|
|
82
|
+
|
|
83
|
+
<session>
|
|
84
|
+
{session_transcript}
|
|
85
|
+
</session>
|
|
86
|
+
|
|
87
|
+
Extract these fields (be specific, not generic):
|
|
88
|
+
|
|
89
|
+
- problem: What was the user trying to solve? What was broken/slow/missing? (1 paragraph)
|
|
90
|
+
- approach: What technical strategy/pattern was used? How does the solution work? (specific details)
|
|
91
|
+
- decisions: Key decisions made - each formatted as "Chose X over Y because Z" (3-5 items)
|
|
92
|
+
- files_modified: Files that were created or significantly modified
|
|
93
|
+
- tools_used: Main tools, commands, or APIs used
|
|
94
|
+
- outcome: Did it work? What was the observable result? (1-2 sentences)
|
|
95
|
+
- lessons: Gotchas discovered, things to remember, warnings for future work (1-3 items)
|
|
96
|
+
|
|
97
|
+
If a field wasn't discussed in the session, write "Not discussed" rather than guessing.
|
|
98
|
+
|
|
99
|
+
Respond with valid JSON only."""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# =============================================================================
|
|
103
|
+
# Session Formatter
|
|
104
|
+
# =============================================================================
|
|
105
|
+
|
|
106
|
+
def format_session_for_extraction(
|
|
107
|
+
session: Session,
|
|
108
|
+
max_messages: int = 50,
|
|
109
|
+
max_chars: int = 30000,
|
|
110
|
+
) -> str:
|
|
111
|
+
"""
|
|
112
|
+
Format a session transcript for LLM extraction.
|
|
113
|
+
|
|
114
|
+
Includes:
|
|
115
|
+
- User messages (full text)
|
|
116
|
+
- Assistant text responses (summarized if long)
|
|
117
|
+
- Tool calls (name + key inputs)
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
session: Session to format
|
|
121
|
+
max_messages: Maximum number of messages to include
|
|
122
|
+
max_chars: Maximum total characters
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Formatted transcript string
|
|
126
|
+
"""
|
|
127
|
+
lines = []
|
|
128
|
+
total_chars = 0
|
|
129
|
+
|
|
130
|
+
# Add session metadata
|
|
131
|
+
lines.append(f"Session ID: {session.id}")
|
|
132
|
+
lines.append(f"Started: {session.started_at.isoformat()}")
|
|
133
|
+
if session.cwd:
|
|
134
|
+
lines.append(f"Working Directory: {session.cwd}")
|
|
135
|
+
if session.git_branch:
|
|
136
|
+
lines.append(f"Git Branch: {session.git_branch}")
|
|
137
|
+
lines.append("")
|
|
138
|
+
lines.append("=== CONVERSATION ===")
|
|
139
|
+
lines.append("")
|
|
140
|
+
|
|
141
|
+
# Process messages
|
|
142
|
+
messages_included = 0
|
|
143
|
+
for msg in session.messages:
|
|
144
|
+
if messages_included >= max_messages:
|
|
145
|
+
lines.append(f"... ({len(session.messages) - messages_included} more messages)")
|
|
146
|
+
break
|
|
147
|
+
|
|
148
|
+
role_label = msg.role.value.upper()
|
|
149
|
+
timestamp = msg.timestamp.strftime("%H:%M") if isinstance(msg.timestamp, datetime) else ""
|
|
150
|
+
|
|
151
|
+
# Format content
|
|
152
|
+
content_parts = []
|
|
153
|
+
for block in msg.content:
|
|
154
|
+
if block.type == ContentBlockType.TEXT and block.text:
|
|
155
|
+
# Truncate long text
|
|
156
|
+
text = block.text.strip()
|
|
157
|
+
if len(text) > 2000:
|
|
158
|
+
text = text[:1500] + "\n... (truncated) ...\n" + text[-300:]
|
|
159
|
+
content_parts.append(text)
|
|
160
|
+
|
|
161
|
+
elif block.type == ContentBlockType.TOOL_CALL:
|
|
162
|
+
tool_str = f"[TOOL: {block.name}]"
|
|
163
|
+
if block.input:
|
|
164
|
+
# Extract key inputs
|
|
165
|
+
key_inputs = []
|
|
166
|
+
for key in ["path", "command", "query", "url", "file_path"]:
|
|
167
|
+
if key in block.input:
|
|
168
|
+
key_inputs.append(f"{key}={block.input[key]}")
|
|
169
|
+
if key_inputs:
|
|
170
|
+
tool_str += f" {', '.join(key_inputs[:3])}"
|
|
171
|
+
content_parts.append(tool_str)
|
|
172
|
+
|
|
173
|
+
elif block.type == ContentBlockType.TOOL_RESULT and block.text:
|
|
174
|
+
# Very brief tool results
|
|
175
|
+
result = block.text.strip()
|
|
176
|
+
if len(result) > 500:
|
|
177
|
+
result = result[:200] + "... (truncated)"
|
|
178
|
+
content_parts.append(f"[RESULT]: {result}")
|
|
179
|
+
|
|
180
|
+
if content_parts:
|
|
181
|
+
content = "\n".join(content_parts)
|
|
182
|
+
message_text = f"[{role_label}] {timestamp}\n{content}\n"
|
|
183
|
+
|
|
184
|
+
# Check character limit
|
|
185
|
+
if total_chars + len(message_text) > max_chars:
|
|
186
|
+
lines.append(f"... (truncated at {max_chars} chars)")
|
|
187
|
+
break
|
|
188
|
+
|
|
189
|
+
lines.append(message_text)
|
|
190
|
+
total_chars += len(message_text)
|
|
191
|
+
messages_included += 1
|
|
192
|
+
|
|
193
|
+
return "\n".join(lines)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# =============================================================================
|
|
197
|
+
# Extractor
|
|
198
|
+
# =============================================================================
|
|
199
|
+
|
|
200
|
+
class SessionExtractor:
|
|
201
|
+
"""Extract structured context from sessions using LLM."""
|
|
202
|
+
|
|
203
|
+
def __init__(
|
|
204
|
+
self,
|
|
205
|
+
api_key: str | None = None,
|
|
206
|
+
base_url: str | None = None,
|
|
207
|
+
model: str = "openai/gpt-4.1-mini",
|
|
208
|
+
):
|
|
209
|
+
"""
|
|
210
|
+
Initialize extractor.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
api_key: API key for LLM provider
|
|
214
|
+
base_url: Base URL for API (for local LLMs)
|
|
215
|
+
model: Model to use for extraction
|
|
216
|
+
"""
|
|
217
|
+
self.api_key = api_key
|
|
218
|
+
self.base_url = base_url
|
|
219
|
+
self.model = model
|
|
220
|
+
self._client = None
|
|
221
|
+
|
|
222
|
+
async def close(self):
|
|
223
|
+
"""Close the underlying OpenAI client (no-op for sync client)."""
|
|
224
|
+
# Sync client doesn't need explicit closing
|
|
225
|
+
self._client = None
|
|
226
|
+
|
|
227
|
+
async def __aenter__(self):
|
|
228
|
+
return self
|
|
229
|
+
|
|
230
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
231
|
+
await self.close()
|
|
232
|
+
|
|
233
|
+
def _get_client(self):
|
|
234
|
+
"""Get or create OpenAI client.
|
|
235
|
+
|
|
236
|
+
Uses sync client to avoid event loop cleanup issues when called
|
|
237
|
+
from sync contexts via asyncio.run().
|
|
238
|
+
|
|
239
|
+
Tries multiple sources for API key in order:
|
|
240
|
+
1. Explicit api_key passed to constructor
|
|
241
|
+
2. BYOK OpenAI config from repr
|
|
242
|
+
3. Local LLM config from repr
|
|
243
|
+
4. OPENAI_API_KEY environment variable
|
|
244
|
+
5. LiteLLM config from repr (cloud mode)
|
|
245
|
+
"""
|
|
246
|
+
if self._client is None:
|
|
247
|
+
import os
|
|
248
|
+
from openai import OpenAI
|
|
249
|
+
|
|
250
|
+
api_key = self.api_key
|
|
251
|
+
base_url = self.base_url
|
|
252
|
+
|
|
253
|
+
if not api_key:
|
|
254
|
+
try:
|
|
255
|
+
# Try BYOK OpenAI config first
|
|
256
|
+
from .config import get_byok_config, get_llm_config
|
|
257
|
+
|
|
258
|
+
byok = get_byok_config("openai")
|
|
259
|
+
if byok and byok.get("api_key"):
|
|
260
|
+
api_key = byok["api_key"]
|
|
261
|
+
base_url = base_url or byok.get("base_url")
|
|
262
|
+
|
|
263
|
+
# Try local LLM config
|
|
264
|
+
if not api_key:
|
|
265
|
+
llm_config = get_llm_config()
|
|
266
|
+
if llm_config.get("local_api_key"):
|
|
267
|
+
api_key = llm_config["local_api_key"]
|
|
268
|
+
base_url = base_url or llm_config.get("local_api_url")
|
|
269
|
+
except Exception:
|
|
270
|
+
pass
|
|
271
|
+
|
|
272
|
+
# Try environment variable
|
|
273
|
+
if not api_key:
|
|
274
|
+
api_key = os.getenv("OPENAI_API_KEY")
|
|
275
|
+
|
|
276
|
+
# Try LiteLLM config (cloud mode)
|
|
277
|
+
if not api_key:
|
|
278
|
+
try:
|
|
279
|
+
from .config import get_litellm_config
|
|
280
|
+
litellm_url, litellm_key = get_litellm_config()
|
|
281
|
+
if litellm_key:
|
|
282
|
+
api_key = litellm_key
|
|
283
|
+
base_url = base_url or litellm_url
|
|
284
|
+
except Exception:
|
|
285
|
+
pass
|
|
286
|
+
|
|
287
|
+
if not api_key:
|
|
288
|
+
raise ValueError(
|
|
289
|
+
"No API key found. Configure one via:\n"
|
|
290
|
+
" - repr llm byok openai <key> (recommended)\n"
|
|
291
|
+
" - OPENAI_API_KEY environment variable\n"
|
|
292
|
+
" - repr login (for cloud mode)"
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
self._client = OpenAI(
|
|
296
|
+
api_key=api_key,
|
|
297
|
+
base_url=base_url,
|
|
298
|
+
)
|
|
299
|
+
return self._client
|
|
300
|
+
|
|
301
|
+
async def extract_context(
|
|
302
|
+
self,
|
|
303
|
+
session: Session,
|
|
304
|
+
linked_commits: list[str] | None = None,
|
|
305
|
+
) -> SessionContext:
|
|
306
|
+
"""
|
|
307
|
+
Extract context from a session.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
session: Session to extract from
|
|
311
|
+
linked_commits: Optional list of linked commit SHAs
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
Extracted SessionContext
|
|
315
|
+
"""
|
|
316
|
+
# Format session for LLM
|
|
317
|
+
transcript = format_session_for_extraction(session)
|
|
318
|
+
|
|
319
|
+
# Call LLM (using sync client to avoid event loop cleanup issues)
|
|
320
|
+
client = self._get_client()
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
response = client.chat.completions.create(
|
|
324
|
+
model=self.model.split("/")[-1] if "/" in self.model else self.model,
|
|
325
|
+
messages=[
|
|
326
|
+
{"role": "system", "content": EXTRACTION_SYSTEM_PROMPT},
|
|
327
|
+
{"role": "user", "content": EXTRACTION_USER_PROMPT.format(
|
|
328
|
+
session_transcript=transcript
|
|
329
|
+
)},
|
|
330
|
+
],
|
|
331
|
+
response_format={"type": "json_object"},
|
|
332
|
+
temperature=0.3,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
# Parse response
|
|
336
|
+
content = response.choices[0].message.content
|
|
337
|
+
extracted = ExtractedContext.model_validate_json(content)
|
|
338
|
+
|
|
339
|
+
except Exception as e:
|
|
340
|
+
# Fallback: create context from session metadata + first user message
|
|
341
|
+
first_user_msg = ""
|
|
342
|
+
for msg in session.messages:
|
|
343
|
+
if msg.role == MessageRole.USER and msg.text_content:
|
|
344
|
+
first_user_msg = msg.text_content[:500]
|
|
345
|
+
break
|
|
346
|
+
|
|
347
|
+
# Try to infer problem from first message
|
|
348
|
+
problem = first_user_msg if first_user_msg else f"AI coding session in {session.cwd or 'project directory'}"
|
|
349
|
+
|
|
350
|
+
# Determine if this is an API error vs other error
|
|
351
|
+
error_str = str(e)
|
|
352
|
+
is_api_error = any(x in error_str.lower() for x in ["401", "403", "api key", "unauthorized", "authentication"])
|
|
353
|
+
|
|
354
|
+
if is_api_error:
|
|
355
|
+
lesson = "Extraction skipped - configure API key with 'repr llm byok openai <key>'"
|
|
356
|
+
else:
|
|
357
|
+
lesson = f"Extraction failed: {error_str[:80]}"
|
|
358
|
+
|
|
359
|
+
extracted = ExtractedContext(
|
|
360
|
+
problem=problem[:500],
|
|
361
|
+
approach="Not extracted - see lessons",
|
|
362
|
+
decisions=[],
|
|
363
|
+
files_modified=session.files_touched[:10],
|
|
364
|
+
tools_used=session.tools_used[:10],
|
|
365
|
+
outcome=f"Session: {session.message_count} messages, {session.duration_seconds or 0:.0f}s",
|
|
366
|
+
lessons=[lesson],
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
# Build SessionContext
|
|
370
|
+
return SessionContext(
|
|
371
|
+
session_id=session.id,
|
|
372
|
+
timestamp=session.started_at,
|
|
373
|
+
problem=extracted.problem,
|
|
374
|
+
approach=extracted.approach,
|
|
375
|
+
decisions=extracted.decisions,
|
|
376
|
+
files_modified=extracted.files_modified or session.files_touched,
|
|
377
|
+
tools_used=extracted.tools_used or session.tools_used,
|
|
378
|
+
outcome=extracted.outcome,
|
|
379
|
+
lessons=extracted.lessons,
|
|
380
|
+
linked_commits=linked_commits or [],
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
async def extract_batch(
|
|
384
|
+
self,
|
|
385
|
+
sessions: list[Session],
|
|
386
|
+
concurrency: int = 3,
|
|
387
|
+
) -> list[SessionContext]:
|
|
388
|
+
"""
|
|
389
|
+
Extract context from multiple sessions with concurrency control.
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
sessions: Sessions to extract from
|
|
393
|
+
concurrency: Max concurrent extractions
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
List of extracted SessionContexts
|
|
397
|
+
"""
|
|
398
|
+
semaphore = asyncio.Semaphore(concurrency)
|
|
399
|
+
|
|
400
|
+
async def extract_with_semaphore(session: Session) -> SessionContext:
|
|
401
|
+
async with semaphore:
|
|
402
|
+
return await self.extract_context(session)
|
|
403
|
+
|
|
404
|
+
tasks = [extract_with_semaphore(s) for s in sessions]
|
|
405
|
+
return await asyncio.gather(*tasks)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
# =============================================================================
|
|
409
|
+
# Convenience Functions
|
|
410
|
+
# =============================================================================
|
|
411
|
+
|
|
412
|
+
async def extract_session_context(
|
|
413
|
+
session: Session,
|
|
414
|
+
api_key: str | None = None,
|
|
415
|
+
model: str = "openai/gpt-4.1-mini",
|
|
416
|
+
) -> SessionContext:
|
|
417
|
+
"""
|
|
418
|
+
Convenience function to extract context from a single session.
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
session: Session to extract from
|
|
422
|
+
api_key: Optional API key
|
|
423
|
+
model: Model to use
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
Extracted SessionContext
|
|
427
|
+
"""
|
|
428
|
+
extractor = SessionExtractor(api_key=api_key, model=model)
|
|
429
|
+
return await extractor.extract_context(session)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def extract_session_context_sync(
|
|
433
|
+
session: Session,
|
|
434
|
+
api_key: str | None = None,
|
|
435
|
+
model: str = "openai/gpt-4.1-mini",
|
|
436
|
+
) -> SessionContext:
|
|
437
|
+
"""
|
|
438
|
+
Synchronous wrapper for extract_session_context.
|
|
439
|
+
"""
|
|
440
|
+
return asyncio.run(extract_session_context(session, api_key, model))
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
# =============================================================================
|
|
444
|
+
# CLI Support
|
|
445
|
+
# =============================================================================
|
|
446
|
+
|
|
447
|
+
def summarize_session(session: Session) -> dict[str, Any]:
|
|
448
|
+
"""
|
|
449
|
+
Create a quick summary of a session without LLM extraction.
|
|
450
|
+
|
|
451
|
+
Useful for listing/previewing sessions before full extraction.
|
|
452
|
+
|
|
453
|
+
Args:
|
|
454
|
+
session: Session to summarize
|
|
455
|
+
|
|
456
|
+
Returns:
|
|
457
|
+
Summary dict
|
|
458
|
+
"""
|
|
459
|
+
# Get first user message as summary
|
|
460
|
+
first_user_msg = ""
|
|
461
|
+
for msg in session.messages:
|
|
462
|
+
if msg.role == MessageRole.USER:
|
|
463
|
+
first_user_msg = msg.text_content[:200]
|
|
464
|
+
if len(msg.text_content) > 200:
|
|
465
|
+
first_user_msg += "..."
|
|
466
|
+
break
|
|
467
|
+
|
|
468
|
+
return {
|
|
469
|
+
"id": session.id,
|
|
470
|
+
"started_at": session.started_at.isoformat(),
|
|
471
|
+
"ended_at": session.ended_at.isoformat() if session.ended_at else None,
|
|
472
|
+
"duration_seconds": session.duration_seconds,
|
|
473
|
+
"channel": session.channel,
|
|
474
|
+
"cwd": session.cwd,
|
|
475
|
+
"model": session.model,
|
|
476
|
+
"message_count": session.message_count,
|
|
477
|
+
"user_messages": session.user_message_count,
|
|
478
|
+
"tools_used": session.tools_used[:5],
|
|
479
|
+
"files_touched": session.files_touched[:5],
|
|
480
|
+
"first_message": first_user_msg,
|
|
481
|
+
}
|