@vellumai/assistant 0.4.49 → 0.4.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) hide show
  1. package/ARCHITECTURE.md +24 -33
  2. package/README.md +3 -3
  3. package/docs/architecture/memory.md +180 -119
  4. package/package.json +2 -2
  5. package/src/__tests__/agent-loop.test.ts +3 -1
  6. package/src/__tests__/anthropic-provider.test.ts +114 -23
  7. package/src/__tests__/approval-cascade.test.ts +1 -15
  8. package/src/__tests__/approval-routes-http.test.ts +2 -0
  9. package/src/__tests__/assistant-feature-flag-guard.test.ts +0 -23
  10. package/src/__tests__/canonical-guardian-store.test.ts +95 -0
  11. package/src/__tests__/checker.test.ts +13 -0
  12. package/src/__tests__/config-schema.test.ts +1 -68
  13. package/src/__tests__/context-memory-e2e.test.ts +11 -100
  14. package/src/__tests__/conversation-routes-guardian-reply.test.ts +8 -0
  15. package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -0
  16. package/src/__tests__/credential-security-e2e.test.ts +1 -0
  17. package/src/__tests__/credential-vault-unit.test.ts +4 -0
  18. package/src/__tests__/credential-vault.test.ts +13 -1
  19. package/src/__tests__/cu-unified-flow.test.ts +532 -0
  20. package/src/__tests__/date-context.test.ts +93 -77
  21. package/src/__tests__/deterministic-verification-control-plane.test.ts +64 -0
  22. package/src/__tests__/guardian-routing-invariants.test.ts +93 -0
  23. package/src/__tests__/history-repair.test.ts +245 -0
  24. package/src/__tests__/host-cu-proxy.test.ts +165 -3
  25. package/src/__tests__/http-user-message-parity.test.ts +1 -0
  26. package/src/__tests__/invite-redemption-service.test.ts +65 -1
  27. package/src/__tests__/keychain-broker-client.test.ts +4 -4
  28. package/src/__tests__/memory-context-benchmark.benchmark.test.ts +56 -18
  29. package/src/__tests__/memory-lifecycle-e2e.test.ts +244 -387
  30. package/src/__tests__/memory-recall-quality.test.ts +244 -407
  31. package/src/__tests__/memory-regressions.experimental.test.ts +126 -101
  32. package/src/__tests__/memory-regressions.test.ts +477 -2841
  33. package/src/__tests__/memory-retrieval.benchmark.test.ts +33 -150
  34. package/src/__tests__/memory-upsert-concurrency.test.ts +5 -244
  35. package/src/__tests__/mime-builder.test.ts +28 -0
  36. package/src/__tests__/native-web-search.test.ts +1 -0
  37. package/src/__tests__/oauth-cli.test.ts +572 -5
  38. package/src/__tests__/oauth-store.test.ts +120 -6
  39. package/src/__tests__/qdrant-collection-migration.test.ts +53 -8
  40. package/src/__tests__/registry.test.ts +0 -1
  41. package/src/__tests__/relay-server.test.ts +46 -1
  42. package/src/__tests__/schedule-tools.test.ts +32 -0
  43. package/src/__tests__/script-proxy-certs.test.ts +1 -1
  44. package/src/__tests__/secret-onetime-send.test.ts +1 -0
  45. package/src/__tests__/secure-keys.test.ts +7 -2
  46. package/src/__tests__/send-endpoint-busy.test.ts +3 -0
  47. package/src/__tests__/session-abort-tool-results.test.ts +1 -14
  48. package/src/__tests__/session-agent-loop-overflow.test.ts +1583 -0
  49. package/src/__tests__/session-agent-loop.test.ts +19 -15
  50. package/src/__tests__/session-confirmation-signals.test.ts +1 -15
  51. package/src/__tests__/session-error.test.ts +124 -2
  52. package/src/__tests__/session-history-web-search.test.ts +918 -0
  53. package/src/__tests__/session-pre-run-repair.test.ts +1 -14
  54. package/src/__tests__/session-provider-retry-repair.test.ts +25 -28
  55. package/src/__tests__/session-queue.test.ts +37 -27
  56. package/src/__tests__/session-runtime-assembly.test.ts +54 -0
  57. package/src/__tests__/session-slash-known.test.ts +1 -15
  58. package/src/__tests__/session-slash-queue.test.ts +1 -15
  59. package/src/__tests__/session-slash-unknown.test.ts +1 -15
  60. package/src/__tests__/session-workspace-cache-state.test.ts +3 -33
  61. package/src/__tests__/session-workspace-injection.test.ts +3 -37
  62. package/src/__tests__/session-workspace-tool-tracking.test.ts +3 -37
  63. package/src/__tests__/skills-install-extract.test.ts +93 -0
  64. package/src/__tests__/skillssh-registry.test.ts +451 -0
  65. package/src/__tests__/trust-store.test.ts +15 -0
  66. package/src/__tests__/voice-invite-redemption.test.ts +32 -1
  67. package/src/agent/ax-tree-compaction.test.ts +51 -0
  68. package/src/agent/loop.ts +39 -12
  69. package/src/approvals/AGENTS.md +1 -1
  70. package/src/approvals/guardian-request-resolvers.ts +14 -2
  71. package/src/bundler/compiler-tools.ts +66 -2
  72. package/src/calls/call-domain.ts +132 -0
  73. package/src/calls/call-store.ts +6 -0
  74. package/src/calls/relay-server.ts +43 -5
  75. package/src/calls/relay-setup-router.ts +17 -1
  76. package/src/calls/twilio-config.ts +1 -1
  77. package/src/calls/types.ts +3 -1
  78. package/src/cli/commands/doctor.ts +4 -3
  79. package/src/cli/commands/mcp.ts +46 -59
  80. package/src/cli/commands/memory.ts +16 -165
  81. package/src/cli/commands/oauth/apps.ts +31 -2
  82. package/src/cli/commands/oauth/connections.ts +431 -97
  83. package/src/cli/commands/oauth/providers.ts +15 -1
  84. package/src/cli/commands/sessions.ts +5 -2
  85. package/src/cli/commands/skills.ts +173 -1
  86. package/src/cli/http-client.ts +0 -20
  87. package/src/cli/main-screen.tsx +2 -2
  88. package/src/cli/program.ts +5 -6
  89. package/src/cli.ts +4 -10
  90. package/src/config/bundled-skills/computer-use/TOOLS.json +1 -1
  91. package/src/config/bundled-skills/computer-use/tools/computer-use-observe.ts +12 -0
  92. package/src/config/bundled-tool-registry.ts +2 -5
  93. package/src/config/schema.ts +1 -12
  94. package/src/config/schemas/memory-lifecycle.ts +0 -9
  95. package/src/config/schemas/memory-processing.ts +0 -180
  96. package/src/config/schemas/memory-retrieval.ts +32 -104
  97. package/src/config/schemas/memory.ts +0 -10
  98. package/src/config/types.ts +0 -4
  99. package/src/context/window-manager.ts +4 -1
  100. package/src/daemon/config-watcher.ts +61 -3
  101. package/src/daemon/daemon-control.ts +1 -1
  102. package/src/daemon/date-context.ts +114 -31
  103. package/src/daemon/handlers/sessions.ts +18 -13
  104. package/src/daemon/handlers/skills.ts +20 -1
  105. package/src/daemon/history-repair.ts +72 -8
  106. package/src/daemon/host-cu-proxy.ts +55 -26
  107. package/src/daemon/lifecycle.ts +31 -3
  108. package/src/daemon/mcp-reload-service.ts +2 -2
  109. package/src/daemon/message-types/computer-use.ts +1 -12
  110. package/src/daemon/message-types/memory.ts +4 -16
  111. package/src/daemon/message-types/messages.ts +1 -0
  112. package/src/daemon/message-types/sessions.ts +4 -0
  113. package/src/daemon/server.ts +12 -1
  114. package/src/daemon/session-agent-loop-handlers.ts +38 -0
  115. package/src/daemon/session-agent-loop.ts +334 -48
  116. package/src/daemon/session-error.ts +89 -6
  117. package/src/daemon/session-history.ts +17 -7
  118. package/src/daemon/session-media-retry.ts +6 -2
  119. package/src/daemon/session-memory.ts +69 -149
  120. package/src/daemon/session-process.ts +10 -1
  121. package/src/daemon/session-runtime-assembly.ts +49 -19
  122. package/src/daemon/session-surfaces.ts +4 -1
  123. package/src/daemon/session-tool-setup.ts +7 -1
  124. package/src/daemon/session.ts +12 -2
  125. package/src/instrument.ts +61 -1
  126. package/src/memory/admin.ts +2 -191
  127. package/src/memory/canonical-guardian-store.ts +38 -2
  128. package/src/memory/conversation-crud.ts +0 -33
  129. package/src/memory/conversation-queries.ts +22 -3
  130. package/src/memory/db-init.ts +28 -0
  131. package/src/memory/embedding-backend.ts +84 -8
  132. package/src/memory/embedding-types.ts +9 -1
  133. package/src/memory/indexer.ts +7 -46
  134. package/src/memory/items-extractor.ts +274 -76
  135. package/src/memory/job-handlers/backfill.ts +2 -127
  136. package/src/memory/job-handlers/cleanup.ts +2 -16
  137. package/src/memory/job-handlers/extraction.ts +2 -138
  138. package/src/memory/job-handlers/index-maintenance.ts +1 -6
  139. package/src/memory/job-handlers/summarization.ts +3 -148
  140. package/src/memory/job-utils.ts +21 -59
  141. package/src/memory/jobs-store.ts +1 -159
  142. package/src/memory/jobs-worker.ts +9 -52
  143. package/src/memory/migrations/104-core-indexes.ts +3 -3
  144. package/src/memory/migrations/149-oauth-tables.ts +2 -0
  145. package/src/memory/migrations/150-oauth-apps-client-secret-path.ts +98 -0
  146. package/src/memory/migrations/151-oauth-providers-ping-url.ts +11 -0
  147. package/src/memory/migrations/152-memory-item-supersession.ts +44 -0
  148. package/src/memory/migrations/153-drop-entity-tables.ts +15 -0
  149. package/src/memory/migrations/154-drop-fts.ts +20 -0
  150. package/src/memory/migrations/155-drop-conflicts.ts +7 -0
  151. package/src/memory/migrations/156-call-session-invite-metadata.ts +24 -0
  152. package/src/memory/migrations/index.ts +7 -0
  153. package/src/memory/qdrant-client.ts +148 -51
  154. package/src/memory/raw-query.ts +1 -1
  155. package/src/memory/retriever.test.ts +294 -273
  156. package/src/memory/retriever.ts +421 -645
  157. package/src/memory/schema/calls.ts +2 -0
  158. package/src/memory/schema/memory-core.ts +3 -48
  159. package/src/memory/schema/oauth.ts +2 -0
  160. package/src/memory/search/formatting.ts +263 -176
  161. package/src/memory/search/lexical.ts +1 -254
  162. package/src/memory/search/ranking.ts +0 -455
  163. package/src/memory/search/semantic.ts +100 -14
  164. package/src/memory/search/staleness.ts +47 -0
  165. package/src/memory/search/tier-classifier.ts +21 -0
  166. package/src/memory/search/types.ts +15 -77
  167. package/src/memory/task-memory-cleanup.ts +4 -6
  168. package/src/messaging/providers/gmail/mime-builder.ts +17 -7
  169. package/src/oauth/byo-connection.test.ts +8 -1
  170. package/src/oauth/oauth-store.ts +113 -27
  171. package/src/oauth/seed-providers.ts +6 -0
  172. package/src/oauth/token-persistence.ts +11 -3
  173. package/src/permissions/defaults.ts +1 -0
  174. package/src/permissions/trust-store.ts +23 -1
  175. package/src/playbooks/playbook-compiler.ts +1 -1
  176. package/src/prompts/system-prompt.ts +18 -2
  177. package/src/providers/anthropic/client.ts +56 -126
  178. package/src/providers/types.ts +7 -1
  179. package/src/runtime/AGENTS.md +9 -0
  180. package/src/runtime/auth/route-policy.ts +6 -3
  181. package/src/runtime/guardian-reply-router.ts +24 -22
  182. package/src/runtime/http-server.ts +2 -2
  183. package/src/runtime/invite-redemption-service.ts +19 -1
  184. package/src/runtime/invite-service.ts +25 -0
  185. package/src/runtime/pending-interactions.ts +2 -2
  186. package/src/runtime/routes/brain-graph-routes.ts +10 -90
  187. package/src/runtime/routes/conversation-routes.ts +9 -1
  188. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +21 -12
  189. package/src/runtime/routes/memory-item-routes.test.ts +754 -0
  190. package/src/runtime/routes/memory-item-routes.ts +503 -0
  191. package/src/runtime/routes/session-management-routes.ts +3 -3
  192. package/src/runtime/routes/settings-routes.ts +2 -2
  193. package/src/runtime/routes/trust-rules-routes.ts +14 -0
  194. package/src/runtime/routes/workspace-routes.ts +2 -1
  195. package/src/security/keychain-broker-client.ts +17 -4
  196. package/src/security/secure-keys.ts +25 -3
  197. package/src/security/token-manager.ts +36 -36
  198. package/src/skills/catalog-install.ts +74 -18
  199. package/src/skills/skillssh-registry.ts +503 -0
  200. package/src/tools/assets/search.ts +5 -1
  201. package/src/tools/computer-use/definitions.ts +0 -10
  202. package/src/tools/computer-use/registry.ts +1 -1
  203. package/src/tools/credentials/vault.ts +1 -3
  204. package/src/tools/memory/definitions.ts +4 -13
  205. package/src/tools/memory/handlers.test.ts +83 -103
  206. package/src/tools/memory/handlers.ts +50 -85
  207. package/src/tools/schedule/create.ts +8 -1
  208. package/src/tools/schedule/update.ts +8 -1
  209. package/src/tools/skills/load.ts +25 -2
  210. package/src/__tests__/clarification-resolver.test.ts +0 -193
  211. package/src/__tests__/conflict-intent-tokenization.test.ts +0 -160
  212. package/src/__tests__/conflict-policy.test.ts +0 -269
  213. package/src/__tests__/conflict-store.test.ts +0 -372
  214. package/src/__tests__/contradiction-checker.test.ts +0 -361
  215. package/src/__tests__/entity-extractor.test.ts +0 -211
  216. package/src/__tests__/entity-search.test.ts +0 -1117
  217. package/src/__tests__/profile-compiler.test.ts +0 -392
  218. package/src/__tests__/session-conflict-gate.test.ts +0 -1228
  219. package/src/__tests__/session-profile-injection.test.ts +0 -557
  220. package/src/config/bundled-skills/knowledge-graph/SKILL.md +0 -25
  221. package/src/config/bundled-skills/knowledge-graph/TOOLS.json +0 -66
  222. package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +0 -211
  223. package/src/daemon/session-conflict-gate.ts +0 -167
  224. package/src/daemon/session-dynamic-profile.ts +0 -77
  225. package/src/memory/clarification-resolver.ts +0 -417
  226. package/src/memory/conflict-intent.ts +0 -205
  227. package/src/memory/conflict-policy.ts +0 -127
  228. package/src/memory/conflict-store.ts +0 -410
  229. package/src/memory/contradiction-checker.ts +0 -508
  230. package/src/memory/entity-extractor.ts +0 -535
  231. package/src/memory/format-recall.ts +0 -47
  232. package/src/memory/fts-reconciler.ts +0 -165
  233. package/src/memory/job-handlers/conflict.ts +0 -200
  234. package/src/memory/profile-compiler.ts +0 -195
  235. package/src/memory/recall-cache.ts +0 -117
  236. package/src/memory/search/entity.ts +0 -535
  237. package/src/memory/search/query-expansion.test.ts +0 -70
  238. package/src/memory/search/query-expansion.ts +0 -118
  239. package/src/runtime/routes/mcp-routes.ts +0 -20
@@ -9,6 +9,7 @@
9
9
 
10
10
  import {
11
11
  getApp,
12
+ getConnection,
12
13
  getConnectionByProvider,
13
14
  getProvider,
14
15
  updateConnection,
@@ -116,14 +117,14 @@ function recordRefreshFailure(service: string): void {
116
117
 
117
118
  const inflightRefreshes = new Map<string, Promise<string>>();
118
119
 
119
- function deduplicatedRefresh(service: string): Promise<string> {
120
- const existing = inflightRefreshes.get(service);
120
+ function deduplicatedRefresh(service: string, connId: string): Promise<string> {
121
+ const existing = inflightRefreshes.get(connId);
121
122
  if (existing) return existing;
122
123
 
123
- const promise = doRefresh(service).finally(() => {
124
- inflightRefreshes.delete(service);
124
+ const promise = doRefresh(service, connId).finally(() => {
125
+ inflightRefreshes.delete(connId);
125
126
  });
126
- inflightRefreshes.set(service, promise);
127
+ inflightRefreshes.set(connId, promise);
127
128
  return promise;
128
129
  }
129
130
 
@@ -157,18 +158,11 @@ export class TokenExpiredError extends Error {
157
158
  }
158
159
 
159
160
  /**
160
- * Check whether the access token for a service is expired or will expire
161
- * within the buffer window, based on the `expiresAt` field in the
162
- * oauth_connection row.
161
+ * Check whether a token is expired or will expire within the buffer window.
163
162
  */
164
- function isTokenExpired(service: string): boolean {
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
- }
163
+ function isTokenExpired(expiresAt: number | null): boolean {
164
+ if (!expiresAt) return false;
165
+ return Date.now() >= expiresAt - EXPIRY_BUFFER_MS;
172
166
  }
173
167
 
174
168
  // ── Refresh config resolution ─────────────────────────────────────────
@@ -191,8 +185,8 @@ interface RefreshConfig {
191
185
  * authMethod. Throws `TokenExpiredError` if the connection is not found
192
186
  * or incomplete.
193
187
  */
194
- function resolveRefreshConfig(service: string): RefreshConfig {
195
- const conn = getConnectionByProvider(service);
188
+ function resolveRefreshConfig(service: string, connId: string): RefreshConfig {
189
+ const conn = getConnection(connId);
196
190
  if (!conn) {
197
191
  throw new TokenExpiredError(
198
192
  service,
@@ -217,15 +211,15 @@ function resolveRefreshConfig(service: string): RefreshConfig {
217
211
  }
218
212
 
219
213
  const tokenUrl = provider.tokenUrl;
220
- const clientId = app.clientId;
221
- if (!tokenUrl || !clientId) {
214
+ const resolvedClientId = app.clientId;
215
+ if (!tokenUrl || !resolvedClientId) {
222
216
  throw new TokenExpiredError(
223
217
  service,
224
218
  `Missing OAuth2 refresh config for "${service}".${recoveryHint(service)}`,
225
219
  );
226
220
  }
227
221
 
228
- const secret = getSecureKey(`oauth_app/${app.id}/client_secret`);
222
+ const secret = getSecureKey(app.clientSecretCredentialPath);
229
223
 
230
224
  const refreshToken = getSecureKey(
231
225
  `oauth_connection/${conn.id}/refresh_token`,
@@ -238,7 +232,7 @@ function resolveRefreshConfig(service: string): RefreshConfig {
238
232
  return {
239
233
  connId: conn.id,
240
234
  tokenUrl,
241
- clientId,
235
+ clientId: resolvedClientId,
242
236
  secret,
243
237
  refreshToken,
244
238
  authMethod,
@@ -254,10 +248,15 @@ function resolveRefreshConfig(service: string): RefreshConfig {
254
248
  * Returns the new access token on success.
255
249
  * Throws `TokenExpiredError` if refresh is not possible.
256
250
  */
257
- async function doRefresh(service: string): Promise<string> {
258
- const refreshConfig = resolveRefreshConfig(service);
259
- const { tokenUrl, clientId, secret, authMethod, connId, refreshToken } =
260
- refreshConfig;
251
+ async function doRefresh(service: string, connId: string): Promise<string> {
252
+ const refreshConfig = resolveRefreshConfig(service, connId);
253
+ const {
254
+ tokenUrl,
255
+ clientId: resolvedClientId,
256
+ secret,
257
+ authMethod,
258
+ refreshToken,
259
+ } = refreshConfig;
261
260
 
262
261
  if (!refreshToken) {
263
262
  throw new TokenExpiredError(
@@ -266,8 +265,8 @@ async function doRefresh(service: string): Promise<string> {
266
265
  );
267
266
  }
268
267
 
269
- if (isRefreshBreakerOpen(service)) {
270
- const state = refreshBreakers.get(service)!;
268
+ if (isRefreshBreakerOpen(connId)) {
269
+ const state = refreshBreakers.get(connId)!;
271
270
  const remainingMs = state.cooldownMs - (Date.now() - state.openedAt);
272
271
  throw new TokenExpiredError(
273
272
  service,
@@ -282,13 +281,13 @@ async function doRefresh(service: string): Promise<string> {
282
281
  try {
283
282
  result = await refreshOAuth2Token(
284
283
  tokenUrl,
285
- clientId,
284
+ resolvedClientId,
286
285
  refreshToken,
287
286
  secret,
288
287
  authMethod,
289
288
  );
290
289
  } catch (err) {
291
- recordRefreshFailure(service);
290
+ recordRefreshFailure(connId);
292
291
  if (isCredentialError(err)) {
293
292
  const msg = err instanceof Error ? err.message : String(err);
294
293
  throw new TokenExpiredError(
@@ -349,7 +348,7 @@ async function doRefresh(service: string): Promise<string> {
349
348
  );
350
349
  }
351
350
 
352
- recordRefreshSuccess(service);
351
+ recordRefreshSuccess(connId);
353
352
  log.info({ service }, "OAuth2 access token refreshed successfully");
354
353
  return result.accessToken;
355
354
  }
@@ -368,12 +367,13 @@ async function doRefresh(service: string): Promise<string> {
368
367
  export async function withValidToken<T>(
369
368
  service: string,
370
369
  callback: (token: string) => Promise<T>,
370
+ clientId?: string,
371
371
  ): Promise<T> {
372
- const conn = getConnectionByProvider(service);
372
+ const conn = getConnectionByProvider(service, clientId);
373
373
  let token = conn
374
374
  ? getSecureKey(`oauth_connection/${conn.id}/access_token`)
375
375
  : undefined;
376
- if (!token) {
376
+ if (!token || !conn) {
377
377
  throw new TokenExpiredError(
378
378
  service,
379
379
  `No access token found for "${service}". Authorization required.${recoveryHint(service)}`,
@@ -381,15 +381,15 @@ export async function withValidToken<T>(
381
381
  }
382
382
 
383
383
  // Proactively refresh if expired or about to expire.
384
- if (isTokenExpired(service)) {
385
- token = await deduplicatedRefresh(service);
384
+ if (isTokenExpired(conn.expiresAt)) {
385
+ token = await deduplicatedRefresh(service, conn.id);
386
386
  }
387
387
 
388
388
  try {
389
389
  return await callback(token);
390
390
  } catch (err: unknown) {
391
391
  if (is401Error(err)) {
392
- token = await deduplicatedRefresh(service);
392
+ token = await deduplicatedRefresh(service, conn.id);
393
393
  return callback(token);
394
394
  }
395
395
  throw err;
@@ -10,7 +10,7 @@ import {
10
10
  writeFileSync,
11
11
  } from "node:fs";
12
12
  import { homedir } from "node:os";
13
- import { dirname, join } from "node:path";
13
+ import { dirname, join, posix, resolve, sep } from "node:path";
14
14
  import { gunzipSync } from "node:zlib";
15
15
 
16
16
  import { getLogger } from "../util/logger.js";
@@ -160,16 +160,35 @@ export function extractTarToDir(tarBuffer: Buffer, destDir: string): boolean {
160
160
 
161
161
  // Skip directories and empty names
162
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);
163
+ // Prevent path traversal and absolute path writes
164
+ const normalizedName = name.replace(/\\/g, "/").replace(/^\.\/+/, "");
165
+ const normalizedPath = posix.normalize(normalizedName);
166
+ const hasWindowsDrivePrefix = /^[a-zA-Z]:\//.test(normalizedPath);
167
+ const isTraversal =
168
+ normalizedPath === ".." || normalizedPath.startsWith("../");
169
+
170
+ if (
171
+ normalizedPath &&
172
+ normalizedPath !== "." &&
173
+ !normalizedPath.startsWith("/") &&
174
+ !hasWindowsDrivePrefix &&
175
+ !isTraversal
176
+ ) {
177
+ const destRoot = resolve(destDir);
178
+ const destPath = resolve(destRoot, normalizedPath);
179
+ const insideDestination =
180
+ destPath === destRoot || destPath.startsWith(destRoot + sep);
181
+ if (!insideDestination) {
182
+ offset += Math.ceil(size / 512) * 512;
183
+ continue;
184
+ }
185
+
167
186
  mkdirSync(dirname(destPath), { recursive: true });
168
187
  writeFileSync(destPath, tarBuffer.subarray(offset, offset + size));
169
188
 
170
189
  if (
171
- normalizedName === "SKILL.md" ||
172
- normalizedName.endsWith("/SKILL.md")
190
+ normalizedPath === "SKILL.md" ||
191
+ normalizedPath.endsWith("/SKILL.md")
173
192
  ) {
174
193
  foundSkillMd = true;
175
194
  }
@@ -319,27 +338,63 @@ export async function installSkillLocally(
319
338
 
320
339
  // ─── Auto-install (for skill_load) ──────────────────────────────────────────
321
340
 
341
+ /**
342
+ * Resolve the catalog skill list, checking local (dev mode) first, then remote.
343
+ *
344
+ * In dev mode with a local catalog, returns local entries immediately to avoid
345
+ * unnecessary network latency. Pass `skillId` to trigger a deferred remote
346
+ * fetch only when the requested skill is not found locally — this preserves the
347
+ * ability to discover remote-only skills without penalising every call with a
348
+ * 10s timeout on flaky networks.
349
+ *
350
+ * Callers that install multiple skills in a loop should call this once and pass
351
+ * the result to `autoInstallFromCatalog` to avoid redundant network requests.
352
+ */
353
+ export async function resolveCatalog(
354
+ skillId?: string,
355
+ ): Promise<CatalogSkill[]> {
356
+ const repoSkillsDir = getRepoSkillsDir();
357
+ if (repoSkillsDir) {
358
+ const local = readLocalCatalog(repoSkillsDir);
359
+ if (local.length > 0) {
360
+ // If no specific skill requested, or it exists locally, skip remote fetch
361
+ if (!skillId || local.some((s) => s.id === skillId)) {
362
+ return local;
363
+ }
364
+ // Skill not found locally — merge with remote so remote-only skills
365
+ // can still be discovered. Local entries take precedence by id.
366
+ try {
367
+ const remote = await fetchCatalog();
368
+ const localIds = new Set(local.map((s) => s.id));
369
+ return [...local, ...remote.filter((s) => !localIds.has(s.id))];
370
+ } catch {
371
+ return local;
372
+ }
373
+ }
374
+ }
375
+
376
+ return fetchCatalog();
377
+ }
378
+
322
379
  /**
323
380
  * Attempt to find and install a skill from the first-party catalog.
324
381
  * Returns true if the skill was installed, false if not found in catalog.
325
382
  * Throws on install failures (network, filesystem, etc).
383
+ *
384
+ * When `catalog` is provided it is used directly, avoiding a redundant
385
+ * network fetch — pass a pre-resolved catalog when calling in a loop.
326
386
  */
327
387
  export async function autoInstallFromCatalog(
328
388
  skillId: string,
389
+ catalog?: CatalogSkill[],
329
390
  ): Promise<boolean> {
330
- // Check local catalog first (dev mode), then remote
331
- const repoSkillsDir = getRepoSkillsDir();
332
- let entry: CatalogSkill | undefined;
391
+ let skills: CatalogSkill[];
333
392
 
334
- if (repoSkillsDir) {
335
- const localCatalog = readLocalCatalog(repoSkillsDir);
336
- entry = localCatalog.find((s) => s.id === skillId);
337
- }
338
-
339
- if (!entry) {
393
+ if (catalog) {
394
+ skills = catalog;
395
+ } else {
340
396
  try {
341
- const remoteCatalog = await fetchCatalog();
342
- entry = remoteCatalog.find((s) => s.id === skillId);
397
+ skills = await resolveCatalog(skillId);
343
398
  } catch (err) {
344
399
  log.warn(
345
400
  { err, skillId },
@@ -349,6 +404,7 @@ export async function autoInstallFromCatalog(
349
404
  }
350
405
  }
351
406
 
407
+ const entry = skills.find((s) => s.id === skillId);
352
408
  if (!entry) {
353
409
  return false;
354
410
  }