context-forge-cli 0.2.1__tar.gz → 0.2.3__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 (54) hide show
  1. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/PKG-INFO +1 -1
  2. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/contextforge/adapters/altimate_code.py +17 -5
  3. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/contextforge/adapters/base.py +15 -0
  4. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/contextforge/adapters/claude_code.py +14 -30
  5. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/contextforge/adapters/claude_desktop.py +13 -4
  6. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/contextforge/adapters/codex.py +24 -2
  7. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/contextforge/adapters/gemini.py +13 -0
  8. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/contextforge/cli.py +73 -0
  9. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/pyproject.toml +1 -1
  10. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/uv.lock +2 -2
  11. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/.contextforge.toml +0 -0
  12. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/.github/workflows/publish.yml +0 -0
  13. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/.gitignore +0 -0
  14. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/.python-version +0 -0
  15. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/AGENTS.md +0 -0
  16. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/PLAN.md +0 -0
  17. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/README.md +0 -0
  18. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/contextforge/__init__.py +0 -0
  19. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/contextforge/adapters/__init__.py +0 -0
  20. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/contextforge/adapters/registry.py +0 -0
  21. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/contextforge/core/__init__.py +0 -0
  22. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/contextforge/core/analytics.py +0 -0
  23. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/contextforge/core/compactor.py +0 -0
  24. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/contextforge/core/db.py +0 -0
  25. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/contextforge/core/injector.py +0 -0
  26. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/contextforge/core/scanner.py +0 -0
  27. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/contextforge/core/summarizer.py +0 -0
  28. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/contextforge/core/token_analyzer.py +0 -0
  29. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/contextforge/models/__init__.py +0 -0
  30. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/contextforge/models/config.py +0 -0
  31. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/contextforge/models/session.py +0 -0
  32. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/contextforge/tui/__init__.py +0 -0
  33. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/contextforge/tui/app.py +0 -0
  34. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/contextforge/tui/styles.tcss +0 -0
  35. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/contextforge/tui/widgets/__init__.py +0 -0
  36. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/contextforge/tui/widgets/session_detail.py +0 -0
  37. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/contextforge/tui/widgets/session_table.py +0 -0
  38. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/contextforge/tui/widgets/stats_panel.py +0 -0
  39. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/contextforge/tui/widgets/status_bar.py +0 -0
  40. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/contextforge/tui/widgets/tokens_panel.py +0 -0
  41. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/contextforge/tui/widgets/transfer_panel.py +0 -0
  42. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/contextforge/utils/__init__.py +0 -0
  43. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/contextforge/utils/display.py +0 -0
  44. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/contextforge/utils/tokens.py +0 -0
  45. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/tests/__init__.py +0 -0
  46. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/tests/adapters/__init__.py +0 -0
  47. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/tests/adapters/test_claude_code.py +0 -0
  48. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/tests/core/__init__.py +0 -0
  49. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/tests/core/test_compactor.py +0 -0
  50. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/tests/core/test_db.py +0 -0
  51. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/tests/core/test_injector.py +0 -0
  52. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/tests/core/test_summarizer.py +0 -0
  53. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/tests/core/test_token_analyzer.py +0 -0
  54. {context_forge_cli-0.2.1 → context_forge_cli-0.2.3}/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.1
3
+ Version: 0.2.3
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
@@ -87,13 +87,19 @@ class AltimateCodeAdapter(ToolAdapter):
87
87
  conn.row_factory = sqlite3.Row
88
88
  cur = conn.cursor()
89
89
  # Join message + part to get full content
90
+ # Note: altimate-code stores data as JSON in 'data' column
90
91
  cur.execute(
91
92
  """
92
- SELECT m.role, p.type as part_type, p.content, m.time as ts
93
+ SELECT
94
+ m.id,
95
+ json_extract(m.data, '$.role') as role,
96
+ m.time_created as ts,
97
+ json_extract(p.data, '$.type') as part_type,
98
+ json_extract(p.data, '$.text') as content
93
99
  FROM message m
94
100
  LEFT JOIN part p ON p.message_id = m.id
95
101
  WHERE m.session_id = ?
96
- ORDER BY m.time ASC, p.id ASC
102
+ ORDER BY m.time_created ASC, p.id ASC
97
103
  """,
98
104
  (session_id,),
99
105
  )
@@ -103,12 +109,13 @@ class AltimateCodeAdapter(ToolAdapter):
103
109
  return []
104
110
 
