@vellumai/assistant 0.5.14 → 0.5.16

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 (175) hide show
  1. package/ARCHITECTURE.md +2 -2
  2. package/docs/architecture/integrations.md +15 -14
  3. package/knip.json +3 -1
  4. package/openapi.yaml +11 -43
  5. package/package.json +1 -1
  6. package/src/__tests__/assistant-feature-flags-integration.test.ts +3 -375
  7. package/src/__tests__/ces-rpc-credential-backend.test.ts +4 -1
  8. package/src/__tests__/checker.test.ts +59 -0
  9. package/src/__tests__/cli-command-risk-guard.test.ts +98 -10
  10. package/src/__tests__/cli-memory.test.ts +372 -0
  11. package/src/__tests__/computer-use-skill-manifest-regression.test.ts +12 -2
  12. package/src/__tests__/config-schema.test.ts +0 -2
  13. package/src/__tests__/config-watcher-feature-flags.test.ts +211 -0
  14. package/src/__tests__/conversation-runtime-assembly.test.ts +7 -4
  15. package/src/__tests__/conversation-slash-commands.test.ts +2 -6
  16. package/src/__tests__/conversation-usage.test.ts +1 -0
  17. package/src/__tests__/credential-security-e2e.test.ts +4 -1
  18. package/src/__tests__/docker-signing-key-bootstrap.test.ts +7 -73
  19. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +6 -7
  20. package/src/__tests__/guardian-routing-invariants.test.ts +151 -0
  21. package/src/__tests__/heartbeat-service.test.ts +1 -3
  22. package/src/__tests__/intent-routing.test.ts +6 -18
  23. package/src/__tests__/log-export-workspace.test.ts +2 -28
  24. package/src/__tests__/managed-skill-lifecycle.test.ts +7 -37
  25. package/src/__tests__/managed-store.test.ts +2 -10
  26. package/src/__tests__/messaging-send-tool.test.ts +6 -6
  27. package/src/__tests__/migration-cross-version-compatibility.test.ts +1 -29
  28. package/src/__tests__/migration-export-http.test.ts +3 -34
  29. package/src/__tests__/migration-import-commit-http.test.ts +1 -29
  30. package/src/__tests__/migration-import-preflight-http.test.ts +3 -34
  31. package/src/__tests__/no-domain-routing-in-prompt-guard.test.ts +2 -1
  32. package/src/__tests__/oauth-apps-routes.test.ts +120 -10
  33. package/src/__tests__/oauth-connect-orchestrator.test.ts +709 -0
  34. package/src/__tests__/oauth-provider-serializer.test.ts +2 -1
  35. package/src/__tests__/oauth-provider-visibility.test.ts +149 -0
  36. package/src/__tests__/oauth-providers-routes.test.ts +5 -2
  37. package/src/__tests__/oauth-store.test.ts +0 -5
  38. package/src/__tests__/outlook-messaging-provider.test.ts +576 -0
  39. package/src/__tests__/path-policy.test.ts +2 -17
  40. package/src/__tests__/permission-types.test.ts +0 -1
  41. package/src/__tests__/platform-callback-registration.test.ts +3 -7
  42. package/src/__tests__/provider-commit-message-generator.test.ts +0 -1
  43. package/src/__tests__/provider-error-scenarios.test.ts +0 -2
  44. package/src/__tests__/qdrant-manager.test.ts +68 -21
  45. package/src/__tests__/require-fresh-approval.test.ts +0 -1
  46. package/src/__tests__/sandbox-diagnostics.test.ts +20 -29
  47. package/src/__tests__/scaffold-managed-skill-tool.test.ts +2 -10
  48. package/src/__tests__/secret-allowlist.test.ts +20 -35
  49. package/src/__tests__/shell-credential-ref.test.ts +0 -5
  50. package/src/__tests__/skill-load-feature-flag.test.ts +2 -43
  51. package/src/__tests__/skill-load-inline-command.test.ts +3 -65
  52. package/src/__tests__/skill-load-inline-includes.test.ts +3 -65
  53. package/src/__tests__/skill-load-tool.test.ts +3 -67
  54. package/src/__tests__/skill-memory.test.ts +362 -119
  55. package/src/__tests__/skills.test.ts +22 -49
  56. package/src/__tests__/slack-channel-config.test.ts +2 -21
  57. package/src/__tests__/starter-bundle.test.ts +2 -8
  58. package/src/__tests__/stt-hints.test.ts +7 -2
  59. package/src/__tests__/system-prompt.test.ts +25 -45
  60. package/src/__tests__/task-compiler.test.ts +0 -21
  61. package/src/__tests__/task-management-tools.test.ts +0 -21
  62. package/src/__tests__/task-memory-cleanup.test.ts +0 -21
  63. package/src/__tests__/task-runner.test.ts +0 -21
  64. package/src/__tests__/task-scheduler.test.ts +0 -21
  65. package/src/__tests__/terminal-tools.test.ts +1 -17
  66. package/src/__tests__/token-estimator-accuracy.benchmark.test.ts +0 -79
  67. package/src/__tests__/tool-approval-handler.test.ts +1 -20
  68. package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -11
  69. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +1 -25
  70. package/src/__tests__/tool-executor-lifecycle-events.test.ts +0 -1
  71. package/src/__tests__/tool-executor.test.ts +0 -1
  72. package/src/__tests__/tool-grant-request-escalation.test.ts +1 -20
  73. package/src/__tests__/tool-preview-lifecycle.test.ts +0 -20
  74. package/src/__tests__/trust-store.test.ts +9 -41
  75. package/src/__tests__/trusted-contact-approval-notifier.test.ts +1 -30
  76. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1 -21
  77. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +0 -22
  78. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -22
  79. package/src/__tests__/trusted-contact-verification.test.ts +0 -22
  80. package/src/__tests__/turn-boundary-resolution.test.ts +0 -28
  81. package/src/__tests__/twilio-provider.test.ts +0 -16
  82. package/src/__tests__/twilio-routes-twiml.test.ts +7 -12
  83. package/src/__tests__/twilio-routes.test.ts +0 -24
  84. package/src/__tests__/update-bulletin.test.ts +17 -89
  85. package/src/__tests__/usage-cache-backfill-migration.test.ts +0 -20
  86. package/src/__tests__/usage-routes.test.ts +0 -21
  87. package/src/__tests__/user-reference.test.ts +1 -5
  88. package/src/__tests__/vbundle-pax-and-symlink.test.ts +4 -34
  89. package/src/__tests__/vellum-self-knowledge-inline-command.test.ts +2 -53
  90. package/src/__tests__/voice-invite-redemption.test.ts +0 -21
  91. package/src/__tests__/voice-scoped-grant-consumer.test.ts +0 -24
  92. package/src/__tests__/voice-session-bridge.test.ts +0 -21
  93. package/src/__tests__/workspace-migration-009-backfill-conversation-disk-view.test.ts +2 -23
  94. package/src/__tests__/workspace-migration-012-rename-conversation-disk-view-dirs.test.ts +2 -2
  95. package/src/__tests__/workspace-migration-013-repair-conversation-disk-view.test.ts +2 -23
  96. package/src/__tests__/workspace-migration-down-functions.test.ts +0 -6
  97. package/src/acp/client-handler.ts +1 -2
  98. package/src/cli/__tests__/notifications.test.ts +0 -22
  99. package/src/cli/cli-memory.ts +176 -0
  100. package/src/cli/commands/oauth/__tests__/providers-update.test.ts +1 -1
  101. package/src/cli/commands/oauth/connect.ts +15 -0
  102. package/src/cli/commands/oauth/providers.ts +49 -42
  103. package/src/cli/commands/platform/__tests__/connect.test.ts +2 -48
  104. package/src/cli/commands/platform/__tests__/disconnect.test.ts +2 -48
  105. package/src/cli/commands/platform/__tests__/status.test.ts +0 -50
  106. package/src/config/bundled-skills/computer-use/TOOLS.json +7 -7
  107. package/src/config/bundled-skills/messaging/SKILL.md +17 -2
  108. package/src/config/bundled-skills/settings/TOOLS.json +3 -3
  109. package/src/config/feature-flag-registry.json +16 -0
  110. package/src/config/loader.ts +4 -0
  111. package/src/config/schemas/security.ts +0 -6
  112. package/src/config/schemas/services.ts +8 -0
  113. package/src/context/window-manager.ts +28 -9
  114. package/src/credential-execution/approval-bridge.ts +0 -1
  115. package/src/daemon/config-watcher.ts +51 -0
  116. package/src/daemon/conversation-agent-loop.ts +3 -2
  117. package/src/daemon/conversation-process.ts +1 -0
  118. package/src/daemon/conversation-usage.ts +1 -0
  119. package/src/daemon/handlers/skills.ts +9 -1
  120. package/src/daemon/lifecycle.ts +13 -4
  121. package/src/daemon/message-types/conversations.ts +1 -0
  122. package/src/daemon/providers-setup.ts +2 -0
  123. package/src/daemon/server.ts +26 -22
  124. package/src/events/domain-events.ts +1 -2
  125. package/src/memory/db-init.ts +9 -0
  126. package/src/memory/job-handlers/batch-extraction.ts +16 -4
  127. package/src/memory/job-handlers/embedding.test.ts +3 -27
  128. package/src/memory/job-handlers/journal-carry-forward.test.ts +1 -29
  129. package/src/memory/llm-usage-store.ts +35 -2
  130. package/src/memory/migrations/201-oauth-providers-feature-flag.ts +11 -0
  131. package/src/memory/migrations/202-drop-callback-transport-column.ts +13 -0
  132. package/src/memory/migrations/index.ts +2 -0
  133. package/src/memory/qdrant-manager.ts +26 -5
  134. package/src/memory/query-expansion.ts +1 -1
  135. package/src/memory/retriever.test.ts +22 -20
  136. package/src/memory/retriever.ts +10 -2
  137. package/src/memory/schema/oauth.ts +1 -1
  138. package/src/memory/search/mmr.ts +8 -5
  139. package/src/memory/slack-thread-store.ts +17 -0
  140. package/src/messaging/providers/outlook/adapter.ts +193 -0
  141. package/src/messaging/providers/outlook/client.ts +311 -0
  142. package/src/messaging/providers/outlook/types.ts +83 -0
  143. package/src/notifications/adapters/slack.ts +1 -1
  144. package/src/oauth/__tests__/identity-verifier.test.ts +1 -1
  145. package/src/oauth/connect-orchestrator.ts +10 -3
  146. package/src/oauth/oauth-store.ts +10 -11
  147. package/src/oauth/provider-serializer.ts +3 -0
  148. package/src/oauth/provider-visibility.ts +16 -0
  149. package/src/oauth/seed-providers.ts +49 -17
  150. package/src/permissions/checker.ts +39 -7
  151. package/src/permissions/types.ts +2 -4
  152. package/src/prompts/journal-context.ts +9 -11
  153. package/src/prompts/system-prompt.ts +3 -64
  154. package/src/prompts/templates/UPDATES.md +6 -0
  155. package/src/runtime/auth/__tests__/credential-service.test.ts +1 -27
  156. package/src/runtime/auth/__tests__/token-service.test.ts +1 -25
  157. package/src/runtime/auth/route-policy.ts +0 -4
  158. package/src/runtime/guardian-reply-router.ts +6 -2
  159. package/src/runtime/routes/conversation-query-routes.ts +2 -58
  160. package/src/runtime/routes/inbound-stages/background-dispatch.ts +43 -2
  161. package/src/runtime/routes/memory-item-routes.test.ts +0 -17
  162. package/src/runtime/routes/memory-item-routes.ts +103 -12
  163. package/src/runtime/routes/oauth-apps.ts +18 -1
  164. package/src/runtime/routes/oauth-providers.ts +13 -1
  165. package/src/runtime/routes/settings-routes.ts +1 -0
  166. package/src/runtime/routes/usage-routes.ts +19 -2
  167. package/src/runtime/routes/work-items-routes.test.ts +0 -21
  168. package/src/runtime/routes/workspace-routes.test.ts +3 -27
  169. package/src/security/secret-allowlist.ts +4 -4
  170. package/src/skills/skill-memory.ts +62 -23
  171. package/src/tools/memory/handlers.test.ts +1 -29
  172. package/src/tools/permission-checker.ts +0 -18
  173. package/src/tools/skills/skill-script-runner.ts +1 -1
  174. package/src/util/device-id.ts +3 -65
  175. package/src/workspace/git-service.ts +27 -6
