wispy-cli 1.2.3 → 1.4.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.
@@ -0,0 +1,357 @@
1
+ /**
2
+ * core/migrate.mjs — Migration tool for importing from OpenClaw
3
+ *
4
+ * Helps users switch from OpenClaw to Wispy by importing:
5
+ * - Memories (MEMORY.md → ~/.wispy/memory/)
6
+ * - Workspace files (SOUL.md, USER.md, IDENTITY.md → user-model)
7
+ * - Cron jobs (openclaw cron format → wispy cron format)
8
+ * - Channel configs (telegram token, etc.)
9
+ *
10
+ * SAFE: Read-only from OpenClaw. Copies to Wispy, never modifies OpenClaw.
11
+ */
12
+
13
+ import { readFile, writeFile, mkdir, readdir, access, stat } from "node:fs/promises";
14
+ import { join, basename } from "node:path";
15
+ import { homedir } from "node:os";
16
+
17
+ const OPENCLAW_DIR = join(homedir(), ".openclaw");
18
+ const OPENCLAW_WORKSPACE = join(OPENCLAW_DIR, "workspace");
19
+
20
+ export class OpenClawMigrator {
21
+ constructor(wispyDir) {
22
+ this.wispyDir = wispyDir;
23
+ this.report = {
24
+ detected: false,
25
+ memories: [],
26
+ userModel: [],
27
+ cronJobs: [],
28
+ channels: [],
29
+ skipped: [],
30
+ errors: [],
31
+ };
32
+ }
33
+
34
+ // ── Detection ─────────────────────────────────────────────────────────────────
35
+
36
+ async detect() {
37
+ try {
38
+ await access(OPENCLAW_DIR);
39
+ this.report.detected = true;
40
+ return true;
41
+ } catch {
42
+ this.report.detected = false;
43
+ return false;
44
+ }
45
+ }
46
+
47
+ // ── Main migration entry ──────────────────────────────────────────────────────
48
+
49
+ /**
50
+ * Run full migration.
51
+ * @param {object} opts
52
+ * @param {boolean} opts.dryRun — if true, only report what would be migrated
53
+ * @param {boolean} opts.memoryOnly — if true, only migrate memories
54
+ */
55
+ async migrate(opts = {}) {
56
+ const { dryRun = false, memoryOnly = false } = opts;
57
+
58
+ const detected = await this.detect();
59
+ if (!detected) {
60
+ return {
61
+ success: false,
62
+ error: `OpenClaw not found at ${OPENCLAW_DIR}`,
63
+ report: this.report,
64
+ };
65
+ }
66
+
67
+ // Migrate memories
68
+ await this._migrateMemories(dryRun);
69
+
70
+ if (!memoryOnly) {
71
+ await this._migrateWorkspaceFiles(dryRun);
72
+ await this._migrateCronJobs(dryRun);
73
+ await this._migrateChannels(dryRun);
74
+ }
75
+
76
+ return {
77
+ success: true,
78
+ dryRun,
79
+ report: this.report,
80
+ };
81
+ }
82
+
83
+ // ── Memory migration ──────────────────────────────────────────────────────────
84
+
85
+ async _migrateMemories(dryRun) {
86
+ const memoryDir = join(this.wispyDir, "memory");
87
+
88
+ // Check for MEMORY.md in workspace root
89
+ const memoryMdPath = join(OPENCLAW_WORKSPACE, "MEMORY.md");
90
+ try {
91
+ const content = await readFile(memoryMdPath, "utf8");
92
+ if (content.trim()) {
93
+ const dest = join(memoryDir, "openclaw-import.md");
94
+ if (!dryRun) {
95
+ await mkdir(memoryDir, { recursive: true });
96
+ await writeFile(dest, `# Imported from OpenClaw MEMORY.md\n\n${content}`, "utf8");
97
+ }
98
+ this.report.memories.push({ source: memoryMdPath, dest, action: "copy" });
99
+ }
100
+ } catch { /* No MEMORY.md — skip */ }
101
+
102
+ // Check for memory/ subdirectory
103
+ const openclawMemoryDir = join(OPENCLAW_WORKSPACE, "memory");
104
+ try {
105
+ const files = await readdir(openclawMemoryDir);
106
+ for (const file of files) {
107
+ if (!file.endsWith(".md") && !file.endsWith(".json")) continue;
108
+ const src = join(openclawMemoryDir, file);
109
+ const dest = join(memoryDir, `openclaw-${file}`);
110
+ try {
111
+ const content = await readFile(src, "utf8");
112
+ if (content.trim()) {
113
+ if (!dryRun) {
114
+ await mkdir(memoryDir, { recursive: true });
115
+ await writeFile(dest, content, "utf8");
116
+ }
117
+ this.report.memories.push({ source: src, dest, action: "copy" });
118
+ }
119
+ } catch (err) {
120
+ this.report.errors.push(`Memory file ${file}: ${err.message}`);
121
+ }
122
+ }
123
+ } catch { /* No memory directory */ }
124
+ }
125
+
126
+ // ── Workspace files → user model ──────────────────────────────────────────────
127
+
128
+ async _migrateWorkspaceFiles(dryRun) {
129
+ const filesToProcess = [
130
+ { name: "USER.md", type: "user" },
131
+ { name: "IDENTITY.md", type: "identity" },
132
+ { name: "SOUL.md", type: "soul" },
133
+ ];
134
+
135
+ for (const { name, type } of filesToProcess) {
136
+ const src = join(OPENCLAW_WORKSPACE, name);
137
+ try {
138
+ const content = await readFile(src, "utf8");
139
+ if (!content.trim()) continue;
140
+
141
+ // Extract useful info and save to wispy memory
142
+ const dest = join(this.wispyDir, "memory", `openclaw-${name.toLowerCase()}`);
143
+ if (!dryRun) {
144
+ await mkdir(join(this.wispyDir, "memory"), { recursive: true });
145
+ await writeFile(dest, `# Imported from OpenClaw ${name}\n\n${content}`, "utf8");
146
+ }
147
+ this.report.userModel.push({ source: src, dest, type, action: "copy" });
148
+
149
+ // Parse USER.md for structured data
150
+ if (type === "user" && !dryRun) {
151
+ await this._importUserMd(content);
152
+ }
153
+ } catch { /* File doesn't exist, skip */ }
154
+ }
155
+ }
156
+
157
+ async _importUserMd(content) {
158
+ // Extract structured fields from USER.md
159
+ const userModelPath = join(this.wispyDir, "user-model.json");
160
+
161
+ let model = {};
162
+ try {
163
+ model = JSON.parse(await readFile(userModelPath, "utf8"));
164
+ } catch {
165
+ model = { basics: {}, preferences: {}, patterns: { peakHours: [], avgSessionLength: 0, commonTasks: [], totalSessions: 0, totalMessages: 0 } };
166
+ }
167
+
168
+ // Simple regex extraction
169
+ const nameMatch = content.match(/\*\*Name:\*\*\s*(.+)/);
170
+ if (nameMatch && !model.basics?.name) {
171
+ const name = nameMatch[1].replace(/\(.+\)/, "").trim();
172
+ model.basics = model.basics ?? {};
173
+ model.basics.name = name;
174
+ }
175
+
176
+ const tzMatch = content.match(/\*\*Timezone:\*\*\s*(.+)/);
177
+ if (tzMatch) {
178
+ model.basics = model.basics ?? {};
179
+ model.basics.timezone = tzMatch[1].trim();
180
+ }
181
+
182
+ // Korean name usually indicates Korean language preference
183
+ if (content.includes("민서") || content.includes("Korean") || content.toLowerCase().includes("asia/seoul")) {
184
+ model.preferences = model.preferences ?? {};
185
+ if (!model.preferences.communicationLanguage) {
186
+ model.preferences.communicationLanguage = "Korean";
187
+ model.basics.language = "ko";
188
+ }
189
+ }
190
+
191
+ // OS hints
192
+ if (content.includes("MacBook") || content.includes("macOS")) {
193
+ model.basics = model.basics ?? {};
194
+ model.basics.os = "macOS";
195
+ }
196
+
197
+ model.updatedAt = new Date().toISOString();
198
+ await mkdir(this.wispyDir, { recursive: true });
199
+ await writeFile(userModelPath, JSON.stringify(model, null, 2) + "\n", "utf8");
200
+ }
201
+
202
+ // ── Cron job migration ───────────────────────────────────────────────────────
203
+
204
+ async _migrateCronJobs(dryRun) {
205
+ // OpenClaw cron config locations
206
+ const candidates = [
207
+ join(OPENCLAW_DIR, "cron.json"),
208
+ join(OPENCLAW_DIR, "config", "cron.json"),
209
+ join(OPENCLAW_WORKSPACE, "cron.json"),
210
+ ];
211
+
212
+ for (const src of candidates) {
213
+ try {
214
+ const raw = await readFile(src, "utf8");
215
+ const openclawCron = JSON.parse(raw);
216
+
217
+ // Convert OpenClaw cron format to Wispy cron format
218
+ const wispyCronPath = join(this.wispyDir, "cron.json");
219
+ let wispyCron = [];
220
+ try {
221
+ wispyCron = JSON.parse(await readFile(wispyCronPath, "utf8"));
222
+ } catch { /* empty */ }
223
+
224
+ const converted = this._convertCronFormat(openclawCron);
225
+ const toAdd = converted.filter(j =>
226
+ !wispyCron.some(existing => existing.name === j.name)
227
+ );
228
+
229
+ if (toAdd.length > 0) {
230
+ if (!dryRun) {
231
+ await mkdir(this.wispyDir, { recursive: true });
232
+ await writeFile(wispyCronPath, JSON.stringify([...wispyCron, ...toAdd], null, 2) + "\n", "utf8");
233
+ }
234
+ for (const job of toAdd) {
235
+ this.report.cronJobs.push({ source: src, job: job.name, action: "import" });
236
+ }
237
+ }
238
+ break; // Only process first found
239
+ } catch { /* Not found or parse error */ }
240
+ }
241
+ }
242
+
243
+ _convertCronFormat(openclawCron) {
244
+ // OpenClaw cron is an array of job objects
245
+ const jobs = Array.isArray(openclawCron) ? openclawCron : (openclawCron.jobs ?? []);
246
+ return jobs.map(job => ({
247
+ name: job.name ?? job.id ?? `imported-${Date.now()}`,
248
+ schedule: job.schedule ?? job.cron ?? "0 9 * * *",
249
+ prompt: job.prompt ?? job.task ?? job.message ?? "Run imported cron job",
250
+ enabled: job.enabled !== false,
251
+ importedFrom: "openclaw",
252
+ }));
253
+ }
254
+
255
+ // ── Channel config migration ──────────────────────────────────────────────────
256
+
257
+ async _migrateChannels(dryRun) {
258
+ // Look for OpenClaw channel configs
259
+ const configCandidates = [
260
+ join(OPENCLAW_DIR, "openclaw.json"),
261
+ join(OPENCLAW_DIR, "config.json"),
262
+ join(OPENCLAW_DIR, "config", "openclaw.json"),
263
+ ];
264
+
265
+ for (const src of configCandidates) {
266
+ try {
267
+ const raw = await readFile(src, "utf8");
268
+ const config = JSON.parse(raw);
269
+
270
+ const channelsPath = join(this.wispyDir, "channels.json");
271
+ let wispyChannels = {};
272
+ try {
273
+ wispyChannels = JSON.parse(await readFile(channelsPath, "utf8"));
274
+ } catch { /* empty */ }
275
+
276
+ // Extract Telegram config
277
+ const telegramToken = config.telegram?.token
278
+ ?? config.channels?.telegram?.token
279
+ ?? config.plugins?.telegram?.token;
280
+
281
+ if (telegramToken && !wispyChannels.telegram) {
282
+ wispyChannels.telegram = { token: telegramToken };
283
+ this.report.channels.push({ channel: "telegram", action: "import" });
284
+ }
285
+
286
+ // Extract Discord config
287
+ const discordToken = config.discord?.token
288
+ ?? config.channels?.discord?.token;
289
+
290
+ if (discordToken && !wispyChannels.discord) {
291
+ wispyChannels.discord = { token: discordToken };
292
+ this.report.channels.push({ channel: "discord", action: "import" });
293
+ }
294
+
295
+ if (this.report.channels.length > 0 && !dryRun) {
296
+ await mkdir(this.wispyDir, { recursive: true });
297
+ await writeFile(channelsPath, JSON.stringify(wispyChannels, null, 2) + "\n", "utf8");
298
+ }
299
+
300
+ break; // Only process first found
301
+ } catch { /* Not found */ }
302
+ }
303
+ }
304
+
305
+ // ── Report formatting ─────────────────────────────────────────────────────────
306
+
307
+ formatReport() {
308
+ const r = this.report;
309
+ const lines = [];
310
+
311
+ if (!r.detected) {
312
+ return `❌ OpenClaw not found at ${OPENCLAW_DIR}`;
313
+ }
314
+
315
+ lines.push(`✅ OpenClaw detected at ${OPENCLAW_DIR}`);
316
+ lines.push("");
317
+
318
+ if (r.memories.length > 0) {
319
+ lines.push(`📝 Memories (${r.memories.length}):`);
320
+ for (const m of r.memories) {
321
+ lines.push(` ${m.action}: ${basename(m.source)} → ${basename(m.dest)}`);
322
+ }
323
+ } else {
324
+ lines.push("📝 Memories: none found");
325
+ }
326
+
327
+ if (r.userModel.length > 0) {
328
+ lines.push(`👤 User profile files (${r.userModel.length}):`);
329
+ for (const u of r.userModel) {
330
+ lines.push(` ${u.action}: ${u.source.replace(homedir(), "~")}`);
331
+ }
332
+ }
333
+
334
+ if (r.cronJobs.length > 0) {
335
+ lines.push(`⏰ Cron jobs (${r.cronJobs.length}):`);
336
+ for (const j of r.cronJobs) {
337
+ lines.push(` import: ${j.job}`);
338
+ }
339
+ }
340
+
341
+ if (r.channels.length > 0) {
342
+ lines.push(`📡 Channels (${r.channels.length}):`);
343
+ for (const c of r.channels) {
344
+ lines.push(` import: ${c.channel}`);
345
+ }
346
+ }
347
+
348
+ if (r.errors.length > 0) {
349
+ lines.push(`⚠️ Errors (${r.errors.length}):`);
350
+ for (const e of r.errors) {
351
+ lines.push(` ${e}`);
352
+ }
353
+ }
354
+
355
+ return lines.join("\n");
356
+ }
357
+ }
package/core/server.mjs CHANGED
@@ -7,11 +7,71 @@
7
7
 
