wazap-mcp 0.18.0 → 0.18.2

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/dist/messages.js CHANGED
@@ -399,9 +399,9 @@ export function searchableText(raw, transcript) {
399
399
  if (type === "reaction" || type === "deleted" || type === "system")
400
400
  return null;
401
401
  const own = rule.text?.(node)?.trim() || rule.caption?.(node)?.trim() || rule.detail?.(node)?.trim() || "";
402
- // No letter or digit means no words to embed — a "🥰🥰" or "..." only adds
403
- // noise that outranks real hits on short queries.
404
- if (!/[\p{L}\p{N}]/u.test(own) && spoken === undefined)
402
+ // Under five letters or digits there is no meaning to embed — a "🥰🥰", a
403
+ // "Da" or a "..." only adds noise that outranks real hits on short queries.
404
+ if ((own.match(/[\p{L}\p{N}]/gu)?.length ?? 0) < 5 && spoken === undefined)
405
405
  return null;
406
406
  return viewText(raw, transcript);
407
407
  }
@@ -211,13 +211,15 @@ class UrlBackend {
211
211
  */
212
212
  export class EmbedEngine {
213
213
  target;
214
- constructor(target) {
214
+ spec;
215
+ constructor(target, spec) {
215
216
  this.target = target;
217
+ this.spec = spec;
216
218
  }
217
219
  static async start(settings, spec, onLog = () => { }) {
218
220
  if (settings.embedUrl !== null) {
219
221
  const base = settings.embedUrl.replace(/\/+$/, "");
220
- return new EmbedEngine(new UrlBackend(base));
222
+ return new EmbedEngine(new UrlBackend(base), spec);
221
223
  }
222
224
  const bin = findLlama(settings);
223
225
  if (bin === null) {
@@ -225,18 +227,24 @@ export class EmbedEngine {
225
227
  }
226
228
  const sidecar = new LlamaSidecar(bin, embedModelPath(settings.modelsDir, spec), onLog);
227
229
  await sidecar.start();
228
- return new EmbedEngine(sidecar);
230
+ return new EmbedEngine(sidecar, spec);
229
231
  }
230
- async embed(texts) {
232
+ /**
233
+ * `kind` picks the model's task prefix: the index holds "document" texts,
234
+ * searches embed "query". The prefix is the model's side of a retrieval
235
+ * pair, not part of what the index stores.
236
+ */
237
+ async embed(texts, kind) {
231
238
  if (texts.length === 0)
232
239
  return [];
233
240
  await this.target.waitReady();
241
+ const input = texts.map((text) => `${this.spec.prompts[kind]}${text}`);
234
242
  let response;
235
243
  try {
236
244
  response = await fetch(`${this.target.base}${EMBED_PATH}`, {
237
245
  method: "POST",
238
246
  headers: { "content-type": "application/json" },
239
- body: JSON.stringify({ content: texts.length === 1 ? texts[0] : texts }),
247
+ body: JSON.stringify({ content: input.length === 1 ? input[0] : input }),
240
248
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
241
249
  });
242
250
  }
@@ -251,7 +259,7 @@ export class EmbedEngine {
251
259
  if (!Array.isArray(reply)) {
252
260
  throw new WazapError("RECALL_FAILED", `embedding server refused: ${reply.error?.message ?? "bad reply"}`);
253
261
  }
254
- if (reply.length !== texts.length) {
262
+ if (reply.length !== input.length) {
255
263
  throw new WazapError("RECALL_FAILED", `embedding server returned ${reply.length} vectors for ${texts.length} texts`);
256
264
  }
257
265
  return reply.map((item, i) => {
@@ -22,6 +22,8 @@ export const EMBED_MODELS = {
22
22
  sha256: "b5ce9d77a3fc4b3b39ccb5643c36777911cc4eb46a66962eadfa3f5f60490d63",
23
23
  dims: 768,
24
24
  url: "https://huggingface.co/ggml-org/embeddinggemma-300M-GGUF/resolve/main/embeddinggemma-300M-Q8_0.gguf",
25
+ // EmbeddingGemma's own retrieval task, from its model card.
26
+ prompts: { query: "task: search result | query: ", document: "title: none | text: " },
25
27
  },
26
28
  "e5-base-multilingual": {
27
29
  alias: "e5-base-multilingual",
@@ -30,6 +32,8 @@ export const EMBED_MODELS = {
30
32
  sha256: "548c31b068947aa26b86c8bbfc1f2fabe5233f6d0e1241319832b20a01e5968a",
31
33
  dims: 768,
32
34
  url: "https://huggingface.co/dinab/multilingual-e5-base-Q8_0-GGUF/resolve/main/multilingual-e5-base-q8_0.gguf",
35
+ // e5's documented asymmetric prefixes.
36
+ prompts: { query: "query: ", document: "passage: " },
33
37
  },
34
38
  };
35
39
  export function embedModelSpec(name) {
@@ -11,6 +11,13 @@ const ON = new Set(["local", "on", "1", "yes", "true"]);
11
11
  const MODEL_ALIASES = ["embeddinggemma-300m", "e5-base-multilingual"];
12
12
  const DEFAULT_MAX_ROWS = 50_000;
13
13
  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;
14
21
  function parseEnabled(raw) {
15
22
  const value = stripPasted(raw ?? "").toLowerCase();
16
23
  if (OFF.has(value))
@@ -36,6 +43,15 @@ function parseMaxRows(raw) {
36
43
  return n;
37
44
  throw new WazapError("INVALID_ID", `WAZAP_RECALL_MAX must be a number >= ${MIN_MAX_ROWS}, got "${value}".`, "Fix WAZAP_RECALL_MAX or remove it");
38
45
  }
46
+ function parseMinSimilarity(raw) {
47
+ const value = stripPasted(raw ?? "");
48
+ if (value === "")
49
+ return DEFAULT_MIN_SIMILARITY;
50
+ const n = Number(value);
51
+ if (Number.isFinite(n) && n >= 0 && n <= 1)
52
+ return n;
53
+ 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
+ }
39
55
  function parseUrl(raw) {
40
56
  const value = stripPasted(raw ?? "");
41
57
  if (value === "")
@@ -59,5 +75,6 @@ export function readRecallSettings(env, dataDir) {
59
75
  embedUrl: parseUrl(env.WAZAP_EMBED_URL),
60
76
  modelsDir: join(dataDir, "models"),
61
77
  maxRows: parseMaxRows(env.WAZAP_RECALL_MAX),
78
+ minSimilarity: parseMinSimilarity(env.WAZAP_RECALL_MIN_SIMILARITY),
62
79
  };
63
80
  }
@@ -21,7 +21,8 @@ import { WazapError } from "../errors.js";
21
21
  export const TEXT_CAP = 2048;
22
22
  /** Rewrite meta+vectors when more than this share of rows is dead. */
23
23
  const COMPACT_DEAD_RATIO = 0.3;
24
- const STATE_VERSION = 1;
24
+ /** v2: model task prompts — a raw-embedded index belongs to a different world. */
25
+ const STATE_VERSION = 2;
25
26
  const QUANT = "int8";
26
27
  /** The index holds message text; it gets history's permissions, not the defaults. */
27
28
  const DIR_MODE = 0o700;
@@ -334,6 +335,7 @@ export class RecallStore {
334
335
  */
335
336
  query(q, nowMs = Date.now()) {
336
337
  const unit = normalize(q.vector);
338
+ const floor = q.minSimilarity ?? 0;
337
339
  const hits = [];
338
340
  for (const record of this.live.values()) {
339
341
  if (q.chatId !== undefined && record.jid !== q.chatId)
@@ -349,7 +351,7 @@ export class RecallStore {
349
351
  for (let i = 0; i < this.spec.dims; i++)
350
352
  dot += unit[i] * this.vectors[offset + i];
351
353
  const similarity = dot / 127;
352
- if (similarity <= 0)
354
+ if (similarity <= 0 || similarity < floor)
353
355
  continue;
354
356
  hits.push({ record, similarity, score: similarity * recencyDecay(nowMs - record.ts) });
355
357
  }
package/dist/tools.js CHANGED
@@ -503,6 +503,8 @@ Each result carries its date and a score: semantic similarity scaled by
503
503
  recency, so fresh matches rank first. chat_id, since, until and from narrow
504
504
  the search exactly like search_messages. A hit marked "index only" lives in
505
505
  the index alone: quote it, but get_message and download_media cannot see it.
506
+ Results under the similarity floor are dropped rather than listed; when only
507
+ weak matches survive, the output says so — do not present them as found facts.
506
508
 
507
509
  RECALL_UNAVAILABLE means recall is off or the embedding setup is missing; the
508
510
  fix names the command the user has to run. Do not retry it.`,
@@ -1121,7 +1123,13 @@ function renderRecall(title, answer) {
1121
1123
  if (hits.length === 0) {
1122
1124
  return `${title}: no messages found.${catchingUp ? ` ${catchingUp}` : ""}`;
1123
1125
  }
1126
+ // Under ~0.55 cosine, embeddinggemma matches are usually coincidental — the
1127
+ // agent must not present them as found facts.
1128
+ const weak = Math.max(...hits.map((h) => h.similarity)) < 0.55;
1124
1129
  const lines = [`# ${title} (${hits.length})`, ""];
1130
+ if (weak) {
1131
+ lines.push(`Weak matches only (best similarity ${Math.max(...hits.map((h) => h.similarity)).toFixed(2)}): the query may have no real answer — treat these as guesses.`, "");
1132
+ }
1125
1133
  if (catchingUp)
1126
1134
  lines.push(catchingUp, "");
1127
1135
  const introduced = new Set();
package/dist/whatsapp.js CHANGED
@@ -624,9 +624,10 @@ export class WhatsAppService {
624
624
  const store = this.readyRecall();
625
625
  const scope = chatId === undefined ? undefined : this.resolveId(chatId);
626
626
  const from = opts.from === undefined ? undefined : opts.from === "me" ? this.ownJid() : this.resolveId(opts.from);
627
- const [vector] = await this.recallEmbed([query]);
627
+ const [vector] = await this.recallEmbed([query], "query");
628
+ const minSimilarity = this.recallEnv instanceof WazapError ? undefined : this.recallEnv.minSimilarity;
628
629
  const hits = store
629
- .query({ vector: vector, chatId: scope, sinceMs: opts.sinceMs, untilMs: opts.untilMs, from, limit })
630
+ .query({ vector: vector, chatId: scope, sinceMs: opts.sinceMs, untilMs: opts.untilMs, from, minSimilarity, limit })
630
631
  .map((hit) => {
631
632
  const live = this.store.messages.has(hit.record.sid);
632
633
  return {
@@ -1236,7 +1237,7 @@ export class WhatsAppService {
1236
1237
  try {
1237
1238
  const spec = EMBED_MODELS[this.recallEnv.model];
1238
1239
  this.recallStore = await RecallStore.open(join(this.paths.root, "recall"), spec, this.recallEnv.maxRows);
1239
- this.recallQueue = new RecallQueue(this.recallStore, (texts) => this.recallEmbed(texts));
1240
+ this.recallQueue = new RecallQueue(this.recallStore, (texts) => this.recallEmbed(texts, "document"));
1240
1241
  if (this.recallStore.count > 0)
1241
1242
  log(`recall index: ${this.recallStore.count} messages`);
1242
1243
  }
@@ -1269,9 +1270,9 @@ export class WhatsAppService {
1269
1270
  }
1270
1271
  return this.recallEngineP;
1271
1272
  }
1272
- async recallEmbed(texts) {
1273
+ async recallEmbed(texts, kind) {
1273
1274
  const engine = await this.recallEngine();
1274
- return engine.embed(texts);
1275
+ return engine.embed(texts, kind);
1275
1276
  }
1276
1277
  /**
1277
1278
  * The ops one raw message turns into: the tombstone a revoke carries for the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wazap-mcp",
3
- "version": "0.18.0",
3
+ "version": "0.18.2",
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",