soulsync 1.0.11 → 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.
@@ -1,18 +1,12 @@
1
1
  {
2
2
  "cloud_url": "https://soulsync.work",
3
3
  "email": "",
4
- "password": "",
4
+ "token": "",
5
5
  "workspace": "./workspace",
6
- "memory_file": "MEMORY.md",
7
6
  "watch_files": [
8
7
  "SOUL.md",
9
- "IDENTITY.md",
10
8
  "USER.md",
11
- "AGENTS.md",
12
- "TOOLS.md",
13
- "skills.json",
14
- "memory/",
15
9
  "MEMORY.md"
16
10
  ],
17
- "_comment": "Register at https://soulsync.work or use curl to call /api/auth/register endpoint"
11
+ "_comment": "Token is obtained after registration/login. Do not share your token."
18
12
  }
package/index.js CHANGED
@@ -1,13 +1,30 @@
1
1
  const { spawn } = require('child_process');
2
2
  const path = require('path');
3
3
  const fs = require('fs');
4
+ const https = require('https');
5
+ const http = require('http');
6
+ const crypto = require('crypto');
4
7
 
5
8
  let pythonProcess = null;
9
+ let config = null;
10
+ let deviceCodePolling = null;
6
11
 
7
12
  function getPluginDir() {
8
13
  return path.dirname(__filename);
9
14
  }
10
15
 
