wispy-cli 1.3.0 → 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,151 @@
1
+ /**
2
+ * lib/channels/signal.mjs — Signal channel adapter
3
+ *
4
+ * Uses signal-cli via subprocess (optional — must be installed separately).
5
+ * Install signal-cli: https://github.com/AsamK/signal-cli
6
+ *
7
+ * Config (channels.json):
8
+ * {
9
+ * "signal": {
10
+ * "number": "+1234567890", // registered Signal number
11
+ * "signalCliPath": "signal-cli", // path to binary (default: signal-cli in PATH)
12
+ * "allowedNumbers": ["+9876543210"] // empty = all
13
+ * }
14
+ * }
15
+ *
16
+ * Limitations: signal-cli must be registered and linked to an account.
17
+ * Run: signal-cli -a +NUMBER register && signal-cli -a +NUMBER verify CODE
18
+ */
19
+
20
+ import { ChannelAdapter } from "./base.mjs";
21
+ import { spawn } from "node:child_process";
22
+
23
+ export class SignalAdapter extends ChannelAdapter {
24
+ constructor(config = {}) {
25
+ super(config);
26
+ this.number = config.number;
27
+ this.signalCliPath = config.signalCliPath ?? "signal-cli";
28
+ this.allowedNumbers = config.allowedNumbers ?? [];
29
+ this._proc = null;
30
+ this._buffer = "";
31
+ }
32
+
33
+ async start() {
34
+ if (!this.number) {
35
+ throw new Error("Signal adapter requires config.number (your Signal phone number)");
36
+ }
37
+
38
+ // Test that signal-cli is available
39
+ await this._testBinary();
40
+
41
+ // Start signal-cli in JSON receive mode
42
+ this._proc = spawn(
43
+ this.signalCliPath,
44
+ ["-a", this.number, "--output=json", "receive", "--ignore-attachments"],
45
+ { stdio: ["ignore", "pipe", "pipe"] }
46
+ );
47
+
48
+ this._proc.stdout.setEncoding("utf8");
49
+ this._proc.stdout.on("data", (chunk) => {
50
+ this._buffer += chunk;
51
+ const lines = this._buffer.split("\n");
52
+ this._buffer = lines.pop() ?? "";
53
+ for (const line of lines) {
54
+ if (line.trim()) this._handleLine(line.trim());
55
+ }
56
+ });
57
+
58
+ this._proc.stderr.setEncoding("utf8");
59
+ this._proc.stderr.on("data", (data) => {
60
+ if (process.env.WISPY_DEBUG) {
61
+ console.error("[signal-cli]", data.trim());
62
+ }
63
+ });
64
+
65
+ this._proc.on("error", (err) => {
66
+ console.error("⚠️ Signal adapter error:", err.message);
67
+ });
68
+
69
+ this._proc.on("exit", (code) => {
70
+ if (code !== 0 && code !== null) {
71
+ console.warn(`⚠️ signal-cli exited with code ${code}`);
72
+ }
73
+ });
74
+
75
+ console.log(`✅ Signal adapter started for ${this.number}`);
76
+ }
77
+
78
+ async stop() {
79
+ if (this._proc) {
80
+ this._proc.kill("SIGTERM");
81
+ this._proc = null;
82
+ }
83
+ }
84
+
85
+ async sendMessage(chatId, text) {
86
+ return new Promise((resolve, reject) => {
87
+ const proc = spawn(
88
+ this.signalCliPath,
89
+ ["-a", this.number, "send", "-m", text, chatId],
90
+ { stdio: ["ignore", "pipe", "pipe"] }
91
+ );
92
+
93
+ let stderr = "";
94
+ proc.stderr.on("data", d => { stderr += d; });
95
+
96
+ proc.on("exit", (code) => {
97
+ if (code === 0) resolve();
98
+ else reject(new Error(`signal-cli send failed (${code}): ${stderr.slice(0, 200)}`));
99
+ });
100
+
101
+ proc.on("error", reject);
102
+
103
+ // Timeout
104
+ setTimeout(() => {
105
+ proc.kill();
106
+ reject(new Error("signal-cli send timed out"));
107
+ }, 15000);
108
+ });
109
+ }
110
+
111
+ _handleLine(line) {
112
+ try {
113
+ const envelope = JSON.parse(line);
114
+ const dataMsg = envelope?.envelope?.dataMessage;
115
+ if (!dataMsg?.message) return;
116
+
117
+ const senderNumber = envelope.envelope.sourceNumber ?? envelope.envelope.source;
118
+ if (!senderNumber) return;
119
+
120
+ // Filter allowed numbers
121
+ if (
122
+ this.allowedNumbers.length > 0 &&
123
+ !this.allowedNumbers.includes(senderNumber)
124
+ ) return;
125
+
126
+ const senderName = envelope.envelope.sourceName ?? senderNumber;
127
+ this._emit(senderNumber, senderNumber, senderName, dataMsg.message, envelope);
128
+ } catch {
129
+ // Ignore parse errors
130
+ }
131
+ }
132
+
133
+ async _testBinary() {
134
+ return new Promise((resolve, reject) => {
135
+ const proc = spawn(this.signalCliPath, ["--version"], { stdio: "pipe" });
136
+ proc.on("exit", (code) => {
137
+ if (code === 0) resolve();
138
+ else reject(new Error(
139
+ `signal-cli not found or not working. Install from: https://github.com/AsamK/signal-cli`
140
+ ));
141
+ });
142
+ proc.on("error", () => {
143
+ reject(new Error(
144
+ `signal-cli binary not found at '${this.signalCliPath}'. ` +
145
+ `Install from: https://github.com/AsamK/signal-cli`
146
+ ));
147
+ });
148
+ setTimeout(() => { proc.kill(); resolve(); }, 5000);
149
+ });
150
+ }
151
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * lib/channels/whatsapp.mjs — WhatsApp channel adapter
3
+ *
4
+ * Uses whatsapp-web.js (optional peer dependency).
5
+ * Install: npm install whatsapp-web.js qrcode-terminal
6
+ *
7
+ * Config (channels.json):
8
+ * { "whatsapp": { "allowedNumbers": ["+1234567890"], "sessionPath": "~/.wispy/whatsapp-session" } }
9
+ */
10
+
11
+ import { ChannelAdapter } from "./base.mjs";
12
+
13
+ let Client, LocalAuth, qrTerminal;
14
+
15
+ async function loadDeps() {
16
+ if (Client) return true;
17
+ try {
18
+ const pkg = await import("whatsapp-web.js");
19
+ Client = pkg.Client;
20
+ LocalAuth = pkg.LocalAuth;
21
+ } catch {
22
+ return false;
23
+ }
24
+ try {
25
+ qrTerminal = (await import("qrcode-terminal")).default;
26
+ } catch {
27
+ qrTerminal = null;
28
+ }
29
+ return true;
30
+ }
31
+
32
+ export class WhatsAppAdapter extends ChannelAdapter {
33
+ constructor(config = {}) {
34
+ super(config);
35
+ this.client = null;
36
+ this.allowedNumbers = config.allowedNumbers ?? []; // e.g. ["+821012345678"]
37
+ this.sessionPath = config.sessionPath ?? null;
38
+ this._ready = false;
39
+ }
40
+
41
+ async start() {
42
+ const ok = await loadDeps();
43
+ if (!ok) {
44
+ throw new Error(
45
+ "whatsapp-web.js is not installed.\n" +
46
+ "Run: npm install whatsapp-web.js qrcode-terminal"
47
+ );
48
+ }
49
+
50
+ const authStrategy = new LocalAuth({
51
+ dataPath: this.sessionPath ?? undefined,
52
+ });
53
+
54
+ this.client = new Client({
55
+ authStrategy,
56
+ puppeteer: {
57
+ headless: true,
58
+ args: ["--no-sandbox", "--disable-setuid-sandbox"],
59
+ },
60
+ });
61
+
62
+ this.client.on("qr", (qr) => {
63
+ console.log("\n📱 Scan this QR code with WhatsApp:\n");
64
+ if (qrTerminal) {
65
+ qrTerminal.generate(qr, { small: true });
66
+ } else {
67
+ console.log(qr);
68
+ }
69
+ console.log("\nWaiting for scan...\n");
70
+ });
71
+
72
+ this.client.on("ready", () => {
73
+ this._ready = true;
74
+ console.log("✅ WhatsApp connected.");
75
+ });
76
+
77
+ this.client.on("message", async (msg) => {
78
+ try {
79
+ // Filter: only allow configured numbers (or all if empty)
80
+ const from = msg.from; // e.g. "821012345678@c.us"
81
+ const normalized = "+" + from.replace("@c.us", "");
82
+ if (
83
+ this.allowedNumbers.length > 0 &&
84
+ !this.allowedNumbers.includes(normalized)
85
+ ) {
86
+ return; // Not allowed
87
+ }
88
+
89
+ const contact = await msg.getContact();
90
+ const username = contact.pushname ?? contact.name ?? normalized;
91
+
92
+ this._emit(from, from, username, msg.body, msg);
93
+ } catch (err) {
94
+ if (process.env.WISPY_DEBUG) {
95
+ console.error("[whatsapp] Message handler error:", err.message);
96
+ }
97
+ }
98
+ });
99
+
100
+ this.client.on("disconnected", (reason) => {
101
+ this._ready = false;
102
+ console.warn("⚠️ WhatsApp disconnected:", reason);
103
+ });
104
+
105
+ await this.client.initialize();
106
+ }
107
+
108
+ async stop() {
109
+ if (this.client) {
110
+ try {
111
+ await this.client.destroy();
112
+ } catch { /* ignore */ }
113
+ this.client = null;
114
+ this._ready = false;
115
+ }
116
+ }
117
+
118
+ async sendMessage(chatId, text) {
119
+ if (!this.client || !this._ready) {
120
+ throw new Error("WhatsApp client not ready");
121
+ }
122
+ // Split long messages (WhatsApp limit ~65000 chars, but keep it reasonable)
123
+ const MAX_LEN = 4000;
124
+ if (text.length <= MAX_LEN) {
125
+ await this.client.sendMessage(chatId, text);
126
+ } else {
127
+ const chunks = [];
128
+ for (let i = 0; i < text.length; i += MAX_LEN) {
129
+ chunks.push(text.slice(i, i + MAX_LEN));
130
+ }
131
+ for (const chunk of chunks) {
132
+ await this.client.sendMessage(chatId, chunk);
133
+ await new Promise(r => setTimeout(r, 500)); // Avoid rate limits
134
+ }
135
+ }
136
+ }
137
+
138
+ get isReady() {
139
+ return this._ready;
140
+ }
141
+ }
@@ -461,33 +461,89 @@ ${bold("Permissions & Audit (v1.1):")}
461
461
  }
