wazap-mcp 0.16.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.
package/README.md CHANGED
@@ -545,9 +545,9 @@ response still carries `account_id`. `link_account` needs an account that
545
545
  already exists. Five accounts is advice, not a cap. One phone number is one
546
546
  account.
547
547
 
548
- An account can override the global webhook URL and secret in `accounts.json`
549
- (`webhook_url`, `webhook_secret`). `wazap webhook test --account work` posts
550
- with that account's id and name.
548
+ An account can override the global webhook URL, secret and event list in
549
+ `accounts.json` (`webhook_url`, `webhook_secret`, `webhook_events`).
550
+ `wazap webhook test --account work` posts with that account's id and name.
551
551
 
552
552
  ## Several clients at once
553
553
 
@@ -704,38 +704,119 @@ What to know before exposing it:
704
704
 
705
705
  ## Outbound webhook
706
706
 
707
- A live inbound message can POST to one URL. Off by default. History sync
708
- is not posted. The only event is `message_received`.
707
+ Live events POST to one URL. Off by default. History sync is not posted.
708
+ Only `message_received` is posted unless you ask for more, because a consumer
709
+ that answers every POST without reading `event` would otherwise answer the
710
+ messages its own owner typed on the phone.
711
+
712
+ The `event` field names one of three. `message_received` is a message another
713
+ person sent. `message_sent` is a message this account sent itself, typed on
714
+ the phone or on another linked device; a message wazap sent through its own
715
+ tools is not announced, so a consumer can never be made to answer itself.
716
+ `connection` says the link came up, went down or expired.
717
+
718
+ Ask for the other two in `WAZAP_WEBHOOK_EVENTS`, comma-separated
719
+ (`message_received,connection`), or say `all` for the three of them. Case
720
+ does not matter and the spaces around a name are ignored. An unknown name
721
+ fails `wazap status`, doctor and setup. An account carries its own list as
722
+ `webhook_events` in `accounts.json`.
709
723
 
710
724
  ```bash
711
725
  npx wazap-mcp config webhook on # asks for URL + secret (secret is not echoed)
712
726
  npx wazap-mcp webhook test # POST a probe event
727
+ npx wazap-mcp webhook test --event connection # needs connection enabled
713
728
  npx wazap-mcp webhook test --account work
714
729
  npx wazap-mcp config webhook off
715
730
  ```
716
731
 
717
732
  On without a URL or secret fails `wazap status`, doctor and setup. A failed
718
733
  delivery retries twice (200 ms, then 500 ms), then sets `webhook.last_error`
719
- and leaves WhatsApp and MCP running. An account may set `webhook_url` and
720
- `webhook_secret` in `accounts.json`; those win over the global URL and secret.
734
+ and leaves WhatsApp and MCP running. An account may set `webhook_url`,
735
+ `webhook_secret` and `webhook_events` in `accounts.json`; those win over the
736
+ global URL, secret and event list, and `config webhook off --account work`
737
+ clears all three.
738
+
739
+ `webhook test --event <name>` posts nothing and exits non-zero when that
740
+ event is not enabled, and says what to enable it with. While the webhook is
741
+ on, `wazap config` prints the events it posts on an `events:` line.
721
742
 
722
743
  HMAC: `X-Wazap-Signature` is `sha256=<hex>`, HMAC-SHA256 of the exact raw
723
744
  JSON body with the secret that signed it. Verify that raw body, not a
724
745
  re-serialized object. HTTPS only, except `http://` on loopback.
725
746
 
