@vellumai/assistant 0.8.2 → 0.8.3

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 (231) hide show
  1. package/ARCHITECTURE.md +11 -12
  2. package/docker-entrypoint.sh +13 -1
  3. package/docker-init-apt-root.sh +79 -6
  4. package/openapi.yaml +336 -21
  5. package/package.json +1 -1
  6. package/src/__tests__/agent-loop-exit-reason.test.ts +272 -0
  7. package/src/__tests__/agent-loop-provider-error-recording.test.ts +195 -0
  8. package/src/__tests__/compactor-tail-resolution.test.ts +107 -1
  9. package/src/__tests__/config-get-vision-flag.test.ts +136 -0
  10. package/src/__tests__/config-loader-backfill.test.ts +115 -18
  11. package/src/__tests__/context-token-estimator.test.ts +30 -65
  12. package/src/__tests__/conversation-agent-loop.test.ts +57 -1
  13. package/src/__tests__/conversation-media-retry.test.ts +19 -8
  14. package/src/__tests__/conversation-runtime-assembly.test.ts +26 -4
  15. package/src/__tests__/date-context.test.ts +45 -0
  16. package/src/__tests__/external-plugin-loader.test.ts +91 -19
  17. package/src/__tests__/guardian-action-no-hardcoded-copy.test.ts +0 -1
  18. package/src/__tests__/guardian-dispatch.test.ts +1 -0
  19. package/src/__tests__/heartbeat-service.test.ts +24 -164
  20. package/src/__tests__/helpers/channel-test-adapter.ts +0 -2
  21. package/src/__tests__/host-app-control-proxy.test.ts +241 -0
  22. package/src/__tests__/host-proxy-preactivation.test.ts +200 -13
  23. package/src/__tests__/injector-background-turn.test.ts +153 -0
  24. package/src/__tests__/injector-chain.test.ts +5 -0
  25. package/src/__tests__/lifecycle-memory-v2-seed.test.ts +9 -2
  26. package/src/__tests__/llm-callsite-catalog.test.ts +25 -0
  27. package/src/__tests__/llm-catalog-parity.test.ts +3 -0
  28. package/src/__tests__/llm-request-log-agent-loop-exit-reason.test.ts +116 -0
  29. package/src/__tests__/llm-request-log-error-payload.test.ts +138 -0
  30. package/src/__tests__/llm-request-log-source-clickhouse.test.ts +2 -0
  31. package/src/__tests__/llm-resolver.test.ts +255 -2
  32. package/src/__tests__/managed-profile-guard.test.ts +10 -0
  33. package/src/__tests__/notification-decision-fallback.test.ts +0 -91
  34. package/src/__tests__/notification-decision-strategy.test.ts +14 -31
  35. package/src/__tests__/notification-deep-link.test.ts +15 -0
  36. package/src/__tests__/notification-guardian-path.test.ts +1 -2
  37. package/src/__tests__/notification-platform-adapter.test.ts +5 -4
  38. package/src/__tests__/notification-telegram-adapter.test.ts +1 -0
  39. package/src/__tests__/notification-vellum-adapter.test.ts +113 -0
  40. package/src/__tests__/openai-provider.test.ts +218 -3
  41. package/src/__tests__/openai-responses-cutover-guard.test.ts +3 -3
  42. package/src/__tests__/openrouter-provider-only.test.ts +51 -3
  43. package/src/__tests__/openrouter-token-estimation.test.ts +34 -25
  44. package/src/__tests__/platform-proxy-context.test.ts +6 -1
  45. package/src/__tests__/plugin-tool-contribution.test.ts +3 -3
  46. package/src/__tests__/plugin-types.test.ts +2 -2
  47. package/src/__tests__/provider-catalog-visibility.test.ts +16 -0
  48. package/src/__tests__/provider-platform-proxy-integration.test.ts +27 -25
  49. package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -1
  50. package/src/__tests__/system-prompt.test.ts +6 -73
  51. package/src/__tests__/workspace-migration-087-memory-router-balanced-profile.test.ts +228 -0
  52. package/src/a2a/__tests__/agent-card.test.ts +98 -0
  53. package/src/a2a/__tests__/e2e-a2a-channel.test.ts +597 -0
  54. package/src/a2a/__tests__/protocol-helpers.test.ts +113 -0
  55. package/src/a2a/__tests__/task-store.test.ts +246 -0
  56. package/src/a2a/agent-card.ts +58 -0
  57. package/src/a2a/feature-gate.ts +8 -0
  58. package/src/a2a/protocol-constants.ts +21 -0
  59. package/src/a2a/protocol-errors.ts +50 -0
  60. package/src/a2a/protocol-types.ts +162 -0
  61. package/src/a2a/task-store.ts +168 -0
  62. package/src/agent/loop.ts +167 -18
  63. package/src/channels/config.ts +9 -0
  64. package/src/channels/types.ts +14 -0
  65. package/src/cli/{__tests__ → commands/__tests__}/notifications.test.ts +201 -28
  66. package/src/cli/commands/__tests__/schedules.test.ts +469 -0
  67. package/src/cli/commands/notifications.ts +65 -35
  68. package/src/cli/commands/plugins.ts +67 -0
  69. package/src/cli/commands/schedules.ts +297 -5
  70. package/src/cli/lib/__tests__/search-plugins.test.ts +261 -0
  71. package/src/cli/lib/install-from-github.ts +8 -9
  72. package/src/cli/lib/search-plugins.ts +163 -0
  73. package/src/cli/program.ts +14 -0
  74. package/src/config/assistant-feature-flags.ts +24 -54
  75. package/src/config/bundled-skills/app-builder/SKILL.md +117 -1
  76. package/src/config/bundled-skills/phone-calls/SKILL.md +1 -1
  77. package/src/config/call-site-defaults.ts +105 -0
  78. package/src/config/feature-flag-registry.json +21 -29
  79. package/src/config/llm-resolver.ts +52 -1
  80. package/src/config/schema.ts +2 -0
  81. package/src/config/schemas/__tests__/memory-v2.test.ts +3 -3
  82. package/src/config/schemas/channels.ts +9 -0
  83. package/src/config/schemas/conversations.ts +10 -0
  84. package/src/config/schemas/heartbeat.ts +14 -0
  85. package/src/config/schemas/llm.ts +1 -3
  86. package/src/config/schemas/memory-retrospective.ts +1 -1
  87. package/src/config/schemas/memory-v2.ts +4 -4
  88. package/src/config/schemas/memory.ts +3 -1
  89. package/src/config/seed-inference-profiles.ts +99 -29
  90. package/src/context/compactor.ts +72 -12
  91. package/src/context/token-estimator.ts +32 -34
  92. package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +3 -22
  93. package/src/daemon/conversation-agent-loop-handlers.ts +78 -0
  94. package/src/daemon/conversation-agent-loop.ts +29 -2
  95. package/src/daemon/conversation-runtime-assembly.ts +9 -0
  96. package/src/daemon/conversation.ts +0 -7
  97. package/src/daemon/date-context.ts +40 -0
  98. package/src/daemon/guardian-action-generators.ts +1 -125
  99. package/src/daemon/handlers/__tests__/config-a2a-complete.test.ts +248 -0
  100. package/src/daemon/handlers/__tests__/config-a2a-invite.test.ts +154 -0
  101. package/src/daemon/handlers/__tests__/config-a2a-redeem.test.ts +133 -0
  102. package/src/daemon/handlers/__tests__/config-a2a.test.ts +95 -0
  103. package/src/daemon/handlers/config-a2a.ts +289 -0
  104. package/src/daemon/handlers/conversations.ts +1 -0
  105. package/src/daemon/host-app-control-proxy.ts +69 -18
  106. package/src/daemon/host-proxy-preactivation.ts +85 -18
  107. package/src/daemon/lifecycle.ts +49 -61
  108. package/src/daemon/memory-v2-startup.ts +49 -13
  109. package/src/daemon/message-types/notifications.ts +21 -0
  110. package/src/daemon/pkb-reminder-builder.test.ts +10 -53
  111. package/src/daemon/pkb-reminder-builder.ts +4 -19
  112. package/src/daemon/process-message.ts +3 -0
  113. package/src/daemon/skill-memory-refresh.ts +5 -1
  114. package/src/daemon/wake-target-adapter.ts +2 -0
  115. package/src/export/__tests__/transcript-formatter.test.ts +121 -0
  116. package/src/export/transcript-formatter.ts +54 -20
  117. package/src/heartbeat/__tests__/heartbeat-service.test.ts +44 -0
  118. package/src/heartbeat/heartbeat-service.ts +34 -191
  119. package/src/home/__tests__/feed-types.test.ts +40 -0
  120. package/src/home/feed-types.ts +14 -2
  121. package/src/ipc/cli-client.ts +147 -45
  122. package/src/memory/__tests__/conversation-queries.test.ts +220 -0
  123. package/src/memory/__tests__/memory-retrospective-enqueue.test.ts +2 -50
  124. package/src/memory/__tests__/memory-retrospective-job.test.ts +87 -4
  125. package/src/memory/conversation-queries.ts +87 -1
  126. package/src/memory/conversation-title-service.ts +26 -4
  127. package/src/memory/db-init.ts +6 -0
  128. package/src/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +84 -3
  129. package/src/memory/graph/conversation-graph-memory.ts +18 -6
  130. package/src/memory/graph/tools.ts +6 -37
  131. package/src/memory/invite-store.ts +53 -0
  132. package/src/memory/llm-request-log-source-clickhouse.ts +7 -2
  133. package/src/memory/llm-request-log-store.ts +92 -1
  134. package/src/memory/memory-retrospective-enqueue.ts +1 -20
  135. package/src/memory/memory-retrospective-job.ts +33 -6
  136. package/src/memory/migrations/250-provider-connection-base-url-and-models.ts +28 -0
  137. package/src/memory/migrations/251-a2a-tasks.ts +49 -0
  138. package/src/memory/migrations/252-llm-request-log-agent-loop-exit-reason.ts +32 -0
  139. package/src/memory/migrations/index.ts +3 -0
  140. package/src/memory/migrations/registry.ts +8 -0
  141. package/src/memory/schema/a2a.ts +15 -0
  142. package/src/memory/schema/index.ts +1 -0
  143. package/src/memory/schema/inference.ts +2 -0
  144. package/src/memory/schema/infrastructure.ts +1 -0
  145. package/src/memory/v2/__tests__/activation-store.test.ts +25 -23
  146. package/src/memory/v2/__tests__/cli-command-store.test.ts +404 -0
  147. package/src/memory/v2/__tests__/frontmatter-sweep.test.ts +25 -4
  148. package/src/memory/v2/__tests__/injection.test.ts +190 -3
  149. package/src/memory/v2/__tests__/static-context.test.ts +12 -1
  150. package/src/memory/v2/activation-store.ts +14 -16
  151. package/src/memory/v2/cli-command-content.ts +19 -0
  152. package/src/memory/v2/cli-command-store.ts +304 -0
  153. package/src/memory/v2/frontmatter-sweep.ts +7 -1
  154. package/src/memory/v2/injection.ts +49 -20
  155. package/src/memory/v2/page-index.ts +38 -13
  156. package/src/memory/v2/static-context.ts +4 -4
  157. package/src/memory/v2/types.ts +23 -0
  158. package/src/messaging/providers/a2a/__tests__/deliver.test.ts +274 -0
  159. package/src/messaging/providers/a2a/deliver.ts +156 -0
  160. package/src/messaging/providers/gmail/client.ts +9 -2
  161. package/src/messaging/providers/index.ts +11 -2
  162. package/src/notifications/__tests__/broadcaster.test.ts +203 -0
  163. package/src/notifications/__tests__/decision-engine.test.ts +283 -0
  164. package/src/notifications/__tests__/deterministic-checks.test.ts +286 -0
  165. package/src/notifications/__tests__/emit-signal-home-feed.test.ts +1 -0
  166. package/src/notifications/__tests__/home-feed-side-effect.test.ts +430 -7
  167. package/src/notifications/adapters/macos.ts +12 -2
  168. package/src/notifications/broadcaster.ts +29 -4
  169. package/src/notifications/copy-composer.ts +17 -64
  170. package/src/notifications/decision-engine.ts +111 -44
  171. package/src/notifications/deterministic-checks.ts +96 -0
  172. package/src/notifications/emit-signal.ts +1 -0
  173. package/src/notifications/home-feed-side-effect.ts +85 -6
  174. package/src/notifications/signal.ts +0 -4
  175. package/src/notifications/types.ts +8 -0
  176. package/src/oauth/platform-connection.test.ts +43 -3
  177. package/src/oauth/platform-connection.ts +13 -4
  178. package/src/plugins/defaults/injectors.ts +38 -19
  179. package/src/plugins/external-plugin-loader.ts +82 -10
  180. package/src/plugins/types.ts +16 -7
  181. package/src/prompts/__tests__/system-prompt.test.ts +6 -51
  182. package/src/prompts/__tests__/task-progress-hint-section.test.ts +4 -8
  183. package/src/prompts/system-prompt.ts +0 -8
  184. package/src/prompts/templates/BOOTSTRAP.md +5 -5
  185. package/src/prompts/templates/system-sections.ts +0 -9
  186. package/src/providers/__tests__/inference.test.ts +2 -0
  187. package/src/providers/call-site-routing.ts +24 -6
  188. package/src/providers/connection-resolution.ts +63 -13
  189. package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +74 -0
  190. package/src/providers/inference/__tests__/connections-openai-compatible.test.ts +175 -0
  191. package/src/providers/inference/__tests__/connections-status-label.test.ts +15 -0
  192. package/src/providers/inference/adapter-factory.ts +9 -20
  193. package/src/providers/inference/auth.ts +12 -0
  194. package/src/providers/inference/backfill.ts +14 -1
  195. package/src/providers/inference/connections.ts +85 -5
  196. package/src/providers/inference/resolve-auth.ts +2 -0
  197. package/src/providers/model-catalog.ts +199 -244
  198. package/src/providers/model-intents.ts +3 -3
  199. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +235 -0
  200. package/src/providers/openai/chat-completions-provider.ts +159 -6
  201. package/src/providers/openrouter/client.ts +42 -4
  202. package/src/providers/platform-proxy/constants.ts +3 -4
  203. package/src/providers/provider-catalog-visibility.ts +3 -1
  204. package/src/providers/provider-send-message.ts +27 -12
  205. package/src/providers/registry.ts +30 -1
  206. package/src/runtime/agent-wake.ts +61 -1
  207. package/src/runtime/auth/route-policy.ts +13 -0
  208. package/src/runtime/http-server.ts +7 -16
  209. package/src/runtime/http-types.ts +0 -47
  210. package/src/runtime/routes/__tests__/consolidation-routes.test.ts +258 -0
  211. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +66 -4
  212. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +275 -44
  213. package/src/runtime/routes/__tests__/llm-call-sites-routes.test.ts +12 -0
  214. package/src/runtime/routes/channel-availability-routes.ts +5 -0
  215. package/src/runtime/routes/consolidation-routes.ts +100 -0
  216. package/src/runtime/routes/conversation-query-routes.ts +70 -11
  217. package/src/runtime/routes/conversation-routes.ts +7 -0
  218. package/src/runtime/routes/index.ts +2 -0
  219. package/src/runtime/routes/inference-provider-connection-routes.ts +134 -1
  220. package/src/runtime/routes/integrations/a2a.ts +235 -0
  221. package/src/runtime/routes/llm-call-sites-routes.ts +11 -1
  222. package/src/runtime/routes/subagents-routes.ts +41 -0
  223. package/src/subagent/manager.ts +2 -0
  224. package/src/tools/memory/register.ts +1 -9
  225. package/src/tools/registry.ts +2 -2
  226. package/src/tools/types.ts +37 -2
  227. package/src/workspace/migrations/087-memory-router-balanced-profile.ts +91 -0
  228. package/src/workspace/migrations/registry.ts +2 -0
  229. package/src/__tests__/guardian-action-conversation-turn.test.ts +0 -441
  230. package/src/memory/graph/__tests__/remember-description.test.ts +0 -55
  231. package/src/runtime/guardian-action-conversation-turn.ts +0 -99
