@rubytech/taskmaster 1.9.4 → 1.9.6
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/pi-embedded-runner/run/attempt.js +40 -0
- package/dist/agents/taskmaster-tools.js +3 -0
- package/dist/agents/tool-policy.js +10 -1
- package/dist/agents/tools/apikeys-tool.js +2 -2
- package/dist/agents/tools/file-delete-tool.js +20 -15
- package/dist/agents/tools/file-list-tool.js +9 -2
- package/dist/agents/tools/verify-contact-tool.js +197 -0
- package/dist/agents/workspace-migrations.js +163 -0
- package/dist/build-info.json +3 -3
- package/dist/config/defaults.js +4 -0
- package/dist/config/legacy.migrations.part-3.js +24 -0
- package/dist/config/zod-schema.js +21 -0
- package/dist/control-ui/assets/index-DpyzE2YD.js +4532 -0
- package/dist/control-ui/assets/index-DpyzE2YD.js.map +1 -0
- package/dist/control-ui/assets/index-ouo9dqKk.css +1 -0
- package/dist/control-ui/index.html +2 -2
- package/dist/gateway/control-ui.js +6 -1
- package/dist/gateway/public-chat/deliver-email.js +39 -0
- package/dist/gateway/public-chat/deliver-otp.js +59 -6
- package/dist/gateway/public-chat/deliver-sms.js +44 -0
- package/dist/gateway/public-chat/otp.js +14 -12
- package/dist/gateway/public-chat-api.js +100 -24
- package/dist/gateway/server-chat.js +5 -0
- package/dist/gateway/server-methods/access.js +11 -1
- package/dist/gateway/server-methods/apikeys.js +8 -4
- package/dist/gateway/server-methods/chat.js +14 -0
- package/dist/gateway/server-methods/public-chat.js +94 -22
- package/dist/gateway/server-methods/tailscale.js +83 -24
- package/dist/gateway/server.impl.js +5 -0
- package/dist/memory/manager.js +6 -2
- package/dist/records/records-manager.js +25 -1
- package/package.json +1 -1
- package/skills/twilio/SKILL.md +29 -0
- package/skills/twilio/references/browser-setup.md +95 -0
- package/templates/beagle/agents/admin/AGENTS.md +24 -0
- package/templates/beagle/agents/public/AGENTS.md +6 -0
- package/templates/customer/agents/admin/AGENTS.md +24 -0
- package/templates/customer/agents/public/AGENTS.md +6 -0
- package/templates/education-hero/agents/admin/AGENTS.md +184 -0
- package/templates/education-hero/agents/admin/BOOTSTRAP.md +114 -0
- package/templates/education-hero/agents/admin/HEARTBEAT.md +10 -0
- package/templates/education-hero/agents/admin/IDENTITY.md +13 -0
- package/templates/education-hero/agents/admin/SOUL.md +34 -0
- package/templates/education-hero/agents/admin/TOOLS.md +36 -0
- package/templates/education-hero/agents/admin/USER.md +13 -0
- package/templates/education-hero/agents/public/AGENTS.md +173 -0
- package/templates/education-hero/agents/public/IDENTITY.md +10 -0
- package/templates/education-hero/agents/public/SOUL.md +84 -0
- package/templates/education-hero/skills/education-hero/SKILL.md +43 -0
- package/templates/education-hero/skills/education-hero/references/admin-process.md +28 -0
- package/templates/education-hero/skills/education-hero/references/brand-voice.md +51 -0
- package/templates/education-hero/skills/education-hero/references/deregistration.md +34 -0
- package/templates/education-hero/skills/education-hero/references/educational-approach.md +28 -0
- package/templates/education-hero/skills/education-hero/references/intent-classification.md +39 -0
- package/templates/education-hero/skills/education-hero/references/la-email-analysis.md +42 -0
- package/templates/education-hero/skills/education-hero/references/legal-rights.md +37 -0
- package/templates/education-hero/skills/education-hero/references/report-writing.md +30 -0
- package/templates/education-hero/skills/interactive-tutor/SKILL.md +60 -0
- package/templates/education-hero/skills/interactive-tutor/references/assessment.md +70 -0
- package/templates/education-hero/skills/interactive-tutor/references/classroom-conduct.md +43 -0
- package/templates/education-hero/skills/interactive-tutor/references/teaching-modes.md +83 -0
- package/templates/education-hero/skills/lesson-planner/SKILL.md +49 -0
- package/templates/education-hero/skills/lesson-planner/references/context-gathering.md +41 -0
- package/templates/education-hero/skills/lesson-planner/references/plan-structure.md +94 -0
- package/templates/education-hero/skills/study-pack-builder/SKILL.md +53 -0
- package/templates/education-hero/skills/study-pack-builder/references/disaggregation.md +49 -0
- package/templates/education-hero/skills/study-pack-builder/references/materials.md +116 -0
- package/templates/maxy/agents/admin/AGENTS.md +20 -0
- package/templates/maxy/agents/public/AGENTS.md +4 -0
- package/templates/taskmaster/agents/admin/AGENTS.md +24 -0
- package/templates/taskmaster/agents/public/AGENTS.md +6 -0
- package/templates/tradesupport/agents/admin/AGENTS.md +24 -0
- package/templates/tradesupport/agents/public/AGENTS.md +6 -0
- package/dist/control-ui/assets/index-CHIqq3Nn.css +0 -1
- package/dist/control-ui/assets/index-zUaHKRVM.js +0 -4227
- package/dist/control-ui/assets/index-zUaHKRVM.js.map +0 -1
|
@@ -5,16 +5,18 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Endpoints:
|
|
7
7
|
* POST /session — create an anonymous session (returns sessionKey)
|
|
8
|
-
* POST /otp/request — request
|
|
8
|
+
* POST /otp/request — request an OTP code (phone via WhatsApp/SMS, or email via Resend)
|
|
9
9
|
* POST /otp/verify — verify OTP and get a verified session (returns sessionKey)
|
|
10
10
|
* POST /chat — send a message, receive the agent reply (sync or SSE stream)
|
|
11
11
|
* GET /chat/history — retrieve past messages for a session
|
|
12
12
|
* POST /chat/abort — cancel an in-progress agent run
|
|
13
|
+
* GET /capabilities — discover available auth methods and OTP channels
|
|
13
14
|
*
|
|
14
15
|
* Authentication mirrors the public chat widget: anonymous sessions use a
|
|
15
|
-
* client-provided identity string; verified sessions use
|
|
16
|
-
*
|
|
17
|
-
* X-Session-Key header on
|
|
16
|
+
* client-provided identity string; verified sessions use OTP sent via phone
|
|
17
|
+
* (WhatsApp with SMS fallback) or email (Resend). The sessionKey returned
|
|
18
|
+
* from /session or /otp/verify is passed via the X-Session-Key header on
|
|
19
|
+
* subsequent requests.
|
|
18
20
|
*
|
|
19
21
|
* The chat endpoint uses `dispatchInboundMessage` — the same full pipeline
|
|
20
22
|
* as the WebSocket `chat.send` handler — so filler messages, internal hooks,
|
|
@@ -34,10 +36,10 @@ import { createInternalHookEvent, triggerInternalHook } from "../hooks/internal-
|
|
|
34
36
|
import { emitAgentEvent, onAgentEvent } from "../infra/agent-events.js";
|
|
35
37
|
import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel.js";
|
|
36
38
|
import { requestOtp, verifyOtp } from "./public-chat/otp.js";
|
|
37
|
-
import { deliverOtp } from "./public-chat/deliver-otp.js";
|
|
39
|
+
import { deliverOtp, detectOtpChannels } from "./public-chat/deliver-otp.js";
|
|
38
40
|
import { buildPublicSessionKey, resolvePublicAgentId } from "./public-chat/session.js";
|
|
39
41
|
import { loadSessionEntry, readSessionMessages } from "./session-utils.js";
|
|
40
|
-
import { extractFileAttachments, sanitizeMediaForChat, stripEnvelopeFromMessages } from "./chat-sanitize.js";
|
|
42
|
+
import { extractFileAttachments, sanitizeMediaForChat, stripEnvelopeFromMessages, } from "./chat-sanitize.js";
|
|
41
43
|
import { resolveWorkspaceRoot } from "./media-http.js";
|
|
42
44
|
import { readJsonBodyOrError, sendInvalidRequest, sendJson, sendMethodNotAllowed, setSseHeaders, writeDone, } from "./http-common.js";
|
|
43
45
|
// ---------------------------------------------------------------------------
|
|
@@ -88,6 +90,10 @@ function getSessionKeyHeader(req) {
|
|
|
88
90
|
function isValidPhone(phone) {
|
|
89
91
|
return /^\+\d{7,15}$/.test(phone);
|
|
90
92
|
}
|
|
93
|
+
/** Basic email format check. */
|
|
94
|
+
function isValidEmail(email) {
|
|
95
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) && email.length <= 254;
|
|
96
|
+
}
|
|
91
97
|
/** Strip spaces, dashes, and parentheses from a phone number. */
|
|
92
98
|
function normalizePhone(raw) {
|
|
93
99
|
return raw.replace(/[\s\-()]/g, "");
|
|
@@ -216,12 +222,38 @@ async function handleOtpRequest(req, res, accountId, cfg, maxBodyBytes) {
|
|
|
216
222
|
if (body === undefined)
|
|
217
223
|
return;
|
|
218
224
|
const payload = (body && typeof body === "object" ? body : {});
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
225
|
+
// Accept `identifier` (preferred) or `phone` (backward compat).
|
|
226
|
+
const rawIdentifier = typeof payload.identifier === "string"
|
|
227
|
+
? payload.identifier.trim()
|
|
228
|
+
: typeof payload.phone === "string"
|
|
229
|
+
? payload.phone.trim()
|
|
230
|
+
: "";
|
|
231
|
+
const isEmail = rawIdentifier.includes("@");
|
|
232
|
+
const verifyMethods = cfg.publicChat?.verifyMethods ?? ["phone"];
|
|
233
|
+
let identifier;
|
|
234
|
+
if (isEmail) {
|
|
235
|
+
if (!verifyMethods.includes("email")) {
|
|
236
|
+
sendForbidden(res, "email verification is not enabled for this account");
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
identifier = rawIdentifier.toLowerCase();
|
|
240
|
+
if (!isValidEmail(identifier)) {
|
|
241
|
+
sendInvalidRequest(res, "invalid email address");
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
if (!verifyMethods.includes("phone")) {
|
|
247
|
+
sendForbidden(res, "phone verification is not enabled for this account");
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
identifier = normalizePhone(rawIdentifier);
|
|
251
|
+
if (!identifier || !isValidPhone(identifier)) {
|
|
252
|
+
sendInvalidRequest(res, "invalid phone number — use E.164 format (e.g. +447123456789)");
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
223
255
|
}
|
|
224
|
-
const result = requestOtp(
|
|
256
|
+
const result = requestOtp(identifier);
|
|
225
257
|
if (!result.ok) {
|
|
226
258
|
sendJson(res, 429, {
|
|
227
259
|
error: { message: "rate limited — try again shortly", type: "rate_limited" },
|
|
@@ -234,13 +266,13 @@ async function handleOtpRequest(req, res, accountId, cfg, maxBodyBytes) {
|
|
|
234
266
|
const agentId = resolvePublicAgentId(cfg, accountId);
|
|
235
267
|
const whatsappAccountId = resolveAgentBoundAccountId(cfg, agentId, "whatsapp") ?? undefined;
|
|
236
268
|
try {
|
|
237
|
-
await deliverOtp(
|
|
269
|
+
const delivery = await deliverOtp(identifier, result.code, whatsappAccountId);
|
|
270
|
+
sendJson(res, 200, { ok: true, channel: delivery.channel });
|
|
238
271
|
}
|
|
239
|
-
catch {
|
|
240
|
-
|
|
241
|
-
|
|
272
|
+
catch (err) {
|
|
273
|
+
const message = err instanceof Error ? err.message : "failed to send verification code";
|
|
274
|
+
sendUnavailable(res, message);
|
|
242
275
|
}
|
|
243
|
-
sendJson(res, 200, { ok: true });
|
|
244
276
|
}
|
|
245
277
|
// ---------------------------------------------------------------------------
|
|
246
278
|
// Route: POST /otp/verify
|
|
@@ -258,21 +290,38 @@ async function handleOtpVerify(req, res, accountId, cfg, maxBodyBytes) {
|
|
|
258
290
|
if (body === undefined)
|
|
259
291
|
return;
|
|
260
292
|
const payload = (body && typeof body === "object" ? body : {});
|
|
261
|
-
|
|
293
|
+
// Accept `identifier` (preferred) or `phone` (backward compat).
|
|
294
|
+
const rawIdentifier = typeof payload.identifier === "string"
|
|
295
|
+
? payload.identifier.trim()
|
|
296
|
+
: typeof payload.phone === "string"
|
|
297
|
+
? payload.phone.trim()
|
|
298
|
+
: "";
|
|
262
299
|
const code = typeof payload.code === "string" ? payload.code.trim() : "";
|
|
263
300
|
const name = typeof payload.name === "string" ? payload.name.trim() : undefined;
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
301
|
+
const isEmail = rawIdentifier.includes("@");
|
|
302
|
+
let identifier;
|
|
303
|
+
if (isEmail) {
|
|
304
|
+
identifier = rawIdentifier.toLowerCase();
|
|
305
|
+
if (!isValidEmail(identifier)) {
|
|
306
|
+
sendInvalidRequest(res, "invalid email address");
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
identifier = normalizePhone(rawIdentifier);
|
|
312
|
+
if (!identifier || !isValidPhone(identifier)) {
|
|
313
|
+
sendInvalidRequest(res, "invalid phone number");
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
267
316
|
}
|
|
268
317
|
if (!code) {
|
|
269
318
|
sendInvalidRequest(res, "code is required");
|
|
270
319
|
return;
|
|
271
320
|
}
|
|
272
|
-
const result = verifyOtp(
|
|
321
|
+
const result = verifyOtp(identifier, code);
|
|
273
322
|
if (!result.ok) {
|
|
274
323
|
const messages = {
|
|
275
|
-
not_found: "no pending verification for this
|
|
324
|
+
not_found: "no pending verification for this identifier",
|
|
276
325
|
expired: "verification code expired",
|
|
277
326
|
max_attempts: "too many attempts — request a new code",
|
|
278
327
|
invalid: "incorrect code",
|
|
@@ -286,11 +335,13 @@ async function handleOtpVerify(req, res, accountId, cfg, maxBodyBytes) {
|
|
|
286
335
|
return;
|
|
287
336
|
}
|
|
288
337
|
const agentId = resolvePublicAgentId(cfg, accountId);
|
|
289
|
-
const sessionKey = buildPublicSessionKey(agentId,
|
|
338
|
+
const sessionKey = buildPublicSessionKey(agentId, identifier);
|
|
290
339
|
sendJson(res, 200, {
|
|
291
340
|
session_key: sessionKey,
|
|
292
341
|
agent_id: agentId,
|
|
293
|
-
|
|
342
|
+
identifier,
|
|
343
|
+
// Backward-compat: include named field matching the identifier type.
|
|
344
|
+
...(isEmail ? { email: identifier } : { phone: identifier }),
|
|
294
345
|
...(name ? { name } : {}),
|
|
295
346
|
});
|
|
296
347
|
}
|
|
@@ -692,6 +743,28 @@ async function handleChatAbort(req, res, maxBodyBytes) {
|
|
|
692
743
|
sendJson(res, 200, { ok: true, run_id: runId ?? null });
|
|
693
744
|
}
|
|
694
745
|
// ---------------------------------------------------------------------------
|
|
746
|
+
// Route: GET /capabilities
|
|
747
|
+
// ---------------------------------------------------------------------------
|
|
748
|
+
async function handleCapabilities(req, res, accountId, cfg) {
|
|
749
|
+
if (req.method !== "GET") {
|
|
750
|
+
sendMethodNotAllowed(res, "GET");
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
const authMode = cfg.publicChat?.auth ?? "anonymous";
|
|
754
|
+
const verifyMethods = cfg.publicChat?.verifyMethods ?? ["phone"];
|
|
755
|
+
const agentId = resolvePublicAgentId(cfg, accountId);
|
|
756
|
+
const whatsappAccountId = resolveAgentBoundAccountId(cfg, agentId, "whatsapp") ?? undefined;
|
|
757
|
+
const channels = detectOtpChannels(whatsappAccountId);
|
|
758
|
+
sendJson(res, 200, {
|
|
759
|
+
auth: authMode,
|
|
760
|
+
verify_methods: verifyMethods,
|
|
761
|
+
otp: {
|
|
762
|
+
available: channels.length > 0,
|
|
763
|
+
channels,
|
|
764
|
+
},
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
// ---------------------------------------------------------------------------
|
|
695
768
|
// Main handler
|
|
696
769
|
// ---------------------------------------------------------------------------
|
|
697
770
|
/**
|
|
@@ -741,6 +814,9 @@ export async function handlePublicChatApiRequest(req, res, opts) {
|
|
|
741
814
|
case "otp/verify":
|
|
742
815
|
await handleOtpVerify(req, res, accountId, cfg, maxBodyBytes);
|
|
743
816
|
return true;
|
|
817
|
+
case "capabilities":
|
|
818
|
+
await handleCapabilities(req, res, accountId, cfg);
|
|
819
|
+
return true;
|
|
744
820
|
case "chat":
|
|
745
821
|
await handleChat(req, res, accountId, cfg, maxBodyBytes);
|
|
746
822
|
return true;
|
|
@@ -47,12 +47,14 @@ export function createChatRunState() {
|
|
|
47
47
|
const deltaSentAt = new Map();
|
|
48
48
|
const abortedRuns = new Map();
|
|
49
49
|
const finalHadContent = new Map();
|
|
50
|
+
const finalTexts = new Map();
|
|
50
51
|
const clear = () => {
|
|
51
52
|
registry.clear();
|
|
52
53
|
buffers.clear();
|
|
53
54
|
deltaSentAt.clear();
|
|
54
55
|
abortedRuns.clear();
|
|
55
56
|
finalHadContent.clear();
|
|
57
|
+
finalTexts.clear();
|
|
56
58
|
};
|
|
57
59
|
return {
|
|
58
60
|
registry,
|
|
@@ -60,6 +62,7 @@ export function createChatRunState() {
|
|
|
60
62
|
deltaSentAt,
|
|
61
63
|
abortedRuns,
|
|
62
64
|
finalHadContent,
|
|
65
|
+
finalTexts,
|
|
63
66
|
clear,
|
|
64
67
|
};
|
|
65
68
|
}
|
|
@@ -97,6 +100,8 @@ export function createAgentEventHandler({ broadcast, nodeSendToSession, agentRun
|
|
|
97
100
|
// Record whether the streaming buffer had content so the chat.send .then()
|
|
98
101
|
// handler knows whether it needs to broadcast the dispatcher's final reply.
|
|
99
102
|
chatRunState.finalHadContent.set(clientRunId, !!text);
|
|
103
|
+
if (text)
|
|
104
|
+
chatRunState.finalTexts.set(clientRunId, text);
|
|
100
105
|
if (jobState === "done") {
|
|
101
106
|
const payload = {
|
|
102
107
|
runId: clientRunId,
|
|
@@ -196,7 +196,17 @@ export const accessHandlers = {
|
|
|
196
196
|
*/
|
|
197
197
|
"access.setAccountPin": async ({ params, respond }) => {
|
|
198
198
|
try {
|
|
199
|
-
|
|
199
|
+
// Normalise workspace name to match sanitiseName() in workspaces.ts —
|
|
200
|
+
// the UI may pass the raw user-typed name (e.g. "Joe's Store") while
|
|
201
|
+
// the workspace was created with the sanitised slug ("joes-store").
|
|
202
|
+
const rawWorkspace = params.workspace?.trim();
|
|
203
|
+
const workspace = rawWorkspace
|
|
204
|
+
? rawWorkspace
|
|
205
|
+
.toLowerCase()
|
|
206
|
+
.replace(/[^a-z0-9_-]/g, "-")
|
|
207
|
+
.replace(/-+/g, "-")
|
|
208
|
+
.replace(/^-|-$/g, "")
|
|
209
|
+
: "";
|
|
200
210
|
const pin = params.pin?.trim();
|
|
201
211
|
if (!workspace) {
|
|
202
212
|
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "workspace is required"));
|
|
@@ -16,9 +16,7 @@ import { readConfigFileSnapshot, writeConfigFile } from "../../config/config.js"
|
|
|
16
16
|
import { ErrorCodes, errorShape } from "../protocol/index.js";
|
|
17
17
|
import { formatForLog } from "../ws-log.js";
|
|
18
18
|
/** Providers whose API keys are stored as auth profiles (type: api_key). */
|
|
19
|
-
const AUTH_PROFILE_PROVIDERS = new Set([
|
|
20
|
-
"anthropic", "openai", "google", "replicate", "hume",
|
|
21
|
-
]);
|
|
19
|
+
const AUTH_PROFILE_PROVIDERS = new Set(["anthropic", "openai", "google", "replicate", "hume"]);
|
|
22
20
|
const PROVIDER_CATALOG = [
|
|
23
21
|
{ id: "anthropic", name: "Anthropic", category: "AI Model", primary: true },
|
|
24
22
|
{ id: "google", name: "Google", category: "Voice & Video", primary: true },
|
|
@@ -28,6 +26,7 @@ const PROVIDER_CATALOG = [
|
|
|
28
26
|
{ id: "hume", name: "Hume", category: "Voice" },
|
|
29
27
|
{ id: "brave", name: "Brave", category: "Web Search" },
|
|
30
28
|
{ id: "elevenlabs", name: "ElevenLabs", category: "Voice" },
|
|
29
|
+
{ id: "resend", name: "Resend", category: "Email" },
|
|
31
30
|
];
|
|
32
31
|
const VALID_PROVIDER_IDS = new Set(PROVIDER_CATALOG.map((p) => p.id));
|
|
33
32
|
export const apikeysHandlers = {
|
|
@@ -38,7 +37,12 @@ export const apikeysHandlers = {
|
|
|
38
37
|
const disabledMap = snapshot.config.apiKeysDisabled ?? {};
|
|
39
38
|
const providers = PROVIDER_CATALOG.map((p) => {
|
|
40
39
|
const raw = storedKeys[p.id]?.trim();
|
|
41
|
-
return {
|
|
40
|
+
return {
|
|
41
|
+
...p,
|
|
42
|
+
hasKey: Boolean(raw),
|
|
43
|
+
disabled: Boolean(disabledMap[p.id]),
|
|
44
|
+
key: raw || undefined,
|
|
45
|
+
};
|
|
42
46
|
});
|
|
43
47
|
respond(true, { providers });
|
|
44
48
|
}
|
|
@@ -18,6 +18,7 @@ import { loadSessionEntry, readSessionMessages, resolveSessionModelRef } from ".
|
|
|
18
18
|
import { sanitizeMediaForChat, stripEnvelopeFromMessages } from "../chat-sanitize.js";
|
|
19
19
|
import { resolveWorkspaceRoot } from "../media-http.js";
|
|
20
20
|
import { formatForLog } from "../ws-log.js";
|
|
21
|
+
import { fireSuggestion } from "../../suggestions/broadcast.js";
|
|
21
22
|
function resolveTranscriptPath(params) {
|
|
22
23
|
const { sessionId, storePath, sessionFile } = params;
|
|
23
24
|
if (sessionFile)
|
|
@@ -701,6 +702,19 @@ export const chatHandlers = {
|
|
|
701
702
|
cfg,
|
|
702
703
|
}));
|
|
703
704
|
}
|
|
705
|
+
// Fire suggestion chips for the next user interaction
|
|
706
|
+
const streamedText = context.chatFinalTexts.get(clientRunId) ?? "";
|
|
707
|
+
context.chatFinalTexts.delete(clientRunId);
|
|
708
|
+
const lastAssistantReply = outboundText || streamedText;
|
|
709
|
+
if (lastAssistantReply) {
|
|
710
|
+
fireSuggestion({
|
|
711
|
+
sessionKey: p.sessionKey,
|
|
712
|
+
broadcast: context.broadcast,
|
|
713
|
+
cfg,
|
|
714
|
+
lastUserMessage: p.message,
|
|
715
|
+
lastAssistantReply,
|
|
716
|
+
});
|
|
717
|
+
}
|
|
704
718
|
context.dedupe.set(`chat:${clientRunId}`, {
|
|
705
719
|
ts: Date.now(),
|
|
706
720
|
ok: true,
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { loadConfig } from "../../config/config.js";
|
|
5
5
|
import { resolveAgentBoundAccountId } from "../../routing/bindings.js";
|
|
6
|
+
import { generateGreeting } from "../../suggestions/greeting.js";
|
|
6
7
|
import { ErrorCodes, errorShape } from "../protocol/index.js";
|
|
7
8
|
import { requestOtp, verifyOtp } from "../public-chat/otp.js";
|
|
8
9
|
import { deliverOtp } from "../public-chat/deliver-otp.js";
|
|
@@ -15,6 +16,10 @@ function normalizePhone(raw) {
|
|
|
15
16
|
function isValidPhone(phone) {
|
|
16
17
|
return /^\+\d{7,15}$/.test(phone);
|
|
17
18
|
}
|
|
19
|
+
/** Basic email format check. */
|
|
20
|
+
function isValidEmail(email) {
|
|
21
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) && email.length <= 254;
|
|
22
|
+
}
|
|
18
23
|
function validateAccountId(raw) {
|
|
19
24
|
if (typeof raw !== "string")
|
|
20
25
|
return null;
|
|
@@ -25,22 +30,48 @@ function validateAccountId(raw) {
|
|
|
25
30
|
}
|
|
26
31
|
export const publicChatHandlers = {
|
|
27
32
|
/**
|
|
28
|
-
* Request an OTP code — sends a 6-digit code
|
|
29
|
-
*
|
|
33
|
+
* Request an OTP code — sends a 6-digit code via WhatsApp/SMS (phone) or
|
|
34
|
+
* Resend (email).
|
|
35
|
+
* Params: { identifier: string } or { phone: string } (backward compat)
|
|
30
36
|
*/
|
|
31
37
|
"public.otp.request": async ({ params, respond, context }) => {
|
|
32
|
-
const
|
|
38
|
+
const rawIdentifier = typeof params.identifier === "string"
|
|
39
|
+
? params.identifier.trim()
|
|
40
|
+
: typeof params.phone === "string"
|
|
41
|
+
? params.phone.trim()
|
|
42
|
+
: "";
|
|
33
43
|
const accountId = validateAccountId(params.accountId);
|
|
34
|
-
if (!phone || !isValidPhone(phone)) {
|
|
35
|
-
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid phone number"));
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
38
44
|
const cfg = loadConfig();
|
|
39
45
|
if (!cfg.publicChat?.enabled) {
|
|
40
46
|
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "public chat disabled"));
|
|
41
47
|
return;
|
|
42
48
|
}
|
|
43
|
-
const
|
|
49
|
+
const isEmail = rawIdentifier.includes("@");
|
|
50
|
+
const verifyMethods = cfg.publicChat?.verifyMethods ?? ["phone"];
|
|
51
|
+
let identifier;
|
|
52
|
+
if (isEmail) {
|
|
53
|
+
if (!verifyMethods.includes("email")) {
|
|
54
|
+
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "email verification is not enabled"));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
identifier = rawIdentifier.toLowerCase();
|
|
58
|
+
if (!isValidEmail(identifier)) {
|
|
59
|
+
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid email address"));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
if (!verifyMethods.includes("phone")) {
|
|
65
|
+
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "phone verification is not enabled"));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
identifier = normalizePhone(rawIdentifier);
|
|
69
|
+
if (!identifier || !isValidPhone(identifier)) {
|
|
70
|
+
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid phone number"));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const result = requestOtp(identifier);
|
|
44
75
|
if (!result.ok) {
|
|
45
76
|
respond(false, { retryAfterMs: result.retryAfterMs }, errorShape(ErrorCodes.INVALID_REQUEST, "rate limited — try again shortly"));
|
|
46
77
|
return;
|
|
@@ -52,27 +83,44 @@ export const publicChatHandlers = {
|
|
|
52
83
|
? (resolveAgentBoundAccountId(cfg, agentId, "whatsapp") ?? undefined)
|
|
53
84
|
: undefined;
|
|
54
85
|
try {
|
|
55
|
-
await deliverOtp(
|
|
86
|
+
const delivery = await deliverOtp(identifier, result.code, whatsappAccountId);
|
|
87
|
+
respond(true, { ok: true, channel: delivery.channel });
|
|
56
88
|
}
|
|
57
89
|
catch (err) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
90
|
+
const message = err instanceof Error ? err.message : "failed to send verification code";
|
|
91
|
+
context.logGateway.warn(`public-chat OTP delivery failed: ${message}`);
|
|
92
|
+
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, message));
|
|
61
93
|
}
|
|
62
|
-
respond(true, { ok: true });
|
|
63
94
|
},
|
|
64
95
|
/**
|
|
65
96
|
* Verify an OTP code and return the session key.
|
|
66
|
-
* Params: {
|
|
97
|
+
* Params: { identifier: string, code: string, accountId: string, name?: string }
|
|
98
|
+
* (or { phone: string } for backward compat)
|
|
67
99
|
*/
|
|
68
100
|
"public.otp.verify": async ({ params, respond }) => {
|
|
69
|
-
const
|
|
101
|
+
const rawIdentifier = typeof params.identifier === "string"
|
|
102
|
+
? params.identifier.trim()
|
|
103
|
+
: typeof params.phone === "string"
|
|
104
|
+
? params.phone.trim()
|
|
105
|
+
: "";
|
|
70
106
|
const code = typeof params.code === "string" ? params.code.trim() : "";
|
|
71
107
|
const name = typeof params.name === "string" ? params.name.trim() : undefined;
|
|
72
108
|
const accountId = validateAccountId(params.accountId);
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
109
|
+
const isEmail = rawIdentifier.includes("@");
|
|
110
|
+
let identifier;
|
|
111
|
+
if (isEmail) {
|
|
112
|
+
identifier = rawIdentifier.toLowerCase();
|
|
113
|
+
if (!isValidEmail(identifier)) {
|
|
114
|
+
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid email address"));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
identifier = normalizePhone(rawIdentifier);
|
|
120
|
+
if (!identifier || !isValidPhone(identifier)) {
|
|
121
|
+
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid phone number"));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
76
124
|
}
|
|
77
125
|
if (!code) {
|
|
78
126
|
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "code required"));
|
|
@@ -87,10 +135,10 @@ export const publicChatHandlers = {
|
|
|
87
135
|
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "public chat disabled"));
|
|
88
136
|
return;
|
|
89
137
|
}
|
|
90
|
-
const result = verifyOtp(
|
|
138
|
+
const result = verifyOtp(identifier, code);
|
|
91
139
|
if (!result.ok) {
|
|
92
140
|
const messages = {
|
|
93
|
-
not_found: "no pending verification for this
|
|
141
|
+
not_found: "no pending verification for this identifier",
|
|
94
142
|
expired: "verification code expired",
|
|
95
143
|
max_attempts: "too many attempts — request a new code",
|
|
96
144
|
invalid: "incorrect code",
|
|
@@ -99,12 +147,14 @@ export const publicChatHandlers = {
|
|
|
99
147
|
return;
|
|
100
148
|
}
|
|
101
149
|
const agentId = resolvePublicAgentId(cfg, accountId);
|
|
102
|
-
const sessionKey = buildPublicSessionKey(agentId,
|
|
150
|
+
const sessionKey = buildPublicSessionKey(agentId, identifier);
|
|
103
151
|
respond(true, {
|
|
104
152
|
ok: true,
|
|
105
153
|
sessionKey,
|
|
106
154
|
agentId,
|
|
107
|
-
|
|
155
|
+
identifier,
|
|
156
|
+
// Backward compat: include named field matching the identifier type.
|
|
157
|
+
...(isEmail ? { email: identifier } : { phone: identifier }),
|
|
108
158
|
name,
|
|
109
159
|
});
|
|
110
160
|
},
|
|
@@ -137,4 +187,26 @@ export const publicChatHandlers = {
|
|
|
137
187
|
agentId,
|
|
138
188
|
});
|
|
139
189
|
},
|
|
190
|
+
/**
|
|
191
|
+
* Generate a proactive greeting using the agent's identity and persona.
|
|
192
|
+
* Params: { accountId: string }
|
|
193
|
+
* Returns: { ok: true, greeting: string }
|
|
194
|
+
*/
|
|
195
|
+
"public.greeting.generate": async ({ params, respond }) => {
|
|
196
|
+
const accountId = validateAccountId(params.accountId);
|
|
197
|
+
if (!accountId) {
|
|
198
|
+
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "accountId required"));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const cfg = loadConfig();
|
|
202
|
+
const agentId = resolvePublicAgentId(cfg, accountId);
|
|
203
|
+
try {
|
|
204
|
+
const result = await generateGreeting({ cfg, agentId });
|
|
205
|
+
respond(true, result);
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
const message = err instanceof Error ? err.message : "greeting generation failed";
|
|
209
|
+
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, message));
|
|
210
|
+
}
|
|
211
|
+
},
|
|
140
212
|
};
|
|
@@ -108,36 +108,77 @@ export const tailscaleHandlers = {
|
|
|
108
108
|
* `loggedIn` becomes true.
|
|
109
109
|
*/
|
|
110
110
|
"tailscale.enable": async ({ respond, context }) => {
|
|
111
|
+
// Matches auth URLs across Tailscale versions:
|
|
112
|
+
// - https://login.tailscale.com/a/<code>
|
|
113
|
+
// - https://login.tailscale.com/authorize/<code>
|
|
114
|
+
// - any other login.tailscale.com path with an auth token
|
|
115
|
+
const authUrlPattern = /https:\/\/login\.tailscale\.com\/[a-z]+\/[A-Za-z0-9_-]+/;
|
|
116
|
+
const extractAuthUrl = (output) => output.match(authUrlPattern)?.[0];
|
|
117
|
+
const captureExecOutput = (err) => {
|
|
118
|
+
const errObj = err;
|
|
119
|
+
return {
|
|
120
|
+
stdout: typeof errObj.stdout === "string" ? errObj.stdout : "",
|
|
121
|
+
stderr: typeof errObj.stderr === "string" ? errObj.stderr : "",
|
|
122
|
+
message: typeof errObj.message === "string" ? errObj.message : "",
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
const needsSudo = (output) => {
|
|
126
|
+
const lower = output.toLowerCase();
|
|
127
|
+
return (lower.includes("access denied") ||
|
|
128
|
+
lower.includes("permission denied") ||
|
|
129
|
+
lower.includes("use 'sudo tailscale") ||
|
|
130
|
+
lower.includes("requires root"));
|
|
131
|
+
};
|
|
132
|
+
// Run `tailscale up` (with optional extra args) and capture output.
|
|
133
|
+
// If the command fails with a permission error, retries with `sudo -n`.
|
|
134
|
+
// `tailscale up` blocks until auth completes, so timeouts are expected —
|
|
135
|
+
// the auth URL appears in stdout/stderr before the timeout kills it.
|
|
136
|
+
const runTailscaleUp = async (binary, extraArgs = []) => {
|
|
137
|
+
const args = ["up", ...extraArgs];
|
|
138
|
+
const execOpts = { timeoutMs: 60_000, maxBuffer: 100_000 };
|
|
139
|
+
const result = await runExec(binary, args, execOpts).catch((err) => captureExecOutput(err));
|
|
140
|
+
const output = `${result.stdout}\n${result.stderr}`;
|
|
141
|
+
if (!needsSudo(output))
|
|
142
|
+
return output;
|
|
143
|
+
// Permission denied — retry with sudo -n (non-interactive)
|
|
144
|
+
context.logGateway.info(`tailscale.enable: permission denied, retrying with sudo: ${args.join(" ")}`);
|
|
145
|
+
const sudoResult = await runExec("sudo", ["-n", binary, ...args], execOpts).catch((err) => captureExecOutput(err));
|
|
146
|
+
return `${sudoResult.stdout}\n${sudoResult.stderr}`;
|
|
147
|
+
};
|
|
111
148
|
try {
|
|
112
149
|
const binary = await findTailscaleBinary();
|
|
113
150
|
if (!binary) {
|
|
114
151
|
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "Tailscale is not installed on this device"));
|
|
115
152
|
return;
|
|
116
153
|
}
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
154
|
+
// ── Pre-check: is tailscaled reachable? ──
|
|
155
|
+
// On Linux (Pi), tailscaled runs as a systemd service. After a package
|
|
156
|
+
// upgrade it may need restarting. Detect this early with a clear message
|
|
157
|
+
// instead of falling through to a generic "no URL" error.
|
|
158
|
+
try {
|
|
159
|
+
await runExec(binary, ["status", "--json"], { timeoutMs: 5_000, maxBuffer: 100_000 });
|
|
160
|
+
}
|
|
161
|
+
catch (statusErr) {
|
|
162
|
+
const { stderr, message } = captureExecOutput(statusErr);
|
|
163
|
+
const detail = `${stderr}\n${message}`.toLowerCase();
|
|
164
|
+
if (detail.includes("connection refused") ||
|
|
165
|
+
detail.includes("no such file or directory") ||
|
|
166
|
+
detail.includes("connect:") ||
|
|
167
|
+
detail.includes("is tailscaled running")) {
|
|
168
|
+
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "The Tailscale service (tailscaled) is not running. " +
|
|
169
|
+
"Try restarting it: sudo systemctl restart tailscaled"));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
// Other status errors (e.g. NeedsLogin) are fine — continue to `tailscale up`
|
|
173
|
+
}
|
|
174
|
+
// ── Attempt 1: `tailscale up` ──
|
|
175
|
+
const combined = await runTailscaleUp(binary);
|
|
176
|
+
const authUrl = extractAuthUrl(combined);
|
|
177
|
+
if (authUrl) {
|
|
178
|
+
respond(true, { authUrl });
|
|
138
179
|
return;
|
|
139
180
|
}
|
|
140
|
-
// No auth URL found —
|
|
181
|
+
// No auth URL found — check if already logged in
|
|
141
182
|
try {
|
|
142
183
|
const status = await readTailscaleStatusJson();
|
|
143
184
|
if (status.BackendState === "Running") {
|
|
@@ -148,8 +189,26 @@ export const tailscaleHandlers = {
|
|
|
148
189
|
catch {
|
|
149
190
|
// ignore
|
|
150
191
|
}
|
|
151
|
-
|
|
152
|
-
|
|
192
|
+
// ── Attempt 2: `tailscale up --force-reauth` ──
|
|
193
|
+
// After a software upgrade or expired auth, the node may be in a stale
|
|
194
|
+
// state where plain `tailscale up` doesn't produce a new auth URL.
|
|
195
|
+
// --force-reauth forces a fresh login flow that always emits a URL.
|
|
196
|
+
context.logGateway.info("tailscale.enable: no auth URL from initial attempt, retrying with --force-reauth");
|
|
197
|
+
const retryCombined = await runTailscaleUp(binary, ["--force-reauth"]);
|
|
198
|
+
const retryUrl = extractAuthUrl(retryCombined);
|
|
199
|
+
if (retryUrl) {
|
|
200
|
+
respond(true, { authUrl: retryUrl });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
// ── Still nothing — surface diagnostic info ──
|
|
204
|
+
const allOutput = `${combined}\n${retryCombined}`.trim();
|
|
205
|
+
context.logGateway.warn(`tailscale.enable: no auth URL found in output: ${allOutput.slice(0, 500)}`);
|
|
206
|
+
// Include a snippet of what Tailscale actually said, so the user can diagnose
|
|
207
|
+
const snippet = allOutput.replace(/\s+/g, " ").trim().slice(0, 200);
|
|
208
|
+
const detail = snippet
|
|
209
|
+
? ` Tailscale output: ${snippet}`
|
|
210
|
+
: " No output received from Tailscale.";
|
|
211
|
+
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, `Could not start Tailscale login — no authentication URL received.${detail}`));
|
|
153
212
|
}
|
|
154
213
|
catch (err) {
|
|
155
214
|
context.logGateway.warn(`tailscale.enable failed: ${err instanceof Error ? err.message : String(err)}`);
|