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
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Session discovery and indexing across all registered adapters."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import sqlite_utils
|
|
8
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
9
|
+
|
|
10
|
+
from contextforge.adapters.registry import get_available_adapters
|
|
11
|
+
from contextforge.core.db import upsert_session
|
|
12
|
+
from contextforge.models.session import Session
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ScanResult:
|
|
17
|
+
new: int = 0
|
|
18
|
+
updated: int = 0
|
|
19
|
+
unchanged: int = 0
|
|
20
|
+
errors: list[str] = field(default_factory=list)
|
|
21
|
+
sessions: list[Session] = field(default_factory=list)
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def total(self) -> int:
|
|
25
|
+
return self.new + self.updated + self.unchanged
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def scan(db: sqlite_utils.Database, quiet: bool = False) -> ScanResult:
|
|
29
|
+
result = ScanResult()
|
|
30
|
+
adapters = get_available_adapters()
|
|
31
|
+
|
|
32
|
+
if not adapters:
|
|
33
|
+
result.errors.append("No supported agentic tools found on this machine.")
|
|
34
|
+
return result
|
|
35
|
+
|
|
36
|
+
with Progress(
|
|
37
|
+
SpinnerColumn(),
|
|
38
|
+
TextColumn("[progress.description]{task.description}"),
|
|
39
|
+
disable=quiet,
|
|
40
|
+
) as progress:
|
|
41
|
+
for adapter in adapters:
|
|
42
|
+
task = progress.add_task(f"Scanning {adapter.tool_name}...", total=None)
|
|
43
|
+
try:
|
|
44
|
+
sessions = adapter.discover_sessions()
|
|
45
|
+
except Exception as exc:
|
|
46
|
+
result.errors.append(f"{adapter.tool_name}: {exc}")
|
|
47
|
+
progress.remove_task(task)
|
|
48
|
+
continue
|
|
49
|
+
|
|
50
|
+
for session in sessions:
|
|
51
|
+
try:
|
|
52
|
+
existing = list(
|
|
53
|
+
db["sessions"].rows_where("id = ?", [session.id])
|
|
54
|
+
)
|
|
55
|
+
before_count = len(existing)
|
|
56
|
+
upsert_session(db, session)
|
|
57
|
+
after = list(db["sessions"].rows_where("id = ?", [session.id]))
|
|
58
|
+
|
|
59
|
+
if before_count == 0:
|
|
60
|
+
result.new += 1
|
|
61
|
+
elif existing[0]["updated_at"] < int(session.updated_at.timestamp() * 1000):
|
|
62
|
+
result.updated += 1
|
|
63
|
+
else:
|
|
64
|
+
result.unchanged += 1
|
|
65
|
+
result.sessions.append(session)
|
|
66
|
+
except Exception as exc:
|
|
67
|
+
result.errors.append(f"{session.id}: {exc}")
|
|
68
|
+
|
|
69
|
+
progress.update(task, description=f"[green]{adapter.tool_name}: {len(sessions)} sessions")
|
|
70
|
+
progress.remove_task(task)
|
|
71
|
+
|
|
72
|
+
return result
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""LLM-based session summarization with caching."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
import sqlite_utils
|
|
8
|
+
|
|
9
|
+
from contextforge.adapters.registry import get_adapter
|
|
10
|
+
from contextforge.core.db import get_session, get_sessions, update_summary
|
|
11
|
+
from contextforge.models.config import ForgeConfig
|
|
12
|
+
from contextforge.utils.tokens import count_tokens, truncate_to_budget
|
|
13
|
+
|
|
14
|
+
_SUMMARY_PROMPT = """\
|
|
15
|
+
You are summarizing a coding session for a developer. Read the conversation below and
|
|
16
|
+
write a 3–5 sentence summary covering:
|
|
17
|
+
- What was being built or investigated
|
|
18
|
+
- Key decisions made or problems solved
|
|
19
|
+
- Files and components touched
|
|
20
|
+
- Current state / what remains to be done
|
|
21
|
+
|
|
22
|
+
Be concise. Under 150 words. Plain prose, no bullet points.
|
|
23
|
+
|
|
24
|
+
--- SESSION ---
|
|
25
|
+
{transcript}
|
|
26
|
+
--- END SESSION ---
|
|
27
|
+
|
|
28
|
+
Summary:"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class BatchSummaryResult:
|
|
33
|
+
summarized: int = 0
|
|
34
|
+
skipped: int = 0
|
|
35
|
+
errors: list[str] = field(default_factory=list)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def summarize_session(
|
|
39
|
+
db: sqlite_utils.Database,
|
|
40
|
+
session_id: str,
|
|
41
|
+
config: ForgeConfig,
|
|
42
|
+
force: bool = False,
|
|
43
|
+
) -> str | None:
|
|
44
|
+
"""Generate (or return cached) summary for a single session.
|
|
45
|
+
|
|
46
|
+
Priority order:
|
|
47
|
+
1. Cached summary in DB (unless force=True)
|
|
48
|
+
2. Tool-native rollup summary (e.g. Codex stage1_outputs) — zero LLM cost
|
|
49
|
+
3. LLM-generated summary (requires ANTHROPIC_API_KEY)
|
|
50
|
+
4. Graceful degradation: first user message preview
|
|
51
|
+
"""
|
|
52
|
+
row = get_session(db, session_id)
|
|
53
|
+
if row is None:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
# Return cached summary unless force-refresh
|
|
57
|
+
if row.get("summary") and not force:
|
|
58
|
+
return row["summary"]
|
|
59
|
+
|
|
60
|
+
# Check for tool-native rollup summary first (e.g. Codex pre-computed)
|
|
61
|
+
try:
|
|
62
|
+
adapter = get_adapter(row["tool"])
|
|
63
|
+
rollup = adapter.get_rollup_summary(session_id)
|
|
64
|
+
if rollup:
|
|
65
|
+
update_summary(db, session_id, rollup)
|
|
66
|
+
return rollup
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
# Load messages for LLM path
|
|
71
|
+
try:
|
|
72
|
+
messages = adapter.load_messages(session_id)
|
|
73
|
+
except Exception:
|
|
74
|
+
messages = []
|
|
75
|
+
|
|
76
|
+
if not messages:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
# Build transcript
|
|
80
|
+
lines = []
|
|
81
|
+
for m in messages:
|
|
82
|
+
prefix = "User" if m.role == "user" else "Assistant"
|
|
83
|
+
lines.append(f"{prefix}: {m.content}")
|
|
84
|
+
transcript = "\n\n".join(lines)
|
|
85
|
+
|
|
86
|
+
# Respect input token budget
|
|
87
|
+
max_input = config.llm.max_input_tokens
|
|
88
|
+
prompt_overhead = count_tokens(_SUMMARY_PROMPT.format(transcript=""))
|
|
89
|
+
transcript = truncate_to_budget(transcript, max_input - prompt_overhead - 200)
|
|
90
|
+
|
|
91
|
+
api_key = config.llm.api_key or os.environ.get("ANTHROPIC_API_KEY")
|
|
92
|
+
if not api_key:
|
|
93
|
+
# Graceful degradation: return first user message as preview
|
|
94
|
+
for m in messages:
|
|
95
|
+
if m.role == "user":
|
|
96
|
+
preview = m.content[:200]
|
|
97
|
+
update_summary(db, session_id, preview)
|
|
98
|
+
return preview
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
import anthropic
|
|
103
|
+
|
|
104
|
+
client = anthropic.Anthropic(api_key=api_key)
|
|
105
|
+
prompt = _SUMMARY_PROMPT.format(transcript=transcript)
|
|
106
|
+
response = client.messages.create(
|
|
107
|
+
model=config.llm.model,
|
|
108
|
+
max_tokens=300,
|
|
109
|
+
messages=[{"role": "user", "content": prompt}],
|
|
110
|
+
)
|
|
111
|
+
summary = response.content[0].text.strip()
|
|
112
|
+
except Exception:
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
update_summary(db, session_id, summary)
|
|
116
|
+
return summary
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def batch_summarize(
|
|
120
|
+
db: sqlite_utils.Database,
|
|
121
|
+
config: ForgeConfig,
|
|
122
|
+
session_ids: list[str] | None = None,
|
|
123
|
+
force: bool = False,
|
|
124
|
+
on_progress: "Callable[[str, str | None], None] | None" = None,
|
|
125
|
+
) -> BatchSummaryResult:
|
|
126
|
+
"""Summarize multiple sessions.
|
|
127
|
+
|
|
128
|
+
If session_ids is None, summarizes all sessions without a cached summary
|
|
129
|
+
(or all sessions if force=True).
|
|
130
|
+
|
|
131
|
+
on_progress(session_id, summary_or_None) is called after each session.
|
|
132
|
+
"""
|
|
133
|
+
result = BatchSummaryResult()
|
|
134
|
+
|
|
135
|
+
if session_ids is None:
|
|
136
|
+
rows = get_sessions(db, limit=10_000)
|
|
137
|
+
if not force:
|
|
138
|
+
rows = [r for r in rows if not r.get("summary")]
|
|
139
|
+
session_ids = [r["id"] for r in rows]
|
|
140
|
+
|
|
141
|
+
for sid in session_ids:
|
|
142
|
+
try:
|
|
143
|
+
summary = summarize_session(db, sid, config, force=force)
|
|
144
|
+
if summary:
|
|
145
|
+
result.summarized += 1
|
|
146
|
+
else:
|
|
147
|
+
result.skipped += 1
|
|
148
|
+
if on_progress:
|
|
149
|
+
on_progress(sid, summary)
|
|
150
|
+
except Exception as exc:
|
|
151
|
+
result.errors.append(f"{sid}: {exc}")
|
|
152
|
+
if on_progress:
|
|
153
|
+
on_progress(sid, None)
|
|
154
|
+
|
|
155
|
+
return result
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Per-turn token analysis for a session."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
import sqlite_utils
|
|
7
|
+
|
|
8
|
+
from contextforge.adapters.registry import get_adapter
|
|
9
|
+
from contextforge.core.db import get_session
|
|
10
|
+
from contextforge.models.session import Message
|
|
11
|
+
from contextforge.utils.tokens import count_tokens
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class TurnStats:
|
|
16
|
+
turn: int
|
|
17
|
+
role: str
|
|
18
|
+
tokens: int
|
|
19
|
+
content_preview: str # first 80 chars, single line
|
|
20
|
+
cumulative: int = 0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class SessionTokenReport:
|
|
25
|
+
session_id: str
|
|
26
|
+
tool: str
|
|
27
|
+
title: str
|
|
28
|
+
turns: list[TurnStats] = field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def total(self) -> int:
|
|
32
|
+
return sum(t.tokens for t in self.turns)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def user_total(self) -> int:
|
|
36
|
+
return sum(t.tokens for t in self.turns if t.role == "user")
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def assistant_total(self) -> int:
|
|
40
|
+
return sum(t.tokens for t in self.turns if t.role == "assistant")
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def turn_count(self) -> int:
|
|
44
|
+
return len(self.turns)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def avg_user(self) -> float:
|
|
48
|
+
user = [t.tokens for t in self.turns if t.role == "user"]
|
|
49
|
+
return sum(user) / len(user) if user else 0.0
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def avg_assistant(self) -> float:
|
|
53
|
+
asst = [t.tokens for t in self.turns if t.role == "assistant"]
|
|
54
|
+
return sum(asst) / len(asst) if asst else 0.0
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def max_turn(self) -> TurnStats | None:
|
|
58
|
+
return max(self.turns, key=lambda t: t.tokens) if self.turns else None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def analyze_tokens(
|
|
62
|
+
db: sqlite_utils.Database,
|
|
63
|
+
session_id: str,
|
|
64
|
+
) -> SessionTokenReport | None:
|
|
65
|
+
row = get_session(db, session_id)
|
|
66
|
+
if row is None:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
adapter = get_adapter(row["tool"])
|
|
70
|
+
messages = adapter.load_messages(session_id)
|
|
71
|
+
|
|
72
|
+
turns: list[TurnStats] = []
|
|
73
|
+
cumulative = 0
|
|
74
|
+
for i, msg in enumerate(messages, 1):
|
|
75
|
+
tokens = count_tokens(msg.content)
|
|
76
|
+
cumulative += tokens
|
|
77
|
+
preview = msg.content.replace("\n", " ").strip()[:80]
|
|
78
|
+
turns.append(TurnStats(
|
|
79
|
+
turn=i,
|
|
80
|
+
role=msg.role,
|
|
81
|
+
tokens=tokens,
|
|
82
|
+
content_preview=preview,
|
|
83
|
+
cumulative=cumulative,
|
|
84
|
+
))
|
|
85
|
+
|
|
86
|
+
return SessionTokenReport(
|
|
87
|
+
session_id=session_id,
|
|
88
|
+
tool=row["tool"],
|
|
89
|
+
title=row.get("title") or session_id[:16],
|
|
90
|
+
turns=turns,
|
|
91
|
+
)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LLMConfig(BaseModel):
|
|
9
|
+
api_key: str | None = None
|
|
10
|
+
model: str = "claude-haiku-4-5-20251001"
|
|
11
|
+
max_input_tokens: int = 60_000
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ScannerConfig(BaseModel):
|
|
15
|
+
max_sessions_per_tool: int = 200
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CompactorConfig(BaseModel):
|
|
19
|
+
default_strategy: str = "summary_only"
|
|
20
|
+
default_token_budget: int = 4096
|
|
21
|
+
file_injection_token_budget: int = 32_768
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ToolConfig(BaseModel):
|
|
25
|
+
enabled: bool = True
|
|
26
|
+
custom_path: str | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ForgeConfig(BaseModel):
|
|
30
|
+
db_path: Path = Field(default_factory=lambda: Path.home() / ".contextforge" / "contextforge.db")
|
|
31
|
+
llm: LLMConfig = Field(default_factory=LLMConfig)
|
|
32
|
+
scanner: ScannerConfig = Field(default_factory=ScannerConfig)
|
|
33
|
+
compactor: CompactorConfig = Field(default_factory=CompactorConfig)
|
|
34
|
+
tools: dict[str, ToolConfig] = Field(default_factory=dict)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Message(BaseModel):
|
|
10
|
+
role: Literal["user", "assistant", "tool", "system"]
|
|
11
|
+
content: str
|
|
12
|
+
timestamp: datetime | None = None
|
|
13
|
+
token_count: int | None = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Session(BaseModel):
|
|
17
|
+
id: str
|
|
18
|
+
tool: str
|
|
19
|
+
title: str | None = None
|
|
20
|
+
cwd: str | None = None
|
|
21
|
+
created_at: datetime
|
|
22
|
+
updated_at: datetime
|
|
23
|
+
messages: list[Message] = Field(default_factory=list)
|
|
24
|
+
token_count: int | None = None
|
|
25
|
+
raw_path: str | None = None
|
|
26
|
+
status: str = "unknown"
|
|
27
|
+
summary: str | None = None
|
|
28
|
+
tags: list[str] = Field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ContextBundle(BaseModel):
|
|
32
|
+
name: str
|
|
33
|
+
source_sessions: list[str]
|
|
34
|
+
compacted_text: str
|
|
35
|
+
token_count: int
|
|
36
|
+
strategy: str
|
|
37
|
+
target_tool: str | None = None
|
|
File without changes
|
contextforge/tui/app.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""ContextForge TUI — full Textual dashboard."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from textual.app import App, ComposeResult
|
|
7
|
+
from textual.binding import Binding
|
|
8
|
+
from textual.containers import Horizontal, Vertical
|
|
9
|
+
from textual.widgets import Footer, Header
|
|
10
|
+
|
|
11
|
+
from contextforge.tui.widgets.session_detail import SessionDetail
|
|
12
|
+
from contextforge.tui.widgets.session_table import SessionTable
|
|
13
|
+
from contextforge.tui.widgets.status_bar import StatusBar
|
|
14
|
+
from contextforge.tui.widgets.tokens_panel import TokensPanel
|
|
15
|
+
from contextforge.tui.widgets.transfer_panel import TransferPanel
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ContextForgeApp(App):
|
|
19
|
+
"""ContextForge dashboard — manage agentic CLI sessions."""
|
|
20
|
+
|
|
21
|
+
TITLE = "ContextForge"
|
|
22
|
+
CSS_PATH = "styles.tcss"
|
|
23
|
+
|
|
24
|
+
BINDINGS = [
|
|
25
|
+
Binding("q", "quit", "Quit"),
|
|
26
|
+
Binding("r", "rescan", "Rescan"),
|
|
27
|
+
Binding("s", "summarize", "Summarize"),
|
|
28
|
+
Binding("t", "transfer", "Transfer"),
|
|
29
|
+
Binding("c", "compact", "Compact"),
|
|
30
|
+
Binding("x", "tokens", "Tokens"),
|
|
31
|
+
Binding("a", "analytics", "Analytics"),
|
|
32
|
+
Binding("/", "filter", "Filter"),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
def __init__(self, db_path: Path | None = None) -> None:
|
|
36
|
+
super().__init__()
|
|
37
|
+
from contextforge.models.config import ForgeConfig
|
|
38
|
+
cfg = ForgeConfig()
|
|
39
|
+
self.db_path: Path = db_path or cfg.db_path
|
|
40
|
+
|
|
41
|
+
# ── Layout ────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
def compose(self) -> ComposeResult:
|
|
44
|
+
yield Header()
|
|
45
|
+
with Horizontal(id="main-horizontal"):
|
|
46
|
+
yield SessionTable(id="session-table")
|
|
47
|
+
yield SessionDetail(id="session-detail")
|
|
48
|
+
yield StatusBar(id="status-bar")
|
|
49
|
+
yield Footer()
|
|
50
|
+
|
|
51
|
+
def on_mount(self) -> None:
|
|
52
|
+
self.query_one(StatusBar).refresh_stats()
|
|
53
|
+
|
|
54
|
+
# ── Session selection ──────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
def on_session_table_row_selected(self, message: SessionTable.RowSelected) -> None:
|
|
57
|
+
self.query_one(SessionDetail).load(message.session_id)
|
|
58
|
+
self._current_session_id = message.session_id
|
|
59
|
+
self._current_tool = message.tool
|
|
60
|
+
|
|
61
|
+
def on_session_table_filter_changed(self, message: SessionTable.FilterChanged) -> None:
|
|
62
|
+
self.query_one(StatusBar).set_filter_indicator(
|
|
63
|
+
message.text, message.tool, message.match_count
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# ── Actions ───────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
def action_rescan(self) -> None:
|
|
69
|
+
"""Re-scan all tools and refresh the table."""
|
|
70
|
+
from contextforge.core.db import get_db
|
|
71
|
+
from contextforge.core.scanner import scan
|
|
72
|
+
|
|
73
|
+
db = get_db(self.db_path)
|
|
74
|
+
result = scan(db, quiet=True)
|
|
75
|
+
self.query_one(SessionTable).reload()
|
|
76
|
+
self.query_one(StatusBar).refresh_stats()
|
|
77
|
+
self.notify(
|
|
78
|
+
f"Scan complete: {result.new} new, {result.updated} updated",
|
|
79
|
+
title="Rescan",
|
|
80
|
+
severity="information",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def action_summarize(self) -> None:
|
|
84
|
+
"""Generate/refresh summary for the selected session."""
|
|
85
|
+
sid = getattr(self, "_current_session_id", None)
|
|
86
|
+
if sid is None:
|
|
87
|
+
self.notify("Select a session first.", severity="warning")
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
from contextforge.core.db import get_db
|
|
91
|
+
from contextforge.core.summarizer import summarize_session
|
|
92
|
+
from contextforge.models.config import ForgeConfig
|
|
93
|
+
|
|
94
|
+
db = get_db(self.db_path)
|
|
95
|
+
summary = summarize_session(db, sid, ForgeConfig())
|
|
96
|
+
if summary:
|
|
97
|
+
self.query_one(SessionDetail)._current_id = None # bust cache
|
|
98
|
+
self.query_one(SessionDetail).load(sid)
|
|
99
|
+
self.notify("Summary generated.", title="Summarize")
|
|
100
|
+
else:
|
|
101
|
+
self.notify(
|
|
102
|
+
"Could not generate summary (no messages or no API key).",
|
|
103
|
+
severity="warning",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def action_transfer(self) -> None:
|
|
107
|
+
"""Open the transfer panel for the selected session."""
|
|
108
|
+
sid = getattr(self, "_current_session_id", None)
|
|
109
|
+
if sid is None:
|
|
110
|
+
self.notify("Select a session first.", severity="warning")
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
from contextforge.core.db import get_db, get_session
|
|
114
|
+
db = get_db(self.db_path)
|
|
115
|
+
row = get_session(db, sid)
|
|
116
|
+
title = (row.get("title") or sid[:16]) if row else sid[:16]
|
|
117
|
+
|
|
118
|
+
def on_dismiss(result) -> None:
|
|
119
|
+
if result is None:
|
|
120
|
+
return
|
|
121
|
+
self._run_transfer(
|
|
122
|
+
session_id=sid,
|
|
123
|
+
target_tool=result["tool"],
|
|
124
|
+
strategy=result["strategy"],
|
|
125
|
+
execute=result["execute"],
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
self.push_screen(TransferPanel(sid, title), callback=on_dismiss)
|
|
129
|
+
|
|
130
|
+
def _run_transfer(
|
|
131
|
+
self,
|
|
132
|
+
session_id: str,
|
|
133
|
+
target_tool: str,
|
|
134
|
+
strategy: str,
|
|
135
|
+
execute: bool,
|
|
136
|
+
) -> None:
|
|
137
|
+
from contextforge.core.compactor import compact
|
|
138
|
+
from contextforge.core.db import get_db, save_bundle
|
|
139
|
+
from contextforge.core.injector import build_inject_command, execute_transfer
|
|
140
|
+
|
|
141
|
+
db = get_db(self.db_path)
|
|
142
|
+
bundle = compact(
|
|
143
|
+
db=db,
|
|
144
|
+
session_ids=[session_id],
|
|
145
|
+
strategy=strategy,
|
|
146
|
+
token_budget=4096,
|
|
147
|
+
target_tool=target_tool,
|
|
148
|
+
)
|
|
149
|
+
cmd, method = build_inject_command(bundle, target_tool)
|
|
150
|
+
|
|
151
|
+
if execute:
|
|
152
|
+
bundle_id = save_bundle(db, bundle)
|
|
153
|
+
execute_transfer(
|
|
154
|
+
db=db,
|
|
155
|
+
bundle=bundle,
|
|
156
|
+
bundle_id=bundle_id,
|
|
157
|
+
target_tool=target_tool,
|
|
158
|
+
)
|
|
159
|
+
self.notify(
|
|
160
|
+
f"Launched {target_tool} with {bundle.token_count} tokens of context.",
|
|
161
|
+
title="Transfer executed",
|
|
162
|
+
)
|
|
163
|
+
else:
|
|
164
|
+
self.notify(
|
|
165
|
+
f"[cyan]{cmd}[/cyan]",
|
|
166
|
+
title=f"Preview → {target_tool} ({method})",
|
|
167
|
+
timeout=12,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def action_compact(self) -> None:
|
|
171
|
+
"""Compact the selected session and show the bundle."""
|
|
172
|
+
sid = getattr(self, "_current_session_id", None)
|
|
173
|
+
if sid is None:
|
|
174
|
+
self.notify("Select a session first.", severity="warning")
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
from contextforge.core.compactor import compact
|
|
178
|
+
from contextforge.core.db import get_db
|
|
179
|
+
|
|
180
|
+
db = get_db(self.db_path)
|
|
181
|
+
bundle = compact(db=db, session_ids=[sid], strategy="summary_only", token_budget=4096)
|
|
182
|
+
self.notify(
|
|
183
|
+
f"Bundle: {bundle.token_count} tokens ({bundle.strategy})\n"
|
|
184
|
+
f"{bundle.compacted_text[:200]}…",
|
|
185
|
+
title="Compact",
|
|
186
|
+
timeout=10,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def action_tokens(self) -> None:
|
|
190
|
+
"""Open the token analysis modal for the selected session."""
|
|
191
|
+
sid = getattr(self, "_current_session_id", None)
|
|
192
|
+
if sid is None:
|
|
193
|
+
self.notify("Select a session first.", severity="warning")
|
|
194
|
+
return
|
|
195
|
+
self.push_screen(TokensPanel(session_id=sid, db_path=self.db_path))
|
|
196
|
+
|
|
197
|
+
def action_analytics(self) -> None:
|
|
198
|
+
"""Open the analytics dashboard modal."""
|
|
199
|
+
from contextforge.tui.widgets.stats_panel import StatsPanel
|
|
200
|
+
self.push_screen(StatsPanel())
|
|
201
|
+
|
|
202
|
+
def action_filter(self) -> None:
|
|
203
|
+
"""Toggle the filter bar in the session table."""
|
|
204
|
+
self.query_one(SessionTable).toggle_filter()
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/* ContextForge TUI Styles */
|
|
2
|
+
|
|
3
|
+
/* ── Layout ─────────────────────────────────────────────── */
|
|
4
|
+
|
|
5
|
+
Screen {
|
|
6
|
+
layout: vertical;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
#main-horizontal {
|
|
10
|
+
height: 1fr;
|
|
11
|
+
layout: horizontal;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/* ── Transfer modal ─────────────────────────────────────── */
|
|
15
|
+
|
|
16
|
+
TransferPanel {
|
|
17
|
+
align: center middle;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
TransferPanel > Vertical {
|
|
21
|
+
width: 64;
|
|
22
|
+
height: auto;
|
|
23
|
+
max-height: 38;
|
|
24
|
+
border: thick $primary;
|
|
25
|
+
background: $surface;
|
|
26
|
+
padding: 1 2;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
#panel-title {
|
|
30
|
+
text-style: bold;
|
|
31
|
+
color: $accent;
|
|
32
|
+
margin-bottom: 1;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.section-label {
|
|
36
|
+
text-style: bold;
|
|
37
|
+
margin-top: 1;
|
|
38
|
+
color: $text-muted;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#btn-row {
|
|
42
|
+
margin-top: 2;
|
|
43
|
+
height: auto;
|
|
44
|
+
layout: horizontal;
|
|
45
|
+
align: right middle;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#btn-row Button {
|
|
49
|
+
margin-left: 1;
|
|
50
|
+
}
|
|
File without changes
|