context-forge-cli 0.2.2__tar.gz → 0.2.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/PKG-INFO +1 -1
  2. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/contextforge/adapters/altimate_code.py +59 -24
  3. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/contextforge/adapters/base.py +15 -0
  4. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/contextforge/adapters/claude_code.py +75 -20
  5. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/contextforge/adapters/codex.py +83 -19
  6. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/contextforge/cli.py +73 -0
  7. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/contextforge/core/token_analyzer.py +23 -1
  8. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/contextforge/models/session.py +2 -0
  9. context_forge_cli-0.2.5/contextforge/tui/widgets/tokens_panel.py +290 -0
  10. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/pyproject.toml +1 -1
  11. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/uv.lock +1 -1
  12. context_forge_cli-0.2.2/contextforge/tui/widgets/tokens_panel.py +0 -134
  13. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/.contextforge.toml +0 -0
  14. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/.github/workflows/publish.yml +0 -0
  15. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/.gitignore +0 -0
  16. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/.python-version +0 -0
  17. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/AGENTS.md +0 -0
  18. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/PLAN.md +0 -0
  19. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/README.md +0 -0
  20. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/contextforge/__init__.py +0 -0
  21. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/contextforge/adapters/__init__.py +0 -0
  22. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/contextforge/adapters/claude_desktop.py +0 -0
  23. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/contextforge/adapters/gemini.py +0 -0
  24. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/contextforge/adapters/registry.py +0 -0
  25. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/contextforge/core/__init__.py +0 -0
  26. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/contextforge/core/analytics.py +0 -0
  27. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/contextforge/core/compactor.py +0 -0
  28. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/contextforge/core/db.py +0 -0
  29. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/contextforge/core/injector.py +0 -0
  30. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/contextforge/core/scanner.py +0 -0
  31. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/contextforge/core/summarizer.py +0 -0
  32. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/contextforge/models/__init__.py +0 -0
  33. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/contextforge/models/config.py +0 -0
  34. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/contextforge/tui/__init__.py +0 -0
  35. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/contextforge/tui/app.py +0 -0
  36. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/contextforge/tui/styles.tcss +0 -0
  37. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/contextforge/tui/widgets/__init__.py +0 -0
  38. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/contextforge/tui/widgets/session_detail.py +0 -0
  39. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/contextforge/tui/widgets/session_table.py +0 -0
  40. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/contextforge/tui/widgets/stats_panel.py +0 -0
  41. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/contextforge/tui/widgets/status_bar.py +0 -0
  42. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/contextforge/tui/widgets/transfer_panel.py +0 -0
  43. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/contextforge/utils/__init__.py +0 -0
  44. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/contextforge/utils/display.py +0 -0
  45. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/contextforge/utils/tokens.py +0 -0
  46. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/tests/__init__.py +0 -0
  47. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/tests/adapters/__init__.py +0 -0
  48. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/tests/adapters/test_claude_code.py +0 -0
  49. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/tests/core/__init__.py +0 -0
  50. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/tests/core/test_compactor.py +0 -0
  51. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/tests/core/test_db.py +0 -0
  52. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/tests/core/test_injector.py +0 -0
  53. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/tests/core/test_summarizer.py +0 -0
  54. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/tests/core/test_token_analyzer.py +0 -0
  55. {context_forge_cli-0.2.2 → context_forge_cli-0.2.5}/tests/fixtures/claude_session_sample.jsonl +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: context-forge-cli
3
- Version: 0.2.2
3
+ Version: 0.2.5
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
@@ -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,14 +96,22 @@ 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
99
  cur.execute(
91
100
  """
92
- SELECT m.role, p.type as part_type, p.content, m.time as ts
101
+ SELECT
102
+ m.id,
103
+ json_extract(m.data, '$.role') as role,
104
+ m.time_created as ts,
105
+ json_extract(p.data, '$.type') as part_type,
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
93
111
  FROM message m
94
112
  LEFT JOIN part p ON p.message_id = m.id
95
113
  WHERE m.session_id = ?
96
- ORDER BY m.time ASC, p.id ASC
114
+ ORDER BY m.time_created ASC, p.id ASC
97
115
  """,
98
116
  (session_id,),
99
117
  )
@@ -103,34 +121,43 @@ class AltimateCodeAdapter(ToolAdapter):
103
121
  return []
104
122
 
105
123
  messages: list[Message] = []
124
+ current_msg_id: str | None = None
106
125
  current_role: str | None = None
107
126
  current_parts: list[str] = []
