axion-code 1.0.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.
- axion/__init__.py +3 -0
- axion/api/__init__.py +0 -0
- axion/api/anthropic.py +460 -0
- axion/api/client.py +259 -0
- axion/api/error.py +161 -0
- axion/api/ollama.py +597 -0
- axion/api/openai_compat.py +805 -0
- axion/api/openai_responses.py +627 -0
- axion/api/prompt_cache.py +31 -0
- axion/api/sse.py +98 -0
- axion/api/types.py +451 -0
- axion/cli/__init__.py +0 -0
- axion/cli/init_cmd.py +50 -0
- axion/cli/input.py +290 -0
- axion/cli/main.py +2953 -0
- axion/cli/render.py +489 -0
- axion/cli/tui.py +766 -0
- axion/commands/__init__.py +0 -0
- axion/commands/handlers/__init__.py +0 -0
- axion/commands/handlers/agents.py +51 -0
- axion/commands/handlers/builtin_commands.py +367 -0
- axion/commands/handlers/mcp.py +59 -0
- axion/commands/handlers/models.py +75 -0
- axion/commands/handlers/plugins.py +55 -0
- axion/commands/handlers/skills.py +61 -0
- axion/commands/parsing.py +317 -0
- axion/commands/registry.py +166 -0
- axion/compat_harness/__init__.py +0 -0
- axion/compat_harness/extractor.py +145 -0
- axion/plugins/__init__.py +0 -0
- axion/plugins/hooks.py +22 -0
- axion/plugins/manager.py +391 -0
- axion/plugins/manifest.py +270 -0
- axion/runtime/__init__.py +0 -0
- axion/runtime/bash.py +388 -0
- axion/runtime/bootstrap.py +39 -0
- axion/runtime/claude_subscription.py +300 -0
- axion/runtime/compact.py +233 -0
- axion/runtime/config.py +397 -0
- axion/runtime/conversation.py +1073 -0
- axion/runtime/file_ops.py +613 -0
- axion/runtime/git.py +213 -0
- axion/runtime/hooks.py +235 -0
- axion/runtime/image.py +212 -0
- axion/runtime/lanes.py +282 -0
- axion/runtime/lsp.py +425 -0
- axion/runtime/mcp/__init__.py +0 -0
- axion/runtime/mcp/client.py +76 -0
- axion/runtime/mcp/lifecycle.py +96 -0
- axion/runtime/mcp/stdio.py +318 -0
- axion/runtime/mcp/tool_bridge.py +79 -0
- axion/runtime/memory.py +196 -0
- axion/runtime/oauth.py +329 -0
- axion/runtime/openai_subscription.py +346 -0
- axion/runtime/permissions.py +247 -0
- axion/runtime/plan_mode.py +96 -0
- axion/runtime/policy_engine.py +259 -0
- axion/runtime/prompt.py +586 -0
- axion/runtime/recovery.py +261 -0
- axion/runtime/remote.py +28 -0
- axion/runtime/sandbox.py +68 -0
- axion/runtime/scheduler.py +231 -0
- axion/runtime/session.py +365 -0
- axion/runtime/sharing.py +159 -0
- axion/runtime/skills.py +124 -0
- axion/runtime/tasks.py +258 -0
- axion/runtime/usage.py +241 -0
- axion/runtime/workers.py +186 -0
- axion/telemetry/__init__.py +0 -0
- axion/telemetry/events.py +67 -0
- axion/telemetry/profile.py +49 -0
- axion/telemetry/sink.py +60 -0
- axion/telemetry/tracer.py +95 -0
- axion/tools/__init__.py +0 -0
- axion/tools/lane_completion.py +33 -0
- axion/tools/registry.py +853 -0
- axion/tools/tool_search.py +226 -0
- axion_code-1.0.0.dist-info/METADATA +709 -0
- axion_code-1.0.0.dist-info/RECORD +82 -0
- axion_code-1.0.0.dist-info/WHEEL +4 -0
- axion_code-1.0.0.dist-info/entry_points.txt +2 -0
- axion_code-1.0.0.dist-info/licenses/LICENSE +21 -0
axion/runtime/session.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Session persistence with JSONL format.
|
|
2
|
+
|
|
3
|
+
Maps to: rust/crates/runtime/src/session.rs
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import enum
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from axion.runtime.usage import TokenUsage
|
|
17
|
+
|
|
18
|
+
SESSION_VERSION = 1
|
|
19
|
+
ROTATE_AFTER_BYTES = 256 * 1024 # 256 KB
|
|
20
|
+
MAX_ROTATED_FILES = 3
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MessageRole(enum.Enum):
|
|
24
|
+
SYSTEM = "system"
|
|
25
|
+
USER = "user"
|
|
26
|
+
ASSISTANT = "assistant"
|
|
27
|
+
TOOL = "tool"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Content blocks (tagged union via subclasses)
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class ContentBlock:
|
|
36
|
+
"""Base class for session content blocks."""
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> dict[str, Any]:
|
|
39
|
+
raise NotImplementedError
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class TextBlock(ContentBlock):
|
|
44
|
+
text: str
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> dict[str, Any]:
|
|
47
|
+
return {"type": "text", "text": self.text}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class ToolUseBlock(ContentBlock):
|
|
52
|
+
id: str
|
|
53
|
+
name: str
|
|
54
|
+
input: str
|
|
55
|
+
|
|
56
|
+
def to_dict(self) -> dict[str, Any]:
|
|
57
|
+
return {"type": "tool_use", "id": self.id, "name": self.name, "input": self.input}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class ImageBlock(ContentBlock):
|
|
62
|
+
"""Image content block — stores base64 data for session persistence."""
|
|
63
|
+
media_type: str # e.g. "image/png", "image/jpeg"
|
|
64
|
+
data: str # base64-encoded
|
|
65
|
+
|
|
66
|
+
def to_dict(self) -> dict[str, Any]:
|
|
67
|
+
return {"type": "image", "media_type": self.media_type, "data": self.data}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class ToolResultBlock(ContentBlock):
|
|
72
|
+
tool_use_id: str
|
|
73
|
+
tool_name: str
|
|
74
|
+
output: str
|
|
75
|
+
is_error: bool = False
|
|
76
|
+
|
|
77
|
+
def to_dict(self) -> dict[str, Any]:
|
|
78
|
+
return {
|
|
79
|
+
"type": "tool_result",
|
|
80
|
+
"tool_use_id": self.tool_use_id,
|
|
81
|
+
"tool_name": self.tool_name,
|
|
82
|
+
"output": self.output,
|
|
83
|
+
"is_error": self.is_error,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def content_block_from_dict(data: dict[str, Any]) -> ContentBlock:
|
|
88
|
+
"""Deserialize a content block from a dict."""
|
|
89
|
+
block_type = data.get("type", "text")
|
|
90
|
+
if block_type == "text":
|
|
91
|
+
return TextBlock(text=data.get("text", ""))
|
|
92
|
+
if block_type == "tool_use":
|
|
93
|
+
return ToolUseBlock(id=data["id"], name=data["name"], input=data.get("input", ""))
|
|
94
|
+
if block_type == "image":
|
|
95
|
+
return ImageBlock(
|
|
96
|
+
media_type=data.get("media_type", "image/png"),
|
|
97
|
+
data=data.get("data", ""),
|
|
98
|
+
)
|
|
99
|
+
if block_type == "tool_result":
|
|
100
|
+
return ToolResultBlock(
|
|
101
|
+
tool_use_id=data["tool_use_id"],
|
|
102
|
+
tool_name=data.get("tool_name", ""),
|
|
103
|
+
output=data.get("output", ""),
|
|
104
|
+
is_error=data.get("is_error", False),
|
|
105
|
+
)
|
|
106
|
+
return TextBlock(text=str(data))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
# Conversation message
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
@dataclass
|
|
114
|
+
class ConversationMessage:
|
|
115
|
+
role: MessageRole
|
|
116
|
+
blocks: list[ContentBlock]
|
|
117
|
+
usage: TokenUsage | None = None
|
|
118
|
+
|
|
119
|
+
def to_dict(self) -> dict[str, Any]:
|
|
120
|
+
d: dict[str, Any] = {
|
|
121
|
+
"role": self.role.value,
|
|
122
|
+
"blocks": [b.to_dict() for b in self.blocks],
|
|
123
|
+
}
|
|
124
|
+
if self.usage is not None:
|
|
125
|
+
d["usage"] = {
|
|
126
|
+
"input_tokens": self.usage.input_tokens,
|
|
127
|
+
"output_tokens": self.usage.output_tokens,
|
|
128
|
+
"cache_creation_input_tokens": self.usage.cache_creation_input_tokens,
|
|
129
|
+
"cache_read_input_tokens": self.usage.cache_read_input_tokens,
|
|
130
|
+
}
|
|
131
|
+
return d
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def from_dict(cls, data: dict[str, Any]) -> ConversationMessage:
|
|
135
|
+
role = MessageRole(data["role"])
|
|
136
|
+
blocks = [content_block_from_dict(b) for b in data.get("blocks", [])]
|
|
137
|
+
usage_data = data.get("usage")
|
|
138
|
+
usage = None
|
|
139
|
+
if usage_data:
|
|
140
|
+
usage = TokenUsage(
|
|
141
|
+
input_tokens=usage_data.get("input_tokens", 0),
|
|
142
|
+
output_tokens=usage_data.get("output_tokens", 0),
|
|
143
|
+
cache_creation_input_tokens=usage_data.get("cache_creation_input_tokens", 0),
|
|
144
|
+
cache_read_input_tokens=usage_data.get("cache_read_input_tokens", 0),
|
|
145
|
+
)
|
|
146
|
+
return cls(role=role, blocks=blocks, usage=usage)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
# Session compaction and fork metadata
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
@dataclass
|
|
154
|
+
class SessionCompaction:
|
|
155
|
+
count: int
|
|
156
|
+
removed_message_count: int
|
|
157
|
+
summary: str
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass
|
|
161
|
+
class SessionFork:
|
|
162
|
+
parent_session_id: str
|
|
163
|
+
branch_name: str | None = None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
# Session errors
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
class SessionError(Exception):
|
|
171
|
+
"""Base error for session operations."""
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class SessionIoError(SessionError):
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class SessionFormatError(SessionError):
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
# Session
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def _generate_session_id() -> str:
|
|
187
|
+
return uuid.uuid4().hex[:16]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _current_time_ms() -> int:
|
|
191
|
+
return int(time.time() * 1000)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass
|
|
195
|
+
class Session:
|
|
196
|
+
"""Persisted conversational state.
|
|
197
|
+
|
|
198
|
+
Maps to: rust/crates/runtime/src/session.rs::Session
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
version: int = SESSION_VERSION
|
|
202
|
+
session_id: str = field(default_factory=_generate_session_id)
|
|
203
|
+
created_at_ms: int = field(default_factory=_current_time_ms)
|
|
204
|
+
updated_at_ms: int = field(default_factory=_current_time_ms)
|
|
205
|
+
messages: list[ConversationMessage] = field(default_factory=list)
|
|
206
|
+
compaction: SessionCompaction | None = None
|
|
207
|
+
fork: SessionFork | None = None
|
|
208
|
+
_persistence_path: Path | None = field(default=None, repr=False)
|
|
209
|
+
|
|
210
|
+
def with_persistence_path(self, path: Path) -> Session:
|
|
211
|
+
self._persistence_path = path
|
|
212
|
+
return self
|
|
213
|
+
|
|
214
|
+
def push_message(self, msg: ConversationMessage) -> None:
|
|
215
|
+
"""Append a message and update the timestamp."""
|
|
216
|
+
self.messages.append(msg)
|
|
217
|
+
self.updated_at_ms = _current_time_ms()
|
|
218
|
+
|
|
219
|
+
def push_user_text(self, text: str) -> None:
|
|
220
|
+
"""Shorthand: append a user text message."""
|
|
221
|
+
self.push_message(
|
|
222
|
+
ConversationMessage(
|
|
223
|
+
role=MessageRole.USER,
|
|
224
|
+
blocks=[TextBlock(text=text)],
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
def push_user_image(
|
|
229
|
+
self, media_type: str, data: str, text: str = ""
|
|
230
|
+
) -> None:
|
|
231
|
+
"""Append a user message with an image (and optional text)."""
|
|
232
|
+
blocks: list[ContentBlock] = [ImageBlock(media_type=media_type, data=data)]
|
|
233
|
+
if text:
|
|
234
|
+
blocks.append(TextBlock(text=text))
|
|
235
|
+
self.push_message(
|
|
236
|
+
ConversationMessage(role=MessageRole.USER, blocks=blocks)
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def push_assistant_text(self, text: str, usage: TokenUsage | None = None) -> None:
|
|
240
|
+
"""Shorthand: append an assistant text message."""
|
|
241
|
+
self.push_message(
|
|
242
|
+
ConversationMessage(
|
|
243
|
+
role=MessageRole.ASSISTANT,
|
|
244
|
+
blocks=[TextBlock(text=text)],
|
|
245
|
+
usage=usage,
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def message_count(self) -> int:
|
|
250
|
+
return len(self.messages)
|
|
251
|
+
|
|
252
|
+
# -----------------------------------------------------------------------
|
|
253
|
+
# Persistence (JSONL)
|
|
254
|
+
# -----------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
def save(self, path: Path | None = None) -> None:
|
|
257
|
+
"""Save session to JSONL file with rotation."""
|
|
258
|
+
target = path or self._persistence_path
|
|
259
|
+
if target is None:
|
|
260
|
+
raise SessionError("No persistence path configured")
|
|
261
|
+
|
|
262
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
263
|
+
|
|
264
|
+
# Rotate if file is too large
|
|
265
|
+
if target.exists() and target.stat().st_size > ROTATE_AFTER_BYTES:
|
|
266
|
+
self._rotate(target)
|
|
267
|
+
|
|
268
|
+
data = self._to_dict()
|
|
269
|
+
line = json.dumps(data, separators=(",", ":"))
|
|
270
|
+
|
|
271
|
+
with open(target, "a", encoding="utf-8") as f:
|
|
272
|
+
f.write(line + "\n")
|
|
273
|
+
|
|
274
|
+
@classmethod
|
|
275
|
+
def load(cls, path: Path) -> Session:
|
|
276
|
+
"""Load session from JSONL file (reads last complete entry)."""
|
|
277
|
+
if not path.exists():
|
|
278
|
+
raise SessionIoError(f"Session file not found: {path}")
|
|
279
|
+
|
|
280
|
+
last_entry: dict[str, Any] | None = None
|
|
281
|
+
with open(path, encoding="utf-8") as f:
|
|
282
|
+
for line in f:
|
|
283
|
+
line = line.strip()
|
|
284
|
+
if line:
|
|
285
|
+
try:
|
|
286
|
+
last_entry = json.loads(line)
|
|
287
|
+
except json.JSONDecodeError:
|
|
288
|
+
continue
|
|
289
|
+
|
|
290
|
+
if last_entry is None:
|
|
291
|
+
raise SessionFormatError(f"No valid entries in session file: {path}")
|
|
292
|
+
|
|
293
|
+
return cls._from_dict(last_entry, path)
|
|
294
|
+
|
|
295
|
+
def _to_dict(self) -> dict[str, Any]:
|
|
296
|
+
d: dict[str, Any] = {
|
|
297
|
+
"version": self.version,
|
|
298
|
+
"session_id": self.session_id,
|
|
299
|
+
"created_at_ms": self.created_at_ms,
|
|
300
|
+
"updated_at_ms": self.updated_at_ms,
|
|
301
|
+
"messages": [m.to_dict() for m in self.messages],
|
|
302
|
+
}
|
|
303
|
+
if self.compaction:
|
|
304
|
+
d["compaction"] = {
|
|
305
|
+
"count": self.compaction.count,
|
|
306
|
+
"removed_message_count": self.compaction.removed_message_count,
|
|
307
|
+
"summary": self.compaction.summary,
|
|
308
|
+
}
|
|
309
|
+
if self.fork:
|
|
310
|
+
d["fork"] = {
|
|
311
|
+
"parent_session_id": self.fork.parent_session_id,
|
|
312
|
+
"branch_name": self.fork.branch_name,
|
|
313
|
+
}
|
|
314
|
+
return d
|
|
315
|
+
|
|
316
|
+
@classmethod
|
|
317
|
+
def _from_dict(cls, data: dict[str, Any], path: Path | None = None) -> Session:
|
|
318
|
+
messages = [ConversationMessage.from_dict(m) for m in data.get("messages", [])]
|
|
319
|
+
|
|
320
|
+
compaction = None
|
|
321
|
+
if "compaction" in data and data["compaction"]:
|
|
322
|
+
c = data["compaction"]
|
|
323
|
+
compaction = SessionCompaction(
|
|
324
|
+
count=c["count"],
|
|
325
|
+
removed_message_count=c["removed_message_count"],
|
|
326
|
+
summary=c["summary"],
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
fork = None
|
|
330
|
+
if "fork" in data and data["fork"]:
|
|
331
|
+
f = data["fork"]
|
|
332
|
+
fork = SessionFork(
|
|
333
|
+
parent_session_id=f["parent_session_id"],
|
|
334
|
+
branch_name=f.get("branch_name"),
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
session = cls(
|
|
338
|
+
version=data.get("version", SESSION_VERSION),
|
|
339
|
+
session_id=data.get("session_id", _generate_session_id()),
|
|
340
|
+
created_at_ms=data.get("created_at_ms", _current_time_ms()),
|
|
341
|
+
updated_at_ms=data.get("updated_at_ms", _current_time_ms()),
|
|
342
|
+
messages=messages,
|
|
343
|
+
compaction=compaction,
|
|
344
|
+
fork=fork,
|
|
345
|
+
)
|
|
346
|
+
if path:
|
|
347
|
+
session._persistence_path = path
|
|
348
|
+
return session
|
|
349
|
+
|
|
350
|
+
@staticmethod
|
|
351
|
+
def _rotate(path: Path) -> None:
|
|
352
|
+
"""Rotate session files, keeping up to MAX_ROTATED_FILES."""
|
|
353
|
+
for i in range(MAX_ROTATED_FILES - 1, 0, -1):
|
|
354
|
+
src = path.with_suffix(f".{i}.jsonl")
|
|
355
|
+
dst = path.with_suffix(f".{i + 1}.jsonl")
|
|
356
|
+
if src.exists():
|
|
357
|
+
if i + 1 > MAX_ROTATED_FILES:
|
|
358
|
+
src.unlink()
|
|
359
|
+
else:
|
|
360
|
+
src.rename(dst)
|
|
361
|
+
|
|
362
|
+
# Rotate current to .1
|
|
363
|
+
rotated = path.with_suffix(".1.jsonl")
|
|
364
|
+
if path.exists():
|
|
365
|
+
path.rename(rotated)
|
axion/runtime/sharing.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Session sharing — export a session as a shareable link or file.
|
|
2
|
+
|
|
3
|
+
Sharing modes:
|
|
4
|
+
1. File export: /share file → creates a .axion-share file that others can import
|
|
5
|
+
2. JSON export: /share json → outputs a portable JSON blob
|
|
6
|
+
3. Link (future): /share link → uploads to a server and returns a URL
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
import zlib
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from axion.runtime.session import Session
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class SharedSession:
|
|
23
|
+
"""A portable session snapshot for sharing."""
|
|
24
|
+
|
|
25
|
+
session_id: str
|
|
26
|
+
messages_count: int
|
|
27
|
+
created_at_ms: int
|
|
28
|
+
shared_at_ms: int
|
|
29
|
+
shared_by: str
|
|
30
|
+
data: str # Compressed, base64-encoded session JSON
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def export_session_for_sharing(
|
|
34
|
+
session: Session,
|
|
35
|
+
shared_by: str = "",
|
|
36
|
+
) -> SharedSession:
|
|
37
|
+
"""Export a session as a shareable snapshot."""
|
|
38
|
+
# Serialize session to JSON
|
|
39
|
+
session_dict = session._to_dict()
|
|
40
|
+
session_json = json.dumps(session_dict, separators=(",", ":"))
|
|
41
|
+
|
|
42
|
+
# Compress and encode
|
|
43
|
+
compressed = zlib.compress(session_json.encode("utf-8"))
|
|
44
|
+
encoded = base64.b64encode(compressed).decode("ascii")
|
|
45
|
+
|
|
46
|
+
return SharedSession(
|
|
47
|
+
session_id=session.session_id,
|
|
48
|
+
messages_count=session.message_count(),
|
|
49
|
+
created_at_ms=session.created_at_ms,
|
|
50
|
+
shared_at_ms=int(time.time() * 1000),
|
|
51
|
+
shared_by=shared_by,
|
|
52
|
+
data=encoded,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def import_shared_session(shared: SharedSession) -> Session:
|
|
57
|
+
"""Import a shared session snapshot."""
|
|
58
|
+
# Decode and decompress
|
|
59
|
+
compressed = base64.b64decode(shared.data)
|
|
60
|
+
session_json = zlib.decompress(compressed).decode("utf-8")
|
|
61
|
+
session_dict = json.loads(session_json)
|
|
62
|
+
|
|
63
|
+
return Session._from_dict(session_dict)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def save_share_file(shared: SharedSession, output_path: Path) -> None:
|
|
67
|
+
"""Save a shared session to a .axion-share file."""
|
|
68
|
+
data = {
|
|
69
|
+
"version": 1,
|
|
70
|
+
"type": "axion-shared-session",
|
|
71
|
+
"session_id": shared.session_id,
|
|
72
|
+
"messages_count": shared.messages_count,
|
|
73
|
+
"created_at_ms": shared.created_at_ms,
|
|
74
|
+
"shared_at_ms": shared.shared_at_ms,
|
|
75
|
+
"shared_by": shared.shared_by,
|
|
76
|
+
"data": shared.data,
|
|
77
|
+
}
|
|
78
|
+
output_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def load_share_file(input_path: Path) -> SharedSession:
|
|
82
|
+
"""Load a shared session from a .axion-share file."""
|
|
83
|
+
raw = json.loads(input_path.read_text(encoding="utf-8"))
|
|
84
|
+
|
|
85
|
+
if raw.get("type") != "axion-shared-session":
|
|
86
|
+
raise ValueError(f"Not a valid Axion share file: {input_path}")
|
|
87
|
+
|
|
88
|
+
return SharedSession(
|
|
89
|
+
session_id=raw["session_id"],
|
|
90
|
+
messages_count=raw["messages_count"],
|
|
91
|
+
created_at_ms=raw["created_at_ms"],
|
|
92
|
+
shared_at_ms=raw["shared_at_ms"],
|
|
93
|
+
shared_by=raw.get("shared_by", ""),
|
|
94
|
+
data=raw["data"],
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def handle_share_command(args: str, session: Session) -> str:
|
|
99
|
+
"""Handle /share [file|json|import <path>].
|
|
100
|
+
|
|
101
|
+
Usage:
|
|
102
|
+
/share file — save as .axion-share file
|
|
103
|
+
/share file out.share — save with custom name
|
|
104
|
+
/share json — print as JSON blob
|
|
105
|
+
/share import file.axion-share — import a shared session
|
|
106
|
+
"""
|
|
107
|
+
parts = args.strip().split(maxsplit=1)
|
|
108
|
+
action = parts[0].lower() if parts else "file"
|
|
109
|
+
target = parts[1].strip() if len(parts) > 1 else ""
|
|
110
|
+
|
|
111
|
+
if action == "file":
|
|
112
|
+
filename = target or f"session-{session.session_id[:8]}.axion-share"
|
|
113
|
+
output_path = Path.cwd() / filename
|
|
114
|
+
shared = export_session_for_sharing(session)
|
|
115
|
+
save_share_file(shared, output_path)
|
|
116
|
+
return (
|
|
117
|
+
f"Session shared to: {output_path}\n"
|
|
118
|
+
f" Messages: {shared.messages_count}\n"
|
|
119
|
+
f" Size: {output_path.stat().st_size:,} bytes\n\n"
|
|
120
|
+
f"Send this file to a teammate. They can import with:\n"
|
|
121
|
+
f" /share import {filename}"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if action == "json":
|
|
125
|
+
shared = export_session_for_sharing(session)
|
|
126
|
+
blob = json.dumps({
|
|
127
|
+
"session_id": shared.session_id,
|
|
128
|
+
"messages": shared.messages_count,
|
|
129
|
+
"data": shared.data[:100] + "...",
|
|
130
|
+
}, indent=2)
|
|
131
|
+
return f"Shared session JSON (truncated):\n{blob}"
|
|
132
|
+
|
|
133
|
+
if action == "import":
|
|
134
|
+
if not target:
|
|
135
|
+
return "Usage: /share import <file.axion-share>"
|
|
136
|
+
import_path = Path(target)
|
|
137
|
+
if not import_path.exists():
|
|
138
|
+
return f"File not found: {target}"
|
|
139
|
+
try:
|
|
140
|
+
shared = load_share_file(import_path)
|
|
141
|
+
imported = import_shared_session(shared)
|
|
142
|
+
# Replace current session
|
|
143
|
+
session.messages = imported.messages
|
|
144
|
+
session.session_id = imported.session_id
|
|
145
|
+
session.created_at_ms = imported.created_at_ms
|
|
146
|
+
return (
|
|
147
|
+
f"Imported session {imported.session_id}\n"
|
|
148
|
+
f" Messages: {imported.message_count()}\n"
|
|
149
|
+
f" Shared by: {shared.shared_by or 'unknown'}"
|
|
150
|
+
)
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
return f"Import failed: {exc}"
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
"Usage:\n"
|
|
156
|
+
" /share file [name] — export session as shareable file\n"
|
|
157
|
+
" /share json — export as JSON\n"
|
|
158
|
+
" /share import <file> — import a shared session"
|
|
159
|
+
)
|
axion/runtime/skills.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Skill loading and execution.
|
|
2
|
+
|
|
3
|
+
Maps to: rust/crates/runtime/src/skills.rs
|
|
4
|
+
|
|
5
|
+
Skills are .md files with optional YAML frontmatter that define reusable
|
|
6
|
+
prompt templates. They can be loaded from several conventional directories
|
|
7
|
+
and executed by replacing {{args}} placeholders with user-supplied arguments.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
# Directories searched (relative to cwd) when resolving a skill by name.
|
|
21
|
+
_SKILL_SEARCH_DIRS: list[str] = [
|
|
22
|
+
".axion/skills", ".claude/skills",
|
|
23
|
+
".axion/commands", ".claude/commands",
|
|
24
|
+
".axion/skills",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class SkillDefinition:
|
|
30
|
+
"""A parsed skill definition."""
|
|
31
|
+
|
|
32
|
+
name: str
|
|
33
|
+
description: str
|
|
34
|
+
content: str
|
|
35
|
+
source_path: Path | None = None
|
|
36
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# YAML frontmatter helpers
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
_FRONTMATTER_RE = re.compile(
|
|
44
|
+
r"\A---\s*\n(.*?)\n---\s*\n(.*)",
|
|
45
|
+
re.DOTALL,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _parse_yaml_frontmatter(text: str) -> tuple[dict[str, Any], str]:
|
|
50
|
+
"""Parse simple YAML frontmatter between --- delimiters.
|
|
51
|
+
|
|
52
|
+
Returns (metadata_dict, remaining_content). Uses a lightweight
|
|
53
|
+
key: value parser so we avoid a hard dependency on PyYAML.
|
|
54
|
+
"""
|
|
55
|
+
match = _FRONTMATTER_RE.match(text)
|
|
56
|
+
if not match:
|
|
57
|
+
return {}, text
|
|
58
|
+
|
|
59
|
+
yaml_block = match.group(1)
|
|
60
|
+
body = match.group(2)
|
|
61
|
+
|
|
62
|
+
metadata: dict[str, Any] = {}
|
|
63
|
+
for line in yaml_block.splitlines():
|
|
64
|
+
line = line.strip()
|
|
65
|
+
if not line or line.startswith("#"):
|
|
66
|
+
continue
|
|
67
|
+
if ":" in line:
|
|
68
|
+
key, _, value = line.partition(":")
|
|
69
|
+
metadata[key.strip()] = value.strip()
|
|
70
|
+
|
|
71
|
+
return metadata, body
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Public API
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def load_skill(path: Path) -> SkillDefinition:
|
|
80
|
+
"""Read a .md skill file, parse YAML frontmatter, and return a SkillDefinition."""
|
|
81
|
+
text = path.read_text(encoding="utf-8")
|
|
82
|
+
metadata, content = _parse_yaml_frontmatter(text)
|
|
83
|
+
|
|
84
|
+
name = metadata.get("name", path.stem)
|
|
85
|
+
description = metadata.get("description", "")
|
|
86
|
+
|
|
87
|
+
return SkillDefinition(
|
|
88
|
+
name=name,
|
|
89
|
+
description=description,
|
|
90
|
+
content=content.strip(),
|
|
91
|
+
source_path=path,
|
|
92
|
+
metadata=metadata,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def resolve_skill(name: str, cwd: Path) -> Path | None:
|
|
97
|
+
"""Search conventional directories for a skill file matching *name*.
|
|
98
|
+
|
|
99
|
+
Searches .claude/skills/, .claude/commands/, .axion/skills/ under *cwd*.
|
|
100
|
+
Returns the first matching .md file, or ``None``.
|
|
101
|
+
"""
|
|
102
|
+
for search_dir in _SKILL_SEARCH_DIRS:
|
|
103
|
+
base = cwd / search_dir
|
|
104
|
+
|
|
105
|
+
# Try exact name with .md extension
|
|
106
|
+
candidate = base / f"{name}.md"
|
|
107
|
+
if candidate.is_file():
|
|
108
|
+
return candidate
|
|
109
|
+
|
|
110
|
+
# Try name as-is (may already include extension)
|
|
111
|
+
candidate = base / name
|
|
112
|
+
if candidate.is_file():
|
|
113
|
+
return candidate
|
|
114
|
+
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def execute_skill(skill: SkillDefinition, user_args: str) -> str:
|
|
119
|
+
"""Execute a skill by replacing ``{{args}}`` with *user_args*.
|
|
120
|
+
|
|
121
|
+
Returns the skill content with placeholders substituted.
|
|
122
|
+
"""
|
|
123
|
+
result = skill.content.replace("{{args}}", user_args)
|
|
124
|
+
return result
|