@rubytech/taskmaster 1.0.110 → 1.0.112

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.
Files changed (30) hide show
  1. package/dist/agents/tool-images.js +1 -1
  2. package/dist/agents/tools/document-tool.js +11 -2
  3. package/dist/agents/tools/message-tool.js +29 -32
  4. package/dist/auto-reply/reply/agent-runner-execution.js +1 -1
  5. package/dist/auto-reply/reply/get-reply-inline-actions.js +4 -1
  6. package/dist/auto-reply/reply/get-reply-run.js +4 -1
  7. package/dist/build-info.json +3 -3
  8. package/dist/control-ui/assets/index-CfybK7_N.css +1 -0
  9. package/dist/control-ui/assets/{index-D4TpiIHx.js → index-D7ZHRWnP.js} +212 -221
  10. package/dist/control-ui/assets/index-D7ZHRWnP.js.map +1 -0
  11. package/dist/control-ui/index.html +2 -2
  12. package/dist/gateway/protocol/schema/sessions-transcript.js +3 -0
  13. package/dist/gateway/public-chat/deliver-otp.js +2 -1
  14. package/dist/gateway/public-chat-api.js +7 -2
  15. package/dist/gateway/server-chat.js +6 -0
  16. package/dist/gateway/server-close.js +8 -0
  17. package/dist/gateway/server-methods/chat.js +47 -0
  18. package/dist/gateway/server-methods/public-chat.js +9 -1
  19. package/dist/gateway/server-methods/sessions-transcript.js +56 -3
  20. package/dist/gateway/server.impl.js +6 -0
  21. package/dist/infra/heartbeat-auth-notify.js +99 -0
  22. package/dist/infra/heartbeat-infra-alert.js +3 -0
  23. package/dist/infra/heartbeat-runner.js +13 -2
  24. package/dist/infra/infra-alert-events.js +16 -0
  25. package/dist/memory/hybrid.js +12 -3
  26. package/dist/memory/internal.js +116 -4
  27. package/dist/memory/manager.js +34 -20
  28. package/package.json +1 -1
  29. package/dist/control-ui/assets/index-BM3zZtpB.css +0 -1
  30. package/dist/control-ui/assets/index-D4TpiIHx.js.map +0 -1
@@ -6,8 +6,8 @@
6
6
  <title>Taskmaster Control</title>
7
7
  <meta name="color-scheme" content="dark light" />
8
8
  <link rel="icon" type="image/png" href="./favicon.png" />
9
- <script type="module" crossorigin src="./assets/index-D4TpiIHx.js"></script>
10
- <link rel="stylesheet" crossorigin href="./assets/index-BM3zZtpB.css">
9
+ <script type="module" crossorigin src="./assets/index-D7ZHRWnP.js"></script>
10
+ <link rel="stylesheet" crossorigin href="./assets/index-CfybK7_N.css">
11
11
  </head>
12
12
  <body>
13
13
  <taskmaster-app></taskmaster-app>
