aline-ai 0.2.5__py3-none-any.whl → 0.3.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.
- {aline_ai-0.2.5.dist-info → aline_ai-0.3.0.dist-info}/METADATA +3 -1
- aline_ai-0.3.0.dist-info/RECORD +41 -0
- aline_ai-0.3.0.dist-info/entry_points.txt +3 -0
- realign/__init__.py +32 -1
- realign/cli.py +203 -19
- realign/commands/__init__.py +2 -2
- realign/commands/clean.py +149 -0
- realign/commands/config.py +1 -1
- realign/commands/export_shares.py +1785 -0
- realign/commands/hide.py +112 -24
- realign/commands/import_history.py +873 -0
- realign/commands/init.py +104 -217
- realign/commands/mirror.py +131 -0
- realign/commands/pull.py +101 -0
- realign/commands/push.py +155 -245
- realign/commands/review.py +216 -54
- realign/commands/session_utils.py +139 -4
- realign/commands/share.py +965 -0
- realign/commands/status.py +559 -0
- realign/commands/sync.py +91 -0
- realign/commands/undo.py +423 -0
- realign/commands/watcher.py +805 -0
- realign/config.py +21 -10
- realign/file_lock.py +3 -1
- realign/hash_registry.py +310 -0
- realign/hooks.py +368 -384
- realign/logging_config.py +2 -2
- realign/mcp_server.py +263 -549
- realign/mcp_watcher.py +999 -142
- realign/mirror_utils.py +322 -0
- realign/prompts/__init__.py +21 -0
- realign/prompts/presets.py +238 -0
- realign/redactor.py +168 -16
- realign/tracker/__init__.py +9 -0
- realign/tracker/git_tracker.py +1123 -0
- realign/watcher_daemon.py +115 -0
- aline_ai-0.2.5.dist-info/RECORD +0 -28
- aline_ai-0.2.5.dist-info/entry_points.txt +0 -5
- realign/commands/auto_commit.py +0 -231
- realign/commands/commit.py +0 -379
- realign/commands/search.py +0 -449
- realign/commands/show.py +0 -416
- {aline_ai-0.2.5.dist-info → aline_ai-0.3.0.dist-info}/WHEEL +0 -0
- {aline_ai-0.2.5.dist-info → aline_ai-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {aline_ai-0.2.5.dist-info → aline_ai-0.3.0.dist-info}/top_level.txt +0 -0
realign/commands/search.py
DELETED
|
@@ -1,449 +0,0 @@
|
|
|
1
|
-
"""ReAlign search command - Search through agent sessions and commit history."""
|
|
2
|
-
|
|
3
|
-
import subprocess
|
|
4
|
-
import re
|
|
5
|
-
import json
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
from typing import Optional, List, Dict, Any, Tuple
|
|
8
|
-
import typer
|
|
9
|
-
from rich.console import Console
|
|
10
|
-
from rich.table import Table
|
|
11
|
-
from .session_utils import find_session_paths_for_commit
|
|
12
|
-
|
|
13
|
-
console = Console()
|
|
14
|
-
SUMMARY_BLOCK_PATTERN = re.compile(
|
|
15
|
-
r"\n*--- LLM-Summary \((?P<model>.+?)\) ---\n(?P<bullets>(?:\*.*\n?)+)",
|
|
16
|
-
re.MULTILINE,
|
|
17
|
-
)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def search_command(
|
|
21
|
-
keyword: str = typer.Argument(..., help="Keyword to search for"),
|
|
22
|
-
show_session: bool = typer.Option(False, "--show-session", help="Show session content for matches"),
|
|
23
|
-
max_results: int = typer.Option(20, "--max", "-n", help="Maximum number of results to show"),
|
|
24
|
-
session_only: bool = typer.Option(False, "--session-only", help="Search only in session files, not commits"),
|
|
25
|
-
commits_only: bool = typer.Option(False, "--commits-only", help="Search only in commits, not session files"),
|
|
26
|
-
):
|
|
27
|
-
"""Search through agent sessions and commit history."""
|
|
28
|
-
# Check if we're in a git repository
|
|
29
|
-
try:
|
|
30
|
-
subprocess.run(
|
|
31
|
-
["git", "rev-parse", "--git-dir"],
|
|
32
|
-
check=True,
|
|
33
|
-
capture_output=True,
|
|
34
|
-
text=True,
|
|
35
|
-
)
|
|
36
|
-
except subprocess.CalledProcessError:
|
|
37
|
-
console.print("[red]Error: Not in a git repository.[/red]")
|
|
38
|
-
raise typer.Exit(1)
|
|
39
|
-
|
|
40
|
-
repo_root = Path(
|
|
41
|
-
subprocess.run(
|
|
42
|
-
["git", "rev-parse", "--show-toplevel"],
|
|
43
|
-
check=True,
|
|
44
|
-
capture_output=True,
|
|
45
|
-
text=True,
|
|
46
|
-
).stdout.strip()
|
|
47
|
-
)
|
|
48
|
-
|
|
49
|
-
console.print(f"[blue]Searching for:[/blue] '{keyword}'")
|
|
50
|
-
|
|
51
|
-
# Determine what to search based on flags
|
|
52
|
-
search_commits = not session_only
|
|
53
|
-
search_sessions = not commits_only
|
|
54
|
-
|
|
55
|
-
# Search in commit messages (including summary blocks)
|
|
56
|
-
if search_commits:
|
|
57
|
-
console.print("\n[bold]Commits with matching summaries:[/bold]")
|
|
58
|
-
|
|
59
|
-
try:
|
|
60
|
-
# Use a unique separator to split commits
|
|
61
|
-
# Format: hash|author|subject|body
|
|
62
|
-
result = subprocess.run(
|
|
63
|
-
["git", "log", f"--grep={keyword}", "-i", "--pretty=format:%H|%an|%s|%b%x00", f"-n{max_results}"],
|
|
64
|
-
capture_output=True,
|
|
65
|
-
text=True,
|
|
66
|
-
check=True,
|
|
67
|
-
cwd=repo_root,
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
if result.stdout:
|
|
71
|
-
commits = []
|
|
72
|
-
# Split by null byte to separate commits
|
|
73
|
-
commit_entries = result.stdout.split("\x00")
|
|
74
|
-
|
|
75
|
-
for entry in commit_entries:
|
|
76
|
-
entry = entry.strip()
|
|
77
|
-
if not entry:
|
|
78
|
-
continue
|
|
79
|
-
|
|
80
|
-
# Split only at the first three pipes to preserve body content
|
|
81
|
-
parts = entry.split("|", 3)
|
|
82
|
-
if len(parts) >= 3:
|
|
83
|
-
commit_hash = parts[0][:8]
|
|
84
|
-
author = parts[1]
|
|
85
|
-
subject = parts[2]
|
|
86
|
-
body = parts[3] if len(parts) > 3 else ""
|
|
87
|
-
summary_model, summary_bullets, cleaned_body = extract_summary_from_body(body)
|
|
88
|
-
|
|
89
|
-
commits.append({
|
|
90
|
-
"hash": commit_hash,
|
|
91
|
-
"author": author,
|
|
92
|
-
"subject": subject,
|
|
93
|
-
"body": body,
|
|
94
|
-
"display_body": cleaned_body.strip(),
|
|
95
|
-
"summary_model": summary_model,
|
|
96
|
-
"summary_bullets": summary_bullets,
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
if commits:
|
|
100
|
-
def highlight_text(text: str, keyword: str) -> str:
|
|
101
|
-
"""Highlight keyword in text (case-insensitive)."""
|
|
102
|
-
if not keyword or not text:
|
|
103
|
-
return text
|
|
104
|
-
# Use regex to find and replace keyword (case-insensitive)
|
|
105
|
-
pattern = re.compile(f'({re.escape(keyword)})', re.IGNORECASE)
|
|
106
|
-
return pattern.sub(r'[black on yellow]\1[/black on yellow]', text)
|
|
107
|
-
|
|
108
|
-
for i, commit in enumerate(commits, 1):
|
|
109
|
-
console.print(f"\n[bold cyan]{i}. Commit {commit['hash']}[/bold cyan] by [green]{commit['author']}[/green]")
|
|
110
|
-
console.print(f" [bold]{highlight_text(commit['subject'], keyword)}[/bold]")
|
|
111
|
-
|
|
112
|
-
# Show body if it exists (excluding agent metadata)
|
|
113
|
-
display_body = commit.get("display_body", "")
|
|
114
|
-
if display_body:
|
|
115
|
-
for line in display_body.split('\n'):
|
|
116
|
-
console.print(f" {highlight_text(line, keyword)}")
|
|
117
|
-
|
|
118
|
-
bullets = commit.get("summary_bullets") or []
|
|
119
|
-
if bullets:
|
|
120
|
-
header = f"LLM-Summary ({commit.get('summary_model') or 'Local summarizer'})"
|
|
121
|
-
console.print(f" [yellow]{highlight_text(header, keyword)}[/yellow]")
|
|
122
|
-
for bullet in bullets:
|
|
123
|
-
console.print(f" [yellow]{highlight_text(bullet, keyword)}[/yellow]")
|
|
124
|
-
|
|
125
|
-
# Show session content if requested
|
|
126
|
-
if show_session and commits:
|
|
127
|
-
console.print("\n[bold]Session content:[/bold]")
|
|
128
|
-
for commit in commits[:5]: # Limit to first 5 for session display
|
|
129
|
-
show_session_content(repo_root, commit["hash"])
|
|
130
|
-
else:
|
|
131
|
-
console.print("[yellow]No commits found matching the keyword.[/yellow]")
|
|
132
|
-
else:
|
|
133
|
-
console.print("[yellow]No commits found matching the keyword.[/yellow]")
|
|
134
|
-
|
|
135
|
-
except subprocess.CalledProcessError as e:
|
|
136
|
-
console.print(f"[red]Error searching commits:[/red] {e}")
|
|
137
|
-
|
|
138
|
-
# Search in session files
|
|
139
|
-
if search_sessions:
|
|
140
|
-
sessions_dir = repo_root / ".realign" / "sessions"
|
|
141
|
-
if sessions_dir.exists():
|
|
142
|
-
console.print("\n[bold]Session file matches:[/bold]")
|
|
143
|
-
|
|
144
|
-
try:
|
|
145
|
-
result = subprocess.run(
|
|
146
|
-
["grep", "-r", "-i", "-l", keyword, str(sessions_dir)],
|
|
147
|
-
capture_output=True,
|
|
148
|
-
text=True,
|
|
149
|
-
check=False,
|
|
150
|
-
)
|
|
151
|
-
|
|
152
|
-
if result.stdout:
|
|
153
|
-
matching_files = result.stdout.strip().split("\n")
|
|
154
|
-
for file_path in matching_files[:max_results]:
|
|
155
|
-
rel_path = Path(file_path).relative_to(repo_root)
|
|
156
|
-
console.print(f"\n[bold cyan]• {rel_path}[/bold cyan]")
|
|
157
|
-
|
|
158
|
-
# Show parsed session content with context
|
|
159
|
-
display_session_matches(Path(file_path), keyword, max_matches=5)
|
|
160
|
-
else:
|
|
161
|
-
console.print("[yellow]No session files found matching the keyword.[/yellow]")
|
|
162
|
-
|
|
163
|
-
except subprocess.CalledProcessError:
|
|
164
|
-
console.print("[yellow]Could not search session files.[/yellow]")
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
def show_session_content(repo_root: Path, commit_hash: str):
|
|
168
|
-
"""Show content of a session file from a commit."""
|
|
169
|
-
console.print(f"\n[bold cyan]Session for commit {commit_hash}:[/bold cyan]")
|
|
170
|
-
|
|
171
|
-
session_paths = find_session_paths_for_commit(repo_root, commit_hash)
|
|
172
|
-
if not session_paths:
|
|
173
|
-
console.print(" [yellow]No session files recorded in this commit.[/yellow]")
|
|
174
|
-
return
|
|
175
|
-
|
|
176
|
-
session_path = session_paths[0]
|
|
177
|
-
session_file = repo_root / session_path
|
|
178
|
-
if session_file.exists():
|
|
179
|
-
console.print(f" [dim]Path: {session_path}[/dim]")
|
|
180
|
-
show_file_content(session_file)
|
|
181
|
-
else:
|
|
182
|
-
result = subprocess.run(
|
|
183
|
-
["git", "show", f"{commit_hash}:{session_path}"],
|
|
184
|
-
capture_output=True,
|
|
185
|
-
text=True,
|
|
186
|
-
check=False,
|
|
187
|
-
cwd=repo_root,
|
|
188
|
-
)
|
|
189
|
-
if result.returncode == 0:
|
|
190
|
-
console.print(f" [dim]Path: {session_path} (from commit tree)[/dim]")
|
|
191
|
-
console.print(result.stdout)
|
|
192
|
-
else:
|
|
193
|
-
console.print(f"[yellow]Session file not found:[/yellow] {session_path}")
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
def extract_summary_from_body(body: str) -> Tuple[str, List[str], str]:
|
|
197
|
-
"""Extract summary metadata from commit body."""
|
|
198
|
-
match = SUMMARY_BLOCK_PATTERN.search(body)
|
|
199
|
-
if not match:
|
|
200
|
-
return "", [], body
|
|
201
|
-
|
|
202
|
-
summary_model = match.group("model").strip()
|
|
203
|
-
bullets_text = match.group("bullets").strip()
|
|
204
|
-
bullet_lines = [line.strip() for line in bullets_text.splitlines() if line.strip()]
|
|
205
|
-
start, end = match.span()
|
|
206
|
-
cleaned_body = (body[:start] + body[end:]).strip()
|
|
207
|
-
return summary_model, bullet_lines, cleaned_body
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
def show_file_content(file_path: Path, highlight: Optional[str] = None, max_lines: int = 20):
|
|
211
|
-
"""Show content of a file with optional highlighting."""
|
|
212
|
-
try:
|
|
213
|
-
with open(file_path, "r", encoding="utf-8") as f:
|
|
214
|
-
lines = f.readlines()
|
|
215
|
-
|
|
216
|
-
console.print(f"[dim]Showing first {min(len(lines), max_lines)} lines...[/dim]")
|
|
217
|
-
|
|
218
|
-
for i, line in enumerate(lines[:max_lines], 1):
|
|
219
|
-
if highlight and highlight.lower() in line.lower():
|
|
220
|
-
console.print(f"{i:4d}: [yellow]{line.rstrip()}[/yellow]")
|
|
221
|
-
else:
|
|
222
|
-
console.print(f"{i:4d}: {line.rstrip()}")
|
|
223
|
-
|
|
224
|
-
if len(lines) > max_lines:
|
|
225
|
-
console.print(f"[dim]... ({len(lines) - max_lines} more lines)[/dim]")
|
|
226
|
-
|
|
227
|
-
except Exception as e:
|
|
228
|
-
console.print(f"[red]Error reading file:[/red] {e}")
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
def extract_text_from_content(content: Any) -> str:
|
|
232
|
-
"""Extract plain text from various content formats."""
|
|
233
|
-
if isinstance(content, str):
|
|
234
|
-
return content
|
|
235
|
-
elif isinstance(content, list):
|
|
236
|
-
texts = []
|
|
237
|
-
for item in content:
|
|
238
|
-
if isinstance(item, dict):
|
|
239
|
-
if item.get("type") == "text" and "text" in item:
|
|
240
|
-
texts.append(item["text"])
|
|
241
|
-
elif isinstance(item, str):
|
|
242
|
-
texts.append(item)
|
|
243
|
-
return " ".join(texts)
|
|
244
|
-
elif isinstance(content, dict):
|
|
245
|
-
if "text" in content:
|
|
246
|
-
return content["text"]
|
|
247
|
-
elif "content" in content:
|
|
248
|
-
return extract_text_from_content(content["content"])
|
|
249
|
-
return ""
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
def extract_username_from_filename(file_path: Path) -> Optional[str]:
|
|
253
|
-
"""
|
|
254
|
-
Extract username from session filename.
|
|
255
|
-
Supports two formats:
|
|
256
|
-
- New: username_agent_shortid.jsonl (e.g., alice_claude_a1b2c3d4.jsonl)
|
|
257
|
-
- Old: timestamp_username_agent_shortid.jsonl (e.g., 1234567890_alice_claude_a1b2c3d4.jsonl)
|
|
258
|
-
"""
|
|
259
|
-
try:
|
|
260
|
-
filename = file_path.stem # Get filename without extension
|
|
261
|
-
parts = filename.split('_')
|
|
262
|
-
|
|
263
|
-
if len(parts) >= 3:
|
|
264
|
-
if parts[0].isdigit():
|
|
265
|
-
# Old format: timestamp_username_agent_shortid
|
|
266
|
-
return parts[1]
|
|
267
|
-
else:
|
|
268
|
-
# New format: username_agent_shortid
|
|
269
|
-
return parts[0]
|
|
270
|
-
except Exception:
|
|
271
|
-
pass
|
|
272
|
-
# Return None if username cannot be extracted (e.g., UUID format)
|
|
273
|
-
return None
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
def extract_agent_from_filename(file_path: Path) -> Optional[str]:
|
|
277
|
-
"""
|
|
278
|
-
Extract agent type from session filename.
|
|
279
|
-
Supports two formats:
|
|
280
|
-
- New: username_agent_shortid.jsonl (e.g., alice_claude_a1b2c3d4.jsonl)
|
|
281
|
-
- Old: timestamp_username_agent_shortid.jsonl (e.g., 1234567890_alice_claude_a1b2c3d4.jsonl)
|
|
282
|
-
Returns 'claude', 'codex', or None if not extractable.
|
|
283
|
-
"""
|
|
284
|
-
try:
|
|
285
|
-
filename = file_path.stem # Get filename without extension
|
|
286
|
-
parts = filename.split('_')
|
|
287
|
-
|
|
288
|
-
if len(parts) >= 3:
|
|
289
|
-
if parts[0].isdigit():
|
|
290
|
-
# Old format: timestamp_username_agent_shortid
|
|
291
|
-
agent = parts[2]
|
|
292
|
-
else:
|
|
293
|
-
# New format: username_agent_shortid
|
|
294
|
-
agent = parts[1]
|
|
295
|
-
|
|
296
|
-
# Normalize agent name
|
|
297
|
-
agent_lower = agent.lower()
|
|
298
|
-
if agent_lower in ('claude', 'codex', 'unknown'):
|
|
299
|
-
return agent_lower
|
|
300
|
-
except Exception:
|
|
301
|
-
pass
|
|
302
|
-
return None
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
def search_in_session_file(file_path: Path, keyword: str, max_matches: int = 5) -> List[Dict[str, Any]]:
|
|
306
|
-
"""
|
|
307
|
-
Search for keyword in a session file and return matching messages with context.
|
|
308
|
-
|
|
309
|
-
Returns a list of dicts with: role, text, timestamp, line_number, username (optional), model (optional)
|
|
310
|
-
"""
|
|
311
|
-
matches = []
|
|
312
|
-
# Extract username and agent type from filename
|
|
313
|
-
username = extract_username_from_filename(file_path)
|
|
314
|
-
agent_from_filename = extract_agent_from_filename(file_path)
|
|
315
|
-
|
|
316
|
-
try:
|
|
317
|
-
with open(file_path, "r", encoding="utf-8") as f:
|
|
318
|
-
for line_num, line in enumerate(f, 1):
|
|
319
|
-
line = line.strip()
|
|
320
|
-
if not line:
|
|
321
|
-
continue
|
|
322
|
-
|
|
323
|
-
try:
|
|
324
|
-
data = json.loads(line)
|
|
325
|
-
|
|
326
|
-
# Extract role and message content
|
|
327
|
-
role = None
|
|
328
|
-
text = ""
|
|
329
|
-
timestamp = data.get("timestamp", "")
|
|
330
|
-
model = None
|
|
331
|
-
|
|
332
|
-
# Handle different message formats
|
|
333
|
-
# Format 1: Claude Code format
|
|
334
|
-
if data.get("type") in ("user", "assistant"):
|
|
335
|
-
role = data.get("type")
|
|
336
|
-
message = data.get("message", {})
|
|
337
|
-
if isinstance(message, dict):
|
|
338
|
-
content = message.get("content", "")
|
|
339
|
-
text = extract_text_from_content(content)
|
|
340
|
-
# Extract model name for assistant messages
|
|
341
|
-
if role == "assistant":
|
|
342
|
-
model = message.get("model", "")
|
|
343
|
-
# Format 2: Codex format
|
|
344
|
-
elif data.get("type") == "response_item":
|
|
345
|
-
payload = data.get("payload", {})
|
|
346
|
-
if payload.get("type") == "message":
|
|
347
|
-
role = payload.get("role")
|
|
348
|
-
content = payload.get("content", [])
|
|
349
|
-
# Extract text from Codex content format
|
|
350
|
-
texts = []
|
|
351
|
-
for item in content if isinstance(content, list) else []:
|
|
352
|
-
if isinstance(item, dict):
|
|
353
|
-
# Codex uses "input_text" and "output_text" types
|
|
354
|
-
if item.get("type") in ("input_text", "output_text"):
|
|
355
|
-
texts.append(item.get("text", ""))
|
|
356
|
-
text = "\n".join(texts)
|
|
357
|
-
# Codex doesn't store model info in session files
|
|
358
|
-
model = None
|
|
359
|
-
# Format 3: Simple JSONL format
|
|
360
|
-
elif "role" in data and "content" in data:
|
|
361
|
-
role = data.get("role")
|
|
362
|
-
text = extract_text_from_content(data.get("content"))
|
|
363
|
-
# Extract model name for assistant messages
|
|
364
|
-
if role == "assistant":
|
|
365
|
-
model = data.get("model", "")
|
|
366
|
-
|
|
367
|
-
# Check if keyword is in the text (case-insensitive)
|
|
368
|
-
if role and text and keyword.lower() in text.lower():
|
|
369
|
-
# If no model from content, use agent from filename (for Codex)
|
|
370
|
-
if not model and agent_from_filename:
|
|
371
|
-
model = agent_from_filename
|
|
372
|
-
|
|
373
|
-
matches.append({
|
|
374
|
-
"role": role,
|
|
375
|
-
"text": text,
|
|
376
|
-
"timestamp": timestamp,
|
|
377
|
-
"line_number": line_num,
|
|
378
|
-
"username": username,
|
|
379
|
-
"model": model,
|
|
380
|
-
})
|
|
381
|
-
|
|
382
|
-
if len(matches) >= max_matches:
|
|
383
|
-
break
|
|
384
|
-
|
|
385
|
-
except json.JSONDecodeError:
|
|
386
|
-
continue
|
|
387
|
-
|
|
388
|
-
except Exception as e:
|
|
389
|
-
console.print(f"[red]Error reading session file:[/red] {e}")
|
|
390
|
-
|
|
391
|
-
return matches
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
def display_session_matches(file_path: Path, keyword: str, max_matches: int = 5):
|
|
395
|
-
"""Display matching messages from a session file with context and highlighting."""
|
|
396
|
-
matches = search_in_session_file(file_path, keyword, max_matches)
|
|
397
|
-
|
|
398
|
-
if not matches:
|
|
399
|
-
return
|
|
400
|
-
|
|
401
|
-
def highlight_text(text: str, keyword: str) -> str:
|
|
402
|
-
"""Highlight keyword in text (case-insensitive)."""
|
|
403
|
-
if not keyword or not text:
|
|
404
|
-
return text
|
|
405
|
-
pattern = re.compile(f'({re.escape(keyword)})', re.IGNORECASE)
|
|
406
|
-
return pattern.sub(r'[black on yellow]\1[/black on yellow]', text)
|
|
407
|
-
|
|
408
|
-
for i, match in enumerate(matches, 1):
|
|
409
|
-
role_color = "blue" if match["role"] == "user" else "green"
|
|
410
|
-
|
|
411
|
-
# Build role label with optional username/model
|
|
412
|
-
role_label = match['role'].upper()
|
|
413
|
-
if match['role'] == 'user':
|
|
414
|
-
username = match.get('username') or 'unknown'
|
|
415
|
-
role_label = f"USER ({username})"
|
|
416
|
-
elif match['role'] == 'assistant':
|
|
417
|
-
model_name = match.get('model')
|
|
418
|
-
if model_name:
|
|
419
|
-
# Check if it's just an agent name (codex, claude, unknown) or full model name
|
|
420
|
-
if model_name.lower() in ('codex', 'claude', 'unknown'):
|
|
421
|
-
# Agent name from filename
|
|
422
|
-
role_label = f"ASSISTANT ({model_name})"
|
|
423
|
-
else:
|
|
424
|
-
# Full model name from session content - extract short version
|
|
425
|
-
model_short = model_name.split('-2024')[0].split('-2025')[0] # Remove date suffix
|
|
426
|
-
role_label = f"ASSISTANT ({model_short})"
|
|
427
|
-
else:
|
|
428
|
-
role_label = "ASSISTANT (unknown)"
|
|
429
|
-
|
|
430
|
-
console.print(f"\n [bold {role_color}]{role_label}[/bold {role_color}] (line {match['line_number']})")
|
|
431
|
-
|
|
432
|
-
# Truncate and highlight text
|
|
433
|
-
text = match["text"]
|
|
434
|
-
if len(text) > 500:
|
|
435
|
-
# Find keyword position and show context around it
|
|
436
|
-
keyword_pos = text.lower().find(keyword.lower())
|
|
437
|
-
if keyword_pos != -1:
|
|
438
|
-
start = max(0, keyword_pos - 200)
|
|
439
|
-
end = min(len(text), keyword_pos + 300)
|
|
440
|
-
text = ("..." if start > 0 else "") + text[start:end] + ("..." if end < len(text) else "")
|
|
441
|
-
|
|
442
|
-
# Display with highlighting and indentation
|
|
443
|
-
for line in text.split('\n'):
|
|
444
|
-
if line.strip():
|
|
445
|
-
console.print(f" {highlight_text(line, keyword)}")
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
if __name__ == "__main__":
|
|
449
|
-
typer.run(search_command)
|