747
+ `text` is a preview, cut at 2000 characters and ending in a single `…`.
748
+ `truncated` is true when it was cut. For `kind: "audio"`, `text` is the
749
+ transcription when wazap auto-transcribed the note itself, which it does for
750
+ incoming notes only; the event waits up to 60 seconds for those words. In
751
+ every other case `text` is the `[voice message · 0:42]` placeholder: a note
752
+ you recorded yourself, a note longer than 600 seconds, a note WhatsApp stated
753
+ no duration for, and a transcription that failed. `ts` is the original local
754
+ time with a numeric offset, kept for consumers already reading it, and
755
+ `timestamp` is the same instant in UTC.
756
+
757
+ Events are not guaranteed to arrive in the order they happened. A message held
758
+ for its transcript is overtaken by the messages behind it, so order by
759
+ `timestamp` and not by arrival. Connection events are the exception: they are
760
+ delivered in the order the link moved in.
761
+
762
+ A message another person sent:
763
+
726
764
  ```json
727
765
  {
728
766
  "event": "message_received",
729
- "from": "+15550100",
767
+ "from": "15550100",
730
768
  "chat_id": "15550100@s.whatsapp.net",
731
- "ts": "2026-09-08T14:00:00+00:00",
732
- "text": "hello, or a short preview",
769
+ "ts": "2026-09-08T17:00:00+03:00",
770
+ "timestamp": "2026-09-08T14:00:00.000Z",
771
+ "text": "hello, or a preview of something longer",
772
+ "truncated": false,
773
+ "kind": "text",
774
+ "from_me": false,
775
+ "is_self_chat": false,
733
776
  "message_id": "false_15550100@s.whatsapp.net_3EB0…",
734
777
  "account_id": "default",
735
778
  "account_name": "default"
736
779
  }
737
780
  ```
738
781
 
782
+ A message sent from the phone, here in the "Message yourself" chat:
783
+
784
+ ```json
785
+ {
786
+ "event": "message_sent",
787
+ "from": "15551234",
788
+ "chat_id": "15551234@s.whatsapp.net",
789
+ "ts": "2026-09-08T17:04:12+03:00",
790
+ "timestamp": "2026-09-08T14:04:12.000Z",
791
+ "text": "call the notary at 14:00 on Wednesday",
792
+ "truncated": false,
793
+ "kind": "text",
794
+ "from_me": true,
795
+ "is_self_chat": true,
796
+ "message_id": "true_15551234@s.whatsapp.net_3EB0…",
797
+ "account_id": "default",
798
+ "account_name": "default"
799
+ }
800
+ ```
801
+
802
+ A connection change. `status` is `linked`, `disconnected` or `expired`;
803
+ `not_linked`, `linking` and `connecting` post nothing, and two changes that
804
+ mean the same status post once.
805
+
806
+ ```json
807
+ {
808
+ "event": "connection",
809
+ "status": "expired",
810
+ "timestamp": "2026-09-08T14:10:00.000Z",
811
+ "account_id": "default",
812
+ "account_name": "default"
813
+ }
814
+ ```
815
+
816
+ `connection` reports what the socket does while wazap is running. A clean
817
+ shutdown posts nothing, and a crash posts nothing either, so silence does not
818
+ mean the link is up. Poll `get_status` when you need to know that.
819
+
739
820
  ## Settings
740
821
 
741
822
  | Variable | Default | Meaning |
@@ -759,9 +840,10 @@ re-serialized object. HTTPS only, except `http://` on loopback.
759
840
  | `WAZAP_TRANSCRIBE_API_KEY` | unset | API key; `OPENAI_API_KEY` is the fallback. Never a flag. |
760
841
  | `WAZAP_TRANSCRIBE_URL` | `https://api.openai.com/v1` | OpenAI-compatible base URL. |
761
842
  | `WAZAP_TRANSCRIBE_MODEL` | `gpt-4o-mini-transcribe` | Model at that URL. |
762
- | `WAZAP_WEBHOOK` | `off` | `on` posts live inbound messages to the webhook URL. |
843
+ | `WAZAP_WEBHOOK` | `off` | `on` posts the enabled events to the webhook URL. |
763
844
  | `WAZAP_WEBHOOK_URL` | unset | HTTPS endpoint. `http://` only on loopback. An account `webhook_url` wins. |
764
845
  | `WAZAP_WEBHOOK_SECRET` | unset | Shared secret for `X-Wazap-Signature`. Never a flag. An account `webhook_secret` wins. |
846
+ | `WAZAP_WEBHOOK_EVENTS` | unset (`message_received`) | Which events to post, comma-separated, or `all`. An account `webhook_events` wins. |
765
847
 
766
848
  Flags beat environment variables, which beat `<data-dir>/.env`.
767
849
 
@@ -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/accounts.js CHANGED
@@ -2,7 +2,8 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, wri
2
2
  import { dirname } from "node:path";
3
3
  import { readLinkedAccount } from "./auth-state.js";
4
4
  import { accountPaths, paths } from "./config.js";
