ata-coder 2.4.2__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.
- ata_coder/__init__.py +1 -0
- ata_coder/agent.py +874 -0
- ata_coder/agent_compact.py +190 -0
- ata_coder/agent_controller.py +218 -0
- ata_coder/agent_extension.py +69 -0
- ata_coder/agent_routing.py +105 -0
- ata_coder/agent_subsystems.py +72 -0
- ata_coder/agent_tools.py +318 -0
- ata_coder/agent_undo.py +63 -0
- ata_coder/anthropic_client.py +465 -0
- ata_coder/change_tracker.py +368 -0
- ata_coder/clawd_integration.py +574 -0
- ata_coder/commands/__init__.py +128 -0
- ata_coder/commands/_core.py +184 -0
- ata_coder/commands/_safety.py +95 -0
- ata_coder/commands/_settings.py +241 -0
- ata_coder/commands/_workflow.py +451 -0
- ata_coder/commands.py +974 -0
- ata_coder/config.py +257 -0
- ata_coder/core/__init__.py +35 -0
- ata_coder/core/events.py +73 -0
- ata_coder/core/queue.py +85 -0
- ata_coder/core/state.py +17 -0
- ata_coder/event_queue.py +5 -0
- ata_coder/extension.py +654 -0
- ata_coder/extensions/__init__.py +1 -0
- ata_coder/extensions/hello_skill.py +47 -0
- ata_coder/fool_proof.py +295 -0
- ata_coder/git_workflow.py +371 -0
- ata_coder/gui.py +511 -0
- ata_coder/llm_client.py +543 -0
- ata_coder/main.py +814 -0
- ata_coder/mcp_client.py +1095 -0
- ata_coder/memory.py +539 -0
- ata_coder/model_registry.py +134 -0
- ata_coder/model_router.py +105 -0
- ata_coder/permissions.py +274 -0
- ata_coder/privilege.py +464 -0
- ata_coder/project.py +273 -0
- ata_coder/prompt_template.py +423 -0
- ata_coder/prompts/auto-mode.md +7 -0
- ata_coder/prompts/coding-rules.md +40 -0
- ata_coder/prompts/execution-guardrails.md +14 -0
- ata_coder/prompts/memory-system.md +24 -0
- ata_coder/prompts/output-style.md +23 -0
- ata_coder/prompts/safety.md +17 -0
- ata_coder/prompts/slash-commands.md +24 -0
- ata_coder/prompts/sub-agents.md +38 -0
- ata_coder/prompts/system-reminders.md +17 -0
- ata_coder/prompts/system.md +105 -0
- ata_coder/prompts/tool-policy.md +46 -0
- ata_coder/repl_theme.py +99 -0
- ata_coder/repl_tracker.py +89 -0
- ata_coder/repl_ui.py +1214 -0
- ata_coder/safety_guard.py +434 -0
- ata_coder/self_correct.py +346 -0
- ata_coder/server.py +882 -0
- ata_coder/server_session.py +159 -0
- ata_coder/server_shell.py +129 -0
- ata_coder/session.py +431 -0
- ata_coder/settings.py +439 -0
- ata_coder/setup_wizard.py +136 -0
- ata_coder/skill_extension.py +92 -0
- ata_coder/skills/architect/SKILL.md +42 -0
- ata_coder/skills/code-reviewer/SKILL.md +37 -0
- ata_coder/skills/codecraft/SKILL.md +452 -0
- ata_coder/skills/debugger/SKILL.md +45 -0
- ata_coder/skills/doc-writer/SKILL.md +36 -0
- ata_coder/skills/general-coder/SKILL.md +76 -0
- ata_coder/skills/math-calculator/README.md +40 -0
- ata_coder/skills/math-calculator/SKILL.md +59 -0
- ata_coder/skills/math-calculator/handler.py +103 -0
- ata_coder/skills/math-calculator/prompts/system.md +8 -0
- ata_coder/skills/math-calculator/requirements.txt +2 -0
- ata_coder/skills/math-calculator/resources/constants.json +8 -0
- ata_coder/skills/math-calculator/tests/test_handler.py +53 -0
- ata_coder/skills/security-auditor/SKILL.md +40 -0
- ata_coder/skills/test-writer/SKILL.md +36 -0
- ata_coder/skills/weather-skill/README.md +45 -0
- ata_coder/skills/weather-skill/handler.py +76 -0
- ata_coder/skills/weather-skill/manifest.json +48 -0
- ata_coder/skills/weather-skill/prompts/system_prompt.txt +9 -0
- ata_coder/skills/weather-skill/prompts/user_prompt_template.txt +3 -0
- ata_coder/skills/weather-skill/requirements.txt +1 -0
- ata_coder/skills/weather-skill/resources/city_list.json +17 -0
- ata_coder/skills/weather-skill/resources/error_messages.json +7 -0
- ata_coder/skills/weather-skill/tests/test_handler.py +28 -0
- ata_coder/skills/weather-skill/weather_utils.py +50 -0
- ata_coder/skills.py +1014 -0
- ata_coder/sub_agent.py +273 -0
- ata_coder/sub_agent_manager.py +203 -0
- ata_coder/system_prompt_builder.py +146 -0
- ata_coder/task_planner.py +391 -0
- ata_coder/terminal.py +318 -0
- ata_coder/test_runner.py +219 -0
- ata_coder/thread_supervisor.py +195 -0
- ata_coder/tool_defs.py +335 -0
- ata_coder/tools/__init__.py +11 -0
- ata_coder/tools/definitions.py +335 -0
- ata_coder/tools/executor.py +1036 -0
- ata_coder/tools/result.py +26 -0
- ata_coder/tools/subagent.py +332 -0
- ata_coder/tools/web.py +361 -0
- ata_coder/tools.py +1576 -0
- ata_coder/types.py +92 -0
- ata_coder/utils.py +113 -0
- ata_coder/web/css/style.css +180 -0
- ata_coder/web/index.html +84 -0
- ata_coder/web/js/app.js +489 -0
- ata_coder/web/package-lock.json +25 -0
- ata_coder/web/package.json +10 -0
- ata_coder/web/tsconfig.json +13 -0
- ata_coder-2.4.2.dist-info/METADATA +799 -0
- ata_coder-2.4.2.dist-info/RECORD +118 -0
- ata_coder-2.4.2.dist-info/WHEEL +5 -0
- ata_coder-2.4.2.dist-info/entry_points.txt +2 -0
- ata_coder-2.4.2.dist-info/licenses/LICENSE +21 -0
- ata_coder-2.4.2.dist-info/top_level.txt +1 -0
ata_coder/session.py
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session persistence — save, list, resume, and export conversations.
|
|
3
|
+
|
|
4
|
+
Sessions are stored as JSONL files in .ata_coder/sessions/.
|
|
5
|
+
Each line is a JSON object representing one message in the conversation.
|
|
6
|
+
|
|
7
|
+
Session metadata (timestamps, summary, skill used) is stored in a
|
|
8
|
+
sessions.json index file.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
from .session import SessionManager
|
|
12
|
+
sm = SessionManager()
|
|
13
|
+
sm.save("my-session", agent.state.messages)
|
|
14
|
+
sm.list_sessions()
|
|
15
|
+
messages = sm.load("my-session")
|
|
16
|
+
sm.delete("my-session")
|
|
17
|
+
sm.export_markdown("my-session", "output.md")
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from datetime import datetime, timezone
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ── Data model ───────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class SessionMeta:
|
|
34
|
+
"""Metadata about a saved session."""
|
|
35
|
+
id: str
|
|
36
|
+
created: str = ""
|
|
37
|
+
updated: str = ""
|
|
38
|
+
message_count: int = 0
|
|
39
|
+
tool_call_count: int = 0
|
|
40
|
+
summary: str = "" # first user message, truncated
|
|
41
|
+
skill: str = ""
|
|
42
|
+
model: str = ""
|
|
43
|
+
workspace: str = ""
|
|
44
|
+
tags: list[str] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> dict[str, Any]:
|
|
47
|
+
return {
|
|
48
|
+
"id": self.id,
|
|
49
|
+
"created": self.created,
|
|
50
|
+
"updated": self.updated,
|
|
51
|
+
"message_count": self.message_count,
|
|
52
|
+
"tool_call_count": self.tool_call_count,
|
|
53
|
+
"summary": self.summary,
|
|
54
|
+
"skill": self.skill,
|
|
55
|
+
"model": self.model,
|
|
56
|
+
"workspace": self.workspace,
|
|
57
|
+
"tags": self.tags,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def from_dict(cls, d: dict[str, Any]) -> "SessionMeta":
|
|
62
|
+
return cls(
|
|
63
|
+
id=d.get("id", ""),
|
|
64
|
+
created=d.get("created", ""),
|
|
65
|
+
updated=d.get("updated", ""),
|
|
66
|
+
message_count=d.get("message_count", 0),
|
|
67
|
+
tool_call_count=d.get("tool_call_count", 0),
|
|
68
|
+
summary=d.get("summary", ""),
|
|
69
|
+
skill=d.get("skill", ""),
|
|
70
|
+
model=d.get("model", ""),
|
|
71
|
+
workspace=d.get("workspace", ""),
|
|
72
|
+
tags=d.get("tags", []),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ── Session manager ──────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
class SessionManager:
|
|
79
|
+
"""
|
|
80
|
+
Manages conversation sessions: save, load, list, delete, export.
|
|
81
|
+
|
|
82
|
+
Session storage:
|
|
83
|
+
- .ata_coder/sessions/<id>.jsonl — conversation messages
|
|
84
|
+
- .ata_coder/sessions.json — index of all sessions
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(self, project_dir: str | Path | None = None):
|
|
88
|
+
if project_dir is None:
|
|
89
|
+
try:
|
|
90
|
+
from .settings import get_settings
|
|
91
|
+
project_dir = get_settings().data_dir
|
|
92
|
+
except Exception:
|
|
93
|
+
project_dir = Path.home() / ".ata_coder"
|
|
94
|
+
self._base_dir = Path(project_dir) / "sessions"
|
|
95
|
+
self._base_dir.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
self._index_path = self._base_dir.parent / "sessions.json"
|
|
97
|
+
self._index: dict[str, SessionMeta] = {}
|
|
98
|
+
self._load_index()
|
|
99
|
+
|
|
100
|
+
# ── Index management ─────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
def _load_index(self) -> None:
|
|
103
|
+
"""Load session index from disk."""
|
|
104
|
+
if self._index_path.exists():
|
|
105
|
+
try:
|
|
106
|
+
with open(self._index_path, "r", encoding="utf-8") as f:
|
|
107
|
+
data = json.load(f)
|
|
108
|
+
for item in data.get("sessions", []):
|
|
109
|
+
meta = SessionMeta.from_dict(item)
|
|
110
|
+
self._index[meta.id] = meta
|
|
111
|
+
logger.debug("Loaded %d sessions from index", len(self._index))
|
|
112
|
+
except Exception as e:
|
|
113
|
+
logger.warning("Failed to load sessions index: %s", e)
|
|
114
|
+
|
|
115
|
+
def _save_index(self) -> None:
|
|
116
|
+
"""Save session index to disk atomically (write-then-rename)."""
|
|
117
|
+
tmp = self._index_path.with_suffix(".tmp")
|
|
118
|
+
try:
|
|
119
|
+
from .utils import sanitize_surrogates
|
|
120
|
+
data = sanitize_surrogates({
|
|
121
|
+
"sessions": [m.to_dict() for m in self._index.values()],
|
|
122
|
+
"updated": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
123
|
+
})
|
|
124
|
+
# Belt-and-suspenders: json.dumps → encode→decode strips any
|
|
125
|
+
# lone surrogates that json.dumps(ensure_ascii=False) may emit.
|
|
126
|
+
raw = json.dumps(data, indent=2, ensure_ascii=False)
|
|
127
|
+
safe = raw.encode("utf-8", errors="replace").decode("utf-8")
|
|
128
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
129
|
+
f.write(safe)
|
|
130
|
+
tmp.replace(self._index_path)
|
|
131
|
+
except Exception as e:
|
|
132
|
+
logger.warning("Failed to save sessions index: %s", e)
|
|
133
|
+
|
|
134
|
+
# ── CRUD ─────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
def save(
|
|
137
|
+
self,
|
|
138
|
+
session_id: str,
|
|
139
|
+
messages: list[dict[str, Any]],
|
|
140
|
+
summary: str = "",
|
|
141
|
+
skill: str = "",
|
|
142
|
+
model: str = "",
|
|
143
|
+
workspace: str = "",
|
|
144
|
+
tool_call_count: int = 0,
|
|
145
|
+
) -> SessionMeta:
|
|
146
|
+
"""
|
|
147
|
+
Save a session's messages to disk.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
session_id: Unique session identifier
|
|
151
|
+
messages: List of OpenAI-format message dicts
|
|
152
|
+
summary: Short description (first user message, truncated)
|
|
153
|
+
skill: Active skill name
|
|
154
|
+
model: Model used
|
|
155
|
+
workspace: Workspace directory
|
|
156
|
+
tool_call_count: Number of tool calls made
|
|
157
|
+
"""
|
|
158
|
+
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
159
|
+
|
|
160
|
+
# Save messages as JSONL
|
|
161
|
+
session_file = self._base_dir / f"{session_id}.jsonl"
|
|
162
|
+
try:
|
|
163
|
+
from .utils import sanitize_surrogates
|
|
164
|
+
with open(session_file, "w", encoding="utf-8") as f:
|
|
165
|
+
for msg in messages:
|
|
166
|
+
safe_msg = sanitize_surrogates(msg)
|
|
167
|
+
# Round-trip through ASCII-safe JSON to guarantee no lone
|
|
168
|
+
# surrogates reach f.write() — ensure_ascii=True escapes
|
|
169
|
+
# all non-ASCII, then we decode back to unicode cleanly.
|
|
170
|
+
line = json.dumps(safe_msg, ensure_ascii=False)
|
|
171
|
+
# Belt-and-suspenders: encode→decode strips any remaining
|
|
172
|
+
# lone surrogates that json.dumps may have emitted.
|
|
173
|
+
line = line.encode("utf-8", errors="replace").decode("utf-8")
|
|
174
|
+
f.write(line + "\n")
|
|
175
|
+
except Exception as e:
|
|
176
|
+
logger.error("Failed to save session %s: %s", session_id, e)
|
|
177
|
+
raise
|
|
178
|
+
|
|
179
|
+
# Update index
|
|
180
|
+
existing = self._index.get(session_id)
|
|
181
|
+
meta = SessionMeta(
|
|
182
|
+
id=session_id,
|
|
183
|
+
created=existing.created if existing else now,
|
|
184
|
+
updated=now,
|
|
185
|
+
message_count=len(messages),
|
|
186
|
+
tool_call_count=tool_call_count,
|
|
187
|
+
summary=summary[:200] if summary else "",
|
|
188
|
+
skill=skill,
|
|
189
|
+
model=model,
|
|
190
|
+
workspace=workspace,
|
|
191
|
+
tags=existing.tags if existing else [],
|
|
192
|
+
)
|
|
193
|
+
self._index[session_id] = meta
|
|
194
|
+
self._save_index()
|
|
195
|
+
|
|
196
|
+
logger.info("Saved session %s: %d messages", session_id, len(messages))
|
|
197
|
+
return meta
|
|
198
|
+
|
|
199
|
+
def resolve_session_id(self, session_id: str) -> str | None:
|
|
200
|
+
"""
|
|
201
|
+
Resolve a session ID, supporting partial hash matching.
|
|
202
|
+
|
|
203
|
+
- Exact match → return as-is.
|
|
204
|
+
- 8-char hex hash → find the session whose ID ends with that hash.
|
|
205
|
+
- Multiple matches → return the most recently updated.
|
|
206
|
+
Returns None if no match found.
|
|
207
|
+
"""
|
|
208
|
+
if session_id in self._index:
|
|
209
|
+
return session_id
|
|
210
|
+
# Try hash-part match (user typed any of the three 8-char hashes)
|
|
211
|
+
if len(session_id) >= 4 and all(c in "0123456789abcdef" for c in session_id):
|
|
212
|
+
candidates = [
|
|
213
|
+
(meta.updated, sid)
|
|
214
|
+
for sid, meta in self._index.items()
|
|
215
|
+
if session_id in sid # matches any part of the 3-part ID
|
|
216
|
+
]
|
|
217
|
+
if candidates:
|
|
218
|
+
candidates.sort(reverse=True)
|
|
219
|
+
best = candidates[0][1]
|
|
220
|
+
logger.info("Resolved session %s → %s", session_id, best)
|
|
221
|
+
return best
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
def load(self, session_id: str) -> list[dict[str, Any]] | None:
|
|
225
|
+
"""
|
|
226
|
+
Load a session's messages from disk.
|
|
227
|
+
Supports partial hash matching via resolve_session_id().
|
|
228
|
+
Returns list of message dicts or None if not found.
|
|
229
|
+
"""
|
|
230
|
+
sid = self.resolve_session_id(session_id)
|
|
231
|
+
if sid is None:
|
|
232
|
+
logger.warning("Session not found: %s", session_id)
|
|
233
|
+
return None
|
|
234
|
+
session_file = self._base_dir / f"{sid}.jsonl"
|
|
235
|
+
if not session_file.exists():
|
|
236
|
+
logger.warning("Session file not found: %s", sid)
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
# Safety: refuse to load files > 100 MB (prevent memory exhaustion)
|
|
240
|
+
try:
|
|
241
|
+
st = session_file.stat()
|
|
242
|
+
if st.st_size > 100_000_000:
|
|
243
|
+
logger.error("Session file too large: %s (%d bytes)", session_id, st.st_size)
|
|
244
|
+
return None
|
|
245
|
+
except OSError:
|
|
246
|
+
pass
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
messages = []
|
|
250
|
+
with open(session_file, "r", encoding="utf-8") as f:
|
|
251
|
+
for line in f:
|
|
252
|
+
line = line.strip()
|
|
253
|
+
if line:
|
|
254
|
+
messages.append(json.loads(line))
|
|
255
|
+
logger.info("Loaded session %s: %d messages", session_id, len(messages))
|
|
256
|
+
return messages
|
|
257
|
+
except Exception as e:
|
|
258
|
+
logger.error("Failed to load session %s: %s", session_id, e)
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
def delete(self, session_id: str) -> bool:
|
|
262
|
+
"""Delete a session (messages + index entry)."""
|
|
263
|
+
session_file = self._base_dir / f"{session_id}.jsonl"
|
|
264
|
+
deleted = False
|
|
265
|
+
try:
|
|
266
|
+
session_file.unlink(missing_ok=True)
|
|
267
|
+
deleted = True
|
|
268
|
+
except Exception as e:
|
|
269
|
+
logger.error("Failed to delete session file %s: %s", session_id, e)
|
|
270
|
+
# Still clean up index — don't leak stale entries
|
|
271
|
+
|
|
272
|
+
if session_id in self._index:
|
|
273
|
+
del self._index[session_id]
|
|
274
|
+
self._save_index()
|
|
275
|
+
deleted = True
|
|
276
|
+
|
|
277
|
+
return deleted
|
|
278
|
+
|
|
279
|
+
def get_meta(self, session_id: str) -> SessionMeta | None:
|
|
280
|
+
"""Get session metadata."""
|
|
281
|
+
return self._index.get(session_id)
|
|
282
|
+
|
|
283
|
+
def list_sessions(self, limit: int = 20,
|
|
284
|
+
workspace: str | None = None) -> list[SessionMeta]:
|
|
285
|
+
"""List sessions, newest first. Optionally filter by workspace."""
|
|
286
|
+
sessions = self._index.values()
|
|
287
|
+
if workspace:
|
|
288
|
+
ws_normalized = str(Path(workspace).resolve())
|
|
289
|
+
sessions = [
|
|
290
|
+
s for s in sessions
|
|
291
|
+
if s.workspace and str(Path(s.workspace).resolve()) == ws_normalized
|
|
292
|
+
]
|
|
293
|
+
sorted_sessions = sorted(sessions, key=lambda m: m.updated, reverse=True)
|
|
294
|
+
return sorted_sessions[:limit]
|
|
295
|
+
|
|
296
|
+
def search_sessions(self, query: str,
|
|
297
|
+
workspace: str | None = None) -> list[SessionMeta]:
|
|
298
|
+
"""Search sessions by summary text. Optionally filter by workspace."""
|
|
299
|
+
q = query.lower()
|
|
300
|
+
results = []
|
|
301
|
+
for meta in self._index.values():
|
|
302
|
+
if q in meta.summary.lower() or q in meta.id.lower():
|
|
303
|
+
results.append(meta)
|
|
304
|
+
if workspace:
|
|
305
|
+
ws_normalized = str(Path(workspace).resolve())
|
|
306
|
+
results = [
|
|
307
|
+
r for r in results
|
|
308
|
+
if r.workspace and str(Path(r.workspace).resolve()) == ws_normalized
|
|
309
|
+
]
|
|
310
|
+
return sorted(results, key=lambda m: m.updated, reverse=True)
|
|
311
|
+
|
|
312
|
+
def get_recent(self, count: int = 5,
|
|
313
|
+
workspace: str | None = None) -> list[SessionMeta]:
|
|
314
|
+
"""Get the most recent sessions, optionally filtered to workspace."""
|
|
315
|
+
return self.list_sessions(limit=count, workspace=workspace)
|
|
316
|
+
|
|
317
|
+
def tag_session(self, session_id: str, tag: str) -> bool:
|
|
318
|
+
"""Add a tag to a session."""
|
|
319
|
+
meta = self._index.get(session_id)
|
|
320
|
+
if not meta:
|
|
321
|
+
return False
|
|
322
|
+
if tag not in meta.tags:
|
|
323
|
+
meta.tags.append(tag)
|
|
324
|
+
self._save_index()
|
|
325
|
+
return True
|
|
326
|
+
|
|
327
|
+
# ── Export ───────────────────────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
def export_markdown(self, session_id: str, output_path: str | None = None) -> str | None:
|
|
330
|
+
"""
|
|
331
|
+
Export a session as a Markdown file.
|
|
332
|
+
Returns the markdown content, or saves to output_path if provided.
|
|
333
|
+
"""
|
|
334
|
+
messages = self.load(session_id)
|
|
335
|
+
if not messages:
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
meta = self.get_meta(session_id)
|
|
339
|
+
lines = [
|
|
340
|
+
f"# Session: {session_id}",
|
|
341
|
+
"",
|
|
342
|
+
f"- **Created:** {meta.created if meta else 'unknown'}",
|
|
343
|
+
f"- **Model:** {meta.model if meta else 'unknown'}",
|
|
344
|
+
f"- **Skill:** {meta.skill if meta else 'none'}",
|
|
345
|
+
f"- **Messages:** {len(messages)}",
|
|
346
|
+
"",
|
|
347
|
+
"---",
|
|
348
|
+
"",
|
|
349
|
+
]
|
|
350
|
+
|
|
351
|
+
for msg in messages:
|
|
352
|
+
role = msg.get("role", "unknown")
|
|
353
|
+
content = msg.get("content", "")
|
|
354
|
+
|
|
355
|
+
if role == "system":
|
|
356
|
+
lines.append(f"<details>\n<summary>System Prompt</summary>\n\n```\n{content[:500]}\n```\n</details>\n")
|
|
357
|
+
elif role == "user":
|
|
358
|
+
lines.append(f"### User\n\n{content}\n")
|
|
359
|
+
elif role == "assistant":
|
|
360
|
+
if content:
|
|
361
|
+
lines.append(f"### Assistant\n\n{content}\n")
|
|
362
|
+
tool_calls = msg.get("tool_calls", [])
|
|
363
|
+
if tool_calls:
|
|
364
|
+
for tc in tool_calls:
|
|
365
|
+
fn = tc.get("function", {})
|
|
366
|
+
lines.append(f"**Tool:** `{fn.get('name', '?')}`\n")
|
|
367
|
+
lines.append(f"```json\n{fn.get('arguments', '')}\n```\n")
|
|
368
|
+
elif role == "tool":
|
|
369
|
+
lines.append(f"**Tool Result:**\n```\n{content[:500]}\n```\n")
|
|
370
|
+
|
|
371
|
+
lines.append("")
|
|
372
|
+
|
|
373
|
+
markdown = "\n".join(lines)
|
|
374
|
+
|
|
375
|
+
if output_path:
|
|
376
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
377
|
+
f.write(markdown)
|
|
378
|
+
logger.info("Exported session to %s", output_path)
|
|
379
|
+
|
|
380
|
+
return markdown
|
|
381
|
+
|
|
382
|
+
def export_json(self, session_id: str, output_path: str | None = None) -> str | None:
|
|
383
|
+
"""Export a session as a single JSON file."""
|
|
384
|
+
messages = self.load(session_id)
|
|
385
|
+
if not messages:
|
|
386
|
+
return None
|
|
387
|
+
|
|
388
|
+
meta = self.get_meta(session_id)
|
|
389
|
+
from .utils import sanitize_surrogates
|
|
390
|
+
data = sanitize_surrogates({
|
|
391
|
+
"session_id": session_id,
|
|
392
|
+
"metadata": meta.to_dict() if meta else {},
|
|
393
|
+
"messages": messages,
|
|
394
|
+
})
|
|
395
|
+
json_str = json.dumps(data, indent=2, ensure_ascii=False)
|
|
396
|
+
|
|
397
|
+
if output_path:
|
|
398
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
399
|
+
f.write(json_str)
|
|
400
|
+
|
|
401
|
+
return json_str
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# ── Auto-save helper ─────────────────────────────────────────────────────────
|
|
405
|
+
|
|
406
|
+
def generate_session_id(task: str, skill: str = "", workspace: str = "") -> str:
|
|
407
|
+
"""
|
|
408
|
+
Generate a 3-part hash-based session ID.
|
|
409
|
+
|
|
410
|
+
Format: ``xxxxxxxx-xxxxxxxx-xxxxxxxx`` (8-8-8 hex)
|
|
411
|
+
|
|
412
|
+
- Part 1: SHA256(workspace path) → first 8 chars — groups by project
|
|
413
|
+
- Part 2: SHA256(ISO timestamp) → first 8 chars — unique per session
|
|
414
|
+
- Part 3: SHA256(task text) → first 8 chars — identifies the task
|
|
415
|
+
|
|
416
|
+
The ``workspace`` parameter hashes the current working directory,
|
|
417
|
+
so ``//resume`` can find all sessions for a given project folder.
|
|
418
|
+
"""
|
|
419
|
+
import hashlib
|
|
420
|
+
# Sanitize inputs — lone surrogates crash .encode()
|
|
421
|
+
from .utils import sanitize_surrogates
|
|
422
|
+
ws_safe = sanitize_surrogates(workspace or "")
|
|
423
|
+
task_safe = sanitize_surrogates(task or "conversation")
|
|
424
|
+
# Part 1: workspace hash (project-scoped)
|
|
425
|
+
ws_hash = hashlib.sha256(ws_safe.encode()).hexdigest()[:8]
|
|
426
|
+
# Part 2: timestamp hash (unique per session)
|
|
427
|
+
now_iso = datetime.now(timezone.utc).isoformat()
|
|
428
|
+
time_hash = hashlib.sha256(now_iso.encode()).hexdigest()[:8]
|
|
429
|
+
# Part 3: task title hash (identifies the conversation topic)
|
|
430
|
+
task_hash = hashlib.sha256(task_safe.encode()).hexdigest()[:8]
|
|
431
|
+
return f"{ws_hash}-{time_hash}-{task_hash}"
|