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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: context-forge-cli
3
- Version: 0.2.3
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=cgCWfhXMdOdseiNk5hbd_jlTxQfLLY3y8JUDTqwE2rM,21393
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=YOPtPAWXJgbBMnujHR_ho9pWNNakKqaOQSuKziEpdWc,6577
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=lTeGGYlQkEayJAaeeJfddWaktAbe0k3sW2iYqW8iTpk,14671
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=yj0Sdzo8yrkQHSqfCxq-NxXuaiaUOSlnZOTIAOQw-gU,8776
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=HKktdSye1Jy8XJ_LktLfVBg2ElYTsaEy9mZb_1vDnTw,2398
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=Q3bT3j6r4LdKgV0Yq7DH1RUJPIu1KF6cyJG3iR7qPss,878
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=xsLB-by3ta1zKts9lGSUConAKMHcpVDOyq6BD-utTQk,4669
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=UVGz-vvmCIxMgnRPn1T53v2ZdKMmxKhoJcc1qbmjpDQ,414
35
- context_forge_cli-0.2.3.dist-info/METADATA,sha256=tfspqjUX_n4dERdy1eaMdskkUY0HGEwiN3p8hlzz4gU,12831
36
- context_forge_cli-0.2.3.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
37
- context_forge_cli-0.2.3.dist-info/entry_points.txt,sha256=nPN0PWb85KCPXqFp4opmBsNejdCHyl7rK_cREsu4r7A,44
38
- context_forge_cli-0.2.3.dist-info/RECORD,,
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,,
@@ -1,2 +1,3 @@
1
1
  [console_scripts]
2
2
  cf = contextforge.cli:app
3
+ cf-mcp = contextforge.mcp_server:main
@@ -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 content
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
- if current_role and current_parts:
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
- if content:
122
- messages.append(
123
- Message(
124
- role=current_role,
125
- content=content,
126
- timestamp=current_ts,
127
- token_count=_count_tokens(content),
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") and content:
155
- if isinstance(content, str):
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
- current_parts.append(str(content).strip())
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
- total = 0
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
- messages.append(
332
- Message(
333
- role="user" if role == "user" else "assistant",
334
- content=content,
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
 
@@ -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
- token_count=row["tokens_used"],
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
- text = payload.get("message", "")
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
- text_str = str(text)
209
- messages.append(Message(
210
- role="user",
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
- if text:
217
- text_str = str(text)
218
- messages.append(Message(
219
- role="assistant",
220
- content=text_str,
221
- token_count=_count_tokens(text_str),
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
- tokens = count_tokens(msg.content)
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()
@@ -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, Footer, Static
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.core.db import get_db
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
- table.add_columns("#", "Role", "Tokens", "Cumul.", "Bar", "Preview")
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
- table.add_row(
124
- str(t.turn),
125
- role_str,
126
- f"{t.tokens:,}",
127
- f"{t.cumulative:,}",
128
- bar_str,
129
- t.content_preview,
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()
@@ -3,15 +3,23 @@ from __future__ import annotations
3
3
 
4
4
  import tiktoken
5
5
 
6
- _ENCODING = tiktoken.get_encoding("cl100k_base")
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(_ENCODING.encode(text))
17
+ return len(_get_encoding().encode(text))
11
18
 
12
19
 
13
20
  def truncate_to_budget(text: str, budget: int) -> str:
14
- tokens = _ENCODING.encode(text)
21
+ enc = _get_encoding()
22
+ tokens = enc.encode(text)
15
23
  if len(tokens) <= budget:
16
24
  return text
17
- return _ENCODING.decode(tokens[:budget])
25
+ return enc.decode(tokens[:budget])