127
+ current_tool_calls: list[dict] = []
128
+ current_tool_results: list[dict] = []
108
129
  current_ts: datetime | None = None
109
130
 
110
131
  def flush():
111
- nonlocal current_role, current_parts, current_ts
112
- if current_role and current_parts:
132
+ nonlocal current_msg_id, current_role, current_parts, current_ts
133
+ nonlocal current_tool_calls, current_tool_results
134
+ if current_role and (current_parts or current_tool_calls):
113
135
  content = "\n".join(p for p in current_parts if p)
114
- if content:
115
- messages.append(
116
- Message(
117
- role=current_role,
118
- content=content,
119
- timestamp=current_ts,
120
- token_count=_count_tokens(content),
121
- )
122
- )
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)
145
+ current_msg_id = None
123
146
  current_role = None
124
147
  current_parts = []
148
+ current_tool_calls = []
149
+ current_tool_results = []
125
150
  current_ts = None
126
151
 
127
152
  for row in rows:
153
+ msg_id = row["id"]
128
154
  role = row["role"]
129
155
  if role not in ("user", "assistant"):
130
156
  continue
131
157
 
132
- if role != current_role:
158
+ if msg_id != current_msg_id:
133
159
  flush()
160
+ current_msg_id = msg_id
134
161
  current_role = role
135
162
  try:
136
163
  current_ts = datetime.fromtimestamp(row["ts"] / 1000, tz=timezone.utc)
@@ -138,28 +165,36 @@ class AltimateCodeAdapter(ToolAdapter):
138
165
  current_ts = None
139
166
 
140
167
  part_type = row["part_type"] or ""
141
- content = row["content"] or ""
142
168
 
143
- if part_type in ("text", "reasoning") and content:
144
- if isinstance(content, str):
169
+ if part_type in ("text", "reasoning"):
170
+ content = row["text_content"] or ""
171
+ if isinstance(content, str) and content:
145
172
  try:
146
173
  parsed = json.loads(content)
147
174
  if isinstance(parsed, dict):
148
175
  content = parsed.get("text", str(parsed))
149
176
  except (json.JSONDecodeError, TypeError):
150
177
  pass
151
- 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})
152
190
 
153
191
  flush()
154
192
  return messages
155
193
 
156
194
  def _count_session_tokens(self, session_id: str) -> int:
157
- """Count total tokens in a session."""
195
+ """Count total tokens in a session (text + tool inputs + tool outputs)."""
158
196
  messages = self.load_messages(session_id)
159
- total = 0
160
- for msg in messages:
161
- total += msg.token_count or _count_tokens(msg.content)
162
- return total
197
+ return sum(msg.token_count or _compute_message_tokens(msg) for msg in messages)
163
198
 
