vibe-remote 2.1.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. config/__init__.py +37 -0
  2. config/paths.py +56 -0
  3. config/v2_compat.py +74 -0
  4. config/v2_config.py +206 -0
  5. config/v2_sessions.py +73 -0
  6. config/v2_settings.py +115 -0
  7. core/__init__.py +0 -0
  8. core/controller.py +736 -0
  9. core/handlers/__init__.py +13 -0
  10. core/handlers/command_handlers.py +342 -0
  11. core/handlers/message_handler.py +365 -0
  12. core/handlers/session_handler.py +233 -0
  13. core/handlers/settings_handler.py +362 -0
  14. modules/__init__.py +0 -0
  15. modules/agent_router.py +58 -0
  16. modules/agents/__init__.py +38 -0
  17. modules/agents/base.py +91 -0
  18. modules/agents/claude_agent.py +344 -0
  19. modules/agents/codex_agent.py +368 -0
  20. modules/agents/opencode_agent.py +2155 -0
  21. modules/agents/service.py +41 -0
  22. modules/agents/subagent_router.py +136 -0
  23. modules/claude_client.py +154 -0
  24. modules/im/__init__.py +63 -0
  25. modules/im/base.py +323 -0
  26. modules/im/factory.py +60 -0
  27. modules/im/formatters/__init__.py +4 -0
  28. modules/im/formatters/base_formatter.py +639 -0
  29. modules/im/formatters/slack_formatter.py +127 -0
  30. modules/im/slack.py +2091 -0
  31. modules/session_manager.py +138 -0
  32. modules/settings_manager.py +587 -0
  33. vibe/__init__.py +6 -0
  34. vibe/__main__.py +12 -0
  35. vibe/_version.py +34 -0
  36. vibe/api.py +412 -0
  37. vibe/cli.py +637 -0
  38. vibe/runtime.py +213 -0
  39. vibe/service_main.py +101 -0
  40. vibe/templates/slack_manifest.json +65 -0
  41. vibe/ui/dist/assets/index-8g3mNwMK.js +35 -0
  42. vibe/ui/dist/assets/index-M55aMB5R.css +1 -0
  43. vibe/ui/dist/assets/logo-BzryTZ7u.png +0 -0
  44. vibe/ui/dist/index.html +17 -0
  45. vibe/ui/dist/logo.png +0 -0
  46. vibe/ui/dist/vite.svg +1 -0
  47. vibe/ui_server.py +346 -0
  48. vibe_remote-2.1.6.dist-info/METADATA +295 -0
  49. vibe_remote-2.1.6.dist-info/RECORD +52 -0
  50. vibe_remote-2.1.6.dist-info/WHEEL +4 -0
  51. vibe_remote-2.1.6.dist-info/entry_points.txt +2 -0
  52. vibe_remote-2.1.6.dist-info/licenses/LICENSE +21 -0
