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 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 33 tools any MCP client can
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 runs locally
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 },
@@ -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
- const sidecar = new LlamaSidecar(bin, embedModelPath(settings.modelsDir, spec), onLog);
229
- await sidecar.start();
230
- return new EmbedEngine(sidecar, spec);
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,
@@ -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) {
@@ -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
- function parseMinSimilarity(raw) {
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 DEFAULT_MIN_SIMILARITY;
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: parseModel(env.WAZAP_EMBED_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. A failed start is not
1252
- * cached the next queued batch tries again.
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 sidecarstopping it unblocks an embedding call in
1394
- * flight — then the store, whose own write queue drains before it closes.
1395
- * An engine still coming up is stopped whenever its start resolves.
1394
+ * Queue first, then the enginereleasing 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.2",
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",