164
199
  def build_inject_command(
165
200
  self,
@@ -5,6 +5,7 @@ from abc import ABC, abstractmethod
5
5
  from pathlib import Path
6
6
 
7
7
  from contextforge.models.session import Message, Session
8
+ from contextforge.utils.tokens import count_tokens
8
9
 
9
10
 
10
11
  class ToolAdapter(ABC):
@@ -42,3 +43,17 @@ class ToolAdapter(ABC):
42
43
  def is_available(self) -> bool:
43
44
  """Return True if this tool is installed and its data paths exist."""
44
45
  return any(p.exists() for p in self.default_paths)
46
+
47
+ def _count_session_tokens(self, session_id: str | Path) -> int:
48
+ """Count total tokens in a session by loading and summing message tokens.
49
+
50
+ This is the default implementation that all adapters can use.
51
+ Adapters may override this with more efficient implementations.
52
+ """
53
+ if isinstance(session_id, Path):
54
+ session_id = session_id.stem
55
+ messages = self.load_messages(str(session_id))
56
+ total = 0
57
+ for msg in messages:
58
+ total += msg.token_count or count_tokens(msg.content)
59
+ return total
@@ -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,20 +327,76 @@ 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
334
+ )
335
+ has_tool_result = any(
336
+ isinstance(b, dict) and b.get("type") == "tool_result"
337
+ for b in content_raw
337
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
 
342
- def _count_session_tokens(self, jsonl_path: Path) -> int:
343
- """Count total tokens in a session JSONL file."""
344
- session_id = jsonl_path.stem
390
+ def _count_session_tokens(self, jsonl_path: Path | str) -> int:
391
+ """Count total tokens in a session JSONL file or session ID.
392
+
393
+ Args:
394
+ jsonl_path: Either a Path to the JSONL file or a session ID string.
395
+ """
396
+ if isinstance(jsonl_path, str):
397
+ session_id = jsonl_path
398
+ else:
399
+ session_id = jsonl_path.stem
345
400
  messages = self.load_messages(session_id)
346
401
  total = 0
347
402
  for msg in messages:
@@ -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
 
@@ -505,6 +505,79 @@ def config_set(key: str, value: str):
505
505
  console.print(f"Would set [bold]{key}[/bold] = [bold]{value}[/bold]")
506
506
 
507
507
 
508
+ # ---------------------------------------------------------------------------
509
+ # cf refresh
510
+ # ---------------------------------------------------------------------------
511
+
512
+ @app.command()
513
+ def refresh(
514
+ quiet: Annotated[bool, typer.Option("--quiet", "-q")] = False,
515
+ ):
516
+ """Refresh token counts and metadata for all sessions.
517
+
518
+ Recalculates token_count for every session from source data.
519
+ Useful after adapter updates or schema changes.
520
+ """
521
+ cfg = _get_config()
522
+ database = _get_db(cfg)
523
+
524
+ from contextforge.adapters.registry import get_adapter
525
+
526
+ # Get all sessions grouped by tool
527
+ sessions_by_tool = {}
528
+ for row in database.execute("SELECT DISTINCT tool FROM sessions ORDER BY tool").fetchall():
529
+ tool = row[0]
530
+ sessions_by_tool[tool] = []
531
+ for s in database.execute("SELECT id, title FROM sessions WHERE tool = ? ORDER BY id", [tool]).fetchall():
532
+ sessions_by_tool[tool].append((s[0], s[1]))
533
+
534
+ from rich.progress import Progress, SpinnerColumn, TextColumn
535
+
536
+ total_sessions = sum(len(sessions) for sessions in sessions_by_tool.values())
537
+ updated_count = 0
538
+ error_count = 0
539
+
540
+ with Progress(
541
+ SpinnerColumn(),
542
+ TextColumn("[progress.description]{task.description}"),
543
+ disable=quiet,
544
+ ) as progress:
545
+ task = progress.add_task("Refreshing...", total=total_sessions)
546
+
547
+ for tool, sessions in sessions_by_tool.items():
548
+ if not sessions:
549
+ continue
550
+
551
+ try:
552
+ adapter = get_adapter(tool)
553
+ except Exception as e:
554
+ if not quiet:
555
+ err_console.print(f"[yellow]Warning:[/yellow] Skipping {tool}: {e}")
556
+ error_count += len(sessions)
557
+ progress.advance(task, len(sessions))
558
+ continue
559
+
560
+ for session_id, title in sessions:
561
+ try:
562
+ token_count = adapter._count_session_tokens(session_id)
563
+ database["sessions"].update(session_id, {"token_count": token_count})
564
+ updated_count += 1
565
+ if not quiet:
566
+ progress.update(task, description=f"Refreshed {session_id[:12]} ({token_count:,} tokens)")
567
+ except Exception as e:
568
+ error_count += 1
569
+ if not quiet:
570
+ err_console.print(f"[yellow]Warning:[/yellow] {session_id}: {e}")
571
+ finally:
572
+ progress.advance(task)
573
+
574
+ if not quiet:
575
+ console.print(
576
+ f"[green]Refresh complete:[/green] "
577
+ f"{updated_count} updated" + (f", {error_count} errors" if error_count else "")
578
+ )
579
+
580
+
508
581
  # ---------------------------------------------------------------------------
509
582
  # cf dashboard
510
583
  # ---------------------------------------------------------------------------
@@ -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
  )
