wazap-mcp 0.17.0 → 0.18.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.
@@ -38,7 +38,7 @@ export function describeStatusAccount(row) {
38
38
  export function accountRows(config) {
39
39
  const registry = AccountRegistry.load(config.dataDir);
40
40
  return registry.all().map((record) => {
41
- let linked = null;
41
+ let linked;
42
42
  try {
43
43
  linked = readLinkedAccount(accountPaths(config.dataDir, record.id).authDir);
44
44
  }
@@ -136,7 +136,9 @@ export function renderGetStatus(s, writeTools, hub) {
136
136
  `- **contacts named**: ${s.contacts_named}`,
137
137
  `- **data dir**: ${s.data_dir} · **read-only**: ${s.read_only} · **write tools**: ${writeLine} · **rate limit**: ${s.rate_limit}/min`,
138
138
  `- **versions**: wazap ${s.wazap_version}, baileys ${s.baileys_version}`,
139
- s.pairing ? `- **pairing code**: ${s.pairing.code} for ${s.pairing.phone_masked}, until ${s.pairing.expires_at}` : null,
139
+ s.pairing
140
+ ? `- **pairing code**: ${s.pairing.code} for ${s.pairing.phone_masked}, until ${s.pairing.expires_at}`
141
+ : null,
140
142
  webhookStatusLine(s.webhook),
141
143
  s.last_error ? `- **last error**: ${s.last_error}` : null,
142
144
  s.hint ? `- **hint**: ${s.hint}` : null,
package/dist/cli.js CHANGED
@@ -23,13 +23,14 @@ import { lockHolder, releaseLock, writeLock } from "./lock.js";
23
23
  import { log, logError, say } from "./logger.js";
24
24
  import { clockLabel, formatAge } from "./messages.js";
25
25
  import { oauthProblem } from "./oauth.js";
26
+ import { downloadEmbed, embedModelSpec, readRecallSettings, } from "./recall/index.js";
26
27
  import { PAIRING_TIMEOUT_MS, linkSession, prettyCode, settledAccount, startPairing } from "./pairing.js";
27
28
  import { runHttp, runStdio, startLoopbackEndpoint } from "./server.js";
28
29
  import { fetchHealth, serviceHolding } from "./service.js";
29
30
  import { applyWrites } from "./settings.js";
30
31
  import { MODELS, downloadModel, maskKey, modelSpec, readTranscribeSettings, stripPasted, transcribeFile, transcribeReady, } from "./transcribe/index.js";
31
32
  import { bold, box, brand, humanLayout, dim, fail, fix, info, maskNumber, next, ok, openScreen, qrSavedLine, shortPath, spinner, tilde, warn, } from "./ui.js";
32
- import { loginWizardSteps, maybeWizard, wizDim, wizFail, wizInfo, wizOk, wizWarn, } from "./wizard.js";
33
+ import { loginWizardSteps, maybeWizard, wizDim, wizFail, wizInfo, wizOk, wizWarn } from "./wizard.js";
33
34
  import { WhatsAppService } from "./whatsapp.js";
34
35
  /** How long a probe waits for the socket to settle; tests shorten it through the environment. */
35
36
  const LIVE_TIMEOUT_MS = Number.parseInt(process.env.WAZAP_LIVE_TIMEOUT_MS ?? "", 10) || 15_000;
@@ -106,7 +107,9 @@ export async function runStatus(config) {
106
107
  /** Today's phrasing, kept verbatim so pipes and log captures keep parsing. */
107
108
  function plainStatus(report) {
108
109
  const lines = [`data dir: ${report.data_dir}`];
109
- const credsNote = report.credentials_readable ? "" : " (credentials unreadable — run `wazap logout` then `wazap login`)";
110
+ const credsNote = report.credentials_readable
111
+ ? ""
112
+ : " (credentials unreadable — run `wazap logout` then `wazap login`)";
110
113
  lines.push(`linked: ${report.linked ? "yes" : "no"}${credsNote}`);
111
114
  if (report.account)
112
115
  lines.push(`account: ${describeAccount(report.account)}`);
@@ -326,6 +329,32 @@ export async function downloadTranscribeModel(settings, spec) {
326
329
  throw err;
327
330
  }
328
331
  }
332
+ /** `wazap embed download`. */
333
+ export async function runEmbed(config) {
334
+ const [verb] = config.args;
335
+ if (verb !== "download") {
336
+ throw new WazapError("INVALID_ID", `Cannot run \`wazap embed ${config.args.join(" ")}\`.`, "Run `wazap embed download`");
337
+ }
338
+ await ensureDeps([DEPS.llama], config);
339
+ const settings = readRecallSettings(process.env, config.dataDir);
340
+ await downloadEmbedModel(settings, config.modelName);
341
+ }
342
+ /** The same check-then-fetch dance downloadTranscribeModel does, for the embed table. */
343
+ export async function downloadEmbedModel(settings, modelName) {
344
+ const spec = embedModelSpec(modelName ?? settings.model);
345
+ const spin = spinner(`Checking ${spec.file}…`);
346
+ try {
347
+ const result = await downloadEmbed(settings.modelsDir, spec, (progress) => {
348
+ const percent = Math.floor((progress.received / progress.total) * 100);
349
+ spin.update(`Downloading ${spec.file} — ${mib(progress.received)} / ${mib(progress.total)} MiB (${percent}%)`);
350
+ });
351
+ spin.stop(ok(`${spec.file} (${mib(spec.bytes)} MiB) ${result.alreadyPresent ? "already present" : "verified"}`));
352
+ }
353
+ catch (err) {
354
+ spin.stop();
355
+ throw err;
356
+ }
357
+ }
329
358
  /** What identifies each provider on screen. Keyed like PROVIDERS, never branched on. */
330
359
  const PROVIDER_ROWS = {
331
360
  local: (settings) => [
@@ -617,7 +646,10 @@ export async function linkAndSync(config, announce = () => { }, w = null) {
617
646
  announce("Link your phone");
618
647
  let account;
619
648
  try {
620
- account = phone === null ? await linkByQr(selected.paths, waiting, w) : await linkByCode(selected.paths.authDir, phone, waiting, w);
649
+ account =
650
+ phone === null
651
+ ? await linkByQr(selected.paths, waiting, w)
652
+ : await linkByCode(selected.paths.authDir, phone, waiting, w);
621
653
  }
622
654
  catch (err) {
623
655
  waiting.stop();
@@ -732,7 +764,8 @@ export async function yieldSession(config, lockFile, why = "pairing") {
732
764
  return () => { };
733
765
  const held = serviceHolding(config.dataDir, running);
734
766
  if (held === null) {
735
- throw leftoverRefusal(config) ?? new WazapError("WHATSAPP_ERROR", `wazap is running (pid ${running}).`, leftoverFix(running));
767
+ throw (leftoverRefusal(config) ??
768
+ new WazapError("WHATSAPP_ERROR", `wazap is running (pid ${running}).`, leftoverFix(running)));
736
769
  }
737
770
  say(info(`Stopping the wazap service for ${why}`));
738
771
  held.supervisor.stop(held.record);
package/dist/compact.js CHANGED
@@ -30,7 +30,13 @@ export function compactConversations(conversations) {
30
30
  last.message_ids.push(m.message_id);
31
31
  continue;
32
32
  }
33
- lines.push({ timestamp: m.timestamp, sender: m.sender.id, from_me: m.from_me, text: m.text, message_ids: [m.message_id] });
33
+ lines.push({
34
+ timestamp: m.timestamp,
35
+ sender: m.sender.id,
36
+ from_me: m.from_me,
37
+ text: m.text,
38
+ message_ids: [m.message_id],
39
+ });
34
40
  // The name rides on the line, once, for the renderer.
35
41
  lines[lines.length - 1].name = m.from_me
36
42
  ? "me"
@@ -40,7 +46,14 @@ export function compactConversations(conversations) {
40
46
  }
41
47
  if (lines.length === 0 && dropped.media === 0 && dropped.wordless === 0)
42
48
  continue;
43
- out.push({ chat_id: c.chat_id, chat_name: c.chat_name, type: c.type, ...(c.note ? { note: c.note } : {}), lines, dropped });
49
+ out.push({
50
+ chat_id: c.chat_id,
51
+ chat_name: c.chat_name,
52
+ type: c.type,
53
+ ...(c.note ? { note: c.note } : {}),
54
+ lines,
55
+ dropped,
56
+ });
44
57
  }
45
58
  return out;
46
59
  }
package/dist/config.js CHANGED
@@ -50,6 +50,7 @@ const COMMAND_ARGS = {
50
50
  // No positional means the first available provider; `off` takes the tunnel down.
51
51
  expose: [0, 1],
52
52
  transcribe: [1, 2],
53
+ embed: [1],
53
54
  update: [0],
54
55
  webhook: [1],
55
56
  account: [1, 2],
@@ -67,8 +68,9 @@ const COMMAND_USAGE = {
67
68
  skills: "Run `wazap skills install [<harness>]`",
68
69
  service: "Run `wazap service install|status|start|stop|restart|logs|uninstall`",
69
70
  transcribe: "Run `wazap transcribe download` or `wazap transcribe test <audio file>`",
71
+ embed: "Run `wazap embed download`",
70
72
  contacts: "Run `wazap contacts resync`",
71
- config: "Run `wazap config`, `wazap config writes on|off`, `wazap config transcribe local|openai|off`, or `wazap config webhook on|off`",
73
+ config: "Run `wazap config`, `wazap config writes on|off`, `wazap config transcribe local|openai|off`, `wazap config recall local|off`, or `wazap config webhook on|off`",
72
74
  webhook: "Run `wazap webhook test`",
73
75
  account: ACCOUNT_USAGE,
74
76
  migrate: MIGRATE_USAGE,
@@ -225,6 +227,7 @@ export function parseCli(argv = process.argv.slice(2)) {
225
227
  rateLimit: sourceOf("WAZAP_RATE_LIMIT", false),
226
228
  transcribe: sourceOf("WAZAP_TRANSCRIBE", false),
227
229
  webhook: sourceOf("WAZAP_WEBHOOK", false),
230
+ recall: sourceOf("WAZAP_RECALL", false),
228
231
  },
229
232
  command,
230
233
  explicitCommand: first !== undefined,
package/dist/connect.js CHANGED
@@ -135,8 +135,18 @@ export function whereInstalled(binPath = process.argv[1] ?? "", pathEnv = proces
135
135
  const script = binPath === "" ? "" : resolve(binPath);
136
136
  if (isNpxPath(binPath))
137
137
  return { kind: "npx", script };
138
- if (commandOnPath("wazap", pathEnv, exists))
139
- return { kind: "global", script };
138
+ const onPath = commandPath("wazap", pathEnv, exists);
139
+ if (onPath) {
140
+ try {
141
+ if (realpathSync(onPath) === realpathSync(script))
142
+ return { kind: "global", script };
143
+ }
144
+ catch {
145
+ // A package path or a direct launcher can still be classified when inspecting another host.
146
+ if (/[/\\]node_modules[/\\]wazap(?:-mcp)?[/\\]/.test(script) || resolve(onPath) === script)
147
+ return { kind: "global", script };
148
+ }
149
+ }
140
150
  return { kind: "checkout", script };
141
151
  }
142
152
  const GLOBAL_FIX = "run `npm i -g wazap-mcp` yourself (sudo on some Linux installs), then `wazap setup` again";
@@ -145,7 +155,12 @@ export function installGlobally(version = WAZAP_VERSION, npm = "npm") {
145
155
  const result = spawnSync(npm, ["install", "-g", `wazap-mcp@${version}`], { stdio: "inherit" });
146
156
  if (result.error !== undefined || result.status !== 0) {
147
157
  const detail = result.error === undefined ? `exit ${result.status ?? -1}` : result.error.message;
148
- return { name: "install", state: "fail", detail: `npm install -g wazap-mcp@${version} failed (${detail})`, fix: GLOBAL_FIX };
158
+ return {
159
+ name: "install",
160
+ state: "fail",
161
+ detail: `npm install -g wazap-mcp@${version} failed (${detail})`,
162
+ fix: GLOBAL_FIX,
163
+ };
149
164
  }
150
165
  return { name: "install", state: "ok", detail: `wazap-mcp@${version} installed globally` };
151
166
  }
@@ -224,8 +239,9 @@ export const GUI_PATH = "/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin";
224
239
  export function mcpEntry(config, spec, install = whereInstalled()) {
225
240
  const entry = entryFor(install);
226
241
  // The global `wazap` bin is a symlink into the package, and launchd's PATH has
227
- // neither it nor npx, so a GUI client gets this Node and the script behind it.
228
- if (spec.gui && entry.command === "wazap") {
242
+ // neither it, nor npx, nor the bare `node` a checkout entry would name, so a
243
+ // GUI client gets this Node and the script behind it.
244
+ if (spec.gui && (entry.command === "wazap" || install.kind === "checkout")) {
229
245
  entry.command = process.execPath;
230
246
  entry.args = [realpathSync(install.script)];
231
247
  }
@@ -244,7 +260,11 @@ export function launchCheck(spec, entry, pathEnv = GUI_PATH, exists, platform =
244
260
  if (platform !== "darwin")
245
261
  return { name: "launch", state: "info", detail: "not checked on this platform" };
246
262
  if (isAbsolute(entry.command) || commandOnPath(entry.command, pathEnv, exists)) {
247
- return { name: "launch", state: "ok", detail: `${spec.describe} can start \`${entry.command}\` without your shell PATH` };
263
+ return {
264
+ name: "launch",
265
+ state: "ok",
266
+ detail: `${spec.describe} can start \`${entry.command}\` without your shell PATH`,
267
+ };
248
268
  }
249
269
  return {
250
270
  name: "launch",
package/dist/deps.js CHANGED
@@ -12,6 +12,7 @@ import { brand, info } from "./ui.js";
12
12
  export const DEPS = {
13
13
  whisper: { binary: "whisper-cli", brew: "whisper-cpp", why: "transcribes voice messages locally" },
14
14
  ffmpeg: { binary: "ffmpeg", brew: "ffmpeg", why: "converts voice notes for whisper" },
15
+ llama: { binary: "llama-server", brew: "llama.cpp", why: "embeds messages for local semantic recall" },
15
16
  tailscale: { binary: "tailscale", brew: "tailscale", why: "gives wazap a public https URL" },
16
17
  cloudflared: { binary: "cloudflared", brew: "cloudflared", why: "gives wazap a public https URL" },
17
18
  };
package/dist/doctor.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { accessSync, constants, statSync } from "node:fs";
2
2
  import { AccountRegistry, accountPolicy, anyAccountLinked, resolveAccount } from "./accounts.js";
3
3
  import { readLinkedAccount } from "./auth-state.js";
4
- import { WAZAP_VERSION, WRITES_ENABLE_FIX, WRITE_TOKEN_NOTE, accountPaths, isRemoteHttp, paths } from "./config.js";
4
+ import { WAZAP_VERSION, WRITES_ENABLE_FIX, WRITE_TOKEN_NOTE, accountPaths, isRemoteHttp, paths, } from "./config.js";
5
5
  import { asWazapError } from "./errors.js";
6
6
  import { lockHolder, lockPid } from "./lock.js";
7
7
  import { oauthProblem, readGrants } from "./oauth.js";
8
+ import { EMBED_MODELS, embedModelPath, embedReady, readRecallSettings } from "./recall/index.js";
8
9
  import { installedService } from "./service.js";
9
10
  import { detectedTargets, skillState } from "./skills.js";
10
11
  import { MODELS, findWhisper, localProvider, maskKey, modelPath, readTranscribeSettings, which, } from "./transcribe/index.js";
@@ -25,6 +26,7 @@ const CHECKS = [
25
26
  checkSkills,
26
27
  checkOAuth,
27
28
  checkTranscribe,
29
+ checkRecall,
28
30
  checkWebhook,
29
31
  checkUpdate,
30
32
  ];
@@ -34,7 +36,7 @@ export async function runChecks(config) {
34
36
  const checks = [];
35
37
  for (const check of CHECKS)
36
38
  checks.push(...[await check(config)].flat());
37
- let linked = false;
39
+ let linked;
38
40
  try {
39
41
  linked = anyAccountLinked(config.dataDir);
40
42
  }
@@ -81,7 +83,12 @@ function checkDataDir(config) {
81
83
  return { name: "data dir", state: "info", detail: `${dir} does not exist yet (login creates it)` };
82
84
  }
83
85
  if (!stat.isDirectory()) {
84
- return { name: "data dir", state: "fail", detail: `${dir} is not a directory`, fix: "move it aside or use --data-dir" };
86
+ return {
87
+ name: "data dir",
88
+ state: "fail",
89
+ detail: `${dir} is not a directory`,
90
+ fix: "move it aside or use --data-dir",
91
+ };
85
92
  }
86
93
  const mode = stat.mode & 0o777;
87
94
  if (process.platform !== "win32" && mode !== 0o700) {
@@ -96,7 +103,12 @@ function checkDataDir(config) {
96
103
  accessSync(dir, constants.W_OK);
97
104
  }
98
105
  catch {
99
- return { name: "data dir", state: "fail", detail: `${dir} is not writable`, fix: "fix its ownership or permissions" };
106
+ return {
107
+ name: "data dir",
108
+ state: "fail",
109
+ detail: `${dir} is not writable`,
110
+ fix: "fix its ownership or permissions",
111
+ };
100
112
  }
101
113
  return { name: "data dir", state: "ok", detail: `${dir} (0700, writable)` };
102
114
  }
@@ -165,7 +177,11 @@ function checkCredentials(config) {
165
177
  if (linkedIds.length === 0)
166
178
  return { name: "credentials", state: "info", detail: "no account linked yet" };
167
179
  // The number is deliberately absent: status is the thing people screenshot.
168
- return { name: "credentials", state: "ok", detail: records.length > 1 ? `readable (${linkedIds.join(", ")})` : "readable" };
180
+ return {
181
+ name: "credentials",
182
+ state: "ok",
183
+ detail: records.length > 1 ? `readable (${linkedIds.join(", ")})` : "readable",
184
+ };
169
185
  }
170
186
  function checkWrites(config) {
171
187
  const selected = resolveAccount(config.dataDir, config.accountId);
@@ -278,6 +294,35 @@ async function localChecks(settings) {
278
294
  : { name: "model", state: "ok", detail: `${spec.file} (${Math.round(size / MIB)} MiB)` },
279
295
  ];
280
296
  }
297
+ const RECALL_OFF_FIX = "run `wazap config recall local` to search messages by meaning";
298
+ /**
299
+ * Off is quiet; on reports the sidecar binary and the model file, the two
300
+ * things `embed download` plus an install can repair.
301
+ */
302
+ async function checkRecall(config) {
303
+ let settings;
304
+ try {
305
+ settings = readRecallSettings(process.env, config.dataDir);
306
+ }
307
+ catch (err) {
308
+ const failure = asWazapError(err);
309
+ return [{ name: "recall", state: "fail", detail: failure.message, fix: failure.fix }];
310
+ }
311
+ if (!settings.enabled)
312
+ return [{ name: "recall", state: "info", detail: "off", fix: RECALL_OFF_FIX }];
313
+ const spec = EMBED_MODELS[settings.model];
314
+ const size = fileSize(embedModelPath(settings.modelsDir, spec));
315
+ const readiness = await embedReady(settings, spec);
316
+ return [
317
+ { name: "recall", state: "ok", detail: `local (${settings.model})` },
318
+ readiness.ok
319
+ ? { name: "llama-server", state: "ok", detail: settings.embedUrl ?? "found" }
320
+ : { name: "llama-server", state: "fail", detail: readiness.detail, fix: readiness.fix },
321
+ size === null && settings.embedUrl === null
322
+ ? { name: "embed model", state: "fail", detail: `${spec.file} is not downloaded`, fix: "run `wazap embed download`" }
323
+ : { name: "embed model", state: "ok", detail: `${spec.file} (${Math.round((size ?? 0) / MIB)} MiB)` },
324
+ ];
325
+ }
281
326
  /** W1 webhook: off is quiet; on without a URL or secret is a visible fail. */
282
327
  export function webhookCheck(env = process.env) {
283
328
  const settings = readWebhookSettings(env);
@@ -341,6 +386,11 @@ async function checkUpdate() {
341
386
  if (latest === null)
342
387
  return { name: "update", state: "info", detail: "update check skipped (no answer)" };
343
388
  return isNewer(latest, WAZAP_VERSION)
344
- ? { name: "update", state: "info", detail: `${latest} is out (running ${WAZAP_VERSION})`, fix: "run `wazap update`" }
389
+ ? {
390
+ name: "update",
391
+ state: "info",
392
+ detail: `${latest} is out (running ${WAZAP_VERSION})`,
393
+ fix: "run `wazap update`",
394
+ }
345
395
  : { name: "update", state: "ok", detail: `${WAZAP_VERSION} is current` };
346
396
  }
package/dist/drafts.js CHANGED
@@ -142,6 +142,9 @@ function formatNumber(digits) {
142
142
  const raw = digits.startsWith("+") ? digits.slice(1) : digits;
143
143
  if (!/^\d+$/.test(raw) || raw.length < 4)
144
144
  return digits.startsWith("+") ? digits : `+${digits}`;
145
- const rest = raw.slice(2).match(/.{1,3}/g)?.join(" ") ?? raw.slice(2);
145
+ const rest = raw
146
+ .slice(2)
147
+ .match(/.{1,3}/g)
148
+ ?.join(" ") ?? raw.slice(2);
146
149
  return `+${raw.slice(0, 2)} ${rest}`;
147
150
  }
package/dist/errors.js CHANGED
@@ -29,6 +29,7 @@ export const ERROR_GUIDE = {
29
29
  NOT_ADMIN: "The linked account is not an admin of that group, so this action is refused. Do not retry.",
30
30
  GROUP_ANNOUNCEMENT_ONLY: "Only admins may post in that group. Do not retry.",
31
31
  MEDIA_UNAVAILABLE: "The media expired on WhatsApp's servers or was never synced. Do not retry; ask the sender to resend.",
32
+ MEDIA_ACCESS_DENIED: "The URL is not a public http(s) address, or resolves to a private or internal one. Pass a public URL or download the file and use file_path.",
32
33
  FILE_NOT_FOUND: "The local path does not exist on the machine running wazap. Check the path with the user.",
33
34
  FILE_TOO_LARGE: "The file is too large. Chat media may be 100 MB; a profile picture may be 10 MB. Send a smaller file.",
34
35
  INVALID_IMAGE: "The file is not a JPEG, PNG or WebP. Pass a photo via file_path or url; GIF, video and documents are refused.",
@@ -41,6 +42,8 @@ export const ERROR_GUIDE = {
41
42
  RATE_LIMITED: "Too many writes too fast. Wait the number of seconds in the fix, then retry once.",
42
43
  TRANSCRIBE_UNAVAILABLE: "Transcription is off, or its binaries or model are missing. Tell the user to run the command in the fix; do not retry.",
43
44
  TRANSCRIBE_FAILED: "The transcription provider ran and failed. Read the message; retry once at most.",
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
+ RECALL_FAILED: "The embedding backend ran and failed. Read the message; retry once at most.",
44
47
  TIMEOUT: "WhatsApp did not answer in time. Retry once; if it fails again, call get_status.",
45
48
  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.",
46
49
  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.",
package/dist/gif.js CHANGED
@@ -25,11 +25,18 @@ export async function gifToMp4(gif) {
25
25
  const output = join(dir, "out.mp4");
26
26
  await writeFile(input, gif);
27
27
  const args = [
28
- "-nostdin", "-loglevel", "error", "-y",
29
- "-i", input,
30
- "-movflags", "faststart",
31
- "-pix_fmt", "yuv420p",
32
- "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2",
28
+ "-nostdin",
29
+ "-loglevel",
30
+ "error",
31
+ "-y",
32
+ "-i",
33
+ input,
34
+ "-movflags",
35
+ "faststart",
36
+ "-pix_fmt",
37
+ "yuv420p",
38
+ "-vf",
39
+ "scale=trunc(iw/2)*2:trunc(ih/2)*2",
33
40
  "-an",
34
41
  output,
35
42
  ];
package/dist/ids.js CHANGED
@@ -2,7 +2,10 @@ import { WazapError } from "./errors.js";
2
2
  const PHONE_EXAMPLE = "Use international format, e.g. +15550100";
3
3
  /** Digits of a phone number in international format, or INVALID_PHONE. */
4
4
  export function normalizePhone(input) {
5
- const digits = input.trim().replace(/^\+/, "").replace(/[\s\-().]/g, "");
5
+ const digits = input
6
+ .trim()
7
+ .replace(/^\+/, "")
8
+ .replace(/[\s\-().]/g, "");
6
9
  if (!/^\d+$/.test(digits) || digits.startsWith("0") || digits.length < 8 || digits.length > 15) {
7
10
  throw new WazapError("INVALID_PHONE", `"${input.trim()}" is not a phone number in international format.`, PHONE_EXAMPLE);
8
11
  }
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { BANNER } from "./banner.js";
3
- import { runAccount, runContacts, runGreet, runLogin, runLogout, runMigrate, runServe, runStatus, runTranscribe } from "./cli.js";
3
+ import { runAccount, runContacts, runEmbed, runGreet, runLogin, runLogout, runMigrate, runServe, runStatus, runTranscribe, } from "./cli.js";
4
4
  import { WAZAP_VERSION, parseCli, pickDefaultAction } from "./config.js";
5
5
  import { migrateLayout } from "./migrate.js";
6
6
  import { CLIENT_NAMES, runConnect } from "./connect.js";
@@ -24,11 +24,12 @@ Usage:
24
24
  wazap service ${SERVICE_VERBS}
25
25
  Keep the server running in the background, under launchd or systemd
26
26
  wazap expose [tailscale|cloudflare|off] Give the running service a public https URL cloud agents can reach
27
- wazap config [writes on|off] [transcribe local|openai|off] [webhook on|off]
27
+ wazap config [writes on|off] [transcribe local|openai|off] [recall local|off] [webhook on|off]
28
28
  Show the effective settings, or change one
29
29
  wazap webhook test [--event <name>] [--account <id>] POST a test event to the configured webhook
30
30
  wazap transcribe download [--model <alias>] Fetch the whisper.cpp model into the data dir
31
31
  wazap transcribe test <audio file> Transcribe a local file with the configured provider
32
+ wazap embed download [--model <alias>] Fetch the llama.cpp embedding model for semantic recall
32
33
  wazap contacts resync Fetch the phone's address book from WhatsApp again
33
34
  wazap update [--dry-run] Upgrade wazap, then the service and the skills that follow it
34
35
  wazap status [--live] [--json] [--account <id>] Check the install, the session and the server
@@ -62,7 +63,8 @@ Options:
62
63
  --transcribe <how> With setup: answer the transcription question (local, openai or off)
63
64
  --service With setup: keep wazap running on this machine, without asking
64
65
  --expose With setup: also give it a public URL cloud agents can reach
65
- --model <alias> With transcribe download: turbo (default), large-v3 or medium
66
+ --model <alias> With transcribe download: turbo (default), large-v3 or medium.
67
+ With embed download: embeddinggemma-300m (default) or e5-base-multilingual
66
68
  --dry-run With connect, skills install, service install or update: print what would happen, and do nothing
67
69
  --live With status: reach WhatsApp for real, then close the connection
68
70
  --json With status: print the whole report as one JSON object on stdout
@@ -78,7 +80,8 @@ WAZAP_OAUTH_PASSWORD, WAZAP_RATE_LIMIT,
78
80
  WAZAP_NO_SHARE, WAZAP_NO_UPDATE_CHECK, WAZAP_TRANSCRIBE, WAZAP_TRANSCRIBE_AUTO,
79
81
  WAZAP_TRANSCRIBE_LANGUAGE, WAZAP_TRANSCRIBE_API_KEY, WAZAP_TRANSCRIBE_URL, WAZAP_TRANSCRIBE_MODEL,
80
82
  WAZAP_WHISPER_MODEL, WAZAP_WHISPER_BIN, WAZAP_WEBHOOK, WAZAP_WEBHOOK_URL,
81
- WAZAP_WEBHOOK_SECRET, WAZAP_WEBHOOK_EVENTS.
83
+ WAZAP_WEBHOOK_SECRET, WAZAP_WEBHOOK_EVENTS, WAZAP_RECALL, WAZAP_RECALL_MAX,
84
+ WAZAP_EMBED_MODEL, WAZAP_EMBED_BIN.
82
85
  An optional <data-dir>/.env is loaded if present.`;
83
86
  async function main() {
84
87
  const invocation = parseCli();
@@ -95,7 +98,16 @@ async function main() {
95
98
  // half-finished migrate and then fail to undo it. The other exempt commands
96
99
  // never open account state, and `service stop` is how a lock that blocks the
97
100
  // migration is released.
98
- const MIGRATE_EXEMPT = new Set(["migrate", "service", "connect", "skills", "expose", "update", "transcribe"]);
101
+ const MIGRATE_EXEMPT = new Set([
102
+ "migrate",
103
+ "service",
104
+ "connect",
105
+ "skills",
106
+ "expose",
107
+ "update",
108
+ "transcribe",
109
+ "embed",
110
+ ]);
99
111
  if (!MIGRATE_EXEMPT.has(config.command))
100
112
  migrateLayout(config.dataDir);
101
113
  switch (config.command) {
@@ -130,6 +142,9 @@ async function main() {
130
142
  case "transcribe":
131
143
  await runTranscribe(config);
132
144
  return;
145
+ case "embed":
146
+ await runEmbed(config);
147
+ return;
133
148
  case "contacts":
134
149
  await runContacts(config);
135
150
  return;
package/dist/messages.js CHANGED
@@ -82,7 +82,11 @@ const RULES = {
82
82
  extendedTextMessage: { type: "text", tag: "[text]", text: (m) => m.extendedTextMessage?.text },
83
83
  imageMessage: { type: "image", tag: "[image]", caption: (m) => m.imageMessage?.caption },
84
84
  // A GIF on WhatsApp is an mp4 with a flag; the reader deserves the word.
85
- videoMessage: { type: "video", tag: (m) => (m.videoMessage?.gifPlayback ? "[gif]" : "[video]"), caption: (m) => m.videoMessage?.caption },
85
+ videoMessage: {
86
+ type: "video",
87
+ tag: (m) => (m.videoMessage?.gifPlayback ? "[gif]" : "[video]"),
88
+ caption: (m) => m.videoMessage?.caption,
89
+ },
86
90
  ptvMessage: { type: "video", tag: "[video]", caption: (m) => m.ptvMessage?.caption },
87
91
  audioMessage: {
88
92
  type: (m) => (m.audioMessage?.ptt ? "voice" : "audio"),
@@ -152,7 +156,12 @@ const MEDIA_KEYS = [
152
156
  "stickerMessage",
153
157
  ];
154
158
  /** Media whose WhatsApp envelope carries a JPEG preview of a few KB. */
155
- const THUMBNAIL_KEYS = ["imageMessage", "videoMessage", "ptvMessage", "documentMessage"];
159
+ const THUMBNAIL_KEYS = [
160
+ "imageMessage",
161
+ "videoMessage",
162
+ "ptvMessage",
163
+ "documentMessage",
164
+ ];
156
165
  /** Envelopes that only wrap another message; the inner one is the real content. */
157
166
  function unwrapEnvelopes(content) {
158
167
  let current = content ?? undefined;
@@ -376,6 +385,35 @@ function spokenTranscript(raw, transcript) {
376
385
  const type = messageType(raw);
377
386
  return type === "voice" || type === "audio" ? transcript.text : undefined;
378
387
  }
388
+ /**
389
+ * What the recall index stores for a message: the rendered view text when the
390
+ * message carries words a person chose, and null when it is only a placeholder
391
+ * — "[sticker]", "[deleted]", a call log, a reaction. A bare tag is nothing to
392
+ * search by; a voice note becomes indexable once its transcript exists.
393
+ */
394
+ export function searchableText(raw, transcript) {
395
+ const spoken = spokenTranscript(raw, transcript);
396
+ const content = unwrapEnvelopes(raw.message);
397
+ const { rule, content: node } = ruleFor(content);
398
+ const type = resolve(rule.type, node);
399
+ if (type === "reaction" || type === "deleted" || type === "system")
400
+ return null;
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)
405
+ return null;
406
+ return viewText(raw, transcript);
407
+ }
408
+ /** The message a REVOKE protocol message takes back, when there is one. */
409
+ export function revokedTargetKey(raw) {
410
+ const content = unwrapEnvelopes(raw.message);
411
+ const proto_ = content?.protocolMessage;
412
+ if (proto_?.type !== proto.Message.ProtocolMessage.Type.REVOKE)
413
+ return undefined;
414
+ const key = proto_.key;
415
+ return key?.id ? key : undefined;
416
+ }
379
417
  /**
380
418
  * What a reader sees. searchMessages matches on this rather than on the bare
381
419
  * placeholder, so a transcript is findable by the words it puts on the screen.
@@ -482,7 +520,7 @@ function senderJid(raw, ctx) {
482
520
  const from = raw.key.participant || raw.participant || raw.key.remoteJid || "";
483
521
  return from ? ctx.canonical(from) : ctx.ownId;
484
522
  }
485
- function phoneOf(jid) {
523
+ export function phoneOf(jid) {
486
524
  const [user = "", domain] = jid.split("@");
487
525
  return domain === "s.whatsapp.net" && /^\d+$/.test(user) ? user : undefined;
488
526
  }
package/dist/notes.js CHANGED
@@ -8,6 +8,9 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
8
  import { dirname } from "node:path";
9
9
  export class Notes {
10
10
  file;
11
+ /** Why the file on disk could not be read, or why the last save failed. Null when healthy. */
12
+ error = null;
13
+ loadError = null;
11
14
  contacts = new Map();
12
15
  handled = new Map();
13
16
  constructor(file) {
@@ -19,24 +22,38 @@ export class Notes {
19
22
  try {
20
23
  parsed = JSON.parse(readFileSync(this.file, "utf8"));
21
24
  }
22
- catch {
25
+ catch (error) {
26
+ if (error.code !== "ENOENT")
27
+ this.error = this.loadError = error instanceof Error ? error.message : String(error);
23
28
  return;
24
29
  }
25
- if (parsed?.v !== 1)
30
+ if (parsed?.v !== 1) {
31
+ this.error = this.loadError = "Unsupported notes file version";
26
32
  return;
33
+ }
27
34
  for (const [jid, note] of Object.entries(parsed.contacts ?? {}))
28
35
  this.contacts.set(jid, note);
29
36
  for (const [jid, mark] of Object.entries(parsed.handled ?? {}))
30
37
  this.handled.set(jid, mark);
31
38
  }
32
39
  save() {
40
+ // A file that never read cleanly must not be overwritten with a fresh one.
41
+ if (this.loadError)
42
+ throw new Error(`Cannot overwrite unreadable notes: ${this.loadError}`);
33
43
  const data = {
34
44
  v: 1,
35
45
  contacts: Object.fromEntries(this.contacts),
36
46
  handled: Object.fromEntries(this.handled),
37
47
  };
38
48
  mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 });
39
- writeFileSync(this.file, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
49
+ try {
50
+ writeFileSync(this.file, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
51
+ this.error = null;
52
+ }
53
+ catch (error) {
54
+ this.error = error instanceof Error ? error.message : String(error);
55
+ throw error;
56
+ }
40
57
  }
41
58
  noteFor(jid) {
42
59
  return this.contacts.get(jid)?.note;