context-forge-cli 0.4.12__tar.gz → 0.4.14__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.4.12 → context_forge_cli-0.4.14}/PKG-INFO +15 -1
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/PLAN.md +2 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/README.md +14 -0
- context_forge_cli-0.4.14/contextforge/adapters/datatzis.py +353 -0
- context_forge_cli-0.4.14/contextforge/adapters/pi.py +450 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/adapters/registry.py +4 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/cli.py +15 -7
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/core/db.py +5 -1
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/tui/widgets/session_detail.py +27 -5
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/tui/widgets/session_table.py +15 -1
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/utils/display.py +50 -1
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/pyproject.toml +1 -1
- context_forge_cli-0.4.14/tests/adapters/test_datatzis.py +152 -0
- context_forge_cli-0.4.14/tests/adapters/test_pi.py +195 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/tests/conftest.py +2 -1
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/tests/core/test_db.py +14 -0
- context_forge_cli-0.4.14/tests/core/test_display.py +81 -0
- context_forge_cli-0.4.14/tests/fixtures/datatzis/my-repo/.datatzis/tasks/22222222222222222222222222222222.json +41 -0
- context_forge_cli-0.4.14/tests/fixtures/datatzis/my-repo/.datatzis/tasks/33333333333333333333333333333333.json +31 -0
- context_forge_cli-0.4.14/tests/fixtures/datatzis/my-repo/.datatzis/workspaces/11111111111111111111111111111111.json +20 -0
- context_forge_cli-0.4.14/tests/fixtures/datatzis/my-repo/.datatzis/workspaces/99999999999999999999999999999999.json +1 -0
- context_forge_cli-0.4.14/tests/fixtures/pi/--home-dev-project-a-/2026-01-15T10-30-00-000Z_a1b2c3d4-0000-4000-8000-000000000001/e5f61122-3333-4444-5555-666677778888/run-0/session.jsonl +5 -0
- context_forge_cli-0.4.14/tests/fixtures/pi/--home-dev-project-a-/2026-01-15T10-30-00-000Z_a1b2c3d4-0000-4000-8000-000000000001/e5f61122-3333-4444-5555-666677778888/run-1/session.jsonl +5 -0
- context_forge_cli-0.4.14/tests/fixtures/pi/--home-dev-project-a-/2026-01-15T10-30-00-000Z_a1b2c3d4-0000-4000-8000-000000000001.jsonl +9 -0
- context_forge_cli-0.4.14/tests/fixtures/pi/--home-dev-project-a-/2026-01-15T11-20-00-000Z_c9d8e7f6-0000-4000-8000-000000000003.jsonl +9 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/tests/test_mcp_server.py +2 -2
- context_forge_cli-0.4.14/tests/test_tui_detail.py +117 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/uv.lock +2 -2
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/.contextforge.toml +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/.github/workflows/publish.yml +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/.gitignore +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/.python-version +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/AGENTS.md +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/__init__.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/adapters/__init__.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/adapters/altimate_code.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/adapters/antigravity.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/adapters/base.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/adapters/claude_code.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/adapters/claude_desktop.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/adapters/codex.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/adapters/cursor.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/adapters/gemini.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/core/__init__.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/core/analytics.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/core/compactor.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/core/injector.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/core/scanner.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/core/summarizer.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/core/token_analyzer.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/mcp_server.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/models/__init__.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/models/config.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/models/session.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/tui/__init__.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/tui/app.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/tui/clipboard.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/tui/styles.tcss +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/tui/widgets/__init__.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/tui/widgets/stats_panel.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/tui/widgets/status_bar.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/tui/widgets/tokens_panel.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/tui/widgets/transfer_panel.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/utils/__init__.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/contextforge/utils/tokens.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/tests/__init__.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/tests/adapters/__init__.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/tests/adapters/test_antigravity.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/tests/adapters/test_claude_code.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/tests/adapters/test_cursor.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/tests/core/__init__.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/tests/core/test_compactor.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/tests/core/test_injector.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/tests/core/test_summarizer.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/tests/core/test_token_analyzer.py +0 -0
- {context_forge_cli-0.4.12 → context_forge_cli-0.4.14}/tests/fixtures/claude_session_sample.jsonl +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: context-forge-cli
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.14
|
|
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
|
|
@@ -172,6 +172,20 @@ The method is chosen automatically based on token count and whether a target ses
|
|
|
172
172
|
| Antigravity | `~/.gemini/antigravity-cli/history.jsonl` | `-i` / `--conversation` |
|
|
173
173
|
| altimate-code | `~/.local/share/altimate-code/opencode.db` | `run -s` / `import` |
|
|
174
174
|
| Cursor | `~/.config/Cursor/User/globalStorage/state.vscdb` | `cursor-agent -p` / `--resume` |
|
|
175
|
+
| Pi agent | `~/.pi/agent/sessions/` JSONL | `--system-prompt` / `--session` + `--append-system-prompt` |
|
|
176
|
+
| datatzis harness | `<repo>/.datatzis/` JSON (per-repo) | launch/resume only: `datatzis chat --resume` (delivered interactively) |
|
|
177
|
+
|
|
178
|
+
Notes:
|
|
179
|
+
|
|
180
|
+
- **Sub-agent tracking** — sessions spawned by sub-agents are tracked and cross-linked: Pi
|
|
181
|
+
(`pi-subagents`) child transcripts get `subagent` + `parent:<session-id>` tags naming the
|
|
182
|
+
parent session; the datatzis harness's orchestrator tasks (one per user prompt) get
|
|
183
|
+
`subagent` + `workspace:<session-id>` tags.
|
|
184
|
+
- **datatzis discovery** — per-repo storage: ContextForge scans repository roots for
|
|
185
|
+
`*/.datatzis/workspaces/` (up to two levels deep). Configure roots via the
|
|
186
|
+
`DATATZIS_SEARCH_ROOTS` environment variable (colon-separated; default `~/Documents/Github`).
|
|
187
|
+
The datatzis CLI is interactive-only, so inject commands open or resume the session rather
|
|
188
|
+
than passing context as a flag.
|
|
175
189
|
|
|
176
190
|
## Session Summaries
|
|
177
191
|
|
|
@@ -89,6 +89,8 @@ Agents and contributors: update this file as you work.
|
|
|
89
89
|
- [ ] `open-claw` adapter (if/when CLI interface is documented)
|
|
90
90
|
- [x] Antigravity adapter (`adapters/antigravity.py`)
|
|
91
91
|
- [x] Cursor adapter (`adapters/cursor.py`)
|
|
92
|
+
- [x] Pi agent adapter (`adapters/pi.py`) — including sub-agent session tracking (`pi-subagents` runs nested under the parent session stem)
|
|
93
|
+
- [x] datatzis harness adapter (`adapters/datatzis.py`) — per-repo `.datatzis/` workspaces + orchestrator-task sub-agent tracking (consultation/dossier runs intentionally out of scope)
|
|
92
94
|
- [ ] Session search: `cf ls --search <query>`
|
|
93
95
|
- [ ] Session archiving: `cf archive <id>`
|
|
94
96
|
|
|
@@ -143,6 +143,20 @@ The method is chosen automatically based on token count and whether a target ses
|
|
|
143
143
|
| Antigravity | `~/.gemini/antigravity-cli/history.jsonl` | `-i` / `--conversation` |
|
|
144
144
|
| altimate-code | `~/.local/share/altimate-code/opencode.db` | `run -s` / `import` |
|
|
145
145
|
| Cursor | `~/.config/Cursor/User/globalStorage/state.vscdb` | `cursor-agent -p` / `--resume` |
|
|
146
|
+
| Pi agent | `~/.pi/agent/sessions/` JSONL | `--system-prompt` / `--session` + `--append-system-prompt` |
|
|
147
|
+
| datatzis harness | `<repo>/.datatzis/` JSON (per-repo) | launch/resume only: `datatzis chat --resume` (delivered interactively) |
|
|
148
|
+
|
|
149
|
+
Notes:
|
|
150
|
+
|
|
151
|
+
- **Sub-agent tracking** — sessions spawned by sub-agents are tracked and cross-linked: Pi
|
|
152
|
+
(`pi-subagents`) child transcripts get `subagent` + `parent:<session-id>` tags naming the
|
|
153
|
+
parent session; the datatzis harness's orchestrator tasks (one per user prompt) get
|
|
154
|
+
`subagent` + `workspace:<session-id>` tags.
|
|
155
|
+
- **datatzis discovery** — per-repo storage: ContextForge scans repository roots for
|
|
156
|
+
`*/.datatzis/workspaces/` (up to two levels deep). Configure roots via the
|
|
157
|
+
`DATATZIS_SEARCH_ROOTS` environment variable (colon-separated; default `~/Documents/Github`).
|
|
158
|
+
The datatzis CLI is interactive-only, so inject commands open or resume the session rather
|
|
159
|
+
than passing context as a flag.
|
|
146
160
|
|
|
147
161
|
## Session Summaries
|
|
148
162
|
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""Adapter for the datatzis harness (per-repo writing harness).
|
|
2
|
+
|
|
3
|
+
The datatzis harness stores its data inside each repository under
|
|
4
|
+
``<repo>/.datatzis/``:
|
|
5
|
+
|
|
6
|
+
* ``workspaces/<32-hex>.json`` — chat sessions. A single JSON object with an
|
|
7
|
+
``id`` (32-hex), ``name``, ``transcript`` (list of ``{role, text}`` rows with
|
|
8
|
+
roles ``user`` / ``assistant`` / ``notice``), the ids of the orchestrator
|
|
9
|
+
tasks it spawned, ``run_id``, ``agent`` spec and an ``updated_at`` stamp.
|
|
10
|
+
* ``tasks/<32-hex>.json`` — one orchestrator execution per user prompt. These
|
|
11
|
+
are the harness's sub-agent units: each records the ``prompt``, ``status``
|
|
12
|
+
(``running`` / ``completed`` / ``failed``), epoch float ``started`` /
|
|
13
|
+
``ended`` stamps, tool ``events`` (tool name, ``arguments_sha256`` — actual
|
|
14
|
+
arguments are intentionally not stored — and results) and an ``info`` block
|
|
15
|
+
with token usage. Workspaces link to their tasks via ``data["tasks"]``.
|
|
16
|
+
* ``consultations/<id>.json`` and ``runs/<ts-slug>/`` are tied to research
|
|
17
|
+
dossiers rather than chat sessions and are deliberately not tracked here.
|
|
18
|
+
|
|
19
|
+
Because ``notice`` rows carry stage/system notices, they are mapped to
|
|
20
|
+
``system`` messages. Per-message timestamps do not exist in workspaces (only
|
|
21
|
+
a session-level ``updated_at``); tasks carry epoch stamps and are used for the
|
|
22
|
+
sub-agent messages.
|
|
23
|
+
|
|
24
|
+
The harness CLI is interactive-only: ``datatzis chat --resume <id-or-name>``
|
|
25
|
+
reopens a session. It has no non-interactive system-prompt surface, so
|
|
26
|
+
``build_inject_command`` returns either a resume command or a plain ``datatzis
|
|
27
|
+
chat`` launch.
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import shlex
|
|
34
|
+
from datetime import datetime, timezone
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
from contextforge.adapters.base import ToolAdapter
|
|
38
|
+
from contextforge.models.session import Message, Session
|
|
39
|
+
from contextforge.utils.tokens import count_tokens
|
|
40
|
+
|
|
41
|
+
DEFAULT_SEARCH_ROOTS: tuple[Path, ...] = (Path.home() / "Documents" / "Github",)
|
|
42
|
+
|
|
43
|
+
_TITLE_SNIPPET_LEN = 80
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _search_roots() -> list[Path]:
|
|
47
|
+
"""Repository roots scanned for ``*/.datatzis/workspaces`` folders."""
|
|
48
|
+
env = os.environ.get("DATATZIS_SEARCH_ROOTS", "")
|
|
49
|
+
if env.strip():
|
|
50
|
+
return [Path(p).expanduser() for p in env.split(os.pathsep) if p.strip()]
|
|
51
|
+
return list(DEFAULT_SEARCH_ROOTS)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _parse_iso(ts) -> datetime | None:
|
|
55
|
+
if not isinstance(ts, str):
|
|
56
|
+
return None
|
|
57
|
+
try:
|
|
58
|
+
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
59
|
+
except (ValueError, AttributeError):
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _parse_epoch(ts) -> datetime | None:
|
|
64
|
+
if isinstance(ts, (int, float)) and ts > 0:
|
|
65
|
+
try:
|
|
66
|
+
return datetime.fromtimestamp(ts, tz=timezone.utc)
|
|
67
|
+
except (OverflowError, OSError, ValueError):
|
|
68
|
+
return None
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _snippet(text: str, limit: int = _TITLE_SNIPPET_LEN) -> str:
|
|
73
|
+
return " ".join(str(text).split())[:limit]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _json_text(value) -> str:
|
|
77
|
+
if isinstance(value, str):
|
|
78
|
+
return value
|
|
79
|
+
try:
|
|
80
|
+
return json.dumps(value, ensure_ascii=False)
|
|
81
|
+
except (TypeError, ValueError):
|
|
82
|
+
return ""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _load_json(path: Path) -> dict | None:
|
|
86
|
+
try:
|
|
87
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
88
|
+
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
|
89
|
+
return None
|
|
90
|
+
return data if isinstance(data, dict) else None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _compute_message_tokens(msg: Message) -> int:
|
|
94
|
+
total = count_tokens(msg.content)
|
|
95
|
+
for tc in msg.tool_calls:
|
|
96
|
+
total += count_tokens(tc.get("input", ""))
|
|
97
|
+
for tr in msg.tool_results:
|
|
98
|
+
total += count_tokens(tr.get("output", ""))
|
|
99
|
+
return total
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class DatatzisAdapter(ToolAdapter):
|
|
103
|
+
tool_name = "datatzis"
|
|
104
|
+
default_paths = list(DEFAULT_SEARCH_ROOTS)
|
|
105
|
+
|
|
106
|
+
def is_available(self) -> bool:
|
|
107
|
+
return any(root.exists() for root in _search_roots())
|
|
108
|
+
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
# Storage layout
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def _iter_workspaces(self) -> list[tuple[Path, Path]]:
|
|
114
|
+
"""Yield (workspace_file, repo_root) pairs for every known repo."""
|
|
115
|
+
results: list[tuple[Path, Path]] = []
|
|
116
|
+
seen: set[Path] = set()
|
|
117
|
+
for root in _search_roots():
|
|
118
|
+
if not root.is_dir():
|
|
119
|
+
continue
|
|
120
|
+
candidates: list[Path] = []
|
|
121
|
+
for depth in (1, 2): # <root>/<repo> and nested installs
|
|
122
|
+
star = "/".join(["*"] * depth)
|
|
123
|
+
candidates.extend(root.glob(f"{star}/.datatzis/workspaces"))
|
|
124
|
+
candidates.append(root / ".datatzis" / "workspaces")
|
|
125
|
+
for ws_dir in sorted(set(candidates)):
|
|
126
|
+
if not ws_dir.is_dir():
|
|
127
|
+
continue
|
|
128
|
+
repo_root = ws_dir.parent.parent
|
|
129
|
+
for ws_file in sorted(ws_dir.glob("*.json")):
|
|
130
|
+
if ws_file in seen:
|
|
131
|
+
continue
|
|
132
|
+
seen.add(ws_file)
|
|
133
|
+
results.append((ws_file, repo_root))
|
|
134
|
+
return results
|
|
135
|
+
|
|
136
|
+
def _task_file(self, repo_root: Path, task_id: str) -> Path:
|
|
137
|
+
return repo_root / ".datatzis" / "tasks" / f"{task_id}.json"
|
|
138
|
+
|
|
139
|
+
def _session_path(self, session_id: str) -> tuple[Path, Path] | None:
|
|
140
|
+
for ws_file, repo_root in self._iter_workspaces():
|
|
141
|
+
if ws_file.stem == session_id:
|
|
142
|
+
return ws_file, repo_root
|
|
143
|
+
data = _load_json(ws_file)
|
|
144
|
+
if data and data.get("id") == session_id:
|
|
145
|
+
return ws_file, repo_root
|
|
146
|
+
for ws_file, repo_root in self._iter_workspaces():
|
|
147
|
+
task_file = self._task_file(repo_root, session_id)
|
|
148
|
+
if task_file.exists():
|
|
149
|
+
return task_file, repo_root
|
|
150
|
+
# Fallback for ids found in the header when workspaces live outside
|
|
151
|
+
# the configured roots is not needed: files are named after ids.
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
# Discovery
|
|
156
|
+
# ------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
def discover_sessions(self) -> list[Session]:
|
|
159
|
+
"""Discover workspace sessions and their orchestrator-task sub-agents."""
|
|
160
|
+
sessions: list[Session] = []
|
|
161
|
+
for ws_file, repo_root in self._iter_workspaces():
|
|
162
|
+
data = _load_json(ws_file)
|
|
163
|
+
if not data or not data.get("id"):
|
|
164
|
+
continue
|
|
165
|
+
sessions.append(self._workspace_session(ws_file, repo_root, data))
|
|
166
|
+
sessions.extend(self._task_sessions(repo_root, data))
|
|
167
|
+
return sessions
|
|
168
|
+
|
|
169
|
+
def _workspace_session(self, ws_file: Path, repo_root: Path, data: dict) -> Session:
|
|
170
|
+
session_id = str(data["id"])
|
|
171
|
+
tasks = data.get("tasks") or []
|
|
172
|
+
created: datetime | None = None
|
|
173
|
+
updated = _parse_iso(data.get("updated_at"))
|
|
174
|
+
if updated is None:
|
|
175
|
+
updated = datetime.fromtimestamp(ws_file.stat().st_mtime, tz=timezone.utc)
|
|
176
|
+
if tasks:
|
|
177
|
+
for task_id in tasks:
|
|
178
|
+
task = _load_json(self._task_file(repo_root, str(task_id)))
|
|
179
|
+
started = _parse_epoch(task.get("started")) if task else None
|
|
180
|
+
if started is not None:
|
|
181
|
+
created = started if created is None else min(created, started)
|
|
182
|
+
if created is None:
|
|
183
|
+
created = updated
|
|
184
|
+
|
|
185
|
+
# Token count: the displayed transcript (not the agent restore-thread)
|
|
186
|
+
tokens = 0
|
|
187
|
+
for row in data.get("transcript") or []:
|
|
188
|
+
tokens += count_tokens(str(row.get("text", "")))
|
|
189
|
+
tokens += count_tokens("\n".join(str(u) for u in data.get("unresolved") or []))
|
|
190
|
+
|
|
191
|
+
name = str(data.get("name") or "")
|
|
192
|
+
title = name if name and name != "Untitled" else None
|
|
193
|
+
if title is None:
|
|
194
|
+
for row in data.get("transcript") or []:
|
|
195
|
+
if row.get("role") == "user" and row.get("text"):
|
|
196
|
+
title = _snippet(row["text"])
|
|
197
|
+
break
|
|
198
|
+
|
|
199
|
+
return Session(
|
|
200
|
+
id=session_id,
|
|
201
|
+
tool=self.tool_name,
|
|
202
|
+
title=title,
|
|
203
|
+
cwd=str(repo_root),
|
|
204
|
+
created_at=created,
|
|
205
|
+
updated_at=updated,
|
|
206
|
+
status="unknown",
|
|
207
|
+
token_count=tokens or None,
|
|
208
|
+
raw_path=str(ws_file),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
def _task_sessions(self, repo_root: Path, workspace: dict) -> list[Session]:
|
|
212
|
+
"""One Session per orchestrator task (sub-agent execution)."""
|
|
213
|
+
session: list[Session] = []
|
|
214
|
+
workspace_id = str(workspace.get("id"))
|
|
215
|
+
for task_id in workspace.get("tasks") or []:
|
|
216
|
+
task_file = self._task_file(repo_root, str(task_id))
|
|
217
|
+
data = _load_json(task_file)
|
|
218
|
+
if not data:
|
|
219
|
+
continue
|
|
220
|
+
created = _parse_epoch(data.get("started"))
|
|
221
|
+
updated = _parse_epoch(data.get("ended"))
|
|
222
|
+
events = data.get("events") or []
|
|
223
|
+
if updated is None and events:
|
|
224
|
+
updated = _parse_epoch(events[-1].get("ended")) or created
|
|
225
|
+
updated = updated or created
|
|
226
|
+
tokens = count_tokens(str(data.get("prompt", "")))
|
|
227
|
+
summary = None
|
|
228
|
+
for event in events:
|
|
229
|
+
tokens += count_tokens(event.get("tool", ""))
|
|
230
|
+
result = event.get("result")
|
|
231
|
+
if result is not None:
|
|
232
|
+
tokens += count_tokens(_json_text(result))
|
|
233
|
+
if event.get("error"):
|
|
234
|
+
tokens += count_tokens(str(event["error"]))
|
|
235
|
+
summary = str(event["error"])
|
|
236
|
+
if data.get("error"):
|
|
237
|
+
summary = str(data["error"])
|
|
238
|
+
|
|
239
|
+
title = _snippet(str(data.get("prompt", ""))) or None
|
|
240
|
+
session.append(
|
|
241
|
+
Session(
|
|
242
|
+
id=str(data.get("id") or task_id),
|
|
243
|
+
tool=self.tool_name,
|
|
244
|
+
title=title,
|
|
245
|
+
cwd=str(repo_root),
|
|
246
|
+
created_at=created,
|
|
247
|
+
updated_at=updated,
|
|
248
|
+
status=str(data.get("status") or "unknown"),
|
|
249
|
+
token_count=tokens or None,
|
|
250
|
+
raw_path=str(task_file),
|
|
251
|
+
summary=summary,
|
|
252
|
+
tags=["subagent", f"workspace:{workspace_id}"],
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
return session
|
|
256
|
+
|
|
257
|
+
# ------------------------------------------------------------------
|
|
258
|
+
# Message loading
|
|
259
|
+
# ------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
def load_messages(self, session_id: str) -> list[Message]:
|
|
262
|
+
resolved = self._session_path(session_id)
|
|
263
|
+
if resolved is None:
|
|
264
|
+
return []
|
|
265
|
+
path, _repo_root = resolved
|
|
266
|
+
data = _load_json(path)
|
|
267
|
+
if data is None:
|
|
268
|
+
return []
|
|
269
|
+
if data.get("id") == session_id and (data.get("transcript") is not None or "tasks" in data):
|
|
270
|
+
return self._workspace_messages(data) # main workspace session
|
|
271
|
+
if "events" in data or "prompt" in data:
|
|
272
|
+
return self._task_messages(data) # orchestrator task session
|
|
273
|
+
return []
|
|
274
|
+
|
|
275
|
+
def _workspace_messages(self, data: dict) -> list[Message]:
|
|
276
|
+
messages: list[Message] = []
|
|
277
|
+
for row in data.get("transcript") or []:
|
|
278
|
+
text = str(row.get("text", "") or "")
|
|
279
|
+
if not text:
|
|
280
|
+
continue
|
|
281
|
+
role = row.get("role")
|
|
282
|
+
if role in ("user", "assistant"):
|
|
283
|
+
mapped = role
|
|
284
|
+
elif role == "notice":
|
|
285
|
+
mapped = "system"
|
|
286
|
+
else:
|
|
287
|
+
continue
|
|
288
|
+
msg = Message(role=mapped, content=text)
|
|
289
|
+
msg.token_count = count_tokens(text)
|
|
290
|
+
messages.append(msg)
|
|
291
|
+
return messages
|
|
292
|
+
|
|
293
|
+
def _task_messages(self, data: dict) -> list[Message]:
|
|
294
|
+
"""User prompt + one aggregate assistant turn holding all tool events.
|
|
295
|
+
|
|
296
|
+
Task events record ``arguments_sha256`` only (the harness does not
|
|
297
|
+
persist raw arguments), so tool-call ``input`` is empty by design.
|
|
298
|
+
"""
|
|
299
|
+
messages: list[Message] = []
|
|
300
|
+
prompt = str(data.get("prompt", "") or "")
|
|
301
|
+
if prompt:
|
|
302
|
+
msg = Message(role="user", content=prompt, timestamp=_parse_epoch(data.get("started")))
|
|
303
|
+
msg.token_count = count_tokens(prompt)
|
|
304
|
+
messages.append(msg)
|
|
305
|
+
|
|
306
|
+
tool_calls: list[dict] = []
|
|
307
|
+
tool_results: list[dict] = []
|
|
308
|
+
last_ended = _parse_epoch(data.get("ended"))
|
|
309
|
+
for event in data.get("events") or []:
|
|
310
|
+
tool_name = event.get("tool", "?")
|
|
311
|
+
tool_calls.append({"name": tool_name, "input": ""})
|
|
312
|
+
result = event.get("result")
|
|
313
|
+
if result is not None:
|
|
314
|
+
tool_results.append({"tool": tool_name, "output": _json_text(result)})
|
|
315
|
+
if event.get("error"):
|
|
316
|
+
tool_results.append({"tool": tool_name, "output": f"error: {event['error']}"})
|
|
317
|
+
ended = _parse_epoch(event.get("ended"))
|
|
318
|
+
if ended is not None:
|
|
319
|
+
last_ended = ended if last_ended is None else max(last_ended, ended)
|
|
320
|
+
if tool_calls:
|
|
321
|
+
msg = Message(
|
|
322
|
+
role="assistant",
|
|
323
|
+
content=f"{len(tool_calls)} tool calls ({data.get('status') or 'unknown'})",
|
|
324
|
+
timestamp=last_ended,
|
|
325
|
+
tool_calls=tool_calls,
|
|
326
|
+
tool_results=tool_results,
|
|
327
|
+
)
|
|
328
|
+
msg.token_count = _compute_message_tokens(msg)
|
|
329
|
+
messages.append(msg)
|
|
330
|
+
return messages
|
|
331
|
+
|
|
332
|
+
# ------------------------------------------------------------------
|
|
333
|
+
# Injection
|
|
334
|
+
# ------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
def build_inject_command(
|
|
337
|
+
self,
|
|
338
|
+
context: str,
|
|
339
|
+
target_session_id: str | None = None,
|
|
340
|
+
cwd: str | None = None,
|
|
341
|
+
method: str = "system_prompt",
|
|
342
|
+
) -> str:
|
|
343
|
+
# datatzis chat is interactive-only: there is no system-prompt or
|
|
344
|
+
# one-shot flag, so injection opens (or resumes) the session and the
|
|
345
|
+
# harness's own handoff/line-mode delivers the context.
|
|
346
|
+
if method == "resume" and target_session_id:
|
|
347
|
+
if cwd:
|
|
348
|
+
return f"cd {shlex.quote(cwd)} && datatzis chat --resume {shlex.quote(target_session_id)}"
|
|
349
|
+
return f"datatzis chat --resume {shlex.quote(target_session_id)}"
|
|
350
|
+
cmd = "datatzis chat"
|
|
351
|
+
if cwd:
|
|
352
|
+
cmd = f"cd {shlex.quote(cwd)} && {cmd}"
|
|
353
|
+
return cmd
|