wazap-mcp 0.18.2 → 0.18.3
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/README.md +39 -2
- package/dist/doctor.js +4 -1
- package/dist/recall/engine.js +76 -3
- package/dist/recall/models.js +13 -0
- package/dist/recall/settings.js +10 -11
- package/dist/whatsapp.js +7 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
```
|
|
9
9
|
|
|
10
10
|
**WhatsApp for your AI agent.** An MCP server that puts your WhatsApp account —
|
|
11
|
-
chats, messages, media, contacts, groups — behind
|
|
11
|
+
chats, messages, media, contacts, groups — behind 34 tools any MCP client can
|
|
12
12
|
call. Pairing-code login, no browser, no phone-number reseller, ~20 MB of RAM.
|
|
13
13
|
|
|
14
14
|
Built on [Baileys](https://github.com/WhiskeySockets/Baileys), which speaks the
|
|
@@ -284,6 +284,7 @@ them. `--dry-run` prints the plan and touches nothing.
|
|
|
284
284
|
| `get_stories` | read | The stories (status updates) received in the last day, by author, with previews on request. They show nowhere else. |
|
|
285
285
|
| `wait_for_messages` | read | Block up to 55 s until a message arrives, then return it with a cursor for the next call. `addressed_to_me` wakes only for direct messages, @-mentions and replies. |
|
|
286
286
|
| `search_messages` | read | Text search across the locally held messages; `since`, `until` and `from` narrow it. |
|
|
287
|
+
| `recall` | read | Semantic search over the whole indexed history: matches by meaning, so a paraphrase or another language still hits, and it finds messages too old for `search_messages`. Off until [turned on](#semantic-recall). |
|
|
287
288
|
| `get_message` | read | One message in full, with its quoted message and reactions. |
|
|
288
289
|
| `search_contacts` | read | Find contacts by name or number. |
|
|
289
290
|
| `sync_contacts` | read | Fetch the phone's address book from WhatsApp again, when names are missing. |
|
|
@@ -423,6 +424,41 @@ transcribed once and not again after a restart. Audio *files* are left alone,
|
|
|
423
424
|
since one can be an hour long; call `transcribe_audio(message_id)` for those.
|
|
424
425
|
`WAZAP_TRANSCRIBE_AUTO=0` keeps the tool and stops the background work.
|
|
425
426
|
|
|
427
|
+
## Semantic recall
|
|
428
|
+
|
|
429
|
+
`search_messages` matches exact words; `recall` matches what was meant. A
|
|
430
|
+
paraphrase or another language still hits, and it keeps finding messages too
|
|
431
|
+
old for `search_messages` to see — those come back marked `index only`, which
|
|
432
|
+
`get_message` and `download_media` cannot open. For an exact string — an id,
|
|
433
|
+
a phone number, a URL — `search_messages` stays the right tool.
|
|
434
|
+
|
|
435
|
+
Off by default, and fully local: a `llama-server` sidecar bound to loopback
|
|
436
|
+
does the embedding, so nothing leaves the machine. It needs llama.cpp, the
|
|
437
|
+
pinned model and persisted history (`WAZAP_PERSIST_HISTORY`, on by default):
|
|
438
|
+
|
|
439
|
+
```bash
|
|
440
|
+
brew install llama.cpp # macOS; elsewhere build llama.cpp and put llama-server on PATH
|
|
441
|
+
wazap embed download # fetch the embedding model, ~318 MB sha256-verified
|
|
442
|
+
wazap config recall local # then restart the service
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
`wazap embed download` offers the `brew install` itself when `llama-server`
|
|
446
|
+
is missing. `wazap status` runs the three checks — `recall`, `llama-server`,
|
|
447
|
+
`embed model` — and `get_status` reports the index as `off`, `indexing`,
|
|
448
|
+
`ready` or `degraded`.
|
|
449
|
+
|
|
450
|
+
`chat_id`, `since`, `until` and `from` narrow a recall exactly like
|
|
451
|
+
`search_messages`. Hits rank by similarity scaled by recency, and anything
|
|
452
|
+
under the similarity floor is dropped rather than listed. The index lives at
|
|
453
|
+
`accounts/<id>/recall/` (dir `0700`, files `0600`), holds only what persisted
|
|
454
|
+
history already stores, and tombstones a message out when it is deleted or
|
|
455
|
+
revoked.
|
|
456
|
+
|
|
457
|
+
The knobs — `WAZAP_RECALL`, `WAZAP_EMBED_MODEL` (`embeddinggemma-300m` by
|
|
458
|
+
default, `e5-base-multilingual` for an older llama.cpp), `WAZAP_EMBED_BIN`,
|
|
459
|
+
`WAZAP_RECALL_MAX`, `WAZAP_RECALL_MIN_SIMILARITY` — are documented in
|
|
460
|
+
`.env.example`.
|
|
461
|
+
|
|
426
462
|
## Skills
|
|
427
463
|
|
|
428
464
|
wazap ships five [Agent Skills](https://agentskills.io) that teach an agent the workflows behind the tools, not just the tools:
|
|
@@ -512,11 +548,12 @@ accounts moves into `accounts/default/` the first time a wazap command runs.
|
|
|
512
548
|
auth/ WhatsApp credentials — treat this like a password
|
|
513
549
|
media/ downloads from download_media
|
|
514
550
|
history/ per-chat message history, so a restart is not amnesia
|
|
551
|
+
recall/ the semantic index, when recall is on
|
|
515
552
|
previews/ one small JPEG per photo or video already previewed
|
|
516
553
|
notes.json notes on contacts and "handled" marks; never sent anywhere
|
|
517
554
|
store.json chat-list snapshot
|
|
518
555
|
qr.png last QR, when login showed one
|
|
519
|
-
models/ whisper.cpp models, when transcription
|
|
556
|
+
models/ whisper.cpp and embedding models, when transcription or recall run locally
|
|
520
557
|
server.lock pid of the running server
|
|
521
558
|
daemon.json loopback endpoint a second wazap bridges to
|
|
522
559
|
oauth.json registered agents and hashed OAuth grants, when OAuth is on
|
package/dist/doctor.js
CHANGED
|
@@ -313,8 +313,11 @@ async function checkRecall(config) {
|
|
|
313
313
|
const spec = EMBED_MODELS[settings.model];
|
|
314
314
|
const size = fileSize(embedModelPath(settings.modelsDir, spec));
|
|
315
315
|
const readiness = await embedReady(settings, spec);
|
|
316
|
+
// An env-set floor is the user's own calibration; only the model's
|
|
317
|
+
// unmeasured default gets flagged.
|
|
318
|
+
const uncalibrated = !spec.floorCalibrated && process.env.WAZAP_RECALL_MIN_SIMILARITY === undefined ? ", uncalibrated floor" : "";
|
|
316
319
|
return [
|
|
317
|
-
{ name: "recall", state: "ok", detail: `local (${settings.model})` },
|
|
320
|
+
{ name: "recall", state: "ok", detail: `local (${settings.model}${uncalibrated})` },
|
|
318
321
|
readiness.ok
|
|
319
322
|
? { name: "llama-server", state: "ok", detail: settings.embedUrl ?? "found" }
|
|
320
323
|
: { name: "llama-server", state: "fail", detail: readiness.detail, fix: readiness.fix },
|
package/dist/recall/engine.js
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
* loopback, restarted when it dies, killed when wazap stops. The queue is the
|
|
4
4
|
* only caller, so a slow restart stalls indexing — never ingestion.
|
|
5
5
|
*
|
|
6
|
+
* The /embedding API is stateless, so every account in the process shares one
|
|
7
|
+
* server: the registry below spawns on the first acquire and kills on the
|
|
8
|
+
* last release, keyed by the only things that make a sidecar distinct — the
|
|
9
|
+
* resolved binary and the model file.
|
|
10
|
+
*
|
|
6
11
|
* WAZAP_EMBED_URL points at an already-running compatible server instead of
|
|
7
12
|
* spawning one; that seam exists for the test stub, not as a supported option.
|
|
8
13
|
*/
|
|
@@ -204,6 +209,66 @@ class UrlBackend {
|
|
|
204
209
|
return Promise.resolve();
|
|
205
210
|
}
|
|
206
211
|
}
|
|
212
|
+
/** The seam the tests replace; production always spawns a real llama-server. */
|
|
213
|
+
export const sidecarFactory = {
|
|
214
|
+
open: (bin, model, onLog) => new LlamaSidecar(bin, model, onLog),
|
|
215
|
+
};
|
|
216
|
+
const sharedSidecars = new Map();
|
|
217
|
+
/**
|
|
218
|
+
* One consumer's claim on a shared sidecar. Embedding calls reach the child
|
|
219
|
+
* directly; stop() only gives this claim back — the last one out is the one
|
|
220
|
+
* that kills the server. The child's restart lines keep arriving through the
|
|
221
|
+
* first consumer's onLog, which in production is the same `log` for everyone.
|
|
222
|
+
*/
|
|
223
|
+
class SharedSidecar {
|
|
224
|
+
key;
|
|
225
|
+
entry;
|
|
226
|
+
released = false;
|
|
227
|
+
constructor(key, entry) {
|
|
228
|
+
this.key = key;
|
|
229
|
+
this.entry = entry;
|
|
230
|
+
}
|
|
231
|
+
/** First claim on a key spawns; later ones join the start already under way. */
|
|
232
|
+
static acquire(bin, model, onLog) {
|
|
233
|
+
const key = `${bin}\n${model}`;
|
|
234
|
+
let entry = sharedSidecars.get(key);
|
|
235
|
+
if (entry === undefined) {
|
|
236
|
+
const sidecar = sidecarFactory.open(bin, model, onLog);
|
|
237
|
+
entry = { refs: 0, sidecar, started: sidecar.start() };
|
|
238
|
+
sharedSidecars.set(key, entry);
|
|
239
|
+
// A failed start frees the slot, so the next acquire spawns fresh
|
|
240
|
+
// instead of joining a rejection. Consumers still holding refs release
|
|
241
|
+
// into a sidecar that never ran — stop() on it is a safe no-op.
|
|
242
|
+
entry.started.catch(() => {
|
|
243
|
+
if (sharedSidecars.get(key) === entry)
|
|
244
|
+
sharedSidecars.delete(key);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
entry.refs++;
|
|
248
|
+
return new SharedSidecar(key, entry);
|
|
249
|
+
}
|
|
250
|
+
/** The shared start; the engine releases its claim when this rejects. */
|
|
251
|
+
started() {
|
|
252
|
+
return this.entry.started;
|
|
253
|
+
}
|
|
254
|
+
get base() {
|
|
255
|
+
return this.entry.sidecar.base;
|
|
256
|
+
}
|
|
257
|
+
waitReady() {
|
|
258
|
+
return this.entry.sidecar.waitReady();
|
|
259
|
+
}
|
|
260
|
+
async stop() {
|
|
261
|
+
if (this.released)
|
|
262
|
+
return;
|
|
263
|
+
this.released = true;
|
|
264
|
+
this.entry.refs--;
|
|
265
|
+
if (this.entry.refs > 0)
|
|
266
|
+
return;
|
|
267
|
+
if (sharedSidecars.get(this.key) === this.entry)
|
|
268
|
+
sharedSidecars.delete(this.key);
|
|
269
|
+
await this.entry.sidecar.stop();
|
|
270
|
+
}
|
|
271
|
+
}
|
|
207
272
|
/**
|
|
208
273
|
* The queue's handle on embeddings. `embed` waits out a restart for up to
|
|
209
274
|
* START_TIMEOUT_MS, then fails the batch — the queue retries, so a dying
|
|
@@ -225,9 +290,17 @@ export class EmbedEngine {
|
|
|
225
290
|
if (bin === null) {
|
|
226
291
|
throw new WazapError("RECALL_UNAVAILABLE", "llama-server not found; semantic recall needs llama.cpp", llamaInstallFix());
|
|
227
292
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
293
|
+
// Accounts on the same binary and model share one server: acquire bumps
|
|
294
|
+
// the registry's refcount, this engine's stop() hands just this claim back.
|
|
295
|
+
const target = SharedSidecar.acquire(bin, embedModelPath(settings.modelsDir, spec), onLog);
|
|
296
|
+
try {
|
|
297
|
+
await target.started();
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
await target.stop();
|
|
301
|
+
throw err;
|
|
302
|
+
}
|
|
303
|
+
return new EmbedEngine(target, spec);
|
|
231
304
|
}
|
|
232
305
|
/**
|
|
233
306
|
* `kind` picks the model's task prefix: the index holds "document" texts,
|
package/dist/recall/models.js
CHANGED
|
@@ -24,6 +24,10 @@ export const EMBED_MODELS = {
|
|
|
24
24
|
url: "https://huggingface.co/ggml-org/embeddinggemma-300M-GGUF/resolve/main/embeddinggemma-300M-Q8_0.gguf",
|
|
25
25
|
// EmbeddingGemma's own retrieval task, from its model card.
|
|
26
26
|
prompts: { query: "task: search result | query: ", document: "title: none | text: " },
|
|
27
|
+
// Measured on a real index under those prompts: noise tops out ~0.31,
|
|
28
|
+
// real paraphrases start ~0.35.
|
|
29
|
+
defaultMinSimilarity: 0.35,
|
|
30
|
+
floorCalibrated: true,
|
|
27
31
|
},
|
|
28
32
|
"e5-base-multilingual": {
|
|
29
33
|
alias: "e5-base-multilingual",
|
|
@@ -34,6 +38,15 @@ export const EMBED_MODELS = {
|
|
|
34
38
|
url: "https://huggingface.co/dinab/multilingual-e5-base-Q8_0-GGUF/resolve/main/multilingual-e5-base-q8_0.gguf",
|
|
35
39
|
// e5's documented asymmetric prefixes.
|
|
36
40
|
prompts: { query: "query: ", document: "passage: " },
|
|
41
|
+
// UNCALIBRATED — a placeholder, not a measurement. e5's contrastive
|
|
42
|
+
// training compresses prompted cosines into a much higher band than
|
|
43
|
+
// gemma's: unrelated pairs commonly read ~0.6-0.75 where real matches
|
|
44
|
+
// start ~0.8, so gemma's 0.35 would pass noise as answers. 0.7 errs
|
|
45
|
+
// high on purpose: a dropped real hit answers "nothing found", a false
|
|
46
|
+
// hit is a wrong memory an agent will repeat. Re-measure on a real
|
|
47
|
+
// index before flipping floorCalibrated.
|
|
48
|
+
defaultMinSimilarity: 0.7,
|
|
49
|
+
floorCalibrated: false,
|
|
37
50
|
},
|
|
38
51
|
};
|
|
39
52
|
export function embedModelSpec(name) {
|
package/dist/recall/settings.js
CHANGED
|
@@ -6,18 +6,12 @@
|
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { WazapError } from "../errors.js";
|
|
8
8
|
import { stripPasted } from "../transcribe/index.js";
|
|
9
|
+
import { EMBED_MODELS } from "./models.js";
|
|
9
10
|
const OFF = new Set(["", "off", "0", "no", "none", "false"]);
|
|
10
11
|
const ON = new Set(["local", "on", "1", "yes", "true"]);
|
|
11
12
|
const MODEL_ALIASES = ["embeddinggemma-300m", "e5-base-multilingual"];
|
|
12
13
|
const DEFAULT_MAX_ROWS = 50_000;
|
|
13
14
|
const MIN_MAX_ROWS = 100;
|
|
14
|
-
/**
|
|
15
|
-
* Cosine floor for embeddinggemma-300m under its task prompts, measured on a
|
|
16
|
-
* real index: noise tops out ~0.31, real paraphrases start ~0.35. The prompts
|
|
17
|
-
* widened the noise/signal gap enough for the floor to mean something.
|
|
18
|
-
* e5-base needs its own calibration.
|
|
19
|
-
*/
|
|
20
|
-
const DEFAULT_MIN_SIMILARITY = 0.35;
|
|
21
15
|
function parseEnabled(raw) {
|
|
22
16
|
const value = stripPasted(raw ?? "").toLowerCase();
|
|
23
17
|
if (OFF.has(value))
|
|
@@ -43,10 +37,14 @@ function parseMaxRows(raw) {
|
|
|
43
37
|
return n;
|
|
44
38
|
throw new WazapError("INVALID_ID", `WAZAP_RECALL_MAX must be a number >= ${MIN_MAX_ROWS}, got "${value}".`, "Fix WAZAP_RECALL_MAX or remove it");
|
|
45
39
|
}
|
|
46
|
-
|
|
40
|
+
/**
|
|
41
|
+
* The env wins over the model's own floor — a cosine that means "real match"
|
|
42
|
+
* is the model's to price, the override is the user's.
|
|
43
|
+
*/
|
|
44
|
+
function parseMinSimilarity(raw, fallback) {
|
|
47
45
|
const value = stripPasted(raw ?? "");
|
|
48
46
|
if (value === "")
|
|
49
|
-
return
|
|
47
|
+
return fallback;
|
|
50
48
|
const n = Number(value);
|
|
51
49
|
if (Number.isFinite(n) && n >= 0 && n <= 1)
|
|
52
50
|
return n;
|
|
@@ -68,13 +66,14 @@ function parseUrl(raw) {
|
|
|
68
66
|
}
|
|
69
67
|
export function readRecallSettings(env, dataDir) {
|
|
70
68
|
const embedBin = stripPasted(env.WAZAP_EMBED_BIN ?? "");
|
|
69
|
+
const model = parseModel(env.WAZAP_EMBED_MODEL);
|
|
71
70
|
return {
|
|
72
71
|
enabled: parseEnabled(env.WAZAP_RECALL),
|
|
73
|
-
model
|
|
72
|
+
model,
|
|
74
73
|
embedBin: embedBin === "" ? null : embedBin,
|
|
75
74
|
embedUrl: parseUrl(env.WAZAP_EMBED_URL),
|
|
76
75
|
modelsDir: join(dataDir, "models"),
|
|
77
76
|
maxRows: parseMaxRows(env.WAZAP_RECALL_MAX),
|
|
78
|
-
minSimilarity: parseMinSimilarity(env.WAZAP_RECALL_MIN_SIMILARITY),
|
|
77
|
+
minSimilarity: parseMinSimilarity(env.WAZAP_RECALL_MIN_SIMILARITY, EMBED_MODELS[model].defaultMinSimilarity),
|
|
79
78
|
};
|
|
80
79
|
}
|
package/dist/whatsapp.js
CHANGED
|
@@ -1248,8 +1248,9 @@ export class WhatsAppService {
|
|
|
1248
1248
|
}
|
|
1249
1249
|
}
|
|
1250
1250
|
/**
|
|
1251
|
-
* The sidecar, started on the first embedding request
|
|
1252
|
-
*
|
|
1251
|
+
* The sidecar, started on the first embedding request and shared with every
|
|
1252
|
+
* other account in the process on the same binary and model. A failed start
|
|
1253
|
+
* is not cached — the next queued batch tries again.
|
|
1253
1254
|
*/
|
|
1254
1255
|
recallEngine() {
|
|
1255
1256
|
if (this.stopped)
|
|
@@ -1390,9 +1391,10 @@ export class WhatsAppService {
|
|
|
1390
1391
|
return { state: pending > 0 ? "indexing" : "ready", indexed, pending };
|
|
1391
1392
|
}
|
|
1392
1393
|
/**
|
|
1393
|
-
* Queue first, then the
|
|
1394
|
-
* flight — then the store, whose own write
|
|
1395
|
-
* An engine still coming up is
|
|
1394
|
+
* Queue first, then the engine — releasing its claim on the shared sidecar
|
|
1395
|
+
* unblocks an embedding call in flight — then the store, whose own write
|
|
1396
|
+
* queue drains before it closes. An engine still coming up is released
|
|
1397
|
+
* whenever its start resolves.
|
|
1396
1398
|
*/
|
|
1397
1399
|
async stopRecall() {
|
|
1398
1400
|
const queueStop = this.recallQueue?.stop() ?? Promise.resolve();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wazap-mcp",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.3",
|
|
4
4
|
"mcpName": "io.github.razvangirgiz/wazap",
|
|
5
5
|
"description": "WhatsApp for your AI agent. MCP server over Baileys: pairing-code login, several accounts, 33 tools, stdio or HTTP.",
|
|
6
6
|
"license": "MIT",
|