5
- import { WazapError } from "./errors.js";
5
+ import { WazapError, asWazapError } from "./errors.js";
6
+ import { parseWebhookEvents } from "./webhook.js";
6
7
  export const DEFAULT_ACCOUNT_ID = "default";
7
8
  export const ACCOUNT_ID_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;
8
9
  const FIX_LIST = "Run `wazap account list`";
@@ -72,10 +73,13 @@ function parseAccountRecord(value, file) {
72
73
  }
73
74
  record.rate_limit = value.rate_limit;
74
75
  }
75
- return { ...record, ...webhookFields(value.id, value.webhook_url, value.webhook_secret, ` in ${file}`) };
76
+ return {
77
+ ...record,
78
+ ...webhookFields(value.id, value.webhook_url, value.webhook_secret, value.webhook_events, ` in ${file}`),
79
+ };
76
80
  }
77
81
  /** Shared by load and `setWebhook` so a writer cannot persist what load refuses. */
78
- function webhookFields(id, url, secret, where = "", fix = "Fix or remove accounts.json") {
82
+ function webhookFields(id, url, secret, events, where = "", fix = "Fix or remove accounts.json") {
79
83
  const fields = {};
80
84
  if (url !== undefined) {
81
85
  if (typeof url !== "string" || url.trim() === "") {
@@ -89,6 +93,19 @@ function webhookFields(id, url, secret, where = "", fix = "Fix or remove account
89
93
  }
90
94
  fields.webhook_secret = secret;
91
95
  }
96
+ if (events !== undefined) {
97
+ if (typeof events !== "string" || events.trim() === "") {
98
+ throw new WazapError("INVALID_ID", `Account "${id}"${where} has a bad webhook_events.`, fix);
99
+ }
100
+ const trimmed = events.trim();
101
+ try {
102
+ parseWebhookEvents(trimmed);
103
+ }
104
+ catch (err) {
105
+ throw new WazapError("INVALID_ID", `Account "${id}"${where} has a bad webhook_events: ${asWazapError(err).message}`, fix);
106
+ }
107
+ fields.webhook_events = trimmed;
108
+ }
92
109
  return fields;
93
110
  }
94
111
  function parseAccountsFile(value, file) {
@@ -200,7 +217,7 @@ export class AccountRegistry {
200
217
  setWebhook(id, webhook) {
201
218
  this.commit(this.withAccount(id, (account) => ({
202
219
  ...account,
203
- ...webhookFields(id, webhook.url, webhook.secret, "", "Set a non-empty webhook URL or secret"),
220
+ ...webhookFields(id, webhook.url, webhook.secret, undefined, "", "Set a non-empty webhook URL or secret"),
204
221
  })));
205
222
  }
206
223
  /** Drop the per-account override so the account follows the global webhook again. */
@@ -209,6 +226,7 @@ export class AccountRegistry {
209
226
  const next = { ...account };
210
227
  delete next.webhook_url;
211
228
  delete next.webhook_secret;
229
+ delete next.webhook_events;
212
230
  return next;
213
231
  }));
214
232
  }
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,
@@ -163,6 +165,7 @@ export function parseCli(argv = process.argv.slice(2)) {
163
165
  service: { type: "boolean" },
164
166
  expose: { type: "boolean" },
165
167
  yes: { type: "boolean", short: "y" },
168
+ event: { type: "string" },
166
169
  account: { type: "string" },
167
170
  name: { type: "string" },
168
171
  help: { type: "boolean", short: "h" },
@@ -224,6 +227,7 @@ export function parseCli(argv = process.argv.slice(2)) {
224
227
  rateLimit: sourceOf("WAZAP_RATE_LIMIT", false),
225
228
  transcribe: sourceOf("WAZAP_TRANSCRIBE", false),
226
229
  webhook: sourceOf("WAZAP_WEBHOOK", false),
230
+ recall: sourceOf("WAZAP_RECALL", false),
227
231
  },
228
232
  command,
229
233
  explicitCommand: first !== undefined,
@@ -245,6 +249,7 @@ export function parseCli(argv = process.argv.slice(2)) {
245
249
  keepRunning: values.expose === true ? "expose" : values.service === true ? "service" : null,
246
250
  accountId: values.account,
247
251
  accountName: values.name,
252
+ webhookEvent: values.event,
248
253
  },
249
254
  };
250
255
  }
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
  }