wispy-cli 1.3.0 → 2.0.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.
- package/bin/wispy.mjs +89 -0
- package/core/deploy.mjs +51 -0
- package/core/engine.mjs +121 -0
- package/core/index.mjs +3 -0
- package/core/migrate.mjs +357 -0
- package/core/skills.mjs +339 -0
- package/core/user-model.mjs +302 -0
- package/lib/channels/email.mjs +187 -0
- package/lib/channels/index.mjs +66 -9
- package/lib/channels/signal.mjs +151 -0
- package/lib/channels/whatsapp.mjs +141 -0
- package/lib/wispy-repl.mjs +102 -24
- package/lib/wispy-tui.mjs +964 -380
- package/package.json +18 -2
|
@@ -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
|
+
}
|
package/lib/wispy-repl.mjs
CHANGED
|
@@ -461,33 +461,89 @@ ${bold("Permissions & Audit (v1.1):")}
|
|
|
461
461
|
}
|
|
462
462
|
|
|
463
463
|
if (cmd === "/skills") {
|
|
464
|
-
//
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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(
|
|
487
|
-
|
|
488
|
-
|
|
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
|
|