@vellumai/assistant 0.6.0 → 0.6.1

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 (285) hide show
  1. package/AGENTS.md +4 -0
  2. package/ARCHITECTURE.md +68 -15
  3. package/Dockerfile +2 -2
  4. package/bun.lock +6 -2
  5. package/docker-entrypoint.sh +32 -1
  6. package/docs/architecture/integrations.md +1 -1
  7. package/docs/architecture/memory.md +21 -24
  8. package/openapi.yaml +538 -3
  9. package/package.json +5 -1
  10. package/src/__tests__/anthropic-provider.test.ts +160 -95
  11. package/src/__tests__/app-dir-path-guard.test.ts +1 -0
  12. package/src/__tests__/app-executors.test.ts +47 -1
  13. package/src/__tests__/app-source-watcher.test.ts +159 -0
  14. package/src/__tests__/checker.test.ts +38 -6
  15. package/src/__tests__/config-schema.test.ts +5 -0
  16. package/src/__tests__/conversation-agent-loop-overflow.test.ts +4 -6
  17. package/src/__tests__/conversation-agent-loop.test.ts +4 -51
  18. package/src/__tests__/conversation-history-web-search.test.ts +1 -1
  19. package/src/__tests__/conversation-runtime-assembly.test.ts +653 -832
  20. package/src/__tests__/conversation-runtime-workspace.test.ts +1 -93
  21. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +17 -4
  22. package/src/__tests__/conversation-wipe.test.ts +2 -6
  23. package/src/__tests__/conversation-workspace-cache-state.test.ts +6 -12
  24. package/src/__tests__/conversation-workspace-injection.test.ts +25 -26
  25. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -1
  26. package/src/__tests__/copy-composer-tc-templates.test.ts +335 -0
  27. package/src/__tests__/date-context.test.ts +76 -210
  28. package/src/__tests__/db-schedule-syntax-migration.test.ts +16 -1
  29. package/src/__tests__/file-list-tool.test.ts +219 -0
  30. package/src/__tests__/first-greeting.test.ts +1 -1
  31. package/src/__tests__/heartbeat-service.test.ts +180 -3
  32. package/src/__tests__/identity-routes.test.ts +328 -0
  33. package/src/__tests__/injection-block.test.ts +24 -0
  34. package/src/__tests__/install-skill-routing.test.ts +7 -6
  35. package/src/__tests__/jobs-store-qdrant-breaker.test.ts +15 -14
  36. package/src/__tests__/list-messages-tool-merge.test.ts +300 -0
  37. package/src/__tests__/llm-context-normalization.test.ts +18 -18
  38. package/src/__tests__/llm-context-route-provider.test.ts +101 -0
  39. package/src/__tests__/llm-request-log-turn-query.test.ts +162 -0
  40. package/src/__tests__/log-export-workspace.test.ts +72 -105
  41. package/src/__tests__/mcp-abort-signal.test.ts +5 -0
  42. package/src/__tests__/mcp-client-auth.test.ts +5 -0
  43. package/src/__tests__/memory-recall-log-store.test.ts +132 -0
  44. package/src/__tests__/migration-export-streaming.test.ts +304 -0
  45. package/src/__tests__/migration-import-commit-http.test.ts +11 -10
  46. package/src/__tests__/mock-fetch.ts +87 -0
  47. package/src/__tests__/notification-decision-recipient-context.test.ts +282 -0
  48. package/src/__tests__/onboarding-template-contract.test.ts +62 -14
  49. package/src/__tests__/parser.test.ts +32 -0
  50. package/src/__tests__/permission-checker-host-gate.test.ts +452 -0
  51. package/src/__tests__/permission-controls-v2-flag.test.ts +55 -0
  52. package/src/__tests__/permission-mode-sse.test.ts +418 -0
  53. package/src/__tests__/permission-mode-store.test.ts +277 -0
  54. package/src/__tests__/permission-mode.test.ts +101 -0
  55. package/src/__tests__/platform-bash-auto-approve.test.ts +359 -0
  56. package/src/__tests__/profiler-routes.test.ts +502 -0
  57. package/src/__tests__/profiler-run-store.test.ts +441 -0
  58. package/src/__tests__/proxy-approval-callback.test.ts +4 -75
  59. package/src/__tests__/registry.test.ts +1 -1
  60. package/src/__tests__/sandbox-host-parity.test.ts +5 -4
  61. package/src/__tests__/scheduler-reuse-conversation.test.ts +368 -0
  62. package/src/__tests__/scrub-corrupted-image-attachments.test.ts +278 -0
  63. package/src/__tests__/search-skills-unified.test.ts +4 -3
  64. package/src/__tests__/send-endpoint-busy.test.ts +42 -3
  65. package/src/__tests__/set-permission-mode.test.ts +274 -0
  66. package/src/__tests__/skill-load-feature-flag.test.ts +12 -0
  67. package/src/__tests__/skill-memory.test.ts +2 -783
  68. package/src/__tests__/strip-memory-injections.test.ts +187 -0
  69. package/src/__tests__/subagent-detail.test.ts +84 -0
  70. package/src/__tests__/subagent-disposal.test.ts +308 -0
  71. package/src/__tests__/subagent-manager-notify.test.ts +19 -10
  72. package/src/__tests__/subagent-notify-parent.test.ts +390 -0
  73. package/src/__tests__/subagent-role-registry.test.ts +108 -0
  74. package/src/__tests__/subagent-tool-filtering.test.ts +71 -0
  75. package/src/__tests__/subagent-tools.test.ts +464 -4
  76. package/src/__tests__/system-prompt-ask-mode.test.ts +139 -0
  77. package/src/__tests__/task-memory-cleanup.test.ts +12 -12
  78. package/src/__tests__/terminal-tools.test.ts +17 -27
  79. package/src/__tests__/test-preload.ts +4 -0
  80. package/src/__tests__/tool-executor.test.ts +4 -26
  81. package/src/__tests__/tool-side-effects-slack-dm.test.ts +1 -0
  82. package/src/__tests__/top-level-renderer.test.ts +10 -13
  83. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +116 -2
  84. package/src/__tests__/workspace-migration-028-recover-conversations-from-disk-view.test.ts +387 -0
  85. package/src/agent/loop.ts +6 -0
  86. package/src/approvals/guardian-request-resolvers.ts +24 -0
  87. package/src/avatar/traits-png-sync.ts +3 -3
  88. package/src/cli/__tests__/run-assistant-command.ts +29 -0
  89. package/src/cli/commands/__tests__/email-download.test.ts +245 -0
  90. package/src/cli/commands/__tests__/email-list.test.ts +192 -0
  91. package/src/cli/commands/__tests__/email-register.test.ts +186 -0
  92. package/src/cli/commands/__tests__/email-send.test.ts +291 -0
  93. package/src/cli/commands/__tests__/email-status.test.ts +181 -0
  94. package/src/cli/commands/__tests__/email-unregister.test.ts +139 -0
  95. package/src/cli/commands/__tests__/routes.test.ts +562 -0
  96. package/src/cli/commands/conversations.ts +1 -8
  97. package/src/cli/commands/email.ts +584 -835
  98. package/src/cli/commands/memory.ts +1 -34
  99. package/src/cli/commands/notifications.ts +7 -2
  100. package/src/cli/commands/oauth/connect.ts +14 -5
  101. package/src/cli/commands/routes.ts +396 -0
  102. package/src/cli/commands/skills.ts +130 -20
  103. package/src/cli/program.ts +2 -0
  104. package/src/cli.ts +1 -120
  105. package/src/config/bundled-skills/app-builder/SKILL.md +4 -1
  106. package/src/config/bundled-skills/gmail/SKILL.md +2 -2
  107. package/src/config/bundled-skills/messaging/SKILL.md +7 -0
  108. package/src/config/bundled-skills/schedule/SKILL.md +22 -2
  109. package/src/config/bundled-skills/schedule/TOOLS.json +8 -0
  110. package/src/config/bundled-skills/settings/tools/avatar-get.ts +3 -13
  111. package/src/config/bundled-skills/settings/tools/avatar-remove.ts +2 -4
  112. package/src/config/bundled-skills/settings/tools/avatar-update.ts +5 -2
  113. package/src/config/bundled-skills/slack/SKILL.md +2 -0
  114. package/src/config/bundled-skills/subagent/SKILL.md +43 -3
  115. package/src/config/bundled-skills/subagent/TOOLS.json +29 -4
  116. package/src/config/env-registry.ts +63 -0
  117. package/src/config/feature-flag-registry.json +17 -1
  118. package/src/config/schema.ts +8 -0
  119. package/src/config/schemas/filing.ts +51 -0
  120. package/src/config/schemas/heartbeat.ts +15 -12
  121. package/src/config/schemas/memory-lifecycle.ts +12 -0
  122. package/src/config/schemas/security.ts +14 -0
  123. package/src/daemon/app-source-watcher.ts +93 -0
  124. package/src/daemon/config-watcher.ts +79 -1
  125. package/src/daemon/conversation-agent-loop-handlers.ts +20 -0
  126. package/src/daemon/conversation-agent-loop.ts +158 -65
  127. package/src/daemon/conversation-history.ts +4 -19
  128. package/src/daemon/conversation-lifecycle.ts +8 -14
  129. package/src/daemon/conversation-process.ts +13 -7
  130. package/src/daemon/conversation-runtime-assembly.ts +300 -306
  131. package/src/daemon/conversation-tool-setup.ts +44 -14
  132. package/src/daemon/conversation-workspace.ts +1 -2
  133. package/src/daemon/conversation.ts +18 -0
  134. package/src/daemon/date-context.ts +26 -53
  135. package/src/daemon/first-greeting.ts +1 -1
  136. package/src/daemon/handlers/conversations.ts +4 -7
  137. package/src/daemon/handlers/shared.test.ts +143 -0
  138. package/src/daemon/handlers/shared.ts +63 -5
  139. package/src/daemon/handlers/skills.ts +11 -18
  140. package/src/daemon/lifecycle.ts +199 -157
  141. package/src/daemon/message-types/conversations.ts +25 -6
  142. package/src/daemon/message-types/messages.ts +9 -1
  143. package/src/daemon/message-types/schedules.ts +1 -0
  144. package/src/daemon/message-types/settings.ts +6 -0
  145. package/src/daemon/profiler-run-store.ts +557 -0
  146. package/src/daemon/server.ts +89 -9
  147. package/src/daemon/shutdown-handlers.ts +5 -0
  148. package/src/daemon/tool-side-effects.ts +23 -3
  149. package/src/export/transcript-formatter.ts +148 -0
  150. package/src/filing/filing-service.ts +228 -0
  151. package/src/heartbeat/heartbeat-service.ts +96 -7
  152. package/src/mcp/client.ts +6 -0
  153. package/src/mcp/mcp-oauth-provider.ts +149 -27
  154. package/src/memory/admin.ts +33 -32
  155. package/src/memory/app-store.ts +69 -0
  156. package/src/memory/conversation-bootstrap.ts +1 -1
  157. package/src/memory/conversation-crud.ts +136 -107
  158. package/src/memory/conversation-group-migration.ts +1 -1
  159. package/src/memory/conversation-queries.ts +58 -12
  160. package/src/memory/conversation-title-service.ts +1 -0
  161. package/src/memory/db-init.ts +182 -376
  162. package/src/memory/graph/bootstrap.ts +75 -66
  163. package/src/memory/graph/capability-seed.ts +167 -15
  164. package/src/memory/graph/consolidation.ts +38 -4
  165. package/src/memory/graph/conversation-graph-memory.ts +133 -104
  166. package/src/memory/graph/extraction-job.ts +9 -4
  167. package/src/memory/graph/extraction.ts +66 -23
  168. package/src/memory/graph/graph-memory-state-store.ts +37 -0
  169. package/src/memory/graph/graph-search.ts +29 -15
  170. package/src/memory/graph/injection.ts +38 -8
  171. package/src/memory/graph/inspect.ts +12 -3
  172. package/src/memory/graph/retriever.ts +365 -262
  173. package/src/memory/graph/store.test.ts +48 -0
  174. package/src/memory/graph/store.ts +150 -11
  175. package/src/memory/graph/tool-handlers.ts +84 -209
  176. package/src/memory/graph/tools.ts +8 -52
  177. package/src/memory/graph/types.ts +24 -0
  178. package/src/memory/job-handlers/cleanup.ts +44 -1
  179. package/src/memory/jobs-store.ts +70 -60
  180. package/src/memory/jobs-worker.ts +44 -28
  181. package/src/memory/llm-request-log-store.ts +96 -12
  182. package/src/memory/memory-recall-log-store.ts +49 -5
  183. package/src/memory/migrations/203-drop-memory-items-tables.ts +33 -1
  184. package/src/memory/migrations/206-memory-graph-node-edits.ts +19 -0
  185. package/src/memory/migrations/206-scrub-corrupted-image-attachments.ts +131 -0
  186. package/src/memory/migrations/207-conversation-graph-memory-state.ts +20 -0
  187. package/src/memory/migrations/208-conversations-last-message-at.ts +35 -0
  188. package/src/memory/migrations/209-strip-thinking-from-consolidated.ts +85 -0
  189. package/src/memory/migrations/210-schedule-reuse-conversation.ts +13 -0
  190. package/src/memory/migrations/211-memory-recall-logs-query-context.ts +21 -0
  191. package/src/memory/migrations/212-llm-request-logs-created-at-index.ts +19 -0
  192. package/src/memory/migrations/index.ts +8 -0
  193. package/src/memory/migrations/registry.ts +8 -0
  194. package/src/memory/schema/conversations.ts +14 -0
  195. package/src/memory/schema/infrastructure.ts +8 -1
  196. package/src/memory/schema/memory-core.ts +0 -51
  197. package/src/memory/schema/memory-graph.ts +15 -0
  198. package/src/memory/task-memory-cleanup.ts +30 -11
  199. package/src/notifications/copy-composer.ts +86 -0
  200. package/src/notifications/decision-engine.ts +35 -0
  201. package/src/permissions/checker.ts +12 -1
  202. package/src/permissions/permission-mode-store.ts +180 -0
  203. package/src/permissions/permission-mode.ts +31 -0
  204. package/src/permissions/workspace-policy.ts +9 -0
  205. package/src/prompts/system-prompt.ts +59 -7
  206. package/src/prompts/templates/BOOTSTRAP-REFERENCE.md +100 -0
  207. package/src/prompts/templates/BOOTSTRAP.md +70 -165
  208. package/src/prompts/templates/HEARTBEAT.md +3 -1
  209. package/src/prompts/templates/SOUL.md +25 -4
  210. package/src/prompts/templates/UPDATES.md +8 -0
  211. package/src/providers/anthropic/client.ts +107 -219
  212. package/src/runtime/auth/route-policy.ts +23 -0
  213. package/src/runtime/http-server.ts +32 -2
  214. package/src/runtime/http-types.ts +12 -1
  215. package/src/runtime/migrations/vbundle-builder.ts +389 -3
  216. package/src/runtime/migrations/vbundle-importer.ts +8 -6
  217. package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +378 -0
  218. package/src/runtime/routes/app-management-routes.ts +1 -11
  219. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +26 -0
  220. package/src/runtime/routes/archive-utils.ts +29 -0
  221. package/src/runtime/routes/avatar-routes.ts +2 -9
  222. package/src/runtime/routes/btw-routes.ts +14 -1
  223. package/src/runtime/routes/conversation-analysis-routes.ts +173 -0
  224. package/src/runtime/routes/conversation-management-routes.ts +1 -14
  225. package/src/runtime/routes/conversation-query-routes.ts +49 -3
  226. package/src/runtime/routes/conversation-routes.ts +264 -44
  227. package/src/runtime/routes/heartbeat-routes.ts +4 -10
  228. package/src/runtime/routes/identity-routes.ts +53 -18
  229. package/src/runtime/routes/llm-context-normalization.ts +14 -10
  230. package/src/runtime/routes/log-export-routes.ts +23 -275
  231. package/src/runtime/routes/memory-item-routes.test.ts +168 -233
  232. package/src/runtime/routes/migration-routes.ts +18 -7
  233. package/src/runtime/routes/profiler-routes.ts +350 -0
  234. package/src/runtime/routes/schedule-routes.ts +27 -12
  235. package/src/runtime/routes/settings-routes.ts +95 -8
  236. package/src/runtime/routes/subagents-routes.ts +28 -7
  237. package/src/runtime/routes/user-route-dispatcher.ts +223 -0
  238. package/src/runtime/routes/user-routes.ts +41 -0
  239. package/src/runtime/routes/workspace-routes.ts +0 -1
  240. package/src/schedule/schedule-store.ts +30 -0
  241. package/src/schedule/scheduler.ts +45 -18
  242. package/src/skills/catalog-install.ts +10 -2
  243. package/src/skills/managed-store.ts +2 -2
  244. package/src/skills/skill-memory.ts +1 -293
  245. package/src/subagent/index.ts +13 -3
  246. package/src/subagent/manager.ts +308 -29
  247. package/src/subagent/types.ts +68 -0
  248. package/src/tasks/task-runner.ts +4 -4
  249. package/src/tools/apps/executors.ts +29 -4
  250. package/src/tools/filesystem/list.ts +93 -0
  251. package/src/tools/permission-checker.ts +78 -0
  252. package/src/tools/registry.ts +4 -0
  253. package/src/tools/schedule/create.ts +3 -0
  254. package/src/tools/schedule/list.ts +1 -0
  255. package/src/tools/schedule/update.ts +6 -0
  256. package/src/tools/shared/filesystem/errors.ts +5 -0
  257. package/src/tools/shared/filesystem/file-ops-service.ts +90 -2
  258. package/src/tools/shared/filesystem/types.ts +17 -0
  259. package/src/tools/shared/shell-output.ts +31 -2
  260. package/src/tools/subagent/abort.ts +12 -2
  261. package/src/tools/subagent/message.ts +9 -2
  262. package/src/tools/subagent/notify-parent.ts +79 -0
  263. package/src/tools/subagent/read.ts +29 -8
  264. package/src/tools/subagent/resolve.ts +21 -0
  265. package/src/tools/subagent/spawn.ts +2 -0
  266. package/src/tools/subagent/status.ts +11 -1
  267. package/src/tools/system/avatar-generator.ts +3 -3
  268. package/src/tools/system/register.ts +23 -0
  269. package/src/tools/system/set-permission-mode.ts +103 -0
  270. package/src/tools/terminal/parser.ts +30 -5
  271. package/src/tools/terminal/safe-env.ts +16 -1
  272. package/src/tools/tool-manifest.ts +6 -0
  273. package/src/tools/types.ts +2 -0
  274. package/src/util/logger.ts +1 -1
  275. package/src/util/platform.ts +50 -17
  276. package/src/workspace/migrations/023-move-config-files-to-workspace.ts +2 -2
  277. package/src/workspace/migrations/024-move-runtime-files-to-workspace.ts +2 -2
  278. package/src/workspace/migrations/028-recover-conversations-from-disk-view.ts +270 -0
  279. package/src/workspace/migrations/029-seed-pkb.ts +84 -0
  280. package/src/workspace/migrations/registry.ts +4 -0
  281. package/src/workspace/top-level-renderer.ts +5 -9
  282. package/src/__tests__/cli-memory.test.ts +0 -377
  283. package/src/__tests__/clipboard.test.ts +0 -88
  284. package/src/cli/cli-memory.ts +0 -179
  285. package/src/util/clipboard.ts +0 -34
