@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.
- package/dist/agents/tool-images.js +1 -1
- package/dist/agents/tools/document-tool.js +11 -2
- package/dist/agents/tools/message-tool.js +29 -32
- package/dist/auto-reply/reply/agent-runner-execution.js +1 -1
- package/dist/auto-reply/reply/get-reply-inline-actions.js +4 -1
- package/dist/auto-reply/reply/get-reply-run.js +4 -1
- package/dist/build-info.json +3 -3
- package/dist/control-ui/assets/index-CfybK7_N.css +1 -0
- package/dist/control-ui/assets/{index-D4TpiIHx.js → index-D7ZHRWnP.js} +212 -221
- package/dist/control-ui/assets/index-D7ZHRWnP.js.map +1 -0
- package/dist/control-ui/index.html +2 -2
- package/dist/gateway/protocol/schema/sessions-transcript.js +3 -0
- package/dist/gateway/public-chat/deliver-otp.js +2 -1
- package/dist/gateway/public-chat-api.js +7 -2
- package/dist/gateway/server-chat.js +6 -0
- package/dist/gateway/server-close.js +8 -0
- package/dist/gateway/server-methods/chat.js +47 -0
- package/dist/gateway/server-methods/public-chat.js +9 -1
- package/dist/gateway/server-methods/sessions-transcript.js +56 -3
- package/dist/gateway/server.impl.js +6 -0
- package/dist/infra/heartbeat-auth-notify.js +99 -0
- package/dist/infra/heartbeat-infra-alert.js +3 -0
- package/dist/infra/heartbeat-runner.js +13 -2
- package/dist/infra/infra-alert-events.js +16 -0
- package/dist/memory/hybrid.js +12 -3
- package/dist/memory/internal.js +116 -4
- package/dist/memory/manager.js +34 -20
- package/package.json +1 -1
- package/dist/control-ui/assets/index-BM3zZtpB.css +0 -1
- 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-
|
|
10
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
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,
|
|
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: "
|
|
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 =
|
|
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: "
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/memory/hybrid.js
CHANGED
|
@@ -9,8 +9,10 @@ export function buildFtsQuery(raw) {
|
|
|
9
9
|
return quoted.join(" AND ");
|
|
10
10
|
}
|
|
11
11
|
export function bm25RankToScore(rank) {
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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,
|