105
111
  messages: list[Message] = []
112
+ current_msg_id: str | None = None
106
113
  current_role: str | None = None
107
114
  current_parts: list[str] = []
108
115
  current_ts: datetime | None = None
109
116
 
110
117
  def flush():
111
- nonlocal current_role, current_parts, current_ts
118
+ nonlocal current_msg_id, current_role, current_parts, current_ts
112
119
  if current_role and current_parts:
113
120
  content = "\n".join(p for p in current_parts if p)
114
121
  if content:
@@ -117,19 +124,24 @@ class AltimateCodeAdapter(ToolAdapter):
117
124
  role=current_role,
118
125
  content=content,
119
126
  timestamp=current_ts,
127
+ token_count=_count_tokens(content),
120
128
  )
121
129
  )
130
+ current_msg_id = None
122
131
  current_role = None
123
132
  current_parts = []
124
133
  current_ts = None
125
134
 
126
135
  for row in rows:
136
+ msg_id = row["id"]
127
137
  role = row["role"]
128
138
  if role not in ("user", "assistant"):
129
139
  continue
130
140
 
131
- if role != current_role:
141
+ # Flush when message ID changes (new message in the message table)
142
+ if msg_id != current_msg_id:
132
143
  flush()
144
+ current_msg_id = msg_id
133
145
  current_role = role
134
146
  try:
135
147
  current_ts = datetime.fromtimestamp(row["ts"] / 1000, tz=timezone.utc)
@@ -157,7 +169,7 @@ class AltimateCodeAdapter(ToolAdapter):
157
169
  messages = self.load_messages(session_id)
158
170
  total = 0
159
171
  for msg in messages:
160
- total += _count_tokens(msg.content)
172
+ total += msg.token_count or _count_tokens(msg.content)
161
173
  return total
162
174
 
163
175
  def build_inject_command(
@@ -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
@@ -333,42 +333,26 @@ class ClaudeCodeAdapter(ToolAdapter):
333
333
  role="user" if role == "user" else "assistant",
334
334
  content=content,
335
335
  timestamp=ts,
336
+ token_count=_count_tokens(content),
336
337
  )
337
338
  )
338
339
 
339
340
  return messages
340
341
 
341
- def _count_session_tokens(self, jsonl_path: Path) -> int:
342
- """Count total tokens in a session JSONL file."""
343
- total = 0
344
- try:
345
- with jsonl_path.open() as f:
346
- for line in f:
347
- line = line.strip()
348
- if not line:
349
- continue
350
- try:
351
- entry = json.loads(line)
352
- except json.JSONDecodeError:
353
- continue
354
-
355
- entry_type = entry.get("type", "")
356
- if entry_type not in ("user", "assistant"):
357
- continue
358
-
359
- msg_data = entry.get("message", {})
360
- content_raw = msg_data.get("content", "")
342
+ def _count_session_tokens(self, jsonl_path: Path | str) -> int:
343
+ """Count total tokens in a session JSONL file or session ID.
361
344
 
362
- # Skip tool-result-only arrays (not actual user/assistant content)
363
- if isinstance(content_raw, list):
364
- if not any(block.get("type") == "text" for block in content_raw if isinstance(block, dict)):
365
- continue
366
-
367
- content = _parse_content(content_raw)
368
- if content:
369
- total += _count_tokens(content)
370
- except Exception:
371
- pass
345
+ Args:
346
+ jsonl_path: Either a Path to the JSONL file or a session ID string.
347
+ """
348
+ if isinstance(jsonl_path, str):
349
+ session_id = jsonl_path
350
+ else:
351
+ session_id = jsonl_path.stem
352
+ messages = self.load_messages(session_id)
353
+ total = 0
354
+ for msg in messages:
355
+ total += msg.token_count or _count_tokens(msg.content)
372
356
  return total
373
357
 
