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
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth-backed Twitter API client.
|
|
3
|
+
*
|
|
4
|
+
* Uses stored OAuth2 Bearer tokens (via the token manager) to execute
|
|
5
|
+
* Twitter API v2 operations directly, without requiring a browser session.
|
|
6
|
+
* Currently supports post and reply; all other operations fall back to the
|
|
7
|
+
* browser-based CDP client.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { withValidToken } from '../security/token-manager.js';
|
|
11
|
+
import { getSecureKey } from '../security/secure-keys.js';
|
|
12
|
+
|
|
13
|
+
const TWITTER_API_BASE = 'https://api.x.com/2';
|
|
14
|
+
const SERVICE = 'integration:twitter';
|
|
15
|
+
|
|
16
|
+
/** Operations that the OAuth client can handle natively. */
|
|
17
|
+
const SUPPORTED_OPERATIONS = new Set(['post', 'reply']);
|
|
18
|
+
|
|
19
|
+
export interface OAuthPostResult {
|
|
20
|
+
tweetId: string;
|
|
21
|
+
text: string;
|
|
22
|
+
url?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface OAuthOperationError {
|
|
26
|
+
message: string;
|
|
27
|
+
suggestFallback: boolean;
|
|
28
|
+
fallbackPath: 'browser';
|
|
29
|
+
operation: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class UnsupportedOAuthOperationError extends Error {
|
|
33
|
+
public readonly suggestFallback = true;
|
|
34
|
+
public readonly fallbackPath = 'browser' as const;
|
|
35
|
+
public readonly operation: string;
|
|
36
|
+
constructor(operation: string) {
|
|
37
|
+
super(`The "${operation}" operation is not available via the OAuth API. Use the browser path instead.`);
|
|
38
|
+
this.name = 'UnsupportedOAuthOperationError';
|
|
39
|
+
this.operation = operation;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Post a tweet (or reply) using OAuth2 Bearer token authentication.
|
|
45
|
+
*
|
|
46
|
+
* The token manager handles refresh transparently — if the stored token
|
|
47
|
+
* is expired it will be refreshed before (or after a 401) calling the API.
|
|
48
|
+
*/
|
|
49
|
+
export async function oauthPostTweet(
|
|
50
|
+
text: string,
|
|
51
|
+
opts?: { inReplyToTweetId?: string },
|
|
52
|
+
): Promise<OAuthPostResult> {
|
|
53
|
+
return withValidToken(SERVICE, async (token) => {
|
|
54
|
+
const body: Record<string, unknown> = { text };
|
|
55
|
+
if (opts?.inReplyToTweetId) {
|
|
56
|
+
body.reply = { in_reply_to_tweet_id: opts.inReplyToTweetId };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const res = await fetch(`${TWITTER_API_BASE}/tweets`, {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: {
|
|
62
|
+
Authorization: `Bearer ${token}`,
|
|
63
|
+
'Content-Type': 'application/json',
|
|
64
|
+
},
|
|
65
|
+
body: JSON.stringify(body),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (!res.ok) {
|
|
69
|
+
const errorBody = await res.text().catch(() => '');
|
|
70
|
+
const err = new Error(
|
|
71
|
+
`Twitter API error (${res.status}): ${errorBody.slice(0, 500)}`,
|
|
72
|
+
);
|
|
73
|
+
// Attach status so the token manager's 401-retry logic can detect it.
|
|
74
|
+
(err as Error & { status: number }).status = res.status;
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const json = (await res.json()) as { data: { id: string; text: string } };
|
|
79
|
+
return {
|
|
80
|
+
tweetId: json.data.id,
|
|
81
|
+
text: json.data.text,
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Check whether OAuth credentials are available for the Twitter integration.
|
|
88
|
+
* Returns true if an access token has been stored (the token manager will
|
|
89
|
+
* handle refresh if it's expired).
|
|
90
|
+
*/
|
|
91
|
+
export function oauthIsAvailable(): boolean {
|
|
92
|
+
return getSecureKey('credential:integration:twitter:access_token') !== undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Check whether a given operation is supported via the OAuth API path.
|
|
97
|
+
* Only `post` and `reply` are currently supported; everything else
|
|
98
|
+
* (timeline, search, bookmarks, etc.) requires the browser path.
|
|
99
|
+
*/
|
|
100
|
+
export function oauthSupportsOperation(operation: string): boolean {
|
|
101
|
+
return SUPPORTED_OPERATIONS.has(operation);
|
|
102
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strategy router for Twitter operations.
|
|
3
|
+
* Selects OAuth or browser path based on persisted preference and capability.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { loadRawConfig } from '../config/loader.js';
|
|
7
|
+
import { oauthPostTweet, oauthIsAvailable, oauthSupportsOperation } from './oauth-client.js';
|
|
8
|
+
import { postTweet as browserPostTweet, SessionExpiredError } from './client.js';
|
|
9
|
+
import type { PostTweetResult } from './client.js';
|
|
10
|
+
|
|
11
|
+
export type TwitterStrategy = 'oauth' | 'browser' | 'auto';
|
|
12
|
+
|
|
13
|
+
export interface RoutedResult<T> {
|
|
14
|
+
result: T;
|
|
15
|
+
pathUsed: TwitterStrategy;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface RoutedError {
|
|
19
|
+
message: string;
|
|
20
|
+
pathUsed: TwitterStrategy;
|
|
21
|
+
suggestAlternative?: TwitterStrategy;
|
|
22
|
+
alternativeSetupHint?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getPreferredStrategy(): TwitterStrategy {
|
|
26
|
+
try {
|
|
27
|
+
const raw = loadRawConfig();
|
|
28
|
+
const strategy = raw.twitterOperationStrategy as string | undefined;
|
|
29
|
+
if (strategy === 'oauth' || strategy === 'browser') return strategy;
|
|
30
|
+
} catch { /* fall through */ }
|
|
31
|
+
return 'auto';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function routedPostTweet(
|
|
35
|
+
text: string,
|
|
36
|
+
opts?: { inReplyToTweetId?: string },
|
|
37
|
+
): Promise<RoutedResult<PostTweetResult>> {
|
|
38
|
+
const strategy = getPreferredStrategy();
|
|
39
|
+
const operation = opts?.inReplyToTweetId ? 'reply' : 'post';
|
|
40
|
+
|
|
41
|
+
if (strategy === 'oauth') {
|
|
42
|
+
// User explicitly wants OAuth
|
|
43
|
+
if (!oauthIsAvailable()) {
|
|
44
|
+
throw Object.assign(new Error('OAuth is not configured. Set up OAuth credentials in Settings, or switch to browser strategy: `vellum x strategy set browser`.'), {
|
|
45
|
+
pathUsed: 'oauth' as const,
|
|
46
|
+
suggestAlternative: 'browser' as const,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
const result = await oauthPostTweet(text, opts);
|
|
50
|
+
return { result: { tweetId: result.tweetId, text: result.text, url: result.url ?? `https://x.com/i/status/${result.tweetId}` }, pathUsed: 'oauth' };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (strategy === 'browser') {
|
|
54
|
+
// User explicitly wants browser
|
|
55
|
+
try {
|
|
56
|
+
const result = await browserPostTweet(text, opts);
|
|
57
|
+
return { result, pathUsed: 'browser' };
|
|
58
|
+
} catch (err) {
|
|
59
|
+
if (err instanceof SessionExpiredError) {
|
|
60
|
+
throw Object.assign(err, {
|
|
61
|
+
pathUsed: 'browser' as const,
|
|
62
|
+
suggestAlternative: 'oauth' as const,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// auto strategy: try OAuth first if available and supported, fallback to browser
|
|
70
|
+
let oauthError: Error | undefined;
|
|
71
|
+
if (oauthIsAvailable() && oauthSupportsOperation(operation)) {
|
|
72
|
+
try {
|
|
73
|
+
const result = await oauthPostTweet(text, opts);
|
|
74
|
+
return { result: { tweetId: result.tweetId, text: result.text, url: result.url ?? `https://x.com/i/status/${result.tweetId}` }, pathUsed: 'oauth' };
|
|
75
|
+
} catch (err) {
|
|
76
|
+
oauthError = err instanceof Error ? err : new Error(String(err));
|
|
77
|
+
// Fall through to browser
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Fallback to browser
|
|
82
|
+
try {
|
|
83
|
+
const result = await browserPostTweet(text, opts);
|
|
84
|
+
return { result, pathUsed: 'browser' };
|
|
85
|
+
} catch (err) {
|
|
86
|
+
if (err instanceof SessionExpiredError) {
|
|
87
|
+
throw Object.assign(err, {
|
|
88
|
+
pathUsed: 'auto' as const,
|
|
89
|
+
oauthError: oauthError?.message,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
if (oauthError) {
|
|
93
|
+
const browserError = err instanceof Error ? err : new Error(String(err));
|
|
94
|
+
throw Object.assign(browserError, {
|
|
95
|
+
pathUsed: 'auto' as const,
|
|
96
|
+
oauthError: oauthError.message,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-key debouncer. Delays execution until no new calls arrive
|
|
3
|
+
* within the specified delay period.
|
|
4
|
+
*/
|
|
5
|
+
export class Debouncer {
|
|
6
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
7
|
+
|
|
8
|
+
constructor(private readonly delayMs: number) {}
|
|
9
|
+
|
|
10
|
+
schedule(fn: () => void): void {
|
|
11
|
+
if (this.timer) clearTimeout(this.timer);
|
|
12
|
+
this.timer = setTimeout(() => {
|
|
13
|
+
this.timer = null;
|
|
14
|
+
fn();
|
|
15
|
+
}, this.delayMs);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
cancel(): void {
|
|
19
|
+
if (this.timer) {
|
|
20
|
+
clearTimeout(this.timer);
|
|
21
|
+
this.timer = null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Multi-key debouncer. Each key gets its own independent timer.
|
|
28
|
+
* Includes an optional entry limit with eviction of oldest non-protected entries.
|
|
29
|
+
*/
|
|
30
|
+
export class DebouncerMap {
|
|
31
|
+
private timers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
32
|
+
private readonly defaultDelayMs: number;
|
|
33
|
+
private readonly maxEntries: number;
|
|
34
|
+
private readonly protectedKeyPrefix: string;
|
|
35
|
+
|
|
36
|
+
constructor(options: {
|
|
37
|
+
defaultDelayMs: number;
|
|
38
|
+
maxEntries?: number;
|
|
39
|
+
protectedKeyPrefix?: string;
|
|
40
|
+
}) {
|
|
41
|
+
this.defaultDelayMs = options.defaultDelayMs;
|
|
42
|
+
this.maxEntries = options.maxEntries ?? Infinity;
|
|
43
|
+
this.protectedKeyPrefix = options.protectedKeyPrefix ?? '';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
schedule(key: string, fn: () => void, delayMs?: number): void {
|
|
47
|
+
const existing = this.timers.get(key);
|
|
48
|
+
if (existing) clearTimeout(existing);
|
|
49
|
+
const timer = setTimeout(() => {
|
|
50
|
+
this.timers.delete(key);
|
|
51
|
+
fn();
|
|
52
|
+
}, delayMs ?? this.defaultDelayMs);
|
|
53
|
+
this.timers.set(key, timer);
|
|
54
|
+
this.enforceLimit();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
cancel(key: string): void {
|
|
58
|
+
const timer = this.timers.get(key);
|
|
59
|
+
if (timer) {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
this.timers.delete(key);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
cancelAll(): void {
|
|
66
|
+
for (const timer of this.timers.values()) {
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
}
|
|
69
|
+
this.timers.clear();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
get size(): number {
|
|
73
|
+
return this.timers.size;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private enforceLimit(): void {
|
|
77
|
+
if (this.timers.size <= this.maxEntries) return;
|
|
78
|
+
const excess = this.timers.size - this.maxEntries;
|
|
79
|
+
let removed = 0;
|
|
80
|
+
for (const [key, timer] of this.timers) {
|
|
81
|
+
if (removed >= excess) break;
|
|
82
|
+
if (this.protectedKeyPrefix && key.startsWith(this.protectedKeyPrefix)) continue;
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
this.timers.delete(key);
|
|
85
|
+
removed++;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { networkInterfaces } from 'node:os';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns the local IPv4 address most likely to be reachable from other
|
|
5
|
+
* devices on the same LAN.
|
|
6
|
+
*
|
|
7
|
+
* Priority order:
|
|
8
|
+
* 1. en0 (Wi-Fi on macOS)
|
|
9
|
+
* 2. en1 (secondary network on macOS)
|
|
10
|
+
* 3. First non-loopback IPv4 on any interface
|
|
11
|
+
*
|
|
12
|
+
* Skips link-local addresses (169.254.x.x) and IPv6.
|
|
13
|
+
* Returns null if no suitable address is found (e.g. no network).
|
|
14
|
+
*/
|
|
15
|
+
export function getLocalIPv4(): string | null {
|
|
16
|
+
const ifaces = networkInterfaces();
|
|
17
|
+
|
|
18
|
+
// Priority interfaces in order
|
|
19
|
+
const priorityInterfaces = ['en0', 'en1'];
|
|
20
|
+
|
|
21
|
+
for (const ifName of priorityInterfaces) {
|
|
22
|
+
const addrs = ifaces[ifName];
|
|
23
|
+
if (!addrs) continue;
|
|
24
|
+
for (const addr of addrs) {
|
|
25
|
+
if (addr.family === 'IPv4' && !addr.internal && !isLinkLocal(addr.address)) {
|
|
26
|
+
return addr.address;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Fallback: first non-loopback, non-link-local IPv4 on any interface
|
|
32
|
+
for (const [, addrs] of Object.entries(ifaces)) {
|
|
33
|
+
if (!addrs) continue;
|
|
34
|
+
for (const addr of addrs) {
|
|
35
|
+
if (addr.family === 'IPv4' && !addr.internal && !isLinkLocal(addr.address)) {
|
|
36
|
+
return addr.address;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Returns true for IPv4 link-local addresses (169.254.x.x). */
|
|
45
|
+
function isLinkLocal(address: string): boolean {
|
|
46
|
+
return address.startsWith('169.254.');
|
|
47
|
+
}
|
package/src/util/platform.ts
CHANGED
|
@@ -135,12 +135,37 @@ export function isTCPEnabled(): boolean {
|
|
|
135
135
|
|
|
136
136
|
/**
|
|
137
137
|
* Returns the hostname/address for the TCP listener.
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
138
|
+
* Resolution order (first match wins):
|
|
139
|
+
* 1. VELLUM_DAEMON_TCP_HOST env var (explicit override)
|
|
140
|
+
* 2. If iOS pairing is enabled: '0.0.0.0' (LAN-accessible)
|
|
141
|
+
* 3. Default: '127.0.0.1' (localhost only)
|
|
141
142
|
*/
|
|
142
143
|
export function getTCPHost(): string {
|
|
143
|
-
|
|
144
|
+
const override = process.env.VELLUM_DAEMON_TCP_HOST?.trim();
|
|
145
|
+
if (override) return override;
|
|
146
|
+
if (isIOSPairingEnabled()) return '0.0.0.0';
|
|
147
|
+
return '127.0.0.1';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Returns whether iOS pairing mode is enabled.
|
|
152
|
+
* When enabled, the TCP listener binds to 0.0.0.0 (all interfaces)
|
|
153
|
+
* instead of 127.0.0.1 (localhost only), making the daemon reachable
|
|
154
|
+
* from iOS devices on the same local network.
|
|
155
|
+
*
|
|
156
|
+
* Resolution order (first match wins):
|
|
157
|
+
* 1. VELLUM_DAEMON_IOS_PAIRING env var ('true'/'1' → on, 'false'/'0' → off)
|
|
158
|
+
* 2. Presence of the flag file ~/.vellum/ios-pairing-enabled (exists → on)
|
|
159
|
+
* 3. Default: false
|
|
160
|
+
*
|
|
161
|
+
* This is separate from isTCPEnabled() — TCP can be enabled for localhost-only
|
|
162
|
+
* access without exposing the daemon to the LAN.
|
|
163
|
+
*/
|
|
164
|
+
export function isIOSPairingEnabled(): boolean {
|
|
165
|
+
const override = process.env.VELLUM_DAEMON_IOS_PAIRING?.trim();
|
|
166
|
+
if (override === 'true' || override === '1') return true;
|
|
167
|
+
if (override === 'false' || override === '0') return false;
|
|
168
|
+
return existsSync(join(getRootDir(), 'ios-pairing-enabled'));
|
|
144
169
|
}
|
|
145
170
|
|
|
146
171
|
export function getHttpTokenPath(): string {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guards against concurrent execution of an async factory.
|
|
3
|
+
* Multiple concurrent callers share the same in-flight promise.
|
|
4
|
+
* On failure, the guard resets so subsequent calls can retry.
|
|
5
|
+
*/
|
|
6
|
+
export class PromiseGuard<T> {
|
|
7
|
+
private promise: Promise<T> | null = null;
|
|
8
|
+
|
|
9
|
+
/** Whether a promise is currently in-flight. */
|
|
10
|
+
get active(): boolean {
|
|
11
|
+
return this.promise !== null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Execute the factory, deduplicating concurrent calls.
|
|
16
|
+
* If a call is already in-flight, returns the same promise.
|
|
17
|
+
* On failure, clears the cached promise to allow retry.
|
|
18
|
+
*
|
|
19
|
+
* @param factory - Creates the promise on first call.
|
|
20
|
+
* @param onError - Optional callback invoked when the factory rejects (before re-throwing).
|
|
21
|
+
*/
|
|
22
|
+
run(factory: () => Promise<T>, onError?: (err: unknown) => void): Promise<T> {
|
|
23
|
+
if (this.promise) return this.promise;
|
|
24
|
+
|
|
25
|
+
this.promise = factory();
|
|
26
|
+
this.promise.catch((err) => {
|
|
27
|
+
this.promise = null;
|
|
28
|
+
onError?.(err);
|
|
29
|
+
});
|
|
30
|
+
return this.promise;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Reset the guard, allowing the next call to create a new promise. */
|
|
34
|
+
reset(): void {
|
|
35
|
+
this.promise = null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared retry utilities with exponential backoff + jitter.
|
|
3
|
+
*
|
|
4
|
+
* Used by both the provider retry layer (exception-based) and the
|
|
5
|
+
* web-search tool layer (HTTP response-based).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const DEFAULT_MAX_RETRIES = 3;
|
|
9
|
+
const DEFAULT_BASE_DELAY_MS = 1000;
|
|
10
|
+
|
|
11
|
+
export interface RetryOptions {
|
|
12
|
+
/** Maximum number of retry attempts (default 3). */
|
|
13
|
+
maxRetries?: number;
|
|
14
|
+
/** Base delay in ms for exponential backoff (default 1000). */
|
|
15
|
+
baseDelayMs?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Compute a retry delay with equal jitter: guaranteed floor of cap/2
|
|
20
|
+
* plus random in [0, cap/2]. Prevents retry storms while ensuring
|
|
21
|
+
* retries never collapse to 0ms.
|
|
22
|
+
*/
|
|
23
|
+
export function computeRetryDelay(attempt: number, baseDelayMs = DEFAULT_BASE_DELAY_MS): number {
|
|
24
|
+
const cap = baseDelayMs * Math.pow(2, attempt);
|
|
25
|
+
const half = cap / 2;
|
|
26
|
+
return half + Math.random() * half;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse a Retry-After header value into milliseconds.
|
|
31
|
+
* RFC 7231 allows either delta-seconds (e.g. "120") or an HTTP-date
|
|
32
|
+
* (e.g. "Tue, 17 Feb 2026 12:00:00 GMT"). Returns undefined if unparseable.
|
|
33
|
+
*/
|
|
34
|
+
export function parseRetryAfterMs(value: string): number | undefined {
|
|
35
|
+
const seconds = Number(value);
|
|
36
|
+
if (!isNaN(seconds)) {
|
|
37
|
+
return seconds * 1000;
|
|
38
|
+
}
|
|
39
|
+
// Try HTTP-date format — Date.parse handles RFC 2822 / IMF-fixdate
|
|
40
|
+
const dateMs = Date.parse(value);
|
|
41
|
+
if (!isNaN(dateMs)) {
|
|
42
|
+
return Math.max(0, dateMs - Date.now());
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Determine the retry delay for an HTTP response. Uses the Retry-After
|
|
49
|
+
* header if present, otherwise falls back to exponential backoff with jitter.
|
|
50
|
+
*/
|
|
51
|
+
export function getHttpRetryDelay(
|
|
52
|
+
response: Response,
|
|
53
|
+
attempt: number,
|
|
54
|
+
baseDelayMs = DEFAULT_BASE_DELAY_MS,
|
|
55
|
+
): number {
|
|
56
|
+
const retryAfter = response.headers.get('retry-after');
|
|
57
|
+
if (retryAfter) {
|
|
58
|
+
const parsed = parseRetryAfterMs(retryAfter);
|
|
59
|
+
if (parsed !== undefined) return parsed;
|
|
60
|
+
}
|
|
61
|
+
// Enforce a minimum floor of baseDelayMs when Retry-After is missing.
|
|
62
|
+
// computeRetryDelay uses equal jitter (cap/2 + random*cap/2) which can
|
|
63
|
+
// dip to ~500ms on attempt 0 — too aggressive for 429 rate limits.
|
|
64
|
+
return Math.max(baseDelayMs, computeRetryDelay(attempt, baseDelayMs));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Whether an HTTP status code is retryable (429 or 5xx).
|
|
69
|
+
*/
|
|
70
|
+
export function isRetryableStatus(status: number): boolean {
|
|
71
|
+
return status === 429 || status >= 500;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Whether an error is a retryable network error (ECONNRESET, ECONNREFUSED, etc.).
|
|
76
|
+
* Checks both the error itself and one level of `cause` chain.
|
|
77
|
+
*/
|
|
78
|
+
export function isRetryableNetworkError(error: unknown): boolean {
|
|
79
|
+
if (!(error instanceof Error)) return false;
|
|
80
|
+
|
|
81
|
+
const retryableCodes = new Set(['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EPIPE']);
|
|
82
|
+
|
|
83
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
84
|
+
if (code && retryableCodes.has(code)) return true;
|
|
85
|
+
|
|
86
|
+
if (error.cause instanceof Error) {
|
|
87
|
+
const causeCode = (error.cause as NodeJS.ErrnoException).code;
|
|
88
|
+
if (causeCode && retryableCodes.has(causeCode)) return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function sleep(ms: number): Promise<void> {
|
|
95
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export { DEFAULT_MAX_RETRIES, DEFAULT_BASE_DELAY_MS };
|
package/src/util/truncate.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Truncate a string to `maxLen` characters, appending `suffix` if truncated. */
|
|
2
2
|
export function truncate(str: string, maxLen: number, suffix = '...'): string {
|
|
3
3
|
if (str.length <= maxLen) return str;
|
|
4
|
-
if (maxLen
|
|
4
|
+
if (maxLen < suffix.length) return str.slice(0, maxLen);
|
|
5
5
|
return str.slice(0, maxLen - suffix.length) + suffix;
|
|
6
6
|
}
|