context-forge-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- context_forge_cli-0.1.0.dist-info/METADATA +340 -0
- context_forge_cli-0.1.0.dist-info/RECORD +37 -0
- context_forge_cli-0.1.0.dist-info/WHEEL +4 -0
- context_forge_cli-0.1.0.dist-info/entry_points.txt +2 -0
- contextforge/__init__.py +3 -0
- contextforge/adapters/__init__.py +3 -0
- contextforge/adapters/altimate_code.py +179 -0
- contextforge/adapters/base.py +44 -0
- contextforge/adapters/claude_code.py +393 -0
- contextforge/adapters/claude_desktop.py +306 -0
- contextforge/adapters/codex.py +220 -0
- contextforge/adapters/registry.py +31 -0
- contextforge/cli.py +523 -0
- contextforge/core/__init__.py +0 -0
- contextforge/core/analytics.py +226 -0
- contextforge/core/compactor.py +182 -0
- contextforge/core/db.py +181 -0
- contextforge/core/injector.py +94 -0
- contextforge/core/scanner.py +72 -0
- contextforge/core/summarizer.py +155 -0
- contextforge/core/token_analyzer.py +91 -0
- contextforge/models/__init__.py +4 -0
- contextforge/models/config.py +34 -0
- contextforge/models/session.py +37 -0
- contextforge/tui/__init__.py +0 -0
- contextforge/tui/app.py +204 -0
- contextforge/tui/styles.tcss +50 -0
- contextforge/tui/widgets/__init__.py +0 -0
- contextforge/tui/widgets/session_detail.py +191 -0
- contextforge/tui/widgets/session_table.py +277 -0
- contextforge/tui/widgets/stats_panel.py +340 -0
- contextforge/tui/widgets/status_bar.py +102 -0
- contextforge/tui/widgets/tokens_panel.py +134 -0
- contextforge/tui/widgets/transfer_panel.py +119 -0
- contextforge/utils/__init__.py +0 -0
- contextforge/utils/display.py +105 -0
- contextforge/utils/tokens.py +17 -0
contextforge/cli.py
ADDED
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
"""ContextForge CLI — cf command."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated, Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
|
|
13
|
+
from contextforge.core import db as db_module
|
|
14
|
+
from contextforge.core.compactor import compact
|
|
15
|
+
from contextforge.core.injector import build_inject_command, execute_transfer
|
|
16
|
+
from contextforge.core.scanner import scan
|
|
17
|
+
from contextforge.core.summarizer import batch_summarize, summarize_session
|
|
18
|
+
from contextforge.models.config import ForgeConfig
|
|
19
|
+
from contextforge.utils.display import console, err_console, sessions_table
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(
|
|
22
|
+
name="cf",
|
|
23
|
+
help="ContextForge — session manager and context bridge for agentic CLI tools.",
|
|
24
|
+
add_completion=False,
|
|
25
|
+
no_args_is_help=True,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
config_app = typer.Typer(name="config", help="Manage ContextForge configuration.")
|
|
29
|
+
app.add_typer(config_app, name="config")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _get_config() -> ForgeConfig:
|
|
33
|
+
return ForgeConfig()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _get_db(cfg: ForgeConfig):
|
|
37
|
+
return db_module.get_db(cfg.db_path)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# cf scan
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
@app.command(name="scan")
|
|
45
|
+
def scan_cmd(
|
|
46
|
+
quiet: Annotated[bool, typer.Option("--quiet", "-q")] = False,
|
|
47
|
+
summarize: Annotated[bool, typer.Option("--summarize", "-s")] = False,
|
|
48
|
+
):
|
|
49
|
+
"""Discover and index sessions from all installed agentic tools.
|
|
50
|
+
|
|
51
|
+
Use --summarize to also generate summaries for new sessions after scanning.
|
|
52
|
+
Requires ANTHROPIC_API_KEY (or api_key in config) for LLM summaries;
|
|
53
|
+
falls back to first-message preview if no key is set.
|
|
54
|
+
"""
|
|
55
|
+
cfg = _get_config()
|
|
56
|
+
database = _get_db(cfg)
|
|
57
|
+
result = scan(database, quiet=quiet)
|
|
58
|
+
|
|
59
|
+
if not quiet:
|
|
60
|
+
console.print(
|
|
61
|
+
f"[green]Scan complete:[/green] "
|
|
62
|
+
f"{result.new} new, {result.updated} updated, {result.unchanged} unchanged"
|
|
63
|
+
)
|
|
64
|
+
for err in result.errors:
|
|
65
|
+
err_console.print(f"[yellow]Warning:[/yellow] {err}")
|
|
66
|
+
|
|
67
|
+
if summarize and result.sessions:
|
|
68
|
+
new_ids = [s.id for s in result.sessions]
|
|
69
|
+
if not quiet:
|
|
70
|
+
console.print(f"[dim]Summarizing {len(new_ids)} sessions...[/dim]")
|
|
71
|
+
|
|
72
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
73
|
+
with Progress(
|
|
74
|
+
SpinnerColumn(),
|
|
75
|
+
TextColumn("[progress.description]{task.description}"),
|
|
76
|
+
disable=quiet,
|
|
77
|
+
) as progress:
|
|
78
|
+
task = progress.add_task("Summarizing...", total=len(new_ids))
|
|
79
|
+
|
|
80
|
+
def on_prog(sid: str, summary: str | None) -> None:
|
|
81
|
+
status = "[green]ok[/green]" if summary else "[dim]skipped[/dim]"
|
|
82
|
+
progress.advance(task)
|
|
83
|
+
progress.update(task, description=f"Summarized {sid[:12]} {status}")
|
|
84
|
+
|
|
85
|
+
summary_result = batch_summarize(
|
|
86
|
+
db=database,
|
|
87
|
+
config=cfg,
|
|
88
|
+
session_ids=new_ids,
|
|
89
|
+
on_progress=on_prog,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if not quiet:
|
|
93
|
+
console.print(
|
|
94
|
+
f"[green]Summaries:[/green] "
|
|
95
|
+
f"{summary_result.summarized} generated, {summary_result.skipped} skipped"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
sys.exit(0 if not result.errors or result.total > 0 else 1)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
# cf ls
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
@app.command(name="ls")
|
|
106
|
+
def list_sessions(
|
|
107
|
+
tool: Annotated[Optional[str], typer.Option("--tool", "-t")] = None,
|
|
108
|
+
limit: Annotated[int, typer.Option("--limit", "-n")] = 50,
|
|
109
|
+
fmt: Annotated[str, typer.Option("--format", "-f")] = "table",
|
|
110
|
+
):
|
|
111
|
+
"""List sessions."""
|
|
112
|
+
cfg = _get_config()
|
|
113
|
+
database = _get_db(cfg)
|
|
114
|
+
rows = db_module.get_sessions(database, tool=tool, limit=limit)
|
|
115
|
+
|
|
116
|
+
if fmt == "json":
|
|
117
|
+
console.print_json(json.dumps(rows))
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
if not rows:
|
|
121
|
+
console.print("[dim]No sessions found. Run [bold]cf scan[/bold] first.[/dim]")
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
table = sessions_table(rows)
|
|
125
|
+
console.print(table)
|
|
126
|
+
console.print(f"[dim]{len(rows)} sessions[/dim]")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
# cf show
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
@app.command()
|
|
134
|
+
def show(
|
|
135
|
+
session_id: str,
|
|
136
|
+
fmt: Annotated[str, typer.Option("--format", "-f")] = "rich",
|
|
137
|
+
):
|
|
138
|
+
"""Show detail and summary for a session."""
|
|
139
|
+
cfg = _get_config()
|
|
140
|
+
database = _get_db(cfg)
|
|
141
|
+
row = db_module.get_session(database, session_id)
|
|
142
|
+
|
|
143
|
+
if row is None:
|
|
144
|
+
err_console.print(f"[red]Session not found:[/red] {session_id}")
|
|
145
|
+
raise typer.Exit(1)
|
|
146
|
+
|
|
147
|
+
if fmt == "json":
|
|
148
|
+
console.print_json(json.dumps(row))
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
summary = row.get("summary") or "[dim]No summary yet — run [bold]cf summarize[/bold][/dim]"
|
|
152
|
+
panel = Panel(
|
|
153
|
+
f"[bold]{row.get('title') or '(no title)'}[/bold]\n\n"
|
|
154
|
+
f"Tool: {row.get('tool')}\n"
|
|
155
|
+
f"CWD: {row.get('cwd') or '?'}\n"
|
|
156
|
+
f"Tokens: {row.get('token_count') or '?'}\n"
|
|
157
|
+
f"Status: {row.get('status')}\n\n"
|
|
158
|
+
f"[bold]Summary:[/bold]\n{summary}",
|
|
159
|
+
title=f"Session {session_id[:16]}",
|
|
160
|
+
)
|
|
161
|
+
console.print(panel)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
# cf summarize
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
@app.command()
|
|
169
|
+
def summarize(
|
|
170
|
+
session_id: Annotated[Optional[str], typer.Argument()] = None,
|
|
171
|
+
all_sessions: Annotated[bool, typer.Option("--all")] = False,
|
|
172
|
+
force: Annotated[bool, typer.Option("--force")] = False,
|
|
173
|
+
):
|
|
174
|
+
"""Generate or refresh a summary for one or all sessions."""
|
|
175
|
+
cfg = _get_config()
|
|
176
|
+
database = _get_db(cfg)
|
|
177
|
+
|
|
178
|
+
if all_sessions:
|
|
179
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
180
|
+
|
|
181
|
+
rows = db_module.get_sessions(database, limit=10_000)
|
|
182
|
+
pending = [r for r in rows if not r.get("summary") or force]
|
|
183
|
+
console.print(f"Summarizing {len(pending)} sessions...")
|
|
184
|
+
|
|
185
|
+
with Progress(
|
|
186
|
+
SpinnerColumn(),
|
|
187
|
+
TextColumn("[progress.description]{task.description}"),
|
|
188
|
+
) as progress:
|
|
189
|
+
task = progress.add_task("Summarizing...", total=len(pending))
|
|
190
|
+
|
|
191
|
+
def on_prog(sid: str, summary: str | None) -> None:
|
|
192
|
+
status = "[green]ok[/green]" if summary else "[dim]skipped[/dim]"
|
|
193
|
+
progress.advance(task)
|
|
194
|
+
progress.update(task, description=f"{sid[:16]} {status}")
|
|
195
|
+
|
|
196
|
+
result = batch_summarize(
|
|
197
|
+
db=database,
|
|
198
|
+
config=cfg,
|
|
199
|
+
session_ids=[r["id"] for r in pending],
|
|
200
|
+
force=force,
|
|
201
|
+
on_progress=on_prog,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
console.print(
|
|
205
|
+
f"[green]Done:[/green] {result.summarized} generated, "
|
|
206
|
+
f"{result.skipped} skipped"
|
|
207
|
+
+ (f", {len(result.errors)} errors" if result.errors else "")
|
|
208
|
+
)
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
if session_id is None:
|
|
212
|
+
err_console.print("[red]Provide a session ID or use --all[/red]")
|
|
213
|
+
raise typer.Exit(1)
|
|
214
|
+
|
|
215
|
+
summary = summarize_session(database, session_id, cfg, force=force)
|
|
216
|
+
if summary:
|
|
217
|
+
console.print(Panel(summary, title="Summary"))
|
|
218
|
+
else:
|
|
219
|
+
err_console.print("[yellow]Could not generate summary (no messages or no API key)[/yellow]")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ---------------------------------------------------------------------------
|
|
223
|
+
# cf compact
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
@app.command(name="compact")
|
|
227
|
+
def compact_cmd(
|
|
228
|
+
session_ids: Annotated[list[str], typer.Argument()],
|
|
229
|
+
strategy: Annotated[str, typer.Option("--strategy", "-s")] = "summary_only",
|
|
230
|
+
budget: Annotated[int, typer.Option("--budget", "-b")] = 4096,
|
|
231
|
+
name: Annotated[Optional[str], typer.Option("--name")] = None,
|
|
232
|
+
target_tool: Annotated[Optional[str], typer.Option("--to")] = None,
|
|
233
|
+
fmt: Annotated[str, typer.Option("--format", "-f")] = "rich",
|
|
234
|
+
save: Annotated[bool, typer.Option("--save")] = False,
|
|
235
|
+
):
|
|
236
|
+
"""Create a ContextBundle from one or more sessions."""
|
|
237
|
+
cfg = _get_config()
|
|
238
|
+
database = _get_db(cfg)
|
|
239
|
+
|
|
240
|
+
bundle = compact(
|
|
241
|
+
db=database,
|
|
242
|
+
session_ids=session_ids,
|
|
243
|
+
strategy=strategy,
|
|
244
|
+
token_budget=budget,
|
|
245
|
+
name=name,
|
|
246
|
+
target_tool=target_tool,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
if fmt == "json":
|
|
250
|
+
console.print_json(bundle.model_dump_json())
|
|
251
|
+
return
|
|
252
|
+
|
|
253
|
+
console.print(Panel(
|
|
254
|
+
f"Strategy: {bundle.strategy}\n"
|
|
255
|
+
f"Sessions: {', '.join(bundle.source_sessions)}\n"
|
|
256
|
+
f"Tokens: {bundle.token_count} / {budget}\n\n"
|
|
257
|
+
f"[bold]Context:[/bold]\n{bundle.compacted_text[:1000]}"
|
|
258
|
+
+ ("[dim]…[/dim]" if len(bundle.compacted_text) > 1000 else ""),
|
|
259
|
+
title=f"Bundle: {bundle.name}",
|
|
260
|
+
))
|
|
261
|
+
|
|
262
|
+
if save:
|
|
263
|
+
bundle_id = db_module.save_bundle(database, bundle)
|
|
264
|
+
console.print(f"[green]Saved bundle ID:[/green] {bundle_id}")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
# cf transfer
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
@app.command()
|
|
272
|
+
def transfer(
|
|
273
|
+
session_ids: Annotated[list[str], typer.Argument()],
|
|
274
|
+
target_tool: Annotated[str, typer.Option("--to")] = ...,
|
|
275
|
+
strategy: Annotated[str, typer.Option("--strategy", "-s")] = "summary_only",
|
|
276
|
+
budget: Annotated[int, typer.Option("--budget", "-b")] = 4096,
|
|
277
|
+
target_session: Annotated[Optional[str], typer.Option("--session")] = None,
|
|
278
|
+
cwd: Annotated[Optional[str], typer.Option("--cwd")] = None,
|
|
279
|
+
method: Annotated[Optional[str], typer.Option("--method")] = None,
|
|
280
|
+
execute: Annotated[bool, typer.Option("--execute")] = False,
|
|
281
|
+
fmt: Annotated[str, typer.Option("--format", "-f")] = "rich",
|
|
282
|
+
):
|
|
283
|
+
"""Compact context from sessions and inject into a new/existing session.
|
|
284
|
+
|
|
285
|
+
By default this is a dry run — prints the command without executing.
|
|
286
|
+
Use --execute to actually launch the target tool.
|
|
287
|
+
"""
|
|
288
|
+
cfg = _get_config()
|
|
289
|
+
database = _get_db(cfg)
|
|
290
|
+
|
|
291
|
+
bundle = compact(
|
|
292
|
+
db=database,
|
|
293
|
+
session_ids=session_ids,
|
|
294
|
+
strategy=strategy,
|
|
295
|
+
token_budget=budget,
|
|
296
|
+
target_tool=target_tool,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
cmd, actual_method = build_inject_command(
|
|
300
|
+
bundle=bundle,
|
|
301
|
+
target_tool=target_tool,
|
|
302
|
+
target_session_id=target_session,
|
|
303
|
+
cwd=cwd,
|
|
304
|
+
method=method,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
if fmt == "json":
|
|
308
|
+
output = {
|
|
309
|
+
"target_tool": target_tool,
|
|
310
|
+
"method": actual_method,
|
|
311
|
+
"bundle_tokens": bundle.token_count,
|
|
312
|
+
"bundle_strategy": bundle.strategy,
|
|
313
|
+
"source_sessions": bundle.source_sessions,
|
|
314
|
+
"command": cmd,
|
|
315
|
+
"dry_run": not execute,
|
|
316
|
+
"context_file_needed": actual_method == "file",
|
|
317
|
+
}
|
|
318
|
+
console.print_json(json.dumps(output))
|
|
319
|
+
else:
|
|
320
|
+
file_note = (
|
|
321
|
+
"\n[dim]Note: CONTEXT.md will be written to the target directory.[/dim]"
|
|
322
|
+
if actual_method == "file"
|
|
323
|
+
else ""
|
|
324
|
+
)
|
|
325
|
+
console.print(Panel(
|
|
326
|
+
f"Target tool: {target_tool}\n"
|
|
327
|
+
f"Method: {actual_method}\n"
|
|
328
|
+
f"Bundle tokens: {bundle.token_count} / {budget}\n"
|
|
329
|
+
f"Strategy: {bundle.strategy}\n\n"
|
|
330
|
+
f"[bold]Command:[/bold]\n[cyan]{cmd}[/cyan]{file_note}",
|
|
331
|
+
title="Transfer Preview",
|
|
332
|
+
))
|
|
333
|
+
|
|
334
|
+
if execute:
|
|
335
|
+
bundle_id = db_module.save_bundle(database, bundle)
|
|
336
|
+
if fmt != "json":
|
|
337
|
+
console.print("[yellow]Executing...[/yellow]")
|
|
338
|
+
execute_transfer(
|
|
339
|
+
db=database,
|
|
340
|
+
bundle=bundle,
|
|
341
|
+
bundle_id=bundle_id,
|
|
342
|
+
target_tool=target_tool,
|
|
343
|
+
target_session_id=target_session,
|
|
344
|
+
cwd=cwd,
|
|
345
|
+
method=method,
|
|
346
|
+
)
|
|
347
|
+
elif fmt != "json":
|
|
348
|
+
console.print("[dim]Dry run. Pass [bold]--execute[/bold] to launch.[/dim]")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# ---------------------------------------------------------------------------
|
|
352
|
+
# cf tokens
|
|
353
|
+
# ---------------------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
@app.command()
|
|
356
|
+
def tokens(
|
|
357
|
+
session_id: str,
|
|
358
|
+
top: Annotated[int, typer.Option("--top", "-n", help="Show only the N heaviest turns")] = 0,
|
|
359
|
+
fmt: Annotated[str, typer.Option("--format", "-f")] = "rich",
|
|
360
|
+
):
|
|
361
|
+
"""Show per-turn token breakdown for a session.
|
|
362
|
+
|
|
363
|
+
Counts tokens in every message, shows role totals, averages, and
|
|
364
|
+
a bar chart of consumption per turn.
|
|
365
|
+
"""
|
|
366
|
+
from contextforge.core.token_analyzer import analyze_tokens
|
|
367
|
+
from rich.table import Table
|
|
368
|
+
from rich.text import Text
|
|
369
|
+
|
|
370
|
+
cfg = _get_config()
|
|
371
|
+
database = _get_db(cfg)
|
|
372
|
+
|
|
373
|
+
report = analyze_tokens(database, session_id)
|
|
374
|
+
if report is None:
|
|
375
|
+
err_console.print(f"[red]Session not found:[/red] {session_id}")
|
|
376
|
+
raise typer.Exit(1)
|
|
377
|
+
|
|
378
|
+
if not report.turns:
|
|
379
|
+
err_console.print("[yellow]No messages found for this session.[/yellow]")
|
|
380
|
+
raise typer.Exit(0)
|
|
381
|
+
|
|
382
|
+
if fmt == "json":
|
|
383
|
+
out = {
|
|
384
|
+
"session_id": report.session_id,
|
|
385
|
+
"tool": report.tool,
|
|
386
|
+
"title": report.title,
|
|
387
|
+
"total_tokens": report.total,
|
|
388
|
+
"user_tokens": report.user_total,
|
|
389
|
+
"assistant_tokens": report.assistant_total,
|
|
390
|
+
"turn_count": report.turn_count,
|
|
391
|
+
"avg_user_tokens": round(report.avg_user, 1),
|
|
392
|
+
"avg_assistant_tokens": round(report.avg_assistant, 1),
|
|
393
|
+
"turns": [
|
|
394
|
+
{
|
|
395
|
+
"turn": t.turn,
|
|
396
|
+
"role": t.role,
|
|
397
|
+
"tokens": t.tokens,
|
|
398
|
+
"cumulative": t.cumulative,
|
|
399
|
+
"preview": t.content_preview,
|
|
400
|
+
}
|
|
401
|
+
for t in report.turns
|
|
402
|
+
],
|
|
403
|
+
}
|
|
404
|
+
console.print_json(json.dumps(out))
|
|
405
|
+
return
|
|
406
|
+
|
|
407
|
+
# ── Summary panel ──────────────────────────────────────────────────────
|
|
408
|
+
max_t = report.max_turn
|
|
409
|
+
console.print(Panel(
|
|
410
|
+
f"[bold]{report.title}[/bold] [{report.tool}]\n\n"
|
|
411
|
+
f"Total tokens: [bold]{report.total:,}[/bold]\n"
|
|
412
|
+
f"Turns: {report.turn_count} "
|
|
413
|
+
f"([cyan]user {report.turn_count // 2 or len([t for t in report.turns if t.role == 'user'])}[/cyan] / "
|
|
414
|
+
f"[green]asst {len([t for t in report.turns if t.role == 'assistant'])}[/green])\n"
|
|
415
|
+
f"User total: [cyan]{report.user_total:,}[/cyan] "
|
|
416
|
+
f"(avg {report.avg_user:,.0f}/turn)\n"
|
|
417
|
+
f"Assistant total:[green]{report.assistant_total:,}[/green] "
|
|
418
|
+
f"(avg {report.avg_assistant:,.0f}/turn)\n"
|
|
419
|
+
+ (f"Heaviest turn: #{max_t.turn} [{max_t.role}] — "
|
|
420
|
+
f"[bold]{max_t.tokens:,}[/bold] tokens" if max_t else ""),
|
|
421
|
+
title=f"Token Analysis · {session_id[:16]}",
|
|
422
|
+
))
|
|
423
|
+
|
|
424
|
+
# ── Per-turn table ──────────────────────────────────────────────────────
|
|
425
|
+
BAR_MAX = 30
|
|
426
|
+
turns_to_show = report.turns
|
|
427
|
+
if top:
|
|
428
|
+
turns_to_show = sorted(report.turns, key=lambda t: t.tokens, reverse=True)[:top]
|
|
429
|
+
turns_to_show = sorted(turns_to_show, key=lambda t: t.turn)
|
|
430
|
+
|
|
431
|
+
max_tokens = max((t.tokens for t in report.turns), default=1)
|
|
432
|
+
|
|
433
|
+
table = Table(show_header=True, header_style="bold", show_lines=False, expand=True)
|
|
434
|
+
table.add_column("#", justify="right", width=4, no_wrap=True)
|
|
435
|
+
table.add_column("Role", width=8, no_wrap=True)
|
|
436
|
+
table.add_column("Tokens", justify="right", width=8, no_wrap=True)
|
|
437
|
+
table.add_column("Cumul.", justify="right", width=9, no_wrap=True)
|
|
438
|
+
table.add_column("Bar", min_width=BAR_MAX, no_wrap=True)
|
|
439
|
+
table.add_column("Preview", no_wrap=True)
|
|
440
|
+
|
|
441
|
+
for t in turns_to_show:
|
|
442
|
+
bar_len = max(1, int(t.tokens / max_tokens * BAR_MAX))
|
|
443
|
+
if t.role == "user":
|
|
444
|
+
color = "cyan"
|
|
445
|
+
elif t.role == "assistant":
|
|
446
|
+
color = "green"
|
|
447
|
+
else:
|
|
448
|
+
color = "dim"
|
|
449
|
+
|
|
450
|
+
bar = Text("█" * bar_len, style=color)
|
|
451
|
+
table.add_row(
|
|
452
|
+
str(t.turn),
|
|
453
|
+
f"[{color}]{t.role}[/{color}]",
|
|
454
|
+
f"{t.tokens:,}",
|
|
455
|
+
f"{t.cumulative:,}",
|
|
456
|
+
bar,
|
|
457
|
+
Text(t.content_preview, overflow="ellipsis"),
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
console.print(table)
|
|
461
|
+
if top:
|
|
462
|
+
console.print(f"[dim]Showing top {top} heaviest turns. Omit --top to see all.[/dim]")
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
# ---------------------------------------------------------------------------
|
|
466
|
+
# cf tag
|
|
467
|
+
# ---------------------------------------------------------------------------
|
|
468
|
+
|
|
469
|
+
@app.command()
|
|
470
|
+
def tag(
|
|
471
|
+
session_id: str,
|
|
472
|
+
tag_name: str,
|
|
473
|
+
):
|
|
474
|
+
"""Add a tag to a session."""
|
|
475
|
+
cfg = _get_config()
|
|
476
|
+
database = _get_db(cfg)
|
|
477
|
+
row = db_module.get_session(database, session_id)
|
|
478
|
+
if row is None:
|
|
479
|
+
err_console.print(f"[red]Session not found:[/red] {session_id}")
|
|
480
|
+
raise typer.Exit(1)
|
|
481
|
+
existing = json.loads(row.get("tags") or "[]")
|
|
482
|
+
if tag_name not in existing:
|
|
483
|
+
existing.append(tag_name)
|
|
484
|
+
database["sessions"].update(session_id, {"tags": json.dumps(existing)})
|
|
485
|
+
console.print(f"[green]Tagged[/green] {session_id[:16]} with [bold]{tag_name}[/bold]")
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
# ---------------------------------------------------------------------------
|
|
489
|
+
# cf config
|
|
490
|
+
# ---------------------------------------------------------------------------
|
|
491
|
+
|
|
492
|
+
@config_app.command(name="show")
|
|
493
|
+
def config_show():
|
|
494
|
+
"""Print active configuration."""
|
|
495
|
+
cfg = _get_config()
|
|
496
|
+
console.print_json(cfg.model_dump_json(indent=2))
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
@config_app.command(name="set")
|
|
500
|
+
def config_set(key: str, value: str):
|
|
501
|
+
"""Set a configuration value (not yet persisted — edit ~/.contextforge/config.toml)."""
|
|
502
|
+
err_console.print(
|
|
503
|
+
"[yellow]Tip:[/yellow] Edit [bold]~/.contextforge/config.toml[/bold] directly to persist settings."
|
|
504
|
+
)
|
|
505
|
+
console.print(f"Would set [bold]{key}[/bold] = [bold]{value}[/bold]")
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# ---------------------------------------------------------------------------
|
|
509
|
+
# cf dashboard
|
|
510
|
+
# ---------------------------------------------------------------------------
|
|
511
|
+
|
|
512
|
+
@app.command()
|
|
513
|
+
def dashboard():
|
|
514
|
+
"""Launch the Textual TUI dashboard.
|
|
515
|
+
|
|
516
|
+
Key bindings:
|
|
517
|
+
r — rescan sessions s — summarize selected
|
|
518
|
+
t — transfer (modal) c — compact selected
|
|
519
|
+
x — token analysis q — quit
|
|
520
|
+
"""
|
|
521
|
+
cfg = _get_config()
|
|
522
|
+
from contextforge.tui.app import ContextForgeApp
|
|
523
|
+
ContextForgeApp(db_path=cfg.db_path).run()
|
|
File without changes
|