context-forge-cli 0.2.5__tar.gz → 0.4.0__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.
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/PKG-INFO +107 -1
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/README.md +105 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/contextforge/cli.py +25 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/contextforge/core/db.py +12 -2
- context_forge_cli-0.4.0/contextforge/mcp_server.py +307 -0
- context_forge_cli-0.4.0/contextforge/utils/tokens.py +25 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/pyproject.toml +3 -1
- context_forge_cli-0.4.0/tests/conftest.py +32 -0
- context_forge_cli-0.4.0/tests/test_mcp_server.py +285 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/uv.lock +357 -2
- context_forge_cli-0.2.5/contextforge/utils/tokens.py +0 -17
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/.contextforge.toml +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/.github/workflows/publish.yml +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/.gitignore +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/.python-version +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/AGENTS.md +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/PLAN.md +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/contextforge/__init__.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/contextforge/adapters/__init__.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/contextforge/adapters/altimate_code.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/contextforge/adapters/base.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/contextforge/adapters/claude_code.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/contextforge/adapters/claude_desktop.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/contextforge/adapters/codex.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/contextforge/adapters/gemini.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/contextforge/adapters/registry.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/contextforge/core/__init__.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/contextforge/core/analytics.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/contextforge/core/compactor.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/contextforge/core/injector.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/contextforge/core/scanner.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/contextforge/core/summarizer.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/contextforge/core/token_analyzer.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/contextforge/models/__init__.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/contextforge/models/config.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/contextforge/models/session.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/contextforge/tui/__init__.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/contextforge/tui/app.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/contextforge/tui/styles.tcss +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/contextforge/tui/widgets/__init__.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/contextforge/tui/widgets/session_detail.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/contextforge/tui/widgets/session_table.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/contextforge/tui/widgets/stats_panel.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/contextforge/tui/widgets/status_bar.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/contextforge/tui/widgets/tokens_panel.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/contextforge/tui/widgets/transfer_panel.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/contextforge/utils/__init__.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/contextforge/utils/display.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/tests/__init__.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/tests/adapters/__init__.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/tests/adapters/test_claude_code.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/tests/core/__init__.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/tests/core/test_compactor.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/tests/core/test_db.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/tests/core/test_injector.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/tests/core/test_summarizer.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/tests/core/test_token_analyzer.py +0 -0
- {context_forge_cli-0.2.5 → context_forge_cli-0.4.0}/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.
|
|
3
|
+
Version: 0.4.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`
|
|
@@ -276,6 +276,111 @@ Press `t` on any session to open the transfer panel. Choose:
|
|
|
276
276
|
- **Preview** — shows the exact shell command (no side effects)
|
|
277
277
|
- **Execute** — builds the bundle and launches the target tool
|
|
278
278
|
|
|
279
|
+
## MCP Server
|
|
280
|
+
|
|
281
|
+
ContextForge ships a [Model Context Protocol](https://modelcontextprotocol.io) server that exposes session data and token analytics to LLM agents.
|
|
282
|
+
|
|
283
|
+
### Setup
|
|
284
|
+
|
|
285
|
+
```bash
|
|
286
|
+
# Run directly (after installation)
|
|
287
|
+
cf-mcp
|
|
288
|
+
|
|
289
|
+
# From source
|
|
290
|
+
uv run python -m contextforge.mcp_server
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Register the server with your agentic tool of choice:
|
|
294
|
+
|
|
295
|
+
#### Claude Code
|
|
296
|
+
|
|
297
|
+
Add to `~/.claude/claude_desktop_config.json` (global) or `.claude/mcp.json` (project):
|
|
298
|
+
```json
|
|
299
|
+
{
|
|
300
|
+
"mcpServers": {
|
|
301
|
+
"contextforge": {
|
|
302
|
+
"command": "cf-mcp"
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
#### Cursor
|
|
309
|
+
|
|
310
|
+
Add to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (project):
|
|
311
|
+
```json
|
|
312
|
+
{
|
|
313
|
+
"mcpServers": {
|
|
314
|
+
"contextforge": {
|
|
315
|
+
"command": "cf-mcp"
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Restart Cursor fully after saving.
|
|
322
|
+
|
|
323
|
+
#### Codex CLI
|
|
324
|
+
|
|
325
|
+
Add to `~/.codex/config.toml` (global) or `.codex/config.toml` (project):
|
|
326
|
+
```toml
|
|
327
|
+
[mcp_servers.contextforge]
|
|
328
|
+
command = "cf-mcp"
|
|
329
|
+
enabled = true
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
#### Gemini CLI
|
|
333
|
+
|
|
334
|
+
Add to `~/.gemini/settings.json` (global) or `.gemini/settings.json` (project):
|
|
335
|
+
```json
|
|
336
|
+
{
|
|
337
|
+
"mcpServers": {
|
|
338
|
+
"contextforge": {
|
|
339
|
+
"command": "cf-mcp"
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
#### Antigravity
|
|
346
|
+
|
|
347
|
+
Add to `mcp_servers.json` in your project root:
|
|
348
|
+
```json
|
|
349
|
+
{
|
|
350
|
+
"servers": [
|
|
351
|
+
{
|
|
352
|
+
"name": "contextforge",
|
|
353
|
+
"transport": "stdio",
|
|
354
|
+
"command": "cf-mcp",
|
|
355
|
+
"enabled": true
|
|
356
|
+
}
|
|
357
|
+
]
|
|
358
|
+
}
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
### Tools
|
|
362
|
+
|
|
363
|
+
| Tool | Description |
|
|
364
|
+
|---|---|
|
|
365
|
+
| `list_sessions` | List indexed sessions; filter by `tool`, limit up to 200 |
|
|
366
|
+
| `get_session` | Full detail for a single session by ID |
|
|
367
|
+
| `get_session_tokens` | Per-turn token breakdown; optionally return only the N heaviest turns |
|
|
368
|
+
| `get_token_analytics` | Aggregated token stats across all sessions for a time window |
|
|
369
|
+
| `get_activity_timeline` | Session count and token usage bucketed over time |
|
|
370
|
+
| `get_top_projects` | Top projects by session count within a time window |
|
|
371
|
+
| `compare_sessions` | Side-by-side token comparison for 2–10 sessions |
|
|
372
|
+
|
|
373
|
+
All tools are **read-only** and accept a `window` parameter of `7d`, `30d`, `6m`, or `1y` where applicable.
|
|
374
|
+
|
|
375
|
+
### Example usage
|
|
376
|
+
|
|
377
|
+
```
|
|
378
|
+
# In Claude Code with the MCP server registered:
|
|
379
|
+
"Show me token usage for all my Claude Code sessions this month"
|
|
380
|
+
"Compare sessions <id1> and <id2> side by side"
|
|
381
|
+
"What are my top 5 projects by session count?"
|
|
382
|
+
```
|
|
383
|
+
|
|
279
384
|
## Troubleshooting
|
|
280
385
|
|
|
281
386
|
### `command not found: cf`
|
|
@@ -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
|
# ---------------------------------------------------------------------------
|
|
@@ -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
|
-
|
|
120
|
-
params = [
|
|
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
|
|
|
@@ -0,0 +1,307 @@
|
|
|
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
|
+
"(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."
|
|
22
|
+
),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_VALID_WINDOWS = {"7d", "30d", "6m", "1y"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _db():
|
|
29
|
+
cfg = ForgeConfig()
|
|
30
|
+
return db_module.get_db(cfg.db_path)
|
|
31
|
+
|
|
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
|
+
|
|
59
|
+
@mcp.tool()
|
|
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]]:
|
|
66
|
+
"""List indexed sessions with basic token summaries.
|
|
67
|
+
|
|
68
|
+
Supports pagination via offset/limit and optional date filtering via window.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
tool: Filter by tool name (claude_code, codex, gemini, etc.). Omit for all tools.
|
|
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".
|
|
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
|
+
|
|
81
|
+
db = _db()
|
|
82
|
+
rows = db_module.get_sessions(
|
|
83
|
+
db, tool=tool, limit=min(limit, 200), offset=offset, since_ms=since_ms
|
|
84
|
+
)
|
|
85
|
+
return [
|
|
86
|
+
{
|
|
87
|
+
"id": r["id"],
|
|
88
|
+
"tool": r["tool"],
|
|
89
|
+
"title": (r.get("title") or "")[:100],
|
|
90
|
+
"cwd": _short_cwd(r.get("cwd") or ""),
|
|
91
|
+
"token_count": r.get("token_count") or 0,
|
|
92
|
+
"updated_at": r.get("updated_at"),
|
|
93
|
+
"status": r.get("status") or "",
|
|
94
|
+
}
|
|
95
|
+
for r in rows
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@mcp.tool()
|
|
100
|
+
def get_session(session_id: str) -> dict[str, Any] | None:
|
|
101
|
+
"""Get details for a single session by ID.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
session_id: The exact session ID from list_sessions.
|
|
105
|
+
"""
|
|
106
|
+
db = _db()
|
|
107
|
+
row = db_module.get_session(db, session_id)
|
|
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
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@mcp.tool()
|
|
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.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
session_id: The session ID.
|
|
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.
|
|
145
|
+
"""
|
|
146
|
+
db = _db()
|
|
147
|
+
report = analyze_tokens(db, session_id)
|
|
148
|
+
if report is None:
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
result: dict[str, Any] = {
|
|
152
|
+
"session_id": report.session_id,
|
|
153
|
+
"tool": report.tool,
|
|
154
|
+
"title": report.title[:100],
|
|
155
|
+
"total_tokens": report.total,
|
|
156
|
+
"user_tokens": report.user_total,
|
|
157
|
+
"assistant_tokens": report.assistant_total,
|
|
158
|
+
"tool_call_tokens": report.tool_call_total,
|
|
159
|
+
"tool_result_tokens": report.tool_result_total,
|
|
160
|
+
"turn_count": report.turn_count,
|
|
161
|
+
"avg_user_tokens": round(report.avg_user, 1),
|
|
162
|
+
"avg_assistant_tokens": round(report.avg_assistant, 1),
|
|
163
|
+
"has_tool_data": report.has_tool_data,
|
|
164
|
+
"heaviest_turn": (
|
|
165
|
+
{
|
|
166
|
+
"turn": report.max_turn.turn,
|
|
167
|
+
"role": report.max_turn.role,
|
|
168
|
+
"tokens": report.max_turn.tokens,
|
|
169
|
+
}
|
|
170
|
+
if report.max_turn
|
|
171
|
+
else None
|
|
172
|
+
),
|
|
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"] = [
|
|
183
|
+
{
|
|
184
|
+
"turn": t.turn,
|
|
185
|
+
"role": t.role,
|
|
186
|
+
"tokens": t.tokens,
|
|
187
|
+
"text_tokens": t.text_tokens,
|
|
188
|
+
"tool_call_tokens": t.tool_call_tokens,
|
|
189
|
+
"tool_result_tokens": t.tool_result_tokens,
|
|
190
|
+
}
|
|
191
|
+
for t in turns
|
|
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
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@mcp.tool()
|
|
212
|
+
def get_token_analytics(window: str = "30d") -> dict[str, Any]:
|
|
213
|
+
"""Get aggregated token usage statistics across all sessions.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
window: Time window — one of "7d", "30d", "6m", "1y" (default "30d").
|
|
217
|
+
"""
|
|
218
|
+
_validate_window(window)
|
|
219
|
+
db = _db()
|
|
220
|
+
overview = analytics_module.get_overview(db, window=window)
|
|
221
|
+
return {
|
|
222
|
+
"window": window,
|
|
223
|
+
"total_sessions": overview.total_sessions,
|
|
224
|
+
"total_tokens": overview.total_tokens,
|
|
225
|
+
"active_tools": overview.active_tools,
|
|
226
|
+
"session_count_by_tool": overview.session_count_by_tool,
|
|
227
|
+
"token_sum_by_tool": overview.token_sum_by_tool,
|
|
228
|
+
"date_range": {
|
|
229
|
+
"start": overview.date_range[0].isoformat() if overview.date_range else None,
|
|
230
|
+
"end": overview.date_range[1].isoformat() if overview.date_range else None,
|
|
231
|
+
},
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@mcp.tool()
|
|
236
|
+
def get_activity_timeline(window: str = "30d") -> list[dict[str, Any]]:
|
|
237
|
+
"""Get session count and token usage bucketed over time.
|
|
238
|
+
|
|
239
|
+
Returns one bucket per day (7d/30d), per week (6m), or per month (1y).
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
window: Time window — one of "7d", "30d", "6m", "1y" (default "30d").
|
|
243
|
+
"""
|
|
244
|
+
_validate_window(window)
|
|
245
|
+
db = _db()
|
|
246
|
+
buckets = analytics_module.get_activity_over_time(db, window=window)
|
|
247
|
+
return [
|
|
248
|
+
{"label": b.label, "sessions": b.count, "tokens": b.tokens}
|
|
249
|
+
for b in buckets
|
|
250
|
+
]
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@mcp.tool()
|
|
254
|
+
def get_top_projects(window: str = "30d", top_n: int = 5) -> list[dict[str, Any]]:
|
|
255
|
+
"""Get top projects by session count within a time window.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
window: Time window — one of "7d", "30d", "6m", "1y" (default "30d").
|
|
259
|
+
top_n: Number of top projects to return (default 5).
|
|
260
|
+
"""
|
|
261
|
+
_validate_window(window)
|
|
262
|
+
db = _db()
|
|
263
|
+
projects = analytics_module.get_top_projects(db, window=window, top_n=top_n)
|
|
264
|
+
return [{"project": p.project, "session_count": p.count} for p in projects]
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@mcp.tool()
|
|
268
|
+
def compare_sessions(session_ids: list[str]) -> list[dict[str, Any]]:
|
|
269
|
+
"""Compare token usage across multiple sessions side-by-side.
|
|
270
|
+
|
|
271
|
+
Returns one entry per session with key metrics, sorted by total tokens descending.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
session_ids: List of session IDs to compare.
|
|
275
|
+
"""
|
|
276
|
+
db = _db()
|
|
277
|
+
results: list[dict[str, Any]] = []
|
|
278
|
+
for sid in session_ids:
|
|
279
|
+
report = analyze_tokens(db, sid)
|
|
280
|
+
if report is None:
|
|
281
|
+
results.append({"session_id": sid, "error": "not found"})
|
|
282
|
+
continue
|
|
283
|
+
results.append({
|
|
284
|
+
"session_id": sid,
|
|
285
|
+
"tool": report.tool,
|
|
286
|
+
"title": report.title[:100],
|
|
287
|
+
"total_tokens": report.total,
|
|
288
|
+
"turn_count": report.turn_count,
|
|
289
|
+
"user_tokens": report.user_total,
|
|
290
|
+
"assistant_tokens": report.assistant_total,
|
|
291
|
+
"tool_call_tokens": report.tool_call_total,
|
|
292
|
+
"tool_result_tokens": report.tool_result_total,
|
|
293
|
+
"avg_tokens_per_turn": (
|
|
294
|
+
round(report.total / report.turn_count, 1) if report.turn_count else 0
|
|
295
|
+
),
|
|
296
|
+
"heaviest_turn_tokens": report.max_turn.tokens if report.max_turn else 0,
|
|
297
|
+
})
|
|
298
|
+
results.sort(key=lambda r: r.get("total_tokens", 0), reverse=True)
|
|
299
|
+
return results
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def main() -> None:
|
|
303
|
+
mcp.run()
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
if __name__ == "__main__":
|
|
307
|
+
main()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Token counting utilities using tiktoken."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import tiktoken
|
|
5
|
+
|
|
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
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def count_tokens(text: str) -> int:
|
|
17
|
+
return len(_get_encoding().encode(text))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def truncate_to_budget(text: str, budget: int) -> str:
|
|
21
|
+
enc = _get_encoding()
|
|
22
|
+
tokens = enc.encode(text)
|
|
23
|
+
if len(tokens) <= budget:
|
|
24
|
+
return text
|
|
25
|
+
return enc.decode(tokens[:budget])
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "context-forge-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
4
|
description = "Session manager and context bridge for agentic CLI tools"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { text = "MIT" }
|
|
@@ -21,6 +21,7 @@ classifiers = [
|
|
|
21
21
|
requires-python = ">=3.13"
|
|
22
22
|
dependencies = [
|
|
23
23
|
"anthropic>=0.89.0",
|
|
24
|
+
"mcp>=1.9.0",
|
|
24
25
|
"pydantic>=2.12.5",
|
|
25
26
|
"rich>=14.3.3",
|
|
26
27
|
"sqlite-utils>=3.39",
|
|
@@ -36,6 +37,7 @@ Repository = "https://github.com/emmver/contextforge"
|
|
|
36
37
|
|
|
37
38
|
[project.scripts]
|
|
38
39
|
cf = "contextforge.cli:app"
|
|
40
|
+
cf-mcp = "contextforge.mcp_server:main"
|
|
39
41
|
|
|
40
42
|
[build-system]
|
|
41
43
|
requires = ["hatchling"]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Shared pytest fixtures — mocks tiktoken for offline test environments."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class _FakeEncoding:
|
|
10
|
+
"""Minimal tiktoken encoding stand-in using word-count approximation."""
|
|
11
|
+
|
|
12
|
+
def encode(self, text: str) -> list[int]:
|
|
13
|
+
return list(range(max(1, len(text.split()))))
|
|
14
|
+
|
|
15
|
+
def decode(self, tokens: list[int]) -> str:
|
|
16
|
+
# Best-effort: can't reconstruct text, but truncate_to_budget tests
|
|
17
|
+
# only need a shorter string back.
|
|
18
|
+
return " ".join(["x"] * len(tokens))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_FAKE_ENCODING = _FakeEncoding()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture(autouse=True)
|
|
25
|
+
def mock_tiktoken():
|
|
26
|
+
"""Prevent tiktoken from downloading its BPE vocab file over the network.
|
|
27
|
+
|
|
28
|
+
Patches _get_encoding() at the source so every importer of count_tokens
|
|
29
|
+
gets the stub regardless of how they bound the function.
|
|
30
|
+
"""
|
|
31
|
+
with patch("contextforge.utils.tokens._get_encoding", return_value=_FAKE_ENCODING):
|
|
32
|
+
yield
|