@@ -1,22 +1,4 @@
1
- import { mkdtempSync, realpathSync, rmSync } from "node:fs";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
- import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
5
-
6
- const testDir = realpathSync(mkdtempSync(join(tmpdir(), "auth-token-test-")));
7
-
8
- mock.module("../../../util/platform.js", () => ({
9
- getRootDir: () => testDir,
10
- getDataDir: () => testDir,
11
- getDbPath: () => join(testDir, "test.db"),
12
- normalizeAssistantId: (id: string) => (id === "self" ? "self" : id),
13
- isMacOS: () => process.platform === "darwin",
14
- isLinux: () => process.platform === "linux",
15
- isWindows: () => process.platform === "win32",
16
- getPidPath: () => join(testDir, "test.pid"),
17
- getLogPath: () => join(testDir, "test.log"),
18
- ensureDataDir: () => {},
19
- }));
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
20
2
 
21
3
  mock.module("../../../util/logger.js", () => ({
22
4
  getLogger: () =>
@@ -39,12 +21,6 @@ beforeEach(() => {
39
21
  initAuthSigningKey(TEST_KEY);
40
22
  });
41
23
 
42
- afterAll(() => {
43
- try {
44
- rmSync(testDir, { recursive: true, force: true });
45
- } catch {}
46
- });
47
-
48
24
  describe("mintToken / verifyToken round-trip", () => {
49
25
  test("mint + verify succeeds for valid token targeting vellum-daemon", () => {
50
26
  const token = mintToken({
@@ -349,10 +349,6 @@ const ACTOR_ENDPOINTS: Array<{ endpoint: string; scopes: Scope[] }> = [
349
349
  { endpoint: "config/embeddings:GET", scopes: ["settings.read"] },
350
350
  { endpoint: "config/embeddings:PUT", scopes: ["settings.write"] },
351
351
 
352
- // Permissions config
353
- { endpoint: "config/permissions/skip:GET", scopes: ["settings.read"] },
354
- { endpoint: "config/permissions/skip:PUT", scopes: ["settings.write"] },
355
-
356
352
  // Generic config read/patch
357
353
  { endpoint: "config:GET", scopes: ["settings.read"] },
358
354
  { endpoint: "config:PATCH", scopes: ["settings.write"] },
@@ -155,8 +155,11 @@ function parseRequestCode(
155
155
  text: string,
156
156
  scopeConversationId?: string,
157
157
  ): CodeParseResult | null {
158
+ // Strip common channel formatting delimiters (backticks, bold, italic,
159
+ // strikethrough) that messaging platforms wrap around inline code.
160
+ const cleaned = text.replace(/^[`*_~]+/, "").replace(/[`*_~]+$/, "").trim();
158
161
  // Request codes are 6 hex chars (A-F, 0-9), uppercase
159
- const upper = text.toUpperCase();
162
+ const upper = cleaned.toUpperCase();
160
163
  const match = upper.match(/^([A-F0-9]{6})(?:\s|$)/);
161
164
  if (!match) return null;
162
165
 
@@ -185,7 +188,7 @@ function parseRequestCode(
185
188
  return null;
186
189
  }
187
190
 
188
- const remainingText = text.slice(code.length).trim();
191
+ const remainingText = cleaned.slice(code.length).trim();
189
192
  return { request, remainingText };
190
193
  }
191
194
 
@@ -754,6 +757,7 @@ const EXPLICIT_REJECT_PHRASES: ReadonlySet<string> = new Set([
754
757
 
755
758
  function normalizeDecisionPhrase(text: string): string {
756
759
  return text
760
+ .replace(/[`*_~]/g, "")
757
761
  .trim()
758
762
  .toLowerCase()
759
763
  .replace(/[.!?]+$/g, "")
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * HTTP route definitions for model configuration, embedding configuration,
3
- * permissions configuration, conversation search, message content, LLM
3
+ * conversation search, message content, LLM
4
4
  * context inspection, and queued message deletion.
5
5
  *
6
6
  * These routes expose conversation query functionality over the HTTP API.
@@ -10,8 +10,6 @@
10
10
  * PUT /v1/model/image-gen — set image-gen model
11
11
  * GET /v1/config/embeddings — current embedding config
12
12
  * PUT /v1/config/embeddings — set embedding provider/model
13
- * GET /v1/config/permissions/skip — dangerouslySkipPermissions status
14
- * PUT /v1/config/permissions/skip — toggle dangerouslySkipPermissions
15
13
  * GET /v1/config — full raw workspace config
16
14
  * PATCH /v1/config — deep-merge partial config
17
15
  * GET /v1/conversations/search — search conversations
@@ -24,7 +22,6 @@ import { z } from "zod";
24
22
 
25
23
  import {
26
24
  deepMergeOverwrite,
27
- getConfig,
28
25
  loadRawConfig,
29
26
  saveRawConfig,
30
27
  } from "../../config/loader.js";
@@ -113,9 +110,7 @@ function applyStoredProviderToLlmContextResult(
113
110
  const mergedSummary = normalized.summary
114
111
  ? { ...normalized.summary, provider }
115
112
  : { provider };
116
- const summary = attachEstimatedCost(
117
- mergedSummary as LlmContextSummary,
118
- );
113
+ const summary = attachEstimatedCost(mergedSummary as LlmContextSummary);
119
114
  return { ...normalized, summary };
120
115
  }
121
116
 
@@ -324,57 +319,6 @@ export function conversationQueryRouteDefinitions(
324
319
  },
325
320
  },
326
321
 
327
- // ── Permissions config ─────────────────────────────────────────────
328
- {
329
- endpoint: "config/permissions/skip",
330
- method: "GET",
331
- policyKey: "config/permissions/skip",
332
- summary: "Get permission-skip flag",
333
- description: "Return whether dangerouslySkipPermissions is enabled.",
334
- tags: ["config"],
335
- responseBody: z.object({
336
- enabled: z.boolean(),
337
- }),
338
- handler: () => {
339
- const config = getConfig();
340
- return Response.json({
341
- enabled: config.permissions.dangerouslySkipPermissions,
342
- });
343
- },
344
- },
345
- {
346
- endpoint: "config/permissions/skip",
347
- method: "PUT",
348
- policyKey: "config/permissions/skip",
349
- summary: "Set permission-skip flag",
350
- description: "Enable or disable dangerouslySkipPermissions.",
351
- tags: ["config"],
352
- requestBody: z.object({
353
- enabled: z.boolean(),
354
- }),
355
- handler: async ({ req }) => {
356
- const body = (await req.json()) as { enabled?: unknown };
357
- if (typeof body.enabled !== "boolean") {
358
- return httpError(
359
- "BAD_REQUEST",
360
- "Missing or invalid field: enabled (boolean)",
361
- 400,
362
- );
363
- }
364
- const raw = loadRawConfig();
365
- const permissions: Record<string, unknown> =
366
- raw.permissions != null &&
367
- typeof raw.permissions === "object" &&
368
- !Array.isArray(raw.permissions)
369
- ? (raw.permissions as Record<string, unknown>)
370
- : {};
371
- permissions.dangerouslySkipPermissions = body.enabled;
372
- raw.permissions = permissions;
373
- saveRawConfig(raw);
374
- return Response.json({ enabled: body.enabled });
375
- },
376
- },
377
-
378
322
  // ── Full config read ─────────────────────────────────────────────
379
323
  {
380
324
  endpoint: "config",
@@ -15,6 +15,7 @@ import * as deliveryCrud from "../../../memory/delivery-crud.js";
15
15
  import * as deliveryStatus from "../../../memory/delivery-status.js";
16
16
  import {
17
17
  extractChannelFromCallbackUrl,
18
+ extractMessageTsFromCallbackUrl,
18
19
  extractThreadTsFromCallbackUrl,
19
20
  setThreadTs,
20
21
  } from "../../../memory/slack-thread-store.js";
@@ -328,9 +329,49 @@ export function setSlackThinkingStatus(
328
329
  // the correct thread for the Assistants API status.
329
330
  const threadTs = extractThreadTsFromCallbackUrl(callbackUrl);
330
331
 
331
- // If there's no thread context, we can't set a thread status — bail.
332
+ // For non-threaded DMs, fall back to emoji reaction on the original message.
332
333
  if (!threadTs) {
333
- return () => {};
334
+ const messageTs = extractMessageTsFromCallbackUrl(callbackUrl);
335
+ if (!messageTs) return () => {};
336
+
337
+ const addPromise = deliverChannelReply(
338
+ callbackUrl,
339
+ {
340
+ chatId,
341
+ assistantId,
342
+ reaction: { action: "add", name: "eyes", messageTs },
343
+ },
344
+ mintBearerToken(),
345
+ ).catch((err) => {
346
+ log.debug({ err, chatId, messageTs }, "Failed to add Slack eyes reaction");
347
+ });
348
+
349
+ const clearReaction = () => {
350
+ if (cleared) return;
351
+ cleared = true;
352
+ clearTimeout(safetyTimer);
353
+ void addPromise.then(() =>
354
+ deliverChannelReply(
355
+ callbackUrl,
356
+ {
357
+ chatId,
358
+ assistantId,
359
+ reaction: { action: "remove", name: "eyes", messageTs },
360
+ },
361
+ mintBearerToken(),
362
+ ).catch((err) => {
363
+ log.debug(
364
+ { err, chatId, messageTs },
365
+ "Failed to remove Slack eyes reaction",
366
+ );
367
+ }),
368
+ );
369
+ };
370
+
371
+ const safetyTimer = setTimeout(clearReaction, SLACK_THINKING_MAX_DURATION_MS);
372
+ (safetyTimer as { unref?: () => void }).unref?.();
373
+
374
+ return clearReaction;
334
375
  }
335
376
 
336
377
  // Track the set promise so clear waits for it to settle first,
@@ -4,9 +4,6 @@
4
4
  * Covers: list with filters, get by ID, create + duplicate rejection,
5
5
  * update + fingerprint collision, delete + 404.
6
6
  */
7
- import { mkdtempSync, rmSync } from "node:fs";
8
- import { tmpdir } from "node:os";
9
- import { join } from "node:path";
10
7
  import {
11
8
  afterAll,
12
9
  beforeAll,
@@ -17,19 +14,6 @@ import {
17
14
  test,
18
15
  } from "bun:test";
19
16
 
20
- const testDir = mkdtempSync(join(tmpdir(), "memory-item-routes-test-"));
21
-
22
- mock.module("../../util/platform.js", () => ({
23
- getDataDir: () => testDir,
24
- isMacOS: () => process.platform === "darwin",
25
- isLinux: () => process.platform === "linux",
26
- isWindows: () => process.platform === "win32",
27
- getPidPath: () => join(testDir, "test.pid"),
28
- getDbPath: () => join(testDir, "test.db"),
29
- getLogPath: () => join(testDir, "test.log"),
30
- ensureDataDir: () => {},
31
- }));
32
-
33
17
  mock.module("../../util/logger.js", () => ({
34
18
  getLogger: () =>
35
19
  new Proxy({} as Record<string, unknown>, {
@@ -206,7 +190,6 @@ describe("Memory Item Routes", () => {
206
190
 
207
191
  afterAll(() => {
208
192
  resetDb();
209
- rmSync(testDir, { recursive: true, force: true });
210
193
  });
211
194
 
212
195
  // =========================================================================
@@ -129,6 +129,26 @@ function buildConversationTitleMap(
129
129
  return map;
130
130
  }
131
131
 
132
+ // ---------------------------------------------------------------------------
133
+ // Semantic search constants
134
+ // ---------------------------------------------------------------------------
135
+
136
+ /**
137
+ * Maximum number of candidate IDs fetched from Qdrant for semantic search.
138
+ *
139
+ * Kind-filtering and pagination happen *after* this fetch, so if a broad query
140
+ * matches more items than this ceiling, results beyond it are silently excluded
141
+ * — kind-filtered counts may be under-reported and offsets past this value are
142
+ * unreachable. The limit exists to bound Qdrant query cost and the size of the
143
+ * subsequent `IN (...)` SQL clauses (SQLite's SQLITE_MAX_VARIABLE_NUMBER is
144
+ * 32,766 in Bun's bundled build, so 10k stays well within that).
145
+ *
146
+ * If this becomes a real bottleneck for large memory stores, consider pushing
147
+ * kind/status filters into Qdrant payload filtering so the fetch limit only
148
+ * needs to cover the final result set.
149
+ */
150
+ const SEMANTIC_SEARCH_FETCH_CEILING = 10_000;
151
+
132
152
  // ---------------------------------------------------------------------------
133
153
  // Semantic search helper
134
154
  // ---------------------------------------------------------------------------
@@ -239,26 +259,68 @@ export async function handleListMemoryItems(url: URL): Promise<Response> {
239
259
  // When a search query is present, try Qdrant hybrid search first.
240
260
  // Falls back to SQL LIKE when embeddings / Qdrant are unavailable.
241
261
  if (searchParam) {
262
+ // Search WITHOUT kind filter so we can compute cross-kind counts.
263
+ // Kind filtering is applied post-hoc while preserving relevance order.
242
264
  const semanticResult = await searchItemsSemantic(
243
265
  searchParam,
244
- limitParam + offsetParam,
245
- kindParam,
266
+ SEMANTIC_SEARCH_FETCH_CEILING,
267
+ null,
246
268
  statusParam,
247
269
  );
248
270
 
249
271
  if (semanticResult && semanticResult.ids.length > 0) {
250
- // Slice for pagination
251
- const pageIds = semanticResult.ids.slice(
252
- offsetParam,
253
- offsetParam + limitParam,
254
- );
272
+ // Compute kindCounts from all semantic matches (no kind filter).
273
+ // Status filter is applied as defense-in-depth against stale Qdrant payloads.
274
+ const kindCountConditions = [inArray(memoryItems.id, semanticResult.ids)];
275
+ if (statusParam && statusParam !== "all") {
276
+ kindCountConditions.push(eq(memoryItems.status, statusParam));
277
+ }
278
+ const kindCountRows = db
279
+ .select({ kind: memoryItems.kind, count: count() })
280
+ .from(memoryItems)
281
+ .where(and(...kindCountConditions))
282
+ .groupBy(memoryItems.kind)
283
+ .all();
284
+ const semanticKindCounts: Record<string, number> = {};
285
+ for (const row of kindCountRows) {
286
+ semanticKindCounts[row.kind] = row.count;
287
+ }
288
+
289
+ // Apply kind filter while preserving semantic relevance ordering.
290
+ // Status filter is applied here too for defense-in-depth consistency.
291
+ let filteredIds = semanticResult.ids;
292
+ if (kindParam) {
293
+ const kindFilterConditions = [
294
+ inArray(memoryItems.id, semanticResult.ids),
295
+ eq(memoryItems.kind, kindParam),
296
+ ];
297
+ if (statusParam && statusParam !== "all") {
298
+ kindFilterConditions.push(eq(memoryItems.status, statusParam));
299
+ }
300
+ const kindIdSet = new Set(
301
+ db
302
+ .select({ id: memoryItems.id })
303
+ .from(memoryItems)
304
+ .where(and(...kindFilterConditions))
305
+ .all()
306
+ .map((r) => r.id),
307
+ );
308
+ filteredIds = semanticResult.ids.filter((id) => kindIdSet.has(id));
309
+ }
310
+
311
+ const total = filteredIds.length;
312
+ const pageIds = filteredIds.slice(offsetParam, offsetParam + limitParam);
255
313
 
256
314
  if (pageIds.length === 0) {
257
- return Response.json({ items: [], total: semanticResult.total });
315
+ return Response.json({
316
+ items: [],
317
+ total,
318
+ kindCounts: semanticKindCounts,
319
+ });
258
320
  }
259
321
 
260
- // Re-apply the same DB-side filters used in the SQL path as defense-
261
- // in-depth against stale Qdrant payloads leaking deleted/mismatched rows.
322
+ // Re-apply DB-side filters as defense-in-depth against stale Qdrant
323
+ // payloads leaking deleted/mismatched rows.
262
324
  const hydrationConditions = [inArray(memoryItems.id, pageIds)];
263
325
  if (statusParam && statusParam !== "all") {
264
326
  hydrationConditions.push(eq(memoryItems.status, statusParam));
@@ -288,12 +350,41 @@ export async function handleListMemoryItems(url: URL): Promise<Response> {
288
350
 
289
351
  return Response.json({
290
352
  items: enrichedItems,
291
- total: semanticResult.total,
353
+ total,
354
+ kindCounts: semanticKindCounts,
292
355
  });
293
356
  }
294
357
  // semanticResult was null (Qdrant unavailable) or empty — fall through to SQL
295
358
  }
296
359
 
360
+ // ── Kind counts for SQL path ───────────────────────────────────────
361
+ // Respects status/search filters but NOT kind filter, so the sidebar
362
+ // can show totals for every kind simultaneously.
363
+ const kindCountConditions = [];
364
+ if (statusParam && statusParam !== "all") {
365
+ kindCountConditions.push(eq(memoryItems.status, statusParam));
366
+ }
367
+ if (searchParam) {
368
+ kindCountConditions.push(
369
+ or(
370
+ like(memoryItems.subject, `%${searchParam}%`),
371
+ like(memoryItems.statement, `%${searchParam}%`),
372
+ )!,
373
+ );
374
+ }
375
+ const kindCountWhere =
376
+ kindCountConditions.length > 0 ? and(...kindCountConditions) : undefined;
377
+ const sqlKindCountRows = db
378
+ .select({ kind: memoryItems.kind, count: count() })
379
+ .from(memoryItems)
380
+ .where(kindCountWhere)
381
+ .groupBy(memoryItems.kind)
382
+ .all();
383
+ const kindCounts: Record<string, number> = {};
384
+ for (const row of sqlKindCountRows) {
385
+ kindCounts[row.kind] = row.count;
386
+ }
387
+
297
388
  // ── SQL path (default or fallback) ──────────────────────────────────
298
389
  const conditions = [];
299
390
  if (statusParam && statusParam !== "all") {
@@ -344,7 +435,7 @@ export async function handleListMemoryItems(url: URL): Promise<Response> {
344
435
  scopeLabel: resolveScopeLabel(item.scopeId, titleMap),
345
436
  }));
346
437
 
347
- return Response.json({ items: enrichedItems, total });
438
+ return Response.json({ items: enrichedItems, total, kindCounts });
348
439
  }
349
440
 
350
441
  // ---------------------------------------------------------------------------
@@ -253,7 +253,10 @@ export function oauthAppsRouteDefinitions(): RouteDefinition[] {
253
253
  );
254
254
  }
255
255
 
256
- let body: { scopes?: string[] } = {};
256
+ let body: {
257
+ scopes?: string[];
258
+ callback_transport?: "loopback" | "gateway";
259
+ } = {};
257
260
  try {
258
261
  const text = await req.text();
259
262
  if (text) {
@@ -263,6 +266,19 @@ export function oauthAppsRouteDefinitions(): RouteDefinition[] {
263
266
  // No body or invalid JSON — use defaults
264
267
  }
265
268
 
269
+ // Validate callback_transport if present
270
+ if (
271
+ body.callback_transport !== undefined &&
272
+ body.callback_transport !== "loopback" &&
273
+ body.callback_transport !== "gateway"
274
+ ) {
275
+ return httpError(
276
+ "BAD_REQUEST",
277
+ 'callback_transport must be "loopback" or "gateway"',
278
+ 400,
279
+ );
280
+ }
281
+
266
282
  const clientSecret = await getAppClientSecret(app);
267
283
 
268
284
  const result = await orchestrateOAuthConnect({
@@ -270,6 +286,7 @@ export function oauthAppsRouteDefinitions(): RouteDefinition[] {
270
286
  clientId: app.clientId,
271
287
  clientSecret,
272
288
  requestedScopes: body.scopes,
289
+ callbackTransport: body.callback_transport ?? "loopback",
273
290
  isInteractive: false,
274
291
  });
275
292
 
@@ -6,8 +6,10 @@
6
6
  * runtime auth middleware.
7
7
  */
8
8
 
9
+ import { loadConfig } from "../../config/loader.js";
9
10
  import { getProvider, listProviders } from "../../oauth/oauth-store.js";
10
11
  import { serializeProviderSummary } from "../../oauth/provider-serializer.js";
12
+ import { isProviderVisible } from "../../oauth/provider-visibility.js";
11
13
  import { httpError } from "../http-errors.js";
12
14
  import type { RouteDefinition } from "../http-router.js";
13
15
 
@@ -22,7 +24,9 @@ export function oauthProvidersRouteDefinitions(): RouteDefinition[] {
22
24
  method: "GET",
23
25
  handler: ({ url }) => {
24
26
  const rows = listProviders();
25
- let serialized = rows
27
+ const config = loadConfig();
28
+ const visibleRows = rows.filter((r) => isProviderVisible(r, config));
29
+ let serialized = visibleRows
26
30
  .map((row) => serializeProviderSummary(row))
27
31
  .filter((s): s is NonNullable<typeof s> => s !== null);
28
32
 
@@ -53,6 +57,14 @@ export function oauthProvidersRouteDefinitions(): RouteDefinition[] {
53
57
  );
54
58
  }
55
59
 
60
+ if (!isProviderVisible(row, loadConfig())) {
61
+ return httpError(
62
+ "NOT_FOUND",
63
+ `No OAuth provider registered for "${params.providerKey}"`,
64
+ 404,
65
+ );
66
+ }
67
+
56
68
  return Response.json({ provider: serializeProviderSummary(row) });
57
69
  },
58
70
  },
@@ -223,6 +223,7 @@ async function handleOAuthConnectStart(body: {
223
223
  requestedScopes: body.requestedScopes,
224
224
  clientId,
225
225
  clientSecret,
226
+ callbackTransport: "loopback",
226
227
  isInteractive: true,
227
228
  openUrl: (url: string) => {
228
229
  authUrl = url;
@@ -11,6 +11,7 @@ import { z } from "zod";
11
11
  import {
12
12
  getUsageDayBuckets,
13
13
  getUsageGroupBreakdown,
14
+ getUsageHourBuckets,
14
15
  getUsageTotals,
15
16
  } from "../../memory/llm-usage-store.js";
16
17
  import { httpError } from "../http-errors.js";
@@ -104,14 +105,30 @@ export function usageRouteDefinitions(): RouteDefinition[] {
104
105
  schema: { type: "integer" },
105
106
  description: "End epoch millis (required)",
106
107
  },
108
+ {
109
+ name: "granularity",
110
+ schema: { type: "string", enum: ["daily", "hourly"] },
111
+ description: 'Bucket granularity: "daily" (default) or "hourly"',
112
+ },
107
113
  ],
108
114
  responseBody: z.object({
109
- buckets: z.array(z.unknown()).describe("Daily usage bucket objects"),
115
+ buckets: z.array(z.unknown()).describe("Usage bucket objects"),
110
116
  }),
111
117
  handler: ({ url }) => {
112
118
  const range = parseTimeRange(url);
113
119
  if (range instanceof Response) return range;
114
- const buckets = getUsageDayBuckets(range);
120
+ const granularity = url.searchParams.get("granularity") ?? "daily";
121
+ if (granularity !== "daily" && granularity !== "hourly") {
122
+ return httpError(
123
+ "BAD_REQUEST",
124
+ `Invalid "granularity" value: "${granularity}". Must be one of: daily, hourly`,
125
+ 400,
126
+ );
127
+ }
128
+ const buckets =
129
+ granularity === "hourly"
130
+ ? getUsageHourBuckets(range)
131
+ : getUsageDayBuckets(range);
115
132
  return Response.json({ buckets });
116
133
  },
117
134
  },
@@ -5,24 +5,8 @@
5
5
  * item falls back to the task-level required tools instead of silently
6
6
  * skipping permission checks.
7
7
  */
8
- import { mkdtempSync, rmSync } from "node:fs";
9
- import { tmpdir } from "node:os";
10
- import { join } from "node:path";
11
8
  import { afterAll, describe, expect, mock, test } from "bun:test";
12
9
 
13
- const testDir = mkdtempSync(join(tmpdir(), "work-items-routes-test-"));
14
-
15
- mock.module("../../util/platform.js", () => ({
16
- getDataDir: () => testDir,
17
- isMacOS: () => process.platform === "darwin",
18
- isLinux: () => process.platform === "linux",
19
- isWindows: () => process.platform === "win32",
20
- getPidPath: () => join(testDir, "test.pid"),
21
- getDbPath: () => join(testDir, "test.db"),
22
- getLogPath: () => join(testDir, "test.log"),
23
- ensureDataDir: () => {},
24
- }));
25
-
26
10
  mock.module("../../util/logger.js", () => ({
27
11
  getLogger: () =>
28
12
  new Proxy({} as Record<string, unknown>, {
@@ -48,11 +32,6 @@ initializeDb();
48
32
 
49
33
  afterAll(() => {
50
34
  resetDb();
51
- try {
52
- rmSync(testDir, { recursive: true });
53
- } catch {
54
- /* best effort */
55
- }
56
35
  });
57
36
 
58
37
  describe("empty required_tools snapshot bypass", () => {
@@ -4,37 +4,17 @@
4
4
  * Covers path resolution (traversal prevention), MIME type detection,
5
5
  * directory listing, file metadata, and raw content serving with range support.
6
6
  */
7
- import {
8
- existsSync,
9
- mkdirSync,
10
- mkdtempSync,
11
- readFileSync,
12
- realpathSync,
13
- rmSync,
14
- writeFileSync,
15
- } from "node:fs";
16
- import { tmpdir } from "node:os";
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
17
8
  import { join } from "node:path";
18
- import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";
9
+ import { beforeAll, describe, expect, test } from "bun:test";
19
10
 
20
11
  // ---------------------------------------------------------------------------
21
12
  // Create a temp workspace directory for isolation
22
13
  // ---------------------------------------------------------------------------
23
14
 
24
- const testWorkspaceDir = realpathSync(
25
- mkdtempSync(join(tmpdir(), "workspace-routes-test-")),
26
- );
15
+ const testWorkspaceDir = process.env.VELLUM_WORKSPACE_DIR!;
27
16
 
28
17
  // Mock platform module so getWorkspaceDir returns our temp dir
29
- mock.module("../../util/platform.js", () => ({
30
- getWorkspaceDir: () => testWorkspaceDir,
31
- getRootDir: () => testWorkspaceDir,
32
- getDataDir: () => testWorkspaceDir,
33
- isMacOS: () => process.platform === "darwin",
34
- isLinux: () => process.platform === "linux",
35
- isWindows: () => process.platform === "win32",
36
- }));
37
-
38
18
  import { workspaceRouteDefinitions } from "./workspace-routes.js";
39
19
  import { isTextMimeType, resolveWorkspacePath } from "./workspace-utils.js";
40
20
 
@@ -65,10 +45,6 @@ beforeAll(() => {
65
45
  writeFileSync(binaryFile, pngSignature);
66
46
  });
67
47
 
68
- afterAll(() => {
69
- rmSync(testWorkspaceDir, { recursive: true, force: true });
70
- });
71
-
72
48
  // ---------------------------------------------------------------------------
73
49
  // Helpers
74
50
  // ---------------------------------------------------------------------------
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * User-defined allowlist for suppressing secret scanner false positives.
3
3
  *
4
- * Reads `~/.vellum/secret-allowlist.json` (if present) and provides
4
+ * Reads `~/.vellum/workspace/data/secret-allowlist.json` (if present) and provides
5
5
  * `isAllowlisted(value)` to check whether a matched value should be
6
6
  * suppressed.
7
7
  *
@@ -18,7 +18,7 @@ import { join } from "node:path";
18
18
 
19
19
  import { pathExists } from "../util/fs.js";
20
20
  import { getLogger } from "../util/logger.js";
21
- import { getProtectedDir } from "../util/platform.js";
21
+ import { getDataDir } from "../util/platform.js";
22
22
 
23
23
  const log = getLogger("secret-allowlist");
24
24
 
@@ -45,7 +45,7 @@ let allowedRegexes: RegExp[] = [];
45
45
  export function loadAllowlist(): void {
46
46
  if (loaded || fileChecked) return;
47
47
 
48
- const filePath = join(getProtectedDir(), "secret-allowlist.json");
48
+ const filePath = join(getDataDir(), "secret-allowlist.json");
49
49
  if (!pathExists(filePath)) {
50
50
  fileChecked = true;
51
51
  return;
@@ -169,7 +169,7 @@ function validateAllowlist(
169
169
  * Returns validation errors, or null if the file doesn't exist.
170
170
  */
171
171
  export function validateAllowlistFile(): AllowlistValidationError[] | null {
172
- const filePath = join(getProtectedDir(), "secret-allowlist.json");
172
+ const filePath = join(getDataDir(), "secret-allowlist.json");
173
173
  if (!pathExists(filePath)) return null;
174
174
 
175
175
  const raw = readFileSync(filePath, "utf-8");