context-forge-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- context_forge_cli-0.1.0.dist-info/METADATA +340 -0
- context_forge_cli-0.1.0.dist-info/RECORD +37 -0
- context_forge_cli-0.1.0.dist-info/WHEEL +4 -0
- context_forge_cli-0.1.0.dist-info/entry_points.txt +2 -0
- contextforge/__init__.py +3 -0
- contextforge/adapters/__init__.py +3 -0
- contextforge/adapters/altimate_code.py +179 -0
- contextforge/adapters/base.py +44 -0
- contextforge/adapters/claude_code.py +393 -0
- contextforge/adapters/claude_desktop.py +306 -0
- contextforge/adapters/codex.py +220 -0
- contextforge/adapters/registry.py +31 -0
- contextforge/cli.py +523 -0
- contextforge/core/__init__.py +0 -0
- contextforge/core/analytics.py +226 -0
- contextforge/core/compactor.py +182 -0
- contextforge/core/db.py +181 -0
- contextforge/core/injector.py +94 -0
- contextforge/core/scanner.py +72 -0
- contextforge/core/summarizer.py +155 -0
- contextforge/core/token_analyzer.py +91 -0
- contextforge/models/__init__.py +4 -0
- contextforge/models/config.py +34 -0
- contextforge/models/session.py +37 -0
- contextforge/tui/__init__.py +0 -0
- contextforge/tui/app.py +204 -0
- contextforge/tui/styles.tcss +50 -0
- contextforge/tui/widgets/__init__.py +0 -0
- contextforge/tui/widgets/session_detail.py +191 -0
- contextforge/tui/widgets/session_table.py +277 -0
- contextforge/tui/widgets/stats_panel.py +340 -0
- contextforge/tui/widgets/status_bar.py +102 -0
- contextforge/tui/widgets/tokens_panel.py +134 -0
- contextforge/tui/widgets/transfer_panel.py +119 -0
- contextforge/utils/__init__.py +0 -0
- contextforge/utils/display.py +105 -0
- contextforge/utils/tokens.py +17 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Analytics aggregations for the ContextForge TUI dashboard."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
import sqlite_utils
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ── Data classes ─────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class OverviewStats:
|
|
18
|
+
total_sessions: int
|
|
19
|
+
total_tokens: int
|
|
20
|
+
active_tools: list[str]
|
|
21
|
+
date_range: tuple[datetime, datetime] | None
|
|
22
|
+
session_count_by_tool: dict[str, int]
|
|
23
|
+
token_sum_by_tool: dict[str, int]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ActivityBucket:
|
|
28
|
+
label: str
|
|
29
|
+
count: int
|
|
30
|
+
tokens: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ProjectStats:
|
|
35
|
+
project: str
|
|
36
|
+
count: int
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ── Internal helpers ──────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _window_start_ms(window: str) -> int:
|
|
43
|
+
"""Return epoch-ms for the start of *window* relative to now."""
|
|
44
|
+
now = datetime.now(timezone.utc)
|
|
45
|
+
if window == "7d":
|
|
46
|
+
delta = timedelta(days=7)
|
|
47
|
+
elif window == "30d":
|
|
48
|
+
delta = timedelta(days=30)
|
|
49
|
+
elif window == "6m":
|
|
50
|
+
# Approximate 6 months as 183 days
|
|
51
|
+
delta = timedelta(days=183)
|
|
52
|
+
elif window == "1y":
|
|
53
|
+
delta = timedelta(days=365)
|
|
54
|
+
else:
|
|
55
|
+
delta = timedelta(days=30)
|
|
56
|
+
start = now - delta
|
|
57
|
+
return int(start.timestamp() * 1000)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _bucket_key(updated_at_ms: int, window: str) -> str:
|
|
61
|
+
"""Return the period label for a given ms timestamp and window."""
|
|
62
|
+
try:
|
|
63
|
+
dt = datetime.fromtimestamp(updated_at_ms / 1000, tz=timezone.utc)
|
|
64
|
+
except Exception:
|
|
65
|
+
return "?"
|
|
66
|
+
if window in ("7d", "30d"):
|
|
67
|
+
return dt.strftime("%Y-%m-%d")
|
|
68
|
+
elif window == "6m":
|
|
69
|
+
return dt.strftime("%Y-W%W")
|
|
70
|
+
else: # 1y
|
|
71
|
+
return dt.strftime("%Y-%m")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _all_bucket_labels(window: str) -> list[str]:
|
|
75
|
+
"""Return the ordered list of all period labels for the window (including zeros)."""
|
|
76
|
+
now = datetime.now(timezone.utc)
|
|
77
|
+
labels: list[str] = []
|
|
78
|
+
|
|
79
|
+
if window == "7d":
|
|
80
|
+
for i in range(6, -1, -1):
|
|
81
|
+
d = now - timedelta(days=i)
|
|
82
|
+
labels.append(d.strftime("%Y-%m-%d"))
|
|
83
|
+
elif window == "30d":
|
|
84
|
+
for i in range(29, -1, -1):
|
|
85
|
+
d = now - timedelta(days=i)
|
|
86
|
+
labels.append(d.strftime("%Y-%m-%d"))
|
|
87
|
+
elif window == "6m":
|
|
88
|
+
# ~26 ISO weeks
|
|
89
|
+
for i in range(25, -1, -1):
|
|
90
|
+
d = now - timedelta(weeks=i)
|
|
91
|
+
labels.append(d.strftime("%Y-W%W"))
|
|
92
|
+
# Deduplicate while preserving order
|
|
93
|
+
seen: set[str] = set()
|
|
94
|
+
unique: list[str] = []
|
|
95
|
+
for lbl in labels:
|
|
96
|
+
if lbl not in seen:
|
|
97
|
+
seen.add(lbl)
|
|
98
|
+
unique.append(lbl)
|
|
99
|
+
labels = unique
|
|
100
|
+
else: # 1y
|
|
101
|
+
# 12 months backwards
|
|
102
|
+
for i in range(11, -1, -1):
|
|
103
|
+
# Subtract i months
|
|
104
|
+
month = now.month - i
|
|
105
|
+
year = now.year
|
|
106
|
+
while month <= 0:
|
|
107
|
+
month += 12
|
|
108
|
+
year -= 1
|
|
109
|
+
labels.append(f"{year:04d}-{month:02d}")
|
|
110
|
+
|
|
111
|
+
return labels
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ── Public API ────────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_overview(
|
|
118
|
+
db: "sqlite_utils.Database",
|
|
119
|
+
window: str = "30d",
|
|
120
|
+
) -> OverviewStats:
|
|
121
|
+
"""Return high-level aggregates for the given time window."""
|
|
122
|
+
start_ms = _window_start_ms(window)
|
|
123
|
+
|
|
124
|
+
rows = list(db.execute(
|
|
125
|
+
"SELECT tool, COUNT(*) as cnt, COALESCE(SUM(token_count), 0) as tok "
|
|
126
|
+
"FROM sessions WHERE updated_at >= ? GROUP BY tool",
|
|
127
|
+
[start_ms],
|
|
128
|
+
).fetchall())
|
|
129
|
+
|
|
130
|
+
session_count_by_tool: dict[str, int] = {}
|
|
131
|
+
token_sum_by_tool: dict[str, int] = {}
|
|
132
|
+
total_sessions = 0
|
|
133
|
+
total_tokens = 0
|
|
134
|
+
|
|
135
|
+
for tool, cnt, tok in rows:
|
|
136
|
+
session_count_by_tool[tool] = cnt
|
|
137
|
+
token_sum_by_tool[tool] = tok
|
|
138
|
+
total_sessions += cnt
|
|
139
|
+
total_tokens += tok
|
|
140
|
+
|
|
141
|
+
active_tools = [t for t, c in session_count_by_tool.items() if c > 0]
|
|
142
|
+
|
|
143
|
+
# Date range
|
|
144
|
+
range_row = db.execute(
|
|
145
|
+
"SELECT MIN(updated_at), MAX(updated_at) FROM sessions WHERE updated_at >= ?",
|
|
146
|
+
[start_ms],
|
|
147
|
+
).fetchone()
|
|
148
|
+
date_range: tuple[datetime, datetime] | None = None
|
|
149
|
+
if range_row and range_row[0] and range_row[1]:
|
|
150
|
+
try:
|
|
151
|
+
earliest = datetime.fromtimestamp(range_row[0] / 1000, tz=timezone.utc)
|
|
152
|
+
latest = datetime.fromtimestamp(range_row[1] / 1000, tz=timezone.utc)
|
|
153
|
+
date_range = (earliest, latest)
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
return OverviewStats(
|
|
158
|
+
total_sessions=total_sessions,
|
|
159
|
+
total_tokens=total_tokens,
|
|
160
|
+
active_tools=active_tools,
|
|
161
|
+
date_range=date_range,
|
|
162
|
+
session_count_by_tool=session_count_by_tool,
|
|
163
|
+
token_sum_by_tool=token_sum_by_tool,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def get_activity_over_time(
|
|
168
|
+
db: "sqlite_utils.Database",
|
|
169
|
+
window: str = "30d",
|
|
170
|
+
) -> list[ActivityBucket]:
|
|
171
|
+
"""Return ordered activity buckets for the sparkline. Zero-count buckets included."""
|
|
172
|
+
start_ms = _window_start_ms(window)
|
|
173
|
+
|
|
174
|
+
data_rows = list(db.execute(
|
|
175
|
+
"SELECT updated_at, COALESCE(token_count, 0) FROM sessions WHERE updated_at >= ?",
|
|
176
|
+
[start_ms],
|
|
177
|
+
).fetchall())
|
|
178
|
+
|
|
179
|
+
# Aggregate into buckets
|
|
180
|
+
bucket_counts: dict[str, int] = {}
|
|
181
|
+
bucket_tokens: dict[str, int] = {}
|
|
182
|
+
for updated_ms, tok in data_rows:
|
|
183
|
+
key = _bucket_key(updated_ms, window)
|
|
184
|
+
bucket_counts[key] = bucket_counts.get(key, 0) + 1
|
|
185
|
+
bucket_tokens[key] = bucket_tokens.get(key, 0) + tok
|
|
186
|
+
|
|
187
|
+
# Fill all period labels (including zeros)
|
|
188
|
+
labels = _all_bucket_labels(window)
|
|
189
|
+
return [
|
|
190
|
+
ActivityBucket(
|
|
191
|
+
label=lbl,
|
|
192
|
+
count=bucket_counts.get(lbl, 0),
|
|
193
|
+
tokens=bucket_tokens.get(lbl, 0),
|
|
194
|
+
)
|
|
195
|
+
for lbl in labels
|
|
196
|
+
]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def get_top_projects(
|
|
200
|
+
db: "sqlite_utils.Database",
|
|
201
|
+
window: str = "30d",
|
|
202
|
+
top_n: int = 5,
|
|
203
|
+
) -> list[ProjectStats]:
|
|
204
|
+
"""Return top N projects by session count within the window."""
|
|
205
|
+
start_ms = _window_start_ms(window)
|
|
206
|
+
|
|
207
|
+
rows = list(db.execute(
|
|
208
|
+
"SELECT cwd, COUNT(*) as cnt FROM sessions "
|
|
209
|
+
"WHERE updated_at >= ? AND cwd IS NOT NULL AND cwd != '' "
|
|
210
|
+
"GROUP BY cwd ORDER BY cnt DESC LIMIT ?",
|
|
211
|
+
[start_ms, top_n],
|
|
212
|
+
).fetchall())
|
|
213
|
+
|
|
214
|
+
result: list[ProjectStats] = []
|
|
215
|
+
for cwd, cnt in rows:
|
|
216
|
+
# Use last 2 path components as display name
|
|
217
|
+
parts = [p for p in cwd.replace("\\", "/").split("/") if p]
|
|
218
|
+
if len(parts) >= 2:
|
|
219
|
+
project = "/".join(parts[-2:])
|
|
220
|
+
elif parts:
|
|
221
|
+
project = parts[-1]
|
|
222
|
+
else:
|
|
223
|
+
project = cwd
|
|
224
|
+
result.append(ProjectStats(project=project, count=cnt))
|
|
225
|
+
|
|
226
|
+
return result
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Context compaction: convert one or more sessions into a token-efficient ContextBundle."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
import sqlite_utils
|
|
8
|
+
|
|
9
|
+
from contextforge.adapters.registry import get_adapter
|
|
10
|
+
from contextforge.core.db import get_session
|
|
11
|
+
from contextforge.models.session import ContextBundle, Message
|
|
12
|
+
from contextforge.utils.tokens import count_tokens, truncate_to_budget
|
|
13
|
+
|
|
14
|
+
Strategy = Literal["summary_only", "key_messages", "full_recent"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _message_importance(msg: Message, index: int, total: int) -> float:
|
|
18
|
+
score = 0.0
|
|
19
|
+
content = msg.content
|
|
20
|
+
|
|
21
|
+
# Recency bonus (later messages score higher)
|
|
22
|
+
score += (index / max(total, 1)) * 2.0
|
|
23
|
+
|
|
24
|
+
# Length is a rough proxy for information density
|
|
25
|
+
score += min(len(content) / 500, 3.0)
|
|
26
|
+
|
|
27
|
+
# Code blocks, file paths, decisions
|
|
28
|
+
if "```" in content:
|
|
29
|
+
score += 2.0
|
|
30
|
+
if any(kw in content.lower() for kw in ("decided", "solution", "fixed", "implemented", "created")):
|
|
31
|
+
score += 1.5
|
|
32
|
+
if "/" in content and any(ext in content for ext in (".py", ".ts", ".js", ".go", ".rs", ".md")):
|
|
33
|
+
score += 1.0
|
|
34
|
+
if msg.role == "user":
|
|
35
|
+
score += 0.5
|
|
36
|
+
|
|
37
|
+
return score
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _compact_summary_only(
|
|
41
|
+
db: sqlite_utils.Database,
|
|
42
|
+
session_ids: list[str],
|
|
43
|
+
token_budget: int,
|
|
44
|
+
) -> str:
|
|
45
|
+
parts = []
|
|
46
|
+
for sid in session_ids:
|
|
47
|
+
row = get_session(db, sid)
|
|
48
|
+
if row is None:
|
|
49
|
+
continue
|
|
50
|
+
tool = row.get("tool", "?")
|
|
51
|
+
title = row.get("title") or sid[:12]
|
|
52
|
+
summary = row.get("summary") or row.get("first_message") or "(no summary)"
|
|
53
|
+
parts.append(f"## Session: {title} [{tool}]\n{summary}")
|
|
54
|
+
return "\n\n---\n\n".join(parts)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _compact_key_messages(
|
|
58
|
+
db: sqlite_utils.Database,
|
|
59
|
+
session_ids: list[str],
|
|
60
|
+
token_budget: int,
|
|
61
|
+
) -> str:
|
|
62
|
+
parts = []
|
|
63
|
+
per_session_budget = token_budget // max(len(session_ids), 1)
|
|
64
|
+
|
|
65
|
+
for sid in session_ids:
|
|
66
|
+
row = get_session(db, sid)
|
|
67
|
+
if row is None:
|
|
68
|
+
continue
|
|
69
|
+
tool = row.get("tool", "?")
|
|
70
|
+
title = row.get("title") or sid[:12]
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
adapter = get_adapter(tool)
|
|
74
|
+
messages = adapter.load_messages(sid)
|
|
75
|
+
except Exception:
|
|
76
|
+
messages = []
|
|
77
|
+
|
|
78
|
+
if not messages:
|
|
79
|
+
summary = row.get("summary") or row.get("first_message") or ""
|
|
80
|
+
parts.append(f"## Session: {title} [{tool}]\n{summary}")
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
# Score and select
|
|
84
|
+
scored = [
|
|
85
|
+
(msg, _message_importance(msg, i, len(messages)))
|
|
86
|
+
for i, msg in enumerate(messages)
|
|
87
|
+
]
|
|
88
|
+
scored.sort(key=lambda x: x[1], reverse=True)
|
|
89
|
+
|
|
90
|
+
selected: list[Message] = []
|
|
91
|
+
used_tokens = 0
|
|
92
|
+
header_tokens = count_tokens(f"## Session: {title} [{tool}]\n")
|
|
93
|
+
remaining = per_session_budget - header_tokens
|
|
94
|
+
|
|
95
|
+
for msg, _ in scored:
|
|
96
|
+
t = count_tokens(f"{msg.role.capitalize()}: {msg.content}\n\n")
|
|
97
|
+
if used_tokens + t > remaining:
|
|
98
|
+
break
|
|
99
|
+
selected.append(msg)
|
|
100
|
+
used_tokens += t
|
|
101
|
+
|
|
102
|
+
# Restore chronological order
|
|
103
|
+
original_order = {id(m): i for i, m in enumerate(messages)}
|
|
104
|
+
selected.sort(key=lambda m: original_order.get(id(m), 0))
|
|
105
|
+
|
|
106
|
+
lines = [f"## Session: {title} [{tool}]"]
|
|
107
|
+
for msg in selected:
|
|
108
|
+
lines.append(f"{msg.role.capitalize()}: {msg.content}")
|
|
109
|
+
parts.append("\n\n".join(lines))
|
|
110
|
+
|
|
111
|
+
return "\n\n---\n\n".join(parts)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _compact_full_recent(
|
|
115
|
+
db: sqlite_utils.Database,
|
|
116
|
+
session_ids: list[str],
|
|
117
|
+
token_budget: int,
|
|
118
|
+
) -> str:
|
|
119
|
+
parts = []
|
|
120
|
+
per_session_budget = token_budget // max(len(session_ids), 1)
|
|
121
|
+
|
|
122
|
+
for sid in session_ids:
|
|
123
|
+
row = get_session(db, sid)
|
|
124
|
+
if row is None:
|
|
125
|
+
continue
|
|
126
|
+
tool = row.get("tool", "?")
|
|
127
|
+
title = row.get("title") or sid[:12]
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
adapter = get_adapter(tool)
|
|
131
|
+
messages = adapter.load_messages(sid)
|
|
132
|
+
except Exception:
|
|
133
|
+
messages = []
|
|
134
|
+
|
|
135
|
+
lines = [f"## Session: {title} [{tool}]"]
|
|
136
|
+
used = count_tokens(lines[0])
|
|
137
|
+
|
|
138
|
+
for msg in reversed(messages):
|
|
139
|
+
chunk = f"{msg.role.capitalize()}: {msg.content}"
|
|
140
|
+
t = count_tokens(chunk)
|
|
141
|
+
if used + t > per_session_budget:
|
|
142
|
+
break
|
|
143
|
+
lines.insert(1, chunk)
|
|
144
|
+
used += t
|
|
145
|
+
|
|
146
|
+
parts.append("\n\n".join(lines))
|
|
147
|
+
|
|
148
|
+
return "\n\n---\n\n".join(parts)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def compact(
|
|
152
|
+
db: sqlite_utils.Database,
|
|
153
|
+
session_ids: list[str],
|
|
154
|
+
strategy: Strategy = "summary_only",
|
|
155
|
+
token_budget: int = 4096,
|
|
156
|
+
name: str | None = None,
|
|
157
|
+
target_tool: str | None = None,
|
|
158
|
+
) -> ContextBundle:
|
|
159
|
+
if not name:
|
|
160
|
+
name = f"bundle-{int(time.time())}"
|
|
161
|
+
|
|
162
|
+
if strategy == "summary_only":
|
|
163
|
+
text = _compact_summary_only(db, session_ids, token_budget)
|
|
164
|
+
elif strategy == "key_messages":
|
|
165
|
+
text = _compact_key_messages(db, session_ids, token_budget)
|
|
166
|
+
elif strategy == "full_recent":
|
|
167
|
+
text = _compact_full_recent(db, session_ids, token_budget)
|
|
168
|
+
else:
|
|
169
|
+
raise ValueError(f"Unknown strategy: {strategy!r}")
|
|
170
|
+
|
|
171
|
+
# Hard cap: never exceed budget
|
|
172
|
+
text = truncate_to_budget(text, token_budget)
|
|
173
|
+
actual_tokens = count_tokens(text)
|
|
174
|
+
|
|
175
|
+
return ContextBundle(
|
|
176
|
+
name=name,
|
|
177
|
+
source_sessions=session_ids,
|
|
178
|
+
compacted_text=text,
|
|
179
|
+
token_count=actual_tokens,
|
|
180
|
+
strategy=strategy,
|
|
181
|
+
target_tool=target_tool,
|
|
182
|
+
)
|
contextforge/core/db.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""SQLite schema, migrations, and CRUD helpers for ContextForge."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import sqlite_utils
|
|
9
|
+
|
|
10
|
+
from contextforge.models.session import ContextBundle, Session
|
|
11
|
+
|
|
12
|
+
_SCHEMA_VERSION = 1
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_db(db_path: Path) -> sqlite_utils.Database:
|
|
16
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
db = sqlite_utils.Database(db_path)
|
|
18
|
+
_migrate(db)
|
|
19
|
+
return db
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _migrate(db: sqlite_utils.Database) -> None:
|
|
23
|
+
if "schema_version" not in db.table_names():
|
|
24
|
+
db["schema_version"].insert({"version": 0})
|
|
25
|
+
|
|
26
|
+
row = next(db["schema_version"].rows)
|
|
27
|
+
version = row["version"]
|
|
28
|
+
|
|
29
|
+
if version < 1:
|
|
30
|
+
db["sessions"].create(
|
|
31
|
+
{
|
|
32
|
+
"id": str,
|
|
33
|
+
"tool": str,
|
|
34
|
+
"title": str,
|
|
35
|
+
"cwd": str,
|
|
36
|
+
"created_at": int,
|
|
37
|
+
"updated_at": int,
|
|
38
|
+
"first_message": str,
|
|
39
|
+
"token_count": int,
|
|
40
|
+
"raw_path": str,
|
|
41
|
+
"status": str,
|
|
42
|
+
"summary": str,
|
|
43
|
+
"summary_updated_at": int,
|
|
44
|
+
"tags": str,
|
|
45
|
+
},
|
|
46
|
+
pk="id",
|
|
47
|
+
if_not_exists=True,
|
|
48
|
+
)
|
|
49
|
+
db["context_bundles"].create(
|
|
50
|
+
{
|
|
51
|
+
"id": int,
|
|
52
|
+
"name": str,
|
|
53
|
+
"created_at": int,
|
|
54
|
+
"source_sessions": str,
|
|
55
|
+
"compacted_text": str,
|
|
56
|
+
"token_count": int,
|
|
57
|
+
"strategy": str,
|
|
58
|
+
"target_tool": str,
|
|
59
|
+
},
|
|
60
|
+
pk="id",
|
|
61
|
+
if_not_exists=True,
|
|
62
|
+
)
|
|
63
|
+
db["transfers"].create(
|
|
64
|
+
{
|
|
65
|
+
"id": int,
|
|
66
|
+
"bundle_id": int,
|
|
67
|
+
"target_tool": str,
|
|
68
|
+
"target_session_id": str,
|
|
69
|
+
"injected_at": int,
|
|
70
|
+
"method": str,
|
|
71
|
+
"command_used": str,
|
|
72
|
+
},
|
|
73
|
+
pk="id",
|
|
74
|
+
if_not_exists=True,
|
|
75
|
+
)
|
|
76
|
+
db["schema_version"].update(1, {"version": 1})
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def upsert_session(db: sqlite_utils.Database, session: Session) -> None:
|
|
80
|
+
existing = list(db["sessions"].rows_where("id = ?", [session.id]))
|
|
81
|
+
if existing:
|
|
82
|
+
existing_updated = existing[0]["updated_at"]
|
|
83
|
+
new_updated = int(session.updated_at.timestamp() * 1000)
|
|
84
|
+
if existing_updated >= new_updated:
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
first_msg = ""
|
|
88
|
+
if session.messages:
|
|
89
|
+
for m in session.messages:
|
|
90
|
+
if m.role == "user":
|
|
91
|
+
first_msg = m.content[:200]
|
|
92
|
+
break
|
|
93
|
+
|
|
94
|
+
db["sessions"].upsert(
|
|
95
|
+
{
|
|
96
|
+
"id": session.id,
|
|
97
|
+
"tool": session.tool,
|
|
98
|
+
"title": session.title,
|
|
99
|
+
"cwd": session.cwd,
|
|
100
|
+
"created_at": int(session.created_at.timestamp() * 1000),
|
|
101
|
+
"updated_at": int(session.updated_at.timestamp() * 1000),
|
|
102
|
+
"first_message": first_msg,
|
|
103
|
+
"token_count": session.token_count,
|
|
104
|
+
"raw_path": session.raw_path,
|
|
105
|
+
"status": session.status,
|
|
106
|
+
"summary": session.summary,
|
|
107
|
+
"summary_updated_at": None,
|
|
108
|
+
"tags": json.dumps(session.tags),
|
|
109
|
+
},
|
|
110
|
+
pk="id",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def get_sessions(
|
|
115
|
+
db: sqlite_utils.Database,
|
|
116
|
+
tool: str | None = None,
|
|
117
|
+
limit: int = 200,
|
|
118
|
+
) -> list[dict]:
|
|
119
|
+
where = "tool = ?" if tool else None
|
|
120
|
+
params = [tool] if tool else []
|
|
121
|
+
rows = db["sessions"].rows_where(
|
|
122
|
+
where,
|
|
123
|
+
params,
|
|
124
|
+
order_by="updated_at desc",
|
|
125
|
+
limit=limit,
|
|
126
|
+
)
|
|
127
|
+
return list(rows)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def get_session(db: sqlite_utils.Database, session_id: str) -> dict | None:
|
|
131
|
+
rows = list(db["sessions"].rows_where("id = ?", [session_id]))
|
|
132
|
+
return rows[0] if rows else None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def save_bundle(db: sqlite_utils.Database, bundle: ContextBundle) -> int:
|
|
136
|
+
result = db["context_bundles"].insert(
|
|
137
|
+
{
|
|
138
|
+
"name": bundle.name,
|
|
139
|
+
"created_at": int(time.time() * 1000),
|
|
140
|
+
"source_sessions": json.dumps(bundle.source_sessions),
|
|
141
|
+
"compacted_text": bundle.compacted_text,
|
|
142
|
+
"token_count": bundle.token_count,
|
|
143
|
+
"strategy": bundle.strategy,
|
|
144
|
+
"target_tool": bundle.target_tool,
|
|
145
|
+
}
|
|
146
|
+
)
|
|
147
|
+
return db.execute("SELECT last_insert_rowid()").fetchone()[0]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def get_bundle(db: sqlite_utils.Database, bundle_id: int) -> dict | None:
|
|
151
|
+
rows = list(db["context_bundles"].rows_where("id = ?", [bundle_id]))
|
|
152
|
+
return rows[0] if rows else None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def save_transfer(
|
|
156
|
+
db: sqlite_utils.Database,
|
|
157
|
+
bundle_id: int,
|
|
158
|
+
target_tool: str,
|
|
159
|
+
method: str,
|
|
160
|
+
command_used: str,
|
|
161
|
+
target_session_id: str | None = None,
|
|
162
|
+
) -> None:
|
|
163
|
+
db["transfers"].insert(
|
|
164
|
+
{
|
|
165
|
+
"bundle_id": bundle_id,
|
|
166
|
+
"target_tool": target_tool,
|
|
167
|
+
"target_session_id": target_session_id,
|
|
168
|
+
"injected_at": int(time.time() * 1000),
|
|
169
|
+
"method": method,
|
|
170
|
+
"command_used": command_used,
|
|
171
|
+
}
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def update_summary(
|
|
176
|
+
db: sqlite_utils.Database, session_id: str, summary: str
|
|
177
|
+
) -> None:
|
|
178
|
+
db["sessions"].update(
|
|
179
|
+
session_id,
|
|
180
|
+
{"summary": summary, "summary_updated_at": int(time.time() * 1000)},
|
|
181
|
+
)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Build and optionally execute context injection commands."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import shlex
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import sqlite_utils
|
|
11
|
+
|
|
12
|
+
from contextforge.adapters.registry import get_adapter
|
|
13
|
+
from contextforge.core.db import save_transfer
|
|
14
|
+
from contextforge.models.session import ContextBundle
|
|
15
|
+
|
|
16
|
+
# If compacted text exceeds this, write to a file instead of inline system prompt
|
|
17
|
+
_INLINE_TOKEN_LIMIT = 4096
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_inject_command(
|
|
21
|
+
bundle: ContextBundle,
|
|
22
|
+
target_tool: str,
|
|
23
|
+
target_session_id: str | None = None,
|
|
24
|
+
cwd: str | None = None,
|
|
25
|
+
method: str | None = None,
|
|
26
|
+
) -> tuple[str, str]:
|
|
27
|
+
"""Return (shell_command, method_used)."""
|
|
28
|
+
adapter = get_adapter(target_tool)
|
|
29
|
+
|
|
30
|
+
# Choose injection method based on token count
|
|
31
|
+
if method is None:
|
|
32
|
+
if bundle.token_count > _INLINE_TOKEN_LIMIT:
|
|
33
|
+
method = "file"
|
|
34
|
+
elif target_session_id:
|
|
35
|
+
method = "resume"
|
|
36
|
+
else:
|
|
37
|
+
method = "system_prompt"
|
|
38
|
+
|
|
39
|
+
if method == "file":
|
|
40
|
+
# Context goes into a temp CONTEXT.md; system prompt references it
|
|
41
|
+
ctx_summary = (
|
|
42
|
+
f"Prior session context has been written to CONTEXT.md in this directory. "
|
|
43
|
+
f"Read it before proceeding. Summary: {bundle.compacted_text[:300]}..."
|
|
44
|
+
)
|
|
45
|
+
cmd = adapter.build_inject_command(
|
|
46
|
+
context=ctx_summary,
|
|
47
|
+
target_session_id=None,
|
|
48
|
+
cwd=cwd,
|
|
49
|
+
method="system_prompt",
|
|
50
|
+
)
|
|
51
|
+
else:
|
|
52
|
+
cmd = adapter.build_inject_command(
|
|
53
|
+
context=bundle.compacted_text,
|
|
54
|
+
target_session_id=target_session_id,
|
|
55
|
+
cwd=cwd,
|
|
56
|
+
method=method,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
return cmd, method
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def execute_transfer(
|
|
63
|
+
db: sqlite_utils.Database,
|
|
64
|
+
bundle: ContextBundle,
|
|
65
|
+
bundle_id: int,
|
|
66
|
+
target_tool: str,
|
|
67
|
+
target_session_id: str | None = None,
|
|
68
|
+
cwd: str | None = None,
|
|
69
|
+
method: str | None = None,
|
|
70
|
+
) -> str:
|
|
71
|
+
"""Build the command, write CONTEXT.md if needed, record transfer, and execute."""
|
|
72
|
+
cmd, actual_method = build_inject_command(
|
|
73
|
+
bundle, target_tool, target_session_id, cwd, method
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
context_file: Path | None = None
|
|
77
|
+
|
|
78
|
+
if actual_method == "file":
|
|
79
|
+
work_dir = Path(cwd) if cwd else Path.cwd()
|
|
80
|
+
context_file = work_dir / "CONTEXT.md"
|
|
81
|
+
context_file.write_text(bundle.compacted_text, encoding="utf-8")
|
|
82
|
+
|
|
83
|
+
save_transfer(
|
|
84
|
+
db=db,
|
|
85
|
+
bundle_id=bundle_id,
|
|
86
|
+
target_tool=target_tool,
|
|
87
|
+
method=actual_method,
|
|
88
|
+
command_used=cmd,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Execute via shell
|
|
92
|
+
subprocess.run(cmd, shell=True, cwd=cwd or None)
|
|
93
|
+
|
|
94
|
+
return cmd
|