@@ -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):
@@ -0,0 +1,290 @@
1
+ """TokensPanel — modal screen showing per-turn token analysis for a session."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from textual.app import ComposeResult
8
+ from textual.binding import Binding
9
+ from textual.containers import ScrollableContainer, Vertical
10
+ from textual.screen import ModalScreen
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))
125
+
126
+
127
+ class TokensPanel(ModalScreen):
128
+ """Full-screen modal showing token breakdown for a session."""
129
+
130
+ BINDINGS = [
131
+ Binding("escape,q", "dismiss(None)", "Close"),
132
+ ]
133
+
134
+ DEFAULT_CSS = """
135
+ TokensPanel {
136
+ align: center middle;
137
+ }
138
+ TokensPanel > Vertical {
139
+ width: 90%;
140
+ height: 85%;
141
+ border: thick $primary;
142
+ background: $surface;
143
+ padding: 0 1;
144
+ }
145
+ TokensPanel #tokens-header {
146
+ height: auto;
147
+ padding: 1 1 0 1;
148
+ color: $text;
149
+ }
150
+ TokensPanel #tokens-stats {
151
+ height: auto;
152
+ padding: 0 1 1 1;
153
+ color: $text-muted;
154
+ border-bottom: solid $primary-background-lighten-1;
155
+ margin-bottom: 1;
156
+ }
157
+ TokensPanel DataTable {
158
+ height: 1fr;
159
+ }
160
+ TokensPanel #tokens-footer {
161
+ height: 1;
162
+ color: $text-disabled;
163
+ text-align: center;
164
+ padding: 0 1;
165
+ }
166
+ """
167
+
168
+ _BAR_MAX = 24
169
+
170
+ def __init__(self, session_id: str, db_path: Path) -> None:
171
+ super().__init__()
172
+ self._session_id = session_id
173
+ self._db_path = db_path
174
+ self._messages: list[Message] = []
175
+
176
+ def compose(self) -> ComposeResult:
177
+ with Vertical():
178
+ yield Static("", id="tokens-header")
179
+ yield Static("", id="tokens-stats")
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")
182
+
183
+ def on_mount(self) -> None:
184
+ self._load()
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
+
191
+ def _load(self) -> None:
192
+ from contextforge.adapters.registry import get_adapter
193
+ from contextforge.core.db import get_db, get_session
194
+ from contextforge.core.token_analyzer import analyze_tokens
195
+
196
+ db = get_db(self._db_path)
197
+ report = analyze_tokens(db, self._session_id)
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
+
208
+ header = self.query_one("#tokens-header", Static)
209
+ stats = self.query_one("#tokens-stats", Static)
210
+ table = self.query_one("#tokens-table", DataTable)
211
+
212
+ if report is None or not report.turns:
213
+ header.update("[bold red]No token data available.[/bold red]")
214
+ return
215
+
216
+ # ── Header ──────────────────────────────────────────────────────────
217
+ header.update(
218
+ f"[bold accent]Token Analysis[/bold accent] "
219
+ f"[dim]{report.title[:60]}[/dim] "
220
+ f"[dim]{report.tool}[/dim]"
221
+ )
222
+
223
+ # ── Stats bar ───────────────────────────────────────────────────────
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
+ )
231
+ stats.update(
232
+ f"Total [bold]{report.total:,}[/bold] tokens │ "
233
+ f"[cyan]User {report.user_total:,}[/cyan] (avg {report.avg_user:,.0f}) │ "
234
+ f"[green]Asst {report.assistant_total:,}[/green] (avg {report.avg_assistant:,.0f}) │ "
235
+ f"{report.turn_count} turns"
236
+ + tool_summary
237
+ + (f" │ Heaviest: turn #{max_t.turn} "
238
+ f"[bold]{max_t.tokens:,}[/bold] tok [{max_t.role}]"
239
+ if max_t else "")
240
+ )
241
+
242
+ # ── Table ────────────────────────────────────────────────────────────
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")
247
+
248
+ max_tokens = max(t.tokens for t in report.turns)
249
+
250
+ for t in report.turns:
251
+ bar_len = max(1, int(t.tokens / max_tokens * self._BAR_MAX))
252
+
253
+ if t.role == "user":
254
+ role_str = "[cyan]user[/cyan]"
255
+ bar_str = f"[cyan]{'█' * bar_len}[/cyan]"
256
+ elif t.role == "assistant":
257
+ role_str = "[green]asst[/green]"
258
+ bar_str = f"[green]{'█' * bar_len}[/green]"
259
+ else:
260
+ role_str = f"[dim]{t.role}[/dim]"
261
+ bar_str = f"[dim]{'█' * bar_len}[/dim]"
262
+
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
+ )
286
+
287
+ # Highlight the heaviest row and ensure the table has focus
288
+ if max_t:
289
+ table.move_cursor(row=max_t.turn - 1)
290
+ table.focus()
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "context-forge-cli"
3
- version = "0.2.2"
3
+ version = "0.2.5"
4
4
  description = "Session manager and context bridge for agentic CLI tools"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -152,7 +152,7 @@ wheels = [
152
152
 
153
153
  [[package]]
154
154
  name = "context-forge-cli"
155
- version = "0.2.1"
155
+ version = "0.2.4"
156
156
  source = { editable = "." }
157
157
  dependencies = [
158
158
  { name = "anthropic" },
@@ -1,134 +0,0 @@
1
- """TokensPanel — modal screen showing per-turn token analysis for a session."""
2
- from __future__ import annotations
3
-
4
- from pathlib import Path
5
-
6
- from textual.app import ComposeResult
7
- from textual.binding import Binding
8
- from textual.containers import Vertical
9
- from textual.screen import ModalScreen
10
- from textual.widgets import DataTable, Footer, Static
11
-
12
-
13
- class TokensPanel(ModalScreen):
14
- """Full-screen modal showing token breakdown for a session."""
15
-
16
- BINDINGS = [
17
- Binding("escape,q", "dismiss(None)", "Close"),
18
- ]
19
-
20
- DEFAULT_CSS = """
21
- TokensPanel {
22
- align: center middle;
23
- }
24
- TokensPanel > Vertical {
25
- width: 90%;
26
- height: 85%;
27
- border: thick $primary;
28
- background: $surface;
29
- padding: 0 1;
30
- }
31
- TokensPanel #tokens-header {
32
- height: auto;
33
- padding: 1 1 0 1;
34
- color: $text;
35
- }
36
- TokensPanel #tokens-stats {
37
- height: auto;
38
- padding: 0 1 1 1;
39
- color: $text-muted;
40
- border-bottom: solid $primary-background-lighten-1;
41
- margin-bottom: 1;
42
- }
43
- TokensPanel DataTable {
44
- height: 1fr;
45
- }
46
- TokensPanel #tokens-footer {
47
- height: 1;
48
- color: $text-disabled;
49
- text-align: center;
50
- padding: 0 1;
51
- }
52
- """
53
-
54
- _BAR_MAX = 24
55
-
56
- def __init__(self, session_id: str, db_path: Path) -> None:
57
- super().__init__()
58
- self._session_id = session_id
59
- self._db_path = db_path
60
-
61
- def compose(self) -> ComposeResult:
62
- with Vertical():
63
- yield Static("", id="tokens-header")
64
- 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")
67
-
68
- def on_mount(self) -> None:
69
- self._load()
70
-
71
- def _load(self) -> None:
72
- from contextforge.core.db import get_db
73
- from contextforge.core.token_analyzer import analyze_tokens
74
-
75
- db = get_db(self._db_path)
76
- report = analyze_tokens(db, self._session_id)
77
-
78
- header = self.query_one("#tokens-header", Static)
79
- stats = self.query_one("#tokens-stats", Static)
80
- table = self.query_one("#tokens-table", DataTable)
81
-
82
- if report is None or not report.turns:
83
- header.update("[bold red]No token data available.[/bold red]")
84
- return
85
-
86
- # ── Header ──────────────────────────────────────────────────────────
87
- header.update(
88
- f"[bold accent]Token Analysis[/bold accent] "
89
- f"[dim]{report.title[:60]}[/dim] "
90
- f"[dim]{report.tool}[/dim]"
91
- )
92
-
93
- # ── Stats bar ───────────────────────────────────────────────────────
94
- max_t = report.max_turn
95
- stats.update(
96
- f"Total [bold]{report.total:,}[/bold] tokens │ "
97
- f"[cyan]User {report.user_total:,}[/cyan] (avg {report.avg_user:,.0f}) │ "
98
- f"[green]Asst {report.assistant_total:,}[/green] (avg {report.avg_assistant:,.0f}) │ "
99
- f"{report.turn_count} turns"
100
- + (f" │ Heaviest: turn #{max_t.turn} "
101
- f"[bold]{max_t.tokens:,}[/bold] tok [{max_t.role}]"
102
- if max_t else "")
103
- )
104
-
105
- # ── Table ────────────────────────────────────────────────────────────
106
- table.add_columns("#", "Role", "Tokens", "Cumul.", "Bar", "Preview")
107
-
108
- max_tokens = max(t.tokens for t in report.turns)
109
-
110
- for t in report.turns:
111
- bar_len = max(1, int(t.tokens / max_tokens * self._BAR_MAX))
112
-
113
- if t.role == "user":
114
- role_str = "[cyan]user[/cyan]"
115
- bar_str = f"[cyan]{'█' * bar_len}[/cyan]"
116
- elif t.role == "assistant":
117
- role_str = "[green]asst[/green]"
118
- bar_str = f"[green]{'█' * bar_len}[/green]"
119
- else:
120
- role_str = f"[dim]{t.role}[/dim]"
121
- bar_str = f"[dim]{'█' * bar_len}[/dim]"
122
-
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
- )
131
-
132
- # Highlight the heaviest row
133
- if max_t:
134
- table.move_cursor(row=max_t.turn - 1)