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.
Files changed (37) hide show
  1. context_forge_cli-0.1.0.dist-info/METADATA +340 -0
  2. context_forge_cli-0.1.0.dist-info/RECORD +37 -0
  3. context_forge_cli-0.1.0.dist-info/WHEEL +4 -0
  4. context_forge_cli-0.1.0.dist-info/entry_points.txt +2 -0
  5. contextforge/__init__.py +3 -0
  6. contextforge/adapters/__init__.py +3 -0
  7. contextforge/adapters/altimate_code.py +179 -0
  8. contextforge/adapters/base.py +44 -0
  9. contextforge/adapters/claude_code.py +393 -0
  10. contextforge/adapters/claude_desktop.py +306 -0
  11. contextforge/adapters/codex.py +220 -0
  12. contextforge/adapters/registry.py +31 -0
  13. contextforge/cli.py +523 -0
  14. contextforge/core/__init__.py +0 -0
  15. contextforge/core/analytics.py +226 -0
  16. contextforge/core/compactor.py +182 -0
  17. contextforge/core/db.py +181 -0
  18. contextforge/core/injector.py +94 -0
  19. contextforge/core/scanner.py +72 -0
  20. contextforge/core/summarizer.py +155 -0
  21. contextforge/core/token_analyzer.py +91 -0
  22. contextforge/models/__init__.py +4 -0
  23. contextforge/models/config.py +34 -0
  24. contextforge/models/session.py +37 -0
  25. contextforge/tui/__init__.py +0 -0
  26. contextforge/tui/app.py +204 -0
  27. contextforge/tui/styles.tcss +50 -0
  28. contextforge/tui/widgets/__init__.py +0 -0
  29. contextforge/tui/widgets/session_detail.py +191 -0
  30. contextforge/tui/widgets/session_table.py +277 -0
  31. contextforge/tui/widgets/stats_panel.py +340 -0
  32. contextforge/tui/widgets/status_bar.py +102 -0
  33. contextforge/tui/widgets/tokens_panel.py +134 -0
  34. contextforge/tui/widgets/transfer_panel.py +119 -0
  35. contextforge/utils/__init__.py +0 -0
  36. contextforge/utils/display.py +105 -0
  37. 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
+ )
@@ -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