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.
@@ -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');
@@ -49,12 +130,6 @@ function startPythonService(mode = '--start') {
49
130
  pythonProcess.on('close', (code) => {
50
131
  console.log(`[SoulSync] Python process exited with code ${code}`);
51
132
  pythonProcess = null;
52
-
53
- // If setup succeeded (code 0), auto-start sync
54
- if (mode === '--setup' && code === 0) {
55
- console.log('[SoulSync] Setup completed, starting sync service...');
56
- startPythonService('--start');
57
- }
58
133
  });
59
134
 
60
135
  pythonProcess.on('error', (err) => {
@@ -65,21 +140,334 @@ function startPythonService(mode = '--start') {
65
140
  return pythonProcess;
66
141
  }
67
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
+
68
249
  module.exports = function register(api) {
69
250
  console.log('[SoulSync] Registering plugin...');
70
251
 
71
- api.registerCli(
72
- ({ program }) => {
73
- program
74
- .command('soulsync:setup')
75
- .description('首次配置:注册或登录 SoulSync 账号')
76
- .action(() => {
77
- console.log('[SoulSync] Starting setup...');
78
- startPythonService('--setup');
79
- });
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: []
80
411
  },
81
- { commands: ['soulsync:setup'] }
82
- );
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
+ });
83
471
 
84
472
  api.registerCli(
85
473
  ({ program }) => {
@@ -88,11 +476,12 @@ module.exports = function register(api) {
88
476
  .description('启动 SoulSync 同步服务')
89
477
  .action(() => {
90
478
  const pluginDir = getPluginDir();
91
- const configPath = path.join(pluginDir, 'config.json');
92
479
 
93
480
  if (!checkConfigExists(pluginDir)) {
94
- console.log('[SoulSync] Not configured. Starting setup...');
95
- startPythonService('--setup');
481
+ console.log('[SoulSync] Not configured. Starting device authorization flow...');
482
+ startDeviceCodeFlow().then(result => {
483
+ console.log('[SoulSync]', result.message);
484
+ });
96
485
  return;
97
486
  }
98
487
 
@@ -113,18 +502,19 @@ module.exports = function register(api) {
113
502
  .command('soulsync:stop')
114
503
  .description('停止 SoulSync 同步服务')
115
504
  .action(() => {
116
- if (pythonProcess) {
117
- console.log('[SoulSync] Stopping Python service...');
118
- pythonProcess.kill();
119
- pythonProcess = null;
120
- } else {
121
- console.log('[SoulSync] Service not running');
122
- }
505
+ stopPythonService();
506
+ console.log('[SoulSync] Service stopped');
123
507
  });
124
508
  },
125
509
  { commands: ['soulsync:stop'] }
126
510
  );
127
511
 
512
+ const pluginDir = getPluginDir();
513
+ if (checkConfigExists(pluginDir)) {
514
+ console.log('[SoulSync] Auto-starting sync service...');
515
+ startPythonService('--start');
516
+ }
517
+
128
518
  console.log('[SoulSync] Plugin loaded. Run "openclaw soulsync:start" to begin.');
129
519
  console.log('[SoulSync] Plugin registered successfully');
130
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.12",
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
 
@@ -67,13 +69,17 @@ class OpenClawClient:
67
69
  with open(token_file, 'r') as f:
68
70
  return f.read().strip()
69
71
 
72
+ if self.config and self.config.get('token'):
73
+ return self.config.get('token')
74
+
70
75
  return None
71
76
 
72
77
  def _get_headers(self) -> dict:
73
- """获取请求头"""
74
78
  headers = {'Content-Type': 'application/json'}
75
79
  if self.token:
76
80
  headers['Authorization'] = f'Bearer {self.token}'
81
+ if self.device_id:
82
+ headers['X-Device-ID'] = self.device_id
77
83
  return headers
78
84
 
79
85
  def authenticate(self, email: str = None, password: str = None) -> dict:
@@ -132,54 +138,13 @@ class OpenClawClient:
132
138
  error = response.json().get('error', 'Unknown error')
133
139
  raise Exception(f"Authentication failed: {error}")
134
140
 
135
- def upload_memory(self, content: str) -> dict:
136
- """上传记忆
137
-
138
- Args:
139
- content: 记忆内容
140
-
141
- Returns:
142
- 上传结果
143
- """
144
- url = f"{self.cloud_url}/api/memories"
145
- data = {'content': content}
146
-
147
- response = self.session.post(url, json=data, headers=self._get_headers())
148
-
149
- if response.status_code == 200:
150
- return response.json()
151
- elif response.status_code == 403:
152
- error = response.json().get('error', 'Subscription required')
153
- raise Exception(f"Upload failed: {error}")
154
- else:
155
- error = response.json().get('error', 'Unknown error')
156
- raise Exception(f"Upload failed: {error}")
157
-
158
- def download_memory(self) -> dict:
159
- """下载记忆
160
-
161
- Returns:
162
- 包含 content, version 等信息的字典
163
- """
164
- url = f"{self.cloud_url}/api/memories"
165
-
166
- response = self.session.get(url, headers=self._get_headers())
167
-
168
- if response.status_code == 200:
169
- return response.json()
170
- elif response.status_code == 404:
171
- return {'content': '', 'version': 0}
172
- else:
173
- error = response.json().get('error', 'Unknown error')
174
- raise Exception(f"Download failed: {error}")
175
-
176
141
  def get_profile(self) -> dict:
177
142
  """获取用户信息
178
143
 
179
144
  Returns:
180
145
  用户信息
181
146
  """
182
- url = f"{self.cloud_url}/api/memories/profile"
147
+ url = f"{self.cloud_url}/api/profiles"
183
148
 
184
149
  response = self.session.get(url, headers=self._get_headers())
185
150
 
@@ -284,6 +249,8 @@ class OpenClawClient:
284
249
  self.token = result.get('token')
285
250
  self.user_id = result.get('user_id')
286
251
  self._save_token(self.token)
252
+ if self.config:
253
+ self.config['token'] = self.token
287
254
  return result
288
255
  else:
289
256
  error = response.json().get('error', 'Unknown error')
@@ -309,6 +276,8 @@ class OpenClawClient:
309
276
  self.token = result.get('token')
310
277
  self.user_id = result.get('user_id')
311
278
  self._save_token(self.token)
279
+ if self.config:
280
+ self.config['token'] = self.token
312
281
  return result
313
282
  else:
314
283
  error = response.json().get('error', 'Unknown error')