context-forge-cli 0.3.0__tar.gz → 0.4.1__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 (57) hide show
  1. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/PKG-INFO +1 -1
  2. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/contextforge/adapters/codex.py +24 -4
  3. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/contextforge/core/db.py +12 -2
  4. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/contextforge/mcp_server.py +120 -32
  5. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/contextforge/utils/tokens.py +10 -1
  6. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/pyproject.toml +1 -1
  7. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/.contextforge.toml +0 -0
  8. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/.github/workflows/publish.yml +0 -0
  9. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/.gitignore +0 -0
  10. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/.python-version +0 -0
  11. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/AGENTS.md +0 -0
  12. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/PLAN.md +0 -0
  13. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/README.md +0 -0
  14. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/contextforge/__init__.py +0 -0
  15. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/contextforge/adapters/__init__.py +0 -0
  16. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/contextforge/adapters/altimate_code.py +0 -0
  17. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/contextforge/adapters/base.py +0 -0
  18. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/contextforge/adapters/claude_code.py +0 -0
  19. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/contextforge/adapters/claude_desktop.py +0 -0
  20. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/contextforge/adapters/gemini.py +0 -0
  21. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/contextforge/adapters/registry.py +0 -0
  22. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/contextforge/cli.py +0 -0
  23. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/contextforge/core/__init__.py +0 -0
  24. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/contextforge/core/analytics.py +0 -0
  25. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/contextforge/core/compactor.py +0 -0
  26. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/contextforge/core/injector.py +0 -0
  27. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/contextforge/core/scanner.py +0 -0
  28. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/contextforge/core/summarizer.py +0 -0
  29. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/contextforge/core/token_analyzer.py +0 -0
  30. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/contextforge/models/__init__.py +0 -0
  31. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/contextforge/models/config.py +0 -0
  32. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/contextforge/models/session.py +0 -0
  33. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/contextforge/tui/__init__.py +0 -0
  34. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/contextforge/tui/app.py +0 -0
  35. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/contextforge/tui/styles.tcss +0 -0
  36. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/contextforge/tui/widgets/__init__.py +0 -0
  37. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/contextforge/tui/widgets/session_detail.py +0 -0
  38. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/contextforge/tui/widgets/session_table.py +0 -0
  39. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/contextforge/tui/widgets/stats_panel.py +0 -0
  40. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/contextforge/tui/widgets/status_bar.py +0 -0
  41. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/contextforge/tui/widgets/tokens_panel.py +0 -0
  42. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/contextforge/tui/widgets/transfer_panel.py +0 -0
  43. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/contextforge/utils/__init__.py +0 -0
  44. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/contextforge/utils/display.py +0 -0
  45. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/tests/__init__.py +0 -0
  46. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/tests/adapters/__init__.py +0 -0
  47. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/tests/adapters/test_claude_code.py +0 -0
  48. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/tests/conftest.py +0 -0
  49. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/tests/core/__init__.py +0 -0
  50. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/tests/core/test_compactor.py +0 -0
  51. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/tests/core/test_db.py +0 -0
  52. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/tests/core/test_injector.py +0 -0
  53. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/tests/core/test_summarizer.py +0 -0
  54. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/tests/core/test_token_analyzer.py +0 -0
  55. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/tests/fixtures/claude_session_sample.jsonl +0 -0
  56. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/tests/test_mcp_server.py +0 -0
  57. {context_forge_cli-0.3.0 → context_forge_cli-0.4.1}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: context-forge-cli
3
- Version: 0.3.0
3
+ Version: 0.4.1
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
@@ -50,19 +50,39 @@ def _compute_message_tokens(msg: Message) -> int:
50
50
  return total
51
51
 
52
52
 
53
- def _extract_function_output(raw_output: str) -> str:
53
+ def _extract_function_output(raw_output) -> str:
54
54
  """Extract the human-readable output from a Codex function_call_output payload.
55
55
 
56
- The `output` field is a JSON string: {"output": "...", "metadata": {...}}.
57
- Returns the inner "output" string, or the raw string on parse failure.
56
+ The `output` field may be:
57
+ - A JSON string: {"output": "...", "metadata": {...}}
58
+ - An already-parsed list of content blocks (e.g. input_text/input_image)
59
+ - An already-parsed dict
60
+ Returns a plain string suitable for token counting.
58
61
  """
