wazap-mcp 0.18.2 → 0.18.4

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/cli.js CHANGED
@@ -4,14 +4,11 @@ import { mkdirSync } from "node:fs";
4
4
  import { createInterface } from "node:readline/promises";
5
5
  import { setTimeout as sleep } from "node:timers/promises";
6
6
  import { DisconnectReason } from "baileys";
7
- import qrcode from "qrcode";
8
- import qrcodeTerminal from "qrcode-terminal";
9
7
  import { accountRows, describeAccount, describeStatusAccount } from "./account-cli.js";
10
8
  import { AccountHub } from "./account-hub.js";
11
9
  import { AccountRegistry, resolveAccount } from "./accounts.js";
12
10
  import { clearSession, readLinkedAccount } from "./auth-state.js";
13
11
  import { banner } from "./banner.js";
14
- import { runBridge } from "./bridge.js";
15
12
  import { BAILEYS_VERSION, WAZAP_VERSION, paths } from "./config.js";
16
13
  import { connectNext, whereInstalled } from "./connect.js";
17
14
  import { decideRole, readDaemon, removeDaemon, writeDaemon } from "./daemon.js";
@@ -460,6 +457,9 @@ export async function runServe(config) {
460
457
  process.exit(2);
461
458
  }
462
459
  if (role.kind === "bridge") {
460
+ // Bridging onto a running daemon is the rare role; the MCP client stack
461
+ // it needs stays out of the common path until then.
462
+ const { runBridge } = await import("./bridge.js");
463
463
  await runBridge(role.daemon, p.daemonFile);
464
464
  return;
465
465
  }
@@ -717,6 +717,11 @@ async function linkByCode(authDir, phone, waiting, w) {
717
717
  return pairing.done;
718
718
  }
