context-forge-cli 0.4.12__tar.gz → 0.4.13__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/PKG-INFO +15 -1
  2. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/PLAN.md +2 -0
  3. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/README.md +14 -0
  4. context_forge_cli-0.4.13/contextforge/adapters/datatzis.py +353 -0
  5. context_forge_cli-0.4.13/contextforge/adapters/pi.py +450 -0
  6. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/adapters/registry.py +4 -0
  7. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/pyproject.toml +1 -1
  8. context_forge_cli-0.4.13/tests/adapters/test_datatzis.py +152 -0
  9. context_forge_cli-0.4.13/tests/adapters/test_pi.py +195 -0
  10. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/tests/conftest.py +2 -1
  11. context_forge_cli-0.4.13/tests/fixtures/datatzis/my-repo/.datatzis/tasks/22222222222222222222222222222222.json +41 -0
  12. context_forge_cli-0.4.13/tests/fixtures/datatzis/my-repo/.datatzis/tasks/33333333333333333333333333333333.json +31 -0
  13. context_forge_cli-0.4.13/tests/fixtures/datatzis/my-repo/.datatzis/workspaces/11111111111111111111111111111111.json +20 -0
  14. context_forge_cli-0.4.13/tests/fixtures/datatzis/my-repo/.datatzis/workspaces/99999999999999999999999999999999.json +1 -0
  15. context_forge_cli-0.4.13/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
  16. context_forge_cli-0.4.13/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
  17. context_forge_cli-0.4.13/tests/fixtures/pi/--home-dev-project-a-/2026-01-15T10-30-00-000Z_a1b2c3d4-0000-4000-8000-000000000001.jsonl +9 -0
  18. context_forge_cli-0.4.13/tests/fixtures/pi/--home-dev-project-a-/2026-01-15T11-20-00-000Z_c9d8e7f6-0000-4000-8000-000000000003.jsonl +9 -0
  19. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/tests/test_mcp_server.py +2 -2
  20. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/uv.lock +2 -2
  21. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/.contextforge.toml +0 -0
  22. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/.github/workflows/publish.yml +0 -0
  23. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/.gitignore +0 -0
  24. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/.python-version +0 -0
  25. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/AGENTS.md +0 -0
  26. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/__init__.py +0 -0
  27. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/adapters/__init__.py +0 -0
  28. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/adapters/altimate_code.py +0 -0
  29. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/adapters/antigravity.py +0 -0
  30. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/adapters/base.py +0 -0
  31. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/adapters/claude_code.py +0 -0
  32. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/adapters/claude_desktop.py +0 -0
  33. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/adapters/codex.py +0 -0
  34. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/adapters/cursor.py +0 -0
  35. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/adapters/gemini.py +0 -0
  36. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/cli.py +0 -0
  37. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/core/__init__.py +0 -0
  38. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/core/analytics.py +0 -0
  39. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/core/compactor.py +0 -0
  40. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/core/db.py +0 -0
  41. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/core/injector.py +0 -0
  42. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/core/scanner.py +0 -0
  43. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/core/summarizer.py +0 -0
  44. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/core/token_analyzer.py +0 -0
  45. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/mcp_server.py +0 -0
  46. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/models/__init__.py +0 -0
  47. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/models/config.py +0 -0
  48. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/models/session.py +0 -0
  49. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/tui/__init__.py +0 -0
  50. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/tui/app.py +0 -0
  51. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/tui/clipboard.py +0 -0
  52. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/tui/styles.tcss +0 -0
  53. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/tui/widgets/__init__.py +0 -0
  54. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/tui/widgets/session_detail.py +0 -0
  55. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/tui/widgets/session_table.py +0 -0
  56. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/tui/widgets/stats_panel.py +0 -0
  57. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/tui/widgets/status_bar.py +0 -0
  58. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/tui/widgets/tokens_panel.py +0 -0
  59. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/tui/widgets/transfer_panel.py +0 -0
  60. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/utils/__init__.py +0 -0
  61. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/utils/display.py +0 -0
  62. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/contextforge/utils/tokens.py +0 -0
  63. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/tests/__init__.py +0 -0
  64. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/tests/adapters/__init__.py +0 -0
  65. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/tests/adapters/test_antigravity.py +0 -0
  66. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/tests/adapters/test_claude_code.py +0 -0
  67. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/tests/adapters/test_cursor.py +0 -0
  68. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/tests/core/__init__.py +0 -0
  69. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/tests/core/test_compactor.py +0 -0
  70. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/tests/core/test_db.py +0 -0
  71. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/tests/core/test_injector.py +0 -0
  72. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/tests/core/test_summarizer.py +0 -0
  73. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/tests/core/test_token_analyzer.py +0 -0
  74. {context_forge_cli-0.4.12 → context_forge_cli-0.4.13}/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.12
3
+ Version: 0.4.13
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