soulsync 1.0.12 → 1.0.13

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/src/main.py CHANGED
@@ -61,7 +61,7 @@ class SoulSyncPlugin:
61
61
 
62
62
  cloud_url = self.config.get('cloud_url', '').strip()
63
63
  email = self.config.get('email', '').strip()
64
- password = self.config.get('password', '').strip()
64
+ token = self.config.get('token', '').strip()
65
65
 
66
66
  if not cloud_url:
67
67
  self.config['cloud_url'] = 'https://soulsync.work'
@@ -98,11 +98,6 @@ class SoulSyncPlugin:
98
98
  elif 'email' in auth_result:
99
99
  config['email'] = auth_result.get('email', '')
100
100
 
101
- # 保存 password(注册时输入的密码,需要保存以便自动登录)
102
- # 注意:登录时我们只有从 config 读取的 password
103
- if 'password' in auth_result:
104
- config['password'] = auth_result.get('password', '')
105
-
106
101
  # 保存 token
107
102
  if 'token' in auth_result:
108
103
  config['token'] = auth_result['token']
@@ -290,47 +285,32 @@ class SoulSyncPlugin:
290
285
  profile = self.client.get_profile()
291
286
  print(f"[SoulSync] Using existing token, user: {profile.get('email', 'unknown')}")
292
287
  except Exception as e:
288
+ error_str = str(e)
289
+ if '401' in error_str or 'Unauthorized' in error_str or 'token' in error_str.lower():
290
+ print("[SoulSync] Token expired or invalid. Please re-login via chat: say 'login SoulSync'")
291
+ print("[SoulSync] Token 已过期,请在聊天中说 'login SoulSync' 重新登录")
292
+ return False
293
293
  print(f"[SoulSync] Token invalid, re-authenticating: {e}")
294
294
  token = None
295
295
 
296
296
  if not token:
297
- email = self.config.get('email', '').strip()
298
- password = self.config.get('password', '').strip()
299
-
300
- print("[SoulSync] No valid token, attempting auto-login...")
301
- try:
302
- result = self.client.authenticate(email, password)
303
- if result:
304
- result['email'] = email
305
- result['password'] = password
306
- print("[SoulSync] Login successful! / 登录成功!")
307
- self._save_auth_to_config(result)
308
- token = self.client.token
309
- except Exception as e:
310
- error_msg = str(e)
311
-
312
- if "429" in error_msg or "too many" in error_msg.lower():
313
- print("\n[SoulSync] ========================================")
314
- print(f"[SoulSync] ❌ {e}")
315
- print("[SoulSync] Too many failed attempts. Please try again later / 登录失败次数过多,请稍后再试")
316
- print("[SoulSync] ========================================\n")
317
- else:
318
- print("\n[SoulSync] ========================================")
319
- print(f"[SoulSync] ❌ {e}")
320
- print("[SoulSync] Please run 'openclaw soulsync:setup' to reconfigure / 请运行 'openclaw soulsync:setup' 重新配置")
321
- print("[SoulSync] ========================================\n")
322
-
323
- sys.exit(0)
324
-
297
+ print("[SoulSync] Token expired or invalid. Please re-configure using 'openclaw soulsync:setup'")
298
+ print("[SoulSync] Token 已过期,请重新运行 'openclaw soulsync:setup' 配置")
299
+ return False
300
+
325
301
  email = self.config.get('email')
326
- password = self.config.get('password')
327
-
302
+
328
303
  try:
329
304
  profile = self.client.get_profile()
330
305
  print(f"\n[SoulSync] Logged in as: {profile.get('email')}")
331
306
  subscription = profile.get('subscription', {})
332
307
  print(f"[SoulSync] Subscription: {subscription.get('status')} (days remaining: {subscription.get('daysRemaining', 0)})\n")
333
308
  except Exception as e:
309
+ error_str = str(e)
310
+ if '401' in error_str or 'Unauthorized' in error_str or 'token' in error_str.lower():
311
+ print("[SoulSync] Token expired or invalid. Please re-login via chat: say 'login SoulSync'")
312
+ print("[SoulSync] Token 已过期,请在聊天中说 'login SoulSync' 重新登录")
313
+ return False
334
314
  print(f"[SoulSync] Warning: Could not get profile: {e}")
