soulsync 1.0.0

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.
package/config.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "cloud_url": "http://localhost:3000",
3
+ "email": "",
4
+ "password": "",
5
+ "workspace": "./workspace",
6
+ "memory_file": "MEMORY.md",
7
+ "watch_files": [
8
+ "SOUL.md",
9
+ "IDENTITY.md",
10
+ "USER.md",
11
+ "AGENTS.md",
12
+ "TOOLS.md",
13
+ "skills.json",
14
+ "memory/",
15
+ "MEMORY.md"
16
+ ]
17
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "soulsync",
3
+ "version": "1.0.0",
4
+ "description": "SoulSync plugin for OpenClaw - cross-bot memory synchronization",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "test": "echo \"Error: no test specified\" && exit 1"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/alanliuc-a11y/soulsync.git"
12
+ },
13
+ "keywords": [
14
+ "openclaw",
15
+ "openclaw-plugin",
16
+ "bot-memory",
17
+ "bot-soulsync"
18
+ ],
19
+ "author": "Alan Liu",
20
+ "license": "MIT",
21
+ "bugs": {
22
+ "url": "https://github.com/alanliuc-a11y/soulsync/issues"
23
+ },
24
+ "homepage": "https://github.com/alanliuc-a11y/soulsync#readme",
25
+ "openclaw": {
26
+ "plugin": true,
27
+ "type": "python"
28
+ }
29
+ }
@@ -0,0 +1,3 @@
1
+ requests>=2.28.0
2
+ watchdog>=3.0.0
3
+ websocket-client>=1.6.0
package/src/client.py ADDED
@@ -0,0 +1,224 @@
1
+ import json
2
+ import os
3
+ import uuid
4
+ import requests
5
+ import websocket
6
+
7
+
8
+ class OpenClawClient:
9
+ """OpenClaw 插件的 API/WS 客户端"""
10
+
11
+ def __init__(self, config: dict):
12
+ self.config = config
13
+ self.cloud_url = config.get('cloud_url', '').rstrip('/')
14
+ self.token = None
15
+ self.user_id = None
16
+ self.device_id = self._load_or_generate_device_id()
17
+ self.ws = None
18
+ self.ws_thread = None
19
+
20
+ def _load_or_generate_device_id(self) -> str:
21
+ """加载或生成设备ID"""
22
+ plugin_dir = os.path.dirname(os.path.dirname(__file__))
23
+ device_id_file = os.path.join(plugin_dir, 'device_id')
24
+
25
+ if os.path.exists(device_id_file):
26
+ with open(device_id_file, 'r') as f:
27
+ return f.read().strip()
28
+
29
+ new_device_id = str(uuid.uuid4())
30
+ with open(device_id_file, 'w') as f:
31
+ f.write(new_device_id)
32
+
33
+ print(f"Generated new device_id: {new_device_id}")
34
+ return new_device_id
35
+
36
+ def _save_token(self, token: str):
37
+ """保存 token"""
38
+ plugin_dir = os.path.dirname(os.path.dirname(__file__))
39
+ token_file = os.path.join(plugin_dir, 'token')
40
+ with open(token_file, 'w') as f:
41
+ f.write(token)
42
+ self.token = token
43
+
44
+ def _load_token(self) -> str:
45
+ """加载 token"""
46
+ plugin_dir = os.path.dirname(os.path.dirname(__file__))
47
+ token_file = os.path.join(plugin_dir, 'token')
48
+
49
+ if os.path.exists(token_file):
50
+ with open(token_file, 'r') as f:
51
+ return f.read().strip()
52
+
53
+ return None
54
+
55
+ def _get_headers(self) -> dict:
56
+ """获取请求头"""
57
+ headers = {'Content-Type': 'application/json'}
58
+ if self.token:
59
+ headers['Authorization'] = f'Bearer {self.token}'
60
+ return headers
61
+
62
+ def authenticate(self, email: str = None, password: str = None) -> dict:
63
+ """认证:注册或登录
64
+
65
+ Args:
66
+ email: 邮箱 (首次注册需要)
67
+ password: 密码 (首次注册需要)
68
+
69
+ Returns:
70
+ 认证结果
71
+ """
72
+ self.token = self._load_token()
73
+
74
+ if self.token:
75
+ try:
76
+ profile = self.get_profile()
77
+ print(f"Using existing token, user: {profile.get('email', 'unknown')}")
78
+ return profile
79
+ except Exception as e:
80
+ print(f"Token invalid, re-authenticating: {e}")
81
+ self.token = None
82
+
83
+ if not email:
84
+ email = self.config.get('email', '')
85
+ if not password:
86
+ password = self.config.get('password', '')
87
+
88
+ if not email or not password:
89
+ raise ValueError("Email and password required for first-time authentication")
90
+
91
+ url = f"{self.cloud_url}/api/auth/device"
92
+ data = {
93
+ 'device_id': self.device_id,
94
+ 'email': email,
95
+ 'password': password
96
+ }
97
+
98
+ response = requests.post(url, json=data, headers={'Content-Type': 'application/json'})
99
+
100
+ if response.status_code == 201:
101
+ result = response.json()
102
+ self.token = result.get('token')
103
+ self.user_id = result.get('user_id')
104
+ self._save_token(self.token)
105
+ print(f"Registered new user: {email}")
106
+ return result
107
+ elif response.status_code == 200:
108
+ result = response.json()
109
+ self.token = result.get('token')
110
+ self.user_id = result.get('user_id')
111
+ self._save_token(self.token)
112
+ print(f"Logged in: {email}")
113
+ return result
114
+ else:
115
+ error = response.json().get('error', 'Unknown error')
116
+ raise Exception(f"Authentication failed: {error}")
117
+
118
+ def upload_memory(self, content: str) -> dict:
119
+ """上传记忆
120
+
121
+ Args:
122
+ content: 记忆内容
123
+
124
+ Returns:
125
+ 上传结果
126
+ """
127
+ url = f"{self.cloud_url}/api/memories"
128
+ data = {'content': content}
129
+
130
+ response = requests.post(url, json=data, headers=self._get_headers())
131
+
132
+ if response.status_code == 200:
133
+ return response.json()
134
+ elif response.status_code == 403:
135
+ error = response.json().get('error', 'Subscription required')
136
+ raise Exception(f"Upload failed: {error}")
137
+ else:
138
+ error = response.json().get('error', 'Unknown error')
139
+ raise Exception(f"Upload failed: {error}")
140
+
141
+ def download_memory(self) -> dict:
142
+ """下载记忆
143
+
144
+ Returns:
145
+ 包含 content, version 等信息的字典
146
+ """
147
+ url = f"{self.cloud_url}/api/memories"
148
+
149
+ response = requests.get(url, headers=self._get_headers())
150
+
151
+ if response.status_code == 200:
152
+ return response.json()
153
+ elif response.status_code == 404:
154
+ return {'content': '', 'version': 0}
155
+ else:
156
+ error = response.json().get('error', 'Unknown error')
157
+ raise Exception(f"Download failed: {error}")
158
+
159
+ def get_profile(self) -> dict:
160
+ """获取用户信息
161
+
162
+ Returns:
163
+ 用户信息
164
+ """
165
+ url = f"{self.cloud_url}/api/memories/profile"
166
+
167
+ response = requests.get(url, headers=self._get_headers())
168
+
169
+ if response.status_code == 200:
170
+ return response.json()
171
+ else:
172
+ error = response.json().get('error', 'Unknown error')
173
+ raise Exception(f"Get profile failed: {error}")
174
+
175
+ def connect_websocket(self, on_message_callback):
176
+ """连接 WebSocket
177
+
178
+ Args:
179
+ on_message_callback: 消息回调函数
180
+ """
181
+ ws_url = self.cloud_url.replace('http', 'ws') + '/ws'
182
+
183
+ def on_ws_message(ws, message):
184
+ try:
185
+ data = json.loads(message)
186
+ on_message_callback(data)
187
+ except Exception as e:
188
+ print(f"WebSocket message error: {e}")
189
+
190
+ def on_ws_error(ws, error):
191
+ print(f"WebSocket error: {error}")
192
+
193
+ def on_ws_close(ws, close_status_code, close_msg):
194
+ print(f"WebSocket closed: {close_status_code} - {close_msg}")
195
+
196
+ def on_ws_open(ws):
197
+ print("WebSocket connected")
198
+ if self.token:
199
+ ws.send(json.dumps({'type': 'auth', 'token': self.token}))
200
+
201
+ self.ws = websocket.WebSocketApp(
202
+ ws_url,
203
+ on_message=on_ws_message,
204
+ on_error=on_ws_error,
205
+ on_close=on_ws_close,
206
+ on_open=on_ws_open
207
+ )
208
+
209
+ self.ws.on_pong = lambda ws, data: None
210
+
211
+ import threading
212
+ self.ws_thread = threading.Thread(target=self.ws.run_forever, daemon=True)
213
+ self.ws_thread.start()
214
+
215
+ def disconnect_websocket(self):
216
+ """断开 WebSocket 连接"""
217
+ if self.ws:
218
+ self.ws.close()
219
+ self.ws = None
220
+
221
+ def send_ping(self):
222
+ """发送 ping 保持连接"""
223
+ if self.ws and self.ws.sock and self.ws.sock.connected:
224
+ self.ws.send(json.dumps({'type': 'ping'}))
package/src/main.py ADDED
@@ -0,0 +1,198 @@
1
+ import json
2
+ import os
3
+ import sys
4
+ import time
5
+
6
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'base'))
7
+
8
+ from src.client import OpenClawClient
9
+ from src.watcher import OpenClawMultiWatcher
10
+ from src.version_manager import VersionManager
11
+ from src.profiles import ProfilesClient
12
+ from src.sync import ProfileSync
13
+
14
+
15
+ class SoulSyncPlugin:
16
+ """SoulSync OpenClaw 插件主类"""
17
+
18
+ def __init__(self):
19
+ self.config = None
20
+ self.client = None
21
+ self.profiles_client = None
22
+ self.watcher = None
23
+ self.version_manager = None
24
+ self.profile_sync = None
25
+ self.running = False
26
+
27
+ def load_config(self):
28
+ """加载配置文件"""
29
+ config_path = os.path.join(os.path.dirname(__file__), '..', 'config.json')
30
+
31
+ if not os.path.exists(config_path):
32
+ raise FileNotFoundError(f"Config file not found: {config_path}")
33
+
34
+ with open(config_path, 'r', encoding='utf-8') as f:
35
+ self.config = json.load(f)
36
+
37
+ workspace = self.config.get('workspace', './workspace')
38
+ workspace = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', workspace))
39
+
40
+ watch_files = self.config.get('watch_files', ['MEMORY.md', 'memory/'])
41
+
42
+ self.config['workspace'] = workspace
43
+ self.config['watch_files'] = watch_files
44
+
45
+ print(f"Config loaded:")
46
+ print(f" Cloud URL: {self.config.get('cloud_url')}")
47
+ print(f" Workspace: {workspace}")
48
+ print(f" Watch files: {watch_files}")
49
+
50
+ def initialize(self):
51
+ """初始化组件"""
52
+ print("\n=== Initializing SoulSync Plugin ===\n")
53
+
54
+ self.client = OpenClawClient(self.config)
55
+
56
+ email = self.config.get('email')
57
+ password = self.config.get('password')
58
+
59
+ if not email or not password:
60
+ print("WARNING: Email and password not configured!")
61
+ print("Please edit config.json and add your email and password\n")
62
+
63
+ try:
64
+ self.client.authenticate(email, password)
65
+ except Exception as e:
66
+ print(f"Authentication error: {e}")
67
+ print("Please check your config.json and try again\n")
68
+ raise
69
+
70
+ profile = self.client.get_profile()
71
+ print(f"\nLogged in as: {profile.get('email')}")
72
+ subscription = profile.get('subscription', {})
73
+ print(f"Subscription: {subscription.get('status')} (days remaining: {subscription.get('daysRemaining', 0)})\n")
74
+
75
+ versions_file = os.path.join(os.path.dirname(__file__), '..', 'versions.json')
76
+ self.version_manager = VersionManager(versions_file)
77
+
78
+ self.profiles_client = ProfilesClient(
79
+ self.config.get('cloud_url'),
80
+ self.client.token
81
+ )
82
+
83
+ self.profile_sync = ProfileSync(
84
+ self.profiles_client,
85
+ self.version_manager,
86
+ self.config.get('workspace')
87
+ )
88
+
89
+ print("Pulling all profiles from cloud...")
90
+ self.profile_sync.pull_all()
91
+
92
+ print("\nStarting file watcher...")
93
+ watch_files = self.config.get('watch_files', [])
94
+ self.watcher = OpenClawMultiWatcher(
95
+ self.config.get('workspace'),
96
+ watch_files,
97
+ self.on_file_change
98
+ )
99
+ self.watcher.start()
100
+
101
+ print("\nConnecting to WebSocket...")
102
+ self.client.connect_websocket(self.on_websocket_message)
103
+
104
+ self.running = True
105
+
106
+ def on_file_change(self, event_type: str, relative_path: str, absolute_path: str = None):
107
+ """文件变化回调"""
108
+ print(f"\n[File {event_type}] {relative_path}")
109
+
110
+ if event_type in ['modified', 'created']:
111
+ time.sleep(0.5)
112
+
113
+ try:
114
+ self.profile_sync.push_file(relative_path)
115
+ print(f"Upload completed: {relative_path}")
116
+ except Exception as e:
117
+ print(f"Upload error: {e}")
118
+
119
+ elif event_type == 'deleted':
120
+ print(f"File deleted (not synced to cloud): {relative_path}")
121
+
122
+ def on_websocket_message(self, data: dict):
123
+ """WebSocket 消息回调"""
124
+ event = data.get('event')
125
+
126
+ if event == 'file_updated':
127
+ file_path = data.get('file_path')
128
+ version = data.get('version')
129
+ print(f"\n[WebSocket] File updated: {file_path} (v{version})")
130
+ try:
131
+ self.profile_sync.on_remote_change(file_path, version)
132
+ except Exception as e:
133
+ print(f"Sync error: {e}")
134
+
135
+ elif event == 'new_memory':
136
+ print(f"\n[WebSocket] New memory available!")
137
+ try:
138
+ self.profile_sync.pull_all()
139
+ print("Memory synced from remote")
140
+ except Exception as e:
141
+ print(f"Sync error: {e}")
142
+
143
+ elif data.get('type') == 'authenticated':
144
+ print(f"[WebSocket] Authenticated, socket_id: {data.get('socket_id')}")
145
+ elif data.get('type') == 'error':
146
+ print(f"[WebSocket] Error: {data.get('message')}")
147
+
148
+ def run(self):
149
+ """运行插件"""
150
+ print("\n" + "=" * 50)
151
+ print("SoulSync OpenClaw Plugin (Multi-File Sync)")
152
+ print("=" * 50 + "\n")
153
+
154
+ try:
155
+ self.load_config()
156
+ self.initialize()
157
+
158
+ print("\n=== Plugin Running ===")
159
+ print("Press Ctrl+C to stop\n")
160
+
161
+ while self.running:
162
+ time.sleep(1)
163
+
164
+ if hasattr(self.client, 'ws') and self.client.ws:
165
+ if not self.client.ws.sock or not self.client.ws.sock.connected:
166
+ print("WebSocket disconnected, reconnecting...")
167
+ try:
168
+ self.client.connect_websocket(self.on_websocket_message)
169
+ except Exception as e:
170
+ print(f"Reconnect error: {e}")
171
+
172
+ except KeyboardInterrupt:
173
+ print("\n\nShutting down...")
174
+ except Exception as e:
175
+ print(f"Error: {e}")
176
+ finally:
177
+ self.stop()
178
+
179
+ def stop(self):
180
+ """停止插件"""
181
+ self.running = False
182
+
183
+ if self.watcher:
184
+ self.watcher.stop()
185
+
186
+ if self.client:
187
+ self.client.disconnect_websocket()
188
+
189
+ print("Plugin stopped")
190
+
191
+
192
+ def main():
193
+ plugin = SoulSyncPlugin()
194
+ plugin.run()
195
+
196
+
197
+ if __name__ == '__main__':
198
+ main()
@@ -0,0 +1,110 @@
1
+ import requests
2
+
3
+
4
+ class ProfilesClient:
5
+ """Profiles API 客户端"""
6
+
7
+ def __init__(self, cloud_url: str, token: str = None):
8
+ self.cloud_url = cloud_url.rstrip('/')
9
+ self.token = token
10
+
11
+ def _get_headers(self) -> dict:
12
+ """获取请求头"""
13
+ headers = {'Content-Type': 'application/json'}
14
+ if self.token:
15
+ headers['Authorization'] = f'Bearer {self.token}'
16
+ return headers
17
+
18
+ def set_token(self, token: str):
19
+ """设置 token"""
20
+ self.token = token
21
+
22
+ def get_profiles(self, path: str = None) -> dict:
23
+ """获取 profiles
24
+
25
+ Args:
26
+ path: 可选的文件路径
27
+
28
+ Returns:
29
+ 包含 files 列表的字典
30
+ """
31
+ url = f"{self.cloud_url}/api/profiles"
32
+ if path:
33
+ url += f"?path={path}"
34
+
35
+ response = requests.get(url, headers=self._get_headers())
36
+
37
+ if response.status_code == 200:
38
+ return response.json()
39
+ elif response.status_code == 404:
40
+ return {'files': []}
41
+ else:
42
+ error = response.json().get('error', 'Unknown error')
43
+ raise Exception(f"Get profiles failed: {error}")
44
+
45
+ def upload_profile(self, file_path: str, content: str, version: int) -> dict:
46
+ """上传 profile
47
+
48
+ Args:
49
+ file_path: 文件路径
50
+ content: 文件内容
51
+ version: 当前版本号
52
+
53
+ Returns:
54
+ 成功时返回 {file_path, version, updated_at}
55
+ 冲突时抛出异常
56
+ """
57
+ url = f"{self.cloud_url}/api/profiles"
58
+ data = {
59
+ 'file_path': file_path,
60
+ 'content': content,
61
+ 'version': version
62
+ }
63
+
64
+ response = requests.post(url, json=data, headers=self._get_headers())
65
+
66
+ if response.status_code == 200:
67
+ return response.json()
68
+ elif response.status_code == 409:
69
+ result = response.json()
70
+ raise ConflictError(
71
+ result.get('latest_content', ''),
72
+ result.get('latest_version', 0)
73
+ )
74
+ elif response.status_code == 403:
75
+ error = response.json().get('error', 'Subscription required')
76
+ raise Exception(f"Upload failed: {error}")
77
+ else:
78
+ error = response.json().get('error', 'Unknown error')
79
+ raise Exception(f"Upload failed: {error}")
80
+
81
+ def sync_profiles(self, since: str = '0') -> dict:
82
+ """增量同步 profiles
83
+
84
+ Args:
85
+ since: 时间戳
86
+
87
+ Returns:
88
+ 包含 files 列表和 server_time 的字典
89
+ """
90
+ url = f"{self.cloud_url}/api/profiles/sync?since={since}"
91
+
92
+ response = requests.get(url, headers=self._get_headers())
93
+
94
+ if response.status_code == 200:
95
+ return response.json()
96
+ elif response.status_code == 403:
97
+ error = response.json().get('error', 'Subscription required')
98
+ raise Exception(f"Sync failed: {error}")
99
+ else:
100
+ error = response.json().get('error', 'Unknown error')
101
+ raise Exception(f"Sync failed: {error}")
102
+
103
+
104
+ class ConflictError(Exception):
105
+ """版本冲突异常"""
106
+
107
+ def __init__(self, latest_content: str, latest_version: int):
108
+ self.latest_content = latest_content
109
+ self.latest_version = latest_version
110
+ super().__init__(f"Version conflict: latest version is {latest_version}")
package/src/sync.py ADDED
@@ -0,0 +1,140 @@
1
+ import os
2
+ import time
3
+ from src.profiles import ProfilesClient, ConflictError
4
+
5
+
6
+ class ProfileSync:
7
+ """多文件同步逻辑"""
8
+
9
+ def __init__(self, client: ProfilesClient, version_manager, workspace: str):
10
+ self.client = client
11
+ self.version_manager = version_manager
12
+ self.workspace = os.path.abspath(workspace)
13
+ self.is_syncing = False
14
+
15
+ def pull_all(self) -> int:
16
+ """从云端拉取所有文件
17
+
18
+ Returns:
19
+ 拉取的文件数量
20
+ """
21
+ print("[Sync] Pulling all profiles from cloud...")
22
+ self.is_syncing = True
23
+
24
+ try:
25
+ result = self.client.sync_profiles(since='0')
26
+ files = result.get('files', [])
27
+
28
+ if not files:
29
+ print("[Sync] No files in cloud")
30
+ return 0
31
+
32
+ count = 0
33
+ for file_info in files:
34
+ file_path = file_info.get('file_path')
35
+ content = file_info.get('content')
36
+ version = file_info.get('version')
37
+
38
+ if file_path and content is not None:
39
+ self._write_local_file(file_path, content)
40
+ self.version_manager.set_version(file_path, version)
41
+ count += 1
42
+ print(f"[Sync] Downloaded: {file_path} (v{version})")
43
+
44
+ print(f"[Sync] Pulled {count} files")
45
+ return count
46
+ finally:
47
+ self.is_syncing = False
48
+
49
+ def push_file(self, file_path: str):
50
+ """推送单个文件到云端
51
+
52
+ Args:
53
+ file_path: 文件相对路径
54
+ """
55
+ if self.is_syncing:
56
+ print(f"[Sync] Skipping push, currently syncing: {file_path}")
57
+ return
58
+
59
+ print(f"[Sync] Pushing file: {file_path}")
60
+
61
+ content = self._read_local_file(file_path)
62
+ if content is None:
63
+ print(f"[Sync] File not found or read error: {file_path}")
64
+ return
65
+
66
+ local_version = self.version_manager.get_version(file_path)
67
+
68
+ try:
69
+ result = self.client.upload_profile(file_path, content, local_version)
70
+
71
+ new_version = result.get('version')
72
+ self.version_manager.set_version(file_path, new_version)
73
+
74
+ print(f"[Sync] Uploaded: {file_path} (v{new_version})")
75
+
76
+ except ConflictError as e:
77
+ print(f"[Sync] Version conflict for {file_path}, pulling latest...")
78
+
79
+ self._write_local_file(file_path, e.latest_content)
80
+ self.version_manager.set_version(file_path, e.latest_version)
81
+
82
+ print(f"[Sync] Resolved conflict: {file_path} (now v{e.latest_version})")
83
+
84
+ except Exception as e:
85
+ print(f"[Sync] Upload error: {e}")
86
+
87
+ def on_remote_change(self, file_path: str, version: int):
88
+ """远程文件变化回调
89
+
90
+ Args:
91
+ file_path: 文件相对路径
92
+ version: 新版本号
93
+ """
94
+ print(f"[Sync] Remote change detected: {file_path} (v{version})")
95
+
96
+ local_version = self.version_manager.get_version(file_path)
97
+
98
+ if version > local_version:
99
+ try:
100
+ result = self.client.get_profiles(path=file_path)
101
+ files = result.get('files', [])
102
+
103
+ if files:
104
+ file_info = files[0]
105
+ content = file_info.get('content')
106
+
107
+ self._write_local_file(file_path, content)
108
+ self.version_manager.set_version(file_path, version)
109
+
110
+ print(f"[Sync] Pulled remote change: {file_path} (v{version})")
111
+ except Exception as e:
112
+ print(f"[Sync] Pull remote change error: {e}")
113
+
114
+ def _read_local_file(self, file_path: str) -> str:
115
+ """读取本地文件"""
116
+ full_path = os.path.join(self.workspace, file_path)
117
+
118
+ if not os.path.exists(full_path):
119
+ return None
120
+
121
+ try:
122
+ with open(full_path, 'r', encoding='utf-8') as f:
123
+ return f.read()
124
+ except Exception as e:
125
+ print(f"[Sync] Read error {file_path}: {e}")
126
+ return None
127
+
128
+ def _write_local_file(self, file_path: str, content: str):
129
+ """写入本地文件"""
130
+ full_path = os.path.join(self.workspace, file_path)
131
+
132
+ directory = os.path.dirname(full_path)
133
+ if directory and not os.path.exists(directory):
134
+ os.makedirs(directory, exist_ok=True)
135
+
136
+ try:
137
+ with open(full_path, 'w', encoding='utf-8') as f:
138
+ f.write(content)
139
+ except Exception as e:
140
+ print(f"[Sync] Write error {file_path}: {e}")
@@ -0,0 +1,61 @@
1
+ import json
2
+ import os
3
+
4
+
5
+ class VersionManager:
6
+ """本地版本管理"""
7
+
8
+ def __init__(self, versions_file: str):
9
+ self.versions_file = versions_file
10
+ self.versions = {}
11
+ self.load()
12
+
13
+ def load(self):
14
+ """加载版本文件"""
15
+ if os.path.exists(self.versions_file):
16
+ try:
17
+ with open(self.versions_file, 'r', encoding='utf-8') as f:
18
+ self.versions = json.load(f)
19
+ except Exception as e:
20
+ print(f"Failed to load versions file: {e}")
21
+ self.versions = {}
22
+ else:
23
+ self.versions = {}
24
+
25
+ def save(self):
26
+ """保存版本文件"""
27
+ try:
28
+ directory = os.path.dirname(self.versions_file)
29
+ if directory and not os.path.exists(directory):
30
+ os.makedirs(directory, exist_ok=True)
31
+
32
+ with open(self.versions_file, 'w', encoding='utf-8') as f:
33
+ json.dump(self.versions, f, indent=2, ensure_ascii=False)
34
+ except Exception as e:
35
+ print(f"Failed to save versions file: {e}")
36
+
37
+ def get_version(self, file_path: str) -> int:
38
+ """获取文件版本"""
39
+ return self.versions.get(file_path, 0)
40
+
41
+ def set_version(self, file_path: str, version: int):
42
+ """设置文件版本"""
43
+ self.versions[file_path] = version
44
+ self.save()
45
+
46
+ def increment_version(self, file_path: str) -> int:
47
+ """递增版本"""
48
+ current = self.get_version(file_path)
49
+ new_version = current + 1
50
+ self.set_version(file_path, new_version)
51
+ return new_version
52
+
53
+ def update_versions(self, updates: dict):
54
+ """批量更新版本"""
55
+ for file_path, version in updates.items():
56
+ self.versions[file_path] = version
57
+ self.save()
58
+
59
+ def get_all_versions(self) -> dict:
60
+ """获取所有版本"""
61
+ return self.versions.copy()
package/src/watcher.py ADDED
@@ -0,0 +1,133 @@
1
+ import os
2
+ import time
3
+ from watchdog.observers import Observer
4
+ from watchdog.events import FileSystemEventHandler, FileSystemEvent
5
+
6
+
7
+ class OpenClawMultiWatcher(FileSystemEventHandler):
8
+ """OpenClaw 多文件/目录监听器"""
9
+
10
+ def __init__(self, workspace: str, watch_paths: list, callback):
11
+ self.workspace = os.path.abspath(workspace)
12
+ self.watch_paths = watch_paths
13
+ self.callback = callback
14
+ self.observer = None
15
+ self.last_events = {}
16
+ self.debounce_seconds = 1
17
+ self.ignore_patterns = ['.tmp', '.swp', '.bak', '~']
18
+
19
+ def start(self):
20
+ """开始监听"""
21
+ if not os.path.exists(self.workspace):
22
+ os.makedirs(self.workspace, exist_ok=True)
23
+
24
+ self.observer = Observer()
25
+
26
+ for watch_path in self.watch_paths:
27
+ full_path = os.path.join(self.workspace, watch_path)
28
+
29
+ if watch_path.endswith('/'):
30
+ dir_name = watch_path.rstrip('/')
31
+ if not os.path.exists(full_path):
32
+ os.makedirs(full_path, exist_ok=True)
33
+ print(f"Created directory: {full_path}")
34
+
35
+ self.observer.schedule(self, full_path, recursive=True)
36
+ print(f"Watching directory: {watch_path}")
37
+ else:
38
+ directory = os.path.dirname(full_path)
39
+ if directory and not os.path.exists(directory):
40
+ os.makedirs(directory, exist_ok=True)
41
+
42
+ if not os.path.exists(full_path):
43
+ with open(full_path, 'w', encoding='utf-8') as f:
44
+ pass
45
+ print(f"Created file: {watch_path}")
46
+
47
+ self.observer.schedule(self, os.path.dirname(full_path), recursive=False)
48
+ print(f"Watching file: {watch_path}")
49
+
50
+ self.observer.start()
51
+ print(f"Started watching {len(self.watch_paths)} paths in {self.workspace}")
52
+
53
+ def stop(self):
54
+ """停止监听"""
55
+ if self.observer:
56
+ self.observer.stop()
57
+ self.observer.join()
58
+ print("Stopped watching")
59
+
60
+ def _should_ignore(self, path: str) -> bool:
61
+ """检查是否应该忽略"""
62
+ basename = os.path.basename(path)
63
+ for pattern in self.ignore_patterns:
64
+ if pattern in basename:
65
+ return True
66
+ return False
67
+
68
+ def _get_relative_path(self, absolute_path: str) -> str:
69
+ """获取相对路径"""
70
+ if absolute_path.startswith(self.workspace):
71
+ relative = absolute_path[len(self.workspace):].lstrip('/')
72
+ return relative
73
+ return absolute_path
74
+
75
+ def _debounce_check(self, file_path: str) -> bool:
76
+ """防抖检查"""
77
+ current_time = time.time()
78
+ last_time = self.last_events.get(file_path, 0)
79
+
80
+ if current_time - last_time < self.debounce_seconds:
81
+ return False
82
+
83
+ self.last_events[file_path] = current_time
84
+ return True
85
+
86
+ def on_modified(self, event):
87
+ """文件被修改"""
88
+ if event.is_directory:
89
+ return
90
+
91
+ event_path = os.path.abspath(event.src_path)
92
+
93
+ if self._should_ignore(event_path):
94
+ return
95
+
96
+ if self._debounce_check(event_path):
97
+ relative_path = self._get_relative_path(event_path)
98
+ print(f"[Watcher] File modified: {relative_path}")
99
+ self._trigger_callback('modified', relative_path, event_path)
100
+
101
+ def on_created(self, event):
102
+ """文件被创建"""
103
+ if event.is_directory:
104
+ return
105
+
106
+ event_path = os.path.abspath(event.src_path)
107
+
108
+ if self._should_ignore(event_path):
109
+ return
110
+
111
+ if self._debounce_check(event_path):
112
+ relative_path = self._get_relative_path(event_path)
113
+ print(f"[Watcher] File created: {relative_path}")
114
+ self._trigger_callback('created', relative_path, event_path)
115
+
116
+ def on_deleted(self, event):
117
+ """文件被删除"""
118
+ if event.is_directory:
119
+ return
120
+
121
+ event_path = os.path.abspath(event.src_path)
122
+
123
+ if self._should_ignore(event_path):
124
+ return
125
+
126
+ relative_path = self._get_relative_path(event_path)
127
+ print(f"[Watcher] File deleted: {relative_path}")
128
+ self._trigger_callback('deleted', relative_path, event_path)
129
+
130
+ def _trigger_callback(self, event_type: str, relative_path: str, absolute_path: str = None):
131
+ """触发回调函数"""
132
+ if self.callback:
133
+ self.callback(event_type, relative_path, absolute_path)
@@ -0,0 +1,5 @@
1
+ # Memory
2
+
3
+ This is your SoulSync memory file.
4
+
5
+ Start writing your memories here, and they will be automatically synced to the cloud!