719
719
  async function linkByQr(p, waiting, w) {
720
+ // The QR drawers are only needed by this one login path, not by serve.
721
+ const [{ default: qrcode }, { default: qrcodeTerminal }] = await Promise.all([
722
+ import("qrcode"),
723
+ import("qrcode-terminal"),
724
+ ]);
720
725
  if (w) {
721
726
  await w.next("Scan this with WhatsApp");
722
727
  waiting.start("Waiting for a QR from WhatsApp…");
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/errors.js CHANGED
@@ -44,6 +44,7 @@ export const ERROR_GUIDE = {
44
44
  TRANSCRIBE_FAILED: "The transcription provider ran and failed. Read the message; retry once at most.",
45
45
  RECALL_UNAVAILABLE: "Semantic recall is off, or llama.cpp or the embedding model is missing. Tell the user to run the command in the fix; do not retry.",
46
46
  RECALL_FAILED: "The embedding backend ran and failed. Read the message; retry once at most.",
47
+ RECALL_BAD_INPUT: "The embedding server refused the input itself. Do not retry it unchanged.",
47
48
  TIMEOUT: "WhatsApp did not answer in time. Retry once; if it fails again, call get_status.",
48
49
  SERVICE_ERROR: "wazap's own background service could not be managed. This is a machine problem, not a WhatsApp one: read the fix and tell the user.",
49
50
  DRAFT_NOT_FOUND: "That draft_id is unknown or was already sent. Call the send tool again to draft, show the new preview, then confirm_send.",
@@ -1,7 +1,13 @@
1
1
  /**
2
2
  * The embedding backend: one `llama-server --embedding` child bound to
3
- * loopback, restarted when it dies, killed when wazap stops. The queue is the
4
- * only caller, so a slow restart stalls indexing — never ingestion.
3
+ * loopback, restarted when it dies, reaped when idle, killed when wazap
4
+ * stops. The queue is the only caller, so a slow restart stalls indexing —
5
+ * never ingestion.
6
+ *
7
+ * The /embedding API is stateless, so every account in the process shares one
8
+ * server: the registry below spawns on the first acquire and kills on the
9
+ * last release, keyed by the only things that make a sidecar distinct — the
10
+ * resolved binary and the model file.
5
11
  *
6
12
  * WAZAP_EMBED_URL points at an already-running compatible server instead of
7
13
  * spawning one; that seam exists for the test stub, not as a supported option.
@@ -204,6 +210,133 @@ class UrlBackend {
204
210
  return Promise.resolve();
205
211
  }
206
212
  }
213
+ /** The seam the tests replace; production always spawns a real llama-server. */
214
+ export const sidecarFactory = {
215
+ open: (bin, model, onLog) => new LlamaSidecar(bin, model, onLog),
216
+ };
217
+ const sharedSidecars = new Map();
218
+ /**
219
+ * The claim side of the registry: an existing entry is joined, a missing one
220
+ * is spawned. A failed start frees the slot, so the next claim spawns fresh
221
+ * instead of joining a rejection; a successful one starts the idle clock.
222
+ * Consumers still holding refs on a failed or reaped entry release into a
223
+ * stopped sidecar — stop() on it is a safe no-op.
224
+ */
225
+ function claimEntry(key, bin, model, onLog, idleMs) {
226
+ let entry = sharedSidecars.get(key);
227
+ if (entry === undefined) {
228
+ const sidecar = sidecarFactory.open(bin, model, onLog);
229
+ entry = { refs: 0, sidecar, started: sidecar.start(), idleMs, idleTimer: null, reaped: false };
230
+ sharedSidecars.set(key, entry);
231
+ const spawned = entry;
232
+ spawned.started.then(() => touchIdle(key, spawned), () => {
233
+ if (sharedSidecars.get(key) === spawned)
234
+ sharedSidecars.delete(key);
235
+ });
236
+ }
237
+ entry.refs++;
238
+ return entry;
239
+ }
240
+ /**
241
+ * The idle clock only runs while the server is up and unused: every embed
242
+ * through the entry re-arms it, and an entry the registry no longer holds —
243
+ * stopped or already reaped — is never armed.
244
+ */
245
+ function touchIdle(key, entry) {
246
+ if (entry.idleMs <= 0 || entry.reaped)
247
+ return;
248
+ if (sharedSidecars.get(key) !== entry)
249
+ return;
250
+ if (entry.idleTimer !== null)
251
+ clearTimeout(entry.idleTimer);
252
+ entry.idleTimer = setTimeout(() => reapIdle(key, entry), entry.idleMs);
253
+ entry.idleTimer.unref();
254
+ }
255
+ /**
256
+ * Idle means nobody is calling embed — the claims themselves can stay. The
257
+ * entry is evicted and the child killed while consumers still hold it; their
258
+ * next embed finds the slot empty and claims a fresh spawn.
259
+ */
260
+ function reapIdle(key, entry) {
261
+ entry.idleTimer = null;
262
+ entry.reaped = true;
263
+ if (sharedSidecars.get(key) === entry)
264
+ sharedSidecars.delete(key);
265
+ entry.sidecar.stop().catch(() => { });
266
+ }
267
+ /**
268
+ * One consumer's claim on a shared sidecar. Embedding calls reach the child
269
+ * through waitReady(); stop() only gives this claim back — the last one out
270
+ * is the one that kills the server. The child's restart lines keep arriving
271
+ * through the first consumer's onLog, which in production is the same `log`
272
+ * for everyone.
273
+ *
274
+ * An idle reap can evict the entry out from under a live claim; the next
275
+ * embed then re-claims the key — joining a respawn already under way or
276
+ * starting one — so a live engine always reaches a running server.
277
+ */
278
+ class SharedSidecar {
279
+ key;
280
+ bin;
281
+ model;
282
+ onLog;
283
+ idleMs;
284
+ released = false;
285
+ entry;
286
+ constructor(key, bin, model, onLog, idleMs) {
287
+ this.key = key;
288
+ this.bin = bin;
289
+ this.model = model;
290
+ this.onLog = onLog;
291
+ this.idleMs = idleMs;
292
+ this.entry = claimEntry(key, bin, model, onLog, idleMs);
293
+ }
294
+ /** First claim on a key spawns; later ones join the start already under way. */
295
+ static acquire(bin, model, onLog, idleMs) {
296
+ return new SharedSidecar(`${bin}\n${model}`, bin, model, onLog, idleMs);
297
+ }
298
+ /** The shared start; the engine releases its claim when this rejects. */
299
+ started() {
300
+ return this.entry.started;
301
+ }
302
+ get base() {
303
+ return this.entry.sidecar.base;
304
+ }
305
+ /**
306
+ * The per-embed heartbeat on the registry: a reaped entry is swapped for a
307
+ * live claim and the idle clock restarts before the health gate runs.
308
+ */
309
+ async waitReady() {
310
+ const entry = this.liveEntry();
311
+ touchIdle(this.key, entry);
312
+ await entry.started;
313
+ await entry.sidecar.waitReady();
314
+ }
315
+ /** The claim's entry while the registry holds it; a fresh claim once it was evicted. */
316
+ liveEntry() {
317
+ if (!this.released && sharedSidecars.get(this.key) !== this.entry) {
318
+ this.entry = claimEntry(this.key, this.bin, this.model, this.onLog, this.idleMs);
319
+ }
320
+ return this.entry;
321
+ }
322
+ async stop() {
323
+ if (this.released)
324
+ return;
325
+ this.released = true;
326
+ const entry = this.entry;
327
+ entry.refs--;
328
+ // A reaped entry was already evicted and stopped; only the claim is left.
329
+ if (entry.refs > 0 || entry.reaped)
330
+ return;
331
+ if (entry.idleTimer !== null) {
332
+ clearTimeout(entry.idleTimer);
333
+ entry.idleTimer = null;
334
+ }
335
+ if (sharedSidecars.get(this.key) === entry)
336
+ sharedSidecars.delete(this.key);
337
+ await entry.sidecar.stop();
338
+ }
339
+ }
207
340
  /**
208
341
  * The queue's handle on embeddings. `embed` waits out a restart for up to
209
342
  * START_TIMEOUT_MS, then fails the batch — the queue retries, so a dying
@@ -225,9 +358,17 @@ export class EmbedEngine {
225
358
  if (bin === null) {
226
359
  throw new WazapError("RECALL_UNAVAILABLE", "llama-server not found; semantic recall needs llama.cpp", llamaInstallFix());
227
360
  }
228
- const sidecar = new LlamaSidecar(bin, embedModelPath(settings.modelsDir, spec), onLog);
229
- await sidecar.start();
230
- return new EmbedEngine(sidecar, spec);
361
+ // Accounts on the same binary and model share one server: acquire bumps
362
+ // the registry's refcount, this engine's stop() hands just this claim back.
363
+ const target = SharedSidecar.acquire(bin, embedModelPath(settings.modelsDir, spec), onLog, settings.embedIdleMs);
364
+ try {
365
+ await target.started();
366
+ }
367
+ catch (err) {
368
+ await target.stop();
369
+ throw err;
370
+ }
371
+ return new EmbedEngine(target, spec);
231
372
  }
232
373
  /**
233
374
  * `kind` picks the model's task prefix: the index holds "document" texts,
@@ -253,7 +394,11 @@ export class EmbedEngine {
253
394
  }
254
395
  if (!response.ok) {
255
396
  const body = (await response.text().catch(() => "")).slice(0, 300);
256
- throw new WazapError("RECALL_FAILED", `embedding server answered ${response.status}: ${body}`);
397
+ // A 4xx means the input itself is unembeddable — over the model's
398
+ // context, malformed — and no retry will change that, so the queue
399
+ // treats it differently from a sick backend.
400
+ const code = response.status >= 400 && response.status < 500 ? "RECALL_BAD_INPUT" : "RECALL_FAILED";
401
+ throw new WazapError(code, `embedding server answered ${response.status}: ${body}`);
257
402
  }
258
403
  const reply = (await response.json());
259
404
  if (!Array.isArray(reply)) {
@@ -24,6 +24,11 @@ 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
+ maxChars: 2048,
28
+ // Measured on a real index under those prompts: noise tops out ~0.31,
29
+ // real paraphrases start ~0.35.
30
+ defaultMinSimilarity: 0.35,
31
+ floorCalibrated: true,
27
32
  },
28
33
  "e5-base-multilingual": {
29
34
  alias: "e5-base-multilingual",
@@ -34,6 +39,17 @@ export const EMBED_MODELS = {
34
39
  url: "https://huggingface.co/dinab/multilingual-e5-base-Q8_0-GGUF/resolve/main/multilingual-e5-base-q8_0.gguf",
35
40
  // e5's documented asymmetric prefixes.
36
41
  prompts: { query: "query: ", document: "passage: " },
42
+ // e5's context window is 512 tokens; the cap leaves headroom for the
43
+ // document prefix and diacritic-heavy text.
44
+ maxChars: 800,
45
+ // Measured on a real 12k-message index: noise tops out ~0.84 while real
46
+ // paraphrases run 0.82-0.88 — the bands overlap, so no floor separates
47
+ // cleanly. 0.85 deliberately errs toward silence: a missed hit answers
48
+ // "nothing found", a false hit is a wrong memory an agent will repeat.
49
+ // Gemma stays the recommended model; e5 remains the fallback for a
50
+ // llama.cpp too old for gemma embeddings.
51
+ defaultMinSimilarity: 0.85,
52
+ floorCalibrated: true,
37
53
  },
38
54
  };
39
55
  export function embedModelSpec(name) {
@@ -10,6 +10,7 @@
10
10
  * by the put it deletes; inside one batch the last op per sid wins.
11
11
  */
12
12
  import { setTimeout as sleep } from "node:timers/promises";
13
+ import { WazapError } from "../errors.js";
13
14
  import { logError } from "../logger.js";
14
15
  /** Texts per embedding request; llama.cpp pools them in one pass. */
15
16
  const BATCH = 32;
@@ -167,10 +168,33 @@ export class RecallQueue {
167
168
  const puts = ops.flatMap((op) => (op.item === undefined ? [] : [op.item]));
168
169
  if (dels.length > 0)
169
170
  await this.store.remove(dels);
171
+ await this.commitPuts(puts);
172
+ }
173
+ /**
174
+ * A 4xx from the embed server names the input, not the backend — one text
175
+ * over the model's context window fails the whole request and would retry
176
+ * forever. The batch is bisected until only the bad text is left, and that
177
+ * single message is dropped: the index loses one unembeddable entry rather
178
+ * than dying behind a poison pill that every restart would re-feed.
179
+ */
180
+ async commitPuts(puts) {
170
181
  if (puts.length === 0)
171
182
  return;
172
- const vectors = await this.embed(puts.map((item) => item.text));
173
- await this.store.add(puts, vectors);
183
+ try {
184
+ const vectors = await this.embed(puts.map((item) => item.text));
185
+ await this.store.add(puts, vectors);
186
+ }
187
+ catch (err) {
188
+ if (!(err instanceof WazapError && err.code === "RECALL_BAD_INPUT"))
189
+ throw err;
190
+ if (puts.length === 1) {
191
+ logError(`recall index: dropping unembeddable message ${puts[0].sid}`, err);
192
+ return;
193
+ }
194
+ const mid = Math.ceil(puts.length / 2);
195
+ await this.commitPuts(puts.slice(0, mid));
196
+ await this.commitPuts(puts.slice(mid));
197
+ }
174
198
  }
175
199
  async applySeals() {
176
200
  for (const [file, seal] of this.seals) {
@@ -6,18 +6,13 @@
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;
15
+ const DEFAULT_EMBED_IDLE_MINUTES = 30;
21
16
  function parseEnabled(raw) {
22
17
  const value = stripPasted(raw ?? "").toLowerCase();
23
18
  if (OFF.has(value))
@@ -43,15 +38,28 @@ function parseMaxRows(raw) {
43
38
  return n;
44
39
  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
40
  }
46
- function parseMinSimilarity(raw) {
41
+ /**
42
+ * The env wins over the model's own floor — a cosine that means "real match"
43
+ * is the model's to price, the override is the user's.
44
+ */
45
+ function parseMinSimilarity(raw, fallback) {
47
46
  const value = stripPasted(raw ?? "");
48
47
  if (value === "")
49
- return DEFAULT_MIN_SIMILARITY;
48
+ return fallback;
50
49
  const n = Number(value);
51
50
  if (Number.isFinite(n) && n >= 0 && n <= 1)
52
51
  return n;
53
52
  throw new WazapError("INVALID_ID", `WAZAP_RECALL_MIN_SIMILARITY must be a number between 0 and 1, got "${value}".`, "Fix WAZAP_RECALL_MIN_SIMILARITY or remove it");
54
53
  }
54
+ function parseEmbedIdle(raw) {
55
+ const value = stripPasted(raw ?? "");
56
+ if (value === "")
57
+ return DEFAULT_EMBED_IDLE_MINUTES * 60_000;
58
+ const minutes = Number(value);
59
+ if (Number.isFinite(minutes) && minutes >= 0)
60
+ return Math.round(minutes * 60_000);
61
+ throw new WazapError("INVALID_ID", `WAZAP_EMBED_IDLE_MINUTES must be a number of minutes >= 0, got "${value}".`, "Fix WAZAP_EMBED_IDLE_MINUTES or remove it");
62
+ }
55
63
  function parseUrl(raw) {
56
64
  const value = stripPasted(raw ?? "");
57
65
  if (value === "")
@@ -68,13 +76,15 @@ function parseUrl(raw) {
68
76
  }
69
77
  export function readRecallSettings(env, dataDir) {
70
78
  const embedBin = stripPasted(env.WAZAP_EMBED_BIN ?? "");
79
+ const model = parseModel(env.WAZAP_EMBED_MODEL);
71
80
  return {
72
81
  enabled: parseEnabled(env.WAZAP_RECALL),
73
- model: parseModel(env.WAZAP_EMBED_MODEL),
82
+ model,
74
83
  embedBin: embedBin === "" ? null : embedBin,
75
84
  embedUrl: parseUrl(env.WAZAP_EMBED_URL),
76
85
  modelsDir: join(dataDir, "models"),
86
+ embedIdleMs: parseEmbedIdle(env.WAZAP_EMBED_IDLE_MINUTES),
77
87
  maxRows: parseMaxRows(env.WAZAP_RECALL_MAX),
78
- minSimilarity: parseMinSimilarity(env.WAZAP_RECALL_MIN_SIMILARITY),
88
+ minSimilarity: parseMinSimilarity(env.WAZAP_RECALL_MIN_SIMILARITY, EMBED_MODELS[model].defaultMinSimilarity),
79
89
  };
80
90
  }
@@ -29,6 +29,18 @@ const DIR_MODE = 0o700;
29
29
  const FILE_MODE = 0o600;
30
30
  /** Owner call: fresh matches rank first. 0.5^(age/half-life) scales similarity. */
31
31
  const RECENCY_HALF_LIFE_MS = 30 * 24 * 60 * 60 * 1000;
32
+ /** A literal query token shorter than this is too common to name anything. */
33
+ const MIN_TOKEN_LEN = 4;
34
+ /** What one matched rare token adds, before its document frequency scales it down. */
35
+ const TOKEN_BOOST = 0.02;
36
+ /** All token bonuses together may add at most this — a neighbour reorder, never a rescue. */
37
+ const TOKEN_BOOST_MAX = 0.05;
38
+ /** Reranking costs a text pass per candidate; only the top of the list gets it. */
39
+ const RERANK_WINDOW = 100;
40
+ /** One chat holds at most this many leading slots; further hits yield to other chats first. */
41
+ const CHAT_SLOT_CAP = 3;
42
+ /** Word overlap at or above this marks a candidate a near-duplicate of a picked hit. */
43
+ const NEAR_DUP_JACCARD = 0.8;
32
44
  function normalize(vector) {
33
45
  let norm = 0;
34
46
  for (const x of vector)
@@ -52,6 +64,39 @@ function quantize(vector, dims) {
52
64
  function recencyDecay(ageMs) {
53
65
  return Math.pow(0.5, Math.max(0, ageMs) / RECENCY_HALF_LIFE_MS);
54
66
  }
67
+ /** Case- and diacritic-insensitive fold, so "Cata" and "cată" are one token. */
68
+ function fold(text) {
69
+ return text
70
+ .toLowerCase()
71
+ .normalize("NFD")
72
+ .replace(/[\u0300-\u036f]/g, "");
73
+ }
74
+ function textProfile(text) {
75
+ const folded = fold(text);
76
+ const words = new Set();
77
+ const literals = new Set();
78
+ for (const match of folded.matchAll(/[\p{L}\p{N}]+/gu)) {
79
+ words.add(match[0]);
80
+ if (match[0].length >= MIN_TOKEN_LEN)
81
+ literals.add(match[0]);
82
+ }
83
+ for (const chunk of folded.split(/\s+/)) {
84
+ const compact = chunk.replace(/[^\p{L}\p{N}]+/gu, "");
85
+ if (compact.length >= MIN_TOKEN_LEN)
86
+ literals.add(compact);
87
+ }
88
+ return { norm: folded.replace(/\s+/g, " ").trim(), words, literals };
89
+ }
90
+ /** Word Jaccard — two texts sharing most of their words are one answer. */
91
+ function nearDuplicate(a, b) {
92
+ if (a.size === 0 || b.size === 0)
93
+ return false;
94
+ let shared = 0;
95
+ for (const token of a)
96
+ if (b.has(token))
97
+ shared++;
98
+ return shared / (a.size + b.size - shared) >= NEAR_DUP_JACCARD;
99
+ }
55
100
  export class RecallStore {
56
101
  dir;
57
102
  spec;
@@ -331,7 +376,9 @@ export class RecallStore {
331
376
  /**
332
377
  * Brute-force cosine over live rows, filtered the way search_messages
333
378
  * filters, then ranked by similarity × recency decay. Exact, zero-dep and
334
- * fast enough at living-memory sizes (50k × 768d int8).
379
+ * fast enough at living-memory sizes (50k × 768d int8). The floor applies
380
+ * to raw similarity — rerank() then reorders the survivors, never rescues
381
+ * what the floor dropped.
335
382
  */
336
383
  query(q, nowMs = Date.now()) {
337
384
  const unit = normalize(q.vector);
@@ -356,7 +403,74 @@ export class RecallStore {
356
403
  hits.push({ record, similarity, score: similarity * recencyDecay(nowMs - record.ts) });
357
404
  }
358
405
  hits.sort((a, b) => b.score - a.score);
359
- return hits.slice(0, q.limit);
406
+ return this.rerank(hits, q);
407
+ }
408
+ /**
409
+ * The bounded reordering pass over the strongest candidates, run after the
410
+ * similarity floor has already decided what counts as an answer.
411
+ *
412
+ * First, literal tokens: embeddings are weak on names and identifiers, so a
413
+ * query token a hit carries verbatim earns a small bonus. A token's weight
414
+ * fades with its document frequency in the candidate set — a token in every
415
+ * candidate is common and adds nothing — and the total is capped, so the
416
+ * bonus reorders near-equal scores and can never lift a weak hit over a
417
+ * strong semantic one.
418
+ *
419
+ * Then diversity: a greedy walk picks in score order, but a near-duplicate
420
+ * of a picked hit, or a hit from a chat that already holds CHAT_SLOT_CAP
421
+ * slots, trails the list instead of filling it. Nothing is dropped and no
422
+ * score is invented — demoted hits keep their score and sit behind the
423
+ * picked ones, so a query scoped to a single chat comes back unchanged.
424
+ */
425
+ rerank(sorted, q) {
426
+ // A raw caller may omit limit; then the window itself is the cap.
427
+ const cap = Number.isFinite(q.limit) ? q.limit : sorted.length;
428
+ const candidates = sorted.slice(0, Math.max(cap, RERANK_WINDOW)).map((hit) => ({ hit, ...textProfile(hit.record.text) }));
429
+ if (candidates.length === 0)
430
+ return [];
431
+ const wanted = q.text === undefined ? new Set() : textProfile(q.text).literals;
432
+ if (wanted.size > 0) {
433
+ const df = new Map();
434
+ for (const token of wanted) {
435
+ let count = 0;
436
+ for (const c of candidates)
437
+ if (c.literals.has(token))
438
+ count++;
439
+ if (count > 0 && count < candidates.length)
440
+ df.set(token, count);
441
+ }
442
+ if (df.size > 0) {
443
+ for (const c of candidates) {
444
+ let bonus = 0;
445
+ for (const [token, count] of df) {
446
+ if (c.literals.has(token))
447
+ bonus += TOKEN_BOOST * (1 - count / candidates.length);
448
+ }
449
+ c.hit = { ...c.hit, score: c.hit.score + Math.min(TOKEN_BOOST_MAX, bonus) };
450
+ }
451
+ candidates.sort((a, b) => b.hit.score - a.hit.score);
452
+ }
453
+ }
454
+ const picked = [];
455
+ const overflow = [];
456
+ const dups = [];
457
+ const perChat = new Map();
458
+ const chosen = [];
459
+ for (const c of candidates) {
460
+ if (chosen.some((p) => p.norm === c.norm || nearDuplicate(p.words, c.words))) {
461
+ dups.push(c.hit);
462
+ continue;
463
+ }
464
+ const held = perChat.get(c.hit.record.jid) ?? 0;
465
+ if (held >= CHAT_SLOT_CAP) {
466
+ overflow.push(c.hit);
467
+ continue;
468
+ }
469
+ perChat.set(c.hit.record.jid, held + 1);
470
+ picked.push(c.hit);
471
+ chosen.push(c);
472
+ }
473
+ return [...picked, ...overflow, ...dups].slice(0, cap);
360
474
  }
361
475
  async maybeCompact() {
362
476
  if (this.nextRow === 0 || this.deadRows <= this.nextRow * COMPACT_DEAD_RATIO)
package/dist/server.js CHANGED
@@ -2,12 +2,8 @@ import { randomUUID, timingSafeEqual } from "node:crypto";
2
2
  import { createServer } from "node:http";
3
3
  import { createConnection } from "node:net";
4
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
- import { mcpAuthRouter, getOAuthProtectedResourceMetadataUrl } from "@modelcontextprotocol/sdk/server/auth/router.js";
6
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
- import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
8
6
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
9
- import express from "express";
10
- import { rateLimit } from "express-rate-limit";
11
7
  import { WAZAP_VERSION, paths, writesHints } from "./config.js";
12
8
  import { APPROVE_PATH, OAUTH_SCOPES, WazapOAuthProvider } from "./oauth.js";
13
9
  import { loadSkills, registerSkillPrompts, skillInstructions } from "./skills.js";
@@ -108,6 +104,9 @@ async function listenHttp(server, host, port) {
108
104
  }
109
105
  /** Serve /mcp and /healthz on one address. Resolves with the bound port, so port 0 works. */
110
106
  export async function startHttpEndpoint(hub, config, endpoint) {
107
+ // express is only needed once a listener is actually bound; a stdio server
108
+ // with sharing off and a bridge onto a running daemon never reach here.
109
+ const { default: express } = await import("express");
111
110
  const app = express();
112
111
  app.use(express.json());
113
112
  app.use((req, res, next) => {
@@ -131,7 +130,14 @@ export async function startHttpEndpoint(hub, config, endpoint) {
131
130
  const oauth = endpoint.oauth;
132
131
  // A server that advertises sign-in must not also answer strangers.
133
132
  const openRead = endpoint.openRead && !oauth;
133
+ let resourceMetadataUrl = null;
134
134
  if (oauth) {
135
+ // The OAuth stack (the SDK's auth router and its own limiter) is the one
136
+ // part of this endpoint only a public server pays for, so it loads here.
137
+ const [{ mcpAuthRouter, getOAuthProtectedResourceMetadataUrl }, { rateLimit }] = await Promise.all([
138
+ import("@modelcontextprotocol/sdk/server/auth/router.js"),
139
+ import("express-rate-limit"),
140
+ ]);
135
141
  // Reached through a TLS proxy: on this machine, or the Docker bridge when
136
142
  // the container binds 0.0.0.0. The proxy's idea of the caller is the one
137
143
  // the password lockout and the SDK's limiters should count.
@@ -149,8 +155,8 @@ export async function startHttpEndpoint(hub, config, endpoint) {
149
155
  }));
150
156
  app.post(APPROVE_PATH, rateLimit({ windowMs: 15 * 60 * 1000, limit: 30, standardHeaders: true, legacyHeaders: false }), express.urlencoded({ extended: false }), oauth.approve);
151
157
  log(`OAuth on: agents sign in at ${oauth.issuerUrl.href}`);
158
+ resourceMetadataUrl = getOAuthProtectedResourceMetadataUrl(oauth.resourceUrl);
152
159
  }
153
- const resourceMetadataUrl = oauth ? getOAuthProtectedResourceMetadataUrl(oauth.resourceUrl) : null;
154
160
  // The first credential the bearer token matches decides the session's tools,
155
161
  // so a leaked read token can never message anyone. An OAuth token carries
156
162
  // the scope the person picked on the consent page.
@@ -196,6 +202,9 @@ export async function startHttpEndpoint(hub, config, endpoint) {
196
202
  const sessionId = req.headers["mcp-session-id"];
197
203
  let transport = typeof sessionId === "string" ? transports.get(sessionId) : undefined;
198
204
  if (!transport && req.method === "POST" && isInitializeRequest(req.body)) {
205
+ // Session state is only needed when a client actually posts initialize;
206
+ // loading it here keeps it off the bind path that daemon.json waits on.
207
+ const { StreamableHTTPServerTransport } = await import("@modelcontextprotocol/sdk/server/streamableHttp.js");
199
208
  const newTransport = new StreamableHTTPServerTransport({
200
209
  sessionIdGenerator: () => randomUUID(),
201
210
  onsessioninitialized: (sid) => {
package/dist/tools.js CHANGED
@@ -500,7 +500,9 @@ search_messages to see. For an exact string — an id, a phone number, a URL —
500
500
  search_messages is the better tool.
501
501
 
502
502
  Each result carries its date and a score: semantic similarity scaled by
503
- recency, so fresh matches rank first. chat_id, since, until and from narrow
503
+ recency, plus a small bonus when the hit repeats a rare query token
504
+ verbatim — a name, a number — so fresh and exact matches rank first.
505
+ chat_id, since, until and from narrow
504
506
  the search exactly like search_messages. A hit marked "index only" lives in
505
507
  the index alone: quote it, but get_message and download_media cannot see it.
506
508
  Results under the similarity floor are dropped rather than listed; when only
package/dist/whatsapp.js CHANGED
@@ -627,7 +627,16 @@ export class WhatsAppService {
627
627
  const [vector] = await this.recallEmbed([query], "query");
628
628
  const minSimilarity = this.recallEnv instanceof WazapError ? undefined : this.recallEnv.minSimilarity;
629
629
  const hits = store
630
- .query({ vector: vector, chatId: scope, sinceMs: opts.sinceMs, untilMs: opts.untilMs, from, minSimilarity, limit })
630
+ .query({
631
+ vector: vector,
632
+ text: query,
633
+ chatId: scope,
634
+ sinceMs: opts.sinceMs,
635
+ untilMs: opts.untilMs,
636
+ from,
637
+ minSimilarity,
638
+ limit,
639
+ })
631
640
  .map((hit) => {
632
641
  const live = this.store.messages.has(hit.record.sid);
633
642
  return {
@@ -1248,8 +1257,9 @@ export class WhatsAppService {
1248
1257
  }
1249
1258
  }
1250
1259
  /**
1251
- * The sidecar, started on the first embedding request. A failed start is not
1252
- * cached the next queued batch tries again.
1260
+ * The sidecar, started on the first embedding request and shared with every
1261
+ * other account in the process on the same binary and model. A failed start
1262
+ * is not cached — the next queued batch tries again.
1253
1263
  */
1254
1264
  recallEngine() {
1255
1265
  if (this.stopped)
@@ -1289,8 +1299,10 @@ export class WhatsAppService {
1289
1299
  if (text !== null) {
1290
1300
  // Capped here, not only in the store, so the feed diff compares the text
1291
1301
  // the index would actually keep — an over-cap message is not fresh work
1292
- // on every boot.
1293
- const capped = text.slice(0, RECALL_TEXT_CAP);
1302
+ // on every boot. The cap is the model's: e5's 512-token window takes
1303
+ // far less text than gemma's.
1304
+ const maxChars = this.recallEnv instanceof WazapError ? RECALL_TEXT_CAP : EMBED_MODELS[this.recallEnv.model].maxChars;
1305
+ const capped = text.slice(0, maxChars);
1294
1306
  ops.push({
1295
1307
  sid,
1296
1308
  item: { sid, jid, ts: messageTimestampMs(raw), sender: this.recallSender(raw, jid), type: messageType(raw), text: capped },
@@ -1390,9 +1402,10 @@ export class WhatsAppService {
1390
1402
  return { state: pending > 0 ? "indexing" : "ready", indexed, pending };
1391
1403
  }
1392
1404
  /**
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.
1405
+ * Queue first, then the enginereleasing its claim on the shared sidecar
1406
+ * unblocks an embedding call in flight — then the store, whose own write
1407
+ * queue drains before it closes. An engine still coming up is released
1408
+ * whenever its start resolves.
1396
1409
  */
1397
1410
  async stopRecall() {
1398
1411
  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.4",
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",