@@ -34,15 +34,17 @@ export interface SubagentDetailResult {
34
34
  }>;
35
35
  }
36
36
 
37
- export function getSubagentDetail(
37
+ /**
38
+ * Parse raw message rows into subagent detail events. Extracted as a pure
39
+ * function so it can be unit-tested without a database.
40
+ */
41
+ export function parseSubagentMessages(
38
42
  subagentId: string,
39
- conversationId: string,
43
+ messages: Array<{ role: string; content: string }>,
40
44
  ): SubagentDetailResult {
41
- const subagentMsgs = getMessages(conversationId);
42
-
43
45
  // Extract objective from the first user message
44
46
  let objective: string | undefined;
45
- const firstUser = subagentMsgs.find((m) => m.role === "user");
47
+ const firstUser = messages.find((m) => m.role === "user");
46
48
  if (firstUser) {
47
49
  try {
48
50
  const parsed = JSON.parse(firstUser.content);
@@ -67,7 +69,7 @@ export function getSubagentDetail(
67
69
  isError?: boolean;
68
70
  }> = [];
69
71
  const pendingTools = new Map<string, string>();
70
- for (const m of subagentMsgs) {
72
+ for (const m of messages) {
71
73
  if (m.role !== "assistant" && m.role !== "user") continue;
72
74
  let content: unknown[];
73
75
  try {
@@ -101,7 +103,19 @@ export function getSubagentDetail(
101
103
  const toolUseId =
102
104
  typeof block.tool_use_id === "string" ? block.tool_use_id : "";
103
105
  const resultContent =
104
- typeof block.content === "string" ? block.content : "";
106
+ typeof block.content === "string"
107
+ ? block.content
108
+ : Array.isArray(block.content)
109
+ ? (block.content as unknown[])
110
+ .filter(
111
+ (b): b is Record<string, unknown> =>
112
+ isRecord(b) &&
113
+ (b as Record<string, unknown>).type === "text" &&
114
+ typeof (b as Record<string, unknown>).text === "string",
115
+ )
116
+ .map((b) => b.text as string)
117
+ .join("\n")
118
+ : "";
105
119
  const isError = block.is_error === true;
106
120
  const toolName = toolUseId ? pendingTools.get(toolUseId) : undefined;
107
121
  events.push({
@@ -117,6 +131,13 @@ export function getSubagentDetail(
117
131
  return { subagentId, objective, events };
118
132
  }
119
133
 
134
+ export function getSubagentDetail(
135
+ subagentId: string,
136
+ conversationId: string,
137
+ ): SubagentDetailResult {
138
+ return parseSubagentMessages(subagentId, getMessages(conversationId));
139
+ }
140
+
120
141
  // ---------------------------------------------------------------------------
121
142
  // Route definitions
122
143
  // ---------------------------------------------------------------------------
@@ -0,0 +1,223 @@
1
+ /**
2
+ * File-based route dispatcher for user-defined HTTP endpoints.
3
+ *
4
+ * Maps requests under the `/x/*` path prefix to handler modules in the
5
+ * workspace routes directory (`$VELLUM_WORKSPACE_DIR/routes/`). Each handler file
6
+ * exports named functions for HTTP methods (GET, POST, PUT, etc.) using
7
+ * the standard Web API Request/Response signature.
8
+ *
9
+ * Modules are lazily loaded on first request and cached by file path +
10
+ * mtime. When a file changes on disk, the next request reloads it via
11
+ * Bun's dynamic `import()` with a cache-busting query parameter.
12
+ */
13
+
14
+ import { existsSync, statSync } from "node:fs";
15
+ import { join, resolve } from "node:path";
16
+
17
+ import { getLogger } from "../../util/logger.js";
18
+ import { getWorkspaceRoutesDir } from "../../util/platform.js";
19
+ import { httpError } from "../http-errors.js";
20
+
21
+ const log = getLogger("user-routes");
22
+
23
+ /** HTTP methods that can be exported from a handler module. */
24
+ const HTTP_METHODS = [
25
+ "GET",
26
+ "POST",
27
+ "PUT",
28
+ "PATCH",
29
+ "DELETE",
30
+ "HEAD",
31
+ "OPTIONS",
32
+ ] as const;
33
+
34
+ type HttpMethod = (typeof HTTP_METHODS)[number];
35
+
36
+ /** The function signature that user-defined route handlers must follow. */
37
+ type RouteHandler = (request: Request) => Response | Promise<Response>;
38
+
39
+ /** A loaded handler module with its cached metadata. */
40
+ interface CachedModule {
41
+ /** The module's exports (keyed by HTTP method name). */
42
+ handlers: Partial<Record<HttpMethod, RouteHandler>>;
43
+ /** Optional description exported by the module for display in CLI. */
44
+ description?: string;
45
+ /** The file's mtime at the time of loading, in milliseconds. */
46
+ mtimeMs: number;
47
+ }
48
+
49
+ /** Default per-request timeout for user-defined route handlers (30 seconds). */
50
+ const DEFAULT_HANDLER_TIMEOUT_MS = 30_000;
51
+
52
+ /** Supported file extensions for handler modules. */
53
+ const HANDLER_EXTENSIONS = [".ts", ".js"] as const;
54
+
55
+ export class UserRouteDispatcher {
56
+ private moduleCache = new Map<string, CachedModule>();
57
+ private handlerTimeoutMs: number;
58
+
59
+ constructor(options?: { handlerTimeoutMs?: number }) {
60
+ this.handlerTimeoutMs =
61
+ options?.handlerTimeoutMs ?? DEFAULT_HANDLER_TIMEOUT_MS;
62
+ }
63
+
64
+ /**
65
+ * Dispatch a request to the appropriate user-defined handler file.
66
+ *
67
+ * @param routePath The path after the `x/` prefix (e.g. `my-app/status`).
68
+ * @param request The original HTTP request.
69
+ * @returns A Response from the handler, or an error response (404, 405, 500).
70
+ */
71
+ async dispatch(routePath: string, request: Request): Promise<Response> {
72
+ if (routePath.includes("..")) {
73
+ return httpError("BAD_REQUEST", "Path traversal is not allowed", 400);
74
+ }
75
+
76
+ const routesDir = getWorkspaceRoutesDir();
77
+ const filePath = this.resolveHandlerFile(routesDir, routePath);
78
+
79
+ if (!filePath) {
80
+ return httpError(
81
+ "NOT_FOUND",
82
+ `No route handler found for /x/${routePath}`,
83
+ 404,
84
+ );
85
+ }
86
+
87
+ const mod = await this.loadModule(filePath);
88
+ const method = request.method as HttpMethod;
89
+ const handler = mod.handlers[method];
90
+
91
+ if (!handler) {
92
+ const allowed = HTTP_METHODS.filter((m) => m in mod.handlers);
93
+ return new Response(null, {
94
+ status: 405,
95
+ headers: { Allow: allowed.join(", ") },
96
+ });
97
+ }
98
+
99
+ return this.executeHandler(handler, request, routePath);
100
+ }
101
+
102
+ /**
103
+ * Resolve a route path to a handler file on disk.
104
+ *
105
+ * Checks for direct file matches first (`<path>.ts`, `<path>.js`),
106
+ * then falls back to index files (`<path>/index.ts`, `<path>/index.js`).
107
+ *
108
+ * Returns the absolute path to the handler file, or null if not found.
109
+ */
110
+ private resolveHandlerFile(
111
+ routesDir: string,
112
+ routePath: string,
113
+ ): string | null {
114
+ const basePath = join(routesDir, routePath);
115
+ const resolved = resolve(basePath);
116
+
117
+ // Ensure the resolved path is within the routes directory to prevent
118
+ // any path traversal that slipped through the initial check.
119
+ if (!resolved.startsWith(resolve(routesDir))) {
120
+ return null;
121
+ }
122
+
123
+ // Direct file match: routes/<path>.ts or routes/<path>.js
124
+ for (const ext of HANDLER_EXTENSIONS) {
125
+ const candidate = `${resolved}${ext}`;
126
+ if (existsSync(candidate)) {
127
+ return candidate;
128
+ }
129
+ }
130
+
131
+ // Index file convention: routes/<path>/index.ts or routes/<path>/index.js
132
+ for (const ext of HANDLER_EXTENSIONS) {
133
+ const candidate = join(resolved, `index${ext}`);
134
+ if (existsSync(candidate)) {
135
+ return candidate;
136
+ }
137
+ }
138
+
139
+ return null;
140
+ }
141
+
142
+ /**
143
+ * Load a handler module, using the mtime-based cache when possible.
144
+ *
145
+ * On cache miss or stale mtime, the module is re-imported via Bun's
146
+ * dynamic `import()` with a cache-busting query parameter derived
147
+ * from the file's current mtime.
148
+ */
149
+ private async loadModule(filePath: string): Promise<CachedModule> {
150
+ const stat = statSync(filePath);
151
+ const mtimeMs = stat.mtimeMs;
152
+
153
+ const cached = this.moduleCache.get(filePath);
154
+ if (cached && cached.mtimeMs === mtimeMs) {
155
+ return cached;
156
+ }
157
+
158
+ // Cache-bust Bun's module cache by appending mtime as a query param.
159
+ const mod = (await import(`${filePath}?t=${mtimeMs}`)) as Record<
160
+ string,
161
+ unknown
162
+ >;
163
+
164
+ const handlers: Partial<Record<HttpMethod, RouteHandler>> = {};
165
+ for (const method of HTTP_METHODS) {
166
+ if (typeof mod[method] === "function") {
167
+ handlers[method] = mod[method] as RouteHandler;
168
+ }
169
+ }
170
+
171
+ const description =
172
+ typeof mod.description === "string" ? mod.description : undefined;
173
+
174
+ const entry: CachedModule = { handlers, description, mtimeMs };
175
+ this.moduleCache.set(filePath, entry);
176
+
177
+ log.info(
178
+ { filePath, methods: Object.keys(handlers), description },
179
+ "Loaded user route handler",
180
+ );
181
+
182
+ return entry;
183
+ }
184
+
185
+ /**
186
+ * Execute a handler function with a per-request timeout and error boundary.
187
+ */
188
+ private async executeHandler(
189
+ handler: RouteHandler,
190
+ request: Request,
191
+ routePath: string,
192
+ ): Promise<Response> {
193
+ try {
194
+ const result = await Promise.race([
195
+ Promise.resolve(handler(request)),
196
+ new Promise<never>((_, reject) =>
197
+ setTimeout(
198
+ () => reject(new Error("Handler timed out")),
199
+ this.handlerTimeoutMs,
200
+ ),
201
+ ),
202
+ ]);
203
+ return result;
204
+ } catch (err) {
205
+ if (err instanceof Error && err.message === "Handler timed out") {
206
+ log.error(
207
+ { routePath, timeoutMs: this.handlerTimeoutMs },
208
+ "User route handler timed out",
209
+ );
210
+ return httpError(
211
+ "SERVICE_UNAVAILABLE",
212
+ `Route handler for /x/${routePath} timed out after ${this.handlerTimeoutMs}ms`,
213
+ 504,
214
+ );
215
+ }
216
+
217
+ log.error({ err, routePath }, "User route handler threw an error");
218
+ const message =
219
+ err instanceof Error ? err.message : "Internal server error";
220
+ return httpError("INTERNAL_ERROR", message, 500);
221
+ }
222
+ }
223
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Route definitions for user-defined endpoints under `/x/*`.
3
+ *
4
+ * Registers a single catch-all route that delegates to the
5
+ * UserRouteDispatcher for file-based dispatch from
6
+ * `$VELLUM_WORKSPACE_DIR/routes/`.
7
+ */
8
+
9
+ import type { RouteDefinition } from "../http-router.js";
10
+ import { UserRouteDispatcher } from "./user-route-dispatcher.js";
11
+
12
+ const dispatcher = new UserRouteDispatcher();
13
+
14
+ /**
15
+ * HTTP methods supported by user-defined route handlers.
16
+ *
17
+ * Each method gets its own route definition so the HttpRouter can match
18
+ * on method before dispatching. The catch-all `x/:path*` pattern ensures
19
+ * all sub-paths are captured regardless of depth.
20
+ */
21
+ const METHODS = [
22
+ "GET",
23
+ "POST",
24
+ "PUT",
25
+ "PATCH",
26
+ "DELETE",
27
+ "HEAD",
28
+ "OPTIONS",
29
+ ] as const;
30
+
31
+ export function userRouteDefinitions(): RouteDefinition[] {
32
+ return METHODS.map((method) => ({
33
+ endpoint: "x/:path*",
34
+ method,
35
+ policyKey: "x",
36
+ summary: `User-defined ${method} route`,
37
+ description: `Dispatches ${method} requests to user-defined handler files in the workspace routes directory.`,
38
+ tags: ["user-routes"],
39
+ handler: ({ params, req }) => dispatcher.dispatch(params.path, req),
40
+ }));
41
+ }
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * Route handlers for workspace file browsing and content serving.
3
3
  *
4
- * WARNING: Workspace contents are included in diagnostic log exports.
5
4
  * Do not store secrets here — use the credential store or protected/ directory.
6
5
  */
7
6
  import {
@@ -36,6 +36,7 @@ export interface ScheduleJob {
36
36
  routingIntent: RoutingIntent;
37
37
  routingHints: Record<string, unknown>;
38
38
  quiet: boolean;
39
+ reuseConversation: boolean;
39
40
  status: ScheduleStatus;
40
41
  createdAt: number;
41
42
  updatedAt: number;
@@ -93,6 +94,7 @@ export function createSchedule(params: {
93
94
  routingIntent?: RoutingIntent;
94
95
  routingHints?: Record<string, unknown>;
95
96
  quiet?: boolean;
97
+ reuseConversation?: boolean;
96
98
  }): ScheduleJob {
97
99
  const expression = params.expression ?? params.cronExpression ?? null;
98
100
  const isOneShot = expression == null;
@@ -121,6 +123,7 @@ export function createSchedule(params: {
121
123
  const routingIntent = params.routingIntent ?? "all_channels";
122
124
  const routingHints = params.routingHints ?? {};
123
125
  const quiet = params.quiet ?? false;
126
+ const reuseConversation = params.reuseConversation ?? false;
124
127
 
125
128
  let nextRunAt: number;
126
129
  if (isOneShot) {
@@ -148,6 +151,7 @@ export function createSchedule(params: {
148
151
  routingIntent,
149
152
  routingHintsJson: JSON.stringify(routingHints),
150
153
  quiet,
154
+ reuseConversation,
151
155
  status: "active" as ScheduleStatus,
152
156
  createdAt: now,
153
157
  updatedAt: now,
@@ -220,6 +224,7 @@ export function updateSchedule(
220
224
  routingIntent?: RoutingIntent;
221
225
  routingHints?: Record<string, unknown>;
222
226
  quiet?: boolean;
227
+ reuseConversation?: boolean;
223
228
  },
224
229
  ): ScheduleJob | null {
225
230
  const db = getDb();
@@ -275,6 +280,8 @@ export function updateSchedule(
275
280
  if (updates.routingHints !== undefined)
276
281
  set.routingHintsJson = JSON.stringify(updates.routingHints);
277
282
  if (updates.quiet !== undefined) set.quiet = updates.quiet;
283
+ if (updates.reuseConversation !== undefined)
284
+ set.reuseConversation = updates.reuseConversation;
278
285
 
279
286
  // Recompute nextRunAt if schedule timing may have changed (only for recurring)
280
287
  if (
@@ -563,6 +570,28 @@ export function completeScheduleRun(
563
570
  }
564
571
  }
565
572
 
573
+ /**
574
+ * Return the conversation ID from the most recent successful run
575
+ * for a given schedule, or null if none exists.
576
+ */
577
+ export function getLastScheduleConversationId(jobId: string): string | null {
578
+ const db = getDb();
579
+ const row = db
580
+ .select({ conversationId: scheduleRuns.conversationId })
581
+ .from(scheduleRuns)
582
+ .where(
583
+ and(
584
+ eq(scheduleRuns.jobId, jobId),
585
+ eq(scheduleRuns.status, "ok"),
586
+ sql`${scheduleRuns.conversationId} IS NOT NULL`,
587
+ ),
588
+ )
589
+ .orderBy(desc(scheduleRuns.createdAt))
590
+ .limit(1)
591
+ .get();
592
+ return row?.conversationId ?? null;
593
+ }
594
+
566
595
  export function getScheduleRuns(jobId: string, limit?: number): ScheduleRun[] {
567
596
  const db = getDb();
568
597
  const rows = db
@@ -757,6 +786,7 @@ function parseJobRow(row: typeof scheduleJobs.$inferSelect): ScheduleJob {
757
786
  routingIntent: (row.routingIntent ?? "all_channels") as RoutingIntent,
758
787
  routingHints: safeParseJson(row.routingHintsJson),
759
788
  quiet: row.quiet ?? false,
789
+ reuseConversation: row.reuseConversation ?? false,
760
790
  status: (row.status ?? "active") as ScheduleStatus,
761
791
  createdAt: row.createdAt,
762
792
  updatedAt: row.updatedAt,
@@ -1,4 +1,5 @@
1
1
  import { bootstrapConversation } from "../memory/conversation-bootstrap.js";
2
+ import { getConversation } from "../memory/conversation-crud.js";
2
3
  import { invalidateAssistantInferredItemsForConversation } from "../memory/task-memory-cleanup.js";
3
4
  import { runSequencesOnce } from "../sequence/engine.js";
4
5
  import { getLogger } from "../util/logger.js";
@@ -14,6 +15,7 @@ import {
14
15
  completeScheduleRun,
15
16
  createScheduleRun,
16
17
  failOneShot,
18
+ getLastScheduleConversationId,
17
19
  type RoutingIntent,
18
20
  } from "./schedule-store.js";
19
21
 
@@ -240,6 +242,7 @@ async function runScheduleOnce(
240
242
  );
241
243
  // Create a fallback conversation for the schedule run record
242
244
  const fallbackConversation = bootstrapConversation({
245
+ conversationType: "scheduled",
243
246
  source: "schedule",
244
247
  scheduleJobId: job.id,
245
248
  groupId: "system:scheduled",
@@ -258,19 +261,36 @@ async function runScheduleOnce(
258
261
  continue;
259
262
  }
260
263
 
261
- const conversation = bootstrapConversation({
262
- source: "schedule",
263
- scheduleJobId: job.id,
264
- groupId: "system:scheduled",
265
- origin: "schedule",
266
- systemHint: isOneShot ? `Reminder: ${job.name}` : `Schedule: ${job.name}`,
267
- });
264
+ // Reuse the conversation from the last successful run when the flag is set
265
+ // and a prior conversation still exists; otherwise bootstrap a new one.
266
+ let conversationId: string | null = null;
267
+ let conversationReused = false;
268
+ if (job.reuseConversation && !isOneShot) {
269
+ const lastId = getLastScheduleConversationId(job.id);
270
+ if (lastId && getConversation(lastId)) {
271
+ conversationId = lastId;
272
+ conversationReused = true;
273
+ }
274
+ }
275
+ if (!conversationId) {
276
+ const conversation = bootstrapConversation({
277
+ conversationType: "scheduled",
278
+ source: "schedule",
279
+ scheduleJobId: job.id,
280
+ groupId: "system:scheduled",
281
+ origin: "schedule",
282
+ systemHint: isOneShot
283
+ ? `Reminder: ${job.name}`
284
+ : `Schedule: ${job.name}`,
285
+ });
286
+ conversationId = conversation.id;
287
+ }
268
288
  onScheduleConversationCreated?.({
269
- conversationId: conversation.id,
289
+ conversationId,
270
290
  scheduleJobId: job.id,
271
291
  title: job.name,
272
292
  });
273
- const runId = createScheduleRun(job.id, conversation.id);
293
+ const runId = createScheduleRun(job.id, conversationId);
274
294
  const isRruleSetMsg =
275
295
  job.syntax === "rrule" &&
276
296
  job.expression != null &&
@@ -285,11 +305,11 @@ async function runScheduleOnce(
285
305
  expression: job.expression,
286
306
  isRruleSet: isRruleSetMsg,
287
307
  isOneShot,
288
- conversationId: conversation.id,
308
+ conversationId,
289
309
  },
290
310
  isOneShot ? "Executing one-shot schedule" : "Executing schedule",
291
311
  );
292
- await processMessage(conversation.id, job.message, {
312
+ await processMessage(conversationId, job.message, {
293
313
  trustClass: "guardian",
294
314
  });
295
315
  completeScheduleRun(runId, { status: "ok" });
@@ -317,13 +337,20 @@ async function runScheduleOnce(
317
337
  completeScheduleRun(runId, { status: "error", error: message });
318
338
  if (isOneShot) failOneShot(job.id);
319
339
 
320
- try {
321
- invalidateAssistantInferredItemsForConversation(conversation.id);
322
- } catch (cleanupErr) {
323
- log.warn(
324
- { err: cleanupErr, conversationId: conversation.id },
325
- "Failed to invalidate assistant-inferred memory items",
326
- );
340
+ // Only skip invalidation when the conversation was *actually* reused,
341
+ // i.e. it contains prior successful context worth preserving. When
342
+ // reuseConversation is true but no prior conversation existed (first run
343
+ // or deleted), the conversation is brand-new and should be invalidated
344
+ // like any other failed conversation.
345
+ if (!conversationReused) {
346
+ try {
347
+ invalidateAssistantInferredItemsForConversation(conversationId);
348
+ } catch (cleanupErr) {
349
+ log.warn(
350
+ { err: cleanupErr, conversationId },
351
+ "Failed to invalidate assistant-inferred memory items",
352
+ );
353
+ }
327
354
  }
328
355
  }
329
356
  }
@@ -14,10 +14,10 @@ import { dirname, join, posix, resolve, sep } from "node:path";
14
14
  import { gunzipSync } from "node:zlib";
15
15
 
16
16
  import { getPlatformBaseUrl } from "../config/env.js";
17
+ import { deleteSkillCapabilityNode } from "../memory/graph/capability-seed.js";
17
18
  import { getLogger } from "../util/logger.js";
18
19
  import { getWorkspaceSkillsDir, readPlatformToken } from "../util/platform.js";
19
20
  import { computeSkillHash, writeInstallMeta } from "./install-meta.js";
20
- import { deleteSkillCapabilityMemory } from "./skill-memory.js";
21
21
 
22
22
  const log = getLogger("catalog-install");
23
23
 
@@ -264,7 +264,7 @@ export function uninstallSkillLocally(skillId: string): void {
264
264
 
265
265
  rmSync(skillDir, { recursive: true, force: true });
266
266
  removeSkillsIndexEntry(skillId);
267
- deleteSkillCapabilityMemory(skillId);
267
+ deleteSkillCapabilityNode(skillId);
268
268
  }
269
269
 
270
270
  export async function installSkillLocally(
@@ -392,6 +392,14 @@ export async function autoInstallFromCatalog(
392
392
  return false;
393
393
  }
394
394
 
395
+ // If the skill already exists on disk (stale index), re-index it instead
396
+ // of attempting a fresh install that would fail.
397
+ const skillDir = join(getWorkspaceSkillsDir(), skillId);
398
+ if (existsSync(join(skillDir, "SKILL.md"))) {
399
+ upsertSkillsIndex(skillId);
400
+ return true;
401
+ }
402
+
395
403
  // installSkillLocally handles dependency installation and SKILLS.md indexing.
396
404
  await installSkillLocally(skillId, entry, false);
397
405
 
@@ -11,10 +11,10 @@ import { dirname, join } from "node:path";
11
11
 
12
12
  import { stringify as stringifyYaml } from "yaml";
13
13
 
14
+ import { deleteSkillCapabilityNode } from "../memory/graph/capability-seed.js";
14
15
  import { getLogger } from "../util/logger.js";
15
16
  import { getWorkspaceSkillsDir } from "../util/platform.js";
16
17
  import { writeInstallMeta } from "./install-meta.js";
17
- import { deleteSkillCapabilityMemory } from "./skill-memory.js";
18
18
 
19
19
  const log = getLogger("managed-store");
20
20
 
@@ -319,7 +319,7 @@ export function deleteManagedSkill(
319
319
  }
320
320
 
321
321
  rmSync(skillDir, { recursive: true });
322
- deleteSkillCapabilityMemory(id);
322
+ deleteSkillCapabilityNode(id);
323
323
  log.info({ id, path: skillDir }, "Deleted managed skill");
324
324
 
325
325
  let indexUpdated = false;