374
358
  def build_inject_command(
@@ -240,8 +240,12 @@ class ClaudeDesktopAdapter(ToolAdapter):
240
240
  pass
241
241
 
242
242
  messages.append(
243
- Message(role="user" if role == "user" else "assistant",
244
- content=content, timestamp=ts)
243
+ Message(
244
+ role="user" if role == "user" else "assistant",
245
+ content=content,
246
+ timestamp=ts,
247
+ token_count=_count_tokens(content),
248
+ )
245
249
  )
246
250
  return messages
247
251
 
@@ -279,7 +283,12 @@ class ClaudeDesktopAdapter(ToolAdapter):
279
283
  except (ValueError, AttributeError):
280
284
  pass
281
285
 
282
- messages.append(Message(role=role, content=content, timestamp=ts))
286
+ messages.append(Message(
287
+ role=role,
288
+ content=content,
289
+ timestamp=ts,
290
+ token_count=_count_tokens(content),
291
+ ))
283
292
  return messages
284
293
 
285
294
  def _count_session_tokens(self, session_id: str) -> int:
@@ -287,7 +296,7 @@ class ClaudeDesktopAdapter(ToolAdapter):
287
296
  messages = self.load_messages(session_id)
288
297
  total = 0
289
298
  for msg in messages:
290
- total += _count_tokens(msg.content)
299
+ total += msg.token_count or _count_tokens(msg.content)
291
300
  return total
292
301
 
293
302
  def build_inject_command(
@@ -7,6 +7,8 @@ import sqlite3
7
7
  from datetime import datetime, timezone
8
8
  from pathlib import Path
9
9
 
10
+ import tiktoken
11
+
10
12
  from contextforge.adapters.base import ToolAdapter
11
13
  from contextforge.models.session import Message, Session
12
14
 
@@ -29,6 +31,16 @@ _CODEX_SESSIONS_DIR = Path.home() / ".codex" / "sessions"
29
31
  _SESSION_INDEX = Path.home() / ".codex" / "session_index.jsonl"
30
32
 
31
33
 
34
+ def _count_tokens(text: str) -> int:
35
+ """Count tokens in text using tiktoken for Claude models."""
36
+ try:
37
+ enc = tiktoken.encoding_for_model("claude-3-5-sonnet-20241022")
38
+ return len(enc.encode(text))
39
+ except Exception:
40
+ # Fallback: rough estimate (~4 chars per token)
41
+ return len(text) // 4
42
+
43
+
32
44
  class CodexAdapter(ToolAdapter):
33
45
  tool_name = "codex"
34
46
  default_paths = [_CODEX_DB, _CODEX_SESSIONS_DIR]
@@ -193,11 +205,21 @@ class CodexAdapter(ToolAdapter):
193
205
  if msg_type == "user_message":
194
206
  text = payload.get("message", "")
195
207
  if text:
196
- messages.append(Message(role="user", content=str(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
+ ))
197
214
  elif msg_type == "agent_message":
198
215
  text = payload.get("message", "")
199
216
  if text:
200
- messages.append(Message(role="assistant", content=str(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
+ ))
201
223
 
202
224
  return messages
203
225
 
@@ -6,12 +6,24 @@ import shlex
6
6
  from datetime import datetime, timezone
7
7
  from pathlib import Path
8
8
 
9
+ import tiktoken
10
+
9
11
  from contextforge.adapters.base import ToolAdapter
10
12
  from contextforge.models.session import Message, Session
11
13
 
12
14
  _GEMINI_TMP_BASE = Path.home() / ".gemini" / "tmp"
13
15
 
14
16
 
17
+ def _count_tokens(text: str) -> int:
18
+ """Count tokens in text using tiktoken for Claude models."""
19
+ try:
20
+ enc = tiktoken.encoding_for_model("claude-3-5-sonnet-20241022")
21
+ return len(enc.encode(text))
22
+ except Exception:
23
+ # Fallback: rough estimate (~4 chars per token)
24
+ return len(text) // 4
25
+
26
+
15
27
  class GeminiAdapter(ToolAdapter):
16
28
  tool_name = "gemini"
17
29
  default_paths = [_GEMINI_TMP_BASE]
@@ -163,6 +175,7 @@ class GeminiAdapter(ToolAdapter):
163
175
  role="user" if msg_type == "user" else "assistant",
164
176
  content=content,
165
177
  timestamp=ts,
178
+ token_count=_count_tokens(content),
166
179
  )
167
180
  )
168
181
 
@@ -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
  # ---------------------------------------------------------------------------
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "context-forge-cli"
3
- version = "0.2.1"
3
+ version = "0.2.3"
4
4
  description = "Session manager and context bridge for agentic CLI tools"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -151,8 +151,8 @@ wheels = [
151
151
  ]
152
152
 
153
153
  [[package]]
154
- name = "context-forge"
155
- version = "0.1.0"
154
+ name = "context-forge-cli"
155
+ version = "0.2.1"
156
156
  source = { editable = "." }
157
157
  dependencies = [
158
158
  { name = "anthropic" },