8
8
  import { createServer } from "node:http";
9
9
  import path from "node:path";
10
- import { readFile, writeFile, mkdir } from "node:fs/promises";
11
- import { randomBytes, createHmac } from "node:crypto";
10
+ import { readFile, writeFile, mkdir, readdir, stat } from "node:fs/promises";
11
+ import { randomBytes, createHmac, createHash } from "node:crypto";
12
12
 
13
13
  import { WISPY_DIR } from "./config.mjs";
14
14
 
15
+ // ── Sync helpers ──────────────────────────────────────────────────────────────
16
+
17
+ const SYNC_PATTERNS = [
18
+ (f) => f.startsWith("memory/") && f.endsWith(".md"),
19
+ (f) => f.startsWith("sessions/") && f.endsWith(".json"),
20
+ (f) => f === "cron/jobs.json",
21
+ (f) => /^workstreams\/[^/]+\/work\.md$/.test(f),
22
+ (f) => f === "permissions.json",
23
+ ];
24
+
25
+ function isSyncable(relPath) {
26
+ return SYNC_PATTERNS.some(fn => fn(relPath));
27
+ }
28
+
29
+ function sha256(content) {
30
+ return createHash("sha256").update(content).digest("hex");
31
+ }
32
+
33
+ function isPathSafe(relPath) {
34
+ // Reject absolute paths and path traversal
35
+ if (path.isAbsolute(relPath)) return false;
36
+ if (relPath.includes("..")) return false;
37
+ if (relPath.includes("\0")) return false;
38
+ return true;
39
+ }
40
+
41
+ async function scanSyncableFiles(baseDir) {
42
+ const entries = [];
43
+ await _scanDir(baseDir, baseDir, entries);
44
+ return entries;
45
+ }
46
+
47
+ async function _scanDir(baseDir, dir, entries) {
48
+ let items;
49
+ try {
50
+ items = await readdir(dir, { withFileTypes: true });
51
+ } catch {
52
+ return;
53
+ }
54
+ for (const item of items) {
55
+ const fullPath = path.join(dir, item.name);
56
+ const relPath = path.relative(baseDir, fullPath).replace(/\\/g, "/");
57
+ if (item.isDirectory()) {
58
+ if (["node_modules", ".git", ".npm"].includes(item.name)) continue;
59
+ await _scanDir(baseDir, fullPath, entries);
60
+ } else if (item.isFile() && isSyncable(relPath)) {
61
+ try {
62
+ const content = await readFile(fullPath);
63
+ const fileStat = await stat(fullPath);
64
+ entries.push({
65
+ path: relPath,
66
+ hash: sha256(content),
67
+ modifiedAt: fileStat.mtime.toISOString(),
68
+ size: content.length,
69
+ });
70
+ } catch { /* skip */ }
71
+ }
72
+ }
73
+ }
74
+
15
75
  const SERVER_CONFIG_FILE = path.join(WISPY_DIR, "server.json");