16
+ function loadConfig(pluginDir) {
17
+ const configPath = path.join(pluginDir, 'config.json');
18
+ try {
19
+ if (fs.existsSync(configPath)) {
20
+ return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
21
+ }
22
+ } catch (e) {
23
+ console.error('[SoulSync] Error loading config:', e);
24
+ }
25
+ return null;
26
+ }
27
+
11
28
  function checkConfigExists(pluginDir) {
12
29
  const configPath = path.join(pluginDir, 'config.json');
13
30
  if (!fs.existsSync(configPath)) {
@@ -19,16 +36,80 @@ function checkConfigExists(pluginDir) {
19
36
  return false;
20
37
  }
21
38
 
39
+ config = loadConfig(pluginDir);
40
+ if (!config) return false;
41
+
42
+ const email = (config.email || '').trim();
43
+ const token = config.token;
44
+ return email && token && email !== 'your-email@example.com';
45
+ }
46
+
47
+ function saveConfig(pluginDir, newConfig) {
48
+ const configPath = path.join(pluginDir, 'config.json');
22
49
  try {
23
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
24
- const email = (config.email || '').trim();
25
- const password = (config.password || '').trim();
26
- return email && password && email !== 'your-email@example.com' && password !== 'your-password';
50
+ let existing = {};
51
+ if (fs.existsSync(configPath)) {
52
+ existing = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
53
+ }
54
+ const merged = { ...existing, ...newConfig };
55
+ fs.writeFileSync(configPath, JSON.stringify(merged, null, 2));
56
+ config = merged;
57
+ return true;
27
58
  } catch (e) {
59
+ console.error('[SoulSync] Error saving config:', e);
28
60
  return false;
29
61
  }
30
62
  }
31
63
 
64
+ function getCloudUrl(pluginDir) {
65
+ const cfg = loadConfig(pluginDir);
66
+ return (cfg && cfg.cloud_url) || 'https://soulsync.work';
67
+ }
68
+
69
+ function makeRequest(method, urlPath, body = null, token = null) {
70
+ return new Promise((resolve, reject) => {
71
+ const cloudUrl = getCloudUrl(getPluginDir());
72
+ const isHttps = cloudUrl.startsWith('https');
73
+ const client = isHttps ? https : http;
74
+
75
+ const urlObj = new URL(cloudUrl + urlPath);
76
+
77
+ const options = {
78
+ hostname: urlObj.hostname,
79
+ port: urlObj.port || (isHttps ? 443 : 80),
80
+ path: urlObj.pathname + urlObj.search,
81
+ method: method,
82
+ headers: {
83
+ 'Content-Type': 'application/json'
84
+ }
85
+ };
86
+
87
+ if (token) {
88
+ options.headers['Authorization'] = `Bearer ${token}`;
89
+ }
90
+
91
+ const req = client.request(options, (res) => {
92
+ let data = '';
93
+ res.on('data', chunk => data += chunk);
94
+ res.on('end', () => {
95
+ try {
96
+ const json = JSON.parse(data);
97
+ resolve({ status: res.statusCode, body: json });
98
+ } catch (e) {
99
+ resolve({ status: res.statusCode, body: data });
100
+ }
101
+ });
102
+ });
103
+
104
+ req.on('error', reject);
105
+
106
+ if (body) {
107
+ req.write(JSON.stringify(body));
108
+ }
109
+ req.end();
110
+ });
111
+ }
112
+
32
113
  function startPythonService(mode = '--start') {
33
114
  const pluginDir = getPluginDir();
34
115
  const pythonScript = path.join(pluginDir, 'src', 'main.py');
@@ -59,21 +140,334 @@ function startPythonService(mode = '--start') {
59
140
  return pythonProcess;
60
141
  }
61
142
 
143
+ function stopPythonService() {
144
+ if (pythonProcess) {
145
+ console.log('[SoulSync] Stopping Python service...');
146
+ pythonProcess.kill();
147
+ pythonProcess = null;
148
+ }
149
+ }
150
+
151
+ function checkConnection() {
152
+ const pluginDir = getPluginDir();
153
+ const cfg = loadConfig(pluginDir);
154
+
155
+ if (!cfg || !cfg.token) {
156
+ return { connected: false, reason: 'not_configured' };
157
+ }
158
+
159
+ return makeRequest('GET', '/api/profiles', null, cfg.token)
160
+ .then(result => {
161
+ if (result.status === 200) {
162
+ return { connected: true, data: result.body };
163
+ } else if (result.status === 401) {
164
+ return { connected: false, reason: 'token_expired' };
165
+ } else {
166
+ return { connected: false, reason: 'server_error', status: result.status };
167
+ }
168
+ })
169
+ .catch(err => {
170
+ return { connected: false, reason: 'connection_error', error: err.message };
171
+ });
172
+ }
173
+
174
+ async function startDeviceCodeFlow() {
175
+ try {
176
+ const result = await makeRequest('POST', '/api/auth/device-code', {});
177
+
178
+ if (result.status !== 200 || !result.body.device_code) {
179
+ return {
180
+ success: false,
181
+ message: 'Failed to start device authorization. Please try again.'
182
+ };
183
+ }
184
+
185
+ const { device_code, auth_url, expires_in } = result.body;
186
+
187
+ deviceCodePolling = setInterval(async () => {
188
+ try {
189
+ const pollResult = await makeRequest('GET', `/api/auth/device-code/${device_code}/status`);
190
+
191
+ if (pollResult.body.status === 'authorized' && pollResult.body.token) {
192
+ clearInterval(deviceCodePolling);
193
+ deviceCodePolling = null;
194
+
195
+ const deviceId = crypto.randomUUID();
196
+ const pluginDir = getPluginDir();
197
+ saveConfig(pluginDir, {
198
+ email: 'device',
199
+ token: pollResult.body.token,
200
+ device_id: deviceId,
201
+ device_name: 'Device',
202
+ cloud_url: getCloudUrl(pluginDir)
203
+ });
204
+
205
+ makeRequest('POST', '/api/devices', {
206
+ device_id: deviceId,
207
+ device_name: 'Device',
208
+ device_type: 'local'
209
+ }, pollResult.body.token).catch(err => {
210
+ console.error('[SoulSync] Failed to register device:', err.message);
211
+ });
212
+
213
+ startPythonService('--start');
214
+
215
+ console.log('[SoulSync] Authorization successful! Syncing started.');
216
+ } else if (pollResult.body.status === 'expired') {
217
+ clearInterval(deviceCodePolling);
218
+ deviceCodePolling = null;
219
+ console.log('[SoulSync] Authorization expired. Please try again.');
220
+ } else if (pollResult.body.status === 'not_found') {
221
+ clearInterval(deviceCodePolling);
222
+ deviceCodePolling = null;
223
+ console.log('[SoulSync] Device code not found. Please try again.');
224
+ }
225
+ } catch (e) {
226
+ console.error('[SoulSync] Polling error:', e);
227
+ }
228
+ }, 3000);
229
+
230
+ setTimeout(() => {
231
+ if (deviceCodePolling) {
232
+ clearInterval(deviceCodePolling);
233
+ deviceCodePolling = null;
234
+ }
235
+ }, expires_in * 1000);
236
+
237
+ return {
238
+ success: true,
239
+ message: `Please open ${auth_url} in your browser to complete authorization. This page will expire in ${Math.floor(expires_in / 60)} minutes.`
240
+ };
241
+ } catch (err) {
242
+ return {
243
+ success: false,
244
+ message: `Error: ${err.message}`
245
+ };
246
+ }
247
+ }
248
+
62
249
  module.exports = function register(api) {
63
250
  console.log('[SoulSync] Registering plugin...');
64
251
 
65
- api.registerCli(
66
- ({ program }) => {
67
- program
68
- .command('soulsync:setup')
69
- .description('首次配置:注册或登录 SoulSync 账号')
70
- .action(() => {
71
- console.log('[SoulSync] Starting setup...');
72
- startPythonService('--setup');
73
- });
252
+ api.registerTool({
253
+ name: 'soulsync_status',
254
+ description: 'Check SoulSync plugin status for OpenClaw (小龙虾). Call when user mentions SoulSync, soul sync, 灵魂同步, OpenClaw plugin, 小龙虾插件, or asks about sync status.',
255
+ input_schema: {
256
+ type: 'object',
257
+ properties: {},
258
+ required: []
259
+ }
260
+ }, async () => {
261
+ const pluginDir = getPluginDir();
262
+ const cfg = loadConfig(pluginDir);
263
+
264
+ if (!cfg || !cfg.token) {
265
+ return {
266
+ configured: false,
267
+ message: 'SoulSync is not configured. Would you like to connect your SoulSync account?'
268
+ };
269
+ }
270
+
271
+ const conn = await checkConnection();
272
+
273
+ if (conn.connected) {
274
+ return {
275
+ configured: true,
276
+ connected: true,
277
+ email: cfg.email,
278
+ message: `SoulSync is connected and syncing for ${cfg.email}.`
279
+ };
280
+ } else if (conn.reason === 'token_expired') {
281
+ return {
282
+ configured: true,
283
+ connected: false,
284
+ message: 'SoulSync token has expired. Would you like to reconnect your account?'
285
+ };
286
+ } else {
287
+ return {
288
+ configured: true,
289
+ connected: false,
290
+ message: `SoulSync is configured but cannot connect to server: ${conn.reason}`
291
+ };
292
+ }
293
+ });
294
+
295
+ api.registerTool({
296
+ name: 'soulsync_sync',
297
+ description: 'Manually trigger SoulSync OpenClaw plugin (小龙虾插件) to sync soul files (SOUL.md, USER.md, MEMORY.md) between local and cloud.',
298
+ input_schema: {
299
+ type: 'object',
300
+ properties: {},
301
+ required: []
302
+ }
303
+ }, async () => {
304
+ const pluginDir = getPluginDir();
305
+ const cfg = loadConfig(pluginDir);
306
+
307
+ if (!cfg || !cfg.token) {
308
+ return {
309
+ success: false,
310
+ message: 'SoulSync is not configured. Would you like to connect your SoulSync account?'
311
+ };
312
+ }
313
+
314
+ const conn = await checkConnection();
315
+
316
+ if (!conn.connected) {
317
+ return {
318
+ success: false,
319
+ message: `Cannot sync: ${conn.reason}. Please check your connection.`
320
+ };
321
+ }
322
+
323
+ if (pythonProcess) {
324
+ return {
325
+ success: true,
326
+ message: 'Sync is already running in the background.'
327
+ };
328
+ }
329
+
330
+ startPythonService('--start');
331
+
332
+ return {
333
+ success: true,
334
+ message: 'Sync started. Your soul files are being synchronized.'
335
+ };
336
+ });
337
+
338
+ api.registerTool({
339
+ name: 'soulsync_connect',
340
+ description: 'Connect SoulSync account using device authorization flow. User will be given a URL to open in browser to complete login/registration. Call when user wants to register, login, connect, or setup SoulSync.',
341
+ input_schema: {
342
+ type: 'object',
343
+ properties: {},
344
+ required: []
345
+ }
346
+ }, async () => {
347
+ if (deviceCodePolling) {
348
+ return {
349
+ success: false,
350
+ message: 'Authorization in progress. Please check your browser and complete the authorization.'
351
+ };
352
+ }
353
+
354
+ return await startDeviceCodeFlow();
355
+ });
356
+
357
+ api.registerTool({
358
+ name: 'soulsync_logout',
359
+ description: 'Disconnect SoulSync account and stop sync service. This will remove local authentication token.',
360
+ input_schema: {
361
+ type: 'object',
362
+ properties: {},
363
+ required: []
364
+ }
365
+ }, async () => {
366
+ stopPythonService();
367
+
368
+ const pluginDir = getPluginDir();
369
+ const configPath = path.join(pluginDir, 'config.json');
370
+ const cfg = loadConfig(pluginDir);
371
+
372
+ if (cfg && cfg.device_id && cfg.token) {
373
+ try {
374
+ await makeRequest('DELETE', `/api/devices/${cfg.device_id}`, null, cfg.token);
375
+ } catch (e) {
376
+ console.error('[SoulSync] Failed to delete device:', e.message);
377
+ }
378
+ }
379
+
380
+ try {
381
+ if (fs.existsSync(configPath)) {
382
+ const existing = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
383
+ delete existing.token;
384
+ delete existing.email;
385
+ delete existing.device_id;
386
+ delete existing.device_name;
387
+ fs.writeFileSync(configPath, JSON.stringify(existing, null, 2));
388
+ }
389
+ } catch (e) {
390
+ console.error('[SoulSync] Error clearing config:', e);
391
+ }
392
+
393
+ if (deviceCodePolling) {
394
+ clearInterval(deviceCodePolling);
395
+ deviceCodePolling = null;
396
+ }
397
+
398
+ return {
399
+ success: true,
400
+ message: 'SoulSync disconnected. Your local data is preserved. To reconnect, say "connect SoulSync".'
401
+ };
402
+ });
403
+
404
+ api.registerTool({
405
+ name: 'soulsync_devices',
406
+ description: 'List all devices connected to your SoulSync account / 查看已连接设备列表',
407
+ schema: {
408
+ type: 'object',
409
+ properties: {},
410
+ required: []
74
411
  },
75
- { commands: ['soulsync:setup'] }
76
- );
412
+ async handler() {
413
+ const pluginDir = getPluginDir();
414
+ const cfg = loadConfig(pluginDir);
415
+
416
+ if (!cfg || !cfg.token) {
417
+ return 'Not logged in / 未登录';
418
+ }
419
+
420
+ try {
421
+ const result = await makeRequest('GET', '/api/devices', null, cfg.token);
422
+ if (result.status === 200) {
423
+ const devices = result.body.devices || [];
424
+ if (devices.length === 0) {
425
+ return 'No devices found / 未找到设备';
426
+ }
427
+ const list = devices.map(d =>
428
+ `- ${d.device_name || 'Unnamed'} (${d.device_type || 'local'}) - Last sync: ${d.last_sync_at || 'Never'}`
429
+ ).join('\n');
430
+ return `Connected devices / 已连接设备:\n${list}`;
431
+ }
432
+ return 'Failed to get devices / 获取设备列表失败';
433
+ } catch (e) {
434
+ return `Error: ${e.message}`;
435
+ }
436
+ }
437
+ });
438
+
439
+ api.registerTool({
440
+ name: 'soulsync_rename_device',
441
+ description: 'Rename the current device / 重命名当前设备',
442
+ schema: {
443
+ type: 'object',
444
+ properties: {
445
+ name: { type: 'string', description: 'New device name / 新设备名称' }
446
+ },
447
+ required: ['name']
448
+ },
449
+ async handler({ name }) {
450
+ const pluginDir = getPluginDir();
451
+ const cfg = loadConfig(pluginDir);
452
+
453
+ if (!cfg || !cfg.token || !cfg.device_id) {
454
+ return 'Not logged in / 未登录';
455
+ }
456
+
457
+ try {
458
+ const result = await makeRequest('PUT', `/api/devices/${cfg.device_id}`, { device_name: name }, cfg.token);
459
+ if (result.status === 200) {
460
+ const configPath = path.join(pluginDir, 'config.json');
461
+ cfg.device_name = name;
462
+ fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2));
463
+ return `Device renamed to "${name}" / 设备已重命名为 "${name}"`;
464
+ }
465
+ return 'Failed to rename device / 重命名设备失败';
466
+ } catch (e) {
467
+ return `Error: ${e.message}`;
468
+ }
469
+ }
470
+ });
77
471
 
78
472
  api.registerCli(
79
473
  ({ program }) => {
@@ -82,11 +476,12 @@ module.exports = function register(api) {
82
476
  .description('启动 SoulSync 同步服务')
83
477
  .action(() => {
84
478
  const pluginDir = getPluginDir();
85
- const configPath = path.join(pluginDir, 'config.json');
86
479
 
87
480
  if (!checkConfigExists(pluginDir)) {
88
- console.log('[SoulSync] Not configured. Starting setup...');
89
- startPythonService('--setup');
481
+ console.log('[SoulSync] Not configured. Starting device authorization flow...');
482
+ startDeviceCodeFlow().then(result => {
483
+ console.log('[SoulSync]', result.message);
484
+ });
90
485
  return;
91
486
  }
92
487
 
@@ -107,18 +502,19 @@ module.exports = function register(api) {
107
502
  .command('soulsync:stop')
108
503
  .description('停止 SoulSync 同步服务')
109
504
  .action(() => {
110
- if (pythonProcess) {
111
- console.log('[SoulSync] Stopping Python service...');
112
- pythonProcess.kill();
113
- pythonProcess = null;
114
- } else {
115
- console.log('[SoulSync] Service not running');
116
- }
505
+ stopPythonService();
506
+ console.log('[SoulSync] Service stopped');
117
507
  });
118
508
  },
119
509
  { commands: ['soulsync:stop'] }
120
510
  );
121
511
 
512
+ const pluginDir = getPluginDir();
513
+ if (checkConfigExists(pluginDir)) {
514
+ console.log('[SoulSync] Auto-starting sync service...');
515
+ startPythonService('--start');
516
+ }
517
+
122
518
  console.log('[SoulSync] Plugin loaded. Run "openclaw soulsync:start" to begin.');
123
519
  console.log('[SoulSync] Plugin registered successfully');
124
520
  };
@@ -7,12 +7,12 @@
7
7
  "properties": {
8
8
  "cloud_url": { "type": "string" },
9
9
  "email": { "type": "string" },
10
- "password": { "type": "string" }
10
+ "token": { "type": "string" }
11
11
  }
12
12
  },
13
13
  "uiHints": {
14
- "cloud_url": { "label": "服务器地址", "placeholder": "http://localhost:3000" },
15
- "email": { "label": "邮箱" },
16
- "password": { "label": "密码", "sensitive": true }
14
+ "cloud_url": { "label": "服务器地址 / Server URL", "placeholder": "https://soulsync.work" },
15
+ "email": { "label": "邮箱 / Email" },
16
+ "token": { "label": "Token", "sensitive": true }
17
17
  }
18
18
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "soulsync",
3
- "version": "1.0.11",
3
+ "version": "1.0.13",
4
4
  "description": "SoulSync plugin for OpenClaw - cross-bot memory synchronization",
5
5
  "main": "index.js",
6
6
  "repository": {
package/src/client.py CHANGED
@@ -37,7 +37,9 @@ class OpenClawClient:
37
37
  self.session.mount('https://', TLSAdapter())
38
38
 
39
39
  def _load_or_generate_device_id(self) -> str:
40
- """加载或生成设备ID"""
40
+ if self.config.get('device_id'):
41
+ return self.config['device_id']
42
+
41
43
  plugin_dir = os.path.dirname(os.path.dirname(__file__))
42
44
  device_id_file = os.path.join(plugin_dir, 'device_id')
43
45
 
@@ -48,8 +50,6 @@ class OpenClawClient:
48
50
  new_device_id = str(uuid.uuid4())
49
51
  with open(device_id_file, 'w') as f:
50
52
  f.write(new_device_id)
51
-
52
- print(f"Generated new device_id: {new_device_id}")
53
53
  return new_device_id
54
54
 
55
55
  def _save_token(self, token: str):
@@ -69,13 +69,17 @@ class OpenClawClient:
69
69
  with open(token_file, 'r') as f:
70
70
  return f.read().strip()
71
71
 
72
+ if self.config and self.config.get('token'):
73
+ return self.config.get('token')
74
+
72
75
  return None
73
76
 
74
77
  def _get_headers(self) -> dict:
75
- """获取请求头"""
76
78
  headers = {'Content-Type': 'application/json'}
77
79
  if self.token:
78
80
  headers['Authorization'] = f'Bearer {self.token}'
81
+ if self.device_id:
82
+ headers['X-Device-ID'] = self.device_id
79
83
  return headers
80
84
 
81
85
  def authenticate(self, email: str = None, password: str = None) -> dict:
@@ -134,54 +138,13 @@ class OpenClawClient:
134
138
  error = response.json().get('error', 'Unknown error')
135
139
  raise Exception(f"Authentication failed: {error}")
136
140
 
137
- def upload_memory(self, content: str) -> dict:
138
- """上传记忆
139
-
140
- Args:
141
- content: 记忆内容
142
-
143
- Returns:
144
- 上传结果
145
- """
146
- url = f"{self.cloud_url}/api/memories"
147
- data = {'content': content}
148
-
149
- response = self.session.post(url, json=data, headers=self._get_headers())
150
-
151
- if response.status_code == 200:
152
- return response.json()
153
- elif response.status_code == 403:
154
- error = response.json().get('error', 'Subscription required')
155
- raise Exception(f"Upload failed: {error}")
156
- else:
157
- error = response.json().get('error', 'Unknown error')
158
- raise Exception(f"Upload failed: {error}")
159
-
160
- def download_memory(self) -> dict:
161
- """下载记忆
162
-
163
- Returns:
164
- 包含 content, version 等信息的字典
165
- """
166
- url = f"{self.cloud_url}/api/memories"
167
-
168
- response = self.session.get(url, headers=self._get_headers())
169
-
170
- if response.status_code == 200:
171
- return response.json()
172
- elif response.status_code == 404:
173
- return {'content': '', 'version': 0}
174
- else:
175
- error = response.json().get('error', 'Unknown error')
176
- raise Exception(f"Download failed: {error}")
177
-
178
141
  def get_profile(self) -> dict:
179
142
  """获取用户信息
180
143
 
181
144
  Returns:
182
145
  用户信息
183
146
  """
184
- url = f"{self.cloud_url}/api/memories/profile"
147
+ url = f"{self.cloud_url}/api/profiles"
185
148
 
186
149
  response = self.session.get(url, headers=self._get_headers())
187
150
 
@@ -286,6 +249,8 @@ class OpenClawClient:
286
249
  self.token = result.get('token')
287
250
  self.user_id = result.get('user_id')
288
251
  self._save_token(self.token)
252
+ if self.config:
253
+ self.config['token'] = self.token
289
254
  return result
290
255
  else:
291
256
  error = response.json().get('error', 'Unknown error')
@@ -311,6 +276,8 @@ class OpenClawClient:
311
276
  self.token = result.get('token')
312
277
  self.user_id = result.get('user_id')
313
278
  self._save_token(self.token)
279
+ if self.config:
280
+ self.config['token'] = self.token
314
281
  return result
315
282
  else:
316
283
  error = response.json().get('error', 'Unknown error')