462
462
 
463
463
  if (cmd === "/skills") {
464
- // Load skills from known locations
465
- const skillDirs = [
466
- "/opt/homebrew/lib/node_modules/openclaw/skills",
467
- path.join(os.homedir(), ".openclaw", "workspace", "skills"),
468
- path.join(WISPY_DIR, "skills"),
469
- ];
470
- const { readdir } = await import("node:fs/promises");
471
- const allSkills = [];
472
- for (const dir of skillDirs) {
473
- try {
474
- const entries = await readdir(dir);
475
- for (const entry of entries) {
476
- try {
477
- const skillMd = await readFile(path.join(dir, entry, "SKILL.md"), "utf8");
478
- allSkills.push({ name: entry, source: dir });
479
- } catch {}
480
- }
481
- } catch {}
482
- }
483
- if (allSkills.length === 0) {
484
- console.log(dim("No skills installed."));
464
+ // Wispy learned skills
465
+ const wispySkills = await engine.skills.list();
466
+ if (wispySkills.length > 0) {
467
+ console.log(bold(`\n🧠 Learned Skills (${wispySkills.length}):\n`));
468
+ for (const s of wispySkills) {
469
+ const used = s.timesUsed > 0 ? dim(` · used ${s.timesUsed}x`) : "";
470
+ const version = s.version > 1 ? dim(` · v${s.version}`) : "";
471
+ console.log(` ${green("/" + s.name.padEnd(25))} ${s.description ?? ""}${used}${version}`);
472
+ if (s.tags?.length > 0) console.log(` ${" ".repeat(27)}${dim(s.tags.map(t => "#" + t).join(" "))}`);
473
+ }
474
+ console.log(dim("\n Run: /<skill-name> or /skill improve <name> \"feedback\""));
485
475
  } else {
486
- console.log(bold(`\n🧩 Skills (${allSkills.length} installed):\n`));
487
- for (const s of allSkills) {
488
- console.log(` ${green(s.name.padEnd(20))} ${dim(s.source.split("/").slice(-2).join("/"))}`);
476
+ console.log(dim("\nNo learned skills yet. Wispy will auto-learn from complex tasks!"));
477
+ console.log(dim("Create manually: /skill create <name>"));
478
+ }
479
+ return true;
480
+ }
481
+
482
+ // /skill <subcommand> — skill management
483
+ if (cmd === "/skill") {
484
+ const sub = parts[1];
485
+ if (!sub) {
486
+ console.log(`${bold("/skill commands:")} create <name> | improve <name> "feedback" | delete <name> | stats <name>`);
487
+ return true;
488
+ }
489
+
490
+ if (sub === "create") {
491
+ const name = parts[2];
492
+ if (!name) { console.log(yellow("Usage: /skill create <name>")); return true; }
493
+ // Capture from last conversation
494
+ const lastUser = conversation.filter(m => m.role === "user").slice(-3).map(m => m.content).join(" / ");
495
+ const lastAssistant = conversation.filter(m => m.role === "assistant").slice(-1)[0]?.content ?? "";
496
+ if (!lastUser) { console.log(yellow("No conversation to create skill from.")); return true; }
497
+ const skill = await engine.skills.create({
498
+ name,
499
+ description: `Created from last conversation`,
500
+ prompt: lastUser.slice(0, 500),
501
+ tools: [],
502
+ tags: [],
503
+ });
504
+ console.log(green(`✅ Skill '${name}' created!`));
505
+ console.log(dim(` Prompt: ${skill.prompt.slice(0, 80)}...`));
506
+ return true;
507
+ }
508
+
509
+ if (sub === "improve") {
510
+ const name = parts[2];
511
+ const feedback = parts.slice(3).join(" ").replace(/^["']|["']$/g, "");
512
+ if (!name || !feedback) { console.log(yellow(`Usage: /skill improve <name> "feedback"`)); return true; }
513
+ try {
514
+ process.stdout.write(dim(` Improving skill '${name}'...`));
515
+ const updated = await engine.skills.improve(name, feedback);
516
+ console.log(green(` ✓ done (v${updated.version})`));
517
+ console.log(dim(` New prompt: ${updated.prompt.slice(0, 100)}...`));
518
+ } catch (err) {
519
+ console.log(red(` ✗ ${err.message}`));
489
520
  }
521
+ return true;
522
+ }
523
+
524
+ if (sub === "delete") {
525
+ const name = parts[2];
526
+ if (!name) { console.log(yellow("Usage: /skill delete <name>")); return true; }
527
+ const result = await engine.skills.delete(name);
528
+ if (result.success) console.log(green(`🗑️ Skill '${name}' deleted.`));
529
+ else console.log(red(`✗ ${result.error}`));
530
+ return true;
490
531
  }
532
+
533
+ if (sub === "stats") {
534
+ const name = parts[2];
535
+ if (!name) { console.log(yellow("Usage: /skill stats <name>")); return true; }
536
+ const stats = await engine.skills.getStats(name);
537
+ if (!stats) { console.log(red(`Skill '${name}' not found.`)); return true; }
538
+ console.log(bold(`\n📊 Skill: ${name}\n`));
539
+ console.log(` Times used: ${stats.timesUsed}`);
540
+ console.log(` Version: ${stats.version}`);
541
+ console.log(` Improvements: ${stats.improvements}`);
542
+ if (stats.lastUsed) console.log(` Last used: ${new Date(stats.lastUsed).toLocaleString()}`);
543
+ return true;
544
+ }
545
+
546
+ console.log(`${bold("/skill commands:")} create <name> | improve <name> "feedback" | delete <name> | stats <name>`);
491
547
  return true;
492
548
  }
493
549
 
@@ -774,6 +830,25 @@ ${bold("Permissions & Audit (v1.1):")}
774
830
  process.exit(0);
775
831
  }
776
832
 
833
+ // ── Skill invocation: /skill-name ─────────────────────────────────────────
834
+ // Any unknown /command is checked against learned skills
835
+ const maybeSkillName = cmd.slice(1); // remove leading /
836
+ if (maybeSkillName && engine.skills) {
837
+ const skill = await engine.skills.get(maybeSkillName);
838
+ if (skill) {
839
+ console.log(dim(`🧠 Running skill: ${skill.name} (v${skill.version ?? 1})`));
840
+ try {
841
+ const result = await engine.skills.execute(maybeSkillName, {}, null);
842
+ if (result?.content) {
843
+ console.log(result.content);
844
+ }
845
+ } catch (err) {
846
+ console.log(red(`✗ Skill error: ${err.message}`));
847
+ }
848
+ return true;
849
+ }
850
+ }
851
+
777
852
  return false;
778
853
  }
779
854
 
@@ -820,6 +895,9 @@ async function runRepl(engine) {
820
895
  onChunk: (chunk) => process.stdout.write(chunk),
821
896
  systemPrompt: await engine._buildSystemPrompt(input),
822
897
  noSave: true,
898
+ onSkillLearned: (skill) => {
899
+ console.log(cyan(`\n💡 Learned new skill: '${skill.name}' — use /${skill.name} next time`));
900
+ },
823
901
  });
824
902
  console.log("\n");
825
903