62
+ if isinstance(raw_output, list):
63
+ # Content-block array — extract text parts, skip images
64
+ parts = [
65
+ block.get("text", "")
66
+ for block in raw_output
67
+ if isinstance(block, dict) and block.get("type") in ("text", "input_text")
68
+ ]
69
+ return "\n".join(p for p in parts if p)
70
+ if isinstance(raw_output, dict):
71
+ return str(raw_output.get("output", ""))
59
72
  try:
60
73
  parsed = json.loads(raw_output)
61
74
  if isinstance(parsed, dict):
62
75
  return str(parsed.get("output", raw_output))
76
+ if isinstance(parsed, list):
77
+ parts = [
78
+ block.get("text", "")
79
+ for block in parsed
80
+ if isinstance(block, dict) and block.get("type") in ("text", "input_text")
81
+ ]
82
+ return "\n".join(p for p in parts if p)
63
83
  except (json.JSONDecodeError, TypeError):
64
84
  pass
65
- return raw_output
85
+ return str(raw_output) if raw_output is not None else ""
66
86
 
67
87
 
68
88
  class CodexAdapter(ToolAdapter):
@@ -115,14 +115,24 @@ def get_sessions(
115
115
  db: sqlite_utils.Database,
116
116
  tool: str | None = None,
117
117
  limit: int = 200,
118
+ offset: int = 0,
119
+ since_ms: int | None = None,
118
120
  ) -> list[dict]:
119
- where = "tool = ?" if tool else None
120
- params = [tool] if tool else []
121
+ clauses: list[str] = []
122
+ params: list = []
123
+ if tool:
124
+ clauses.append("tool = ?")
125
+ params.append(tool)
126
+ if since_ms is not None:
127
+ clauses.append("updated_at >= ?")
128
+ params.append(since_ms)
129
+ where = " AND ".join(clauses) if clauses else None
121
130
  rows = db["sessions"].rows_where(
122
131
  where,
123
132
  params,
124
133
  order_by="updated_at desc",
125
134
  limit=limit,
135
+ offset=offset,
126
136
  )
127
137
  return list(rows)
128
138
 
@@ -14,38 +14,83 @@ mcp = FastMCP(
14
14
  "contextforge",
15
15
  instructions=(
16
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."
17
+ "and their token usage. Start with list_sessions to discover what's available "
18
+ "(supports pagination via offset/limit and date filtering via window). "
19
+ "Use get_session_tokens for token stats — pass include_turns=True with "
20
+ "turns_limit/turns_offset to page through per-turn data without blowing up "
21
+ "the context window. Use get_token_analytics for aggregated stats across all tools."
20
22
  ),
21
23
  )
22
24
 
25
+ _VALID_WINDOWS = {"7d", "30d", "6m", "1y"}
26
+
23
27
 
24
28
  def _db():
25
29
  cfg = ForgeConfig()
26
30
  return db_module.get_db(cfg.db_path)
27
31
 
28
32
 
33
+ def _short_cwd(cwd: str) -> str:
34
+ """Return a short project label from a full cwd path.
35
+
36
+ Returns the last path component, or '~' for the user home directory,
37
+ or '' for empty paths.
38
+ """
39
+ if not cwd:
40
+ return ""
41
+ parts = [p for p in cwd.replace("\\", "/").split("/") if p]
42
+ if not parts:
43
+ return ""
44
+ # Detect home directory: last part is a username-like segment with no parent project
45
+ # If the path ends at /Users/<name> or /home/<name>, show '~'
46
+ if len(parts) >= 2 and parts[-2] in ("Users", "home"):
47
+ return "~"
48
+ return parts[-1]
49
+
50
+
51
+ def _validate_window(window: str) -> str:
52
+ if window not in _VALID_WINDOWS:
53
+ raise ValueError(
54
+ f"Invalid window {window!r}. Must be one of: {', '.join(sorted(_VALID_WINDOWS))}"
55
+ )
56
+ return window
57
+
58
+
29
59
  @mcp.tool()
30
- def list_sessions(tool: str | None = None, limit: int = 50) -> list[dict[str, Any]]:
60
+ def list_sessions(
61
+ tool: str | None = None,
62
+ limit: int = 50,
63
+ offset: int = 0,
64
+ window: str | None = None,
65
+ ) -> list[dict[str, Any]]:
31
66
  """List indexed sessions with basic token summaries.
32
67
 
68
+ Supports pagination via offset/limit and optional date filtering via window.
69
+
33
70
  Args:
34
71
  tool: Filter by tool name (claude_code, codex, gemini, etc.). Omit for all tools.
35
- limit: Maximum sessions to return (default 50, max 200).
72
+ limit: Page size (default 50, max 200).
73
+ offset: Number of sessions to skip for pagination (default 0).
74
+ window: Optional time window to filter by recency — one of "7d", "30d", "6m", "1y".
36
75
  """