335
315
 
336
316
  # 版本管理器
@@ -391,20 +371,12 @@ class SoulSyncPlugin:
391
371
  """WebSocket 消息回调"""
392
372
  event = data.get('event')
393
373
 
394
- if event == 'file_updated':
395
- file_path = data.get('file_path')
374
+ if event == 'profile:updated':
375
+ user_id = data.get('user_id')
396
376
  version = data.get('version')
397
- print(f"\n[SoulSync] [WebSocket] File updated: {file_path} (v{version})")
398
- try:
399
- self.profile_sync.on_remote_change(file_path, version)
400
- except Exception as e:
401
- print(f"[SoulSync] Sync error: {e}")
402
-
403
- elif event == 'new_memory':
404
- print(f"\n[SoulSync] [WebSocket] New memory available!")
377
+ print(f"\n[SoulSync] [WebSocket] Profile updated (v{version})")
405
378
  try:
406
379
  self.profile_sync.pull_all()
407
- print("[SoulSync] Memory synced from remote")
408
380
  except Exception as e:
409
381
  print(f"[SoulSync] Sync error: {e}")
410
382
 
@@ -489,9 +461,9 @@ def main():
489
461
  plugin.load_config()
490
462
 
491
463
  email = plugin.config.get('email', '').strip()
492
- password = plugin.config.get('password', '').strip()
464
+ token = plugin.config.get('token', '').strip()
493
465
 
494
- if not email or not password:
466
+ if not email or not token:
495
467
  print("\n[SoulSync] ========================================")
496
468
  print("[SoulSync] Not configured. Run 'openclaw soulsync:setup' first.")
497
469
  print("[SoulSync] 尚未配置,请先运行 'openclaw soulsync:setup'")
package/src/profiles.py CHANGED
@@ -1,12 +1,19 @@
1
1
  import requests
2
+ import sys
3
+ import os
4
+
5
+ sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
6
+ from src.client import TLSAdapter
2
7
 
3
8
 
4
9
  class ProfilesClient:
5
- """Profiles API 客户端"""
10
+ """Profiles API 客户端 - 统一同步接口"""
6
11
 
7
12
  def __init__(self, cloud_url: str, token: str = None):
8
13
  self.cloud_url = cloud_url.rstrip('/')
9
14
  self.token = token
15
+ self.session = requests.Session()
16
+ self.session.mount('https://', TLSAdapter())
10
17
 
11
18
  def _get_headers(self) -> dict:
12
19
  """获取请求头"""
@@ -19,57 +26,50 @@ class ProfilesClient:
19
26
  """设置 token"""
20
27
  self.token = token
21
28
 
22
- def get_profiles(self, path: str = None) -> dict:
23
- """获取 profiles
29
+ def get_profiles(self) -> dict:
30
+ """获取用户的完整 profiles
24
31
 
25
- Args:
26
- path: 可选的文件路径
27
-
28
32
  Returns:
29
- 包含 files 列表的字典
33
+ 包含 content (dict), version, updated_at 的字典
34
+ 示例: {"content": {"SOUL.md": "...", "USER.md": "..."}, "version": 3, "updated_at": "..."}
30
35
  """
31
36
  url = f"{self.cloud_url}/api/profiles"
32
- if path:
33
- url += f"?path={path}"
34
37
 
35
- response = requests.get(url, headers=self._get_headers())
38
+ response = self.session.get(url, headers=self._get_headers())
36
39
 
37
40
  if response.status_code == 200:
38
41
  return response.json()
39
- elif response.status_code == 404:
40
- return {'files': []}
41
42
  else:
42
43
  error = response.json().get('error', 'Unknown error')
43
44
  raise Exception(f"Get profiles failed: {error}")
44
45
 
45
- def upload_profile(self, file_path: str, content: str, version: int) -> dict:
46
- """上传 profile
46
+ def upload_profiles(self, content: dict, version: int) -> dict:
47
+ """整体替换用户的 profiles
47
48
 
48
49
  Args:
49
- file_path: 文件路径
50
- content: 文件内容
51
- version: 当前版本号
50
+ content: 包含所有文件的 dict,key 是文件名,value 是内容
51
+ 示例: {"SOUL.md": "...", "USER.md": "...", "MEMORY.md": "..."}
52
+ version: 客户端当前持有的版本号
52
53
 
53
54
  Returns:
54
- 成功时返回 {file_path, version, updated_at}
55
- 冲突时抛出异常
55
+ 成功时返回 {"content": {...}, "version": N, "updated_at": "..."}
56
+ 冲突时抛出 ConflictError
56
57
  """
