claude-dev-cli 0.8.2__tar.gz → 0.8.4__tar.gz
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.
Potentially problematic release.
This version of claude-dev-cli might be problematic. Click here for more details.
- {claude_dev_cli-0.8.2/src/claude_dev_cli.egg-info → claude_dev_cli-0.8.4}/PKG-INFO +59 -6
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/README.md +58 -5
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/pyproject.toml +1 -1
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/src/claude_dev_cli/__init__.py +1 -1
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/src/claude_dev_cli/cli.py +131 -0
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/src/claude_dev_cli/config.py +44 -2
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/src/claude_dev_cli/history.py +90 -4
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4/src/claude_dev_cli.egg-info}/PKG-INFO +59 -6
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/src/claude_dev_cli.egg-info/SOURCES.txt +1 -0
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/tests/test_config.py +22 -0
- claude_dev_cli-0.8.4/tests/test_history.py +488 -0
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/LICENSE +0 -0
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/MANIFEST.in +0 -0
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/setup.cfg +0 -0
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/src/claude_dev_cli/commands.py +0 -0
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/src/claude_dev_cli/context.py +0 -0
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/src/claude_dev_cli/core.py +0 -0
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/src/claude_dev_cli/plugins/__init__.py +0 -0
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/src/claude_dev_cli/plugins/base.py +0 -0
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/src/claude_dev_cli/plugins/diff_editor/__init__.py +0 -0
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/src/claude_dev_cli/plugins/diff_editor/plugin.py +0 -0
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/src/claude_dev_cli/plugins/diff_editor/viewer.py +0 -0
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/src/claude_dev_cli/secure_storage.py +0 -0
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/src/claude_dev_cli/template_manager.py +0 -0
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/src/claude_dev_cli/templates.py +0 -0
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/src/claude_dev_cli/toon_utils.py +0 -0
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/src/claude_dev_cli/usage.py +0 -0
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/src/claude_dev_cli/warp_integration.py +0 -0
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/src/claude_dev_cli/workflows.py +0 -0
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/src/claude_dev_cli.egg-info/dependency_links.txt +0 -0
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/src/claude_dev_cli.egg-info/entry_points.txt +0 -0
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/src/claude_dev_cli.egg-info/requires.txt +0 -0
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/src/claude_dev_cli.egg-info/top_level.txt +0 -0
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/tests/test_cli.py +0 -0
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/tests/test_commands.py +0 -0
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/tests/test_context.py +0 -0
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/tests/test_core.py +0 -0
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/tests/test_diff_editor.py +0 -0
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/tests/test_secure_storage.py +0 -0
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/tests/test_template_manager.py +0 -0
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/tests/test_toon_utils.py +0 -0
- {claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/tests/test_usage.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: claude-dev-cli
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.4
|
|
4
4
|
Summary: A powerful CLI tool for developers using Claude AI with multi-API routing, test generation, code review, and usage tracking
|
|
5
5
|
Author-email: Julio <thinmanj@users.noreply.github.com>
|
|
6
6
|
License: MIT
|
|
@@ -44,6 +44,13 @@ Dynamic: license-file
|
|
|
44
44
|
|
|
45
45
|
# Claude Dev CLI
|
|
46
46
|
|
|
47
|
+
[](https://badge.fury.io/py/claude-dev-cli)
|
|
48
|
+
[](https://www.python.org/downloads/)
|
|
49
|
+
[](https://github.com/thinmanj/claude-dev-cli)
|
|
50
|
+
[](https://opensource.org/licenses/MIT)
|
|
51
|
+
[](https://github.com/thinmanj/homebrew-tap)
|
|
52
|
+
[](https://github.com/psf/black)
|
|
53
|
+
|
|
47
54
|
A powerful command-line tool for developers using Claude AI with multi-API routing, test generation, code review, and comprehensive usage tracking.
|
|
48
55
|
|
|
49
56
|
## Features
|
|
@@ -99,19 +106,39 @@ A powerful command-line tool for developers using Claude AI with multi-API routi
|
|
|
99
106
|
|
|
100
107
|
## Installation
|
|
101
108
|
|
|
102
|
-
###
|
|
109
|
+
### Via Homebrew (macOS/Linux)
|
|
103
110
|
|
|
104
111
|
```bash
|
|
105
|
-
|
|
112
|
+
# Add the tap
|
|
113
|
+
brew tap thinmanj/tap
|
|
114
|
+
|
|
115
|
+
# Install
|
|
116
|
+
brew install claude-dev-cli
|
|
117
|
+
|
|
118
|
+
# Or in one command
|
|
119
|
+
brew install thinmanj/tap/claude-dev-cli
|
|
106
120
|
```
|
|
107
121
|
|
|
108
|
-
###
|
|
122
|
+
### Via pip
|
|
109
123
|
|
|
110
124
|
```bash
|
|
111
|
-
#
|
|
125
|
+
# Basic installation
|
|
126
|
+
pip install claude-dev-cli
|
|
127
|
+
|
|
128
|
+
# With TOON support (30-60% token reduction)
|
|
112
129
|
pip install claude-dev-cli[toon]
|
|
113
130
|
```
|
|
114
131
|
|
|
132
|
+
### Via pipx (Recommended for CLI tools)
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
# Isolated installation
|
|
136
|
+
pipx install claude-dev-cli
|
|
137
|
+
|
|
138
|
+
# With TOON support
|
|
139
|
+
pipx install claude-dev-cli[toon]
|
|
140
|
+
```
|
|
141
|
+
|
|
115
142
|
## Quick Start
|
|
116
143
|
|
|
117
144
|
### 1. Set Up API Keys
|
|
@@ -266,7 +293,33 @@ cdc template list --user
|
|
|
266
293
|
- **explain-code**: Detailed code explanation
|
|
267
294
|
- **api-design**: API design assistance
|
|
268
295
|
|
|
269
|
-
### 6.
|
|
296
|
+
### 6. Conversation History & Summarization (NEW in v0.8.3)
|
|
297
|
+
|
|
298
|
+
```bash
|
|
299
|
+
# List recent conversations
|
|
300
|
+
cdc history list
|
|
301
|
+
|
|
302
|
+
# Search conversations
|
|
303
|
+
cdc history list --search "python decorators"
|
|
304
|
+
|
|
305
|
+
# Export conversation
|
|
306
|
+
cdc history export 20240109_143022 -o chat.md
|
|
307
|
+
|
|
308
|
+
# Summarize to reduce token usage
|
|
309
|
+
cdc history summarize --latest
|
|
310
|
+
cdc history summarize 20240109_143022 --keep-recent 6
|
|
311
|
+
|
|
312
|
+
# Delete old conversations
|
|
313
|
+
cdc history delete 20240109_143022
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
**Auto-Summarization** in interactive mode:
|
|
317
|
+
- Automatically triggers when conversation exceeds 8,000 tokens
|
|
318
|
+
- Keeps recent messages (default: 4 pairs), summarizes older ones
|
|
319
|
+
- Reduces API costs by ~30-50% in long conversations
|
|
320
|
+
- Shows token savings after summarization
|
|
321
|
+
|
|
322
|
+
### 7. Usage Tracking
|
|
270
323
|
|
|
271
324
|
```bash
|
|
272
325
|
# View all usage
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Claude Dev CLI
|
|
2
2
|
|
|
3
|
+
[](https://badge.fury.io/py/claude-dev-cli)
|
|
4
|
+
[](https://www.python.org/downloads/)
|
|
5
|
+
[](https://github.com/thinmanj/claude-dev-cli)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
[](https://github.com/thinmanj/homebrew-tap)
|
|
8
|
+
[](https://github.com/psf/black)
|
|
9
|
+
|
|
3
10
|
A powerful command-line tool for developers using Claude AI with multi-API routing, test generation, code review, and comprehensive usage tracking.
|
|
4
11
|
|
|
5
12
|
## Features
|
|
@@ -55,19 +62,39 @@ A powerful command-line tool for developers using Claude AI with multi-API routi
|
|
|
55
62
|
|
|
56
63
|
## Installation
|
|
57
64
|
|
|
58
|
-
###
|
|
65
|
+
### Via Homebrew (macOS/Linux)
|
|
59
66
|
|
|
60
67
|
```bash
|
|
61
|
-
|
|
68
|
+
# Add the tap
|
|
69
|
+
brew tap thinmanj/tap
|
|
70
|
+
|
|
71
|
+
# Install
|
|
72
|
+
brew install claude-dev-cli
|
|
73
|
+
|
|
74
|
+
# Or in one command
|
|
75
|
+
brew install thinmanj/tap/claude-dev-cli
|
|
62
76
|
```
|
|
63
77
|
|
|
64
|
-
###
|
|
78
|
+
### Via pip
|
|
65
79
|
|
|
66
80
|
```bash
|
|
67
|
-
#
|
|
81
|
+
# Basic installation
|
|
82
|
+
pip install claude-dev-cli
|
|
83
|
+
|
|
84
|
+
# With TOON support (30-60% token reduction)
|
|
68
85
|
pip install claude-dev-cli[toon]
|
|
69
86
|
```
|
|
70
87
|
|
|
88
|
+
### Via pipx (Recommended for CLI tools)
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
# Isolated installation
|
|
92
|
+
pipx install claude-dev-cli
|
|
93
|
+
|
|
94
|
+
# With TOON support
|
|
95
|
+
pipx install claude-dev-cli[toon]
|
|
96
|
+
```
|
|
97
|
+
|
|
71
98
|
## Quick Start
|
|
72
99
|
|
|
73
100
|
### 1. Set Up API Keys
|
|
@@ -222,7 +249,33 @@ cdc template list --user
|
|
|
222
249
|
- **explain-code**: Detailed code explanation
|
|
223
250
|
- **api-design**: API design assistance
|
|
224
251
|
|
|
225
|
-
### 6.
|
|
252
|
+
### 6. Conversation History & Summarization (NEW in v0.8.3)
|
|
253
|
+
|
|
254
|
+
```bash
|
|
255
|
+
# List recent conversations
|
|
256
|
+
cdc history list
|
|
257
|
+
|
|
258
|
+
# Search conversations
|
|
259
|
+
cdc history list --search "python decorators"
|
|
260
|
+
|
|
261
|
+
# Export conversation
|
|
262
|
+
cdc history export 20240109_143022 -o chat.md
|
|
263
|
+
|
|
264
|
+
# Summarize to reduce token usage
|
|
265
|
+
cdc history summarize --latest
|
|
266
|
+
cdc history summarize 20240109_143022 --keep-recent 6
|
|
267
|
+
|
|
268
|
+
# Delete old conversations
|
|
269
|
+
cdc history delete 20240109_143022
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
**Auto-Summarization** in interactive mode:
|
|
273
|
+
- Automatically triggers when conversation exceeds 8,000 tokens
|
|
274
|
+
- Keeps recent messages (default: 4 pairs), summarizes older ones
|
|
275
|
+
- Reduces API costs by ~30-50% in long conversations
|
|
276
|
+
- Shows token savings after summarization
|
|
277
|
+
|
|
278
|
+
### 7. Usage Tracking
|
|
226
279
|
|
|
227
280
|
```bash
|
|
228
281
|
# View all usage
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "claude-dev-cli"
|
|
7
|
-
version = "0.8.
|
|
7
|
+
version = "0.8.4"
|
|
8
8
|
description = "A powerful CLI tool for developers using Claude AI with multi-API routing, test generation, code review, and usage tracking"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -145,6 +145,7 @@ def interactive(
|
|
|
145
145
|
config = Config()
|
|
146
146
|
history_dir = config.config_dir / "history"
|
|
147
147
|
conv_history = ConversationHistory(history_dir)
|
|
148
|
+
summ_config = config.get_summarization_config()
|
|
148
149
|
|
|
149
150
|
# Load or create conversation
|
|
150
151
|
if continue_conversation:
|
|
@@ -203,6 +204,42 @@ def interactive(
|
|
|
203
204
|
full_response = ''.join(response_buffer)
|
|
204
205
|
conversation.add_message("assistant", full_response)
|
|
205
206
|
|
|
207
|
+
# Check if auto-summarization is needed
|
|
208
|
+
if summ_config.auto_summarize and conversation.should_summarize(threshold_tokens=summ_config.threshold_tokens):
|
|
209
|
+
console.print("\n[yellow]⚠ Conversation getting long, summarizing older messages...[/yellow]")
|
|
210
|
+
old_messages, recent_messages = conversation.compress_messages(keep_recent=summ_config.keep_recent_messages)
|
|
211
|
+
|
|
212
|
+
if old_messages:
|
|
213
|
+
# Build summary prompt
|
|
214
|
+
conversation_text = []
|
|
215
|
+
if conversation.summary:
|
|
216
|
+
conversation_text.append(f"Previous summary:\n{conversation.summary}\n\n")
|
|
217
|
+
|
|
218
|
+
conversation_text.append("Conversation to summarize:\n")
|
|
219
|
+
for msg in old_messages:
|
|
220
|
+
role_name = "User" if msg.role == "user" else "Assistant"
|
|
221
|
+
conversation_text.append(f"{role_name}: {msg.content}\n")
|
|
222
|
+
|
|
223
|
+
summary_prompt = (
|
|
224
|
+
"Please provide a concise summary of this conversation that captures:"
|
|
225
|
+
"\n1. Main topics discussed"
|
|
226
|
+
"\n2. Key questions asked and answers provided"
|
|
227
|
+
"\n3. Important decisions or conclusions"
|
|
228
|
+
"\n4. Any action items or follow-ups mentioned"
|
|
229
|
+
"\n\nKeep the summary under 300 words but retain all important context."
|
|
230
|
+
"\n\n" + "".join(conversation_text)
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# Get summary (use same client)
|
|
234
|
+
new_summary = client.call(summary_prompt)
|
|
235
|
+
|
|
236
|
+
# Update conversation
|
|
237
|
+
conversation.summary = new_summary
|
|
238
|
+
conversation.messages = recent_messages
|
|
239
|
+
|
|
240
|
+
tokens_saved = len("".join(conversation_text)) // 4
|
|
241
|
+
console.print(f"[dim]✓ Summarized older messages (~{tokens_saved:,} tokens saved)[/dim]")
|
|
242
|
+
|
|
206
243
|
# Auto-save periodically
|
|
207
244
|
if save and len(conversation.messages) % 10 == 0:
|
|
208
245
|
conv_history.save_conversation(conversation)
|
|
@@ -341,6 +378,100 @@ def history_export(ctx: click.Context, conversation_id: str, format: str, output
|
|
|
341
378
|
click.echo(content)
|
|
342
379
|
|
|
343
380
|
|
|
381
|
+
@history.command('summarize')
|
|
382
|
+
@click.argument('conversation_id', required=False)
|
|
383
|
+
@click.option('--keep-recent', type=int, default=4, help='Number of recent message pairs to keep')
|
|
384
|
+
@click.option('--latest', is_flag=True, help='Summarize the latest conversation')
|
|
385
|
+
@click.pass_context
|
|
386
|
+
def history_summarize(
|
|
387
|
+
ctx: click.Context,
|
|
388
|
+
conversation_id: Optional[str],
|
|
389
|
+
keep_recent: int,
|
|
390
|
+
latest: bool
|
|
391
|
+
) -> None:
|
|
392
|
+
"""Summarize a conversation to reduce token usage.
|
|
393
|
+
|
|
394
|
+
Older messages are compressed into an AI-generated summary while keeping
|
|
395
|
+
recent messages intact. This reduces context window usage and costs.
|
|
396
|
+
|
|
397
|
+
Examples:
|
|
398
|
+
cdc history summarize 20240109_143022
|
|
399
|
+
cdc history summarize --latest
|
|
400
|
+
cdc history summarize --latest --keep-recent 6
|
|
401
|
+
"""
|
|
402
|
+
console = ctx.obj['console']
|
|
403
|
+
config = Config()
|
|
404
|
+
conv_history = ConversationHistory(config.config_dir / "history")
|
|
405
|
+
|
|
406
|
+
# Determine which conversation to summarize
|
|
407
|
+
if latest:
|
|
408
|
+
conv = conv_history.get_latest_conversation()
|
|
409
|
+
if not conv:
|
|
410
|
+
console.print("[red]No conversations found[/red]")
|
|
411
|
+
sys.exit(1)
|
|
412
|
+
conversation_id = conv.conversation_id
|
|
413
|
+
elif not conversation_id:
|
|
414
|
+
console.print("[red]Error: Provide conversation_id or use --latest[/red]")
|
|
415
|
+
console.print("\nUsage: cdc history summarize CONVERSATION_ID")
|
|
416
|
+
console.print(" or: cdc history summarize --latest")
|
|
417
|
+
sys.exit(1)
|
|
418
|
+
|
|
419
|
+
# Load conversation to show stats
|
|
420
|
+
conv = conv_history.load_conversation(conversation_id)
|
|
421
|
+
if not conv:
|
|
422
|
+
console.print(f"[red]Conversation {conversation_id} not found[/red]")
|
|
423
|
+
sys.exit(1)
|
|
424
|
+
|
|
425
|
+
# Show before stats
|
|
426
|
+
tokens_before = conv.estimate_tokens()
|
|
427
|
+
messages_before = len(conv.messages)
|
|
428
|
+
console.print(f"\n[cyan]Conversation:[/cyan] {conversation_id}")
|
|
429
|
+
console.print(f"[dim]Messages: {messages_before} | Estimated tokens: {tokens_before:,}[/dim]\n")
|
|
430
|
+
|
|
431
|
+
if messages_before <= keep_recent:
|
|
432
|
+
console.print(f"[yellow]Too few messages to summarize ({messages_before} <= {keep_recent})[/yellow]")
|
|
433
|
+
return
|
|
434
|
+
|
|
435
|
+
# Summarize
|
|
436
|
+
with console.status("[bold blue]Generating summary..."):
|
|
437
|
+
summary = conv_history.summarize_conversation(conversation_id, keep_recent)
|
|
438
|
+
|
|
439
|
+
if not summary:
|
|
440
|
+
console.print("[red]Failed to generate summary[/red]")
|
|
441
|
+
sys.exit(1)
|
|
442
|
+
|
|
443
|
+
# Reload and show after stats
|
|
444
|
+
conv = conv_history.load_conversation(conversation_id)
|
|
445
|
+
if conv:
|
|
446
|
+
tokens_after = conv.estimate_tokens()
|
|
447
|
+
messages_after = len(conv.messages)
|
|
448
|
+
|
|
449
|
+
token_savings = tokens_before - tokens_after
|
|
450
|
+
savings_percent = (token_savings / tokens_before * 100) if tokens_before > 0 else 0
|
|
451
|
+
|
|
452
|
+
console.print("[green]✓ Conversation summarized[/green]\n")
|
|
453
|
+
console.print(f"[dim]Messages:[/dim] {messages_before} → {messages_after}")
|
|
454
|
+
console.print(f"[dim]Tokens:[/dim] {tokens_before:,} → {tokens_after:,} ({token_savings:,} saved, {savings_percent:.1f}%)\n")
|
|
455
|
+
console.print("[bold]Summary:[/bold]")
|
|
456
|
+
console.print(Panel(summary, border_style="blue"))
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
@history.command('delete')
|
|
460
|
+
@click.argument('conversation_id')
|
|
461
|
+
@click.pass_context
|
|
462
|
+
def history_delete(ctx: click.Context, conversation_id: str) -> None:
|
|
463
|
+
"""Delete a conversation."""
|
|
464
|
+
console = ctx.obj['console']
|
|
465
|
+
config = Config()
|
|
466
|
+
conv_history = ConversationHistory(config.config_dir / "history")
|
|
467
|
+
|
|
468
|
+
if conv_history.delete_conversation(conversation_id):
|
|
469
|
+
console.print(f"[green]✓[/green] Deleted conversation: {conversation_id}")
|
|
470
|
+
else:
|
|
471
|
+
console.print(f"[red]Conversation {conversation_id} not found[/red]")
|
|
472
|
+
sys.exit(1)
|
|
473
|
+
|
|
474
|
+
|
|
344
475
|
@main.group()
|
|
345
476
|
def config() -> None:
|
|
346
477
|
"""Manage configuration."""
|
|
@@ -21,6 +21,15 @@ class ContextConfig(BaseModel):
|
|
|
21
21
|
include_tests: bool = True # Include test files by default
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
class SummarizationConfig(BaseModel):
|
|
25
|
+
"""Conversation summarization configuration."""
|
|
26
|
+
|
|
27
|
+
auto_summarize: bool = True # Enable automatic summarization
|
|
28
|
+
threshold_tokens: int = 8000 # Token threshold for auto-summarization
|
|
29
|
+
keep_recent_messages: int = 4 # Number of recent message pairs to keep
|
|
30
|
+
summary_max_words: int = 300 # Maximum words in generated summary
|
|
31
|
+
|
|
32
|
+
|
|
24
33
|
class APIConfig(BaseModel):
|
|
25
34
|
"""Configuration for a Claude API key."""
|
|
26
35
|
|
|
@@ -75,6 +84,13 @@ class Config:
|
|
|
75
84
|
|
|
76
85
|
def _ensure_config_dir(self) -> None:
|
|
77
86
|
"""Ensure configuration directory exists."""
|
|
87
|
+
# Check if config_dir exists as a file (not directory)
|
|
88
|
+
if self.config_dir.exists() and not self.config_dir.is_dir():
|
|
89
|
+
raise RuntimeError(
|
|
90
|
+
f"Configuration path {self.config_dir} exists but is not a directory. "
|
|
91
|
+
f"Please remove or rename this file."
|
|
92
|
+
)
|
|
93
|
+
|
|
78
94
|
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
79
95
|
self.usage_log.touch(exist_ok=True)
|
|
80
96
|
|
|
@@ -87,12 +103,33 @@ class Config:
|
|
|
87
103
|
"default_model": "claude-3-5-sonnet-20241022",
|
|
88
104
|
"max_tokens": 4096,
|
|
89
105
|
"context": ContextConfig().model_dump(),
|
|
106
|
+
"summarization": SummarizationConfig().model_dump(),
|
|
90
107
|
}
|
|
91
108
|
self._save_config(default_config)
|
|
92
109
|
return default_config
|
|
93
110
|
|
|
94
|
-
|
|
95
|
-
|
|
111
|
+
# Check if config_file is actually a directory
|
|
112
|
+
if self.config_file.is_dir():
|
|
113
|
+
raise RuntimeError(
|
|
114
|
+
f"Configuration file {self.config_file} is a directory. "
|
|
115
|
+
f"Please remove this directory."
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
with open(self.config_file, 'r') as f:
|
|
120
|
+
config = json.load(f)
|
|
121
|
+
|
|
122
|
+
# Ensure required keys exist (for backwards compatibility)
|
|
123
|
+
if "context" not in config:
|
|
124
|
+
config["context"] = ContextConfig().model_dump()
|
|
125
|
+
if "summarization" not in config:
|
|
126
|
+
config["summarization"] = SummarizationConfig().model_dump()
|
|
127
|
+
|
|
128
|
+
return config
|
|
129
|
+
except (json.JSONDecodeError, IOError) as e:
|
|
130
|
+
raise RuntimeError(
|
|
131
|
+
f"Failed to load configuration from {self.config_file}: {e}"
|
|
132
|
+
)
|
|
96
133
|
|
|
97
134
|
def _save_config(self, data: Optional[Dict] = None) -> None:
|
|
98
135
|
"""Save configuration to file."""
|
|
@@ -263,3 +300,8 @@ class Config:
|
|
|
263
300
|
"""Get context gathering configuration."""
|
|
264
301
|
context_data = self._data.get("context", {})
|
|
265
302
|
return ContextConfig(**context_data) if context_data else ContextConfig()
|
|
303
|
+
|
|
304
|
+
def get_summarization_config(self) -> SummarizationConfig:
|
|
305
|
+
"""Get conversation summarization configuration."""
|
|
306
|
+
summ_data = self._data.get("summarization", {})
|
|
307
|
+
return SummarizationConfig(**summ_data) if summ_data else SummarizationConfig()
|
|
@@ -39,11 +39,13 @@ class Conversation:
|
|
|
39
39
|
self,
|
|
40
40
|
conversation_id: Optional[str] = None,
|
|
41
41
|
created_at: Optional[datetime] = None,
|
|
42
|
-
updated_at: Optional[datetime] = None
|
|
42
|
+
updated_at: Optional[datetime] = None,
|
|
43
|
+
summary: Optional[str] = None
|
|
43
44
|
):
|
|
44
|
-
self.conversation_id = conversation_id or datetime.utcnow().strftime("%Y%m%d_%H%M%
|
|
45
|
+
self.conversation_id = conversation_id or datetime.utcnow().strftime("%Y%m%d_%H%M%S_%f")
|
|
45
46
|
self.created_at = created_at or datetime.utcnow()
|
|
46
47
|
self.updated_at = updated_at or datetime.utcnow()
|
|
48
|
+
self.summary = summary # AI-generated summary of older messages
|
|
47
49
|
self.messages: List[Message] = []
|
|
48
50
|
|
|
49
51
|
def add_message(self, role: str, content: str) -> None:
|
|
@@ -62,14 +64,37 @@ class Conversation:
|
|
|
62
64
|
return summary
|
|
63
65
|
return "(empty conversation)"
|
|
64
66
|
|
|
67
|
+
def estimate_tokens(self) -> int:
|
|
68
|
+
"""Estimate token count for the conversation."""
|
|
69
|
+
# Rough estimation: ~4 characters per token
|
|
70
|
+
total_chars = len(self.summary or "")
|
|
71
|
+
for msg in self.messages:
|
|
72
|
+
total_chars += len(msg.content)
|
|
73
|
+
return total_chars // 4
|
|
74
|
+
|
|
75
|
+
def should_summarize(self, threshold_tokens: int = 8000) -> bool:
|
|
76
|
+
"""Check if conversation should be summarized."""
|
|
77
|
+
return self.estimate_tokens() > threshold_tokens and len(self.messages) > 4
|
|
78
|
+
|
|
79
|
+
def compress_messages(self, keep_recent: int = 4) -> tuple[List[Message], List[Message]]:
|
|
80
|
+
"""Split messages into old (to summarize) and recent (to keep)."""
|
|
81
|
+
if len(self.messages) <= keep_recent:
|
|
82
|
+
return [], self.messages
|
|
83
|
+
|
|
84
|
+
split_point = len(self.messages) - keep_recent
|
|
85
|
+
return self.messages[:split_point], self.messages[split_point:]
|
|
86
|
+
|
|
65
87
|
def to_dict(self) -> Dict[str, Any]:
|
|
66
88
|
"""Convert to dictionary for storage."""
|
|
67
|
-
|
|
89
|
+
data = {
|
|
68
90
|
"conversation_id": self.conversation_id,
|
|
69
91
|
"created_at": self.created_at.isoformat(),
|
|
70
92
|
"updated_at": self.updated_at.isoformat(),
|
|
71
93
|
"messages": [msg.to_dict() for msg in self.messages]
|
|
72
94
|
}
|
|
95
|
+
if self.summary:
|
|
96
|
+
data["summary"] = self.summary
|
|
97
|
+
return data
|
|
73
98
|
|
|
74
99
|
@classmethod
|
|
75
100
|
def from_dict(cls, data: Dict[str, Any]) -> "Conversation":
|
|
@@ -77,7 +102,8 @@ class Conversation:
|
|
|
77
102
|
conv = cls(
|
|
78
103
|
conversation_id=data["conversation_id"],
|
|
79
104
|
created_at=datetime.fromisoformat(data["created_at"]),
|
|
80
|
-
updated_at=datetime.fromisoformat(data["updated_at"])
|
|
105
|
+
updated_at=datetime.fromisoformat(data["updated_at"]),
|
|
106
|
+
summary=data.get("summary")
|
|
81
107
|
)
|
|
82
108
|
conv.messages = [Message.from_dict(msg) for msg in data.get("messages", [])]
|
|
83
109
|
return conv
|
|
@@ -187,3 +213,63 @@ class ConversationHistory:
|
|
|
187
213
|
return json.dumps(conv.to_dict(), indent=2)
|
|
188
214
|
|
|
189
215
|
return None
|
|
216
|
+
|
|
217
|
+
def summarize_conversation(
|
|
218
|
+
self,
|
|
219
|
+
conversation_id: str,
|
|
220
|
+
keep_recent: int = 4
|
|
221
|
+
) -> Optional[str]:
|
|
222
|
+
"""Summarize older messages in a conversation, keeping recent ones.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
conversation_id: The conversation to summarize
|
|
226
|
+
keep_recent: Number of recent message pairs to keep unsummarized
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
The generated summary or None if conversation not found
|
|
230
|
+
"""
|
|
231
|
+
from claude_dev_cli.core import ClaudeClient
|
|
232
|
+
|
|
233
|
+
conv = self.load_conversation(conversation_id)
|
|
234
|
+
if not conv:
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
# Split messages
|
|
238
|
+
old_messages, recent_messages = conv.compress_messages(keep_recent)
|
|
239
|
+
|
|
240
|
+
if not old_messages:
|
|
241
|
+
return "No messages to summarize (too few messages)"
|
|
242
|
+
|
|
243
|
+
# Build summary prompt
|
|
244
|
+
conversation_text = []
|
|
245
|
+
if conv.summary:
|
|
246
|
+
conversation_text.append(f"Previous summary:\n{conv.summary}\n\n")
|
|
247
|
+
|
|
248
|
+
conversation_text.append("Conversation to summarize:\n")
|
|
249
|
+
for msg in old_messages:
|
|
250
|
+
role_name = "User" if msg.role == "user" else "Assistant"
|
|
251
|
+
conversation_text.append(f"{role_name}: {msg.content}\n")
|
|
252
|
+
|
|
253
|
+
prompt = (
|
|
254
|
+
"Please provide a concise summary of this conversation that captures:"
|
|
255
|
+
"\n1. Main topics discussed"
|
|
256
|
+
"\n2. Key questions asked and answers provided"
|
|
257
|
+
"\n3. Important decisions or conclusions"
|
|
258
|
+
"\n4. Any action items or follow-ups mentioned"
|
|
259
|
+
"\n\nKeep the summary under 300 words but retain all important context."
|
|
260
|
+
"\n\n" + "".join(conversation_text)
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
# Get summary from Claude
|
|
264
|
+
client = ClaudeClient()
|
|
265
|
+
new_summary = client.call(prompt)
|
|
266
|
+
|
|
267
|
+
# Update conversation
|
|
268
|
+
conv.summary = new_summary
|
|
269
|
+
conv.messages = recent_messages
|
|
270
|
+
conv.updated_at = datetime.utcnow()
|
|
271
|
+
|
|
272
|
+
# Save updated conversation
|
|
273
|
+
self.save_conversation(conv)
|
|
274
|
+
|
|
275
|
+
return new_summary
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: claude-dev-cli
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.4
|
|
4
4
|
Summary: A powerful CLI tool for developers using Claude AI with multi-API routing, test generation, code review, and usage tracking
|
|
5
5
|
Author-email: Julio <thinmanj@users.noreply.github.com>
|
|
6
6
|
License: MIT
|
|
@@ -44,6 +44,13 @@ Dynamic: license-file
|
|
|
44
44
|
|
|
45
45
|
# Claude Dev CLI
|
|
46
46
|
|
|
47
|
+
[](https://badge.fury.io/py/claude-dev-cli)
|
|
48
|
+
[](https://www.python.org/downloads/)
|
|
49
|
+
[](https://github.com/thinmanj/claude-dev-cli)
|
|
50
|
+
[](https://opensource.org/licenses/MIT)
|
|
51
|
+
[](https://github.com/thinmanj/homebrew-tap)
|
|
52
|
+
[](https://github.com/psf/black)
|
|
53
|
+
|
|
47
54
|
A powerful command-line tool for developers using Claude AI with multi-API routing, test generation, code review, and comprehensive usage tracking.
|
|
48
55
|
|
|
49
56
|
## Features
|
|
@@ -99,19 +106,39 @@ A powerful command-line tool for developers using Claude AI with multi-API routi
|
|
|
99
106
|
|
|
100
107
|
## Installation
|
|
101
108
|
|
|
102
|
-
###
|
|
109
|
+
### Via Homebrew (macOS/Linux)
|
|
103
110
|
|
|
104
111
|
```bash
|
|
105
|
-
|
|
112
|
+
# Add the tap
|
|
113
|
+
brew tap thinmanj/tap
|
|
114
|
+
|
|
115
|
+
# Install
|
|
116
|
+
brew install claude-dev-cli
|
|
117
|
+
|
|
118
|
+
# Or in one command
|
|
119
|
+
brew install thinmanj/tap/claude-dev-cli
|
|
106
120
|
```
|
|
107
121
|
|
|
108
|
-
###
|
|
122
|
+
### Via pip
|
|
109
123
|
|
|
110
124
|
```bash
|
|
111
|
-
#
|
|
125
|
+
# Basic installation
|
|
126
|
+
pip install claude-dev-cli
|
|
127
|
+
|
|
128
|
+
# With TOON support (30-60% token reduction)
|
|
112
129
|
pip install claude-dev-cli[toon]
|
|
113
130
|
```
|
|
114
131
|
|
|
132
|
+
### Via pipx (Recommended for CLI tools)
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
# Isolated installation
|
|
136
|
+
pipx install claude-dev-cli
|
|
137
|
+
|
|
138
|
+
# With TOON support
|
|
139
|
+
pipx install claude-dev-cli[toon]
|
|
140
|
+
```
|
|
141
|
+
|
|
115
142
|
## Quick Start
|
|
116
143
|
|
|
117
144
|
### 1. Set Up API Keys
|
|
@@ -266,7 +293,33 @@ cdc template list --user
|
|
|
266
293
|
- **explain-code**: Detailed code explanation
|
|
267
294
|
- **api-design**: API design assistance
|
|
268
295
|
|
|
269
|
-
### 6.
|
|
296
|
+
### 6. Conversation History & Summarization (NEW in v0.8.3)
|
|
297
|
+
|
|
298
|
+
```bash
|
|
299
|
+
# List recent conversations
|
|
300
|
+
cdc history list
|
|
301
|
+
|
|
302
|
+
# Search conversations
|
|
303
|
+
cdc history list --search "python decorators"
|
|
304
|
+
|
|
305
|
+
# Export conversation
|
|
306
|
+
cdc history export 20240109_143022 -o chat.md
|
|
307
|
+
|
|
308
|
+
# Summarize to reduce token usage
|
|
309
|
+
cdc history summarize --latest
|
|
310
|
+
cdc history summarize 20240109_143022 --keep-recent 6
|
|
311
|
+
|
|
312
|
+
# Delete old conversations
|
|
313
|
+
cdc history delete 20240109_143022
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
**Auto-Summarization** in interactive mode:
|
|
317
|
+
- Automatically triggers when conversation exceeds 8,000 tokens
|
|
318
|
+
- Keeps recent messages (default: 4 pairs), summarizes older ones
|
|
319
|
+
- Reduces API costs by ~30-50% in long conversations
|
|
320
|
+
- Shows token savings after summarization
|
|
321
|
+
|
|
322
|
+
### 7. Usage Tracking
|
|
270
323
|
|
|
271
324
|
```bash
|
|
272
325
|
# View all usage
|
|
@@ -70,6 +70,28 @@ class TestConfig:
|
|
|
70
70
|
assert config.config_dir.exists()
|
|
71
71
|
assert config.usage_log.exists()
|
|
72
72
|
|
|
73
|
+
def test_config_dir_as_file_raises(self, temp_home: Path) -> None:
|
|
74
|
+
"""Test that having config path as file raises error."""
|
|
75
|
+
# Create config path as a file instead of directory
|
|
76
|
+
config_path = temp_home / ".claude-dev-cli"
|
|
77
|
+
config_path.write_text("invalid file")
|
|
78
|
+
|
|
79
|
+
with pytest.raises(RuntimeError, match="exists but is not a directory"):
|
|
80
|
+
Config()
|
|
81
|
+
|
|
82
|
+
def test_config_file_as_dir_raises(self, temp_home: Path) -> None:
|
|
83
|
+
"""Test that having config.json as directory raises error."""
|
|
84
|
+
# Create config directory
|
|
85
|
+
config_dir = temp_home / ".claude-dev-cli"
|
|
86
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
|
|
88
|
+
# Create config.json as a directory
|
|
89
|
+
config_file = config_dir / "config.json"
|
|
90
|
+
config_file.mkdir()
|
|
91
|
+
|
|
92
|
+
with pytest.raises(RuntimeError, match="is a directory"):
|
|
93
|
+
Config()
|
|
94
|
+
|
|
73
95
|
def test_init_creates_default_config(self, temp_home: Path) -> None:
|
|
74
96
|
"""Test that Config.__init__ creates default config file."""
|
|
75
97
|
config = Config()
|
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
"""Tests for conversation history and summarization."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import pytest
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from unittest.mock import Mock, patch, MagicMock
|
|
8
|
+
|
|
9
|
+
from claude_dev_cli.history import Message, Conversation, ConversationHistory
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestMessage:
|
|
13
|
+
"""Tests for Message class."""
|
|
14
|
+
|
|
15
|
+
def test_message_creation(self):
|
|
16
|
+
"""Test creating a message."""
|
|
17
|
+
msg = Message("user", "Hello, Claude!")
|
|
18
|
+
assert msg.role == "user"
|
|
19
|
+
assert msg.content == "Hello, Claude!"
|
|
20
|
+
assert isinstance(msg.timestamp, datetime)
|
|
21
|
+
|
|
22
|
+
def test_message_to_dict(self):
|
|
23
|
+
"""Test converting message to dictionary."""
|
|
24
|
+
msg = Message("assistant", "Hello! How can I help?")
|
|
25
|
+
data = msg.to_dict()
|
|
26
|
+
|
|
27
|
+
assert data["role"] == "assistant"
|
|
28
|
+
assert data["content"] == "Hello! How can I help?"
|
|
29
|
+
assert "timestamp" in data
|
|
30
|
+
|
|
31
|
+
def test_message_from_dict(self):
|
|
32
|
+
"""Test creating message from dictionary."""
|
|
33
|
+
data = {
|
|
34
|
+
"role": "user",
|
|
35
|
+
"content": "Test message",
|
|
36
|
+
"timestamp": "2024-01-09T12:00:00"
|
|
37
|
+
}
|
|
38
|
+
msg = Message.from_dict(data)
|
|
39
|
+
|
|
40
|
+
assert msg.role == "user"
|
|
41
|
+
assert msg.content == "Test message"
|
|
42
|
+
assert isinstance(msg.timestamp, datetime)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TestConversation:
|
|
46
|
+
"""Tests for Conversation class."""
|
|
47
|
+
|
|
48
|
+
def test_conversation_creation(self):
|
|
49
|
+
"""Test creating a conversation."""
|
|
50
|
+
conv = Conversation()
|
|
51
|
+
assert conv.conversation_id is not None
|
|
52
|
+
assert isinstance(conv.created_at, datetime)
|
|
53
|
+
assert isinstance(conv.updated_at, datetime)
|
|
54
|
+
assert len(conv.messages) == 0
|
|
55
|
+
assert conv.summary is None
|
|
56
|
+
|
|
57
|
+
def test_conversation_with_summary(self):
|
|
58
|
+
"""Test creating conversation with summary."""
|
|
59
|
+
summary = "This is a test summary"
|
|
60
|
+
conv = Conversation(summary=summary)
|
|
61
|
+
assert conv.summary == summary
|
|
62
|
+
|
|
63
|
+
def test_add_message(self):
|
|
64
|
+
"""Test adding messages to conversation."""
|
|
65
|
+
conv = Conversation()
|
|
66
|
+
conv.add_message("user", "First message")
|
|
67
|
+
conv.add_message("assistant", "First response")
|
|
68
|
+
|
|
69
|
+
assert len(conv.messages) == 2
|
|
70
|
+
assert conv.messages[0].role == "user"
|
|
71
|
+
assert conv.messages[0].content == "First message"
|
|
72
|
+
assert conv.messages[1].role == "assistant"
|
|
73
|
+
|
|
74
|
+
def test_get_summary_with_messages(self):
|
|
75
|
+
"""Test getting conversation summary."""
|
|
76
|
+
conv = Conversation()
|
|
77
|
+
conv.add_message("user", "What is Python?")
|
|
78
|
+
conv.add_message("assistant", "Python is a programming language...")
|
|
79
|
+
|
|
80
|
+
summary = conv.get_summary(20)
|
|
81
|
+
assert summary == "What is Python?"
|
|
82
|
+
|
|
83
|
+
def test_get_summary_truncated(self):
|
|
84
|
+
"""Test summary truncation."""
|
|
85
|
+
conv = Conversation()
|
|
86
|
+
long_message = "a" * 150
|
|
87
|
+
conv.add_message("user", long_message)
|
|
88
|
+
|
|
89
|
+
summary = conv.get_summary(100)
|
|
90
|
+
assert len(summary) <= 103 # 100 + "..."
|
|
91
|
+
assert summary.endswith("...")
|
|
92
|
+
|
|
93
|
+
def test_get_summary_empty(self):
|
|
94
|
+
"""Test summary of empty conversation."""
|
|
95
|
+
conv = Conversation()
|
|
96
|
+
summary = conv.get_summary()
|
|
97
|
+
assert summary == "(empty conversation)"
|
|
98
|
+
|
|
99
|
+
def test_estimate_tokens_no_messages(self):
|
|
100
|
+
"""Test token estimation for empty conversation."""
|
|
101
|
+
conv = Conversation()
|
|
102
|
+
tokens = conv.estimate_tokens()
|
|
103
|
+
assert tokens == 0
|
|
104
|
+
|
|
105
|
+
def test_estimate_tokens_with_messages(self):
|
|
106
|
+
"""Test token estimation with messages."""
|
|
107
|
+
conv = Conversation()
|
|
108
|
+
# Each character ~= 0.25 tokens, so 400 chars ~= 100 tokens
|
|
109
|
+
conv.add_message("user", "a" * 200)
|
|
110
|
+
conv.add_message("assistant", "b" * 200)
|
|
111
|
+
|
|
112
|
+
tokens = conv.estimate_tokens()
|
|
113
|
+
assert tokens == 100 # 400 chars / 4
|
|
114
|
+
|
|
115
|
+
def test_estimate_tokens_with_summary(self):
|
|
116
|
+
"""Test token estimation includes summary."""
|
|
117
|
+
conv = Conversation(summary="Summary text here" * 10) # 170 chars
|
|
118
|
+
conv.add_message("user", "a" * 230) # 230 chars
|
|
119
|
+
|
|
120
|
+
# Total: 400 chars / 4 = 100 tokens
|
|
121
|
+
tokens = conv.estimate_tokens()
|
|
122
|
+
assert tokens == 100
|
|
123
|
+
|
|
124
|
+
def test_should_summarize_below_threshold(self):
|
|
125
|
+
"""Test should_summarize returns False below threshold."""
|
|
126
|
+
conv = Conversation()
|
|
127
|
+
conv.add_message("user", "a" * 100)
|
|
128
|
+
conv.add_message("assistant", "b" * 100)
|
|
129
|
+
|
|
130
|
+
# 200 chars = 50 tokens, below 8000 threshold
|
|
131
|
+
assert not conv.should_summarize(threshold_tokens=8000)
|
|
132
|
+
|
|
133
|
+
def test_should_summarize_above_threshold(self):
|
|
134
|
+
"""Test should_summarize returns True above threshold."""
|
|
135
|
+
conv = Conversation()
|
|
136
|
+
# Add enough messages to exceed 1000 token threshold
|
|
137
|
+
for i in range(10):
|
|
138
|
+
conv.add_message("user", "a" * 200) # 200 chars = 50 tokens
|
|
139
|
+
conv.add_message("assistant", "b" * 200) # 200 chars = 50 tokens
|
|
140
|
+
|
|
141
|
+
# Total: 2000 chars = 500 tokens, above 400 threshold
|
|
142
|
+
assert conv.should_summarize(threshold_tokens=400)
|
|
143
|
+
|
|
144
|
+
def test_should_summarize_too_few_messages(self):
|
|
145
|
+
"""Test should_summarize returns False with too few messages."""
|
|
146
|
+
conv = Conversation()
|
|
147
|
+
# Even with many tokens, need minimum message count
|
|
148
|
+
conv.add_message("user", "a" * 4000) # 1000 tokens
|
|
149
|
+
|
|
150
|
+
assert not conv.should_summarize(threshold_tokens=500)
|
|
151
|
+
|
|
152
|
+
def test_compress_messages_few_messages(self):
|
|
153
|
+
"""Test compress_messages with too few messages."""
|
|
154
|
+
conv = Conversation()
|
|
155
|
+
conv.add_message("user", "message 1")
|
|
156
|
+
conv.add_message("assistant", "response 1")
|
|
157
|
+
|
|
158
|
+
old, recent = conv.compress_messages(keep_recent=4)
|
|
159
|
+
assert len(old) == 0
|
|
160
|
+
assert len(recent) == 2
|
|
161
|
+
|
|
162
|
+
def test_compress_messages_split(self):
|
|
163
|
+
"""Test compress_messages splits correctly."""
|
|
164
|
+
conv = Conversation()
|
|
165
|
+
for i in range(10):
|
|
166
|
+
conv.add_message("user", f"message {i}")
|
|
167
|
+
conv.add_message("assistant", f"response {i}")
|
|
168
|
+
|
|
169
|
+
# Total: 20 messages, keep 4 recent
|
|
170
|
+
old, recent = conv.compress_messages(keep_recent=4)
|
|
171
|
+
assert len(old) == 16
|
|
172
|
+
assert len(recent) == 4
|
|
173
|
+
assert recent[0].content == "message 8"
|
|
174
|
+
assert recent[-1].content == "response 9"
|
|
175
|
+
|
|
176
|
+
def test_to_dict(self):
|
|
177
|
+
"""Test converting conversation to dictionary."""
|
|
178
|
+
conv = Conversation()
|
|
179
|
+
conv.add_message("user", "test")
|
|
180
|
+
data = conv.to_dict()
|
|
181
|
+
|
|
182
|
+
assert "conversation_id" in data
|
|
183
|
+
assert "created_at" in data
|
|
184
|
+
assert "updated_at" in data
|
|
185
|
+
assert "messages" in data
|
|
186
|
+
assert len(data["messages"]) == 1
|
|
187
|
+
|
|
188
|
+
def test_to_dict_with_summary(self):
|
|
189
|
+
"""Test to_dict includes summary."""
|
|
190
|
+
conv = Conversation(summary="Test summary")
|
|
191
|
+
data = conv.to_dict()
|
|
192
|
+
|
|
193
|
+
assert data["summary"] == "Test summary"
|
|
194
|
+
|
|
195
|
+
def test_from_dict(self):
|
|
196
|
+
"""Test creating conversation from dictionary."""
|
|
197
|
+
data = {
|
|
198
|
+
"conversation_id": "test_123",
|
|
199
|
+
"created_at": "2024-01-09T12:00:00",
|
|
200
|
+
"updated_at": "2024-01-09T12:05:00",
|
|
201
|
+
"summary": "Test summary",
|
|
202
|
+
"messages": [
|
|
203
|
+
{
|
|
204
|
+
"role": "user",
|
|
205
|
+
"content": "Hello",
|
|
206
|
+
"timestamp": "2024-01-09T12:01:00"
|
|
207
|
+
}
|
|
208
|
+
]
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
conv = Conversation.from_dict(data)
|
|
212
|
+
assert conv.conversation_id == "test_123"
|
|
213
|
+
assert conv.summary == "Test summary"
|
|
214
|
+
assert len(conv.messages) == 1
|
|
215
|
+
assert conv.messages[0].content == "Hello"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class TestConversationHistory:
|
|
219
|
+
"""Tests for ConversationHistory class."""
|
|
220
|
+
|
|
221
|
+
@pytest.fixture
|
|
222
|
+
def temp_history_dir(self, tmp_path):
|
|
223
|
+
"""Create a temporary history directory."""
|
|
224
|
+
return tmp_path / "history"
|
|
225
|
+
|
|
226
|
+
@pytest.fixture
|
|
227
|
+
def conv_history(self, temp_history_dir):
|
|
228
|
+
"""Create a ConversationHistory instance."""
|
|
229
|
+
return ConversationHistory(temp_history_dir)
|
|
230
|
+
|
|
231
|
+
def test_init_creates_directory(self, temp_history_dir):
|
|
232
|
+
"""Test initialization creates directory."""
|
|
233
|
+
ConversationHistory(temp_history_dir)
|
|
234
|
+
assert temp_history_dir.exists()
|
|
235
|
+
|
|
236
|
+
def test_save_conversation(self, conv_history, temp_history_dir):
|
|
237
|
+
"""Test saving a conversation."""
|
|
238
|
+
conv = Conversation()
|
|
239
|
+
conv.add_message("user", "test")
|
|
240
|
+
|
|
241
|
+
conv_history.save_conversation(conv)
|
|
242
|
+
|
|
243
|
+
# Check file exists
|
|
244
|
+
file_path = temp_history_dir / f"{conv.conversation_id}.json"
|
|
245
|
+
assert file_path.exists()
|
|
246
|
+
|
|
247
|
+
# Check content
|
|
248
|
+
with open(file_path) as f:
|
|
249
|
+
data = json.load(f)
|
|
250
|
+
assert data["conversation_id"] == conv.conversation_id
|
|
251
|
+
assert len(data["messages"]) == 1
|
|
252
|
+
|
|
253
|
+
def test_load_conversation(self, conv_history):
|
|
254
|
+
"""Test loading a conversation."""
|
|
255
|
+
# Save first
|
|
256
|
+
conv = Conversation()
|
|
257
|
+
conv.add_message("user", "test message")
|
|
258
|
+
conv_history.save_conversation(conv)
|
|
259
|
+
|
|
260
|
+
# Load
|
|
261
|
+
loaded = conv_history.load_conversation(conv.conversation_id)
|
|
262
|
+
assert loaded is not None
|
|
263
|
+
assert loaded.conversation_id == conv.conversation_id
|
|
264
|
+
assert len(loaded.messages) == 1
|
|
265
|
+
assert loaded.messages[0].content == "test message"
|
|
266
|
+
|
|
267
|
+
def test_load_nonexistent_conversation(self, conv_history):
|
|
268
|
+
"""Test loading a conversation that doesn't exist."""
|
|
269
|
+
loaded = conv_history.load_conversation("nonexistent_id")
|
|
270
|
+
assert loaded is None
|
|
271
|
+
|
|
272
|
+
def test_list_conversations(self, conv_history):
|
|
273
|
+
"""Test listing conversations."""
|
|
274
|
+
import time
|
|
275
|
+
# Create multiple conversations with small delays to ensure ordering
|
|
276
|
+
for i in range(3):
|
|
277
|
+
conv = Conversation()
|
|
278
|
+
conv.add_message("user", f"message {i}")
|
|
279
|
+
conv_history.save_conversation(conv)
|
|
280
|
+
time.sleep(0.01) # Small delay to ensure different timestamps
|
|
281
|
+
|
|
282
|
+
conversations = conv_history.list_conversations()
|
|
283
|
+
assert len(conversations) == 3
|
|
284
|
+
|
|
285
|
+
def test_list_conversations_with_limit(self, conv_history):
|
|
286
|
+
"""Test listing conversations with limit."""
|
|
287
|
+
import time
|
|
288
|
+
for i in range(5):
|
|
289
|
+
conv = Conversation()
|
|
290
|
+
conv.add_message("user", f"message {i}")
|
|
291
|
+
conv_history.save_conversation(conv)
|
|
292
|
+
time.sleep(0.01)
|
|
293
|
+
|
|
294
|
+
conversations = conv_history.list_conversations(limit=2)
|
|
295
|
+
assert len(conversations) == 2
|
|
296
|
+
|
|
297
|
+
def test_list_conversations_with_search(self, conv_history):
|
|
298
|
+
"""Test searching conversations."""
|
|
299
|
+
import time
|
|
300
|
+
conv1 = Conversation()
|
|
301
|
+
conv1.add_message("user", "Python programming")
|
|
302
|
+
conv_history.save_conversation(conv1)
|
|
303
|
+
time.sleep(0.01)
|
|
304
|
+
|
|
305
|
+
conv2 = Conversation()
|
|
306
|
+
conv2.add_message("user", "JavaScript coding")
|
|
307
|
+
conv_history.save_conversation(conv2)
|
|
308
|
+
|
|
309
|
+
# Search for Python
|
|
310
|
+
results = conv_history.list_conversations(search_query="Python")
|
|
311
|
+
assert len(results) == 1
|
|
312
|
+
assert "Python" in results[0].messages[0].content
|
|
313
|
+
|
|
314
|
+
def test_delete_conversation(self, conv_history, temp_history_dir):
|
|
315
|
+
"""Test deleting a conversation."""
|
|
316
|
+
conv = Conversation()
|
|
317
|
+
conv_history.save_conversation(conv)
|
|
318
|
+
|
|
319
|
+
# Verify it exists
|
|
320
|
+
file_path = temp_history_dir / f"{conv.conversation_id}.json"
|
|
321
|
+
assert file_path.exists()
|
|
322
|
+
|
|
323
|
+
# Delete
|
|
324
|
+
result = conv_history.delete_conversation(conv.conversation_id)
|
|
325
|
+
assert result is True
|
|
326
|
+
assert not file_path.exists()
|
|
327
|
+
|
|
328
|
+
def test_delete_nonexistent_conversation(self, conv_history):
|
|
329
|
+
"""Test deleting a conversation that doesn't exist."""
|
|
330
|
+
result = conv_history.delete_conversation("nonexistent")
|
|
331
|
+
assert result is False
|
|
332
|
+
|
|
333
|
+
def test_get_latest_conversation(self, conv_history):
|
|
334
|
+
"""Test getting the latest conversation."""
|
|
335
|
+
# Create conversations with delay to ensure ordering
|
|
336
|
+
conv1 = Conversation()
|
|
337
|
+
conv1.add_message("user", "first")
|
|
338
|
+
conv_history.save_conversation(conv1)
|
|
339
|
+
|
|
340
|
+
import time
|
|
341
|
+
time.sleep(0.01) # Small delay
|
|
342
|
+
|
|
343
|
+
conv2 = Conversation()
|
|
344
|
+
conv2.add_message("user", "second")
|
|
345
|
+
conv_history.save_conversation(conv2)
|
|
346
|
+
|
|
347
|
+
latest = conv_history.get_latest_conversation()
|
|
348
|
+
assert latest is not None
|
|
349
|
+
assert latest.messages[0].content == "second"
|
|
350
|
+
|
|
351
|
+
def test_export_conversation_markdown(self, conv_history):
|
|
352
|
+
"""Test exporting conversation as markdown."""
|
|
353
|
+
conv = Conversation()
|
|
354
|
+
conv.add_message("user", "Hello")
|
|
355
|
+
conv.add_message("assistant", "Hi there!")
|
|
356
|
+
conv_history.save_conversation(conv)
|
|
357
|
+
|
|
358
|
+
markdown = conv_history.export_conversation(conv.conversation_id, "markdown")
|
|
359
|
+
assert markdown is not None
|
|
360
|
+
assert "Hello" in markdown
|
|
361
|
+
assert "Hi there!" in markdown
|
|
362
|
+
assert "**You:**" in markdown
|
|
363
|
+
assert "**Claude:**" in markdown
|
|
364
|
+
|
|
365
|
+
def test_export_conversation_json(self, conv_history):
|
|
366
|
+
"""Test exporting conversation as JSON."""
|
|
367
|
+
conv = Conversation()
|
|
368
|
+
conv.add_message("user", "Test")
|
|
369
|
+
conv_history.save_conversation(conv)
|
|
370
|
+
|
|
371
|
+
json_str = conv_history.export_conversation(conv.conversation_id, "json")
|
|
372
|
+
assert json_str is not None
|
|
373
|
+
|
|
374
|
+
data = json.loads(json_str)
|
|
375
|
+
assert data["conversation_id"] == conv.conversation_id
|
|
376
|
+
|
|
377
|
+
def test_export_nonexistent_conversation(self, conv_history):
|
|
378
|
+
"""Test exporting a conversation that doesn't exist."""
|
|
379
|
+
result = conv_history.export_conversation("nonexistent", "markdown")
|
|
380
|
+
assert result is None
|
|
381
|
+
|
|
382
|
+
@patch('claude_dev_cli.core.ClaudeClient')
|
|
383
|
+
def test_summarize_conversation(self, mock_client_class, conv_history):
|
|
384
|
+
"""Test summarizing a conversation."""
|
|
385
|
+
# Setup mock
|
|
386
|
+
mock_client = Mock()
|
|
387
|
+
mock_client.call.return_value = "This is a test summary of the conversation."
|
|
388
|
+
mock_client_class.return_value = mock_client
|
|
389
|
+
|
|
390
|
+
# Create conversation with enough messages
|
|
391
|
+
conv = Conversation()
|
|
392
|
+
for i in range(6):
|
|
393
|
+
conv.add_message("user", f"Question {i}")
|
|
394
|
+
conv.add_message("assistant", f"Answer {i}")
|
|
395
|
+
conv_history.save_conversation(conv)
|
|
396
|
+
|
|
397
|
+
# Summarize
|
|
398
|
+
summary = conv_history.summarize_conversation(conv.conversation_id, keep_recent=4)
|
|
399
|
+
|
|
400
|
+
assert summary is not None
|
|
401
|
+
assert "test summary" in summary
|
|
402
|
+
|
|
403
|
+
# Verify conversation was updated
|
|
404
|
+
updated_conv = conv_history.load_conversation(conv.conversation_id)
|
|
405
|
+
assert updated_conv.summary == summary
|
|
406
|
+
assert len(updated_conv.messages) == 4 # Only recent messages kept
|
|
407
|
+
|
|
408
|
+
@patch('claude_dev_cli.core.ClaudeClient')
|
|
409
|
+
def test_summarize_with_previous_summary(self, mock_client_class, conv_history):
|
|
410
|
+
"""Test summarizing with existing summary."""
|
|
411
|
+
mock_client = Mock()
|
|
412
|
+
mock_client.call.return_value = "Updated summary"
|
|
413
|
+
mock_client_class.return_value = mock_client
|
|
414
|
+
|
|
415
|
+
# Create conversation with existing summary
|
|
416
|
+
conv = Conversation(summary="Previous summary")
|
|
417
|
+
for i in range(6):
|
|
418
|
+
conv.add_message("user", f"Message {i}")
|
|
419
|
+
conv.add_message("assistant", f"Response {i}")
|
|
420
|
+
conv_history.save_conversation(conv)
|
|
421
|
+
|
|
422
|
+
# Summarize
|
|
423
|
+
summary = conv_history.summarize_conversation(conv.conversation_id, keep_recent=2)
|
|
424
|
+
|
|
425
|
+
# Check that call included previous summary
|
|
426
|
+
call_args = mock_client.call.call_args[0][0]
|
|
427
|
+
assert "Previous summary" in call_args
|
|
428
|
+
|
|
429
|
+
def test_summarize_too_few_messages(self, conv_history):
|
|
430
|
+
"""Test summarizing with too few messages."""
|
|
431
|
+
conv = Conversation()
|
|
432
|
+
conv.add_message("user", "Only one message")
|
|
433
|
+
conv_history.save_conversation(conv)
|
|
434
|
+
|
|
435
|
+
result = conv_history.summarize_conversation(conv.conversation_id, keep_recent=4)
|
|
436
|
+
assert "too few messages" in result.lower()
|
|
437
|
+
|
|
438
|
+
def test_summarize_nonexistent_conversation(self, conv_history):
|
|
439
|
+
"""Test summarizing a conversation that doesn't exist."""
|
|
440
|
+
result = conv_history.summarize_conversation("nonexistent")
|
|
441
|
+
assert result is None
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
class TestConversationPersistence:
|
|
445
|
+
"""Tests for conversation persistence and data integrity."""
|
|
446
|
+
|
|
447
|
+
def test_roundtrip_conversation(self, tmp_path):
|
|
448
|
+
"""Test saving and loading preserves all data."""
|
|
449
|
+
history = ConversationHistory(tmp_path)
|
|
450
|
+
|
|
451
|
+
# Create conversation with all features
|
|
452
|
+
conv = Conversation(summary="Initial summary")
|
|
453
|
+
conv.add_message("user", "First question")
|
|
454
|
+
conv.add_message("assistant", "First answer")
|
|
455
|
+
conv.add_message("user", "Second question")
|
|
456
|
+
|
|
457
|
+
# Save
|
|
458
|
+
history.save_conversation(conv)
|
|
459
|
+
|
|
460
|
+
# Load
|
|
461
|
+
loaded = history.load_conversation(conv.conversation_id)
|
|
462
|
+
|
|
463
|
+
# Verify everything matches
|
|
464
|
+
assert loaded.conversation_id == conv.conversation_id
|
|
465
|
+
assert loaded.summary == conv.summary
|
|
466
|
+
assert len(loaded.messages) == len(conv.messages)
|
|
467
|
+
assert loaded.messages[0].content == conv.messages[0].content
|
|
468
|
+
assert loaded.messages[1].role == conv.messages[1].role
|
|
469
|
+
|
|
470
|
+
def test_conversation_update(self, tmp_path):
|
|
471
|
+
"""Test updating a conversation."""
|
|
472
|
+
history = ConversationHistory(tmp_path)
|
|
473
|
+
|
|
474
|
+
# Create and save
|
|
475
|
+
conv = Conversation()
|
|
476
|
+
conv.add_message("user", "Original message")
|
|
477
|
+
history.save_conversation(conv)
|
|
478
|
+
|
|
479
|
+
# Load, modify, save
|
|
480
|
+
loaded = history.load_conversation(conv.conversation_id)
|
|
481
|
+
loaded.add_message("assistant", "New response")
|
|
482
|
+
loaded.summary = "Added summary"
|
|
483
|
+
history.save_conversation(loaded)
|
|
484
|
+
|
|
485
|
+
# Load again and verify
|
|
486
|
+
final = history.load_conversation(conv.conversation_id)
|
|
487
|
+
assert len(final.messages) == 2
|
|
488
|
+
assert final.summary == "Added summary"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/src/claude_dev_cli/plugins/diff_editor/__init__.py
RENAMED
|
File without changes
|
{claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/src/claude_dev_cli/plugins/diff_editor/plugin.py
RENAMED
|
File without changes
|
{claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/src/claude_dev_cli/plugins/diff_editor/viewer.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{claude_dev_cli-0.8.2 → claude_dev_cli-0.8.4}/src/claude_dev_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|