context-forge-cli 0.2.3__py3-none-any.whl → 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {context_forge_cli-0.2.3.dist-info → context_forge_cli-0.3.0.dist-info}/METADATA +107 -1
- {context_forge_cli-0.2.3.dist-info → context_forge_cli-0.3.0.dist-info}/RECORD +13 -12
- {context_forge_cli-0.2.3.dist-info → context_forge_cli-0.3.0.dist-info}/entry_points.txt +1 -0
- contextforge/adapters/altimate_code.py +47 -23
- contextforge/adapters/claude_code.py +65 -17
- contextforge/adapters/codex.py +83 -19
- contextforge/cli.py +25 -0
- contextforge/core/token_analyzer.py +23 -1
- contextforge/mcp_server.py +219 -0
- contextforge/models/session.py +2 -0
- contextforge/tui/widgets/tokens_panel.py +171 -15
- contextforge/utils/tokens.py +12 -4
- {context_forge_cli-0.2.3.dist-info → context_forge_cli-0.3.0.dist-info}/WHEEL +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: context-forge-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Session manager and context bridge for agentic CLI tools
|
|
5
5
|
Project-URL: Homepage, https://github.com/emmver/contextforge
|
|
6
6
|
Project-URL: Repository, https://github.com/emmver/contextforge
|
|
@@ -18,6 +18,7 @@ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
|
18
18
|
Classifier: Topic :: Utilities
|
|
19
19
|
Requires-Python: >=3.13
|
|
20
20
|
Requires-Dist: anthropic>=0.89.0
|
|
21
|
+
Requires-Dist: mcp>=1.9.0
|
|
21
22
|
Requires-Dist: pydantic>=2.12.5
|
|
22
23
|
Requires-Dist: rich>=14.3.3
|
|
23
24
|
Requires-Dist: sqlite-utils>=3.39
|
|
@@ -304,6 +305,111 @@ Press `t` on any session to open the transfer panel. Choose:
|
|
|
304
305
|
- **Preview** — shows the exact shell command (no side effects)
|
|
305
306
|
- **Execute** — builds the bundle and launches the target tool
|
|
306
307
|
|
|
308
|
+
## MCP Server
|
|
309
|
+
|
|
310
|
+
ContextForge ships a [Model Context Protocol](https://modelcontextprotocol.io) server that exposes session data and token analytics to LLM agents.
|
|
311
|
+
|
|
312
|
+
### Setup
|
|
313
|
+
|
|
314
|
+
```bash
|
|
315
|
+
# Run directly (after installation)
|
|
316
|
+
cf-mcp
|
|
317
|
+
|
|
318
|
+
# From source
|
|
319
|
+
uv run python -m contextforge.mcp_server
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Register the server with your agentic tool of choice:
|
|
323
|
+
|
|
324
|
+
#### Claude Code
|
|
325
|
+
|
|
326
|
+
Add to `~/.claude/claude_desktop_config.json` (global) or `.claude/mcp.json` (project):
|
|
327
|
+
```json
|
|
328
|
+
{
|
|
329
|
+
"mcpServers": {
|
|
330
|
+
"contextforge": {
|
|
331
|
+
"command": "cf-mcp"
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
#### Cursor
|
|
338
|
+
|
|
339
|
+
Add to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (project):
|
|
340
|
+
```json
|
|
341
|
+
{
|
|
342
|
+
"mcpServers": {
|
|
343
|
+
"contextforge": {
|
|
344
|
+
"command": "cf-mcp"
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Restart Cursor fully after saving.
|
|
351
|
+
|
|
352
|
+
#### Codex CLI
|
|
353
|
+
|
|
354
|
+
Add to `~/.codex/config.toml` (global) or `.codex/config.toml` (project):
|
|
355
|
+
```toml
|
|
356
|
+
[mcp_servers.contextforge]
|
|
357
|
+
command = "cf-mcp"
|
|
358
|
+
enabled = true
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
#### Gemini CLI
|
|
362
|
+
|
|
363
|
+
Add to `~/.gemini/settings.json` (global) or `.gemini/settings.json` (project):
|
|
364
|
+
```json
|
|
365
|
+
{
|
|
366
|
+
"mcpServers": {
|
|
367
|
+
"contextforge": {
|
|
368
|
+
"command": "cf-mcp"
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
#### Antigravity
|
|
375
|
+
|
|
376
|
+
Add to `mcp_servers.json` in your project root:
|
|
377
|
+
```json
|
|
378
|
+
{
|
|
379
|
+
"servers": [
|
|
380
|
+
{
|
|
381
|
+
"name": "contextforge",
|
|
382
|
+
"transport": "stdio",
|
|
383
|
+
"command": "cf-mcp",
|
|
384
|
+
"enabled": true
|
|
385
|
+
}
|
|
386
|
+
]
|
|
387
|
+
}
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
### Tools
|
|
391
|
+
|
|
392
|
+
| Tool | Description |
|
|
393
|
+
|---|---|
|
|
394
|
+
| `list_sessions` | List indexed sessions; filter by `tool`, limit up to 200 |
|
|
395
|
+
| `get_session` | Full detail for a single session by ID |
|
|
396
|
+
| `get_session_tokens` | Per-turn token breakdown; optionally return only the N heaviest turns |
|
|
397
|
+
| `get_token_analytics` | Aggregated token stats across all sessions for a time window |
|
|
398
|
+
| `get_activity_timeline` | Session count and token usage bucketed over time |
|
|
399
|
+
| `get_top_projects` | Top projects by session count within a time window |
|
|
400
|
+
| `compare_sessions` | Side-by-side token comparison for 2–10 sessions |
|
|
401
|
+
|
|
402
|
+
All tools are **read-only** and accept a `window` parameter of `7d`, `30d`, `6m`, or `1y` where applicable.
|
|
403
|
+
|
|
404
|
+
### Example usage
|
|
405
|
+
|
|
406
|
+
```
|
|
407
|
+
# In Claude Code with the MCP server registered:
|
|
408
|
+
"Show me token usage for all my Claude Code sessions this month"
|
|
409
|
+
"Compare sessions <id1> and <id2> side by side"
|
|
410
|
+
"What are my top 5 projects by session count?"
|
|
411
|
+
```
|
|
412
|
+
|
|
307
413
|
## Troubleshooting
|
|
308
414
|
|
|
309
415
|
### `command not found: cf`
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
contextforge/__init__.py,sha256=L5NnW7jpnDQrAhnqTWSzW-bk2Bp8TXpFK1-SmQXI2QE,104
|
|
2
|
-
contextforge/cli.py,sha256=
|
|
2
|
+
contextforge/cli.py,sha256=EMUHOpLqrW7pkgpmB_H6WQ7smRFZNnSMno2lEoQecGQ,22075
|
|
3
|
+
contextforge/mcp_server.py,sha256=HVXPjVuY1g2BT4LutHWcPcdSb-_P8TTSK-l5rzR7C3w,7267
|
|
3
4
|
contextforge/adapters/__init__.py,sha256=HGIFdC_tNpX_QOPrePwU5cEVBhp3Hsh6Sihwb23HdTk,171
|
|
4
|
-
contextforge/adapters/altimate_code.py,sha256=
|
|
5
|
+
contextforge/adapters/altimate_code.py,sha256=1-Cn12pnJixopAXvUoRGOiyn0xy5TJzuwO3Ux1YlNHs,7857
|
|
5
6
|
contextforge/adapters/base.py,sha256=DeB_Yf0NNbdDLrJgVD3bDubW6S9x6O3b8EuYz4y-9m8,1983
|
|
6
|
-
contextforge/adapters/claude_code.py,sha256=
|
|
7
|
+
contextforge/adapters/claude_code.py,sha256=g2C_aIrLBuQyLeUwI1ddiCmBGN0CEioF6zdypf4dcV8,17156
|
|
7
8
|
contextforge/adapters/claude_desktop.py,sha256=P0fI3Tg00bX8MifV09I1iRPqk-6m5w1kpllCW7Bc2hg,12238
|
|
8
|
-
contextforge/adapters/codex.py,sha256=
|
|
9
|
+
contextforge/adapters/codex.py,sha256=KTh4r9TnFvCUHeMl3aLc1bv6pGq1OQwxf22rM8tTGWs,11403
|
|
9
10
|
contextforge/adapters/gemini.py,sha256=757prqt5_AILxFm53CEfhlqjFKlxVs-YmLBImt8Aj1M,6844
|
|
10
11
|
contextforge/adapters/registry.py,sha256=kbasRJE4WBJ8qw7VGENgjp8NDS3jyIHo8wpGxjk9srs,1177
|
|
11
12
|
contextforge/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
@@ -15,10 +16,10 @@ contextforge/core/db.py,sha256=1d8HQZEwiEvotByCWWgDyOPJT744lh6vb77XURjscco,5205
|
|
|
15
16
|
contextforge/core/injector.py,sha256=_4UeJn-hJfs2-cZcqScpJjEBZCOnKc7odcKDYZTj55I,2670
|
|
16
17
|
contextforge/core/scanner.py,sha256=Wbgp4TJ7SmFf-f6ZY6aKlmL_wUTUCFhuMTEjYYPPUhQ,2487
|
|
17
18
|
contextforge/core/summarizer.py,sha256=4qPzBHfLno1C_nyiTGM_It_0vm3alcsYCZKueMQITlA,4631
|
|
18
|
-
contextforge/core/token_analyzer.py,sha256=
|
|
19
|
+
contextforge/core/token_analyzer.py,sha256=78sgW3lhXcB49n1SEk2dn_qn7dqDIlAlRDzDWimqBdo,3288
|
|
19
20
|
contextforge/models/__init__.py,sha256=abmJPhl_AVrhA526IYpF7NqWTy8MBH5jVSAxwOLzuL4,151
|
|
20
21
|
contextforge/models/config.py,sha256=TbqPaOV_oF3kvVFJDGIADRuJid-9UsGq2QQyb8BP_-g,953
|
|
21
|
-
contextforge/models/session.py,sha256=
|
|
22
|
+
contextforge/models/session.py,sha256=yPndNVzwkW-3mYGTR9ruB-7uKuQnYMO0qi5--nTiqQk,1097
|
|
22
23
|
contextforge/tui/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
23
24
|
contextforge/tui/app.py,sha256=-3ETEJjXAsigVvvB65wpXMBt4L4NiqM4Nj8cS1dBFQ8,7774
|
|
24
25
|
contextforge/tui/styles.tcss,sha256=hiSe3g2OJFME8QAHVHbtvgaB8_BNYfwKvQUmZzbWHHc,945
|
|
@@ -27,12 +28,12 @@ contextforge/tui/widgets/session_detail.py,sha256=xjnbD67Es7du7S3q960rZR31erwkNj
|
|
|
27
28
|
contextforge/tui/widgets/session_table.py,sha256=s-E1j629eY5MgO3IaxOWs03YP6oToaLBXur27h4EV80,9782
|
|
28
29
|
contextforge/tui/widgets/stats_panel.py,sha256=N5rPdrI0_JNsJaggZ30y6eVNGX_eRbHVT40dOm61FiY,12277
|
|
29
30
|
contextforge/tui/widgets/status_bar.py,sha256=uAtgZWA8ah8CynoaEgKqhGYH7MHq6Ih-Nw3_go3-Dkg,3068
|
|
30
|
-
contextforge/tui/widgets/tokens_panel.py,sha256=
|
|
31
|
+
contextforge/tui/widgets/tokens_panel.py,sha256=PHuwZV8jNYOHkvHzXR4YG-cevkMuNQ9SHASQWJ9JUfU,10661
|
|
31
32
|
contextforge/tui/widgets/transfer_panel.py,sha256=Qrgkt1-MpzRHWuwNyu2ErM26IRl0yaAMdbx3XMS7Lqk,4145
|
|
32
33
|
contextforge/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
33
34
|
contextforge/utils/display.py,sha256=bagZgpZ6WGki2THNHA8lfg9ITqM8W0q_M6Os-pgwFHk,3111
|
|
34
|
-
contextforge/utils/tokens.py,sha256=
|
|
35
|
-
context_forge_cli-0.
|
|
36
|
-
context_forge_cli-0.
|
|
37
|
-
context_forge_cli-0.
|
|
38
|
-
context_forge_cli-0.
|
|
35
|
+
contextforge/utils/tokens.py,sha256=u2QzgkcmVX-PEj5j3_0BdUqFuQB1OABBkaNK02vX9gk,571
|
|
36
|
+
context_forge_cli-0.3.0.dist-info/METADATA,sha256=_ibNpcFhBKcIM9ibjQbQEBH0L5yuvrX-ZJkHRklZAjo,15129
|
|
37
|
+
context_forge_cli-0.3.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
38
|
+
context_forge_cli-0.3.0.dist-info/entry_points.txt,sha256=u_-APOF7WFMcw-CJAbhkPCADmZu-J2fTg80_iqBlOyU,82
|
|
39
|
+
context_forge_cli-0.3.0.dist-info/RECORD,,
|
|
@@ -25,6 +25,16 @@ def _count_tokens(text: str) -> int:
|
|
|
25
25
|
return len(text) // 4
|
|
26
26
|
|
|
27
27
|
|
|
28
|
+
def _compute_message_tokens(msg) -> int:
|
|
29
|
+
"""Count tokens across all content: text + tool_call inputs + tool_result outputs."""
|
|
30
|
+
total = _count_tokens(msg.content)
|
|
31
|
+
for tc in msg.tool_calls:
|
|
32
|
+
total += _count_tokens(tc.get("input", ""))
|
|
33
|
+
for tr in msg.tool_results:
|
|
34
|
+
total += _count_tokens(tr.get("output", ""))
|
|
35
|
+
return total
|
|
36
|
+
|
|
37
|
+
|
|
28
38
|
class AltimateCodeAdapter(ToolAdapter):
|
|
29
39
|
tool_name = "altimate_code"
|
|
30
40
|
default_paths = [_OPENCODE_DB]
|
|
@@ -86,8 +96,6 @@ class AltimateCodeAdapter(ToolAdapter):
|
|
|
86
96
|
conn = sqlite3.connect(str(_OPENCODE_DB))
|
|
87
97
|
conn.row_factory = sqlite3.Row
|
|
88
98
|
cur = conn.cursor()
|
|
89
|
-
# Join message + part to get full content
|
|
90
|
-
# Note: altimate-code stores data as JSON in 'data' column
|
|
91
99
|
cur.execute(
|
|
92
100
|
"""
|
|
93
101
|
SELECT
|
|
@@ -95,7 +103,11 @@ class AltimateCodeAdapter(ToolAdapter):
|
|
|
95
103
|
json_extract(m.data, '$.role') as role,
|
|
96
104
|
m.time_created as ts,
|
|
97
105
|
json_extract(p.data, '$.type') as part_type,
|
|
98
|
-
json_extract(p.data, '$.text') as
|
|
106
|
+
json_extract(p.data, '$.text') as text_content,
|
|
107
|
+
json_extract(p.data, '$.tool') as tool_name,
|
|
108
|
+
json_extract(p.data, '$.callID') as call_id,
|
|
109
|
+
json_extract(p.data, '$.state.input') as tool_input,
|
|
110
|
+
json_extract(p.data, '$.state.output') as tool_output
|
|
99
111
|
FROM message m
|
|
100
112
|
LEFT JOIN part p ON p.message_id = m.id
|
|
101
113
|
WHERE m.session_id = ?
|
|
@@ -112,24 +124,29 @@ class AltimateCodeAdapter(ToolAdapter):
|
|
|
112
124
|
current_msg_id: str | None = None
|
|
113
125
|
current_role: str | None = None
|
|
114
126
|
current_parts: list[str] = []
|
|
127
|
+
current_tool_calls: list[dict] = []
|
|
128
|
+
current_tool_results: list[dict] = []
|
|
115
129
|
current_ts: datetime | None = None
|
|
116
130
|
|
|
117
131
|
def flush():
|
|
118
132
|
nonlocal current_msg_id, current_role, current_parts, current_ts
|
|
119
|
-
|
|
133
|
+
nonlocal current_tool_calls, current_tool_results
|
|
134
|
+
if current_role and (current_parts or current_tool_calls):
|
|
120
135
|
content = "\n".join(p for p in current_parts if p)
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
136
|
+
msg = Message(
|
|
137
|
+
role=current_role,
|
|
138
|
+
content=content,
|
|
139
|
+
timestamp=current_ts,
|
|
140
|
+
tool_calls=list(current_tool_calls),
|
|
141
|
+
tool_results=list(current_tool_results),
|
|
142
|
+
)
|
|
143
|
+
msg.token_count = _compute_message_tokens(msg)
|
|
144
|
+
messages.append(msg)
|
|
130
145
|
current_msg_id = None
|
|
131
146
|
current_role = None
|
|
132
147
|
current_parts = []
|
|
148
|
+
current_tool_calls = []
|
|
149
|
+
current_tool_results = []
|
|
133
150
|
current_ts = None
|
|
134
151
|
|
|
135
152
|
for row in rows:
|
|
@@ -138,7 +155,6 @@ class AltimateCodeAdapter(ToolAdapter):
|
|
|
138
155
|
if role not in ("user", "assistant"):
|
|
139
156
|
continue
|
|
140
157
|
|
|
141
|
-
# Flush when message ID changes (new message in the message table)
|
|
142
158
|
if msg_id != current_msg_id:
|
|
143
159
|
flush()
|
|
144
160
|
current_msg_id = msg_id
|
|
@@ -149,28 +165,36 @@ class AltimateCodeAdapter(ToolAdapter):
|
|
|
149
165
|
current_ts = None
|
|
150
166
|
|
|
151
167
|
part_type = row["part_type"] or ""
|
|
152
|
-
content = row["content"] or ""
|
|
153
168
|
|
|
154
|
-
if part_type in ("text", "reasoning")
|
|
155
|
-
|
|
169
|
+
if part_type in ("text", "reasoning"):
|
|
170
|
+
content = row["text_content"] or ""
|
|
171
|
+
if isinstance(content, str) and content:
|
|
156
172
|
try:
|
|
157
173
|
parsed = json.loads(content)
|
|
158
174
|
if isinstance(parsed, dict):
|
|
159
175
|
content = parsed.get("text", str(parsed))
|
|
160
176
|
except (json.JSONDecodeError, TypeError):
|
|
161
177
|
pass
|
|
162
|
-
|
|
178
|
+
content = str(content).strip()
|
|
179
|
+
if content:
|
|
180
|
+
current_parts.append(content)
|
|
181
|
+
|
|
182
|
+
elif part_type == "tool":
|
|
183
|
+
tool_name = row["tool_name"] or "?"
|
|
184
|
+
tool_input = row["tool_input"] or ""
|
|
185
|
+
tool_output = row["tool_output"] or ""
|
|
186
|
+
# input from json_extract is already a JSON string; keep as-is for token counting
|
|
187
|
+
current_tool_calls.append({"name": tool_name, "input": tool_input})
|
|
188
|
+
if tool_output:
|
|
189
|
+
current_tool_results.append({"output": tool_output})
|
|
163
190
|
|
|
164
191
|
flush()
|
|
165
192
|
return messages
|
|
166
193
|
|
|
167
194
|
def _count_session_tokens(self, session_id: str) -> int:
|
|
168
|
-
"""Count total tokens in a session."""
|
|
195
|
+
"""Count total tokens in a session (text + tool inputs + tool outputs)."""
|
|
169
196
|
messages = self.load_messages(session_id)
|
|
170
|
-
|
|
171
|
-
for msg in messages:
|
|
172
|
-
total += msg.token_count or _count_tokens(msg.content)
|
|
173
|
-
return total
|
|
197
|
+
return sum(msg.token_count or _compute_message_tokens(msg) for msg in messages)
|
|
174
198
|
|
|
175
199
|
def build_inject_command(
|
|
176
200
|
self,
|
|
@@ -64,6 +64,16 @@ def _count_tokens(text: str) -> int:
|
|
|
64
64
|
return len(text) // 4
|
|
65
65
|
|
|
66
66
|
|
|
67
|
+
def _compute_message_tokens(msg: Message) -> int:
|
|
68
|
+
"""Count tokens across all content in a message: text + tool_call inputs + tool_result outputs."""
|
|
69
|
+
total = _count_tokens(msg.content)
|
|
70
|
+
for tc in msg.tool_calls:
|
|
71
|
+
total += _count_tokens(tc.get("input", ""))
|
|
72
|
+
for tr in msg.tool_results:
|
|
73
|
+
total += _count_tokens(tr.get("output", ""))
|
|
74
|
+
return total
|
|
75
|
+
|
|
76
|
+
|
|
67
77
|
class ClaudeCodeAdapter(ToolAdapter):
|
|
68
78
|
tool_name = "claude_code"
|
|
69
79
|
default_paths = [_HISTORY_PATH, _PROJECTS_DIR]
|
|
@@ -309,17 +319,6 @@ class ClaudeCodeAdapter(ToolAdapter):
|
|
|
309
319
|
role = msg_data.get("role", entry_type)
|
|
310
320
|
content_raw = msg_data.get("content", "")
|
|
311
321
|
|
|
312
|
-
# Skip entries where content is an array of tool results/tool use
|
|
313
|
-
# (these are Claude's internal messages, not user input or assistant responses)
|
|
314
|
-
if isinstance(content_raw, list):
|
|
315
|
-
# Only include arrays that contain text blocks (actual assistant content)
|
|
316
|
-
if not any(block.get("type") == "text" for block in content_raw if isinstance(block, dict)):
|
|
317
|
-
continue
|
|
318
|
-
|
|
319
|
-
content = _parse_content(content_raw)
|
|
320
|
-
if not content:
|
|
321
|
-
continue
|
|
322
|
-
|
|
323
322
|
ts_str = entry.get("timestamp")
|
|
324
323
|
ts = None
|
|
325
324
|
if ts_str:
|
|
@@ -328,14 +327,63 @@ class ClaudeCodeAdapter(ToolAdapter):
|
|
|
328
327
|
except (ValueError, AttributeError):
|
|
329
328
|
pass
|
|
330
329
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
timestamp=ts,
|
|
336
|
-
token_count=_count_tokens(content),
|
|
330
|
+
if isinstance(content_raw, list):
|
|
331
|
+
has_text = any(
|
|
332
|
+
isinstance(b, dict) and b.get("type") == "text"
|
|
333
|
+
for b in content_raw
|
|
337
334
|
)
|
|
335
|
+
has_tool_result = any(
|
|
336
|
+
isinstance(b, dict) and b.get("type") == "tool_result"
|
|
337
|
+
for b in content_raw
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
# tool_result-only user entries: attribute their outputs to the
|
|
341
|
+
# last assistant turn (the one that issued the tool calls)
|
|
342
|
+
if has_tool_result and not has_text:
|
|
343
|
+
if messages and messages[-1].role == "assistant":
|
|
344
|
+
last = messages[-1]
|
|
345
|
+
for block in content_raw:
|
|
346
|
+
if not isinstance(block, dict) or block.get("type") != "tool_result":
|
|
347
|
+
continue
|
|
348
|
+
inner = block.get("content", "")
|
|
349
|
+
if isinstance(inner, list):
|
|
350
|
+
for item in inner:
|
|
351
|
+
if isinstance(item, dict) and item.get("type") == "text":
|
|
352
|
+
output = item.get("text", "")
|
|
353
|
+
if output:
|
|
354
|
+
last.tool_results.append({"output": output})
|
|
355
|
+
elif isinstance(inner, str) and inner:
|
|
356
|
+
last.tool_results.append({"output": inner})
|
|
357
|
+
# Recompute token_count now that tool_results have been added
|
|
358
|
+
last.token_count = _compute_message_tokens(last)
|
|
359
|
+
continue
|
|
360
|
+
|
|
361
|
+
# Extract tool_call entries from assistant content arrays
|
|
362
|
+
tool_calls: list[dict] = []
|
|
363
|
+
if role != "user":
|
|
364
|
+
for block in content_raw:
|
|
365
|
+
if not isinstance(block, dict) or block.get("type") != "tool_use":
|
|
366
|
+
continue
|
|
367
|
+
try:
|
|
368
|
+
input_str = json.dumps(block.get("input", {}))
|
|
369
|
+
except (TypeError, ValueError):
|
|
370
|
+
input_str = ""
|
|
371
|
+
tool_calls.append({"name": block.get("name", "?"), "input": input_str})
|
|
372
|
+
else:
|
|
373
|
+
tool_calls = []
|
|
374
|
+
|
|
375
|
+
content = _parse_content(content_raw)
|
|
376
|
+
if not content and not tool_calls:
|
|
377
|
+
continue
|
|
378
|
+
|
|
379
|
+
msg = Message(
|
|
380
|
+
role="user" if role == "user" else "assistant",
|
|
381
|
+
content=content,
|
|
382
|
+
timestamp=ts,
|
|
383
|
+
tool_calls=tool_calls,
|
|
338
384
|
)
|
|
385
|
+
msg.token_count = _compute_message_tokens(msg)
|
|
386
|
+
messages.append(msg)
|
|
339
387
|
|
|
340
388
|
return messages
|
|
341
389
|
|
contextforge/adapters/codex.py
CHANGED
|
@@ -41,6 +41,30 @@ def _count_tokens(text: str) -> int:
|
|
|
41
41
|
return len(text) // 4
|
|
42
42
|
|
|
43
43
|
|
|
44
|
+
def _compute_message_tokens(msg: Message) -> int:
|
|
45
|
+
total = _count_tokens(msg.content)
|
|
46
|
+
for tc in msg.tool_calls:
|
|
47
|
+
total += _count_tokens(tc.get("input", ""))
|
|
48
|
+
for tr in msg.tool_results:
|
|
49
|
+
total += _count_tokens(tr.get("output", ""))
|
|
50
|
+
return total
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _extract_function_output(raw_output: str) -> str:
|
|
54
|
+
"""Extract the human-readable output from a Codex function_call_output payload.
|
|
55
|
+
|
|
56
|
+
The `output` field is a JSON string: {"output": "...", "metadata": {...}}.
|
|
57
|
+
Returns the inner "output" string, or the raw string on parse failure.
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
parsed = json.loads(raw_output)
|
|
61
|
+
if isinstance(parsed, dict):
|
|
62
|
+
return str(parsed.get("output", raw_output))
|
|
63
|
+
except (json.JSONDecodeError, TypeError):
|
|
64
|
+
pass
|
|
65
|
+
return raw_output
|
|
66
|
+
|
|
67
|
+
|
|
44
68
|
class CodexAdapter(ToolAdapter):
|
|
45
69
|
tool_name = "codex"
|
|
46
70
|
default_paths = [_CODEX_DB, _CODEX_SESSIONS_DIR]
|
|
@@ -87,7 +111,10 @@ class CodexAdapter(ToolAdapter):
|
|
|
87
111
|
cwd=row["cwd"] or None,
|
|
88
112
|
created_at=created,
|
|
89
113
|
updated_at=updated,
|
|
90
|
-
|
|
114
|
+
# tokens_used in state_5.sqlite is cumulative API billing spend
|
|
115
|
+
# (re-sends full context each call), NOT conversation footprint.
|
|
116
|
+
# Leave token_count=None so cf refresh computes it from rollout content.
|
|
117
|
+
token_count=None,
|
|
91
118
|
raw_path=row["rollout_path"] or None,
|
|
92
119
|
status="unknown",
|
|
93
120
|
)
|
|
@@ -184,6 +211,31 @@ class CodexAdapter(ToolAdapter):
|
|
|
184
211
|
|
|
185
212
|
def _parse_rollout(self, path: Path) -> list[Message]:
|
|
186
213
|
messages: list[Message] = []
|
|
214
|
+
|
|
215
|
+
# Pending tool data for the current assistant turn (buffered until
|
|
216
|
+
# we see the agent_message that closes the turn).
|
|
217
|
+
pending_tool_calls: list[dict] = []
|
|
218
|
+
pending_tool_results: list[dict] = []
|
|
219
|
+
|
|
220
|
+
def flush_pending_assistant(text: str = "", ts=None):
|
|
221
|
+
"""Emit an assistant message with any buffered tool data."""
|
|
222
|
+
nonlocal pending_tool_calls, pending_tool_results
|
|
223
|
+
if not text and not pending_tool_calls:
|
|
224
|
+
pending_tool_calls = []
|
|
225
|
+
pending_tool_results = []
|
|
226
|
+
return
|
|
227
|
+
msg = Message(
|
|
228
|
+
role="assistant",
|
|
229
|
+
content=text,
|
|
230
|
+
timestamp=ts,
|
|
231
|
+
tool_calls=list(pending_tool_calls),
|
|
232
|
+
tool_results=list(pending_tool_results),
|
|
233
|
+
)
|
|
234
|
+
msg.token_count = _compute_message_tokens(msg)
|
|
235
|
+
messages.append(msg)
|
|
236
|
+
pending_tool_calls = []
|
|
237
|
+
pending_tool_results = []
|
|
238
|
+
|
|
187
239
|
with path.open() as f:
|
|
188
240
|
for line in f:
|
|
189
241
|
line = line.strip()
|
|
@@ -196,30 +248,42 @@ class CodexAdapter(ToolAdapter):
|
|
|
196
248
|
|
|
197
249
|
entry_type = entry.get("type", "")
|
|
198
250
|
payload = entry.get("payload", {})
|
|
251
|
+
ts_str = entry.get("timestamp")
|
|
252
|
+
ts = None
|
|
253
|
+
if ts_str:
|
|
254
|
+
try:
|
|
255
|
+
ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
|
256
|
+
except (ValueError, AttributeError):
|
|
257
|
+
pass
|
|
199
258
|
|
|
200
259
|
if entry_type == "event_msg":
|
|
201
260
|
msg_type = payload.get("type", "")
|
|
202
|
-
# user_message / agent_message are the clean human-facing turns.
|
|
203
|
-
# response_item entries are skipped — they contain system context
|
|
204
|
-
# injections (AGENTS.md, environment_context) and duplicate content.
|
|
205
261
|
if msg_type == "user_message":
|
|
206
|
-
|
|
262
|
+
# New user turn — flush any orphaned tool data first
|
|
263
|
+
flush_pending_assistant()
|
|
264
|
+
text = str(payload.get("message", ""))
|
|
207
265
|
if text:
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
content=text_str,
|
|
212
|
-
token_count=_count_tokens(text_str),
|
|
213
|
-
))
|
|
266
|
+
msg = Message(role="user", content=text, timestamp=ts)
|
|
267
|
+
msg.token_count = _count_tokens(text)
|
|
268
|
+
messages.append(msg)
|
|
214
269
|
elif msg_type == "agent_message":
|
|
215
|
-
text = payload.get("message", "")
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
270
|
+
text = str(payload.get("message", ""))
|
|
271
|
+
flush_pending_assistant(text=text, ts=ts)
|
|
272
|
+
|
|
273
|
+
elif entry_type == "response_item":
|
|
274
|
+
item_type = payload.get("type", "")
|
|
275
|
+
if item_type == "function_call":
|
|
276
|
+
name = payload.get("name", "?")
|
|
277
|
+
arguments = payload.get("arguments", "")
|
|
278
|
+
pending_tool_calls.append({"name": name, "input": arguments})
|
|
279
|
+
elif item_type == "function_call_output":
|
|
280
|
+
raw_output = payload.get("output", "")
|
|
281
|
+
output = _extract_function_output(raw_output)
|
|
282
|
+
if output:
|
|
283
|
+
pending_tool_results.append({"output": output})
|
|
284
|
+
|
|
285
|
+
# Flush any trailing tool data (e.g. incomplete / aborted turn)
|
|
286
|
+
flush_pending_assistant()
|
|
223
287
|
|
|
224
288
|
return messages
|
|
225
289
|
|
contextforge/cli.py
CHANGED
|
@@ -578,6 +578,31 @@ def refresh(
|
|
|
578
578
|
)
|
|
579
579
|
|
|
580
580
|
|
|
581
|
+
# ---------------------------------------------------------------------------
|
|
582
|
+
# cf mcp
|
|
583
|
+
# ---------------------------------------------------------------------------
|
|
584
|
+
|
|
585
|
+
@app.command(name="mcp")
|
|
586
|
+
def mcp_server():
|
|
587
|
+
"""Start the ContextForge MCP server (stdio transport).
|
|
588
|
+
|
|
589
|
+
Exposes session data and token analysis as MCP tools so LLM agents
|
|
590
|
+
can query token usage, list sessions, and pull analytics directly.
|
|
591
|
+
|
|
592
|
+
Add to your Claude Code MCP config:
|
|
593
|
+
{
|
|
594
|
+
"mcpServers": {
|
|
595
|
+
"contextforge": {
|
|
596
|
+
"command": "cf",
|
|
597
|
+
"args": ["mcp"]
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
"""
|
|
602
|
+
from contextforge.mcp_server import main as _run_mcp
|
|
603
|
+
_run_mcp()
|
|
604
|
+
|
|
605
|
+
|
|
581
606
|
# ---------------------------------------------------------------------------
|
|
582
607
|
# cf dashboard
|
|
583
608
|
# ---------------------------------------------------------------------------
|
|
@@ -18,6 +18,9 @@ class TurnStats:
|
|
|
18
18
|
tokens: int
|
|
19
19
|
content_preview: str # first 80 chars, single line
|
|
20
20
|
cumulative: int = 0
|
|
21
|
+
text_tokens: int = 0
|
|
22
|
+
tool_call_tokens: int = 0
|
|
23
|
+
tool_result_tokens: int = 0
|
|
21
24
|
|
|
22
25
|
|
|
23
26
|
@dataclass
|
|
@@ -26,11 +29,20 @@ class SessionTokenReport:
|
|
|
26
29
|
tool: str
|
|
27
30
|
title: str
|
|
28
31
|
turns: list[TurnStats] = field(default_factory=list)
|
|
32
|
+
has_tool_data: bool = False
|
|
29
33
|
|
|
30
34
|
@property
|
|
31
35
|
def total(self) -> int:
|
|
32
36
|
return sum(t.tokens for t in self.turns)
|
|
33
37
|
|
|
38
|
+
@property
|
|
39
|
+
def tool_call_total(self) -> int:
|
|
40
|
+
return sum(t.tool_call_tokens for t in self.turns)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def tool_result_total(self) -> int:
|
|
44
|
+
return sum(t.tool_result_tokens for t in self.turns)
|
|
45
|
+
|
|
34
46
|
@property
|
|
35
47
|
def user_total(self) -> int:
|
|
36
48
|
return sum(t.tokens for t in self.turns if t.role == "user")
|
|
@@ -71,8 +83,14 @@ def analyze_tokens(
|
|
|
71
83
|
|
|
72
84
|
turns: list[TurnStats] = []
|
|
73
85
|
cumulative = 0
|
|
86
|
+
has_tool_data = False
|
|
74
87
|
for i, msg in enumerate(messages, 1):
|
|
75
|
-
|
|
88
|
+
text_tokens = count_tokens(msg.content)
|
|
89
|
+
tool_call_tokens = sum(count_tokens(tc.get("input", "")) for tc in msg.tool_calls)
|
|
90
|
+
tool_result_tokens = sum(count_tokens(tr.get("output", "")) for tr in msg.tool_results)
|
|
91
|
+
tokens = text_tokens + tool_call_tokens + tool_result_tokens
|
|
92
|
+
if tool_call_tokens or tool_result_tokens:
|
|
93
|
+
has_tool_data = True
|
|
76
94
|
cumulative += tokens
|
|
77
95
|
preview = msg.content.replace("\n", " ").strip()[:80]
|
|
78
96
|
turns.append(TurnStats(
|
|
@@ -81,6 +99,9 @@ def analyze_tokens(
|
|
|
81
99
|
tokens=tokens,
|
|
82
100
|
content_preview=preview,
|
|
83
101
|
cumulative=cumulative,
|
|
102
|
+
text_tokens=text_tokens,
|
|
103
|
+
tool_call_tokens=tool_call_tokens,
|
|
104
|
+
tool_result_tokens=tool_result_tokens,
|
|
84
105
|
))
|
|
85
106
|
|
|
86
107
|
return SessionTokenReport(
|
|
@@ -88,4 +109,5 @@ def analyze_tokens(
|
|
|
88
109
|
tool=row["tool"],
|
|
89
110
|
title=row.get("title") or session_id[:16],
|
|
90
111
|
turns=turns,
|
|
112
|
+
has_tool_data=has_tool_data,
|
|
91
113
|
)
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""ContextForge MCP server — exposes token analysis and session tools to LLM agents."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from mcp.server.fastmcp import FastMCP
|
|
7
|
+
|
|
8
|
+
from contextforge.core import analytics as analytics_module
|
|
9
|
+
from contextforge.core import db as db_module
|
|
10
|
+
from contextforge.core.token_analyzer import analyze_tokens
|
|
11
|
+
from contextforge.models.config import ForgeConfig
|
|
12
|
+
|
|
13
|
+
mcp = FastMCP(
|
|
14
|
+
"contextforge",
|
|
15
|
+
instructions=(
|
|
16
|
+
"ContextForge provides read-only access to indexed agentic-tool sessions "
|
|
17
|
+
"and their token usage. Start with list_sessions to discover what's available, "
|
|
18
|
+
"then use get_session_tokens for per-turn breakdown or get_token_analytics "
|
|
19
|
+
"for aggregated stats across all tools."
|
|
20
|
+
),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _db():
|
|
25
|
+
cfg = ForgeConfig()
|
|
26
|
+
return db_module.get_db(cfg.db_path)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@mcp.tool()
|
|
30
|
+
def list_sessions(tool: str | None = None, limit: int = 50) -> list[dict[str, Any]]:
|
|
31
|
+
"""List indexed sessions with basic token summaries.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
tool: Filter by tool name (claude_code, codex, gemini, etc.). Omit for all tools.
|
|
35
|
+
limit: Maximum sessions to return (default 50, max 200).
|
|
36
|
+
"""
|
|
37
|
+
db = _db()
|
|
38
|
+
rows = db_module.get_sessions(db, tool=tool, limit=min(limit, 200))
|
|
39
|
+
return [
|
|
40
|
+
{
|
|
41
|
+
"id": r["id"],
|
|
42
|
+
"tool": r["tool"],
|
|
43
|
+
"title": r.get("title") or "",
|
|
44
|
+
"cwd": r.get("cwd") or "",
|
|
45
|
+
"token_count": r.get("token_count") or 0,
|
|
46
|
+
"updated_at": r.get("updated_at"),
|
|
47
|
+
"status": r.get("status") or "",
|
|
48
|
+
"summary": r.get("summary") or "",
|
|
49
|
+
}
|
|
50
|
+
for r in rows
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@mcp.tool()
|
|
55
|
+
def get_session(session_id: str) -> dict[str, Any] | None:
|
|
56
|
+
"""Get full details for a single session by ID.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
session_id: The exact session ID from list_sessions.
|
|
60
|
+
"""
|
|
61
|
+
db = _db()
|
|
62
|
+
row = db_module.get_session(db, session_id)
|
|
63
|
+
return dict(row) if row else None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@mcp.tool()
|
|
67
|
+
def get_session_tokens(session_id: str, top: int = 0) -> dict[str, Any] | None:
|
|
68
|
+
"""Get per-turn token breakdown for a session.
|
|
69
|
+
|
|
70
|
+
Returns total tokens, per-role totals and averages, and a list of turns
|
|
71
|
+
with individual counts for text, tool calls, and tool results.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
session_id: The session ID.
|
|
75
|
+
top: If > 0, return only the N heaviest turns (sorted by turn number).
|
|
76
|
+
"""
|
|
77
|
+
db = _db()
|
|
78
|
+
report = analyze_tokens(db, session_id)
|
|
79
|
+
if report is None:
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
turns = report.turns
|
|
83
|
+
if top > 0:
|
|
84
|
+
turns = sorted(turns, key=lambda t: t.tokens, reverse=True)[:top]
|
|
85
|
+
turns = sorted(turns, key=lambda t: t.turn)
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
"session_id": report.session_id,
|
|
89
|
+
"tool": report.tool,
|
|
90
|
+
"title": report.title,
|
|
91
|
+
"total_tokens": report.total,
|
|
92
|
+
"user_tokens": report.user_total,
|
|
93
|
+
"assistant_tokens": report.assistant_total,
|
|
94
|
+
"tool_call_tokens": report.tool_call_total,
|
|
95
|
+
"tool_result_tokens": report.tool_result_total,
|
|
96
|
+
"turn_count": report.turn_count,
|
|
97
|
+
"avg_user_tokens": round(report.avg_user, 1),
|
|
98
|
+
"avg_assistant_tokens": round(report.avg_assistant, 1),
|
|
99
|
+
"has_tool_data": report.has_tool_data,
|
|
100
|
+
"heaviest_turn": (
|
|
101
|
+
{
|
|
102
|
+
"turn": report.max_turn.turn,
|
|
103
|
+
"role": report.max_turn.role,
|
|
104
|
+
"tokens": report.max_turn.tokens,
|
|
105
|
+
"preview": report.max_turn.content_preview,
|
|
106
|
+
}
|
|
107
|
+
if report.max_turn
|
|
108
|
+
else None
|
|
109
|
+
),
|
|
110
|
+
"turns": [
|
|
111
|
+
{
|
|
112
|
+
"turn": t.turn,
|
|
113
|
+
"role": t.role,
|
|
114
|
+
"tokens": t.tokens,
|
|
115
|
+
"text_tokens": t.text_tokens,
|
|
116
|
+
"tool_call_tokens": t.tool_call_tokens,
|
|
117
|
+
"tool_result_tokens": t.tool_result_tokens,
|
|
118
|
+
"cumulative": t.cumulative,
|
|
119
|
+
"preview": t.content_preview,
|
|
120
|
+
}
|
|
121
|
+
for t in turns
|
|
122
|
+
],
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@mcp.tool()
|
|
127
|
+
def get_token_analytics(window: str = "30d") -> dict[str, Any]:
|
|
128
|
+
"""Get aggregated token usage statistics across all sessions.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
window: Time window — one of "7d", "30d", "6m", "1y" (default "30d").
|
|
132
|
+
"""
|
|
133
|
+
db = _db()
|
|
134
|
+
overview = analytics_module.get_overview(db, window=window)
|
|
135
|
+
return {
|
|
136
|
+
"window": window,
|
|
137
|
+
"total_sessions": overview.total_sessions,
|
|
138
|
+
"total_tokens": overview.total_tokens,
|
|
139
|
+
"active_tools": overview.active_tools,
|
|
140
|
+
"session_count_by_tool": overview.session_count_by_tool,
|
|
141
|
+
"token_sum_by_tool": overview.token_sum_by_tool,
|
|
142
|
+
"date_range": {
|
|
143
|
+
"start": overview.date_range[0].isoformat() if overview.date_range else None,
|
|
144
|
+
"end": overview.date_range[1].isoformat() if overview.date_range else None,
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@mcp.tool()
|
|
150
|
+
def get_activity_timeline(window: str = "30d") -> list[dict[str, Any]]:
|
|
151
|
+
"""Get session count and token usage bucketed over time.
|
|
152
|
+
|
|
153
|
+
Returns one bucket per day (7d/30d), per week (6m), or per month (1y).
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
window: Time window — one of "7d", "30d", "6m", "1y" (default "30d").
|
|
157
|
+
"""
|
|
158
|
+
db = _db()
|
|
159
|
+
buckets = analytics_module.get_activity_over_time(db, window=window)
|
|
160
|
+
return [
|
|
161
|
+
{"label": b.label, "sessions": b.count, "tokens": b.tokens}
|
|
162
|
+
for b in buckets
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@mcp.tool()
|
|
167
|
+
def get_top_projects(window: str = "30d", top_n: int = 5) -> list[dict[str, Any]]:
|
|
168
|
+
"""Get top projects by session count within a time window.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
window: Time window — one of "7d", "30d", "6m", "1y" (default "30d").
|
|
172
|
+
top_n: Number of top projects to return (default 5).
|
|
173
|
+
"""
|
|
174
|
+
db = _db()
|
|
175
|
+
projects = analytics_module.get_top_projects(db, window=window, top_n=top_n)
|
|
176
|
+
return [{"project": p.project, "session_count": p.count} for p in projects]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@mcp.tool()
|
|
180
|
+
def compare_sessions(session_ids: list[str]) -> list[dict[str, Any]]:
|
|
181
|
+
"""Compare token usage across multiple sessions side-by-side.
|
|
182
|
+
|
|
183
|
+
Returns one entry per session with key metrics, sorted by total tokens descending.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
session_ids: List of session IDs to compare (2–10 sessions recommended).
|
|
187
|
+
"""
|
|
188
|
+
db = _db()
|
|
189
|
+
results: list[dict[str, Any]] = []
|
|
190
|
+
for sid in session_ids:
|
|
191
|
+
report = analyze_tokens(db, sid)
|
|
192
|
+
if report is None:
|
|
193
|
+
results.append({"session_id": sid, "error": "not found"})
|
|
194
|
+
continue
|
|
195
|
+
results.append({
|
|
196
|
+
"session_id": sid,
|
|
197
|
+
"tool": report.tool,
|
|
198
|
+
"title": report.title,
|
|
199
|
+
"total_tokens": report.total,
|
|
200
|
+
"turn_count": report.turn_count,
|
|
201
|
+
"user_tokens": report.user_total,
|
|
202
|
+
"assistant_tokens": report.assistant_total,
|
|
203
|
+
"tool_call_tokens": report.tool_call_total,
|
|
204
|
+
"tool_result_tokens": report.tool_result_total,
|
|
205
|
+
"avg_tokens_per_turn": (
|
|
206
|
+
round(report.total / report.turn_count, 1) if report.turn_count else 0
|
|
207
|
+
),
|
|
208
|
+
"heaviest_turn_tokens": report.max_turn.tokens if report.max_turn else 0,
|
|
209
|
+
})
|
|
210
|
+
results.sort(key=lambda r: r.get("total_tokens", 0), reverse=True)
|
|
211
|
+
return results
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def main() -> None:
|
|
215
|
+
mcp.run()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
if __name__ == "__main__":
|
|
219
|
+
main()
|
contextforge/models/session.py
CHANGED
|
@@ -11,6 +11,8 @@ class Message(BaseModel):
|
|
|
11
11
|
content: str
|
|
12
12
|
timestamp: datetime | None = None
|
|
13
13
|
token_count: int | None = None
|
|
14
|
+
tool_calls: list[dict] = Field(default_factory=list) # [{name, input}] on assistant turns
|
|
15
|
+
tool_results: list[dict] = Field(default_factory=list) # [{output}] attributed to the assistant turn that invoked them
|
|
14
16
|
|
|
15
17
|
|
|
16
18
|
class Session(BaseModel):
|
|
@@ -1,13 +1,127 @@
|
|
|
1
1
|
"""TokensPanel — modal screen showing per-turn token analysis for a session."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import json
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
|
|
6
7
|
from textual.app import ComposeResult
|
|
7
8
|
from textual.binding import Binding
|
|
8
|
-
from textual.containers import Vertical
|
|
9
|
+
from textual.containers import ScrollableContainer, Vertical
|
|
9
10
|
from textual.screen import ModalScreen
|
|
10
|
-
from textual.widgets import DataTable,
|
|
11
|
+
from textual.widgets import DataTable, Static
|
|
12
|
+
|
|
13
|
+
from contextforge.models.session import Message
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TurnDetailPanel(ModalScreen):
|
|
17
|
+
"""Modal showing the full content of a single turn."""
|
|
18
|
+
|
|
19
|
+
BINDINGS = [
|
|
20
|
+
Binding("escape,q", "dismiss(None)", "Close"),
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
DEFAULT_CSS = """
|
|
24
|
+
TurnDetailPanel {
|
|
25
|
+
align: center middle;
|
|
26
|
+
}
|
|
27
|
+
TurnDetailPanel > Vertical {
|
|
28
|
+
width: 92%;
|
|
29
|
+
height: 88%;
|
|
30
|
+
border: thick $accent;
|
|
31
|
+
background: $surface;
|
|
32
|
+
padding: 0 1;
|
|
33
|
+
}
|
|
34
|
+
TurnDetailPanel #turn-header {
|
|
35
|
+
height: auto;
|
|
36
|
+
padding: 1 1 0 1;
|
|
37
|
+
border-bottom: solid $primary-background-lighten-1;
|
|
38
|
+
margin-bottom: 1;
|
|
39
|
+
}
|
|
40
|
+
TurnDetailPanel ScrollableContainer {
|
|
41
|
+
height: 1fr;
|
|
42
|
+
padding: 0 1;
|
|
43
|
+
}
|
|
44
|
+
TurnDetailPanel .section-label {
|
|
45
|
+
color: $text-muted;
|
|
46
|
+
text-style: bold;
|
|
47
|
+
margin-top: 1;
|
|
48
|
+
}
|
|
49
|
+
TurnDetailPanel .content-block {
|
|
50
|
+
color: $text;
|
|
51
|
+
margin-bottom: 1;
|
|
52
|
+
}
|
|
53
|
+
TurnDetailPanel .tool-name {
|
|
54
|
+
color: $warning;
|
|
55
|
+
text-style: bold;
|
|
56
|
+
}
|
|
57
|
+
TurnDetailPanel .tool-input {
|
|
58
|
+
color: $text-muted;
|
|
59
|
+
margin-left: 2;
|
|
60
|
+
}
|
|
61
|
+
TurnDetailPanel .tool-output {
|
|
62
|
+
color: $success;
|
|
63
|
+
margin-left: 2;
|
|
64
|
+
}
|
|
65
|
+
TurnDetailPanel #turn-footer {
|
|
66
|
+
height: 1;
|
|
67
|
+
color: $text-disabled;
|
|
68
|
+
text-align: center;
|
|
69
|
+
}
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, turn_num: int, msg: Message) -> None:
|
|
73
|
+
super().__init__()
|
|
74
|
+
self._turn_num = turn_num
|
|
75
|
+
self._msg = msg
|
|
76
|
+
|
|
77
|
+
def compose(self) -> ComposeResult:
|
|
78
|
+
with Vertical():
|
|
79
|
+
yield Static("", id="turn-header")
|
|
80
|
+
with ScrollableContainer():
|
|
81
|
+
yield Static("", id="turn-body")
|
|
82
|
+
yield Static("ESC / q — close", id="turn-footer")
|
|
83
|
+
|
|
84
|
+
def on_mount(self) -> None:
|
|
85
|
+
msg = self._msg
|
|
86
|
+
role_color = "cyan" if msg.role == "user" else "green"
|
|
87
|
+
|
|
88
|
+
self.query_one("#turn-header", Static).update(
|
|
89
|
+
f"[bold]Turn #{self._turn_num}[/bold] "
|
|
90
|
+
f"[{role_color}]{msg.role}[/{role_color}] "
|
|
91
|
+
f"[dim]{(msg.token_count or 0):,} tokens[/dim]"
|
|
92
|
+
+ (f" [yellow]{len(msg.tool_calls)} call(s)[/yellow]" if msg.tool_calls else "")
|
|
93
|
+
+ (f" [magenta]{len(msg.tool_results)} result(s)[/magenta]" if msg.tool_results else "")
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
parts: list[str] = []
|
|
97
|
+
|
|
98
|
+
# ── Text content ────────────────────────────────────────────────────
|
|
99
|
+
if msg.content:
|
|
100
|
+
parts.append("[bold dim]── Content ──[/bold dim]")
|
|
101
|
+
parts.append(msg.content)
|
|
102
|
+
|
|
103
|
+
# ── Tool calls ──────────────────────────────────────────────────────
|
|
104
|
+
if msg.tool_calls:
|
|
105
|
+
parts.append("\n[bold dim]── Tool Calls ──[/bold dim]")
|
|
106
|
+
for i, tc in enumerate(msg.tool_calls, 1):
|
|
107
|
+
name = tc.get("name", "?")
|
|
108
|
+
raw_input = tc.get("input", "")
|
|
109
|
+
try:
|
|
110
|
+
formatted = json.dumps(json.loads(raw_input), indent=2)
|
|
111
|
+
except (json.JSONDecodeError, TypeError):
|
|
112
|
+
formatted = raw_input
|
|
113
|
+
parts.append(f"[bold yellow]{i}. {name}[/bold yellow]")
|
|
114
|
+
parts.append(f"[dim]{formatted}[/dim]")
|
|
115
|
+
|
|
116
|
+
# ── Tool results ────────────────────────────────────────────────────
|
|
117
|
+
if msg.tool_results:
|
|
118
|
+
parts.append("\n[bold dim]── Tool Results ──[/bold dim]")
|
|
119
|
+
for i, tr in enumerate(msg.tool_results, 1):
|
|
120
|
+
output = tr.get("output", "")
|
|
121
|
+
parts.append(f"[bold green]Result {i}[/bold green]")
|
|
122
|
+
parts.append(f"[dim]{output}[/dim]")
|
|
123
|
+
|
|
124
|
+
self.query_one("#turn-body", Static).update("\n".join(parts))
|
|
11
125
|
|
|
12
126
|
|
|
13
127
|
class TokensPanel(ModalScreen):
|
|
@@ -57,24 +171,40 @@ class TokensPanel(ModalScreen):
|
|
|
57
171
|
super().__init__()
|
|
58
172
|
self._session_id = session_id
|
|
59
173
|
self._db_path = db_path
|
|
174
|
+
self._messages: list[Message] = []
|
|
60
175
|
|
|
61
176
|
def compose(self) -> ComposeResult:
|
|
62
177
|
with Vertical():
|
|
63
178
|
yield Static("", id="tokens-header")
|
|
64
179
|
yield Static("", id="tokens-stats")
|
|
65
|
-
yield DataTable(id="tokens-table", show_cursor=True, zebra_stripes=True)
|
|
66
|
-
yield Static("ESC / q — close", id="tokens-footer")
|
|
180
|
+
yield DataTable(id="tokens-table", show_cursor=True, zebra_stripes=True, cursor_type="row")
|
|
181
|
+
yield Static("ESC / q — close │ Enter — view turn", id="tokens-footer")
|
|
67
182
|
|
|
68
183
|
def on_mount(self) -> None:
|
|
69
184
|
self._load()
|
|
70
185
|
|
|
186
|
+
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
187
|
+
row_idx = event.cursor_row
|
|
188
|
+
if 0 <= row_idx < len(self._messages):
|
|
189
|
+
self.app.push_screen(TurnDetailPanel(row_idx + 1, self._messages[row_idx]))
|
|
190
|
+
|
|
71
191
|
def _load(self) -> None:
|
|
72
|
-
from contextforge.
|
|
192
|
+
from contextforge.adapters.registry import get_adapter
|
|
193
|
+
from contextforge.core.db import get_db, get_session
|
|
73
194
|
from contextforge.core.token_analyzer import analyze_tokens
|
|
74
195
|
|
|
75
196
|
db = get_db(self._db_path)
|
|
76
197
|
report = analyze_tokens(db, self._session_id)
|
|
77
198
|
|
|
199
|
+
# Load messages so TurnDetailPanel can show full content
|
|
200
|
+
row = get_session(db, self._session_id)
|
|
201
|
+
if row is not None:
|
|
202
|
+
try:
|
|
203
|
+
adapter = get_adapter(row["tool"])
|
|
204
|
+
self._messages = adapter.load_messages(self._session_id)
|
|
205
|
+
except Exception:
|
|
206
|
+
self._messages = []
|
|
207
|
+
|
|
78
208
|
header = self.query_one("#tokens-header", Static)
|
|
79
209
|
stats = self.query_one("#tokens-stats", Static)
|
|
80
210
|
table = self.query_one("#tokens-table", DataTable)
|
|
@@ -92,18 +222,28 @@ class TokensPanel(ModalScreen):
|
|
|
92
222
|
|
|
93
223
|
# ── Stats bar ───────────────────────────────────────────────────────
|
|
94
224
|
max_t = report.max_turn
|
|
225
|
+
tool_summary = ""
|
|
226
|
+
if report.has_tool_data:
|
|
227
|
+
tool_summary = (
|
|
228
|
+
f" │ [yellow]Calls {report.tool_call_total:,}[/yellow]"
|
|
229
|
+
f" [magenta]Results {report.tool_result_total:,}[/magenta]"
|
|
230
|
+
)
|
|
95
231
|
stats.update(
|
|
96
232
|
f"Total [bold]{report.total:,}[/bold] tokens │ "
|
|
97
233
|
f"[cyan]User {report.user_total:,}[/cyan] (avg {report.avg_user:,.0f}) │ "
|
|
98
234
|
f"[green]Asst {report.assistant_total:,}[/green] (avg {report.avg_assistant:,.0f}) │ "
|
|
99
235
|
f"{report.turn_count} turns"
|
|
236
|
+
+ tool_summary
|
|
100
237
|
+ (f" │ Heaviest: turn #{max_t.turn} "
|
|
101
238
|
f"[bold]{max_t.tokens:,}[/bold] tok [{max_t.role}]"
|
|
102
239
|
if max_t else "")
|
|
103
240
|
)
|
|
104
241
|
|
|
105
242
|
# ── Table ────────────────────────────────────────────────────────────
|
|
106
|
-
|
|
243
|
+
if report.has_tool_data:
|
|
244
|
+
table.add_columns("#", "Role", "Tokens", "Text", "Calls", "Results", "Cumul.", "Bar", "Preview")
|
|
245
|
+
else:
|
|
246
|
+
table.add_columns("#", "Role", "Tokens", "Cumul.", "Bar", "Preview")
|
|
107
247
|
|
|
108
248
|
max_tokens = max(t.tokens for t in report.turns)
|
|
109
249
|
|
|
@@ -120,15 +260,31 @@ class TokensPanel(ModalScreen):
|
|
|
120
260
|
role_str = f"[dim]{t.role}[/dim]"
|
|
121
261
|
bar_str = f"[dim]{'█' * bar_len}[/dim]"
|
|
122
262
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
263
|
+
if report.has_tool_data:
|
|
264
|
+
calls_str = f"[yellow]{t.tool_call_tokens:,}[/yellow]" if t.tool_call_tokens else "[dim]—[/dim]"
|
|
265
|
+
results_str = f"[magenta]{t.tool_result_tokens:,}[/magenta]" if t.tool_result_tokens else "[dim]—[/dim]"
|
|
266
|
+
table.add_row(
|
|
267
|
+
str(t.turn),
|
|
268
|
+
role_str,
|
|
269
|
+
f"{t.tokens:,}",
|
|
270
|
+
f"{t.text_tokens:,}",
|
|
271
|
+
calls_str,
|
|
272
|
+
results_str,
|
|
273
|
+
f"{t.cumulative:,}",
|
|
274
|
+
bar_str,
|
|
275
|
+
t.content_preview,
|
|
276
|
+
)
|
|
277
|
+
else:
|
|
278
|
+
table.add_row(
|
|
279
|
+
str(t.turn),
|
|
280
|
+
role_str,
|
|
281
|
+
f"{t.tokens:,}",
|
|
282
|
+
f"{t.cumulative:,}",
|
|
283
|
+
bar_str,
|
|
284
|
+
t.content_preview,
|
|
285
|
+
)
|
|
131
286
|
|
|
132
|
-
# Highlight the heaviest row
|
|
287
|
+
# Highlight the heaviest row and ensure the table has focus
|
|
133
288
|
if max_t:
|
|
134
289
|
table.move_cursor(row=max_t.turn - 1)
|
|
290
|
+
table.focus()
|
contextforge/utils/tokens.py
CHANGED
|
@@ -3,15 +3,23 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
import tiktoken
|
|
5
5
|
|
|
6
|
-
_ENCODING =
|
|
6
|
+
_ENCODING = None
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _get_encoding() -> tiktoken.Encoding:
|
|
10
|
+
global _ENCODING
|
|
11
|
+
if _ENCODING is None:
|
|
12
|
+
_ENCODING = tiktoken.get_encoding("cl100k_base")
|
|
13
|
+
return _ENCODING
|
|
7
14
|
|
|
8
15
|
|
|
9
16
|
def count_tokens(text: str) -> int:
|
|
10
|
-
return len(
|
|
17
|
+
return len(_get_encoding().encode(text))
|
|
11
18
|
|
|
12
19
|
|
|
13
20
|
def truncate_to_budget(text: str, budget: int) -> str:
|
|
14
|
-
|
|
21
|
+
enc = _get_encoding()
|
|
22
|
+
tokens = enc.encode(text)
|
|
15
23
|
if len(tokens) <= budget:
|
|
16
24
|
return text
|
|
17
|
-
return
|
|
25
|
+
return enc.decode(tokens[:budget])
|
|
File without changes
|