57
58
  url = f"{self.cloud_url}/api/profiles"
58
59
  data = {
59
- 'file_path': file_path,
60
60
  'content': content,
61
61
  'version': version
62
62
  }
63
63
 
64
- response = requests.post(url, json=data, headers=self._get_headers())
64
+ response = self.session.put(url, json=data, headers=self._get_headers())
65
65
 
66
66
  if response.status_code == 200:
67
67
  return response.json()
68
68
  elif response.status_code == 409:
69
69
  result = response.json()
70
70
  raise ConflictError(
71
- result.get('latest_content', ''),
72
- result.get('latest_version', 0)
71
+ server_content=result.get('server_content', {}),
72
+ server_version=result.get('server_version', 0)
73
73
  )
74
74
  elif response.status_code == 403:
75
75
  error = response.json().get('error', 'Subscription required')
@@ -77,34 +77,12 @@ class ProfilesClient:
77
77
  else:
78
78
  error = response.json().get('error', 'Unknown error')
79
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
80
 
103
81
 
104
82
  class ConflictError(Exception):
105
83
  """版本冲突异常"""
106
84
 
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}")
85
+ def __init__(self, server_content: dict, server_version: int):
86
+ self.server_content = server_content
87
+ self.server_version = server_version
88
+ super().__init__(f"Version conflict: server version is {server_version}")
package/src/sync.py CHANGED
@@ -42,7 +42,7 @@ class ProfileSync:
42
42
  with open(absolute_path, 'r', encoding='utf-8') as f:
43
43
  return f.read()
44
44
  except Exception as e:
45
- print(f"[Sync] Error reading local file {file_path}: {e}")
45
+ print(f"[SoulSync] Error reading local file {file_path}: {e}")
46
46
  return None
47
47
  return None
48
48
 
@@ -57,7 +57,7 @@ class ProfileSync:
57
57
  with open(absolute_path, 'w', encoding='utf-8') as f:
58
58
  f.write(content)
59
59
  except Exception as e:
60
- print(f"[Sync] Error writing local file {file_path}: {e}")
60
+ print(f"[SoulSync] Error writing local file {file_path}: {e}")
61
61
 
62
62
  def _create_conflict_backup(self, file_path: str, local_content: str, server_content: str):
63
63
  """创建冲突备份文件"""
@@ -71,64 +71,51 @@ class ProfileSync:
71
71
  f.write(local_content or "(empty)")
72
72
  f.write("\n\n========== SERVER VERSION ==========\n")
73
73
  f.write(server_content or "(empty)")
74
- print(f"[Sync] Conflict backup created: {conflict_path}")
74
+ print(f"[SoulSync] Conflict backup created: {conflict_path}")
75
75
  except Exception as e:
76
- print(f"[Sync] Error creating conflict backup: {e}")
76
+ print(f"[SoulSync] Error creating conflict backup: {e}")
77
77
 
78
78
  def pull_all(self):
79
79
  """Pull all profiles from cloud"""
80
- print("[Sync] Pulling all profiles from cloud...")
80
+ print("[SoulSync] Pulling all profiles from cloud...")
81
81
 
82
82
  try:
83
83
  result = self.client.get_profiles()
84
- cloud_files = result.get('files', [])
84
+ cloud_content = result.get('content', {})
85
+ cloud_version = result.get('version', 0)
85
86
  except Exception as e:
86
- print(f"[Sync] Error fetching cloud profiles: {e}")
87
+ print(f"[SoulSync] Error fetching cloud profiles: {e}")
87
88
  return
88
89
 
89
- if not cloud_files:
90
- print("[Sync] No files on cloud")
90
+ if not cloud_content:
91
+ print("[SoulSync] No profiles on cloud")
91
92
  return
92
93
 
94
+ local_files = ['SOUL.md', 'USER.md', 'MEMORY.md']
93
95
  pulled_count = 0
94
- pushed_count = 0
95
96
  skipped_count = 0
96
97
 
97
- for cloud_file in cloud_files:
98
- file_path = cloud_file.get('file_path')
99
- cloud_version = cloud_file.get('version', 0)
100
- cloud_content = cloud_file.get('content', '')
101
-
102
- if not file_path:
103
- continue
104
-
105
- local_content = self._read_local_file(file_path)
106
- local_version = self.version_manager.get_version(file_path)
98
+ for file_name in local_files:
99
+ cloud_file_content = cloud_content.get(file_name, '')
100
+ local_content = self._read_local_file(file_name)
101
+ local_version = self.version_manager.get_version(file_name)
107
102
 
108
103
  if cloud_version > local_version:
109
- self._mark_syncing(file_path)
104
+ self._mark_syncing(file_name)
110
105
  try:
111
- self._write_local_file(file_path, cloud_content)
112
- self.version_manager.set_version(file_path, cloud_version)
113
- pulled_count += 1
114
- print(f"[Sync] Pulled: {file_path} (v{cloud_version})")
106
+ if cloud_file_content:
107
+ self._write_local_file(file_name, cloud_file_content)
108
+ self.version_manager.set_version(file_name, cloud_version)
109
+ pulled_count += 1
110
+ print(f"[SoulSync] Pulled: {file_name} (v{cloud_version})")
115
111
  finally:
116
- self._unmark_syncing(file_path)
117
- elif local_version > cloud_version and local_content is not None:
118
- try:
119
- result = self.client.upload_profile(file_path, local_content, local_version)
120
- self.version_manager.set_version(file_path, result.get('version', local_version))
121
- pushed_count += 1
122
- print(f"[Sync] Pushed: {file_path} (v{local_version})")
123
- except ConflictError as e:
124
- self._handle_conflict(file_path, local_content, e.server_content, e.server_version)
125
- pushed_count += 1
126
- except Exception as e:
127
- print(f"[Sync] Error pushing {file_path}: {e}")
112
+ self._unmark_syncing(file_name)
128
113
  else:
129
114
  skipped_count += 1
130
115
 
131
- print(f"[Sync] Sync complete: {pulled_count} pulled, {pushed_count} pushed, {skipped_count} skipped")
116
+ self.version_manager.set_version('__profiles__', cloud_version)
117
+
118
+ print(f"[SoulSync] Sync complete: {pulled_count} pulled, {skipped_count} skipped")
132
119
 
133
120
  def push_file(self, file_path: str):
134
121
  """Push a file to cloud"""
@@ -140,64 +127,84 @@ class ProfileSync:
140
127
  try:
141
128
  local_content = self._read_local_file(file_path)
142
129
  if local_content is None:
143
- print(f"[Sync] File not found locally: {file_path}")
130
+ print(f"[SoulSync] File not found locally: {file_path}")
144
131
  return
145
132
 
146
133
  local_version = self.version_manager.get_version(file_path)
147
134
 
135
+ profiles_version = self.version_manager.get_version('__profiles__')
136
+
137
+ current_profiles = {}
148
138
  try:
149
- result = self.client.upload_profile(file_path, local_content, local_version)
150
- new_version = result.get('version', local_version + 1)
139
+ result = self.client.get_profiles()
140
+ current_profiles = result.get('content', {})
141
+ except Exception:
142
+ pass
143
+
144
+ current_profiles[file_path] = local_content
145
+
146
+ try:
147
+ result = self.client.upload_profiles(current_profiles, profiles_version)
148
+ new_version = result.get('version', profiles_version + 1)
149
+ self.version_manager.set_version('__profiles__', new_version)
151
150
  self.version_manager.set_version(file_path, new_version)