76
+ since_ms: int | None = None
77
+ if window is not None:
78
+ _validate_window(window)
79
+ since_ms = analytics_module._window_start_ms(window)
80
+
37
81
  db = _db()
38
- rows = db_module.get_sessions(db, tool=tool, limit=min(limit, 200))
82
+ rows = db_module.get_sessions(
83
+ db, tool=tool, limit=min(limit, 200), offset=offset, since_ms=since_ms
84
+ )
39
85
  return [
40
86
  {
41
87
  "id": r["id"],
42
88
  "tool": r["tool"],
43
- "title": r.get("title") or "",
44
- "cwd": r.get("cwd") or "",
89
+ "title": (r.get("title") or "")[:100],
90
+ "cwd": _short_cwd(r.get("cwd") or ""),
45
91
  "token_count": r.get("token_count") or 0,
46
92
  "updated_at": r.get("updated_at"),
47
93
  "status": r.get("status") or "",
48
- "summary": r.get("summary") or "",
49
94
  }
50
95
  for r in rows
51
96
  ]
@@ -53,41 +98,60 @@ def list_sessions(tool: str | None = None, limit: int = 50) -> list[dict[str, An
53
98
 
54
99
  @mcp.tool()
55
100
  def get_session(session_id: str) -> dict[str, Any] | None:
56
- """Get full details for a single session by ID.
101
+ """Get details for a single session by ID.
57
102
 
58
103
  Args:
59
104
  session_id: The exact session ID from list_sessions.
60
105
  """
61
106
  db = _db()
62
107
  row = db_module.get_session(db, session_id)
63
- return dict(row) if row else None
108
+ if row is None:
109
+ return None
110
+ return {
111
+ "id": row["id"],
112
+ "tool": row["tool"],
113
+ "title": (row.get("title") or "")[:100],
114
+ "cwd": row.get("cwd") or "",
115
+ "project": _short_cwd(row.get("cwd") or ""),
116
+ "token_count": row.get("token_count") or 0,
117
+ "status": row.get("status") or "",
118
+ "summary": (row.get("summary") or "")[:300],
119
+ "created_at": row.get("created_at"),
120
+ "updated_at": row.get("updated_at"),
121
+ "tags": row.get("tags") or "[]",
122
+ }
64
123
 
65
124
 
66
125
  @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.
126
+ def get_session_tokens(
127
+ session_id: str,
128
+ include_turns: bool = False,
129
+ turns_limit: int = 100,
130
+ turns_offset: int = 0,
131
+ top: int = 0,
132
+ ) -> dict[str, Any] | None:
133
+ """Get token breakdown for a session.
134
+
135
+ By default returns only summary statistics (cheap). Set include_turns=True
136
+ to also page through per-turn data using turns_limit and turns_offset.
72
137
 
73
138
  Args:
74
139
  session_id: The session ID.
75
- top: If > 0, return only the N heaviest turns (sorted by turn number).
140
+ include_turns: If True, include per-turn token counts. Default False.
141
+ turns_limit: Max turns to return per page when include_turns=True (default 100).
142
+ turns_offset: Turn index to start from for pagination (default 0).
143
+ top: If > 0, return the N heaviest turns instead of sequential pagination.
144
+ Overrides turns_offset when set.
76
145
  """
77
146
  db = _db()
78
147
  report = analyze_tokens(db, session_id)
79
148
  if report is None:
80
149
  return None
81
150
 
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 {
151
+ result: dict[str, Any] = {
88
152
  "session_id": report.session_id,
89
153
  "tool": report.tool,
90
- "title": report.title,
154
+ "title": report.title[:100],
91
155
  "total_tokens": report.total,
92
156
  "user_tokens": report.user_total,
93
157
  "assistant_tokens": report.assistant_total,
@@ -102,12 +166,20 @@ def get_session_tokens(session_id: str, top: int = 0) -> dict[str, Any] | None:
102
166
  "turn": report.max_turn.turn,
103
167
  "role": report.max_turn.role,
104
168
  "tokens": report.max_turn.tokens,
105
- "preview": report.max_turn.content_preview,
106
169
  }
107
170
  if report.max_turn
108
171
  else None
109
172
  ),
110
- "turns": [
173
+ }
174
+
175
+ if include_turns:
176
+ turns = report.turns
177
+ if top > 0:
178
+ turns = sorted(turns, key=lambda t: t.tokens, reverse=True)[:top]
179
+ turns = sorted(turns, key=lambda t: t.turn)
180
+ else:
181
+ turns = turns[turns_offset: turns_offset + turns_limit]
182
+ result["turns"] = [
111
183
  {
112
184
  "turn": t.turn,
113
185
  "role": t.role,
@@ -115,12 +187,25 @@ def get_session_tokens(session_id: str, top: int = 0) -> dict[str, Any] | None:
115
187
  "text_tokens": t.text_tokens,
116
188
  "tool_call_tokens": t.tool_call_tokens,
117
189
  "tool_result_tokens": t.tool_result_tokens,
118
- "cumulative": t.cumulative,
119
- "preview": t.content_preview,
120
190
  }
121
191
  for t in turns
122
- ],
123
- }
192
+ ]
193
+ if top > 0:
194
+ result["turns_pagination"] = {
195
+ "mode": "top",
196
+ "n": top,
197
+ "total": report.turn_count,
198
+ }
199
+ else:
200
+ result["turns_pagination"] = {
201
+ "mode": "sequential",
202
+ "offset": turns_offset,
203
+ "limit": turns_limit,
204
+ "total": report.turn_count,
205
+ "has_more": (turns_offset + turns_limit) < report.turn_count,
206
+ }
207
+
208
+ return result
124
209
 
125
210
 
126
211
  @mcp.tool()
@@ -130,6 +215,7 @@ def get_token_analytics(window: str = "30d") -> dict[str, Any]:
130
215
  Args:
131
216
  window: Time window — one of "7d", "30d", "6m", "1y" (default "30d").
132
217
  """
218
+ _validate_window(window)
133
219
  db = _db()
134
220
  overview = analytics_module.get_overview(db, window=window)
135
221
  return {
@@ -155,6 +241,7 @@ def get_activity_timeline(window: str = "30d") -> list[dict[str, Any]]:
155
241
  Args:
156
242
  window: Time window — one of "7d", "30d", "6m", "1y" (default "30d").
157
243
  """
244
+ _validate_window(window)
158
245
  db = _db()
159
246
  buckets = analytics_module.get_activity_over_time(db, window=window)
160
247
  return [
@@ -171,6 +258,7 @@ def get_top_projects(window: str = "30d", top_n: int = 5) -> list[dict[str, Any]
171
258
  window: Time window — one of "7d", "30d", "6m", "1y" (default "30d").
172
259
  top_n: Number of top projects to return (default 5).
173
260
  """
261
+ _validate_window(window)
174
262
  db = _db()
175
263
  projects = analytics_module.get_top_projects(db, window=window, top_n=top_n)
176
264
  return [{"project": p.project, "session_count": p.count} for p in projects]
@@ -183,7 +271,7 @@ def compare_sessions(session_ids: list[str]) -> list[dict[str, Any]]:
183
271
  Returns one entry per session with key metrics, sorted by total tokens descending.
184
272
 
185
273
  Args:
186
- session_ids: List of session IDs to compare (2–10 sessions recommended).
274
+ session_ids: List of session IDs to compare.
187
275
  """
188
276
  db = _db()
189
277
  results: list[dict[str, Any]] = []
@@ -195,7 +283,7 @@ def compare_sessions(session_ids: list[str]) -> list[dict[str, Any]]:
195
283
  results.append({
196
284
  "session_id": sid,
197
285
  "tool": report.tool,
198
- "title": report.title,
286
+ "title": report.title[:100],
199
287
  "total_tokens": report.total,
200
288
  "turn_count": report.turn_count,
201
289
  "user_tokens": report.user_total,
@@ -13,7 +13,16 @@ def _get_encoding() -> tiktoken.Encoding:
13
13
  return _ENCODING
14
14
 
15
15
 
16
- def count_tokens(text: str) -> int:
16
+ def count_tokens(text) -> int:
17
+ if not isinstance(text, str):
18
+ if isinstance(text, list):
19
+ text = " ".join(
20
+ block.get("text", "")
21
+ for block in text
22
+ if isinstance(block, dict) and block.get("type") in ("text", "input_text")
23
+ )
24
+ else:
25
+ text = str(text) if text is not None else ""
17
26
  return len(_get_encoding().encode(text))
18
27
 
19
28
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "context-forge-cli"
3
- version = "0.3.0"
3
+ version = "0.4.1"
4
4
  description = "Session manager and context bridge for agentic CLI tools"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }