@vellumai/assistant 0.4.48 → 0.4.49
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/ARCHITECTURE.md +2 -2
- package/README.md +2 -23
- package/docs/architecture/integrations.md +45 -41
- package/docs/architecture/keychain-broker.md +3 -3
- package/docs/runbook-trusted-contacts.md +3 -8
- package/hook-templates/debug-prompt-logger/hook.json +1 -1
- package/hook-templates/debug-prompt-logger/run.sh +1 -3
- package/package.json +1 -1
- package/src/__tests__/actor-token-service.test.ts +0 -1
- package/src/__tests__/anthropic-provider.test.ts +156 -0
- package/src/__tests__/approval-cascade.test.ts +810 -0
- package/src/__tests__/approval-primitive.test.ts +0 -1
- package/src/__tests__/approval-routes-http.test.ts +2 -0
- package/src/__tests__/assistant-attachments.test.ts +12 -34
- package/src/__tests__/assistant-feature-flag-guardrails.test.ts +76 -0
- package/src/__tests__/assistant-feature-flags-integration.test.ts +0 -1
- package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +2 -2
- package/src/__tests__/channel-guardian.test.ts +0 -2
- package/src/__tests__/channel-readiness-routes.test.ts +15 -6
- package/src/__tests__/channel-readiness-service.test.ts +10 -9
- package/src/__tests__/checker.test.ts +9 -29
- package/src/__tests__/computer-use-skill-manifest-regression.test.ts +1 -1
- package/src/__tests__/computer-use-tools.test.ts +2 -19
- package/src/__tests__/config-watcher.test.ts +0 -1
- package/src/__tests__/confirmation-request-guardian-bridge.test.ts +0 -1
- package/src/__tests__/context-image-dimensions.test.ts +332 -0
- package/src/__tests__/context-token-estimator.test.ts +196 -13
- package/src/__tests__/conversation-attention-store.test.ts +0 -1
- package/src/__tests__/conversation-attention-telegram.test.ts +0 -1
- package/src/__tests__/conversation-routes-guardian-reply.test.ts +144 -0
- package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -0
- package/src/__tests__/credential-metadata-store.test.ts +64 -73
- package/src/__tests__/credential-security-invariants.test.ts +13 -7
- package/src/__tests__/credential-vault-unit.test.ts +280 -49
- package/src/__tests__/credential-vault.test.ts +138 -16
- package/src/__tests__/credentials-cli.test.ts +71 -0
- package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +0 -1
- package/src/__tests__/ephemeral-permissions.test.ts +3 -3
- package/src/__tests__/gateway-only-guard.test.ts +0 -1
- package/src/__tests__/guardian-action-grant-mint-consume.test.ts +0 -1
- package/src/__tests__/guardian-decision-primitive-canonical.test.ts +0 -1
- package/src/__tests__/guardian-routing-invariants.test.ts +0 -1
- package/src/__tests__/guardian-verification-voice-binding.test.ts +0 -1
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +0 -39
- package/src/__tests__/heartbeat-service.test.ts +0 -1
- package/src/__tests__/host-cu-proxy.test.ts +629 -0
- package/src/__tests__/host-shell-tool.test.ts +27 -15
- package/src/__tests__/http-user-message-parity.test.ts +1 -0
- package/src/__tests__/ingress-url-consistency.test.ts +14 -21
- package/src/__tests__/integration-status.test.ts +32 -51
- package/src/__tests__/intent-routing.test.ts +0 -1
- package/src/__tests__/invite-routes-http.test.ts +10 -9
- package/src/__tests__/keychain-broker-client.test.ts +11 -43
- package/src/__tests__/notification-routing-intent.test.ts +0 -1
- package/src/__tests__/oauth-cli.test.ts +373 -14
- package/src/__tests__/oauth-provider-profiles.test.ts +9 -9
- package/src/__tests__/oauth-scope-policy.test.ts +4 -6
- package/src/__tests__/oauth-store.test.ts +756 -0
- package/src/__tests__/onboarding-starter-tasks.test.ts +0 -1
- package/src/__tests__/provider-error-scenarios.test.ts +0 -1
- package/src/__tests__/provider-streaming.benchmark.test.ts +0 -1
- package/src/__tests__/public-ingress-urls.test.ts +15 -21
- package/src/__tests__/recording-handler.test.ts +3 -4
- package/src/__tests__/registry.test.ts +2 -2
- package/src/__tests__/runtime-events-sse.test.ts +55 -7
- package/src/__tests__/schedule-store.test.ts +0 -1
- package/src/__tests__/scheduler-recurrence.test.ts +0 -1
- package/src/__tests__/scoped-approval-grants.test.ts +0 -1
- package/src/__tests__/scoped-grant-security-matrix.test.ts +0 -1
- package/src/__tests__/secret-ingress-handler.test.ts +0 -1
- package/src/__tests__/send-endpoint-busy.test.ts +21 -6
- package/src/__tests__/sequence-store.test.ts +0 -1
- package/src/__tests__/session-init.benchmark.test.ts +4 -5
- package/src/__tests__/skill-include-graph.test.ts +66 -0
- package/src/__tests__/skill-load-feature-flag.test.ts +0 -1
- package/src/__tests__/skill-load-tool.test.ts +149 -1
- package/src/__tests__/skill-projection-feature-flag.test.ts +0 -1
- package/src/__tests__/skills-uninstall.test.ts +1 -1
- package/src/__tests__/skills.test.ts +3 -3
- package/src/__tests__/slack-channel-config.test.ts +67 -3
- package/src/__tests__/slack-share-routes.test.ts +17 -19
- package/src/__tests__/system-prompt.test.ts +0 -1
- package/src/__tests__/telegram-invite-adapter.test.ts +18 -22
- package/src/__tests__/terminal-tools.test.ts +4 -3
- package/src/__tests__/test-support/computer-use-skill-harness.ts +3 -2
- package/src/__tests__/tool-approval-handler.test.ts +0 -1
- package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +0 -1
- package/src/__tests__/tool-executor-lifecycle-events.test.ts +0 -1
- package/src/__tests__/tool-executor-shell-integration.test.ts +0 -1
- package/src/__tests__/tool-executor.test.ts +0 -1
- package/src/__tests__/tool-grant-request-escalation.test.ts +0 -1
- package/src/__tests__/trust-store-pattern-matches.test.ts +29 -0
- package/src/__tests__/trust-store.test.ts +1 -22
- package/src/__tests__/trusted-contact-approval-notifier.test.ts +0 -1
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +0 -1
- package/src/__tests__/twilio-routes.test.ts +0 -16
- package/src/__tests__/verification-control-plane-policy.test.ts +0 -1
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +0 -1
- package/src/agent/ax-tree-compaction.test.ts +235 -0
- package/src/agent/loop.ts +76 -130
- package/src/calls/call-domain.ts +1 -6
- package/src/calls/relay-server.ts +9 -13
- package/src/calls/twilio-config.ts +2 -7
- package/src/calls/twilio-routes.ts +1 -2
- package/src/calls/voice-ingress-preflight.ts +1 -1
- package/src/cli/commands/browser-relay.ts +18 -12
- package/src/cli/commands/completions.ts +0 -3
- package/src/cli/commands/credentials.ts +101 -15
- package/src/cli/commands/oauth/apps.ts +255 -0
- package/src/cli/commands/oauth/connections.ts +299 -0
- package/src/cli/commands/oauth/index.ts +52 -0
- package/src/cli/commands/oauth/providers.ts +242 -0
- package/src/cli/commands/skills.ts +4 -338
- package/src/cli/program.ts +1 -5
- package/src/cli/reference.ts +1 -3
- package/src/config/assistant-feature-flags.ts +0 -3
- package/src/config/bundled-skills/_shared/CLI_RETRIEVAL_PATTERN.md +1 -1
- package/src/config/bundled-skills/computer-use/SKILL.md +3 -6
- package/src/config/bundled-skills/computer-use/TOOLS.json +22 -4
- package/src/config/bundled-skills/google-calendar/calendar-client.ts +21 -16
- package/src/config/bundled-skills/messaging/tools/shared.ts +1 -4
- package/src/config/bundled-skills/settings/SKILL.md +1 -1
- package/src/config/bundled-skills/settings/TOOLS.json +2 -8
- package/src/config/bundled-skills/settings/tools/voice-config-update.ts +5 -33
- package/src/config/env-registry.ts +14 -83
- package/src/config/env.ts +11 -50
- package/src/config/feature-flag-registry.json +16 -16
- package/src/config/loader.ts +0 -6
- package/src/config/schema.ts +3 -1
- package/src/config/skills.ts +21 -2
- package/src/context/image-dimensions.ts +229 -0
- package/src/context/token-estimator.ts +75 -12
- package/src/context/window-manager.ts +49 -10
- package/src/daemon/assistant-attachments.ts +1 -13
- package/src/daemon/handlers/config-ingress.ts +8 -33
- package/src/daemon/handlers/config-slack-channel.ts +49 -46
- package/src/daemon/handlers/config-telegram.ts +32 -16
- package/src/daemon/handlers/sessions.ts +10 -24
- package/src/daemon/handlers/shared.ts +0 -130
- package/src/daemon/host-cu-proxy.ts +401 -0
- package/src/daemon/lifecycle.ts +36 -68
- package/src/daemon/message-protocol.ts +3 -0
- package/src/daemon/message-types/computer-use.ts +2 -119
- package/src/daemon/message-types/host-cu.ts +19 -0
- package/src/daemon/message-types/messages.ts +3 -0
- package/src/daemon/server.ts +14 -21
- package/src/daemon/session-agent-loop-handlers.ts +2 -0
- package/src/daemon/session-attachments.ts +1 -2
- package/src/daemon/session-slash.ts +1 -1
- package/src/daemon/session-surfaces.ts +40 -28
- package/src/daemon/session-tool-setup.ts +2 -9
- package/src/daemon/session.ts +138 -15
- package/src/daemon/tool-side-effects.ts +2 -8
- package/src/daemon/watch-handler.ts +2 -2
- package/src/events/tool-metrics-listener.ts +2 -2
- package/src/hooks/manager.ts +1 -4
- package/src/inbound/public-ingress-urls.ts +7 -7
- package/src/logfire.ts +16 -5
- package/src/memory/conversation-key-store.ts +21 -0
- package/src/memory/db-init.ts +4 -0
- package/src/memory/migrations/149-oauth-tables.ts +60 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/schema/index.ts +1 -0
- package/src/memory/schema/oauth.ts +65 -0
- package/src/messaging/provider.ts +4 -4
- package/src/messaging/providers/gmail/client.ts +82 -2
- package/src/messaging/providers/gmail/people-client.ts +10 -10
- package/src/messaging/providers/telegram-bot/adapter.ts +17 -17
- package/src/messaging/providers/whatsapp/adapter.ts +11 -8
- package/src/messaging/registry.ts +2 -32
- package/src/notifications/copy-composer.ts +0 -5
- package/src/notifications/signal.ts +4 -5
- package/src/oauth/byo-connection.test.ts +126 -25
- package/src/oauth/byo-connection.ts +22 -6
- package/src/oauth/connect-orchestrator.ts +113 -57
- package/src/oauth/connect-types.ts +17 -23
- package/src/oauth/connection-resolver.ts +35 -11
- package/src/oauth/connection.ts +1 -1
- package/src/oauth/manual-token-connection.ts +104 -0
- package/src/oauth/oauth-store.ts +496 -0
- package/src/oauth/platform-connection.test.ts +29 -0
- package/src/oauth/platform-connection.ts +6 -5
- package/src/oauth/provider-behaviors.ts +124 -0
- package/src/oauth/scope-policy.ts +9 -2
- package/src/oauth/seed-providers.ts +161 -0
- package/src/oauth/token-persistence.ts +74 -78
- package/src/permissions/checker.ts +3 -3
- package/src/permissions/defaults.ts +0 -1
- package/src/permissions/prompter.ts +10 -1
- package/src/permissions/trust-store.ts +13 -0
- package/src/prompts/__tests__/build-cli-reference-section.test.ts +3 -1
- package/src/prompts/system-prompt.ts +28 -40
- package/src/providers/anthropic/client.ts +133 -24
- package/src/providers/retry.ts +1 -27
- package/src/runtime/auth/route-policy.ts +0 -3
- package/src/runtime/channel-reply-delivery.ts +0 -40
- package/src/runtime/gateway-client.ts +0 -7
- package/src/runtime/http-server.ts +8 -6
- package/src/runtime/http-types.ts +2 -2
- package/src/runtime/middleware/twilio-validation.ts +1 -11
- package/src/runtime/pending-interactions.ts +14 -12
- package/src/runtime/routes/channel-delivery-routes.ts +0 -1
- package/src/runtime/routes/conversation-routes.ts +73 -19
- package/src/runtime/routes/events-routes.ts +21 -11
- package/src/runtime/routes/host-cu-routes.ts +97 -0
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +12 -111
- package/src/runtime/routes/integrations/slack/share.ts +6 -7
- package/src/runtime/routes/log-export-routes.ts +126 -8
- package/src/runtime/routes/settings-routes.ts +55 -48
- package/src/runtime/routes/surface-action-routes.ts +1 -1
- package/src/runtime/routes/watch-routes.ts +128 -0
- package/src/schedule/integration-status.ts +10 -9
- package/src/security/credential-key.ts +0 -156
- package/src/security/keychain-broker-client.ts +5 -6
- package/src/security/oauth2.ts +1 -1
- package/src/security/token-manager.ts +119 -46
- package/src/skills/catalog-install.ts +358 -0
- package/src/skills/include-graph.ts +32 -0
- package/src/telegram/bot-username.ts +2 -3
- package/src/tools/browser/network-recorder.ts +1 -1
- package/src/tools/browser/network-recording-types.ts +1 -1
- package/src/tools/computer-use/definitions.ts +46 -11
- package/src/tools/computer-use/registry.ts +4 -5
- package/src/tools/credentials/broker.ts +1 -2
- package/src/tools/credentials/metadata-store.ts +17 -121
- package/src/tools/credentials/vault.ts +94 -167
- package/src/tools/registry.ts +2 -7
- package/src/tools/skills/load.ts +62 -3
- package/src/tools/watch/watch-state.ts +0 -12
- package/src/util/logger.ts +7 -41
- package/src/util/platform.ts +9 -28
- package/src/watcher/providers/google-calendar.ts +2 -1
- package/src/__tests__/computer-use-session-compaction.test.ts +0 -143
- package/src/__tests__/computer-use-session-lifecycle.test.ts +0 -322
- package/src/__tests__/computer-use-session-working-dir.test.ts +0 -166
- package/src/__tests__/computer-use-skill-baseline.test.ts +0 -78
- package/src/__tests__/computer-use-skill-endstate.test.ts +0 -105
- package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +0 -249
- package/src/__tests__/ride-shotgun-handler.test.ts +0 -452
- package/src/cli/commands/dev.ts +0 -129
- package/src/cli/commands/map.ts +0 -391
- package/src/cli/commands/oauth.ts +0 -77
- package/src/config/bundled-skills/computer-use/tools/computer-use-request-control.ts +0 -16
- package/src/daemon/computer-use-session.ts +0 -1026
- package/src/daemon/ride-shotgun-handler.ts +0 -569
- package/src/oauth/provider-base-urls.ts +0 -21
- package/src/oauth/provider-profiles.ts +0 -192
- package/src/prompts/computer-use-prompt.ts +0 -98
- package/src/runtime/routes/computer-use-routes.ts +0 -641
- package/src/runtime/telegram-streaming-delivery.test.ts +0 -729
- package/src/runtime/telegram-streaming-delivery.ts +0 -393
- package/src/tools/computer-use/request-computer-control.ts +0 -56
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Token manager for OAuth2 credentials.
|
|
3
3
|
*
|
|
4
|
-
* Reads refresh configuration (tokenUrl, clientId)
|
|
5
|
-
*
|
|
6
|
-
* refresh
|
|
4
|
+
* Reads refresh configuration (tokenUrl, clientId, authMethod) exclusively
|
|
5
|
+
* from the SQLite oauth-store (provider + app + connection rows). After a
|
|
6
|
+
* successful refresh, writes tokens to new-format secure key paths and
|
|
7
|
+
* updates the oauth_connection row.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
getApp,
|
|
12
|
+
getConnectionByProvider,
|
|
13
|
+
getProvider,
|
|
14
|
+
updateConnection,
|
|
15
|
+
} from "../oauth/oauth-store.js";
|
|
13
16
|
import { getLogger } from "../util/logger.js";
|
|
14
|
-
import { credentialKey, migrateKeys } from "./credential-key.js";
|
|
15
17
|
import { refreshOAuth2Token, type TokenEndpointAuthMethod } from "./oauth2.js";
|
|
16
18
|
import { getSecureKey, setSecureKeyAsync } from "./secure-keys.js";
|
|
17
19
|
|
|
@@ -156,54 +158,113 @@ export class TokenExpiredError extends Error {
|
|
|
156
158
|
|
|
157
159
|
/**
|
|
158
160
|
* Check whether the access token for a service is expired or will expire
|
|
159
|
-
* within the buffer window, based on the `expiresAt` field in
|
|
161
|
+
* within the buffer window, based on the `expiresAt` field in the
|
|
162
|
+
* oauth_connection row.
|
|
160
163
|
*/
|
|
161
164
|
function isTokenExpired(service: string): boolean {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
+
try {
|
|
166
|
+
const conn = getConnectionByProvider(service);
|
|
167
|
+
if (!conn?.expiresAt) return false;
|
|
168
|
+
return Date.now() >= conn.expiresAt - EXPIRY_BUFFER_MS;
|
|
169
|
+
} catch {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Refresh config resolution ─────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
/** Shared shape for resolved refresh configuration. */
|
|
177
|
+
interface RefreshConfig {
|
|
178
|
+
tokenUrl: string;
|
|
179
|
+
clientId: string;
|
|
180
|
+
/** OAuth client secret (optional — PKCE flows may omit it). */
|
|
181
|
+
secret?: string;
|
|
182
|
+
refreshToken?: string;
|
|
183
|
+
authMethod?: TokenEndpointAuthMethod;
|
|
184
|
+
connId: string;
|
|
165
185
|
}
|
|
166
186
|
|
|
167
187
|
/**
|
|
168
|
-
*
|
|
169
|
-
* refresh token and OAuth2 config stored in credential metadata.
|
|
188
|
+
* Resolve refresh configuration from the SQLite oauth-store.
|
|
170
189
|
*
|
|
171
|
-
*
|
|
172
|
-
* Throws `TokenExpiredError` if
|
|
190
|
+
* Looks up connection -> app -> provider to read tokenUrl, clientId, and
|
|
191
|
+
* authMethod. Throws `TokenExpiredError` if the connection is not found
|
|
192
|
+
* or incomplete.
|
|
173
193
|
*/
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
if (!
|
|
194
|
+
function resolveRefreshConfig(service: string): RefreshConfig {
|
|
195
|
+
const conn = getConnectionByProvider(service);
|
|
196
|
+
if (!conn) {
|
|
177
197
|
throw new TokenExpiredError(
|
|
178
198
|
service,
|
|
179
|
-
`No
|
|
199
|
+
`No OAuth connection found for "${service}". Re-authorization required.${recoveryHint(service)}`,
|
|
180
200
|
);
|
|
181
201
|
}
|
|
182
202
|
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
203
|
+
const app = getApp(conn.oauthAppId);
|
|
204
|
+
if (!app) {
|
|
205
|
+
throw new TokenExpiredError(
|
|
206
|
+
service,
|
|
207
|
+
`No OAuth app found for "${service}". Re-authorization required.${recoveryHint(service)}`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
186
210
|
|
|
211
|
+
const provider = getProvider(conn.providerKey);
|
|
212
|
+
if (!provider) {
|
|
213
|
+
throw new TokenExpiredError(
|
|
214
|
+
service,
|
|
215
|
+
`No OAuth provider found for "${service}". Re-authorization required.${recoveryHint(service)}`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const tokenUrl = provider.tokenUrl;
|
|
220
|
+
const clientId = app.clientId;
|
|
187
221
|
if (!tokenUrl || !clientId) {
|
|
188
|
-
// Legacy credentials created by the old integration flow don't store
|
|
189
|
-
// oauth2TokenUrl/oauth2ClientId in metadata. The client ID is user-specific
|
|
190
|
-
// (from their Google Cloud Console) and cannot be recovered, so the only
|
|
191
|
-
// path forward is re-authorization via the new oauth2_connect flow.
|
|
192
|
-
const isLegacy = service === "integration:gmail" && !tokenUrl && !clientId;
|
|
193
|
-
const hint = isLegacy
|
|
194
|
-
? ` This is a one-time migration: your old Gmail connection needs to be re-authorized. Ask me to "reconnect Gmail" to set it up again.`
|
|
195
|
-
: "";
|
|
196
222
|
throw new TokenExpiredError(
|
|
197
223
|
service,
|
|
198
|
-
`Missing OAuth2 refresh config for "${service}".${
|
|
224
|
+
`Missing OAuth2 refresh config for "${service}".${recoveryHint(service)}`,
|
|
199
225
|
);
|
|
200
226
|
}
|
|
201
227
|
|
|
202
|
-
const
|
|
203
|
-
|
|
228
|
+
const secret = getSecureKey(`oauth_app/${app.id}/client_secret`);
|
|
229
|
+
|
|
230
|
+
const refreshToken = getSecureKey(
|
|
231
|
+
`oauth_connection/${conn.id}/refresh_token`,
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const authMethod = provider.tokenEndpointAuthMethod as
|
|
204
235
|
| TokenEndpointAuthMethod
|
|
205
236
|
| undefined;
|
|
206
|
-
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
connId: conn.id,
|
|
240
|
+
tokenUrl,
|
|
241
|
+
clientId,
|
|
242
|
+
secret,
|
|
243
|
+
refreshToken,
|
|
244
|
+
authMethod,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Attempt to refresh the OAuth2 access token for a service.
|
|
250
|
+
*
|
|
251
|
+
* Reads refresh config exclusively from the SQLite oauth-store (provider,
|
|
252
|
+
* app, connection).
|
|
253
|
+
*
|
|
254
|
+
* Returns the new access token on success.
|
|
255
|
+
* Throws `TokenExpiredError` if refresh is not possible.
|
|
256
|
+
*/
|
|
257
|
+
async function doRefresh(service: string): Promise<string> {
|
|
258
|
+
const refreshConfig = resolveRefreshConfig(service);
|
|
259
|
+
const { tokenUrl, clientId, secret, authMethod, connId, refreshToken } =
|
|
260
|
+
refreshConfig;
|
|
261
|
+
|
|
262
|
+
if (!refreshToken) {
|
|
263
|
+
throw new TokenExpiredError(
|
|
264
|
+
service,
|
|
265
|
+
`No refresh token available for "${service}". Re-authorization required.${recoveryHint(service)}`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
207
268
|
|
|
208
269
|
if (isRefreshBreakerOpen(service)) {
|
|
209
270
|
const state = refreshBreakers.get(service)!;
|
|
@@ -220,10 +281,10 @@ async function doRefresh(service: string): Promise<string> {
|
|
|
220
281
|
let result;
|
|
221
282
|
try {
|
|
222
283
|
result = await refreshOAuth2Token(
|
|
223
|
-
|
|
284
|
+
tokenUrl,
|
|
224
285
|
clientId,
|
|
225
286
|
refreshToken,
|
|
226
|
-
|
|
287
|
+
secret,
|
|
227
288
|
authMethod,
|
|
228
289
|
);
|
|
229
290
|
} catch (err) {
|
|
@@ -241,9 +302,10 @@ async function doRefresh(service: string): Promise<string> {
|
|
|
241
302
|
throw err;
|
|
242
303
|
}
|
|
243
304
|
|
|
305
|
+
// ----- Store refreshed access_token -----
|
|
244
306
|
if (
|
|
245
307
|
!(await setSecureKeyAsync(
|
|
246
|
-
|
|
308
|
+
`oauth_connection/${connId}/access_token`,
|
|
247
309
|
result.accessToken,
|
|
248
310
|
))
|
|
249
311
|
) {
|
|
@@ -256,7 +318,7 @@ async function doRefresh(service: string): Promise<string> {
|
|
|
256
318
|
if (result.refreshToken) {
|
|
257
319
|
if (
|
|
258
320
|
!(await setSecureKeyAsync(
|
|
259
|
-
|
|
321
|
+
`oauth_connection/${connId}/refresh_token`,
|
|
260
322
|
result.refreshToken,
|
|
261
323
|
))
|
|
262
324
|
) {
|
|
@@ -267,7 +329,7 @@ async function doRefresh(service: string): Promise<string> {
|
|
|
267
329
|
}
|
|
268
330
|
}
|
|
269
331
|
|
|
270
|
-
// Update
|
|
332
|
+
// Update oauth_connection row with new expiry.
|
|
271
333
|
// Use null to explicitly clear a stale expiresAt when the provider omits
|
|
272
334
|
// expires_in (or returns 0), so isTokenExpired won't keep forcing refreshes.
|
|
273
335
|
const expiresAt =
|
|
@@ -275,7 +337,17 @@ async function doRefresh(service: string): Promise<string> {
|
|
|
275
337
|
? Date.now() + result.expiresIn * 1000
|
|
276
338
|
: null;
|
|
277
339
|
|
|
278
|
-
|
|
340
|
+
try {
|
|
341
|
+
updateConnection(connId, {
|
|
342
|
+
expiresAt,
|
|
343
|
+
hasRefreshToken: !!result.refreshToken,
|
|
344
|
+
});
|
|
345
|
+
} catch (err) {
|
|
346
|
+
log.warn(
|
|
347
|
+
{ err, service },
|
|
348
|
+
"Failed to update oauth_connection after refresh",
|
|
349
|
+
);
|
|
350
|
+
}
|
|
279
351
|
|
|
280
352
|
recordRefreshSuccess(service);
|
|
281
353
|
log.info({ service }, "OAuth2 access token refreshed successfully");
|
|
@@ -290,16 +362,17 @@ async function doRefresh(service: string): Promise<string> {
|
|
|
290
362
|
* 2. If the token is expired or near-expiry, refreshes it before calling the callback.
|
|
291
363
|
* 3. If the callback throws with a 401 status, attempts one refresh-and-retry cycle.
|
|
292
364
|
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
365
|
+
* Retained only for BYO connection internals — prefer
|
|
366
|
+
* `resolveOAuthConnection(service).request()` for new code.
|
|
295
367
|
*/
|
|
296
368
|
export async function withValidToken<T>(
|
|
297
369
|
service: string,
|
|
298
370
|
callback: (token: string) => Promise<T>,
|
|
299
371
|
): Promise<T> {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
372
|
+
const conn = getConnectionByProvider(service);
|
|
373
|
+
let token = conn
|
|
374
|
+
? getSecureKey(`oauth_connection/${conn.id}/access_token`)
|
|
375
|
+
: undefined;
|
|
303
376
|
if (!token) {
|
|
304
377
|
throw new TokenExpiredError(
|
|
305
378
|
service,
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import {
|
|
4
|
+
cpSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { gunzipSync } from "node:zlib";
|
|
15
|
+
|
|
16
|
+
import { getLogger } from "../util/logger.js";
|
|
17
|
+
import {
|
|
18
|
+
getWorkspaceConfigPath,
|
|
19
|
+
getWorkspaceSkillsDir,
|
|
20
|
+
readPlatformToken,
|
|
21
|
+
} from "../util/platform.js";
|
|
22
|
+
|
|
23
|
+
const log = getLogger("catalog-install");
|
|
24
|
+
|
|
25
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export interface CatalogSkill {
|
|
28
|
+
id: string;
|
|
29
|
+
name: string;
|
|
30
|
+
description: string;
|
|
31
|
+
emoji?: string;
|
|
32
|
+
includes?: string[];
|
|
33
|
+
version?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface CatalogManifest {
|
|
37
|
+
version: number;
|
|
38
|
+
skills: CatalogSkill[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── Path helpers ────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
export function getSkillsIndexPath(): string {
|
|
44
|
+
return join(getWorkspaceSkillsDir(), "SKILLS.md");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolve the repo-level skills/ directory when running in dev mode.
|
|
49
|
+
* Returns the path if VELLUM_DEV is set and the directory exists, or undefined.
|
|
50
|
+
*/
|
|
51
|
+
export function getRepoSkillsDir(): string | undefined {
|
|
52
|
+
if (!process.env.VELLUM_DEV) return undefined;
|
|
53
|
+
|
|
54
|
+
// assistant/src/skills/catalog-install.ts -> ../../../skills/
|
|
55
|
+
const candidate = join(import.meta.dir, "..", "..", "..", "skills");
|
|
56
|
+
if (existsSync(join(candidate, "catalog.json"))) {
|
|
57
|
+
return candidate;
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Platform API ────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
function getConfigPlatformUrl(): string | undefined {
|
|
65
|
+
try {
|
|
66
|
+
const configPath = getWorkspaceConfigPath();
|
|
67
|
+
if (!existsSync(configPath)) return undefined;
|
|
68
|
+
const raw = JSON.parse(readFileSync(configPath, "utf-8")) as Record<
|
|
69
|
+
string,
|
|
70
|
+
unknown
|
|
71
|
+
>;
|
|
72
|
+
const platform = raw.platform as Record<string, unknown> | undefined;
|
|
73
|
+
const baseUrl = platform?.baseUrl;
|
|
74
|
+
if (typeof baseUrl === "string" && baseUrl.trim()) return baseUrl.trim();
|
|
75
|
+
} catch {
|
|
76
|
+
// ignore
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getPlatformUrl(): string {
|
|
82
|
+
return (
|
|
83
|
+
process.env.VELLUM_PLATFORM_URL ??
|
|
84
|
+
getConfigPlatformUrl() ??
|
|
85
|
+
"https://platform.vellum.ai"
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function buildHeaders(): Record<string, string> {
|
|
90
|
+
const headers: Record<string, string> = {};
|
|
91
|
+
const token = readPlatformToken();
|
|
92
|
+
if (token) {
|
|
93
|
+
headers["X-Session-Token"] = token;
|
|
94
|
+
}
|
|
95
|
+
return headers;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ─── Catalog operations ──────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
export async function fetchCatalog(): Promise<CatalogSkill[]> {
|
|
101
|
+
const url = `${getPlatformUrl()}/v1/skills/`;
|
|
102
|
+
const response = await fetch(url, {
|
|
103
|
+
headers: buildHeaders(),
|
|
104
|
+
signal: AbortSignal.timeout(10000),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (!response.ok) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Platform API error ${response.status}: ${response.statusText}`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const manifest = (await response.json()) as CatalogManifest;
|
|
114
|
+
if (!Array.isArray(manifest.skills)) {
|
|
115
|
+
throw new Error("Platform catalog has invalid skills array");
|
|
116
|
+
}
|
|
117
|
+
return manifest.skills;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function readLocalCatalog(repoSkillsDir: string): CatalogSkill[] {
|
|
121
|
+
try {
|
|
122
|
+
const raw = readFileSync(join(repoSkillsDir, "catalog.json"), "utf-8");
|
|
123
|
+
const manifest = JSON.parse(raw) as CatalogManifest;
|
|
124
|
+
if (!Array.isArray(manifest.skills)) return [];
|
|
125
|
+
return manifest.skills;
|
|
126
|
+
} catch {
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ─── Tar extraction ──────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Extract all files from a tar archive (uncompressed) into a directory.
|
|
135
|
+
* Returns true if a SKILL.md was found in the archive.
|
|
136
|
+
*/
|
|
137
|
+
export function extractTarToDir(tarBuffer: Buffer, destDir: string): boolean {
|
|
138
|
+
let foundSkillMd = false;
|
|
139
|
+
let offset = 0;
|
|
140
|
+
while (offset + 512 <= tarBuffer.length) {
|
|
141
|
+
const header = tarBuffer.subarray(offset, offset + 512);
|
|
142
|
+
|
|
143
|
+
// End-of-archive (two consecutive zero blocks)
|
|
144
|
+
if (header.every((b) => b === 0)) break;
|
|
145
|
+
|
|
146
|
+
// Filename (bytes 0-99, null-terminated)
|
|
147
|
+
const nameEnd = header.indexOf(0, 0);
|
|
148
|
+
const name = header
|
|
149
|
+
.subarray(0, Math.min(nameEnd >= 0 ? nameEnd : 100, 100))
|
|
150
|
+
.toString("utf-8");
|
|
151
|
+
|
|
152
|
+
// File type (byte 156): '5' = directory, '0' or '\0' = regular file
|
|
153
|
+
const typeFlag = header[156];
|
|
154
|
+
|
|
155
|
+
// File size (bytes 124-135, octal)
|
|
156
|
+
const sizeStr = header.subarray(124, 136).toString("utf-8").trim();
|
|
157
|
+
const size = parseInt(sizeStr, 8) || 0;
|
|
158
|
+
|
|
159
|
+
offset += 512; // past header
|
|
160
|
+
|
|
161
|
+
// Skip directories and empty names
|
|
162
|
+
if (name && typeFlag !== 53 /* '5' */) {
|
|
163
|
+
// Prevent path traversal
|
|
164
|
+
const normalizedName = name.replace(/^\.\//, "");
|
|
165
|
+
if (!normalizedName.startsWith("..") && !normalizedName.includes("/..")) {
|
|
166
|
+
const destPath = join(destDir, normalizedName);
|
|
167
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
168
|
+
writeFileSync(destPath, tarBuffer.subarray(offset, offset + size));
|
|
169
|
+
|
|
170
|
+
if (
|
|
171
|
+
normalizedName === "SKILL.md" ||
|
|
172
|
+
normalizedName.endsWith("/SKILL.md")
|
|
173
|
+
) {
|
|
174
|
+
foundSkillMd = true;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Skip to next header (data padded to 512 bytes)
|
|
180
|
+
offset += Math.ceil(size / 512) * 512;
|
|
181
|
+
}
|
|
182
|
+
return foundSkillMd;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function fetchAndExtractSkill(
|
|
186
|
+
skillId: string,
|
|
187
|
+
destDir: string,
|
|
188
|
+
): Promise<void> {
|
|
189
|
+
const url = `${getPlatformUrl()}/v1/skills/${encodeURIComponent(skillId)}/`;
|
|
190
|
+
const response = await fetch(url, {
|
|
191
|
+
headers: buildHeaders(),
|
|
192
|
+
signal: AbortSignal.timeout(15000),
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
if (!response.ok) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`Failed to fetch skill "${skillId}": HTTP ${response.status}`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const gzipBuffer = Buffer.from(await response.arrayBuffer());
|
|
202
|
+
const tarBuffer = gunzipSync(gzipBuffer);
|
|
203
|
+
const foundSkillMd = extractTarToDir(tarBuffer, destDir);
|
|
204
|
+
|
|
205
|
+
if (!foundSkillMd) {
|
|
206
|
+
throw new Error(`SKILL.md not found in archive for "${skillId}"`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ─── SKILLS.md index management ──────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
function atomicWriteFile(filePath: string, content: string): void {
|
|
213
|
+
const dir = dirname(filePath);
|
|
214
|
+
mkdirSync(dir, { recursive: true });
|
|
215
|
+
const tmpPath = join(dir, `.tmp-${randomUUID()}`);
|
|
216
|
+
writeFileSync(tmpPath, content, "utf-8");
|
|
217
|
+
renameSync(tmpPath, filePath);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function upsertSkillsIndex(id: string): void {
|
|
221
|
+
const indexPath = getSkillsIndexPath();
|
|
222
|
+
let lines: string[] = [];
|
|
223
|
+
if (existsSync(indexPath)) {
|
|
224
|
+
lines = readFileSync(indexPath, "utf-8").split("\n");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
228
|
+
const pattern = new RegExp(`^[-*]\\s+(?:\`)?${escaped}(?:\`)?\\s*$`);
|
|
229
|
+
if (lines.some((line) => pattern.test(line))) return;
|
|
230
|
+
|
|
231
|
+
const nonEmpty = lines.filter((l) => l.trim());
|
|
232
|
+
nonEmpty.push(`- ${id}`);
|
|
233
|
+
const content = nonEmpty.join("\n");
|
|
234
|
+
atomicWriteFile(indexPath, content.endsWith("\n") ? content : content + "\n");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function removeSkillsIndexEntry(id: string): void {
|
|
238
|
+
const indexPath = getSkillsIndexPath();
|
|
239
|
+
if (!existsSync(indexPath)) return;
|
|
240
|
+
|
|
241
|
+
const lines = readFileSync(indexPath, "utf-8").split("\n");
|
|
242
|
+
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
243
|
+
const pattern = new RegExp(`^[-*]\\s+(?:\`)?${escaped}(?:\`)?\\s*$`);
|
|
244
|
+
const filtered = lines.filter((line) => !pattern.test(line));
|
|
245
|
+
|
|
246
|
+
// If nothing changed, skip the write
|
|
247
|
+
if (filtered.length === lines.length) return;
|
|
248
|
+
|
|
249
|
+
const content = filtered.join("\n");
|
|
250
|
+
atomicWriteFile(indexPath, content.endsWith("\n") ? content : content + "\n");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ─── Install / uninstall ─────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
export function uninstallSkillLocally(skillId: string): void {
|
|
256
|
+
const skillDir = join(getWorkspaceSkillsDir(), skillId);
|
|
257
|
+
|
|
258
|
+
if (!existsSync(skillDir)) {
|
|
259
|
+
throw new Error(`Skill "${skillId}" is not installed.`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
rmSync(skillDir, { recursive: true, force: true });
|
|
263
|
+
removeSkillsIndexEntry(skillId);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export async function installSkillLocally(
|
|
267
|
+
skillId: string,
|
|
268
|
+
catalogEntry: CatalogSkill,
|
|
269
|
+
overwrite: boolean,
|
|
270
|
+
): Promise<void> {
|
|
271
|
+
const skillDir = join(getWorkspaceSkillsDir(), skillId);
|
|
272
|
+
const skillFilePath = join(skillDir, "SKILL.md");
|
|
273
|
+
|
|
274
|
+
if (existsSync(skillFilePath) && !overwrite) {
|
|
275
|
+
throw new Error(
|
|
276
|
+
`Skill "${skillId}" is already installed. Use --overwrite to replace it.`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
mkdirSync(skillDir, { recursive: true });
|
|
281
|
+
|
|
282
|
+
// In dev mode, install from the local repo skills directory if available
|
|
283
|
+
const repoSkillsDir = getRepoSkillsDir();
|
|
284
|
+
const repoSkillSource = repoSkillsDir
|
|
285
|
+
? join(repoSkillsDir, skillId)
|
|
286
|
+
: undefined;
|
|
287
|
+
|
|
288
|
+
if (repoSkillSource && existsSync(join(repoSkillSource, "SKILL.md"))) {
|
|
289
|
+
cpSync(repoSkillSource, skillDir, { recursive: true });
|
|
290
|
+
} else {
|
|
291
|
+
await fetchAndExtractSkill(skillId, skillDir);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Write version metadata
|
|
295
|
+
if (catalogEntry.version) {
|
|
296
|
+
const meta = {
|
|
297
|
+
version: catalogEntry.version,
|
|
298
|
+
installedAt: new Date().toISOString(),
|
|
299
|
+
};
|
|
300
|
+
atomicWriteFile(
|
|
301
|
+
join(skillDir, "version.json"),
|
|
302
|
+
JSON.stringify(meta, null, 2) + "\n",
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Install npm dependencies if the skill has a package.json
|
|
307
|
+
if (existsSync(join(skillDir, "package.json"))) {
|
|
308
|
+
const bunPath = `${homedir()}/.bun/bin`;
|
|
309
|
+
execSync("bun install", {
|
|
310
|
+
cwd: skillDir,
|
|
311
|
+
stdio: "inherit",
|
|
312
|
+
env: { ...process.env, PATH: `${bunPath}:${process.env.PATH}` },
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Register in SKILLS.md only after all steps succeed
|
|
317
|
+
upsertSkillsIndex(skillId);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ─── Auto-install (for skill_load) ──────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Attempt to find and install a skill from the first-party catalog.
|
|
324
|
+
* Returns true if the skill was installed, false if not found in catalog.
|
|
325
|
+
* Throws on install failures (network, filesystem, etc).
|
|
326
|
+
*/
|
|
327
|
+
export async function autoInstallFromCatalog(
|
|
328
|
+
skillId: string,
|
|
329
|
+
): Promise<boolean> {
|
|
330
|
+
// Check local catalog first (dev mode), then remote
|
|
331
|
+
const repoSkillsDir = getRepoSkillsDir();
|
|
332
|
+
let entry: CatalogSkill | undefined;
|
|
333
|
+
|
|
334
|
+
if (repoSkillsDir) {
|
|
335
|
+
const localCatalog = readLocalCatalog(repoSkillsDir);
|
|
336
|
+
entry = localCatalog.find((s) => s.id === skillId);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (!entry) {
|
|
340
|
+
try {
|
|
341
|
+
const remoteCatalog = await fetchCatalog();
|
|
342
|
+
entry = remoteCatalog.find((s) => s.id === skillId);
|
|
343
|
+
} catch (err) {
|
|
344
|
+
log.warn(
|
|
345
|
+
{ err, skillId },
|
|
346
|
+
"Failed to fetch remote catalog for auto-install",
|
|
347
|
+
);
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (!entry) {
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
await installSkillLocally(skillId, entry, false);
|
|
357
|
+
return true;
|
|
358
|
+
}
|
|
@@ -151,3 +151,35 @@ export function traverseIncludes(
|
|
|
151
151
|
dfs(rootId);
|
|
152
152
|
return { visited };
|
|
153
153
|
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Collect all missing skill IDs reachable from the root's include graph.
|
|
157
|
+
* DFS traversal that tracks visited nodes to prevent infinite loops on cycles.
|
|
158
|
+
* The root itself is never reported as missing (it's already loaded by the caller).
|
|
159
|
+
*/
|
|
160
|
+
export function collectAllMissing(
|
|
161
|
+
rootId: string,
|
|
162
|
+
catalogIndex: Map<string, SkillSummary>,
|
|
163
|
+
): Set<string> {
|
|
164
|
+
const missing = new Set<string>();
|
|
165
|
+
const visited = new Set<string>();
|
|
166
|
+
|
|
167
|
+
function dfs(id: string): void {
|
|
168
|
+
if (visited.has(id)) return;
|
|
169
|
+
visited.add(id);
|
|
170
|
+
|
|
171
|
+
const skill = catalogIndex.get(id);
|
|
172
|
+
if (!skill?.includes) return;
|
|
173
|
+
|
|
174
|
+
for (const childId of skill.includes) {
|
|
175
|
+
if (!catalogIndex.has(childId)) {
|
|
176
|
+
missing.add(childId);
|
|
177
|
+
} else if (!visited.has(childId)) {
|
|
178
|
+
dfs(childId);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
dfs(rootId);
|
|
184
|
+
return missing;
|
|
185
|
+
}
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { getConfig } from "../config/loader.js";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Read the Telegram bot username from config
|
|
5
|
-
* TELEGRAM_BOT_USERNAME env var.
|
|
4
|
+
* Read the Telegram bot username from config.
|
|
6
5
|
*/
|
|
7
6
|
export function getTelegramBotUsername(): string | undefined {
|
|
8
7
|
const value = getConfig().telegram.botUsername;
|
|
9
8
|
if (value.trim().length > 0) {
|
|
10
9
|
return value.trim();
|
|
11
10
|
}
|
|
12
|
-
return
|
|
11
|
+
return undefined;
|
|
13
12
|
}
|