@@ -13,6 +13,8 @@ export const SessionsTranscriptEntrySchema = Type.Object({
13
13
  Type.Literal("user"),
14
14
  Type.Literal("assistant"),
15
15
  Type.Literal("tool"),
16
+ Type.Literal("tool_call"),
17
+ Type.Literal("tool_result"),
16
18
  Type.Literal("thinking"),
17
19
  Type.Literal("error"),
18
20
  Type.Literal("system"),
@@ -20,6 +22,7 @@ export const SessionsTranscriptEntrySchema = Type.Object({
20
22
  content: Type.String(),
21
23
  model: Type.Optional(Type.String()),
22
24
  toolName: Type.Optional(Type.String()),
25
+ toolCallId: Type.Optional(Type.String()),
23
26
  meta: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
24
27
  });
25
28
  export const SessionsTranscriptResultSchema = Type.Object({
@@ -2,8 +2,9 @@
2
2
  * Deliver OTP verification codes via WhatsApp.
3
3
  */
4
4
  import { sendMessageWhatsApp } from "../../web/outbound.js";
5
- export async function deliverOtp(phone, code) {
5
+ export async function deliverOtp(phone, code, accountId) {
6
6
  await sendMessageWhatsApp(phone, `Your verification code is: ${code}`, {
7
7
  verbose: false,
8
+ accountId,
8
9
  });
9
10
  }
@@ -26,6 +26,7 @@ import path from "node:path";
26
26
  import { resolveAgentWorkspaceDir, resolveSessionAgentId } from "../agents/agent-scope.js";
27
27
  import { resolveEffectiveMessagesConfig, resolveIdentityName } from "../agents/identity.js";
28
28
  import { dispatchInboundMessage } from "../auto-reply/dispatch.js";
29
+ import { resolveAgentBoundAccountId } from "../routing/bindings.js";
29
30
  import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
30
31
  import { extractShortModelName, } from "../auto-reply/reply/response-prefix-template.js";
31
32
  import { loadConfig } from "../config/config.js";
@@ -202,7 +203,7 @@ async function handleSession(req, res, accountId, cfg, maxBodyBytes) {
202
203
  // ---------------------------------------------------------------------------
203
204
  // Route: POST /otp/request
204
205
  // ---------------------------------------------------------------------------
205
- async function handleOtpRequest(req, res, _accountId, cfg, maxBodyBytes) {
206
+ async function handleOtpRequest(req, res, accountId, cfg, maxBodyBytes) {
206
207
  if (req.method !== "POST") {
207
208
  sendMethodNotAllowed(res);
208
209
  return;
@@ -228,8 +229,12 @@ async function handleOtpRequest(req, res, _accountId, cfg, maxBodyBytes) {
228
229
  });
229
230
  return;
230
231
  }
232
+ // Resolve the WhatsApp account bound to this account's public agent so the
233
+ // OTP code is sent from the correct number (not the first active account).
234
+ const agentId = resolvePublicAgentId(cfg, accountId);
235
+ const whatsappAccountId = resolveAgentBoundAccountId(cfg, agentId, "whatsapp") ?? undefined;
231
236
  try {
232
- await deliverOtp(phone, result.code);
237
+ await deliverOtp(phone, result.code, whatsappAccountId);
233
238
  }
234
239
  catch {
235
240
  sendUnavailable(res, "failed to send verification code — is WhatsApp connected?");
@@ -46,17 +46,20 @@ export function createChatRunState() {
46
46
  const buffers = new Map();
47
47
  const deltaSentAt = new Map();
48
48
  const abortedRuns = new Map();
49
+ const finalHadContent = new Map();
49
50
  const clear = () => {
50
51
  registry.clear();
51
52
  buffers.clear();
52
53
  deltaSentAt.clear();
53
54
  abortedRuns.clear();
55
+ finalHadContent.clear();
54
56
  };
55
57
  return {
56
58
  registry,
57
59
  buffers,
58
60
  deltaSentAt,
59
61
  abortedRuns,
62
+ finalHadContent,
60
63
  clear,
61
64
  };
62
65
  }
@@ -91,6 +94,9 @@ export function createAgentEventHandler({ broadcast, nodeSendToSession, agentRun
91
94
  chatRunState.deltaSentAt.delete(clientRunId);
92
95
  // Strip silent reply token so it never reaches the chat UI
93
96
  const text = isSilentReplyText(rawText) ? "" : rawText;
97
+ // Record whether the streaming buffer had content so the chat.send .then()
98
+ // handler knows whether it needs to broadcast the dispatcher's final reply.
99
+ chatRunState.finalHadContent.set(clientRunId, !!text);
94
100
  if (jobState === "done") {
95
101
  const payload = {
96
102
  runId: clientRunId,
@@ -72,6 +72,14 @@ export function createGatewayCloseHandler(params) {
72
72
  /* ignore */
73
73
  }
74
74
  }
75
+ if (params.infraAlertUnsub) {
76
+ try {
77
+ params.infraAlertUnsub();
78
+ }
79
+ catch {
80
+ /* ignore */
81
+ }
82
+ }
75
83
  params.chatRunState.clear();
76
84
  for (const c of params.clients) {
77
85
  try {
@@ -604,6 +604,52 @@ export const chatHandlers = {
604
604
  message,
605
605
  });
606
606
  }
607
+ else if (finalReplyParts.length > 0) {
608
+ // Agent started but the reply came through the dispatcher, not
609
+ // streaming (e.g. auth error, billing error, context overflow).
610
+ // emitChatFinal already broadcast a "final" — check whether it
611
+ // had content. If the streaming buffer was empty, the dispatcher's
612
+ // reply was the only response and needs to be persisted + broadcast.
613
+ const agentStreamedContent = context.chatFinalHadContent.get(clientRunId) ?? false;
614
+ if (!agentStreamedContent) {
615
+ const combinedReply = finalReplyParts
616
+ .map((part) => part.trim())
617
+ .filter(Boolean)
618
+ .join("\n\n")
619
+ .trim();
620
+ if (combinedReply) {
621
+ const { storePath: latestStorePath, entry: latestEntry } = loadSessionEntry(p.sessionKey);
622
+ const sessionId = latestEntry?.sessionId ?? entry?.sessionId ?? clientRunId;
623
+ const appended = appendAssistantTranscriptMessage({
624
+ message: combinedReply,
625
+ sessionId,
626
+ storePath: latestStorePath,
627
+ sessionFile: latestEntry?.sessionFile,
628
+ createIfMissing: true,
629
+ });
630
+ let message;
631
+ if (appended.ok) {
632
+ message = appended.message;
633
+ }
634
+ else {
635
+ context.logGateway.warn(`webchat transcript append (fallback) failed: ${appended.error ?? "unknown error"}`);
636
+ message = {
637
+ role: "assistant",
638
+ content: [{ type: "text", text: combinedReply }],
639
+ timestamp: Date.now(),
640
+ stopReason: "injected",
641
+ usage: { input: 0, output: 0, totalTokens: 0 },
642
+ };
643
+ }
644
+ broadcastChatFinal({
645
+ context,
646
+ runId: clientRunId,
647
+ sessionKey: p.sessionKey,
648
+ message,
649
+ });
650
+ }
651
+ }
652
+ }
607
653
  // Fire message:outbound hook for conversation archiving
608
654
  const outboundText = finalReplyParts.join("\n\n").trim();
609
655
  if (outboundText) {
@@ -644,6 +690,7 @@ export const chatHandlers = {
644
690
  })
645
691
  .finally(() => {
646
692
  context.chatAbortControllers.delete(clientRunId);
693
+ context.chatFinalHadContent.delete(clientRunId);
647
694
  });
648
695
  }
649
696
  catch (err) {
@@ -2,6 +2,7 @@
2
2
  * RPC handlers for public chat: OTP verification and session resolution.
3
3
  */
4
4
  import { loadConfig } from "../../config/config.js";
5
+ import { resolveAgentBoundAccountId } from "../../routing/bindings.js";
5
6
  import { ErrorCodes, errorShape } from "../protocol/index.js";
6
7
  import { requestOtp, verifyOtp } from "../public-chat/otp.js";
7
8
  import { deliverOtp } from "../public-chat/deliver-otp.js";
@@ -29,6 +30,7 @@ export const publicChatHandlers = {
29
30
  */
30
31
  "public.otp.request": async ({ params, respond, context }) => {
31
32
  const phone = typeof params.phone === "string" ? normalizePhone(params.phone.trim()) : "";
33
+ const accountId = validateAccountId(params.accountId);
32
34
  if (!phone || !isValidPhone(phone)) {
33
35
  respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid phone number"));
34
36
  return;
@@ -43,8 +45,14 @@ export const publicChatHandlers = {
43
45
  respond(false, { retryAfterMs: result.retryAfterMs }, errorShape(ErrorCodes.INVALID_REQUEST, "rate limited — try again shortly"));
44
46
  return;
45
47
  }
48
+ // Resolve the WhatsApp account bound to this account's public agent so the
49
+ // OTP code is sent from the correct number (not the first active account).
50
+ const agentId = accountId ? resolvePublicAgentId(cfg, accountId) : undefined;
51
+ const whatsappAccountId = agentId
52
+ ? (resolveAgentBoundAccountId(cfg, agentId, "whatsapp") ?? undefined)
53
+ : undefined;
46
54
  try {
47
- await deliverOtp(phone, result.code);
55
+ await deliverOtp(phone, result.code, whatsappAccountId);
48
56
  }
49
57
  catch (err) {
50
58
  context.logGateway.warn(`public-chat OTP delivery failed: ${String(err)}`);
@@ -30,6 +30,28 @@ function extractTextFromContentBlocks(blocks) {
30
30
  }
31
31
  return parts.join("\n");
32
32
  }
33
+ /** Format tool input as readable key=value pairs instead of raw JSON. */
34
+ function formatToolInput(input) {
35
+ if (input == null)
36
+ return "";
37
+ if (typeof input === "string")
38
+ return input;
39
+ if (typeof input !== "object" || Array.isArray(input))
40
+ return JSON.stringify(input);
41
+ const obj = input;
42
+ const parts = [];
43
+ for (const [key, val] of Object.entries(obj)) {
44
+ if (val === undefined)
45
+ continue;
46
+ const valStr = typeof val === "string"
47
+ ? val.length > 200
48
+ ? `"${val.slice(0, 200)}..."`
49
+ : `"${val}"`
50
+ : JSON.stringify(val);
51
+ parts.push(`${key}: ${valStr}`);
52
+ }
53
+ return parts.join("\n");
54
+ }
33
55
  function expandLineToEntries(line, sessionId, sessionKey, agentId, fileMtimeMs) {
34
56
  const entries = [];
35
57
  const ts = resolveTimestamp(line, fileMtimeMs);
@@ -64,9 +86,10 @@ function expandLineToEntries(line, sessionId, sessionKey, agentId, fileMtimeMs)
64
86
  sessionKey,
65
87
  agentId,
66
88
  timestamp: ts,
67
- type: "tool",
89
+ type: "tool_result",
68
90
  content,
69
91
  ...(line.toolName ? { toolName: line.toolName } : {}),
92
+ ...(line.toolCallId ? { toolCallId: line.toolCallId } : {}),
70
93
  ...(model ? { model } : {}),
71
94
  });
72
95
  return entries;
@@ -74,6 +97,34 @@ function expandLineToEntries(line, sessionId, sessionKey, agentId, fileMtimeMs)
74
97
  if (line.type === "message" && line.message) {
75
98
  const msg = line.message;
76
99
  const contentBlocks = Array.isArray(msg.content) ? msg.content : [];
100
+ // Handle toolResult messages — these are separate JSONL lines from the SDK
101
+ // with role: "toolResult", toolCallId, toolName, and content blocks.
102
+ if (msg.role === "toolResult") {
103
+ const textParts = [];
104
+ for (const block of contentBlocks) {
105
+ if (block && typeof block === "object" && typeof block.text === "string" && block.text.trim()) {
106
+ textParts.push(block.text.trim());
107
+ }
108
+ }
109
+ if (typeof msg.content === "string" && msg.content.trim()) {
110
+ textParts.push(msg.content.trim());
111
+ }
112
+ const content = textParts.length > 0 ? textParts.join("\n") : "(empty result)";
113
+ const toolName = typeof msg.toolName === "string" ? msg.toolName : undefined;
114
+ const toolCallId = typeof msg.toolCallId === "string" ? msg.toolCallId : undefined;
115
+ entries.push({
116
+ sessionId,
117
+ sessionKey,
118
+ agentId,
119
+ timestamp: ts,
120
+ type: "tool_result",
121
+ content: msg.isError ? `[error] ${content}` : content,
122
+ ...(toolName ? { toolName } : {}),
123
+ ...(toolCallId ? { toolCallId } : {}),
124
+ ...(model ? { model } : {}),
125
+ });
126
+ return entries;
127
+ }
77
128
  // If content is a simple string (not blocks), treat as a single text entry
78
129
  if (typeof msg.content === "string") {
79
130
  const role = msg.role;
@@ -111,15 +162,17 @@ function expandLineToEntries(line, sessionId, sessionKey, agentId, fileMtimeMs)
111
162
  else if (blockType === "tool_use" || blockType === "toolCall") {
112
163
  const toolName = typeof block.name === "string" ? block.name : undefined;
113
164
  const input = blockType === "toolCall" ? block.arguments : block.input;
114
- const content = input != null ? JSON.stringify(input) : "";
165
+ const content = formatToolInput(input);
166
+ const blockId = typeof block.id === "string" ? block.id : undefined;
115
167
  entries.push({
116
168
  sessionId,
117
169
  sessionKey,
118
170
  agentId,
119
171
  timestamp: ts,
120
- type: "tool",
172
+ type: "tool_call",
121
173
  content,
122
174
  ...(toolName ? { toolName } : {}),
175
+ ...(blockId ? { toolCallId: blockId } : {}),
123
176
  ...(model ? { model } : {}),
124
177
  });
125
178
  }
@@ -13,6 +13,7 @@ import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
13
13
  import { clearAgentRunContext, onAgentEvent } from "../infra/agent-events.js";
14
14
  import { onHeartbeatEvent } from "../infra/heartbeat-events.js";
15
15
  import { startHeartbeatRunner } from "../infra/heartbeat-runner.js";
16
+ import { onInfraAlertEvent } from "../infra/infra-alert-events.js";
16
17
  import { getMachineDisplayName } from "../infra/machine-name.js";
17
18
  import { ensureTaskmasterCliOnPath } from "../infra/path-env.js";
18
19
  import { primeRemoteSkillsCache, refreshRemoteBinsForConnectedNodes, setSkillsRemoteRegistry, } from "../infra/skills-remote.js";
@@ -327,6 +328,9 @@ export async function startGatewayServer(port = 18789, opts = {}) {
327
328
  const heartbeatUnsub = onHeartbeatEvent((evt) => {
328
329
  broadcast("heartbeat", evt, { dropIfSlow: true });
329
330
  });
331
+ const infraAlertUnsub = onInfraAlertEvent((evt) => {
332
+ broadcast("notification", evt);
333
+ });
330
334
  let heartbeatRunner = startHeartbeatRunner({ cfg: cfgAtStart });
331
335
  void cron.start().catch((err) => logCron.error(`failed to start: ${String(err)}`));
332
336
  const execApprovalManager = new ExecApprovalManager();
@@ -376,6 +380,7 @@ export async function startGatewayServer(port = 18789, opts = {}) {
376
380
  chatAbortControllers,
377
381
  chatAbortedRuns: chatRunState.abortedRuns,
378
382
  chatRunBuffers: chatRunState.buffers,
383
+ chatFinalHadContent: chatRunState.finalHadContent,
379
384
  chatDeltaSentAt: chatRunState.deltaSentAt,
380
385
  addChatRun,
381
386
  removeChatRun,
@@ -494,6 +499,7 @@ export async function startGatewayServer(port = 18789, opts = {}) {
494
499
  dedupeCleanup,
495
500
  agentUnsub,
496
501
  heartbeatUnsub,
502
+ infraAlertUnsub,
497
503
  chatRunState,
498
504
  clients,
499
505
  configReloader,
@@ -0,0 +1,99 @@
1
+ import { buildAuthHealthSummary, formatRemainingShort, } from "../agents/auth-health.js";
2
+ import { loadAuthProfileStore } from "../agents/auth-profiles.js";
3
+ import { getChannelPlugin } from "../channels/plugins/index.js";
4
+ import { createSubsystemLogger } from "../logging/subsystem.js";
5
+ import { emitInfraAlertEvent } from "./infra-alert-events.js";
6
+ import { deliverOutboundPayloads } from "./outbound/deliver.js";
7
+ const log = createSubsystemLogger("gateway/heartbeat-auth-notify");
8
+ const COOLDOWN_MS = 6 * 60 * 60 * 1000; // 6 hours — same as infra alerts
9
+ let lastNotifiedMs = 0;
10
+ function formatAuthAlertMessage(summary) {
11
+ const problems = summary.providers.filter((p) => p.status === "expired" || p.status === "expiring");
12
+ if (problems.length === 0)
13
+ return null;
14
+ const parts = [];
15
+ for (const provider of problems) {
16
+ const name = provider.provider.charAt(0).toUpperCase() + provider.provider.slice(1);
17
+ if (provider.status === "expired") {
18
+ parts.push(`${name} API key has expired`);
19
+ }
20
+ else if (provider.status === "expiring") {
21
+ const remaining = formatRemainingShort(provider.remainingMs);
22
+ parts.push(`${name} API key expires in ${remaining}`);
23
+ }
24
+ }
25
+ if (parts.length === 0)
26
+ return null;
27
+ const detail = parts.join("; ");
28
+ return `${detail}. Open the control panel and go to Settings > API Keys to update.`;
29
+ }
30
+ /**
31
+ * Proactive auth health check — runs after each heartbeat cycle to detect
32
+ * expired or soon-to-expire API tokens and notify the admin before they
33
+ * encounter errors.
34
+ *
35
+ * Alerts go to both the delivery channel (WhatsApp/iMessage) and the
36
+ * Control Panel via the infra-alert event bus.
37
+ *
38
+ * Returns true if an alert was sent, false otherwise. Never throws.
39
+ */
40
+ export async function checkAndNotifyAuthHealth(params) {
41
+ try {
42
+ const { cfg, delivery, deps } = params;
43
+ const nowMs = params.nowMs ?? Date.now();
44
+ // Cooldown: don't spam the admin.
45
+ if (nowMs - lastNotifiedMs < COOLDOWN_MS)
46
+ return false;
47
+ const store = loadAuthProfileStore();
48
+ const summary = buildAuthHealthSummary({ store, cfg });
49
+ const message = formatAuthAlertMessage(summary);
50
+ if (!message)
51
+ return false;
52
+ // Always broadcast to Control Panel regardless of delivery channel.
53
+ emitInfraAlertEvent({ category: "auth", message });
54
+ // Deliver via WhatsApp/iMessage if target available.
55
+ if (delivery.channel !== "none" && delivery.to) {
56
+ const plugin = getChannelPlugin(delivery.channel);
57
+ if (plugin?.heartbeat?.checkReady) {
58
+ const readiness = await plugin.heartbeat.checkReady({
59
+ cfg,
60
+ accountId: delivery.accountId,
61
+ deps,
62
+ });
63
+ if (!readiness.ok) {
64
+ log.debug("auth notify skipped channel delivery: not ready", {
65
+ reason: readiness.reason,
66
+ });
67
+ lastNotifiedMs = nowMs;
68
+ return true; // CP was still notified
69
+ }
70
+ }
71
+ await deliverOutboundPayloads({
72
+ cfg,
73
+ channel: delivery.channel,
74
+ to: delivery.to,
75
+ accountId: delivery.accountId,
76
+ payloads: [{ text: message }],
77
+ deps,
78
+ });
79
+ }
80
+ lastNotifiedMs = nowMs;
81
+ log.info("auth health alert sent", {
82
+ to: delivery.to ?? "control-panel-only",
83
+ problems: summary.providers
84
+ .filter((p) => p.status === "expired" || p.status === "expiring")
85
+ .map((p) => `${p.provider}:${p.status}`),
86
+ });
87
+ return true;
88
+ }
89
+ catch (err) {
90
+ log.error("auth health check failed", {
91
+ error: err instanceof Error ? err.message : String(err),
92
+ });
93
+ return false;
94
+ }
95
+ }
96
+ /** Reset cooldown timer. Exposed for testing. */
97
+ export function resetAuthNotifyCooldown() {
98
+ lastNotifiedMs = 0;
99
+ }
@@ -1,6 +1,7 @@
1
1
  import { describeFailoverError } from "../agents/failover-error.js";
2
2
  import { getChannelPlugin } from "../channels/plugins/index.js";
3
3
  import { createSubsystemLogger } from "../logging/subsystem.js";
4
+ import { emitInfraAlertEvent } from "./infra-alert-events.js";
4
5
  import { deliverOutboundPayloads } from "./outbound/deliver.js";
5
6
  const log = createSubsystemLogger("gateway/heartbeat-infra-alert");
6
7
  const COOLDOWN_MS = 6 * 60 * 60 * 1000; // 6 hours
@@ -79,6 +80,7 @@ export async function maybeAlertAdmin(ctx) {
79
80
  payloads: [{ text: message }],
80
81
  deps,
81
82
  });
83
+ emitInfraAlertEvent({ category, message });
82
84
  cooldowns.set(category, nowMs);
83
85
  log.info("infra alert sent", { category, to: delivery.to });
84
86
  return true;
@@ -115,6 +117,7 @@ export async function maybeAlertAdmin(ctx) {
115
117
  payloads: [{ text: message }],
116
118
  deps,
117
119
  });
120
+ emitInfraAlertEvent({ category, message });
118
121
  cooldowns.set(category, nowMs);
119
122
  log.info("infra alert sent", {
120
123
  category,
@@ -25,6 +25,7 @@ import { resolveHeartbeatVisibility } from "./heartbeat-visibility.js";
25
25
  import { requestHeartbeatNow, setHeartbeatWakeHandler, } from "./heartbeat-wake.js";
26
26
  import { deliverOutboundPayloads } from "./outbound/deliver.js";
27
27
  import { resolveHeartbeatDeliveryTarget, resolveHeartbeatSenderContext, } from "./outbound/targets.js";
28
+ import { checkAndNotifyAuthHealth } from "./heartbeat-auth-notify.js";
28
29
  import { maybeNotifyUpdateAvailable } from "./heartbeat-update-notify.js";
29
30
  const log = createSubsystemLogger("gateway/heartbeat");
30
31
  let heartbeatsEnabled = true;
@@ -630,6 +631,14 @@ async function checkAndNotifyUpdate(cfg, agent, deps) {
630
631
  const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat, bindingAccountId });
631
632
  await maybeNotifyUpdateAvailable({ cfg, delivery, deps });
632
633
  }
634
+ async function checkAuthHealth(cfg, agent, deps) {
635
+ const agentId = agent.agentId;
636
+ const heartbeat = agent.heartbeat;
637
+ const { entry } = resolveHeartbeatSession(cfg, agentId, heartbeat);
638
+ const bindingAccountId = resolveAgentBoundAccountId(cfg, agentId, "whatsapp") ?? undefined;
639
+ const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat, bindingAccountId });
640
+ await checkAndNotifyAuthHealth({ cfg, delivery, deps });
641
+ }
633
642
  export function startHeartbeatRunner(opts) {
634
643
  const runtime = opts.runtime ?? defaultRuntime;
635
644
  const runOnce = opts.runOnce ?? runHeartbeatOnce;
@@ -751,12 +760,14 @@ export function startHeartbeatRunner(opts) {
751
760
  if (res.status === "ran")
752
761
  ran = true;
753
762
  }
754
- // After heartbeat cycle: check for software updates and notify admin.
763
+ // After heartbeat cycle: check for software updates and auth health, notify admin.
755
764
  // Uses the first agent's delivery target. Non-blocking — never delays the next heartbeat.
756
765
  if (ran) {
757
766
  const firstAgent = state.agents.values().next().value;
758
767
  if (firstAgent) {
759
- void checkAndNotifyUpdate(state.cfg, firstAgent, { runtime: state.runtime }).catch(() => { });
768
+ const postRunDeps = { runtime: state.runtime };
769
+ void checkAndNotifyUpdate(state.cfg, firstAgent, postRunDeps).catch(() => { });
770
+ void checkAuthHealth(state.cfg, firstAgent, postRunDeps).catch(() => { });
760
771
  }
761
772
  }
762
773
  scheduleNext();
@@ -0,0 +1,16 @@
1
+ const listeners = new Set();
2
+ export function emitInfraAlertEvent(evt) {
3
+ const enriched = { ts: Date.now(), ...evt };
4
+ for (const listener of listeners) {
5
+ try {
6
+ listener(enriched);
7
+ }
8
+ catch {
9
+ /* ignore */
10
+ }
11
+ }
12
+ }
13
+ export function onInfraAlertEvent(listener) {
14
+ listeners.add(listener);
15
+ return () => listeners.delete(listener);
16
+ }
@@ -9,8 +9,10 @@ export function buildFtsQuery(raw) {
9
9
  return quoted.join(" AND ");
10
10
  }
11
11
  export function bm25RankToScore(rank) {
12
- const normalized = Number.isFinite(rank) ? Math.max(0, rank) : 999;
13
- return 1 / (1 + normalized);
12
+ // FTS5 bm25() returns negative values (more negative = more relevant).
13
+ // Convert to 0-1 scale: absRank/(1+absRank) → higher for better matches.
14
+ const absRank = Number.isFinite(rank) ? Math.abs(rank) : 0;
15
+ return absRank / (1 + absRank);
14
16
  }
15
17
  /**
16
18
  * Path-based boost factors applied during hybrid merge.
@@ -73,7 +75,14 @@ export function mergeHybridResults(params) {
73
75
  }
74
76
  }
75
77
  const merged = Array.from(byId.values()).map((entry) => {
76
- const raw = params.vectorWeight * entry.vectorScore + params.textWeight * entry.textScore;
78
+ const weighted = params.vectorWeight * entry.vectorScore + params.textWeight * entry.textScore;
79
+ // Keyword-only results (found by FTS but missed by vector search) must not be
80
+ // capped by textWeight — their text score passes through directly so exact keyword
81
+ // matches remain visible above minScore. When both signals are present, the weighted
82
+ // formula controls ranking as configured.
83
+ const raw = entry.vectorScore === 0 && entry.textScore > 0
84
+ ? Math.max(weighted, entry.textScore)
85
+ : weighted;
77
86
  const score = raw * pathBoost(entry.path);
78
87
  return {
79
88
  path: entry.path,