@rubytech/taskmaster 1.9.3 → 1.9.5

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 (79) hide show
  1. package/dist/agents/auth-profiles/store.js +10 -2
  2. package/dist/agents/pi-embedded-runner/run/attempt.js +40 -0
  3. package/dist/agents/taskmaster-tools.js +3 -0
  4. package/dist/agents/tool-policy.js +10 -1
  5. package/dist/agents/tools/apikeys-tool.js +2 -2
  6. package/dist/agents/tools/file-delete-tool.js +20 -15
  7. package/dist/agents/tools/file-list-tool.js +9 -2
  8. package/dist/agents/tools/image-generate-api.js +3 -3
  9. package/dist/agents/tools/image-generate-tool.js +3 -3
  10. package/dist/agents/tools/verify-contact-tool.js +197 -0
  11. package/dist/agents/workspace-migrations.js +163 -0
  12. package/dist/build-info.json +3 -3
  13. package/dist/config/defaults.js +4 -0
  14. package/dist/config/legacy.migrations.part-3.js +24 -0
  15. package/dist/config/zod-schema.js +21 -0
  16. package/dist/control-ui/assets/index-DpyzE2YD.js +4532 -0
  17. package/dist/control-ui/assets/index-DpyzE2YD.js.map +1 -0
  18. package/dist/control-ui/assets/index-ouo9dqKk.css +1 -0
  19. package/dist/control-ui/index.html +2 -2
  20. package/dist/gateway/control-ui.js +6 -1
  21. package/dist/gateway/public-chat/deliver-email.js +39 -0
  22. package/dist/gateway/public-chat/deliver-otp.js +59 -6
  23. package/dist/gateway/public-chat/deliver-sms.js +44 -0
  24. package/dist/gateway/public-chat/otp.js +14 -12
  25. package/dist/gateway/public-chat-api.js +100 -24
  26. package/dist/gateway/server-chat.js +5 -0
  27. package/dist/gateway/server-methods/access.js +11 -1
  28. package/dist/gateway/server-methods/apikeys.js +8 -4
  29. package/dist/gateway/server-methods/chat.js +14 -0
  30. package/dist/gateway/server-methods/public-chat.js +94 -22
  31. package/dist/gateway/server-methods/tailscale.js +65 -12
  32. package/dist/gateway/server.impl.js +5 -0
  33. package/dist/memory/manager.js +6 -2
  34. package/dist/records/records-manager.js +25 -1
  35. package/package.json +1 -1
  36. package/skills/twilio/SKILL.md +29 -0
  37. package/skills/twilio/references/browser-setup.md +95 -0
  38. package/templates/beagle/agents/admin/AGENTS.md +24 -0
  39. package/templates/beagle/agents/public/AGENTS.md +6 -0
  40. package/templates/customer/agents/admin/AGENTS.md +24 -0
  41. package/templates/customer/agents/public/AGENTS.md +6 -0
  42. package/templates/education-hero/agents/admin/AGENTS.md +184 -0
  43. package/templates/education-hero/agents/admin/BOOTSTRAP.md +114 -0
  44. package/templates/education-hero/agents/admin/HEARTBEAT.md +10 -0
  45. package/templates/education-hero/agents/admin/IDENTITY.md +13 -0
  46. package/templates/education-hero/agents/admin/SOUL.md +34 -0
  47. package/templates/education-hero/agents/admin/TOOLS.md +36 -0
  48. package/templates/education-hero/agents/admin/USER.md +13 -0
  49. package/templates/education-hero/agents/public/AGENTS.md +173 -0
  50. package/templates/education-hero/agents/public/IDENTITY.md +10 -0
  51. package/templates/education-hero/agents/public/SOUL.md +84 -0
  52. package/templates/education-hero/skills/education-hero/SKILL.md +43 -0
  53. package/templates/education-hero/skills/education-hero/references/admin-process.md +28 -0
  54. package/templates/education-hero/skills/education-hero/references/brand-voice.md +51 -0
  55. package/templates/education-hero/skills/education-hero/references/deregistration.md +34 -0
  56. package/templates/education-hero/skills/education-hero/references/educational-approach.md +28 -0
  57. package/templates/education-hero/skills/education-hero/references/intent-classification.md +39 -0
  58. package/templates/education-hero/skills/education-hero/references/la-email-analysis.md +42 -0
  59. package/templates/education-hero/skills/education-hero/references/legal-rights.md +37 -0
  60. package/templates/education-hero/skills/education-hero/references/report-writing.md +30 -0
  61. package/templates/education-hero/skills/interactive-tutor/SKILL.md +60 -0
  62. package/templates/education-hero/skills/interactive-tutor/references/assessment.md +70 -0
  63. package/templates/education-hero/skills/interactive-tutor/references/classroom-conduct.md +43 -0
  64. package/templates/education-hero/skills/interactive-tutor/references/teaching-modes.md +83 -0
  65. package/templates/education-hero/skills/lesson-planner/SKILL.md +49 -0
  66. package/templates/education-hero/skills/lesson-planner/references/context-gathering.md +41 -0
  67. package/templates/education-hero/skills/lesson-planner/references/plan-structure.md +94 -0
  68. package/templates/education-hero/skills/study-pack-builder/SKILL.md +53 -0
  69. package/templates/education-hero/skills/study-pack-builder/references/disaggregation.md +49 -0
  70. package/templates/education-hero/skills/study-pack-builder/references/materials.md +116 -0
  71. package/templates/maxy/agents/admin/AGENTS.md +20 -0
  72. package/templates/maxy/agents/public/AGENTS.md +4 -0
  73. package/templates/taskmaster/agents/admin/AGENTS.md +24 -0
  74. package/templates/taskmaster/agents/public/AGENTS.md +6 -0
  75. package/templates/tradesupport/agents/admin/AGENTS.md +24 -0
  76. package/templates/tradesupport/agents/public/AGENTS.md +6 -0
  77. package/dist/control-ui/assets/index-CHIqq3Nn.css +0 -1
  78. package/dist/control-ui/assets/index-zUaHKRVM.js +0 -4227
  79. package/dist/control-ui/assets/index-zUaHKRVM.js.map +0 -1
