vellum 0.2.13 → 0.2.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -0
- package/bun.lock +2 -2
- package/docs/skills.md +4 -4
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
- package/src/__tests__/app-git-history.test.ts +176 -0
- package/src/__tests__/app-git-service.test.ts +169 -0
- package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
- package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
- package/src/__tests__/browser-skill-endstate.test.ts +6 -6
- package/src/__tests__/call-bridge.test.ts +105 -13
- package/src/__tests__/call-domain.test.ts +163 -0
- package/src/__tests__/call-orchestrator.test.ts +113 -0
- package/src/__tests__/call-routes-http.test.ts +246 -6
- package/src/__tests__/channel-approval-routes.test.ts +438 -0
- package/src/__tests__/channel-approval.test.ts +266 -0
- package/src/__tests__/channel-approvals.test.ts +393 -0
- package/src/__tests__/channel-delivery-store.test.ts +447 -0
- package/src/__tests__/checker.test.ts +607 -1048
- package/src/__tests__/cli.test.ts +1 -56
- package/src/__tests__/config-schema.test.ts +137 -18
- package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
- package/src/__tests__/conflict-policy.test.ts +121 -0
- package/src/__tests__/conflict-store.test.ts +2 -0
- package/src/__tests__/contacts-tools.test.ts +3 -3
- package/src/__tests__/contradiction-checker.test.ts +99 -1
- package/src/__tests__/credential-security-invariants.test.ts +22 -6
- package/src/__tests__/credential-vault-unit.test.ts +780 -0
- package/src/__tests__/elevenlabs-client.test.ts +62 -0
- package/src/__tests__/ephemeral-permissions.test.ts +73 -23
- package/src/__tests__/filesystem-tools.test.ts +579 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
- package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
- package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
- package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
- package/src/__tests__/handlers-slack-config.test.ts +2 -1
- package/src/__tests__/handlers-telegram-config.test.ts +855 -0
- package/src/__tests__/handlers-twitter-config.test.ts +141 -1
- package/src/__tests__/hooks-runner.test.ts +6 -2
- package/src/__tests__/host-file-edit-tool.test.ts +124 -0
- package/src/__tests__/host-file-read-tool.test.ts +62 -0
- package/src/__tests__/host-file-write-tool.test.ts +59 -0
- package/src/__tests__/host-shell-tool.test.ts +251 -0
- package/src/__tests__/ingress-reconcile.test.ts +581 -0
- package/src/__tests__/ipc-snapshot.test.ts +100 -41
- package/src/__tests__/ipc-validate.test.ts +50 -0
- package/src/__tests__/key-migration.test.ts +23 -0
- package/src/__tests__/memory-regressions.test.ts +99 -0
- package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
- package/src/__tests__/oauth-callback-registry.test.ts +11 -4
- package/src/__tests__/playbook-execution.test.ts +502 -0
- package/src/__tests__/playbook-tools.test.ts +4 -6
- package/src/__tests__/public-ingress-urls.test.ts +34 -0
- package/src/__tests__/qdrant-manager.test.ts +267 -0
- package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
- package/src/__tests__/recurrence-engine.test.ts +9 -0
- package/src/__tests__/recurrence-types.test.ts +8 -0
- package/src/__tests__/registry.test.ts +1 -1
- package/src/__tests__/runtime-runs.test.ts +1 -25
- package/src/__tests__/schedule-store.test.ts +16 -14
- package/src/__tests__/schedule-tools.test.ts +83 -0
- package/src/__tests__/scheduler-recurrence.test.ts +111 -10
- package/src/__tests__/secret-allowlist.test.ts +18 -17
- package/src/__tests__/secret-ingress-handler.test.ts +11 -0
- package/src/__tests__/secret-scanner.test.ts +43 -0
- package/src/__tests__/session-conflict-gate.test.ts +442 -6
- package/src/__tests__/session-init.benchmark.test.ts +3 -0
- package/src/__tests__/session-process-bridge.test.ts +242 -0
- package/src/__tests__/session-skill-tools.test.ts +1 -1
- package/src/__tests__/shell-identity.test.ts +256 -0
- package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
- package/src/__tests__/subagent-tools.test.ts +637 -54
- package/src/__tests__/task-management-tools.test.ts +936 -0
- package/src/__tests__/task-runner.test.ts +2 -2
- package/src/__tests__/terminal-tools.test.ts +840 -0
- package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
- package/src/__tests__/tool-executor.test.ts +85 -151
- package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
- package/src/__tests__/trust-store.test.ts +27 -453
- package/src/__tests__/twilio-provider.test.ts +153 -3
- package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
- package/src/__tests__/twilio-routes-twiml.test.ts +4 -4
- package/src/__tests__/twilio-routes.test.ts +17 -262
- package/src/__tests__/twitter-auth-handler.test.ts +2 -1
- package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
- package/src/__tests__/twitter-cli-routing.test.ts +252 -0
- package/src/__tests__/twitter-oauth-client.test.ts +209 -0
- package/src/__tests__/workspace-policy.test.ts +213 -0
- package/src/calls/call-bridge.ts +92 -19
- package/src/calls/call-domain.ts +157 -5
- package/src/calls/call-orchestrator.ts +93 -7
- package/src/calls/call-store.ts +6 -0
- package/src/calls/elevenlabs-client.ts +8 -0
- package/src/calls/elevenlabs-config.ts +7 -5
- package/src/calls/twilio-provider.ts +91 -0
- package/src/calls/twilio-routes.ts +32 -37
- package/src/calls/types.ts +3 -1
- package/src/calls/voice-quality.ts +29 -7
- package/src/cli/twitter.ts +200 -21
- package/src/cli.ts +1 -20
- package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
- package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
- package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
- package/src/config/bundled-skills/messaging/SKILL.md +17 -2
- package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
- package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +142 -34
- package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
- package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
- package/src/config/bundled-skills/twitter/SKILL.md +103 -17
- package/src/config/defaults.ts +10 -4
- package/src/config/schema.ts +80 -21
- package/src/config/types.ts +1 -0
- package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
- package/src/daemon/assistant-attachments.ts +4 -2
- package/src/daemon/handlers/apps.ts +69 -0
- package/src/daemon/handlers/config.ts +543 -24
- package/src/daemon/handlers/index.ts +1 -0
- package/src/daemon/handlers/sessions.ts +22 -6
- package/src/daemon/handlers/shared.ts +2 -1
- package/src/daemon/handlers/skills.ts +5 -20
- package/src/daemon/ipc-contract-inventory.json +28 -0
- package/src/daemon/ipc-contract.ts +168 -10
- package/src/daemon/ipc-validate.ts +17 -0
- package/src/daemon/lifecycle.ts +2 -0
- package/src/daemon/server.ts +78 -72
- package/src/daemon/session-attachments.ts +1 -1
- package/src/daemon/session-conflict-gate.ts +62 -6
- package/src/daemon/session-notifiers.ts +1 -1
- package/src/daemon/session-process.ts +62 -3
- package/src/daemon/session-tool-setup.ts +1 -2
- package/src/daemon/tls-certs.ts +189 -0
- package/src/daemon/video-thumbnail.ts +5 -3
- package/src/hooks/manager.ts +5 -9
- package/src/memory/app-git-service.ts +295 -0
- package/src/memory/app-store.ts +21 -0
- package/src/memory/conflict-intent.ts +47 -4
- package/src/memory/conflict-policy.ts +73 -0
- package/src/memory/conflict-store.ts +9 -1
- package/src/memory/contradiction-checker.ts +28 -0
- package/src/memory/conversation-key-store.ts +15 -0
- package/src/memory/db.ts +81 -0
- package/src/memory/embedding-local.ts +3 -13
- package/src/memory/external-conversation-store.ts +234 -0
- package/src/memory/job-handlers/conflict.ts +22 -2
- package/src/memory/jobs-worker.ts +67 -28
- package/src/memory/runs-store.ts +54 -7
- package/src/memory/schema.ts +20 -0
- package/src/messaging/provider.ts +9 -0
- package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
- package/src/messaging/providers/telegram-bot/client.ts +104 -0
- package/src/messaging/providers/telegram-bot/types.ts +15 -0
- package/src/messaging/registry.ts +1 -0
- package/src/permissions/checker.ts +48 -44
- package/src/permissions/prompter.ts +0 -4
- package/src/permissions/shell-identity.ts +227 -0
- package/src/permissions/trust-store.ts +76 -53
- package/src/permissions/types.ts +0 -19
- package/src/permissions/workspace-policy.ts +114 -0
- package/src/providers/retry.ts +12 -37
- package/src/runtime/assistant-event-hub.ts +41 -4
- package/src/runtime/channel-approval-parser.ts +60 -0
- package/src/runtime/channel-approval-types.ts +71 -0
- package/src/runtime/channel-approvals.ts +145 -0
- package/src/runtime/gateway-client.ts +16 -0
- package/src/runtime/http-server.ts +29 -9
- package/src/runtime/routes/call-routes.ts +52 -2
- package/src/runtime/routes/channel-routes.ts +296 -16
- package/src/runtime/routes/events-routes.ts +97 -28
- package/src/runtime/routes/run-routes.ts +2 -7
- package/src/runtime/run-orchestrator.ts +0 -3
- package/src/schedule/recurrence-engine.ts +26 -2
- package/src/schedule/recurrence-types.ts +1 -1
- package/src/schedule/schedule-store.ts +12 -3
- package/src/security/secret-scanner.ts +7 -0
- package/src/tasks/ephemeral-permissions.ts +0 -2
- package/src/tasks/task-scheduler.ts +2 -1
- package/src/tools/calls/call-start.ts +8 -0
- package/src/tools/execution-target.ts +21 -0
- package/src/tools/execution-timeout.ts +49 -0
- package/src/tools/executor.ts +6 -135
- package/src/tools/network/web-search.ts +9 -32
- package/src/tools/policy-context.ts +29 -0
- package/src/tools/schedule/update.ts +8 -1
- package/src/tools/terminal/parser.ts +16 -18
- package/src/tools/types.ts +4 -11
- package/src/twitter/oauth-client.ts +102 -0
- package/src/twitter/router.ts +101 -0
- package/src/util/debounce.ts +88 -0
- package/src/util/network-info.ts +47 -0
- package/src/util/platform.ts +29 -4
- package/src/util/promise-guard.ts +37 -0
- package/src/util/retry.ts +98 -0
- package/src/util/truncate.ts +1 -1
- package/src/workspace/git-service.ts +129 -112
- package/src/tools/contacts/contact-merge.ts +0 -55
- package/src/tools/contacts/contact-search.ts +0 -58
- package/src/tools/contacts/contact-upsert.ts +0 -64
- package/src/tools/playbooks/index.ts +0 -4
- package/src/tools/playbooks/playbook-create.ts +0 -96
- package/src/tools/playbooks/playbook-delete.ts +0 -52
- package/src/tools/playbooks/playbook-list.ts +0 -74
- package/src/tools/playbooks/playbook-update.ts +0 -111
|
@@ -131,6 +131,97 @@ export class TwilioConversationRelayProvider implements VoiceProvider {
|
|
|
131
131
|
return data.status;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
// ── Caller ID eligibility ───────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Check whether a phone number can be used as an outbound caller ID
|
|
138
|
+
* by the current Twilio account. A number is eligible if it appears as
|
|
139
|
+
* either an Incoming Phone Number (owned) or an Outgoing Caller ID
|
|
140
|
+
* (verified) on the account.
|
|
141
|
+
*/
|
|
142
|
+
async checkCallerIdEligibility(
|
|
143
|
+
phoneNumber: string,
|
|
144
|
+
): Promise<{ eligible: boolean; reason?: string }> {
|
|
145
|
+
const { accountSid, authToken } = this.getCredentials();
|
|
146
|
+
const encodedNumber = encodeURIComponent(phoneNumber);
|
|
147
|
+
|
|
148
|
+
let incomingOk = false;
|
|
149
|
+
let outgoingOk = false;
|
|
150
|
+
|
|
151
|
+
// Check incoming phone numbers (owned by this account)
|
|
152
|
+
const incomingRes = await fetch(
|
|
153
|
+
`${this.baseUrl(accountSid)}/IncomingPhoneNumbers.json?PhoneNumber=${encodedNumber}`,
|
|
154
|
+
{
|
|
155
|
+
method: 'GET',
|
|
156
|
+
headers: {
|
|
157
|
+
Authorization: this.authHeader(accountSid, authToken),
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
if (incomingRes.ok) {
|
|
163
|
+
incomingOk = true;
|
|
164
|
+
const incomingData = (await incomingRes.json()) as {
|
|
165
|
+
incoming_phone_numbers: unknown[];
|
|
166
|
+
};
|
|
167
|
+
if (incomingData.incoming_phone_numbers.length > 0) {
|
|
168
|
+
log.info({ phoneNumber }, 'Number found in IncomingPhoneNumbers — eligible as caller ID');
|
|
169
|
+
return { eligible: true };
|
|
170
|
+
}
|
|
171
|
+
} else {
|
|
172
|
+
log.warn(
|
|
173
|
+
{ status: incomingRes.status, phoneNumber },
|
|
174
|
+
'Failed to query IncomingPhoneNumbers — falling through to OutgoingCallerIds',
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Check outgoing caller IDs (verified with this account)
|
|
179
|
+
const outgoingRes = await fetch(
|
|
180
|
+
`${this.baseUrl(accountSid)}/OutgoingCallerIds.json?PhoneNumber=${encodedNumber}`,
|
|
181
|
+
{
|
|
182
|
+
method: 'GET',
|
|
183
|
+
headers: {
|
|
184
|
+
Authorization: this.authHeader(accountSid, authToken),
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
if (outgoingRes.ok) {
|
|
190
|
+
outgoingOk = true;
|
|
191
|
+
const outgoingData = (await outgoingRes.json()) as {
|
|
192
|
+
outgoing_caller_ids: unknown[];
|
|
193
|
+
};
|
|
194
|
+
if (outgoingData.outgoing_caller_ids.length > 0) {
|
|
195
|
+
log.info({ phoneNumber }, 'Number found in OutgoingCallerIds — eligible as caller ID');
|
|
196
|
+
return { eligible: true };
|
|
197
|
+
}
|
|
198
|
+
} else {
|
|
199
|
+
log.warn(
|
|
200
|
+
{ status: outgoingRes.status, phoneNumber },
|
|
201
|
+
'Failed to query OutgoingCallerIds',
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// If any API call failed, the eligibility check is inconclusive —
|
|
206
|
+
// propagate as an error rather than returning a false negative.
|
|
207
|
+
if (!incomingOk || !outgoingOk) {
|
|
208
|
+
const failedEndpoints = [
|
|
209
|
+
...(!incomingOk ? [`IncomingPhoneNumbers: ${incomingRes.status}`] : []),
|
|
210
|
+
...(!outgoingOk ? [`OutgoingCallerIds: ${outgoingRes.status}`] : []),
|
|
211
|
+
].join(', ');
|
|
212
|
+
throw new Error(
|
|
213
|
+
`Unable to verify caller ID eligibility for ${phoneNumber}: Twilio API error (${failedEndpoints}). The number may be eligible but could not be confirmed. Please check your Twilio credentials and try again.`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
log.info({ phoneNumber }, 'Number not found in either IncomingPhoneNumbers or OutgoingCallerIds');
|
|
218
|
+
return {
|
|
219
|
+
eligible: false,
|
|
220
|
+
reason:
|
|
221
|
+
'Number is not owned by or verified with your Twilio account. To use this number as caller ID, either: (1) add it as an Incoming Phone Number, or (2) verify it as an Outgoing Caller ID in the Twilio Console.',
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
134
225
|
// ── Webhook signature verification ──────────────────────────────────
|
|
135
226
|
|
|
136
227
|
/**
|
|
@@ -25,9 +25,7 @@ import { getTwilioConfig } from './twilio-config.js';
|
|
|
25
25
|
import { loadConfig } from '../config/loader.js';
|
|
26
26
|
import { getTwilioRelayUrl } from '../inbound/public-ingress-urls.js';
|
|
27
27
|
import { fireCallCompletionNotifier } from './call-state.js';
|
|
28
|
-
import { resolveVoiceQualityProfile } from './voice-quality.js';
|
|
29
|
-
import { getElevenLabsConfig } from './elevenlabs-config.js';
|
|
30
|
-
import { ElevenLabsClient } from './elevenlabs-client.js';
|
|
28
|
+
import { resolveVoiceQualityProfile, isVoiceProfileValid } from './voice-quality.js';
|
|
31
29
|
|
|
32
30
|
const log = getLogger('twilio-routes');
|
|
33
31
|
|
|
@@ -139,7 +137,7 @@ export async function handleVoiceWebhook(req: Request): Promise<Response> {
|
|
|
139
137
|
log.info({ callSessionId, callSid }, 'Stored CallSid from voice webhook');
|
|
140
138
|
}
|
|
141
139
|
|
|
142
|
-
|
|
140
|
+
let profile = resolveVoiceQualityProfile(loadConfig());
|
|
143
141
|
|
|
144
142
|
log.info({ callSessionId, mode: profile.mode, ttsProvider: profile.ttsProvider, voice: profile.voice }, 'Voice quality profile resolved');
|
|
145
143
|
|
|
@@ -147,6 +145,36 @@ export async function handleVoiceWebhook(req: Request): Promise<Response> {
|
|
|
147
145
|
log.warn({ callSessionId, errors: profile.validationErrors }, 'Voice quality profile has validation warnings');
|
|
148
146
|
}
|
|
149
147
|
|
|
148
|
+
// WS-A: Enforce strict fallback semantics — reject invalid profiles when fallback is disabled
|
|
149
|
+
if (!isVoiceProfileValid(profile)) {
|
|
150
|
+
if (!profile.fallbackToStandardOnError) {
|
|
151
|
+
const errorMsg = `Voice quality configuration error: ${profile.validationErrors.join('; ')}`;
|
|
152
|
+
log.error({ callSessionId, errors: profile.validationErrors }, errorMsg);
|
|
153
|
+
return new Response(errorMsg, { status: 500 });
|
|
154
|
+
}
|
|
155
|
+
// Fallback is enabled — profile already resolved to standard; log explicitly
|
|
156
|
+
log.info({ callSessionId }, 'Profile invalid with fallback enabled; proceeding with standard mode');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// WS-B: Guard elevenlabs_agent until consultation bridge exists.
|
|
160
|
+
// This fires BEFORE any ElevenLabs API calls, blocking the entire mode.
|
|
161
|
+
if (profile.mode === 'elevenlabs_agent') {
|
|
162
|
+
if (!profile.fallbackToStandardOnError) {
|
|
163
|
+
const msg = 'elevenlabs_agent mode is restricted: consultation bridging (waiting_on_user) is not yet supported. Set calls.voice.fallbackToStandardOnError=true to fall back to standard mode.';
|
|
164
|
+
log.error({ callSessionId }, msg);
|
|
165
|
+
return new Response(msg, { status: 501 });
|
|
166
|
+
}
|
|
167
|
+
log.warn({ callSessionId }, 'elevenlabs_agent mode is restricted/experimental — consultation bridging is not yet supported; falling back to standard ConversationRelay TwiML');
|
|
168
|
+
const standardConfig = loadConfig();
|
|
169
|
+
profile = resolveVoiceQualityProfile({
|
|
170
|
+
...standardConfig,
|
|
171
|
+
calls: {
|
|
172
|
+
...standardConfig.calls,
|
|
173
|
+
voice: { ...standardConfig.calls.voice, mode: 'twilio_standard' },
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
150
178
|
const twilioConfig = getTwilioConfig();
|
|
151
179
|
let relayUrl: string;
|
|
152
180
|
try {
|
|
@@ -157,39 +185,6 @@ export async function handleVoiceWebhook(req: Request): Promise<Response> {
|
|
|
157
185
|
}
|
|
158
186
|
const welcomeGreeting = process.env.CALL_WELCOME_GREETING ?? 'Hello, how can I help you today?';
|
|
159
187
|
|
|
160
|
-
if (profile.mode === 'elevenlabs_agent') {
|
|
161
|
-
try {
|
|
162
|
-
const elevenLabsConfig = getElevenLabsConfig();
|
|
163
|
-
const client = new ElevenLabsClient({
|
|
164
|
-
apiBaseUrl: elevenLabsConfig.apiBaseUrl,
|
|
165
|
-
apiKey: elevenLabsConfig.apiKey,
|
|
166
|
-
timeoutMs: elevenLabsConfig.registerCallTimeoutMs,
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
const result = await client.registerCall({
|
|
170
|
-
agent_id: elevenLabsConfig.agentId,
|
|
171
|
-
from_number: formBody.get('From') || session.fromNumber,
|
|
172
|
-
to_number: formBody.get('To') || session.toNumber,
|
|
173
|
-
direction: 'outbound',
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
log.info({ callSessionId }, 'ElevenLabs register-call succeeded');
|
|
177
|
-
return new Response(result.twiml, {
|
|
178
|
-
status: 200,
|
|
179
|
-
headers: { 'Content-Type': 'text/xml' },
|
|
180
|
-
});
|
|
181
|
-
} catch (err) {
|
|
182
|
-
log.error({ err, callSessionId }, 'ElevenLabs register-call failed');
|
|
183
|
-
if (profile.fallbackToStandardOnError) {
|
|
184
|
-
log.warn({ callSessionId }, 'Falling back to twilio_standard mode');
|
|
185
|
-
const standardProfile = resolveVoiceQualityProfile({ ...loadConfig(), calls: { ...loadConfig().calls, voice: { ...loadConfig().calls.voice, mode: 'twilio_standard' } } });
|
|
186
|
-
const twiml = generateTwiML(callSessionId, relayUrl, welcomeGreeting, standardProfile);
|
|
187
|
-
return new Response(twiml, { status: 200, headers: { 'Content-Type': 'text/xml' } });
|
|
188
|
-
}
|
|
189
|
-
return new Response('ElevenLabs service unavailable', { status: 502 });
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
188
|
const twiml = generateTwiML(callSessionId, relayUrl, welcomeGreeting, profile);
|
|
194
189
|
|
|
195
190
|
log.info({ callSessionId }, 'Returning ConversationRelay TwiML');
|
package/src/calls/types.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export type CallStatus = 'initiated' | 'ringing' | 'in_progress' | 'waiting_on_user' | 'completed' | 'failed' | 'cancelled';
|
|
2
|
-
export type CallEventType = 'call_started' | 'call_connected' | 'caller_spoke' | 'assistant_spoke' | 'user_question_asked' | 'user_answered' | 'call_ended' | 'call_failed';
|
|
2
|
+
export type CallEventType = 'call_started' | 'call_connected' | 'caller_spoke' | 'assistant_spoke' | 'user_question_asked' | 'user_answered' | 'user_instruction_relayed' | 'call_ended' | 'call_failed';
|
|
3
3
|
export type PendingQuestionStatus = 'pending' | 'answered' | 'expired' | 'cancelled';
|
|
4
4
|
|
|
5
5
|
export interface CallSession {
|
|
@@ -11,6 +11,8 @@ export interface CallSession {
|
|
|
11
11
|
toNumber: string;
|
|
12
12
|
task: string | null;
|
|
13
13
|
status: CallStatus;
|
|
14
|
+
callerIdentityMode: string | null;
|
|
15
|
+
callerIdentitySource: string | null;
|
|
14
16
|
startedAt: number | null;
|
|
15
17
|
endedAt: number | null;
|
|
16
18
|
lastError: string | null;
|
|
@@ -13,17 +13,34 @@ export interface VoiceQualityProfile {
|
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Build a Twilio-compatible ElevenLabs voice string.
|
|
16
|
-
*
|
|
16
|
+
*
|
|
17
|
+
* Twilio ConversationRelay accepts:
|
|
18
|
+
* - bare voiceId
|
|
19
|
+
* - voiceId-model-speed_stability_similarity
|
|
20
|
+
*
|
|
21
|
+
* We default to bare voiceId unless a model is explicitly configured.
|
|
22
|
+
* This avoids forcing model/tuning suffixes that may be rejected for some
|
|
23
|
+
* voice + model combinations.
|
|
24
|
+
*
|
|
25
|
+
* See: https://www.twilio.com/docs/voice/conversationrelay/voice-configuration
|
|
17
26
|
*/
|
|
18
27
|
export function buildElevenLabsVoiceSpec(config: {
|
|
19
28
|
voiceId: string;
|
|
20
|
-
voiceModelId
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
29
|
+
voiceModelId?: string;
|
|
30
|
+
speed?: number;
|
|
31
|
+
stability?: number;
|
|
32
|
+
similarityBoost?: number;
|
|
24
33
|
}): string {
|
|
25
|
-
|
|
26
|
-
return
|
|
34
|
+
const voiceId = config.voiceId?.trim();
|
|
35
|
+
if (!voiceId) return '';
|
|
36
|
+
|
|
37
|
+
const voiceModelId = config.voiceModelId?.trim();
|
|
38
|
+
if (!voiceModelId) return voiceId;
|
|
39
|
+
|
|
40
|
+
const speed = config.speed ?? 1.0;
|
|
41
|
+
const stability = config.stability ?? 0.5;
|
|
42
|
+
const similarityBoost = config.similarityBoost ?? 0.75;
|
|
43
|
+
return `${voiceId}-${voiceModelId}-${speed}_${stability}_${similarityBoost}`;
|
|
27
44
|
}
|
|
28
45
|
|
|
29
46
|
/**
|
|
@@ -90,3 +107,8 @@ export function resolveVoiceQualityProfile(config?: ReturnType<typeof loadConfig
|
|
|
90
107
|
|
|
91
108
|
return standardProfile;
|
|
92
109
|
}
|
|
110
|
+
|
|
111
|
+
/** Returns false when the profile has any validation errors. */
|
|
112
|
+
export function isVoiceProfileValid(profile: VoiceQualityProfile): boolean {
|
|
113
|
+
return profile.validationErrors.length === 0;
|
|
114
|
+
}
|
package/src/cli/twitter.ts
CHANGED
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
clearSession,
|
|
14
14
|
} from '../twitter/session.js';
|
|
15
15
|
import {
|
|
16
|
-
postTweet,
|
|
17
16
|
getUserByScreenName,
|
|
18
17
|
getUserTweets,
|
|
19
18
|
getTweetDetail,
|
|
@@ -27,6 +26,7 @@ import {
|
|
|
27
26
|
getUserMedia,
|
|
28
27
|
SessionExpiredError,
|
|
29
28
|
} from '../twitter/client.js';
|
|
29
|
+
import { routedPostTweet } from '../twitter/router.js';
|
|
30
30
|
import { getSocketPath, readSessionToken } from '../util/platform.js';
|
|
31
31
|
import {
|
|
32
32
|
serialize,
|
|
@@ -69,11 +69,32 @@ async function run(cmd: Command, fn: () => Promise<unknown>): Promise<void> {
|
|
|
69
69
|
getJson(cmd),
|
|
70
70
|
);
|
|
71
71
|
} catch (err) {
|
|
72
|
+
const meta = err as Record<string, unknown>;
|
|
72
73
|
if (err instanceof SessionExpiredError) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
// Preserve backward-compatible error code while surfacing router metadata
|
|
75
|
+
const payload: Record<string, unknown> = {
|
|
76
|
+
ok: false,
|
|
77
|
+
error: 'session_expired',
|
|
78
|
+
message: SESSION_EXPIRED_MSG,
|
|
79
|
+
};
|
|
80
|
+
if (meta.pathUsed !== undefined) payload.pathUsed = meta.pathUsed;
|
|
81
|
+
if (meta.suggestAlternative !== undefined) payload.suggestAlternative = meta.suggestAlternative;
|
|
82
|
+
if (meta.oauthError !== undefined) payload.oauthError = meta.oauthError;
|
|
83
|
+
output(payload, getJson(cmd));
|
|
84
|
+
process.exitCode = 1;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
// For routed errors with any router metadata, emit structured JSON
|
|
88
|
+
// so callers can see dual-path diagnostics (pathUsed, oauthError, etc.)
|
|
89
|
+
if (err instanceof Error && (meta.pathUsed !== undefined || meta.suggestAlternative !== undefined || meta.oauthError !== undefined)) {
|
|
90
|
+
const payload: Record<string, unknown> = {
|
|
91
|
+
ok: false,
|
|
92
|
+
error: err.message,
|
|
93
|
+
};
|
|
94
|
+
if (meta.pathUsed !== undefined) payload.pathUsed = meta.pathUsed;
|
|
95
|
+
if (meta.suggestAlternative !== undefined) payload.suggestAlternative = meta.suggestAlternative;
|
|
96
|
+
if (meta.oauthError !== undefined) payload.oauthError = meta.oauthError;
|
|
97
|
+
output(payload, getJson(cmd));
|
|
77
98
|
process.exitCode = 1;
|
|
78
99
|
return;
|
|
79
100
|
}
|
|
@@ -90,7 +111,7 @@ export function registerTwitterCommand(program: Command): void {
|
|
|
90
111
|
.command('x')
|
|
91
112
|
.alias('twitter')
|
|
92
113
|
.description(
|
|
93
|
-
'Post on X and manage
|
|
114
|
+
'Post on X and manage connections. Supports OAuth (official API) and browser session paths.',
|
|
94
115
|
)
|
|
95
116
|
.option('--json', 'Machine-readable JSON output');
|
|
96
117
|
|
|
@@ -172,25 +193,95 @@ export function registerTwitterCommand(program: Command): void {
|
|
|
172
193
|
});
|
|
173
194
|
|
|
174
195
|
// =========================================================================
|
|
175
|
-
// status — check session status
|
|
196
|
+
// status — check session status + OAuth and strategy info
|
|
176
197
|
// =========================================================================
|
|
177
198
|
tw.command('status')
|
|
178
|
-
.description('Check
|
|
179
|
-
.action((_opts: unknown, cmd: Command) => {
|
|
199
|
+
.description('Check Twitter session, OAuth, and strategy status')
|
|
200
|
+
.action(async (_opts: unknown, cmd: Command) => {
|
|
180
201
|
const session = loadSession();
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
ok: true,
|
|
185
|
-
loggedIn: true,
|
|
202
|
+
const browserInfo: Record<string, unknown> = session
|
|
203
|
+
? {
|
|
204
|
+
browserSessionActive: true,
|
|
186
205
|
cookieCount: session.cookies.length,
|
|
187
206
|
importedAt: session.importedAt,
|
|
188
207
|
recordingId: session.recordingId,
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
208
|
+
}
|
|
209
|
+
: { browserSessionActive: false };
|
|
210
|
+
|
|
211
|
+
// Query daemon for OAuth / strategy config
|
|
212
|
+
let oauthInfo: Record<string, unknown> = {};
|
|
213
|
+
try {
|
|
214
|
+
const daemonResponse = await sendDaemonMessage({
|
|
215
|
+
type: 'twitter_integration_config',
|
|
216
|
+
action: 'get',
|
|
217
|
+
} as import('../daemon/ipc-protocol.js').ClientMessage, 'twitter_integration_config_response');
|
|
218
|
+
const r = daemonResponse as Record<string, unknown>;
|
|
219
|
+
oauthInfo = {
|
|
220
|
+
oauthConnected: r.connected ?? false,
|
|
221
|
+
oauthAccount: r.accountInfo ?? undefined,
|
|
222
|
+
preferredStrategy: r.strategy ?? 'auto',
|
|
223
|
+
strategyConfigured: r.strategyConfigured ?? false,
|
|
224
|
+
};
|
|
225
|
+
} catch {
|
|
226
|
+
// Daemon may not be running; report what we can from the local session
|
|
227
|
+
oauthInfo = {
|
|
228
|
+
oauthConnected: undefined,
|
|
229
|
+
oauthAccount: undefined,
|
|
230
|
+
preferredStrategy: undefined,
|
|
231
|
+
strategyConfigured: undefined,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
output(
|
|
236
|
+
{
|
|
237
|
+
ok: true,
|
|
238
|
+
loggedIn: !!session,
|
|
239
|
+
...browserInfo,
|
|
240
|
+
...oauthInfo,
|
|
241
|
+
},
|
|
242
|
+
getJson(cmd),
|
|
243
|
+
);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// =========================================================================
|
|
247
|
+
// strategy — get or set the Twitter operation strategy
|
|
248
|
+
// =========================================================================
|
|
249
|
+
const strategyCli = tw.command('strategy')
|
|
250
|
+
.description('Get or set the Twitter operation strategy (oauth, browser, auto)')
|
|
251
|
+
.action(async (_opts: unknown, cmd: Command) => {
|
|
252
|
+
const json = getJson(cmd);
|
|
253
|
+
try {
|
|
254
|
+
const daemonResponse = await sendDaemonMessage({
|
|
255
|
+
type: 'twitter_integration_config',
|
|
256
|
+
action: 'get_strategy',
|
|
257
|
+
} as import('../daemon/ipc-protocol.js').ClientMessage, 'twitter_integration_config_response');
|
|
258
|
+
const r = daemonResponse as Record<string, unknown>;
|
|
259
|
+
output({ ok: true, strategy: r.strategy ?? 'auto' }, json);
|
|
260
|
+
} catch (err) {
|
|
261
|
+
outputError(err instanceof Error ? err.message : String(err));
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
strategyCli.command('set')
|
|
266
|
+
.description('Set the Twitter operation strategy')
|
|
267
|
+
.argument('<value>', 'Strategy value: oauth, browser, or auto')
|
|
268
|
+
.action(async (value: string, _opts: unknown, cmd: Command) => {
|
|
269
|
+
const json = getJson(cmd);
|
|
270
|
+
try {
|
|
271
|
+
const daemonResponse = await sendDaemonMessage({
|
|
272
|
+
type: 'twitter_integration_config',
|
|
273
|
+
action: 'set_strategy',
|
|
274
|
+
strategy: value,
|
|
275
|
+
} as import('../daemon/ipc-protocol.js').ClientMessage, 'twitter_integration_config_response');
|
|
276
|
+
const r = daemonResponse as Record<string, unknown>;
|
|
277
|
+
if (r.success) {
|
|
278
|
+
output({ ok: true, strategy: r.strategy }, json);
|
|
279
|
+
} else {
|
|
280
|
+
output({ ok: false, error: r.error ?? 'Failed to set strategy' }, json);
|
|
281
|
+
process.exitCode = 1;
|
|
282
|
+
}
|
|
283
|
+
} catch (err) {
|
|
284
|
+
outputError(err instanceof Error ? err.message : String(err));
|
|
194
285
|
}
|
|
195
286
|
});
|
|
196
287
|
|
|
@@ -202,11 +293,12 @@ export function registerTwitterCommand(program: Command): void {
|
|
|
202
293
|
.argument('<text>', 'Tweet text')
|
|
203
294
|
.action(async (text: string, _opts: unknown, cmd: Command) => {
|
|
204
295
|
await run(cmd, async () => {
|
|
205
|
-
const result = await
|
|
296
|
+
const { result, pathUsed } = await routedPostTweet(text);
|
|
206
297
|
return {
|
|
207
298
|
tweetId: result.tweetId,
|
|
208
299
|
text: result.text,
|
|
209
300
|
url: result.url,
|
|
301
|
+
pathUsed,
|
|
210
302
|
};
|
|
211
303
|
});
|
|
212
304
|
});
|
|
@@ -226,12 +318,13 @@ export function registerTwitterCommand(program: Command): void {
|
|
|
226
318
|
throw new Error(`Could not extract tweet ID from: ${tweetUrl}`);
|
|
227
319
|
}
|
|
228
320
|
const inReplyToTweetId = idMatch[1];
|
|
229
|
-
const result = await
|
|
321
|
+
const { result, pathUsed } = await routedPostTweet(text, { inReplyToTweetId });
|
|
230
322
|
return {
|
|
231
323
|
tweetId: result.tweetId,
|
|
232
324
|
text: result.text,
|
|
233
325
|
url: result.url,
|
|
234
326
|
inReplyToTweetId,
|
|
327
|
+
pathUsed,
|
|
235
328
|
};
|
|
236
329
|
});
|
|
237
330
|
});
|
|
@@ -382,6 +475,92 @@ export function registerTwitterCommand(program: Command): void {
|
|
|
382
475
|
});
|
|
383
476
|
}
|
|
384
477
|
|
|
478
|
+
// ---------------------------------------------------------------------------
|
|
479
|
+
// Daemon IPC helper — send a message and wait for the first response
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
|
|
482
|
+
function sendDaemonMessage(
|
|
483
|
+
message: import('../daemon/ipc-protocol.js').ClientMessage,
|
|
484
|
+
expectedResponseType: string,
|
|
485
|
+
): Promise<Record<string, unknown>> {
|
|
486
|
+
return new Promise((resolve, reject) => {
|
|
487
|
+
const socketPath = getSocketPath();
|
|
488
|
+
const sessionToken = readSessionToken();
|
|
489
|
+
const socket = net.createConnection(socketPath);
|
|
490
|
+
const parser = createMessageParser();
|
|
491
|
+
|
|
492
|
+
const timeoutHandle = setTimeout(() => {
|
|
493
|
+
socket.destroy();
|
|
494
|
+
reject(new Error('Daemon request timed out after 10s'));
|
|
495
|
+
}, 10_000);
|
|
496
|
+
timeoutHandle.unref();
|
|
497
|
+
|
|
498
|
+
let authenticated = !sessionToken;
|
|
499
|
+
let messageSent = false;
|
|
500
|
+
|
|
501
|
+
const sendPayload = () => {
|
|
502
|
+
if (messageSent) return;
|
|
503
|
+
messageSent = true;
|
|
504
|
+
socket.write(serialize(message));
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
socket.on('error', (err) => {
|
|
508
|
+
clearTimeout(timeoutHandle);
|
|
509
|
+
reject(new Error(`Cannot connect to daemon: ${err.message}. Is the daemon running?`));
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
socket.on('data', (chunk) => {
|
|
513
|
+
const messages = parser.feed(chunk.toString('utf-8'));
|
|
514
|
+
for (const msg of messages) {
|
|
515
|
+
const m = msg as unknown as Record<string, unknown>;
|
|
516
|
+
|
|
517
|
+
if (!authenticated && m.type === 'auth_result') {
|
|
518
|
+
if ((m as { success: boolean }).success) {
|
|
519
|
+
authenticated = true;
|
|
520
|
+
sendPayload();
|
|
521
|
+
} else {
|
|
522
|
+
clearTimeout(timeoutHandle);
|
|
523
|
+
socket.destroy();
|
|
524
|
+
reject(new Error('Daemon authentication failed'));
|
|
525
|
+
}
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Reject immediately on daemon error frames so the CLI surfaces the
|
|
530
|
+
// real failure reason instead of hanging until the timeout fires.
|
|
531
|
+
if (m.type === 'error') {
|
|
532
|
+
clearTimeout(timeoutHandle);
|
|
533
|
+
socket.destroy();
|
|
534
|
+
reject(new Error((m as { message?: string }).message ?? 'Daemon returned an error'));
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Only resolve on the expected response type; skip everything else
|
|
539
|
+
if (m.type === expectedResponseType) {
|
|
540
|
+
clearTimeout(timeoutHandle);
|
|
541
|
+
socket.destroy();
|
|
542
|
+
resolve(m);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
// Skip all other message types (auth_result, daemon_status, pong, session_info, tasks_changed, etc.)
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
socket.on('connect', () => {
|
|
550
|
+
if (sessionToken) {
|
|
551
|
+
socket.write(
|
|
552
|
+
serialize({
|
|
553
|
+
type: 'auth',
|
|
554
|
+
token: sessionToken,
|
|
555
|
+
} as unknown as import('../daemon/ipc-protocol.js').ClientMessage),
|
|
556
|
+
);
|
|
557
|
+
} else {
|
|
558
|
+
sendPayload();
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
|
|
385
564
|
// ---------------------------------------------------------------------------
|
|
386
565
|
// Chrome CDP restart helper
|
|
387
566
|
// ---------------------------------------------------------------------------
|
package/src/cli.ts
CHANGED
|
@@ -20,7 +20,6 @@ import { ensureDaemonRunning } from './daemon/lifecycle.js';
|
|
|
20
20
|
import { shouldAutoStartDaemon } from './daemon/connection-policy.js';
|
|
21
21
|
import { renderMainScreen, updateStatusText, updateDaemonText, type MainScreenLayout } from './cli/main-screen.jsx';
|
|
22
22
|
|
|
23
|
-
const SHORT_HASH_LENGTH = 8;
|
|
24
23
|
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
25
24
|
const HEARTBEAT_TIMEOUT_MS = 10_000;
|
|
26
25
|
const RECONNECT_BASE_DELAY_MS = 1_000;
|
|
@@ -43,21 +42,6 @@ export function sanitizeUrlForDisplay(rawUrl: unknown): string {
|
|
|
43
42
|
}
|
|
44
43
|
}
|
|
45
44
|
|
|
46
|
-
/**
|
|
47
|
-
* Format a human-readable principal tag for display in permission prompts.
|
|
48
|
-
* Core principals are omitted (empty string) since they're the default.
|
|
49
|
-
* Skill principals show the skill name, shortened version hash, and target.
|
|
50
|
-
*/
|
|
51
|
-
export function formatPrincipalTag(req: Pick<ConfirmationRequest, 'principalKind' | 'principalId' | 'principalVersion' | 'executionTarget'>): string {
|
|
52
|
-
if (!req.principalKind || req.principalKind === 'core') return '';
|
|
53
|
-
const name = req.principalId ?? req.principalKind;
|
|
54
|
-
// Show a shortened version hash when available (first 8 hex chars after any scheme prefix)
|
|
55
|
-
const versionSuffix = req.principalVersion
|
|
56
|
-
? `@${req.principalVersion.replace(/^[^:]+:/, '').slice(0, SHORT_HASH_LENGTH)}`
|
|
57
|
-
: '';
|
|
58
|
-
const target = req.executionTarget ? ` \u2192 ${req.executionTarget}` : '';
|
|
59
|
-
return `[${req.principalKind}: ${name}${versionSuffix}${target}]`;
|
|
60
|
-
}
|
|
61
45
|
|
|
62
46
|
export async function startCli(): Promise<void> {
|
|
63
47
|
const socketPath = getSocketPath();
|
|
@@ -186,13 +170,10 @@ export async function startCli(): Promise<void> {
|
|
|
186
170
|
|
|
187
171
|
function renderConfirmationPrompt(req: ConfirmationRequest): void {
|
|
188
172
|
const preview = formatCommandPreview(req);
|
|
189
|
-
const principalTag = formatPrincipalTag(req);
|
|
190
173
|
process.stdout.write('\n');
|
|
191
174
|
process.stdout.write(`\u250C ${req.toolName}: ${preview}\n`);
|
|
192
175
|
process.stdout.write(`\u2502 Risk: ${req.riskLevel}${req.sandboxed ? ' [sandboxed]' : ''}\n`);
|
|
193
|
-
if (
|
|
194
|
-
process.stdout.write(`\u2502 Principal: ${principalTag}\n`);
|
|
195
|
-
} else if (req.executionTarget) {
|
|
176
|
+
if (req.executionTarget) {
|
|
196
177
|
process.stdout.write(`\u2502 Target: ${req.executionTarget}\n`);
|
|
197
178
|
}
|
|
198
179
|
if (req.diff) {
|
|
@@ -1,9 +1,57 @@
|
|
|
1
1
|
import type { ToolContext, ToolExecutionResult } from '../../../../tools/types.js';
|
|
2
|
-
import {
|
|
2
|
+
import { mergeContacts, getContact } from '../../../../contacts/contact-store.js';
|
|
3
3
|
|
|
4
|
-
export async function
|
|
4
|
+
export async function executeContactMerge(
|
|
5
5
|
input: Record<string, unknown>,
|
|
6
|
-
|
|
6
|
+
_context: ToolContext,
|
|
7
7
|
): Promise<ToolExecutionResult> {
|
|
8
|
-
|
|
8
|
+
const keepId = input.keep_id as string | undefined;
|
|
9
|
+
const mergeId = input.merge_id as string | undefined;
|
|
10
|
+
|
|
11
|
+
if (!keepId || typeof keepId !== 'string') {
|
|
12
|
+
return { content: 'Error: keep_id is required', isError: true };
|
|
13
|
+
}
|
|
14
|
+
if (!mergeId || typeof mergeId !== 'string') {
|
|
15
|
+
return { content: 'Error: merge_id is required', isError: true };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Show what will be merged for clarity
|
|
19
|
+
const keepContact = getContact(keepId);
|
|
20
|
+
const mergeContact = getContact(mergeId);
|
|
21
|
+
|
|
22
|
+
if (!keepContact) {
|
|
23
|
+
return { content: `Error: Contact "${keepId}" not found`, isError: true };
|
|
24
|
+
}
|
|
25
|
+
if (!mergeContact) {
|
|
26
|
+
return { content: `Error: Contact "${mergeId}" not found`, isError: true };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const merged = mergeContacts(keepId, mergeId);
|
|
31
|
+
|
|
32
|
+
const channelList = merged.channels
|
|
33
|
+
.map((ch) => ` - ${ch.type}: ${ch.address}${ch.isPrimary ? ' (primary)' : ''}`)
|
|
34
|
+
.join('\n');
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
content: [
|
|
38
|
+
`Merged "${mergeContact.displayName}" into "${keepContact.displayName}".`,
|
|
39
|
+
``,
|
|
40
|
+
`Surviving contact (${merged.id}):`,
|
|
41
|
+
` Name: ${merged.displayName}`,
|
|
42
|
+
` Importance: ${merged.importance.toFixed(2)}`,
|
|
43
|
+
` Interactions: ${merged.interactionCount}`,
|
|
44
|
+
merged.relationship ? ` Relationship: ${merged.relationship}` : null,
|
|
45
|
+
merged.channels.length > 0 ? ` Channels:\n${channelList}` : null,
|
|
46
|
+
``,
|
|
47
|
+
`Deleted contact: ${mergeContact.displayName} (${mergeId})`,
|
|
48
|
+
].filter(Boolean).join('\n'),
|
|
49
|
+
isError: false,
|
|
50
|
+
};
|
|
51
|
+
} catch (err) {
|
|
52
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
53
|
+
return { content: `Error: ${msg}`, isError: true };
|
|
54
|
+
}
|
|
9
55
|
}
|
|
56
|
+
|
|
57
|
+
export { executeContactMerge as run };
|