config/__init__.py ADDED
@@ -0,0 +1,37 @@
1
+ from .paths import (
2
+ get_vibe_remote_dir,
3
+ get_config_dir,
4
+ get_state_dir,
5
+ get_logs_dir,
6
+ get_runtime_dir,
7
+ get_runtime_pid_path,
8
+ get_runtime_ui_pid_path,
9
+ get_runtime_status_path,
10
+ get_runtime_doctor_path,
11
+ get_config_path,
12
+ get_settings_path,
13
+ get_sessions_path,
14
+ ensure_data_dirs,
15
+ )
16
+ from .v2_config import V2Config
17
+ from .v2_settings import SettingsStore
18
+ from .v2_sessions import SessionsStore
19
+
20
+ __all__ = [
21
+ "V2Config",
22
+ "SettingsStore",
23
+ "SessionsStore",
24
+ "get_vibe_remote_dir",
25
+ "get_config_dir",
26
+ "get_state_dir",
27
+ "get_logs_dir",
28
+ "get_runtime_dir",
29
+ "get_runtime_pid_path",
30
+ "get_runtime_ui_pid_path",
31
+ "get_runtime_status_path",
32
+ "get_runtime_doctor_path",
33
+ "get_config_path",
34
+ "get_settings_path",
35
+ "get_sessions_path",
36
+ "ensure_data_dirs",
37
+ ]
config/paths.py ADDED
@@ -0,0 +1,56 @@
1
+ from pathlib import Path
2
+
3
+
4
+ def get_vibe_remote_dir() -> Path:
5
+ return Path.home() / ".vibe_remote"
6
+
7
+
8
+ def get_config_dir() -> Path:
9
+ return get_vibe_remote_dir() / "config"
10
+
11
+
12
+ def get_state_dir() -> Path:
13
+ return get_vibe_remote_dir() / "state"
14
+
15
+
16
+ def get_logs_dir() -> Path:
17
+ return get_vibe_remote_dir() / "logs"
18
+
19
+
20
+ def get_runtime_dir() -> Path:
21
+ return get_vibe_remote_dir() / "runtime"
22
+
23
+
24
+ def get_runtime_pid_path() -> Path:
25
+ return get_runtime_dir() / "vibe.pid"
26
+
27
+
28
+ def get_runtime_ui_pid_path() -> Path:
29
+ return get_runtime_dir() / "vibe-ui.pid"
30
+
31
+
32
+ def get_runtime_status_path() -> Path:
33
+ return get_runtime_dir() / "status.json"
34
+
35
+
36
+ def get_runtime_doctor_path() -> Path:
37
+ return get_runtime_dir() / "doctor.json"
38
+
39
+
40
+ def get_config_path() -> Path:
41
+ return get_config_dir() / "config.json"
42
+
43
+
44
+ def get_settings_path() -> Path:
45
+ return get_state_dir() / "settings.json"
46
+
47
+
48
+ def get_sessions_path() -> Path:
49
+ return get_state_dir() / "sessions.json"
50
+
51
+
52
+ def ensure_data_dirs() -> None:
53
+ get_config_dir().mkdir(parents=True, exist_ok=True)
54
+ get_state_dir().mkdir(parents=True, exist_ok=True)
55
+ get_logs_dir().mkdir(parents=True, exist_ok=True)
56
+ get_runtime_dir().mkdir(parents=True, exist_ok=True)
config/v2_compat.py ADDED
@@ -0,0 +1,74 @@
1
+ from dataclasses import dataclass
2
+ from typing import Optional
3
+
4
+ from config.v2_config import V2Config, SlackConfig
5
+
6
+
7
+ @dataclass
8
+ class ClaudeCompatConfig:
9
+ permission_mode: str
10
+ cwd: str
11
+ system_prompt: Optional[str] = None
12
+
13
+ def __post_init__(self) -> None:
14
+ self.permission_mode = str(self.permission_mode)
15
+ self.cwd = str(self.cwd)
16
+
17
+
18
+ @dataclass
19
+ class CodexCompatConfig:
20
+ binary: str
21
+ extra_args: list[str]
22
+ default_model: Optional[str] = None
23
+
24
+
25
+ @dataclass
26
+ class OpenCodeCompatConfig:
27
+ binary: str
28
+ port: int
29
+ request_timeout_seconds: int
30
+ error_retry_limit: int = 1 # Max retries on LLM stream errors (0 = no retry)
31
+
32
+
33
+ @dataclass
34
+ class AppCompatConfig:
35
+ platform: str
36
+ slack: SlackConfig
37
+ claude: ClaudeCompatConfig
38
+ codex: Optional[CodexCompatConfig]
39
+ opencode: Optional[OpenCodeCompatConfig]
40
+ log_level: str
41
+ ack_mode: str
42
+
43
+
44
+ def to_app_config(v2: V2Config) -> AppCompatConfig:
45
+ claude = ClaudeCompatConfig(
46
+ permission_mode="default",
47
+ cwd=v2.runtime.default_cwd,
48
+ system_prompt=None,
49
+ )
50
+ codex = None
51
+ if v2.agents.codex.enabled:
52
+ codex = CodexCompatConfig(
53
+ binary=v2.agents.codex.cli_path,
54
+ extra_args=[],
55
+ default_model=v2.agents.codex.default_model,
56
+ )
57
+ opencode = None
58
+ if v2.agents.opencode.enabled:
59
+ opencode = OpenCodeCompatConfig(
60
+ binary=v2.agents.opencode.cli_path,
61
+ port=4096,
62
+ request_timeout_seconds=60,
63
+ error_retry_limit=v2.agents.opencode.error_retry_limit,
64
+ )
65
+ slack = SlackConfig(**v2.slack.__dict__)
66
+ return AppCompatConfig(
67
+ platform="slack",
68
+ slack=slack,
69
+ claude=claude,
70
+ codex=codex,
71
+ opencode=opencode,
72
+ log_level=v2.runtime.log_level,
73
+ ack_mode=v2.ack_mode,
74
+ )
config/v2_config.py ADDED
@@ -0,0 +1,206 @@
1
+ import json
2
+ import logging
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import List, Optional, Union
6
+
7
+ from config import paths
8
+ from modules.im.base import BaseIMConfig
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ @dataclass
14
+ class SlackConfig(BaseIMConfig):
15
+ bot_token: str
16
+ app_token: Optional[str] = None
17
+ signing_secret: Optional[str] = None
18
+ team_id: Optional[str] = None
19
+ team_name: Optional[str] = None
20
+ app_id: Optional[str] = None
21
+ require_mention: bool = False
22
+
23
+ def validate(self) -> None:
24
+ # Allow empty token for initial setup
25
+ if self.bot_token and not self.bot_token.startswith("xoxb-"):
26
+ raise ValueError("Invalid Slack bot token format (should start with xoxb-)")
27
+ if self.app_token and not self.app_token.startswith("xapp-"):
28
+ raise ValueError("Invalid Slack app token format (should start with xapp-)")
29
+
30
+
31
+ @dataclass
32
+ class GatewayConfig:
33
+ relay_url: Optional[str] = None
34
+ workspace_token: Optional[str] = None
35
+ client_id: Optional[str] = None
36
+ client_secret: Optional[str] = None
37
+ last_connected_at: Optional[str] = None
38
+
39
+
40
+ @dataclass
41
+ class RuntimeConfig:
42
+ default_cwd: str
43
+ log_level: str = "INFO"
44
+
45
+
46
+
47
+ @dataclass
48
+ class OpenCodeConfig:
49
+ enabled: bool = True
50
+ cli_path: str = "opencode"
51
+ default_agent: Optional[str] = None
52
+ default_model: Optional[str] = None
53
+ default_reasoning_effort: Optional[str] = None
54
+ error_retry_limit: int = 1 # Max retries on LLM stream errors (0 = no retry)
55
+
56
+
57
+ @dataclass
58
+ class ClaudeConfig:
59
+ enabled: bool = True
60
+ cli_path: str = "claude"
61
+ default_model: Optional[str] = None
62
+
63
+
64
+ @dataclass
65
+ class CodexConfig:
66
+ enabled: bool = True
67
+ cli_path: str = "codex"
68
+ default_model: Optional[str] = None
69
+
70
+
71
+ @dataclass
72
+ class AgentsConfig:
73
+ default_backend: str = "opencode"
74
+ opencode: OpenCodeConfig = field(default_factory=OpenCodeConfig)
75
+ claude: ClaudeConfig = field(default_factory=ClaudeConfig)
76
+ codex: CodexConfig = field(default_factory=CodexConfig)
77
+
78
+
79
+ @dataclass
80
+ class UiConfig:
81
+ setup_host: str = "127.0.0.1"
82
+ setup_port: int = 5123
83
+ open_browser: bool = True
84
+
85
+
86
+ @dataclass
87
+ class V2Config:
88
+ mode: str
89
+ version: str
90
+ slack: SlackConfig
91
+ runtime: RuntimeConfig
92
+ agents: AgentsConfig
93
+ gateway: Optional[GatewayConfig] = None
94
+ ui: UiConfig = field(default_factory=UiConfig)
95
+ ack_mode: str = "reaction"
96
+
97
+ @classmethod
98
+ def load(cls, config_path: Optional[Path] = None) -> "V2Config":
99
+ paths.ensure_data_dirs()
100
+ path = config_path or paths.get_config_path()
101
+ if not path.exists():
102
+ raise FileNotFoundError(f"Config not found: {path}")
103
+ payload = json.loads(path.read_text(encoding="utf-8"))
104
+ return cls.from_payload(payload)
105
+
106
+ @classmethod
107
+ def from_payload(cls, payload: dict) -> "V2Config":
108
+ if not isinstance(payload, dict):
109
+ raise ValueError("Config payload must be an object")
110
+
111
+ mode = payload.get("mode")
112
+ if mode not in {"self_host", "saas"}:
113
+ raise ValueError("Config 'mode' must be 'self_host' or 'saas'")
114
+
115
+ slack_payload = payload.get("slack")
116
+ if not isinstance(slack_payload, dict):
117
+ raise ValueError("Config 'slack' must be an object")
118
+
119
+ if "require_mention" not in slack_payload:
120
+ slack_payload = dict(slack_payload)
121
+ slack_payload["require_mention"] = False
122
+
123
+ slack = SlackConfig(**slack_payload)
124
+ slack.validate()
125
+ gateway_payload = payload.get("gateway")
126
+ if gateway_payload is not None and not isinstance(gateway_payload, dict):
127
+ raise ValueError("Config 'gateway' must be an object")
128
+ gateway = GatewayConfig(**gateway_payload) if gateway_payload else None
129
+
130
+ runtime_payload = payload.get("runtime")
131
+ if not isinstance(runtime_payload, dict):
132
+ raise ValueError("Config 'runtime' must be an object")
133
+ runtime = RuntimeConfig(**runtime_payload)
134
+
135
+ agents_payload = payload.get("agents")
136
+ if not isinstance(agents_payload, dict):
137
+ raise ValueError("Config 'agents' must be an object")
138
+
139
+ opencode_payload = agents_payload.get("opencode") or {}
140
+ if not isinstance(opencode_payload, dict):
141
+ raise ValueError("Config 'agents.opencode' must be an object")
142
+
143
+ claude_payload = agents_payload.get("claude") or {}
144
+ if not isinstance(claude_payload, dict):
145
+ raise ValueError("Config 'agents.claude' must be an object")
146
+
147
+ codex_payload = agents_payload.get("codex") or {}
148
+ if not isinstance(codex_payload, dict):
149
+ raise ValueError("Config 'agents.codex' must be an object")
150
+
151
+ opencode = OpenCodeConfig(**opencode_payload)
152
+ claude = ClaudeConfig(**claude_payload)
153
+ codex = CodexConfig(**codex_payload)
154
+
155
+ default_backend = agents_payload.get("default_backend", "opencode")
156
+ if default_backend not in {"opencode", "claude", "codex"}:
157
+ raise ValueError("Config 'agents.default_backend' must be 'opencode', 'claude', or 'codex'")
158
+
159
+ agents = AgentsConfig(
160
+ default_backend=default_backend,
161
+ opencode=opencode,
162
+ claude=claude,
163
+ codex=codex,
164
+ )
165
+
166
+ ui_payload = payload.get("ui") or {}
167
+ if not isinstance(ui_payload, dict):
168
+ raise ValueError("Config 'ui' must be an object")
169
+ ui = UiConfig(**ui_payload)
170
+ ack_mode = payload.get("ack_mode", "reaction")
171
+ if ack_mode not in {"reaction", "message"}:
172
+ raise ValueError("Config 'ack_mode' must be 'reaction' or 'message'")
173
+
174
+ return cls(
175
+ mode=mode,
176
+ version=payload.get("version", "v2"),
177
+ slack=slack,
178
+ runtime=runtime,
179
+ agents=agents,
180
+ gateway=gateway,
181
+ ui=ui,
182
+ ack_mode=ack_mode,
183
+ )
184
+
185
+ def save(self, config_path: Optional[Path] = None) -> None:
186
+ paths.ensure_data_dirs()
187
+ path = config_path or paths.get_config_path()
188
+ payload = {
189
+ "mode": self.mode,
190
+ "version": self.version,
191
+ "slack": self.slack.__dict__,
192
+ "runtime": {
193
+ "default_cwd": self.runtime.default_cwd,
194
+ "log_level": self.runtime.log_level,
195
+ },
196
+ "agents": {
197
+ "default_backend": self.agents.default_backend,
198
+ "opencode": self.agents.opencode.__dict__,
199
+ "claude": self.agents.claude.__dict__,
200
+ "codex": self.agents.codex.__dict__,
201
+ },
202
+ "gateway": self.gateway.__dict__ if self.gateway else None,
203
+ "ui": self.ui.__dict__,
204
+ "ack_mode": self.ack_mode,
205
+ }
206
+ path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
config/v2_sessions.py ADDED
@@ -0,0 +1,73 @@
1
+ import json
2
+ import logging
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Dict, Optional
6
+
7
+ from config import paths
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ @dataclass
13
+ class SessionState:
14
+ # session_mappings: user_id -> agent_name -> thread_id -> session_id
15
+ session_mappings: Dict[str, Dict[str, Dict[str, str]]] = field(
16
+ default_factory=dict
17
+ )
18
+ active_slack_threads: Dict[str, Dict[str, Dict[str, float]]] = field(
19
+ default_factory=dict
20
+ )
21
+ last_activity: Optional[str] = None
22
+
23
+
24
+ @dataclass
25
+ class SessionsStore:
26
+ sessions_path: Path = field(default_factory=paths.get_sessions_path)
27
+ state: SessionState = field(default_factory=SessionState)
28
+
29
+ def load(self) -> None:
30
+ if not self.sessions_path.exists():
31
+ return
32
+ try:
33
+ payload = json.loads(self.sessions_path.read_text(encoding="utf-8"))
34
+ except Exception as exc:
35
+ logger.error("Failed to load sessions: %s", exc)
36
+ return
37
+ self.state = SessionState(
38
+ session_mappings=payload.get("session_mappings", {}),
39
+ active_slack_threads=payload.get("active_slack_threads", {}),
40
+ last_activity=payload.get("last_activity"),
41
+ )
42
+
43
+ def _ensure_user_namespace(self, user_id: str) -> None:
44
+ if user_id not in self.state.session_mappings:
45
+ self.state.session_mappings[user_id] = {}
46
+ if user_id not in self.state.active_slack_threads:
47
+ self.state.active_slack_threads[user_id] = {}
48
+
49
+ def get_agent_map(self, user_id: str, agent_name: str) -> Dict[str, str]:
50
+ """Get mapping of thread_id -> session_id for a user and agent."""
51
+ self._ensure_user_namespace(user_id)
52
+ agent_map = self.state.session_mappings[user_id].get(agent_name)
53
+ if agent_map is None:
54
+ agent_map = {}
55
+ self.state.session_mappings[user_id][agent_name] = agent_map
56
+ return agent_map
57
+
58
+ def get_thread_map(self, user_id: str, channel_id: str) -> Dict[str, float]:
59
+ self._ensure_user_namespace(user_id)
60
+ channel_map = self.state.active_slack_threads[user_id].get(channel_id)
61
+ if channel_map is None:
62
+ channel_map = {}
63
+ self.state.active_slack_threads[user_id][channel_id] = channel_map
64
+ return channel_map
65
+
66
+ def save(self) -> None:
67
+ paths.ensure_data_dirs()
68
+ payload = {
69
+ "session_mappings": self.state.session_mappings,
70
+ "active_slack_threads": self.state.active_slack_threads,
71
+ "last_activity": self.state.last_activity,
72
+ }
73
+ self.sessions_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
config/v2_settings.py ADDED
@@ -0,0 +1,115 @@
1
+ import json
2
+ import json
3
+ import logging
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Dict, List, Optional
7
+
8
+ from config import paths
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ DEFAULT_SHOW_MESSAGE_TYPES: List[str] = []
13
+ ALLOWED_MESSAGE_TYPES = {"system", "assistant", "toolcall"}
14
+
15
+
16
+ def normalize_show_message_types(show_message_types: Optional[List[str]]) -> List[str]:
17
+ if show_message_types is None:
18
+ return DEFAULT_SHOW_MESSAGE_TYPES.copy()
19
+ return [msg for msg in show_message_types if msg in ALLOWED_MESSAGE_TYPES]
20
+
21
+
22
+ @dataclass
23
+ class RoutingSettings:
24
+ agent_backend: Optional[str] = None
25
+ opencode_agent: Optional[str] = None
26
+ opencode_model: Optional[str] = None
27
+ opencode_reasoning_effort: Optional[str] = None
28
+
29
+
30
+ @dataclass
31
+ class ChannelSettings:
32
+ enabled: bool = False
33
+ show_message_types: List[str] = field(
34
+ default_factory=lambda: DEFAULT_SHOW_MESSAGE_TYPES.copy()
35
+ )
36
+ custom_cwd: Optional[str] = None
37
+ routing: RoutingSettings = field(default_factory=RoutingSettings)
38
+ # Per-channel require_mention override: None=use global default, True=require, False=don't require
39
+ require_mention: Optional[bool] = None
40
+
41
+
42
+ @dataclass
43
+ class SettingsState:
44
+ channels: Dict[str, ChannelSettings] = field(default_factory=dict)
45
+
46
+
47
+ class SettingsStore:
48
+ def __init__(self, settings_path: Optional[Path] = None):
49
+ self.settings_path = settings_path or paths.get_settings_path()
50
+ self.settings: SettingsState = SettingsState()
51
+ self._load()
52
+
53
+ def _load(self) -> None:
54
+ if not self.settings_path.exists():
55
+ return
56
+ try:
57
+ payload = json.loads(self.settings_path.read_text(encoding="utf-8"))
58
+ except Exception as exc:
59
+ logger.error("Failed to load settings: %s", exc)
60
+ return
61
+ raw_channels = payload.get("channels") if isinstance(payload, dict) else None
62
+ if raw_channels is None:
63
+ logger.error("Failed to load settings: invalid format")
64
+ return
65
+ if not isinstance(raw_channels, dict):
66
+ logger.error("Failed to load settings: channels must be an object")
67
+ return
68
+ channels = {}
69
+ for channel_id, channel_payload in raw_channels.items():
70
+ if not isinstance(channel_payload, dict):
71
+ continue
72
+ routing_payload = channel_payload.get("routing") or {}
73
+ routing = RoutingSettings(
74
+ agent_backend=routing_payload.get("agent_backend"),
75
+ opencode_agent=routing_payload.get("opencode_agent"),
76
+ opencode_model=routing_payload.get("opencode_model"),
77
+ opencode_reasoning_effort=routing_payload.get("opencode_reasoning_effort"),
78
+ )
79
+ channels[channel_id] = ChannelSettings(
80
+ enabled=channel_payload.get("enabled", False),
81
+ show_message_types=normalize_show_message_types(
82
+ channel_payload.get("show_message_types")
83
+ ),
84
+ custom_cwd=channel_payload.get("custom_cwd"),
85
+ routing=routing,
86
+ require_mention=channel_payload.get("require_mention"),
87
+ )
88
+ self.settings = SettingsState(channels=channels)
89
+
90
+ def save(self) -> None:
91
+ paths.ensure_data_dirs()
92
+ payload = {"channels": {}}
93
+ for channel_id, settings in self.settings.channels.items():
94
+ payload["channels"][channel_id] = {
95
+ "enabled": settings.enabled,
96
+ "show_message_types": settings.show_message_types,
97
+ "custom_cwd": settings.custom_cwd,
98
+ "routing": {
99
+ "agent_backend": settings.routing.agent_backend,
100
+ "opencode_agent": settings.routing.opencode_agent,
101
+ "opencode_model": settings.routing.opencode_model,
102
+ "opencode_reasoning_effort": settings.routing.opencode_reasoning_effort,
103
+ },
104
+ "require_mention": settings.require_mention,
105
+ }
106
+ self.settings_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
107
+
108
+ def get_channel(self, channel_id: str) -> ChannelSettings:
109
+ if channel_id not in self.settings.channels:
110
+ self.settings.channels[channel_id] = ChannelSettings()
111
+ return self.settings.channels[channel_id]
112
+
113
+ def update_channel(self, channel_id: str, settings: ChannelSettings) -> None:
114
+ self.settings.channels[channel_id] = settings
115
+ self.save()
core/__init__.py ADDED
File without changes