@@ -38,6 +38,10 @@ import {
38
38
  spreadActivation,
39
39
  } from "./activation.js";
40
40
  import { hydrate, save } from "./activation-store.js";
41
+ import {
42
+ getCliCommandCapability,
43
+ isCliCommandSlug,
44
+ } from "./cli-command-store.js";
41
45
  import { getEdgeIndex } from "./edge-index.js";
42
46
  import { readPage, renderPageContent } from "./page-store.js";
43
47
  import { runRouter } from "./router.js";
@@ -355,20 +359,22 @@ async function finalizeInjection(args: {
355
359
  // on that user message and the agent keeps seeing it across subsequent turns
356
360
  // until compaction evicts the turn.
357
361
  //
358
- // Skill slugs whose in-process cache entry is missing (e.g. startup race
359
- // between the skill seed and the first turn, or stale Qdrant index pointing
360
- // at an uninstalled skill) are excluded from `everInjected` so future
361
- // per-turn runs re-attempt attachment once the cache is populated. Without
362
- // this, the slug would be marked injected even though `renderInjectionBlock`
363
- // silently dropped it.
364
- const missingSkillSlugs = new Set(
362
+ // Synthetic slugs (skills, CLI commands) whose in-process cache entry is
363
+ // missing (e.g. startup race between the seed and the first turn, or stale
364
+ // Qdrant index pointing at an uninstalled skill / removed CLI command) are
365
+ // excluded from `everInjected` so future per-turn runs re-attempt attachment
366
+ // once the cache is populated. Without this, the slug would be marked
367
+ // injected even though `renderInjectionBlock` silently dropped it.
368
+ const missingSyntheticSlugs = new Set(
365
369
  slugsToRender.filter(
366
- (slug) => isSkillSlug(slug) && !getSkillCapability(slug),
370
+ (slug) =>
371
+ (isSkillSlug(slug) && !getSkillCapability(slug)) ||
372
+ (isCliCommandSlug(slug) && !getCliCommandCapability(slug)),
367
373
  ),
368
374
  );
369
375
  const everInjectedSet = new Set(priorEverInjected.map((entry) => entry.slug));
370
376
  const newlyInjected = slugsToRender.filter(
371
- (slug) => !everInjectedSet.has(slug) && !missingSkillSlugs.has(slug),
377
+ (slug) => !everInjectedSet.has(slug) && !missingSyntheticSlugs.has(slug),
372
378
  );
373
379
  const nextEverInjected: EverInjectedEntry[] = [
374
380
  ...priorEverInjected,
@@ -728,18 +734,18 @@ const INJECTION_HEADER =
728
734
  * distinguish "file vanished" (stale index) from "file is malformed"
729
735
  * (data-corruption / programmer error).
730
736
  *
731
- * Skill slugs whose entry the cache no longer knows (e.g. uninstalled
732
- * mid-run) are silently dropped, mirroring the missing-pages behavior but
733
- * without entering `missingSlugs` the skill catalog is the source of
734
- * truth for skill availability, not on-disk concept pages, so a missing
735
- * skill is an expected catalog-level outcome rather than a stale-index
736
- * bug.
737
+ * Skill and CLI-command slugs whose entry the in-process cache no longer
738
+ * knows (e.g. uninstalled mid-run, or a CLI command removed between seeds)
739
+ * are silently dropped, mirroring the missing-pages behavior but without
740
+ * entering `missingSlugs` the synthetic catalogs are the source of truth
741
+ * for those entries, not on-disk concept pages.
737
742
  *
738
743
  * Each concept-page section is rendered as a path header followed by either
739
744
  * the page's `summary` (when present in frontmatter) or the full page (the
740
- * fallback for pages predating the summary field). Skills sit at the end
741
- * under `### Skills You Can Use`, unchanged. The leading `**CRITICAL:**`
742
- * line tells the agent how to read the block.
745
+ * fallback for pages predating the summary field). Skills sit after the
746
+ * concept sections under `### Skills You Can Use`, and CLI subcommands sit
747
+ * after the skills under `### CLI Commands You Can Use`. The leading
748
+ * `**CRITICAL:**` line tells the agent how to read the block.
743
749
  *
744
750
  * **CRITICAL:** These are page summaries. Read the page file if it looks relevant.
745
751
  *
@@ -758,13 +764,24 @@ const INJECTION_HEADER =
758
764
  * ### Skills You Can Use
759
765
  * - <skill-1 content>
760
766
  * - <skill-2 content>
767
+ *
768
+ * ### CLI Commands You Can Use
769
+ * Run `assistant <command> --help` for full usage.
770
+ * - `assistant <name-1>`: <description-1>
771
+ * - `assistant <name-2>`: <description-2>
761
772
  */
762
773
  async function renderInjectionBlock(
763
774
  workspaceDir: string,
764
775
  slugs: string[],
765
776
  ): Promise<RenderInjectionBlockResult> {
766
- const conceptSlugs = slugs.filter((s) => !isSkillSlug(s));
767
- const skillSlugs = slugs.filter((s) => isSkillSlug(s));
777
+ const conceptSlugs: string[] = [];
778
+ const skillSlugs: string[] = [];
779
+ const cliCommandSlugs: string[] = [];
780
+ for (const slug of slugs) {
781
+ if (isSkillSlug(slug)) skillSlugs.push(slug);
782
+ else if (isCliCommandSlug(slug)) cliCommandSlugs.push(slug);
783
+ else conceptSlugs.push(slug);
784
+ }
768
785
 
769
786
  const settled = await Promise.allSettled(
770
787
  conceptSlugs.map((slug) => readPage(workspaceDir, slug)),
@@ -815,6 +832,18 @@ async function renderInjectionBlock(
815
832
  sections.push(`### Skills You Can Use\n${skillLines.join("\n")}`);
816
833
  }
817
834
 
835
+ const cliCommandLines: string[] = [];
836
+ for (const slug of cliCommandSlugs) {
837
+ const entry = getCliCommandCapability(slug);
838
+ if (!entry) continue;
839
+ cliCommandLines.push(`- \`assistant ${entry.id}\`: ${entry.description}`);
840
+ }
841
+ if (cliCommandLines.length > 0) {
842
+ sections.push(
843
+ `### CLI Commands You Can Use\nRun \`assistant <command> --help\` for full usage.\n${cliCommandLines.join("\n")}`,
844
+ );
845
+ }
846
+
818
847
  if (sections.length === 0) {
819
848
  return { block: null, missingSlugs, corruptSlugs };
820
849
  }
@@ -80,11 +80,12 @@ let cache: CachedIndex | null = null;
80
80
  /**
81
81
  * Return a `PageIndex` for `workspaceDir`. Cached module-locally; the cache
82
82
  * is invalidated by `invalidatePageIndex` (called by daemon-side hooks when
83
- * concept pages or skill entries change).
83
+ * concept pages, skill entries, or CLI-command entries change).
84
84
  *
85
85
  * Cold builds list every concept page in parallel, drop pages whose read
86
- * rejects, append seeded skill entries from `listSkillEntries()`, sort by
87
- * slug for deterministic IDs, then resolve outgoing edges to numeric IDs.
86
+ * rejects, append seeded skill entries from `listSkillEntries()` and CLI
87
+ * command entries from `listCliCommandEntries()`, sort by slug for
88
+ * deterministic IDs, then resolve outgoing edges to numeric IDs.
88
89
  */
89
90
  export async function getPageIndex(workspaceDir: string): Promise<PageIndex> {
90
91
  if (cache && cache.workspaceDir === workspaceDir) {
@@ -107,20 +108,29 @@ export async function getPageIndex(workspaceDir: string): Promise<PageIndex> {
107
108
  outgoingSlugs: string[];
108
109
  }
109
110
 
110
- const { listSkillEntries, SKILL_SLUG_PREFIX } =
111
- await import("./skill-store.js");
112
-
113
- // Build the skill-slug set first so we can drop colliding concept pages.
114
- // Collision policy: **skill entries win**. Skill rows are seeded from the
115
- // curated catalog and the router needs them to be reachable under their
116
- // canonical slugs; a hand-authored page sitting under `skills/<id>` is
117
- // either a stale leftover from a prior write or a user mistake. `bySlug`
118
- // is last-writer-wins, so without explicit dedupe one side would silently
119
- // shadow the other depending on iteration order.
111
+ const [
112
+ { listSkillEntries, SKILL_SLUG_PREFIX },
113
+ { listCliCommandEntries, CLI_COMMAND_SLUG_PREFIX },
114
+ ] = await Promise.all([
115
+ import("./skill-store.js"),
116
+ import("./cli-command-store.js"),
117
+ ]);
118
+
119
+ // Build the synthetic-slug sets first so we can drop colliding concept
120
+ // pages. Collision policy: **synthetic entries win**. Skill and CLI rows
121
+ // are seeded from authoritative in-process catalogs; a hand-authored page
122
+ // sitting under `skills/<id>` or `cli-commands/<name>` is either a stale
123
+ // leftover from a prior write or a user mistake. `bySlug` is last-writer-
124
+ // wins, so without explicit dedupe one side would silently shadow the
125
+ // other depending on iteration order.
120
126
  const skillEntries = listSkillEntries();
121
127
  const skillSlugs = new Set(
122
128
  skillEntries.map((entry) => `${SKILL_SLUG_PREFIX}${entry.id}`),
123
129
  );
130
+ const cliCommandEntries = listCliCommandEntries();
131
+ const cliCommandSlugs = new Set(
132
+ cliCommandEntries.map((entry) => `${CLI_COMMAND_SLUG_PREFIX}${entry.id}`),
133
+ );
124
134
 
125
135
  const drafts: DraftEntry[] = [];
126
136
  for (let i = 0; i < settled.length; i++) {
@@ -142,6 +152,13 @@ export async function getPageIndex(workspaceDir: string): Promise<PageIndex> {
142
152
  );
143
153
  continue;
144
154
  }
155
+ if (cliCommandSlugs.has(slug)) {
156
+ log.warn(
157
+ { slug },
158
+ "Dropping concept page from index — slug collides with a seeded CLI-command entry; CLI command wins",
159
+ );
160
+ continue;
161
+ }
145
162
  const summarySource = page.frontmatter.summary?.trim() || page.body.trim();
146
163
  drafts.push({
147
164
  slug,
@@ -158,6 +175,14 @@ export async function getPageIndex(workspaceDir: string): Promise<PageIndex> {
158
175
  });
159
176
  }
160
177
 
178
+ for (const entry of cliCommandEntries) {
179
+ drafts.push({
180
+ slug: `${CLI_COMMAND_SLUG_PREFIX}${entry.id}`,
181
+ summary: normalizeSummary(entry.description),
182
+ outgoingSlugs: [],
183
+ });
184
+ }
185
+
161
186
  drafts.sort((a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0));
162
187
 
163
188
  // Assign 1-based dense IDs in sort order so entries[i].id === i + 1.
@@ -35,9 +35,9 @@ const MEMORY_V2_STATIC_BLOCKS: readonly MemoryV2StaticBlock[] = [
35
35
  ];
36
36
 
37
37
  /**
38
- * Build the v2 static memory block, gated on `config.memory.v2.enabled`.
39
- * Empty/missing files are skipped; returns `null` when the gate is off or
40
- * every file is empty.
38
+ * Build the v2 static memory block, gated on `config.memory.enabled` and
39
+ * `config.memory.v2.enabled`. Empty/missing files are skipped; returns `null`
40
+ * when either gate is off or every file is empty.
41
41
  */
42
42
  export function readMemoryV2StaticContent(): string | null {
43
43
  let config;
@@ -46,7 +46,7 @@ export function readMemoryV2StaticContent(): string | null {
46
46
  } catch {
47
47
  return null;
48
48
  }
49
- if (!config.memory.v2.enabled) {
49
+ if (!config.memory.enabled || !config.memory.v2.enabled) {
50
50
  return null;
51
51
  }
52
52
 
@@ -115,3 +115,26 @@ export interface SkillEntry {
115
115
  id: string;
116
116
  content: string;
117
117
  }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // CLI-command entries (synthetic concept-collection rows, not on-disk pages)
121
+ // ---------------------------------------------------------------------------
122
+
123
+ /**
124
+ * Per-CLI-subcommand capability snapshot held in-process and embedded into the
125
+ * unified `memory_v2_concept_pages` Qdrant collection under the slug
126
+ * `cli-commands/<name>`. `content` is the full `helpInformation()` output for
127
+ * the top-level subcommand — the embedding target, intentionally uncapped so
128
+ * activation hints in flag descriptions and examples carry semantic weight.
129
+ * `description` is the one-line Commander description, rendered terse in
130
+ * `### CLI Commands You Can Use` so the injection block stays compact even
131
+ * for verbose `--help` outputs.
132
+ *
133
+ * Plain interface (no Zod) — same in-process-only justification as
134
+ * `SkillEntry`.
135
+ */
136
+ export interface CliCommandEntry {
137
+ id: string;
138
+ description: string;
139
+ content: string;
140
+ }
@@ -0,0 +1,274 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ import type { ChannelReplyPayload } from "@vellumai/gateway-client";
4
+
5
+ import type { A2ATask, Artifact } from "../../../../a2a/protocol-types.js";
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Mock state
9
+ // ---------------------------------------------------------------------------
10
+
11
+ let completedTask: A2ATask | null = null;
12
+ let completeWithArtifactsCalls: Array<{
13
+ taskId: string;
14
+ artifacts: Artifact[];
15
+ }> = [];
16
+ let pushUrlByTaskId: Record<string, string | null> = {};
17
+ let completeError: Error | null = null;
18
+
19
+ const fetchCalls: Array<{
20
+ url: string;
21
+ init: RequestInit;
22
+ }> = [];
23
+ let fetchResponses: Array<{ ok: boolean; status: number; body: string }> = [];
24
+ let fetchCallIndex = 0;
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Mocks
28
+ // ---------------------------------------------------------------------------
29
+
30
+ const defaultTask: A2ATask = {
31
+ id: "task-123",
32
+ status: { state: "completed", timestamp: new Date().toISOString() },
33
+ artifacts: [
34
+ {
35
+ artifact_id: "art-1",
36
+ parts: [{ kind: "text", text: "Hello from assistant" }],
37
+ },
38
+ ],
39
+ };
40
+
41
+ mock.module("../../../../a2a/task-store.js", () => ({
42
+ completeWithArtifacts: (taskId: string, artifacts: Artifact[]): A2ATask => {
43
+ completeWithArtifactsCalls.push({ taskId, artifacts });
44
+ if (completeError) throw completeError;
45
+ return completedTask ?? defaultTask;
46
+ },
47
+ getPushUrl: (taskId: string): string | null => {
48
+ return pushUrlByTaskId[taskId] ?? null;
49
+ },
50
+ }));
51
+
52
+ mock.module("../../../../util/logger.js", () => ({
53
+ getLogger: () => ({
54
+ debug: () => {},
55
+ info: () => {},
56
+ warn: () => {},
57
+ error: () => {},
58
+ }),
59
+ }));
60
+
61
+ // Intercept global fetch for push notification testing
62
+ const originalFetch = globalThis.fetch;
63
+
64
+ // Import the module under test AFTER mocks are set up
65
+ const { deliverA2AReply } = await import("../deliver.js");
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Setup / teardown
69
+ // ---------------------------------------------------------------------------
70
+
71
+ beforeEach(() => {
72
+ completedTask = null;
73
+ completeWithArtifactsCalls = [];
74
+ pushUrlByTaskId = {};
75
+ completeError = null;
76
+ fetchCalls.length = 0;
77
+ fetchResponses = [];
78
+ fetchCallIndex = 0;
79
+
80
+ globalThis.fetch = (async (
81
+ input: string | URL | Request,
82
+ init?: RequestInit,
83
+ ) => {
84
+ const url = typeof input === "string" ? input : input.toString();
85
+ fetchCalls.push({ url, init: init ?? {} });
86
+ const responseSpec = fetchResponses[fetchCallIndex++] ?? {
87
+ ok: true,
88
+ status: 200,
89
+ body: "{}",
90
+ };
91
+ return new Response(responseSpec.body, {
92
+ status: responseSpec.status,
93
+ statusText: responseSpec.ok ? "OK" : "Error",
94
+ });
95
+ }) as typeof fetch;
96
+ });
97
+
98
+ afterEach(() => {
99
+ globalThis.fetch = originalFetch;
100
+ });
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Tests
104
+ // ---------------------------------------------------------------------------
105
+
106
+ describe("deliverA2AReply", () => {
107
+ const baseCallbackUrl = "https://example.com/deliver/a2a?taskId=task-123";
108
+
109
+ test("completes task with text artifact", async () => {
110
+ const payload: ChannelReplyPayload = {
111
+ chatId: "chat-1",
112
+ text: "Hello from the assistant",
113
+ };
114
+
115
+ const result = await deliverA2AReply(baseCallbackUrl, payload);
116
+
117
+ expect(result.ok).toBe(true);
118
+ expect(completeWithArtifactsCalls).toHaveLength(1);
119
+ expect(completeWithArtifactsCalls[0].taskId).toBe("task-123");
120
+ expect(completeWithArtifactsCalls[0].artifacts).toHaveLength(1);
121
+ expect(completeWithArtifactsCalls[0].artifacts[0].parts).toEqual([
122
+ { kind: "text", text: "Hello from the assistant" },
123
+ ]);
124
+ });
125
+
126
+ test("completes task with file attachments", async () => {
127
+ const payload: ChannelReplyPayload = {
128
+ chatId: "chat-1",
129
+ text: "Here is a file",
130
+ attachments: [
131
+ {
132
+ id: "att-1",
133
+ filename: "report.pdf",
134
+ mimeType: "application/pdf",
135
+ sizeBytes: 1024,
136
+ kind: "file",
137
+ data: "data:application/pdf;base64,abc123",
138
+ },
139
+ ],
140
+ };
141
+
142
+ const result = await deliverA2AReply(baseCallbackUrl, payload);
143
+
144
+ expect(result.ok).toBe(true);
145
+ expect(completeWithArtifactsCalls).toHaveLength(1);
146
+ const parts = completeWithArtifactsCalls[0].artifacts[0].parts;
147
+ expect(parts).toHaveLength(2);
148
+ expect(parts[0]).toEqual({
149
+ kind: "text",
150
+ text: "Here is a file",
151
+ });
152
+ expect(parts[1]).toEqual({
153
+ kind: "file",
154
+ filename: "report.pdf",
155
+ media_type: "application/pdf",
156
+ url: "data:application/pdf;base64,abc123",
157
+ });
158
+ });
159
+
160
+ test("returns ok: false when taskId is missing from URL", async () => {
161
+ const result = await deliverA2AReply("https://example.com/deliver/a2a", {
162
+ chatId: "chat-1",
163
+ text: "Hello",
164
+ });
165
+
166
+ expect(result.ok).toBe(false);
167
+ expect(completeWithArtifactsCalls).toHaveLength(0);
168
+ });
169
+
170
+ test("returns ok: true when payload has no content", async () => {
171
+ const result = await deliverA2AReply(baseCallbackUrl, {
172
+ chatId: "chat-1",
173
+ });
174
+
175
+ expect(result.ok).toBe(true);
176
+ expect(completeWithArtifactsCalls).toHaveLength(0);
177
+ });
178
+
179
+ test("returns ok: false when task completion throws", async () => {
180
+ completeError = new Error("A2A task not found: task-123");
181
+
182
+ const result = await deliverA2AReply(baseCallbackUrl, {
183
+ chatId: "chat-1",
184
+ text: "Hello",
185
+ });
186
+
187
+ expect(result.ok).toBe(false);
188
+ });
189
+
190
+ test("returns ok: false when task is already terminal", async () => {
191
+ completeError = new Error(
192
+ 'Cannot transition task task-123 from terminal state "completed" to "completed"',
193
+ );
194
+
195
+ const result = await deliverA2AReply(baseCallbackUrl, {
196
+ chatId: "chat-1",
197
+ text: "Hello",
198
+ });
199
+
200
+ expect(result.ok).toBe(false);
201
+ });
202
+
203
+ describe("push notifications", () => {
204
+ test("POSTs completed task to push URL", async () => {
205
+ pushUrlByTaskId["task-123"] = "https://requester.example.com/push";
206
+ fetchResponses = [{ ok: true, status: 200, body: "{}" }];
207
+
208
+ const result = await deliverA2AReply(baseCallbackUrl, {
209
+ chatId: "chat-1",
210
+ text: "Done",
211
+ });
212
+
213
+ expect(result.ok).toBe(true);
214
+
215
+ // Wait for the fire-and-forget push to complete
216
+ await new Promise((r) => setTimeout(r, 50));
217
+
218
+ expect(fetchCalls).toHaveLength(1);
219
+ expect(fetchCalls[0].url).toBe("https://requester.example.com/push");
220
+ expect(fetchCalls[0].init.method).toBe("POST");
221
+
222
+ const headers = fetchCalls[0].init.headers as Record<string, string>;
223
+ expect(headers["Content-Type"]).toBe("application/a2a+json");
224
+ expect(headers["A2A-Version"]).toBe("1.0");
225
+ });
226
+
227
+ test("does not push when no push URL configured", async () => {
228
+ const result = await deliverA2AReply(baseCallbackUrl, {
229
+ chatId: "chat-1",
230
+ text: "Done",
231
+ });
232
+
233
+ expect(result.ok).toBe(true);
234
+
235
+ await new Promise((r) => setTimeout(r, 50));
236
+
237
+ expect(fetchCalls).toHaveLength(0);
238
+ });
239
+
240
+ test("push failure does not affect delivery result", async () => {
241
+ pushUrlByTaskId["task-123"] = "https://requester.example.com/push";
242
+ // All retries fail with 500
243
+ fetchResponses = Array(4).fill({
244
+ ok: false,
245
+ status: 500,
246
+ body: "Internal Server Error",
247
+ });
248
+
249
+ const result = await deliverA2AReply(baseCallbackUrl, {
250
+ chatId: "chat-1",
251
+ text: "Done",
252
+ });
253
+
254
+ // Delivery still succeeds even though push will fail
255
+ expect(result.ok).toBe(true);
256
+ });
257
+
258
+ test("stops retrying on non-retryable client error", async () => {
259
+ pushUrlByTaskId["task-123"] = "https://requester.example.com/push";
260
+ fetchResponses = [{ ok: false, status: 404, body: "Not Found" }];
261
+
262
+ await deliverA2AReply(baseCallbackUrl, {
263
+ chatId: "chat-1",
264
+ text: "Done",
265
+ });
266
+
267
+ // Wait for the fire-and-forget push to settle
268
+ await new Promise((r) => setTimeout(r, 50));
269
+
270
+ // Should only attempt once on a 4xx (non-429) error
271
+ expect(fetchCalls).toHaveLength(1);
272
+ });
273
+ });
274
+ });
@@ -0,0 +1,156 @@
1
+ /**
2
+ * A2A direct delivery adapter.
3
+ *
4
+ * Completes an A2A task with response artifacts and optionally POSTs the
5
+ * completed task to the requester's push notification URL.
6
+ */
7
+
8
+ import type {
9
+ ChannelDeliveryResult,
10
+ ChannelReplyPayload,
11
+ } from "@vellumai/gateway-client";
12
+
13
+ import {
14
+ A2A_CONTENT_TYPE,
15
+ A2A_VERSION,
16
+ A2A_VERSION_HEADER,
17
+ } from "../../../a2a/protocol-constants.js";
18
+ import type { Part } from "../../../a2a/protocol-types.js";
19
+ import * as taskStore from "../../../a2a/task-store.js";
20
+ import { getLogger } from "../../../util/logger.js";
21
+ import {
22
+ computeRetryDelay,
23
+ isRetryableStatus,
24
+ sleep,
25
+ } from "../../../util/retry.js";
26
+
27
+ const log = getLogger("a2a-deliver");
28
+
29
+ const MAX_RETRIES = 3;
30
+ const PUSH_TIMEOUT_MS = 15_000;
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Helpers
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /** Extract the `taskId` query parameter from a callback URL. */
37
+ function parseTaskId(callbackUrl: string): string | null {
38
+ try {
39
+ return new URL(callbackUrl).searchParams.get("taskId");
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ /** Build A2A parts from a channel reply payload. */
46
+ function buildParts(payload: ChannelReplyPayload): Part[] {
47
+ const parts: Part[] = [];
48
+
49
+ if (payload.text) {
50
+ parts.push({ kind: "text", text: payload.text });
51
+ }
52
+
53
+ if (payload.attachments) {
54
+ for (const att of payload.attachments) {
55
+ parts.push({
56
+ kind: "file",
57
+ filename: att.filename,
58
+ media_type: att.mimeType,
59
+ url: att.data,
60
+ });
61
+ }
62
+ }
63
+
64
+ return parts;
65
+ }
66
+
67
+ /** POST the completed task to the requester's push URL with retry. */
68
+ async function pushNotification(
69
+ pushUrl: string,
70
+ taskJson: unknown,
71
+ ): Promise<void> {
72
+ let lastError: Error | null = null;
73
+
74
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
75
+ if (attempt > 0) {
76
+ await sleep(computeRetryDelay(attempt - 1));
77
+ }
78
+
79
+ try {
80
+ const response = await fetch(pushUrl, {
81
+ method: "POST",
82
+ headers: {
83
+ "Content-Type": A2A_CONTENT_TYPE,
84
+ [A2A_VERSION_HEADER]: A2A_VERSION,
85
+ },
86
+ body: JSON.stringify(taskJson),
87
+ signal: AbortSignal.timeout(PUSH_TIMEOUT_MS),
88
+ });
89
+
90
+ if (response.ok) return;
91
+
92
+ const body = await response.text().catch(() => "");
93
+ lastError = new Error(
94
+ `Push notification failed with status ${response.status}: ${body}`,
95
+ );
96
+
97
+ if (!isRetryableStatus(response.status)) {
98
+ break;
99
+ }
100
+ } catch (err) {
101
+ lastError = err instanceof Error ? err : new Error(String(err));
102
+ }
103
+ }
104
+
105
+ // Push failure is logged but doesn't propagate
106
+ log.warn(
107
+ { pushUrl, error: lastError?.message },
108
+ "A2A push notification failed after retries",
109
+ );
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Public API
114
+ // ---------------------------------------------------------------------------
115
+
116
+ /** Deliver an assistant reply as an A2A task completion. */
117
+ export async function deliverA2AReply(
118
+ callbackUrl: string,
119
+ payload: ChannelReplyPayload,
120
+ ): Promise<ChannelDeliveryResult> {
121
+ const taskId = parseTaskId(callbackUrl);
122
+ if (!taskId) {
123
+ return { ok: false };
124
+ }
125
+
126
+ const parts = buildParts(payload);
127
+ if (parts.length === 0) {
128
+ log.debug({ taskId }, "No content to deliver; skipping A2A completion");
129
+ return { ok: true };
130
+ }
131
+
132
+ let completedTask;
133
+ try {
134
+ completedTask = taskStore.completeWithArtifacts(taskId, [
135
+ { artifact_id: crypto.randomUUID(), parts },
136
+ ]);
137
+ } catch (err) {
138
+ const message = err instanceof Error ? err.message : String(err);
139
+ log.error({ taskId, error: message }, "Failed to complete A2A task");
140
+ return { ok: false };
141
+ }
142
+
143
+ // Push notification — fire-and-forget
144
+ const pushUrl = taskStore.getPushUrl(taskId);
145
+ if (pushUrl) {
146
+ pushNotification(pushUrl, completedTask).catch((err) => {
147
+ log.error(
148
+ { taskId, pushUrl, error: String(err) },
149
+ "Unexpected push notification error",
150
+ );
151
+ });
152
+ }
153
+
154
+ log.info({ taskId }, "A2A reply delivered");
155
+ return { ok: true };
156
+ }