152
- print(f"[Sync] Pushed: {file_path} (v{new_version})")
151
+ print(f"[SoulSync] Pushed: {file_path} (v{new_version})")
153
152
  except ConflictError as e:
154
153
  self._handle_conflict(file_path, local_content, e.server_content, e.server_version)
155
154
  except Exception as e:
156
- print(f"[Sync] Error pushing {file_path}: {e}")
155
+ print(f"[SoulSync] Error pushing {file_path}: {e}")
157
156
  finally:
158
157
  self._unmark_syncing(file_path)
159
158
 
160
159
  def on_remote_change(self, file_path: str, version: int):
161
160
  """Handle remote file change"""
162
- local_version = self.version_manager.get_version(file_path)
161
+ local_version = self.version_manager.get_version('__profiles__')
163
162
 
164
163
  if version <= local_version:
165
- print(f"[Sync] Remote version not newer, skipping: {file_path} (local: v{local_version}, remote: v{version})")
164
+ print(f"[SoulSync] Remote version not newer, skipping: {file_path} (local: v{local_version}, remote: v{version})")
166
165
  return
167
166
 
168
167
  self._mark_syncing(file_path)
169
168
 
170
169
  try:
171
- result = self.client.get_profiles(file_path)
172
- files = result.get('files', [])
173
- if not files:
174
- print(f"[Sync] File not found on cloud: {file_path}")
170
+ result = self.client.get_profiles()
171
+ cloud_content = result.get('content', {})
172
+ cloud_version = result.get('version', 0)
173
+
174
+ if file_path not in cloud_content:
175
+ print(f"[SoulSync] File not found on cloud: {file_path}")
175
176
  return
176
177
 
177
- cloud_file = files[0]
178
- cloud_content = cloud_file.get('content', '')
178
+ cloud_file_content = cloud_content.get(file_path, '')
179
179
 
180
180
  local_content = self._read_local_file(file_path)
181
181
 
182
- if local_content is not None and local_content != cloud_content:
183
- self._create_conflict_backup(file_path, local_content, cloud_content)
182
+ if local_content is not None and local_content != cloud_file_content:
183
+ self._create_conflict_backup(file_path, local_content, cloud_file_content)
184
184
 
185
- self._write_local_file(file_path, cloud_content)
185
+ self._write_local_file(file_path, cloud_file_content)
186
+ self.version_manager.set_version('__profiles__', cloud_version)
186
187
  self.version_manager.set_version(file_path, version)
187
- print(f"[Sync] Pulled remote change: {file_path} (v{version})")
188
+ print(f"[SoulSync] Pulled remote change: {file_path} (v{version})")
188
189
  except Exception as e:
189
- print(f"[Sync] Error handling remote change for {file_path}: {e}")
190
+ print(f"[SoulSync] Error handling remote change for {file_path}: {e}")
190
191
  finally:
191
192
  self._unmark_syncing(file_path)
192
193
 
193
194
  def _handle_conflict(self, file_path: str, local_content: str, server_content: str, server_version: int):
194
195
  """Handle conflict when pushing file"""
195
- print(f"[Sync] CONFLICT detected for {file_path}")
196
+ print(f"[SoulSync] CONFLICT detected for {file_path}")
196
197
 
197
198
  self._create_conflict_backup(file_path, local_content, server_content)
198
199
 
199
- self._write_local_file(file_path, server_content)
200
+ if isinstance(server_content, dict):
201
+ server_file_content = server_content.get(file_path, '')
202
+ else:
203
+ server_file_content = server_content
204
+
205
+ self._write_local_file(file_path, server_file_content)
206
+ self.version_manager.set_version('__profiles__', server_version)
200
207
  self.version_manager.set_version(file_path, server_version)
201
208
 
202
- print(f"[Sync] Conflict resolved: server version used for {file_path}")
203
- print(f"[Sync] Please manually merge the conflict backup file")
209
+ print(f"[SoulSync] Conflict resolved: server version used for {file_path}")
210
+ print(f"[SoulSync] Please manually merge the conflict backup file")