@@ -134,7 +134,9 @@ function mergeAuthProfileStores(base, override) {
134
134
  !override.usageStats) {
135
135
  return base;
136
136
  }
137
- // Merge profiles, preferring the fresher credential for OAuth/token types
137
+ // Merge profiles, preferring the fresher credential for OAuth/token types.
138
+ // For api_key profiles, prefer base (main store) — centralized API key
139
+ // management writes there, and agent stores only have inherited copies.
138
140
  const mergedProfiles = { ...base.profiles };
139
141
  for (const [profileId, overrideCred] of Object.entries(override.profiles)) {
140
142
  const baseCred = base.profiles[profileId];
@@ -142,8 +144,14 @@ function mergeAuthProfileStores(base, override) {
142
144
  // No conflict — use override
143
145
  mergedProfiles[profileId] = overrideCred;
144
146
  }
147
+ else if (baseCred.type === "api_key" && overrideCred.type === "api_key") {
148
+ // API keys: base (main store) is authoritative — applyApiKeys() writes
149
+ // the centralized key there. Agent stores only have stale inherited
150
+ // copies. Always prefer base so key updates propagate immediately.
151
+ // (keep base — already in mergedProfiles)
152
+ }
145
153
  else {
146
- // Both have this profile — prefer the one with later expiry (fresher token)
154
+ // OAuth/token: prefer the one with later expiry (fresher token)
147
155
  const baseExpiry = getCredentialExpiry(baseCred);
148
156
  const overrideExpiry = getCredentialExpiry(overrideCred);
149
157
  if (overrideExpiry >= baseExpiry) {
@@ -486,6 +486,46 @@ export async function runEmbeddedAttempt(params) {
486
486
  ? validateAnthropicTurns(validatedGemini)
487
487
  : validatedGemini;
488
488
  const limited = limitHistoryTurns(validated, getEffectiveHistoryLimit(params.sessionKey, params.config));
489
+ // On the first turn of a public-chat session, inject the configured
490
+ // greeting as a synthetic assistant message so the agent knows what
491
+ // the visitor is responding to and treats it as its own opening message.
492
+ // Greetings are keyed by accountId (workspace), not agentId, so we
493
+ // resolve which accountId maps to this session's agent.
494
+ if (limited.length === 0 && params.config?.publicChat?.greetings && params.sessionKey) {
495
+ const greetings = params.config.publicChat.greetings;
496
+ const { sessionAgentId } = resolveSessionAgentIds({
497
+ sessionKey: params.sessionKey,
498
+ config: params.config,
499
+ });
500
+ // Find greeting: try agentId first, then find the accountId whose
501
+ // public agent matches this session's agent.
502
+ let greetingText = greetings[sessionAgentId];
503
+ if (!greetingText) {
504
+ for (const [accountId, text] of Object.entries(greetings)) {
505
+ if (!text)
506
+ continue;
507
+ try {
508
+ const { resolvePublicAgentId } = await import("../../../gateway/public-chat/session.js");
509
+ const mapped = resolvePublicAgentId(params.config, accountId);
510
+ if (mapped === sessionAgentId) {
511
+ greetingText = text;
512
+ break;
513
+ }
514
+ }
515
+ catch {
516
+ break;
517
+ }
518
+ }
519
+ }
520
+ if (greetingText) {
521
+ log.info(`Injecting greeting as assistant message: agentId=${sessionAgentId} greeting="${greetingText.slice(0, 60)}"`);
522
+ limited.unshift({
523
+ role: "assistant",
524
+ content: [{ type: "text", text: greetingText }],
525
+ timestamp: 0,
526
+ });
527
+ }
528
+ }
489
529
  cacheTrace?.recordStage("session:limited", { messages: limited });
490
530
  if (limited.length > 0) {
491
531
  activeSession.agent.replaceMessages(limited);
@@ -34,6 +34,7 @@ import { createSkillReadTool } from "./tools/skill-read-tool.js";
34
34
  import { createApiKeysTool } from "./tools/apikeys-tool.js";
35
35
  import { createImageGenerateTool } from "./tools/image-generate-tool.js";
36
36
  import { createSoftwareUpdateTool } from "./tools/software-update-tool.js";
37
+ import { createVerifyContactTool, createVerifyContactCodeTool, } from "./tools/verify-contact-tool.js";
37
38
  export function createTaskmasterTools(options) {
38
39
  const imageTool = options?.agentDir?.trim()
39
40
  ? createImageTool({
@@ -147,6 +148,8 @@ export function createTaskmasterTools(options) {
147
148
  createContactDeleteTool(),
148
149
  createContactLookupTool(),
149
150
  createContactUpdateTool(),
151
+ createVerifyContactTool({ agentSessionKey: options?.agentSessionKey }),
152
+ createVerifyContactCodeTool({ agentSessionKey: options?.agentSessionKey }),
150
153
  createRelayMessageTool(),
151
154
  createApiKeysTool(),
152
155
  createFileDeleteTool({
@@ -36,7 +36,14 @@ export const TOOL_GROUPS = {
36
36
  // Skill management
37
37
  "group:skills": ["skill_read", "skill_draft_save"],
38
38
  // Contact record management
39
- "group:contacts": ["contact_create", "contact_delete", "contact_lookup", "contact_update"],
39
+ "group:contacts": [
40
+ "contact_create",
41
+ "contact_delete",
42
+ "contact_lookup",
43
+ "contact_update",
44
+ "verify_contact",
45
+ "verify_contact_code",
46
+ ],
40
47
  // Admin management tools
41
48
  "group:admin": ["authorize_admin", "revoke_admin", "list_admins"],
42
49
  // All Taskmaster native tools (excludes provider plugins).
@@ -71,6 +78,8 @@ export const TOOL_GROUPS = {
71
78
  "file_delete",
72
79
  "file_list",
73
80
  "skill_draft_save",
81
+ "verify_contact",
82
+ "verify_contact_code",
74
83
  ],
75
84
  };
76
85
  // Tools that are never granted by profiles — must be explicitly added to the
@@ -13,7 +13,7 @@ const ApiKeysSchema = Type.Object({
13
13
  description: 'Action to perform: "set" stores a key, "list" shows which providers have keys configured, "remove" deletes a stored key, "disable" toggles a key on/off without deleting it.',
14
14
  }),
15
15
  provider: Type.Optional(Type.String({
16
- description: "Provider ID (required for set/remove/disable). Valid providers: anthropic, google, tavily, openai, replicate, hume, brave, elevenlabs.",
16
+ description: "Provider ID (required for set/remove/disable). Valid providers: anthropic, google, tavily, openai, replicate, hume, brave, elevenlabs, resend.",
17
17
  })),
18
18
  apiKey: Type.Optional(Type.String({
19
19
  description: "The API key value (required for set).",
@@ -26,7 +26,7 @@ export function createApiKeysTool() {
26
26
  return {
27
27
  label: "API Keys",
28
28
  name: "api_keys",
29
- description: "Manage API keys for external providers. Use action 'set' to store a key (provider + apiKey required), 'list' to check which providers have keys configured, 'remove' to delete a stored key, or 'disable' to toggle a key on/off without deleting it (provider + disabled required). Valid providers: anthropic, google, tavily, openai, replicate, hume, brave, elevenlabs. Keys are applied immediately without restart.",
29
+ description: "Manage API keys for external providers. Use action 'set' to store a key (provider + apiKey required), 'list' to check which providers have keys configured, 'remove' to delete a stored key, or 'disable' to toggle a key on/off without deleting it (provider + disabled required). Valid providers: anthropic, google, tavily, openai, replicate, hume, brave, elevenlabs, resend. Keys are applied immediately without restart.",
30
30
  parameters: ApiKeysSchema,
31
31
  execute: async (_toolCallId, args) => {
32
32
  const params = args;
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { realpath } from "node:fs/promises";
4
4
  import { Type } from "@sinclair/typebox";
5
- import { resolveAgentWorkspaceRoot, resolveSessionAgentId } from "../agent-scope.js";
5
+ import { resolveAgentWorkspaceDir, resolveAgentWorkspaceRoot, resolveSessionAgentId, } from "../agent-scope.js";
6
6
  import { jsonResult, readStringParam } from "./common.js";
7
7
  const FileDeleteSchema = Type.Object({
8
8
  path: Type.String({
@@ -18,13 +18,7 @@ const FileDeleteSchema = Type.Object({
18
18
  * Protected top-level entries that cannot be deleted.
19
19
  * These are structural directories/files whose removal would break the workspace.
20
20
  */
21
- const PROTECTED_TOP_LEVEL = new Set([
22
- "agents",
23
- "IDENTITY.md",
24
- "SOUL.md",
25
- "AGENTS.md",
26
- "TOOLS.md",
27
- ]);
21
+ const PROTECTED_TOP_LEVEL = new Set(["agents", "IDENTITY.md", "SOUL.md", "AGENTS.md", "TOOLS.md"]);
28
22
  /**
29
23
  * Validate that `target` is strictly inside `root` (not equal to root).
30
24
  * Both paths must be real (symlinks resolved) before calling.
@@ -56,12 +50,17 @@ export function createFileDeleteTool(options) {
56
50
  config: cfg,
57
51
  });
58
52
  const workspaceRoot = resolveAgentWorkspaceRoot(cfg, agentId);
53
+ const agentWorkspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
59
54
  // ── Normalise and resolve target ────────────────────────────
60
55
  // Reject obviously malicious patterns before touching the filesystem.
61
56
  if (rawPath.includes("\0")) {
62
57
  return jsonResult({ error: "Invalid path." });
63
58
  }
64
- const resolved = path.resolve(workspaceRoot, rawPath);
59
+ // Memory paths resolve relative to the agent's workspace dir, which
60
+ // contains shared-scope symlinks alongside agent-scoped subdirectories.
61
+ const isMemoryPath = rawPath === "memory" || rawPath.startsWith("memory/");
62
+ const base = isMemoryPath ? agentWorkspaceDir : workspaceRoot;
63
+ const resolved = path.resolve(base, rawPath);
65
64
  // ── Containment check (pre-realpath) ────────────────────────
66
65
  if (!isInsideRoot(resolved, workspaceRoot)) {
67
66
  return jsonResult({ error: "Path is outside the workspace." });
@@ -94,12 +93,18 @@ export function createFileDeleteTool(options) {
94
93
  return jsonResult({ error: "Path resolves outside the workspace." });
95
94
  }
96
95
  // ── Protected path check ────────────────────────────────────
97
- const relative = path.relative(realRoot, realTarget);
98
- const topSegment = relative.split(path.sep)[0];
99
- if (topSegment && PROTECTED_TOP_LEVEL.has(topSegment)) {
100
- return jsonResult({
101
- error: `Cannot delete protected path: ${topSegment} is a structural workspace entry.`,
102
- });
96
+ // Memory paths physically reside under agents/<id>/memory/ but the
97
+ // user addresses them as memory/… — they are never structural entries.
98
+ // Only apply the structural protection for non-memory paths resolved
99
+ // against the workspace root.
100
+ if (!isMemoryPath) {
101
+ const relative = path.relative(realRoot, realTarget);
102
+ const topSegment = relative.split(path.sep)[0];
103
+ if (topSegment && PROTECTED_TOP_LEVEL.has(topSegment)) {
104
+ return jsonResult({
105
+ error: `Cannot delete protected path: ${topSegment} is a structural workspace entry.`,
106
+ });
107
+ }
103
108
  }
104
109
  // ── Delete ──────────────────────────────────────────────────
105
110
  try {
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { realpath } from "node:fs/promises";
4
4
  import { Type } from "@sinclair/typebox";
5
- import { resolveAgentWorkspaceRoot, resolveSessionAgentId } from "../agent-scope.js";
5
+ import { resolveAgentWorkspaceDir, resolveAgentWorkspaceRoot, resolveSessionAgentId, } from "../agent-scope.js";
6
6
  import { jsonResult, readStringParam, readNumberParam } from "./common.js";
7
7
  const FileListSchema = Type.Object({
8
8
  path: Type.Optional(Type.String({
@@ -82,11 +82,18 @@ export function createFileListTool(options) {
82
82
  config: cfg,
83
83
  });
84
84
  const workspaceRoot = resolveAgentWorkspaceRoot(cfg, agentId);
85
+ const agentWorkspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
85
86
  // ── Normalise and resolve target ────────────────────────────
86
87
  if (rawPath.includes("\0")) {
87
88
  return jsonResult({ error: "Invalid path." });
88
89
  }
89
- const resolved = rawPath ? path.resolve(workspaceRoot, rawPath) : workspaceRoot;
90
+ // Memory paths resolve relative to the agent's workspace dir, which
91
+ // contains shared-scope symlinks (groups/, users/, …) alongside
92
+ // agent-scoped subdirectories (admin/, notes/, uploads/). The
93
+ // workspace root's memory/ only has the shared scopes.
94
+ const isMemoryPath = rawPath === "memory" || rawPath.startsWith("memory/");
95
+ const base = isMemoryPath ? agentWorkspaceDir : workspaceRoot;
96
+ const resolved = rawPath ? path.resolve(base, rawPath) : workspaceRoot;
90
97
  // ── Containment check ───────────────────────────────────────
91
98
  let realTarget;
92
99
  try {
@@ -1,11 +1,11 @@
1
1
  import { createSubsystemLogger } from "../../logging/subsystem.js";
2
2
  import { fetchWithTimeout, normalizeBaseUrl, readErrorResponse, } from "../../media-understanding/providers/shared.js";
3
3
  const log = createSubsystemLogger("image-gen");
4
- /** Show first 4 and last 4 characters of a key, mask everything in between. */
4
+ /** Show first 8 and last 4 characters of a key, mask everything in between. */
5
5
  function maskKey(key) {
6
- if (key.length <= 12)
6
+ if (key.length <= 16)
7
7
  return `${key.slice(0, 4)}...`;
8
- return `${key.slice(0, 4)}...${key.slice(-4)}`;
8
+ return `${key.slice(0, 8)}...${key.slice(-4)}`;
9
9
  }
10
10
  /* ------------------------------------------------------------------ */
11
11
  /* Constants */
@@ -10,11 +10,11 @@ const log = createSubsystemLogger("image-gen");
10
10
  /* ------------------------------------------------------------------ */
11
11
  /* Helpers */
12
12
  /* ------------------------------------------------------------------ */
13
- /** Show first 4 and last 4 characters of a key, mask everything in between. */
13
+ /** Show first 8 and last 4 characters of a key, mask everything in between. */
14
14
  function maskKey(key) {
15
- if (key.length <= 12)
15
+ if (key.length <= 16)
16
16
  return `${key.slice(0, 4)}...`;
17
- return `${key.slice(0, 4)}...${key.slice(-4)}`;
17
+ return `${key.slice(0, 8)}...${key.slice(-4)}`;
18
18
  }
19
19
  /* ------------------------------------------------------------------ */
20
20
  /* Constants */
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Cross-authentication tool -- links a second identifier (phone or email) to an
3
+ * existing contact by sending an OTP to the new identifier and verifying it.
4
+ *
5
+ * Two-step flow:
6
+ * 1. verify_contact -- sends OTP to the unverified identifier
7
+ * 2. verify_contact_code -- confirms the OTP and updates the contact record
8
+ */
9
+ import { Type } from "@sinclair/typebox";
10
+ import { loadConfig } from "../../config/config.js";
11
+ import { deliverOtp } from "../../gateway/public-chat/deliver-otp.js";
12
+ import { requestOtp, verifyOtp } from "../../gateway/public-chat/otp.js";
13
+ import { findRecordByEmail, findRecordByPhone, getRecord, setRecord, } from "../../records/records-manager.js";
14
+ import { resolveAgentBoundAccountId } from "../../routing/bindings.js";
15
+ import { jsonResult } from "./common.js";
16
+ /** In-memory pending cross-auth keyed by session key. */
17
+ const pendingCrossAuth = new Map();
18
+ const VerifyContactSchema = Type.Object({
19
+ type: Type.Union([Type.Literal("phone"), Type.Literal("email")], {
20
+ description: 'The type of identifier to verify: "phone" or "email".',
21
+ }),
22
+ value: Type.String({
23
+ description: "The phone number (E.164 format) or email address to send the verification code to.",
24
+ }),
25
+ });
26
+ const VerifyContactCodeSchema = Type.Object({
27
+ code: Type.String({ description: "The 6-digit verification code." }),
28
+ });
29
+ /** Extract the peer identifier from a DM session key (agent:x:dm:identifier). */
30
+ function extractPeerId(sessionKey) {
31
+ const match = sessionKey.match(/^agent:[^:]+:dm:(.+)$/);
32
+ return match?.[1];
33
+ }
34
+ /** Extract the agent ID from a session key (agent:x:...). */
35
+ function extractAgentId(sessionKey) {
36
+ const match = sessionKey.match(/^agent:([^:]+):/);
37
+ return match?.[1];
38
+ }
39
+ /** Resolve the contact record for a given peer identifier. */
40
+ function resolveContactForPeer(peerId) {
41
+ const isEmailPeer = peerId.includes("@");
42
+ if (isEmailPeer)
43
+ return findRecordByEmail(peerId);
44
+ return findRecordByPhone(peerId) ?? getRecord(peerId);
45
+ }
46
+ export function createVerifyContactTool(opts) {
47
+ const sessionKey = opts?.agentSessionKey;
48
+ return {
49
+ label: "Verification",
50
+ name: "verify_contact",
51
+ description: "Send a verification code to a phone number or email address to cross-authenticate the current contact. " +
52
+ "Use this when a contact who authenticated via one method (e.g. email) provides their other identifier " +
53
+ "(e.g. phone number) and you want to verify and link it to their contact record.",
54
+ parameters: VerifyContactSchema,
55
+ execute: async (_toolCallId, args) => {
56
+ const params = args;
57
+ if (!sessionKey) {
58
+ return jsonResult({ success: false, error: "no active session" });
59
+ }
60
+ const peerId = extractPeerId(sessionKey);
61
+ if (!peerId || peerId.startsWith("anon-")) {
62
+ return jsonResult({
63
+ success: false,
64
+ error: "contact must be authenticated (not anonymous) to cross-verify",
65
+ });
66
+ }
67
+ const contact = resolveContactForPeer(peerId);
68
+ if (!contact) {
69
+ return jsonResult({
70
+ success: false,
71
+ error: `no contact record found for ${peerId}`,
72
+ });
73
+ }
74
+ // Check they don't already have this identifier
75
+ if (params.type === "email" && contact.email) {
76
+ return jsonResult({
77
+ success: false,
78
+ error: `contact already has email: ${contact.email}`,
79
+ });
80
+ }
81
+ if (params.type === "phone" && contact.phone) {
82
+ return jsonResult({
83
+ success: false,
84
+ error: `contact already has phone: ${contact.phone}`,
85
+ });
86
+ }
87
+ // Check for conflicts -- another contact already using this identifier
88
+ const existing = params.type === "email" ? findRecordByEmail(params.value) : findRecordByPhone(params.value);
89
+ if (existing && existing.id !== contact.id) {
90
+ return jsonResult({
91
+ success: false,
92
+ error: `another contact (${existing.name}) already uses this ${params.type}`,
93
+ });
94
+ }
95
+ // Request and deliver OTP
96
+ const normalized = params.type === "email" ? params.value.toLowerCase() : params.value;
97
+ const otpResult = requestOtp(normalized);
98
+ if (!otpResult.ok) {
99
+ return jsonResult({
100
+ success: false,
101
+ error: "rate limited -- try again shortly",
102
+ retryAfterMs: otpResult.retryAfterMs,
103
+ });
104
+ }
105
+ const cfg = loadConfig();
106
+ const agentId = extractAgentId(sessionKey);
107
+ const whatsappAccountId = agentId
108
+ ? (resolveAgentBoundAccountId(cfg, agentId, "whatsapp") ?? undefined)
109
+ : undefined;
110
+ try {
111
+ const delivery = await deliverOtp(normalized, otpResult.code, whatsappAccountId);
112
+ pendingCrossAuth.set(sessionKey, {
113
+ type: params.type,
114
+ value: normalized,
115
+ contactId: contact.id,
116
+ });
117
+ return jsonResult({
118
+ success: true,
119
+ message: `Verification code sent via ${delivery.channel}. Ask the contact for the code.`,
120
+ channel: delivery.channel,
121
+ });
122
+ }
123
+ catch (err) {
124
+ return jsonResult({
125
+ success: false,
126
+ error: err instanceof Error ? err.message : "failed to send verification code",
127
+ });
128
+ }
129
+ },
130
+ };
131
+ }
132
+ export function createVerifyContactCodeTool(opts) {
133
+ const sessionKey = opts?.agentSessionKey;
134
+ return {
135
+ label: "Verification",
136
+ name: "verify_contact_code",
137
+ description: "Verify a cross-authentication code. Call this after verify_contact when the contact provides the 6-digit code.",
138
+ parameters: VerifyContactCodeSchema,
139
+ execute: async (_toolCallId, args) => {
140
+ const params = args;
141
+ if (!sessionKey) {
142
+ return jsonResult({ success: false, error: "no active session" });
143
+ }
144
+ const pending = pendingCrossAuth.get(sessionKey);
145
+ if (!pending) {
146
+ return jsonResult({
147
+ success: false,
148
+ error: "no pending cross-verification -- call verify_contact first",
149
+ });
150
+ }
151
+ const result = verifyOtp(pending.value, params.code);
152
+ if (!result.ok) {
153
+ const messages = {
154
+ not_found: "no pending verification -- code may have expired",
155
+ expired: "verification code expired -- request a new one",
156
+ max_attempts: "too many attempts -- request a new code",
157
+ invalid: "incorrect code",
158
+ };
159
+ // Don't clear pending on invalid code -- let them retry
160
+ if (result.error !== "invalid") {
161
+ pendingCrossAuth.delete(sessionKey);
162
+ }
163
+ return jsonResult({
164
+ success: false,
165
+ error: messages[result.error] ?? "verification failed",
166
+ });
167
+ }
168
+ // Update contact record with the linked identifier
169
+ const contact = getRecord(pending.contactId);
170
+ if (!contact) {
171
+ pendingCrossAuth.delete(sessionKey);
172
+ return jsonResult({ success: false, error: "contact record not found" });
173
+ }
174
+ const updateInput = {
175
+ name: contact.name,
176
+ fields: contact.fields,
177
+ workspace: contact.workspace,
178
+ };
179
+ if (pending.type === "email")
180
+ updateInput.email = pending.value;
181
+ else
182
+ updateInput.phone = pending.value;
183
+ const updated = setRecord(contact.id, updateInput);
184
+ pendingCrossAuth.delete(sessionKey);
185
+ return jsonResult({
186
+ success: true,
187
+ message: `${pending.type} verified and linked to contact ${updated.name}.`,
188
+ contact: {
189
+ id: updated.id,
190
+ name: updated.name,
191
+ email: updated.email,
192
+ phone: updated.phone,
193
+ },
194
+ });
195
+ },
196
+ };
197
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Workspace migrations — patches applied to deployed workspace files on gateway startup.
3
+ *
4
+ * Unlike bundled skills (which are whole-directory copies), workspace files like
5
+ * AGENTS.md are customised per-deployment (owner names, phone numbers, business
6
+ * details). Migrations patch these files in-place — checking for existing content
7
+ * first so they're idempotent.
8
+ *
9
+ * Each migration runs on every startup. They must be safe to re-run (no-op when
10
+ * the change is already present).
11
+ */
12
+ import fs from "node:fs/promises";
13
+ import path from "node:path";
14
+ import { createSubsystemLogger } from "../logging/subsystem.js";
15
+ import { listAgentIds, resolveAgentWorkspaceDir } from "./agent-scope.js";
16
+ const log = createSubsystemLogger("workspace-migrations");
17
+ // ---------------------------------------------------------------------------
18
+ // Helpers
19
+ // ---------------------------------------------------------------------------
20
+ /** Patterns that indicate a public-facing (non-admin) agent. */
21
+ const PUBLIC_AGENT_INDICATORS = [
22
+ "customer-facing",
23
+ "Public Agent",
24
+ "public agent",
25
+ "parent and student-facing",
26
+ "booking agent",
27
+ "promotional instance",
28
+ ];
29
+ /** Formatting section headers commonly found at the end of AGENTS files. */
30
+ const FORMATTING_HEADERS = ["## WhatsApp Formatting", "## Message Formatting", "## Formatting"];
31
+ /** Section headers that typically precede "## Capabilities" in admin agents. */
32
+ const CAPABILITIES_HEADERS = ["## Capabilities", "## Stripe CLI Operations", "## User Management"];
33
+ function isPublicAgent(content) {
34
+ return PUBLIC_AGENT_INDICATORS.some((p) => content.includes(p));
35
+ }
36
+ /**
37
+ * Find the index of a formatting-style section to insert before.
38
+ * Returns -1 if no known section is found.
39
+ */
40
+ function findFormattingInsertPoint(content) {
41
+ for (const header of FORMATTING_HEADERS) {
42
+ const idx = content.lastIndexOf(header);
43
+ if (idx !== -1)
44
+ return idx;
45
+ }
46
+ return -1;
47
+ }
48
+ /**
49
+ * Find the index of a "## Capabilities" or similar section to insert before.
50
+ * Returns -1 if no known section is found.
51
+ */
52
+ function findCapabilitiesInsertPoint(content) {
53
+ for (const header of CAPABILITIES_HEADERS) {
54
+ const idx = content.indexOf(header);
55
+ if (idx !== -1)
56
+ return idx;
57
+ }
58
+ return -1;
59
+ }
60
+ /**
61
+ * Insert a section into AGENTS.md content at the best position.
62
+ * Uses the provided finder to locate an insertion point; falls back to appending.
63
+ */
64
+ function insertSection(content, section, findInsertPoint) {
65
+ const insertIdx = findInsertPoint(content);
66
+ if (insertIdx !== -1) {
67
+ return content.slice(0, insertIdx) + section + "\n\n---\n\n" + content.slice(insertIdx);
68
+ }
69
+ return content.trimEnd() + "\n\n---\n\n" + section + "\n";
70
+ }
71
+ // ---------------------------------------------------------------------------
72
+ // Migration: Skill Recommendations (v1.9)
73
+ // ---------------------------------------------------------------------------
74
+ const ADMIN_SKILL_SECTION = `## Skill Recommendations
75
+
76
+ When you notice a task that follows a repeatable pattern — something that has come up before or is likely to recur — proactively suggest creating a skill for it. Skills encode processes so they're followed consistently every time, without relying on memory or re-explanation.
77
+
78
+ **Signs a task should be a skill:**
79
+ - You've handled the same type of request more than once
80
+ - The task has clear steps that shouldn't vary between occurrences
81
+ - Getting it wrong has consequences (compliance, accuracy, customer experience)
82
+ - The process involves domain knowledge that shouldn't be guessed at
83
+
84
+ Don't wait to be asked. If you spot the pattern, recommend it.`;
85
+ const PUBLIC_SKILL_SECTION = `## Spotting Repeatable Patterns
86
+
87
+ When you find yourself repeatedly handling the same type of request — following the same steps, giving the same guidance, or applying the same rules — note the pattern in memory for review. Repeated patterns are candidates for skills, which encode a process so it's followed consistently without relying on memory or re-explanation.`;
88
+ function hasSkillSection(content) {
89
+ return (content.includes("## Skill Recommendations") ||
90
+ content.includes("## Spotting Repeatable Patterns"));
91
+ }
92
+ async function patchSkillRecommendations(agentsPath, content) {
93
+ if (hasSkillSection(content))
94
+ return null;
95
+ const section = isPublicAgent(content) ? PUBLIC_SKILL_SECTION : ADMIN_SKILL_SECTION;
96
+ return insertSection(content, section, findFormattingInsertPoint);
97
+ }
98
+ // ---------------------------------------------------------------------------
99
+ // Migration: Owner Learning (v1.9)
100
+ // ---------------------------------------------------------------------------
101
+ const OWNER_LEARNING_SECTION = `## Learning About Your Operator
102
+
103
+ You talk to the same person every day. An assistant that adapts to how someone thinks and works is more useful than one that starts fresh every session.
104
+
105
+ Pay attention to how they communicate, what they care about, how they make decisions, and what works well between you. When you notice something worth remembering — a preference, a pattern, a lesson about working with them — update \`USER.md\` or store it in \`memory/admin/\`.
106
+
107
+ This isn't a one-time setup. USER.md should grow as your understanding of them deepens: their communication style, decision-making patterns, working rhythms, what they delegate vs handle personally, and what kind of updates they actually find valuable.`;
108
+ function hasOwnerLearningSection(content) {
109
+ return content.includes("## Learning About") || content.includes("## Learning about");
110
+ }
111
+ async function patchOwnerLearning(agentsPath, content) {
112
+ // Only admin agents — public agents don't have USER.md or admin memory.
113
+ if (isPublicAgent(content))
114
+ return null;
115
+ if (hasOwnerLearningSection(content))
116
+ return null;
117
+ return insertSection(content, OWNER_LEARNING_SECTION, findCapabilitiesInsertPoint);
118
+ }
119
+ const MIGRATIONS = [
120
+ { name: "skill-recommendations", apply: patchSkillRecommendations },
121
+ { name: "owner-learning", apply: patchOwnerLearning },
122
+ ];
123
+ /**
124
+ * Run all workspace migrations for every configured agent.
125
+ * Called once at gateway startup, after bundled skill sync.
126
+ */
127
+ export async function runWorkspaceMigrations(cfg) {
128
+ const agentIds = listAgentIds(cfg);
129
+ for (const agentId of agentIds) {
130
+ const wsDir = resolveAgentWorkspaceDir(cfg, agentId);
131
+ const agentsPath = path.join(wsDir, "AGENTS.md");
132
+ let content;
133
+ try {
134
+ content = await fs.readFile(agentsPath, "utf-8");
135
+ }
136
+ catch {
137
+ continue; // No AGENTS.md — nothing to patch.
138
+ }
139
+ let current = content;
140
+ const applied = [];
141
+ for (const migration of MIGRATIONS) {
142
+ try {
143
+ const result = await migration.apply(agentsPath, current);
144
+ if (result !== null) {
145
+ current = result;
146
+ applied.push(migration.name);
147
+ }
148
+ }
149
+ catch (err) {
150
+ log.warn(`migration "${migration.name}" failed for agent "${agentId}": ${String(err)}`);
151
+ }
152
+ }
153
+ if (applied.length > 0) {
154
+ try {
155
+ await fs.writeFile(agentsPath, current, "utf-8");
156
+ log.info(`patched AGENTS.md for agent "${agentId}": ${applied.join(", ")}`);
157
+ }
158
+ catch (err) {
159
+ log.warn(`failed to write AGENTS.md for agent "${agentId}": ${String(err)}`);
160
+ }
161
+ }
162
+ }
163
+ }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.9.3",
3
- "commit": "65d408bbe013355d2df19762fe1a745a98c3584e",
4
- "builtAt": "2026-02-27T07:59:49.321Z"
2
+ "version": "1.9.5",
3
+ "commit": "521c1bca1da1357a98d5afc5f71aafcf2a5831b8",
4
+ "builtAt": "2026-02-28T15:36:00.493Z"
5
5
  }
@@ -443,6 +443,10 @@ export function applyApiKeys(config) {
443
443
  };
444
444
  continue;
445
445
  }
446
+ if (provider === "resend") {
447
+ cfg = setNestedKey(cfg, ["publicChat", "email", "apiKey"], trimmedKey);
448
+ continue;
449
+ }
446
450
  }
447
451
  return cfg;
448
452
  }