16
76
  const DEFAULT_PORT = 18790;
17
77
  const DEFAULT_HOST = "127.0.0.1";
@@ -335,6 +395,96 @@ export class WispyServer {
335
395
  }
336
396
  }
337
397
 
398
+ // ── Sync ────────────────────────────────────────────────────────────────
399
+
400
+ // GET /api/sync/manifest — list all syncable files with hash + mtime
401
+ if (pathname === "/api/sync/manifest" && method === "GET") {
402
+ const files = await scanSyncableFiles(WISPY_DIR);
403
+ return jsonResponse(res, 200, { files });
404
+ }
405
+
406
+ // GET /api/sync/file?path=<relPath> — download a file
407
+ if (pathname === "/api/sync/file" && method === "GET") {
408
+ const relPath = url.searchParams.get("path");
409
+ if (!relPath) return errorResponse(res, 400, "path required");
410
+ if (!isPathSafe(relPath)) return errorResponse(res, 400, "invalid path");
411
+ if (!isSyncable(relPath)) return errorResponse(res, 400, "path not syncable");
412
+
413
+ const fullPath = path.join(WISPY_DIR, relPath);
414
+ try {
415
+ const content = await readFile(fullPath);
416
+ const fileStat = await stat(fullPath);
417
+ return jsonResponse(res, 200, {
418
+ path: relPath,
419
+ content: content.toString("base64"),
420
+ encoding: "base64",
421
+ modifiedAt: fileStat.mtime.toISOString(),
422
+ hash: sha256(content),
423
+ size: content.length,
424
+ });
425
+ } catch {
426
+ return errorResponse(res, 404, "File not found");
427
+ }
428
+ }
429
+
430
+ // POST /api/sync/file — upload a file
431
+ if (pathname === "/api/sync/file" && method === "POST") {
432
+ const body = await readBody(req);
433
+ const { path: relPath, content, encoding = "base64", modifiedAt } = body;
434
+ if (!relPath || content == null) return errorResponse(res, 400, "path and content required");
435
+ if (!isPathSafe(relPath)) return errorResponse(res, 400, "invalid path");
436
+ if (!isSyncable(relPath)) return errorResponse(res, 400, "path not syncable");
437
+
438
+ const fullPath = path.join(WISPY_DIR, relPath);
439
+ await mkdir(path.dirname(fullPath), { recursive: true });
440
+ const buf = Buffer.from(content, encoding === "base64" ? "base64" : "utf8");
441
+ await writeFile(fullPath, buf);
442
+ if (modifiedAt) {
443
+ const mtime = new Date(modifiedAt);
444
+ const { utimes } = await import("node:fs/promises");
445
+ await utimes(fullPath, mtime, mtime).catch(() => {});
446
+ }
447
+ return jsonResponse(res, 200, { success: true, path: relPath, size: buf.length });
448
+ }
449
+
450
+ // POST /api/sync/bulk — upload multiple files at once
451
+ if (pathname === "/api/sync/bulk" && method === "POST") {
452
+ const body = await readBody(req);
453
+ const files = body.files ?? [];
454
+ if (!Array.isArray(files)) return errorResponse(res, 400, "files must be an array");
455
+
456
+ const results = [];
457
+ for (const file of files) {
458
+ const { path: relPath, content, encoding = "base64", modifiedAt } = file;
459
+ if (!relPath || content == null) {
460
+ results.push({ path: relPath, success: false, error: "missing path or content" });
461
+ continue;
462
+ }
463
+ if (!isPathSafe(relPath) || !isSyncable(relPath)) {
464
+ results.push({ path: relPath, success: false, error: "invalid or non-syncable path" });
465
+ continue;
466
+ }
467
+ try {
468
+ const fullPath = path.join(WISPY_DIR, relPath);
469
+ await mkdir(path.dirname(fullPath), { recursive: true });
470
+ const buf = Buffer.from(content, encoding === "base64" ? "base64" : "utf8");
471
+ await writeFile(fullPath, buf);
472
+ if (modifiedAt) {
473
+ const mtime = new Date(modifiedAt);
474
+ const { utimes } = await import("node:fs/promises");
475
+ await utimes(fullPath, mtime, mtime).catch(() => {});
476
+ }
477
+ results.push({ path: relPath, success: true, size: buf.length });
478
+ } catch (err) {
479
+ results.push({ path: relPath, success: false, error: err.message });
480
+ }
481
+ }
482
+
483
+ const succeeded = results.filter(r => r.success).length;
484
+ const failed = results.filter(r => !r.success).length;
485
+ return jsonResponse(res, 200, { success: failed === 0, received: files.length, succeeded, failed, results });
486
+ }
487
+
338
488
  // ── Audit ───────────────────────────────────────────────────────────────
339
489
  if (pathname === "/api/audit" && method === "GET") {
340
490
  const filter = {};
package/core/session.mjs CHANGED
@@ -116,6 +116,7 @@ export class SessionManager {
116
116
 
117
117
  /**
118
118
  * Save a session to disk.
119
+ * If auto-sync is enabled, pushes the file to remote (non-blocking).
119
120
  */
120
121
  async save(id) {
121
122
  const session = this._sessions.get(id);
@@ -123,6 +124,13 @@ export class SessionManager {
123
124
  await mkdir(SESSIONS_DIR, { recursive: true });
124
125
  const filePath = path.join(SESSIONS_DIR, `${id}.json`);
125
126
  await writeFile(filePath, JSON.stringify(session.toJSON(), null, 2) + "\n", "utf8");
127
+
128
+ // Auto-sync: push to remote if configured (fire-and-forget)
129
+ try {
130
+ const { getSyncManager } = await import("./sync.mjs");
131
+ const syncMgr = await getSyncManager();
132
+ if (syncMgr?.auto) syncMgr.pushFile(filePath);
133
+ } catch { /* sync is optional */ }
126
134
  }
127
135
 
128
136
  /**