@vellumai/assistant 0.11.1 → 0.11.2-staging.2

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 (318) hide show
  1. package/Dockerfile +1 -3
  2. package/README.md +1 -1
  3. package/eslint.config.mjs +28 -8
  4. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
  6. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  8. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
  9. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
  10. package/node_modules/@vellumai/service-contracts/package.json +1 -0
  11. package/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
  12. package/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
  13. package/node_modules/@vellumai/slack-text/src/index.test.ts +48 -0
  14. package/node_modules/@vellumai/slack-text/src/index.ts +25 -6
  15. package/openapi.yaml +292 -5
  16. package/package.json +1 -1
  17. package/src/__tests__/agent-loop-resume-interrupted.test.ts +223 -0
  18. package/src/__tests__/byok-default-profile-ensure.test.ts +43 -0
  19. package/src/__tests__/cli-logger-boundary-guard.test.ts +87 -0
  20. package/src/__tests__/compaction-events.test.ts +139 -2
  21. package/src/__tests__/config-loader-backfill.test.ts +35 -3
  22. package/src/__tests__/config-schema.test.ts +1 -0
  23. package/src/__tests__/conversation-agent-loop-fatal-cleanup.test.ts +30 -0
  24. package/src/__tests__/conversation-agent-loop.test.ts +180 -7
  25. package/src/__tests__/conversation-error.test.ts +211 -4
  26. package/src/__tests__/conversation-options-turn-scoped-transport.test.ts +64 -0
  27. package/src/__tests__/conversation-queue.test.ts +126 -0
  28. package/src/__tests__/conversation-retry-route.test.ts +46 -7
  29. package/src/__tests__/conversation-slash.test.ts +4 -2
  30. package/src/__tests__/conversation-summarize-route.test.ts +7 -1
  31. package/src/__tests__/conversation-summarize-up-to.test.ts +71 -5
  32. package/src/__tests__/document-create-dedupe.test.ts +2 -1
  33. package/src/__tests__/document-find-replace.test.ts +2 -1
  34. package/src/__tests__/document-tool-security.test.ts +2 -1
  35. package/src/__tests__/document-update-default-surface.test.ts +2 -1
  36. package/src/__tests__/document-workspace-file.test.ts +467 -0
  37. package/src/__tests__/emit-signal-routing-intent.test.ts +290 -49
  38. package/src/__tests__/guardian-card-withdrawal.test.ts +91 -3
  39. package/src/__tests__/http-user-message-parity.test.ts +66 -0
  40. package/src/__tests__/list-messages-attachments.test.ts +87 -0
  41. package/src/__tests__/list-messages-provider-error.test.ts +143 -0
  42. package/src/__tests__/list-messages-system-card.test.ts +100 -0
  43. package/src/__tests__/llm-resolver.test.ts +19 -9
  44. package/src/__tests__/managed-profile-guard.test.ts +23 -0
  45. package/src/__tests__/notification-platform-adapter.test.ts +130 -2
  46. package/src/__tests__/notification-telegram-adapter.test.ts +6 -0
  47. package/src/__tests__/notification-vellum-adapter.test.ts +45 -0
  48. package/src/__tests__/plugin-api-model-profiles.test.ts +10 -1
  49. package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
  50. package/src/__tests__/provider-error-scenarios.test.ts +140 -0
  51. package/src/__tests__/provider-send-message-override-profile.test.ts +95 -0
  52. package/src/__tests__/run-conversation-turn-persistence.test.ts +66 -3
  53. package/src/__tests__/scripted-turn-metadata-persistence.test.ts +209 -0
  54. package/src/__tests__/skills.test.ts +27 -0
  55. package/src/__tests__/slack-channels-routes.test.ts +0 -2
  56. package/src/__tests__/slack-share-routes.test.ts +0 -3
  57. package/src/__tests__/slack-users-routes.test.ts +0 -2
  58. package/src/__tests__/subagent-tools.test.ts +11 -0
  59. package/src/__tests__/tool-preview-lifecycle.test.ts +58 -0
  60. package/src/__tests__/tool-result-spool.test.ts +5 -4
  61. package/src/__tests__/turn-boundary-resolution.test.ts +53 -0
  62. package/src/__tests__/turn-events-store.test.ts +26 -0
  63. package/src/__tests__/ui-visual-surface.test.ts +695 -0
  64. package/src/__tests__/unified-turn-context-visible-app.test.ts +99 -0
  65. package/src/__tests__/visible-app-context.test.ts +189 -0
  66. package/src/__tests__/workspace-git-service.test.ts +173 -33
  67. package/src/__tests__/workspace-migration-137-repair-retired-fireworks-minimax-model-id.test.ts +157 -0
  68. package/src/__tests__/workspace-migration-138-backfill-home-feed-titles.test.ts +373 -0
  69. package/src/__tests__/workspace-migration-139-clear-renamed-cost-profile-label.test.ts +137 -0
  70. package/src/agent/loop.ts +36 -7
  71. package/src/api/events/context-window-usage.ts +31 -0
  72. package/src/api/events/notification-intent.ts +8 -0
  73. package/src/api/events/ui-surface-pending.ts +35 -0
  74. package/src/api/index.ts +14 -0
  75. package/src/api/responses/conversation-message.ts +25 -4
  76. package/src/api/surfaces.ts +90 -1
  77. package/src/approvals/guardian-card-withdrawal.ts +66 -31
  78. package/src/approvals/guardian-decision-primitive.ts +4 -0
  79. package/src/calls/__tests__/call-setup-router.test.ts +156 -26
  80. package/src/calls/__tests__/voice-session-bridge.test.ts +201 -12
  81. package/src/calls/call-setup-router.ts +80 -36
  82. package/src/calls/voice-session-bridge.ts +173 -26
  83. package/src/cli/commands/inference.help.ts +3 -3
  84. package/src/cli/commands/notifications.help.ts +3 -2
  85. package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +42 -128
  86. package/src/cli/commands/platform/__tests__/credits.test.ts +9 -78
  87. package/src/cli/commands/platform/__tests__/helpers.ts +90 -0
  88. package/src/cli/commands/platform/__tests__/invoices.test.ts +238 -0
  89. package/src/cli/commands/platform/__tests__/plans.test.ts +9 -88
  90. package/src/cli/commands/platform/__tests__/status.test.ts +12 -87
  91. package/src/cli/commands/platform/__tests__/subscription.test.ts +9 -86
  92. package/src/cli/commands/platform/index.help.ts +92 -0
  93. package/src/cli/commands/platform/index.ts +7 -0
  94. package/src/cli/commands/platform/invoices.ts +132 -0
  95. package/src/cli/commands/usage.help.ts +1 -1
  96. package/src/cli/lib/list-installed-plugins.ts +2 -1
  97. package/src/config/__tests__/default-profile-catalog.test.ts +7 -10
  98. package/src/config/__tests__/deployment-context-defaults.test.ts +27 -5
  99. package/src/config/assistant-feature-flags.ts +7 -2
  100. package/src/config/bundled-skills/app-builder/SKILL.md +3 -2
  101. package/src/config/bundled-skills/subagent/SKILL.md +3 -1
  102. package/src/config/bundled-skills/subagent/TOOLS.json +3 -3
  103. package/src/config/bundled-skills/visualize/SKILL.md +163 -0
  104. package/src/config/call-site-defaults.ts +3 -2
  105. package/src/config/default-profile-catalog.ts +75 -70
  106. package/src/config/default-profile-names.ts +13 -28
  107. package/src/config/env-registry.ts +1 -0
  108. package/src/config/feature-flag-registry.json +16 -0
  109. package/src/config/llm-resolver.ts +21 -5
  110. package/src/config/loader.ts +18 -11
  111. package/src/config/schemas/llm.ts +1 -9
  112. package/src/config/schemas/memory-retrospective.ts +9 -0
  113. package/src/config/schemas/monitoring.ts +28 -2
  114. package/src/config/schemas/workspace-git.ts +15 -0
  115. package/src/config/seed-inference-profiles.ts +19 -5
  116. package/src/context/post-turn-tool-result-truncation.ts +2 -2
  117. package/src/conversations/__tests__/message-consolidation.test.ts +54 -0
  118. package/src/conversations/message-consolidation.ts +17 -15
  119. package/src/daemon/__tests__/turn-tail-assistant-reply-notify.test.ts +182 -0
  120. package/src/daemon/__tests__/turn-tail-deleted-conversation.test.ts +181 -0
  121. package/src/daemon/conversation-agent-loop-handlers.ts +90 -7
  122. package/src/daemon/conversation-agent-loop.ts +107 -22
  123. package/src/daemon/conversation-error.ts +169 -48
  124. package/src/daemon/conversation-messaging.ts +87 -4
  125. package/src/daemon/conversation-process.ts +57 -45
  126. package/src/daemon/conversation-runtime-assembly.ts +57 -0
  127. package/src/daemon/conversation-store.ts +36 -1
  128. package/src/daemon/conversation-surfaces.ts +68 -6
  129. package/src/daemon/conversation-turn-finalize.ts +76 -18
  130. package/src/daemon/conversation.ts +122 -27
  131. package/src/daemon/lifecycle.ts +9 -0
  132. package/src/daemon/message-types/conversations.ts +8 -0
  133. package/src/daemon/message-types/surfaces.ts +3 -0
  134. package/src/documents/document-store.ts +247 -10
  135. package/src/home/__tests__/feed-types.test.ts +43 -8
  136. package/src/home/__tests__/feed-writer.test.ts +59 -0
  137. package/src/home/feed-types.ts +34 -7
  138. package/src/home/feed-writer.ts +15 -6
  139. package/src/live-voice/__tests__/activity-label.test.ts +95 -0
  140. package/src/live-voice/__tests__/live-activity-reporter.test.ts +86 -5
  141. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +310 -25
  142. package/src/live-voice/__tests__/live-voice-events.test.ts +4 -4
  143. package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +8 -4
  144. package/src/live-voice/__tests__/live-voice-vad.test.ts +9 -1
  145. package/src/live-voice/activity-label.ts +169 -0
  146. package/src/live-voice/live-activity-reporter.ts +35 -6
  147. package/src/live-voice/live-voice-session.ts +282 -41
  148. package/src/live-voice/protocol.ts +35 -0
  149. package/src/messaging/providers/slack/__tests__/adapter-mention-rendering.test.ts +84 -2
  150. package/src/messaging/providers/slack/adapter.ts +86 -19
  151. package/src/messaging/providers/slack/api.test.ts +85 -1
  152. package/src/messaging/providers/slack/api.ts +121 -288
  153. package/src/messaging/providers/slack/client.ts +20 -251
  154. package/src/messaging/providers/slack/send.test.ts +4 -9
  155. package/src/messaging/providers/slack/send.ts +1 -1
  156. package/src/messaging/providers/slack/types.ts +5 -0
  157. package/src/messaging/providers/slack/web-api-transport.test.ts +181 -0
  158. package/src/messaging/providers/slack/web-api-transport.ts +367 -0
  159. package/src/messaging/providers/slack/withdraw.ts +6 -14
  160. package/src/messaging/providers/telegram-bot/send.test.ts +31 -1
  161. package/src/messaging/providers/telegram-bot/send.ts +23 -2
  162. package/src/messaging/providers/telegram-bot/withdraw.test.ts +152 -0
  163. package/src/messaging/providers/telegram-bot/withdraw.ts +166 -0
  164. package/src/monitoring/__tests__/db-integrity-sample.test.ts +18 -4
  165. package/src/monitoring/__tests__/file-descriptors.test.ts +144 -0
  166. package/src/monitoring/file-descriptors.ts +262 -0
  167. package/src/monitoring/process-memory.ts +4 -11
  168. package/src/monitoring/resource-sampler.ts +5 -0
  169. package/src/monitoring/worker.ts +12 -0
  170. package/src/notifications/__tests__/assistant-reply-producer.test.ts +740 -0
  171. package/src/notifications/__tests__/broadcaster.test.ts +347 -7
  172. package/src/notifications/__tests__/copy-composer.test.ts +53 -3
  173. package/src/notifications/__tests__/decision-engine.test.ts +349 -29
  174. package/src/notifications/__tests__/deterministic-checks.test.ts +21 -0
  175. package/src/notifications/__tests__/edit-notification.test.ts +330 -0
  176. package/src/notifications/__tests__/guardian-delivery-recorder.test.ts +71 -0
  177. package/src/notifications/__tests__/home-feed-side-effect.test.ts +170 -12
  178. package/src/notifications/adapters/macos.ts +3 -0
  179. package/src/notifications/adapters/platform.ts +90 -14
  180. package/src/notifications/adapters/telegram.ts +6 -4
  181. package/src/notifications/assistant-reply-producer.ts +219 -0
  182. package/src/notifications/broadcaster.ts +523 -317
  183. package/src/notifications/copy-composer.ts +27 -6
  184. package/src/notifications/decision-engine.ts +154 -77
  185. package/src/notifications/deterministic-checks.ts +6 -7
  186. package/src/notifications/edit-notification.ts +8 -4
  187. package/src/notifications/emit-signal.ts +49 -16
  188. package/src/notifications/guardian-delivery-recorder.ts +8 -2
  189. package/src/notifications/home-feed-side-effect.ts +43 -6
  190. package/src/notifications/notification-utils.ts +39 -4
  191. package/src/notifications/signal.ts +10 -0
  192. package/src/notifications/types.ts +17 -0
  193. package/src/persistence/bookmark-crud.ts +18 -9
  194. package/src/persistence/conversation-attention-store.ts +26 -0
  195. package/src/persistence/conversation-crud.ts +79 -32
  196. package/src/persistence/conversation-queries.ts +30 -15
  197. package/src/persistence/conversation-title-service.ts +5 -170
  198. package/src/persistence/conversation-types.ts +192 -1
  199. package/src/persistence/db-init.ts +14 -1
  200. package/src/persistence/db-maintenance.ts +5 -4
  201. package/src/persistence/embeddings/__tests__/plugin-index-qdrant-init.test.ts +219 -0
  202. package/src/persistence/embeddings/__tests__/plugin-index.test.ts +22 -0
  203. package/src/persistence/embeddings/__tests__/worker-script-version.test.ts +76 -0
  204. package/src/persistence/embeddings/embedding-local.ts +2 -1
  205. package/src/persistence/embeddings/embedding-runtime-manager.ts +29 -5
  206. package/src/persistence/embeddings/plugin-index.ts +84 -12
  207. package/src/persistence/migrations/360-add-document-workspace-path.test.ts +110 -0
  208. package/src/persistence/migrations/360-add-document-workspace-path.ts +39 -0
  209. package/src/persistence/planner-statistics.ts +14 -0
  210. package/src/persistence/schema/documents.ts +26 -11
  211. package/src/persistence/steps.ts +2 -0
  212. package/src/platform/client.test.ts +172 -2
  213. package/src/platform/client.ts +178 -55
  214. package/src/plugin-api/conversation-turn.ts +13 -0
  215. package/src/plugin-api/model-profiles.test.ts +6 -2
  216. package/src/plugins/defaults/memory/__tests__/bookmark-crud.test.ts +34 -0
  217. package/src/plugins/defaults/memory/__tests__/conversation-queries.test.ts +22 -0
  218. package/src/plugins/defaults/memory/__tests__/jobs-store-enqueue-gate.test.ts +11 -0
  219. package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +199 -0
  220. package/src/plugins/defaults/memory/__tests__/memory-retrospective-enqueue.test.ts +98 -1
  221. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +105 -1
  222. package/src/plugins/defaults/memory/__tests__/memory-retrospective-sweep.test.ts +24 -2
  223. package/src/plugins/defaults/memory/__tests__/memory-retrospective-wake-chain.test.ts +524 -0
  224. package/src/plugins/defaults/memory/__tests__/memory-tier-boundary-guard.test.ts +1 -1
  225. package/src/plugins/defaults/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +9 -4
  226. package/src/plugins/defaults/memory/host-utils.ts +5 -0
  227. package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +105 -1
  228. package/src/plugins/defaults/memory/memory-retrospective-enqueue.ts +64 -5
  229. package/src/plugins/defaults/memory/memory-retrospective-job.ts +36 -1
  230. package/src/plugins/defaults/memory/memory-retrospective-sweep.ts +12 -3
  231. package/src/plugins/defaults/memory/src/__tests__/memory-v2-simulate-route.test.ts +1 -0
  232. package/src/plugins/defaults/memory/substrate/__tests__/skill-store.test.ts +195 -1
  233. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +1 -1
  234. package/src/plugins/defaults/memory/substrate/skill-store.ts +167 -29
  235. package/src/plugins/defaults/memory/v2/__tests__/injection.test.ts +131 -0
  236. package/src/plugins/defaults/memory/v2/__tests__/router.test.ts +1 -0
  237. package/src/plugins/defaults/memory/v2/activation-log-store.ts +4 -0
  238. package/src/plugins/defaults/memory/v2/injection.ts +48 -3
  239. package/src/plugins/defaults/memory/v2/rerank-local.ts +6 -2
  240. package/src/plugins/defaults/memory/v3/__tests__/pool-select.test.ts +1 -1
  241. package/src/plugins/defaults/platform-hosted/routes/reengage.ts +1 -1
  242. package/src/plugins/defaults/turn-context/injectors.ts +1 -0
  243. package/src/plugins/defaults/turn-context/unified-turn-context.ts +26 -0
  244. package/src/plugins/types.ts +17 -0
  245. package/src/prompts/templates/system-sections.ts +2 -2
  246. package/src/providers/__tests__/dispatch-connection-routing.test.ts +43 -2
  247. package/src/providers/__tests__/vellum-mismatch-routing.test.ts +8 -0
  248. package/src/providers/call-site-routing.ts +77 -14
  249. package/src/providers/connection-resolution.ts +42 -2
  250. package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +127 -1
  251. package/src/providers/inference/adapter-factory.ts +70 -12
  252. package/src/providers/model-catalog.ts +0 -11
  253. package/src/providers/model-intents.ts +11 -0
  254. package/src/providers/registry.ts +8 -0
  255. package/src/providers/retry.ts +85 -17
  256. package/src/providers/types.ts +8 -1
  257. package/src/runtime/__tests__/agent-wake.test.ts +181 -0
  258. package/src/runtime/agent-wake.ts +132 -9
  259. package/src/runtime/channel-approval-types.ts +25 -0
  260. package/src/runtime/routes/__tests__/connection-routes-vs-cli-parity.test.ts +2 -2
  261. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +11 -0
  262. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +121 -9
  263. package/src/runtime/routes/__tests__/platform-invoice-routes.test.ts +392 -0
  264. package/src/runtime/routes/__tests__/slack-channel-routes.test.ts +7 -0
  265. package/src/runtime/routes/__tests__/workspace-commit-routes.test.ts +63 -0
  266. package/src/runtime/routes/consolidation-routes.ts +1 -1
  267. package/src/runtime/routes/conversation-management-routes.ts +18 -6
  268. package/src/runtime/routes/conversation-query-routes.ts +14 -23
  269. package/src/runtime/routes/conversation-routes.ts +113 -8
  270. package/src/runtime/routes/credential-routes.ts +1 -5
  271. package/src/runtime/routes/documents-routes.ts +207 -12
  272. package/src/runtime/routes/identity-routes.ts +3 -93
  273. package/src/runtime/routes/inbound-stages/admission-policy.test.ts +31 -1
  274. package/src/runtime/routes/inbound-stages/admission-policy.ts +18 -0
  275. package/src/runtime/routes/inference-provider-connection-routes.ts +52 -3
  276. package/src/runtime/routes/notification-routes.ts +4 -1
  277. package/src/runtime/routes/platform-routes.ts +238 -3
  278. package/src/runtime/routes/playground/guard.ts +1 -2
  279. package/src/runtime/routes/slack-channel-routes.ts +2 -4
  280. package/src/runtime/routes/workspace-commit-routes.ts +49 -2
  281. package/src/runtime/routes/workspace-routes.ts +11 -12
  282. package/src/runtime/routes/workspace-utils.ts +49 -2
  283. package/src/runtime/services/conversation-serializer.ts +2 -4
  284. package/src/subagent/__tests__/consult-context-gating.test.ts +98 -0
  285. package/src/subagent/__tests__/consult-context-skills.test.ts +82 -0
  286. package/src/subagent/__tests__/consult-context.test.ts +84 -0
  287. package/src/subagent/__tests__/consult-prompt.test.ts +36 -0
  288. package/src/subagent/consult-context.ts +410 -0
  289. package/src/subagent/consult-prompt.ts +38 -5
  290. package/src/subagent/manager.ts +9 -4
  291. package/src/subagent/types.ts +8 -0
  292. package/src/telemetry/telemetry-event-sources.ts +7 -0
  293. package/src/telemetry/telemetry-wire-source.json +1 -1
  294. package/src/telemetry/telemetry-wire.generated.ts +1 -0
  295. package/src/telemetry/turn-events-store.ts +30 -1
  296. package/src/telemetry/types.ts +24 -0
  297. package/src/telemetry/usage-telemetry-reporter.test.ts +48 -0
  298. package/src/tools/browser/pinned-tabs.ts +3 -1
  299. package/src/tools/subagent/spawn.ts +23 -1
  300. package/src/tools/terminal/safe-env.ts +1 -0
  301. package/src/tools/ui-surface/definitions.ts +39 -1
  302. package/src/tools/ui-surface/surface-shape-docs.ts +38 -4
  303. package/src/tools/ui-surface/visual-validation.ts +787 -0
  304. package/src/tools/workflows/run-workflow.test.ts +1 -0
  305. package/src/util/__tests__/short-title.test.ts +229 -0
  306. package/src/util/__tests__/worker-compute.test.ts +65 -0
  307. package/src/util/cgroup-cpu.ts +93 -0
  308. package/src/util/errors.ts +27 -0
  309. package/src/util/process-tree.ts +19 -0
  310. package/src/util/short-title.ts +189 -0
  311. package/src/util/worker-compute.ts +83 -0
  312. package/src/workspace/byok-default-profile-ensure.ts +25 -15
  313. package/src/workspace/git-service.ts +79 -30
  314. package/src/workspace/migrations/137-repair-retired-fireworks-minimax-model-id.ts +131 -0
  315. package/src/workspace/migrations/138-backfill-home-feed-titles.ts +179 -0
  316. package/src/workspace/migrations/139-clear-renamed-cost-profile-label.ts +92 -0
  317. package/src/workspace/migrations/registry.ts +6 -0
  318. package/src/plugins/defaults/memory/substrate/constants.ts +0 -8
@@ -342,6 +342,7 @@ describe("manage_workflows", () => {
342
342
  expect(parsed.profiles).toEqual([
343
343
  "balanced",
344
344
  "cost-optimized",
345
+ "latency-optimized",
345
346
  "quality-optimized",
346
347
  ]);
347
348
  expect(parsed.activeProfile).toBe("balanced");
@@ -0,0 +1,229 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ normalizeTitle,
5
+ stripMarkdown,
6
+ stripThinkingTags,
7
+ truncateTitle,
8
+ } from "../short-title.js";
9
+
10
+ /** The character and word budgets `short-title.ts` documents and enforces. */
11
+ const MAX_TITLE_LENGTH = 40;
12
+ const MAX_TITLE_WORDS = 7;
13
+
14
+ describe("stripMarkdown", () => {
15
+ test("unwraps bold, italic, strikethrough, and code spans", () => {
16
+ expect(stripMarkdown("**Auth** Rewrite")).toBe("Auth Rewrite");
17
+ expect(stripMarkdown("__Auth__ Rewrite")).toBe("Auth Rewrite");
18
+ expect(stripMarkdown("*Auth* Rewrite")).toBe("Auth Rewrite");
19
+ expect(stripMarkdown("_Auth_ Rewrite")).toBe("Auth Rewrite");
20
+ expect(stripMarkdown("~~Auth~~ Rewrite")).toBe("Auth Rewrite");
21
+ expect(stripMarkdown("`Auth` Rewrite")).toBe("Auth Rewrite");
22
+ });
23
+
24
+ test("keeps link text and drops the target", () => {
25
+ expect(stripMarkdown("[Auth Rewrite](https://example.com)")).toBe(
26
+ "Auth Rewrite",
27
+ );
28
+ });
29
+
30
+ test("removes heading markers", () => {
31
+ expect(stripMarkdown("## Auth Rewrite")).toBe("Auth Rewrite");
32
+ });
33
+
34
+ test("preserves snake_case identifiers", () => {
35
+ expect(stripMarkdown("record_conversation_title")).toBe(
36
+ "record_conversation_title",
37
+ );
38
+ });
39
+ });
40
+
41
+ describe("stripThinkingTags", () => {
42
+ test("removes paired thinking blocks and their contents", () => {
43
+ expect(stripThinkingTags("<thinking>hmm</thinking>Auth Rewrite")).toBe(
44
+ "Auth Rewrite",
45
+ );
46
+ expect(stripThinkingTags("<thought>hmm</thought>Auth Rewrite")).toBe(
47
+ "Auth Rewrite",
48
+ );
49
+ expect(stripThinkingTags("<think>hmm</think>Auth Rewrite")).toBe(
50
+ "Auth Rewrite",
51
+ );
52
+ });
53
+
54
+ test("removes unpaired tags but keeps the surrounding text", () => {
55
+ expect(stripThinkingTags("<thinking>Auth Rewrite")).toBe("Auth Rewrite");
56
+ expect(stripThinkingTags("Auth Rewrite</think>")).toBe("Auth Rewrite");
57
+ });
58
+
59
+ test("removes colon-prefixed pseudo tags", () => {
60
+ expect(stripThinkingTags("<:anything>Auth Rewrite")).toBe("Auth Rewrite");
61
+ });
62
+
63
+ test("leaves plain text untouched", () => {
64
+ expect(stripThinkingTags("Auth Rewrite")).toBe("Auth Rewrite");
65
+ });
66
+ });
67
+
68
+ describe("truncateTitle", () => {
69
+ test("leaves titles within the character limit untouched", () => {
70
+ const title = "Auth Middleware Rewrite";
71
+ expect(title.length).toBeLessThanOrEqual(MAX_TITLE_LENGTH);
72
+ expect(truncateTitle(title)).toBe(title);
73
+ });
74
+
75
+ test("trims at a word boundary when few but long words exceed the char limit", () => {
76
+ const title = "Supercalifragilistic Authentication Middleware Rewrite";
77
+ expect(title.split(" ").length).toBeLessThanOrEqual(MAX_TITLE_WORDS);
78
+ expect(truncateTitle(title)).toBe("Supercalifragilistic Authentication");
79
+ });
80
+
81
+ test("hard-slices a single word longer than the char limit", () => {
82
+ const title = "a".repeat(MAX_TITLE_LENGTH + 5);
83
+ expect(truncateTitle(title)).toBe("a".repeat(MAX_TITLE_LENGTH));
84
+ });
85
+
86
+ test("keeps the first five words when the word limit is exceeded", () => {
87
+ const title =
88
+ "alpha bravo charlie delta echo foxtrot golf hotel india juliet";
89
+ expect(title.length).toBeGreaterThan(MAX_TITLE_LENGTH);
90
+ expect(title.split(" ").length).toBeGreaterThan(MAX_TITLE_WORDS);
91
+ expect(truncateTitle(title)).toBe("alpha bravo charlie delta echo");
92
+ });
93
+
94
+ test("still honors the char limit when the first five words overflow it", () => {
95
+ const title =
96
+ "Authentication Infrastructure Modernization Deployment Verification Report Complete Today";
97
+ expect(title.length).toBeGreaterThan(MAX_TITLE_LENGTH);
98
+ expect(title.split(" ").length).toBeGreaterThan(MAX_TITLE_WORDS);
99
+ const result = truncateTitle(title);
100
+ expect(result).toBe("Authentication Infrastructure");
101
+ expect(result.length).toBeLessThanOrEqual(MAX_TITLE_LENGTH);
102
+ });
103
+ });
104
+
105
+ describe("normalizeTitle prose rejection", () => {
106
+ test("rejects multi-line output", () => {
107
+ expect(normalizeTitle("Auth Rewrite\nand more")).toBe("");
108
+ });
109
+
110
+ test("rejects embedded transcript markers", () => {
111
+ expect(normalizeTitle("User: how do I log in")).toBe("");
112
+ expect(normalizeTitle("Assistant : here you go")).toBe("");
113
+ });
114
+
115
+ test("rejects sentence-shaped clauses ending in terminal punctuation", () => {
116
+ expect(normalizeTitle("This is a topic about authentication.")).toBe("");
117
+ expect(normalizeTitle("What did they want to build here?")).toBe("");
118
+ });
119
+
120
+ test("keeps short phrases that end in punctuation", () => {
121
+ expect(normalizeTitle("Auth Rewrite?")).toBe("Auth Rewrite?");
122
+ });
123
+
124
+ test("rejects first-person reasoning openers", () => {
125
+ expect(normalizeTitle("I need to generate a title")).toBe("");
126
+ expect(normalizeTitle("I'll work through these files")).toBe("");
127
+ expect(normalizeTitle("I cannot title this")).toBe("");
128
+ });
129
+
130
+ test("rejects imperative and deferral openers", () => {
131
+ expect(normalizeTitle("Let me summarize the request")).toBe("");
132
+ expect(normalizeTitle("Looking at the conversation")).toBe("");
133
+ expect(normalizeTitle("Based on the messages")).toBe("");
134
+ expect(normalizeTitle("Given the context")).toBe("");
135
+ });
136
+
137
+ test("rejects infinitive openers", () => {
138
+ expect(normalizeTitle("To generate a good title")).toBe("");
139
+ expect(normalizeTitle("To summarize the discussion")).toBe("");
140
+ expect(normalizeTitle("To title this thread")).toBe("");
141
+ });
142
+
143
+ test("rejects subject-led reasoning openers", () => {
144
+ expect(normalizeTitle("The user wants a summary")).toBe("");
145
+ expect(normalizeTitle("The conversation is about Docker")).toBe("");
146
+ expect(normalizeTitle("The assistant should reply")).toBe("");
147
+ expect(normalizeTitle("The title should be short")).toBe("");
148
+ });
149
+
150
+ test("rejects preamble openers", () => {
151
+ expect(normalizeTitle("Here's a title")).toBe("");
152
+ expect(normalizeTitle("Here is a title")).toBe("");
153
+ expect(normalizeTitle("Here are the options")).toBe("");
154
+ expect(normalizeTitle("Sure, Auth Rewrite")).toBe("");
155
+ expect(normalizeTitle("Okay, Auth Rewrite")).toBe("");
156
+ expect(normalizeTitle("Ok, Auth Rewrite")).toBe("");
157
+ });
158
+
159
+ test("accepts bare noun-phrase titles that share a subject word", () => {
160
+ expect(normalizeTitle("The Conversation API")).toBe("The Conversation API");
161
+ expect(normalizeTitle("The User Interface Redesign")).toBe(
162
+ "The User Interface Redesign",
163
+ );
164
+ expect(normalizeTitle("Auth Middleware Rewrite")).toBe(
165
+ "Auth Middleware Rewrite",
166
+ );
167
+ });
168
+ });
169
+
170
+ describe("normalizeTitle", () => {
171
+ test("strips surrounding quotes", () => {
172
+ expect(normalizeTitle('"Auth Rewrite"')).toBe("Auth Rewrite");
173
+ expect(normalizeTitle("'Auth Rewrite'")).toBe("Auth Rewrite");
174
+ });
175
+
176
+ test("strips markdown and thinking tags together", () => {
177
+ expect(normalizeTitle("<thinking>hmm</thinking> **Auth Rewrite**")).toBe(
178
+ "Auth Rewrite",
179
+ );
180
+ });
181
+
182
+ test("returns empty for blank input", () => {
183
+ expect(normalizeTitle("")).toBe("");
184
+ expect(normalizeTitle(" ")).toBe("");
185
+ expect(normalizeTitle("<thinking>hmm</thinking>")).toBe("");
186
+ });
187
+
188
+ test("returns empty for leaked prose", () => {
189
+ expect(normalizeTitle("I need to generate a title for this")).toBe("");
190
+ expect(normalizeTitle("User: how do I log in")).toBe("");
191
+ expect(normalizeTitle("Auth Rewrite\nand more")).toBe("");
192
+ });
193
+
194
+ test("returns empty for meta-failure titles regardless of case", () => {
195
+ const metaFailures = [
196
+ "missing context",
197
+ "no context",
198
+ "insufficient context",
199
+ "unclear context",
200
+ "empty context",
201
+ "no topic",
202
+ "unclear topic",
203
+ "unclear request",
204
+ "unclear message",
205
+ "empty conversation",
206
+ "empty message",
207
+ "no content",
208
+ ];
209
+ for (const meta of metaFailures) {
210
+ expect(normalizeTitle(meta)).toBe("");
211
+ }
212
+ expect(normalizeTitle("Missing Context")).toBe("");
213
+ expect(normalizeTitle('"No Topic"')).toBe("");
214
+ });
215
+
216
+ test("truncates an over-long accepted title", () => {
217
+ expect(
218
+ normalizeTitle("alpha bravo charlie delta echo foxtrot golf hotel india"),
219
+ ).toBe("alpha bravo charlie delta echo");
220
+ });
221
+
222
+ test("keeps a wordy title inside the character budget", () => {
223
+ const result = normalizeTitle(
224
+ "Authentication Infrastructure Modernization Deployment Verification Report Complete Today",
225
+ );
226
+ expect(result).toBe("Authentication Infrastructure");
227
+ expect(result.length).toBeLessThanOrEqual(MAX_TITLE_LENGTH);
228
+ });
229
+ });
@@ -0,0 +1,65 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ computeWorkerIntraOpThreads,
5
+ ONNX_INTRA_OP_THREADS_ENV,
6
+ WORKER_MAX_INTRA_OP_THREADS,
7
+ WORKER_MIN_INTRA_OP_THREADS,
8
+ workerComputeEnv,
9
+ } from "../worker-compute.js";
10
+
11
+ describe("computeWorkerIntraOpThreads", () => {
12
+ test("targets a quarter of the available cores", () => {
13
+ // 8 cores -> 2 threads, inside both clamps.
14
+ expect(computeWorkerIntraOpThreads(8)).toBe(2);
15
+ });
16
+
17
+ test("clamps small machines up to the minimum", () => {
18
+ // 2 cores -> 0 raw, which ONNX would read as "unbounded"; clamped up.
19
+ expect(computeWorkerIntraOpThreads(2)).toBe(WORKER_MIN_INTRA_OP_THREADS);
20
+ });
21
+
22
+ test("clamps large machines down to the maximum", () => {
23
+ // 64 cores -> 16 raw, clamped down.
24
+ expect(computeWorkerIntraOpThreads(64)).toBe(WORKER_MAX_INTRA_OP_THREADS);
25
+ });
26
+
27
+ test("never returns a value ONNX would treat as unbounded", () => {
28
+ for (const cores of [0, -1, -100, NaN, Infinity]) {
29
+ expect(computeWorkerIntraOpThreads(cores)).toBeGreaterThanOrEqual(
30
+ WORKER_MIN_INTRA_OP_THREADS,
31
+ );
32
+ }
33
+ });
34
+
35
+ test("fractional container quotas floor rather than round up", () => {
36
+ // A 1.5-core cgroup quota must not hand ONNX more than it has.
37
+ expect(computeWorkerIntraOpThreads(1.5)).toBe(WORKER_MIN_INTRA_OP_THREADS);
38
+ });
39
+ });
40
+
41
+ describe("workerComputeEnv", () => {
42
+ const saved = process.env[ONNX_INTRA_OP_THREADS_ENV];
43
+
44
+ afterEach(() => {
45
+ if (saved === undefined) {
46
+ delete process.env[ONNX_INTRA_OP_THREADS_ENV];
47
+ } else {
48
+ process.env[ONNX_INTRA_OP_THREADS_ENV] = saved;
49
+ }
50
+ });
51
+
52
+ test("computes a clamped thread cap", () => {
53
+ delete process.env[ONNX_INTRA_OP_THREADS_ENV];
54
+ const env = workerComputeEnv();
55
+ const threads = Number(env[ONNX_INTRA_OP_THREADS_ENV]);
56
+ expect(Number.isInteger(threads)).toBe(true);
57
+ expect(threads).toBeGreaterThanOrEqual(WORKER_MIN_INTRA_OP_THREADS);
58
+ expect(threads).toBeLessThanOrEqual(WORKER_MAX_INTRA_OP_THREADS);
59
+ });
60
+
61
+ test("keeps an operator-provided override", () => {
62
+ process.env[ONNX_INTRA_OP_THREADS_ENV] = "7";
63
+ expect(workerComputeEnv()[ONNX_INTRA_OP_THREADS_ENV]).toBe("7");
64
+ });
65
+ });
@@ -7,7 +7,9 @@
7
7
  */
8
8
 
9
9
  import { readFileSync } from "node:fs";
10
+ import { availableParallelism, cpus } from "node:os";
10
11
 
12
+ import { getCpuLimit, getIsPlatform } from "../config/env-registry.js";
11
13
  import { parseKeyedCounts } from "./cgroup-memory.js";
12
14
 
13
15
  export interface ContainerCpuStat {
@@ -45,3 +47,94 @@ export function getContainerCpuStat(): ContainerCpuStat | null {
45
47
  const raw = readCpuStatRaw();
46
48
  return raw != null ? parseCpuStat(raw) : null;
47
49
  }
50
+
51
+ /**
52
+ * Parse a Kubernetes-style CPU string (e.g. "2000m", "1", "500m") into
53
+ * fractional cores. Returns null if the value is not a recognized format.
54
+ */
55
+ export function parseK8sCpuCores(value: string): number | null {
56
+ const trimmed = value.trim();
57
+ const milliMatch = trimmed.match(/^(\d+)m$/);
58
+ if (milliMatch) {
59
+ const millis = parseInt(milliMatch[1], 10);
60
+ return millis > 0 ? millis / 1000 : null;
61
+ }
62
+ if (/^\d+(\.\d+)?$/.test(trimmed)) {
63
+ const num = parseFloat(trimmed);
64
+ return !isNaN(num) && num > 0 ? num : null;
65
+ }
66
+ return null;
67
+ }
68
+
69
+ /**
70
+ * Read the container's CPU core limit.
71
+ *
72
+ * Resolution order:
73
+ * 1. VELLUM_CPU_LIMIT env var (K8s resource format, e.g. "2000m" or "2").
74
+ * In platform mode the container runs under gVisor where cgroup files may
75
+ * report the node's CPU count rather than the sandbox limit.
76
+ * 2. cgroups v2 cpu.max (quota / period → fractional cores).
77
+ * 3. cgroups v1 cpu.cfs_quota_us / cpu.cfs_period_us.
78
+ * 4. os.cpus().length as last resort.
79
+ */
80
+ export function getContainerCpuCores(): number {
81
+ // 1. Prefer the explicit env var set by the platform StatefulSet template.
82
+ try {
83
+ const envLimit = getCpuLimit();
84
+ if (envLimit) {
85
+ const parsed = parseK8sCpuCores(envLimit);
86
+ if (parsed !== null) {
87
+ return parsed;
88
+ }
89
+ }
90
+ } catch {
91
+ /* env var parsing failed – fall through */
92
+ }
93
+
94
+ // 2. Try cgroups v2: /sys/fs/cgroup/cpu.max contains "$MAX $PERIOD".
95
+ try {
96
+ const raw = readFileSync("/sys/fs/cgroup/cpu.max", "utf-8").trim();
97
+ if (!raw.startsWith("max")) {
98
+ const parts = raw.split(/\s+/);
99
+ const quota = parseInt(parts[0], 10);
100
+ const period = parseInt(parts[1], 10);
101
+ if (!isNaN(quota) && !isNaN(period) && period > 0 && quota > 0) {
102
+ const cores = quota / period;
103
+ // Sanity check: if the value looks like the node's full CPU count
104
+ // and we're on a platform pod, it's likely gVisor leaking the host value.
105
+ if (cores < cpus().length * 0.9 || !getIsPlatform()) {
106
+ return cores;
107
+ }
108
+ }
109
+ }
110
+ } catch {
111
+ /* not available */
112
+ }
113
+
114
+ // 3. Try cgroups v1.
115
+ try {
116
+ const quota = parseInt(
117
+ readFileSync("/sys/fs/cgroup/cpu/cpu.cfs_quota_us", "utf-8").trim(),
118
+ 10,
119
+ );
120
+ const period = parseInt(
121
+ readFileSync("/sys/fs/cgroup/cpu/cpu.cfs_period_us", "utf-8").trim(),
122
+ 10,
123
+ );
124
+ if (!isNaN(quota) && !isNaN(period) && period > 0 && quota > 0) {
125
+ const cores = quota / period;
126
+ if (cores < cpus().length * 0.9 || !getIsPlatform()) {
127
+ return cores;
128
+ }
129
+ }
130
+ } catch {
131
+ /* not available */
132
+ }
133
+
134
+ // 4. Fall back to the visible CPU count; 0 when even that syscall fails.
135
+ try {
136
+ return cpus().length || availableParallelism();
137
+ } catch {
138
+ return 0;
139
+ }
140
+ }
@@ -123,6 +123,24 @@ export type ProviderErrorReason =
123
123
  | "network_error"
124
124
  | "unknown";
125
125
 
126
+ export type ProviderCredentialSource =
127
+ | "byok"
128
+ | "no-auth"
129
+ | "oauth-subscription"
130
+ | "vellum-managed";
131
+
132
+ export interface ProviderRouteAttribution {
133
+ connectionName?: string;
134
+ profileName?: string;
135
+ isManagedRoute?: boolean;
136
+ /**
137
+ * Credential surface used for this request. Finer than `isManagedRoute`:
138
+ * it separates personal provider keys, keyless endpoints, and subscription
139
+ * logins from each other so error copy can name what the user must fix.
140
+ */
141
+ credentialSource?: ProviderCredentialSource;
142
+ }
143
+
126
144
  export class ProviderError extends AssistantError {
127
145
  /** Delay (in ms) suggested by the server's Retry-After header, if present. */
128
146
  public readonly retryAfterMs?: number;
@@ -146,6 +164,8 @@ export class ProviderError extends AssistantError {
146
164
  public readonly abortReason?: unknown;
147
165
  /** Semantic failure classification stamped at the throw site. */
148
166
  public readonly reason?: ProviderErrorReason;
167
+ /** Transport route selected for the failed provider call. */
168
+ public routeAttribution?: ProviderRouteAttribution;
149
169
 
150
170
  constructor(
151
171
  message: string,
@@ -174,6 +194,13 @@ export class ProviderError extends AssistantError {
174
194
  this.abortReason = options?.abortReason;
175
195
  this.reason = options?.reason;
176
196
  }
197
+
198
+ attachRouteAttribution(attribution: ProviderRouteAttribution): void {
199
+ this.routeAttribution = {
200
+ ...attribution,
201
+ ...this.routeAttribution,
202
+ };
203
+ }
177
204
  }
178
205
 
179
206
  export class ToolError extends AssistantError {
@@ -115,6 +115,25 @@ export function deriveName(command: string): string {
115
115
  return argv0;
116
116
  }
117
117
 
118
+ /**
119
+ * Redacted command descriptor for a live PID from `/proc/<pid>/cmdline`, or
120
+ * null when the process is gone or its `/proc` entry is unreadable. Kernel
121
+ * threads have an empty command line and read as `(unknown)`.
122
+ *
123
+ * The raw command line is read here but never returned: it can carry secrets
124
+ * (bearer tokens, API keys, database URLs) passed as process arguments, so
125
+ * callers that record process identity in snapshots or logs get only the
126
+ * {@link deriveName} descriptor.
127
+ */
128
+ export function readProcessCommand(pid: number): string | null {
129
+ try {
130
+ const raw = readFileSync(`/proc/${pid}/cmdline`, "utf8");
131
+ return deriveName(raw.split("\0").filter(Boolean).join(" "));
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
136
+
118
137
  /**
119
138
  * Captures the plugin path segment(s) from a command whose executable/script
120
139
  * path lives under a `plugins/<name>/` directory. Group 1 is the first segment
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Title hygiene helpers shared by every surface that renders a short,
3
+ * scannable conversation title: markdown/thinking-tag stripping, prose and
4
+ * meta-failure rejection, and length truncation.
5
+ */
6
+
7
+ const MAX_TITLE_LENGTH = 40;
8
+ const MAX_TITLE_WORDS = 7;
9
+
10
+ /** Word count a title exceeding `MAX_TITLE_WORDS` is cut back to. */
11
+ const TRIMMED_TITLE_WORDS = 5;
12
+
13
+ const META_FAILURE_TITLES = new Set([
14
+ "missing context",
15
+ "no context",
16
+ "insufficient context",
17
+ "unclear context",
18
+ "empty context",
19
+ "no topic",
20
+ "unclear topic",
21
+ "unclear request",
22
+ "unclear message",
23
+ "empty conversation",
24
+ "empty message",
25
+ "no content",
26
+ ]);
27
+
28
+ /** Reasoning/sentence openers that never start a legitimate topic title. */
29
+ const LEAKED_PROSE_PREFIXES = [
30
+ "i need to",
31
+ "i needed to",
32
+ "i should",
33
+ "i will",
34
+ "i'll",
35
+ "i can ",
36
+ "i can't",
37
+ "i cannot",
38
+ "i'm ",
39
+ "i am ",
40
+ "i've ",
41
+ "i have ",
42
+ "i'd ",
43
+ "i would",
44
+ "let me",
45
+ "looking at",
46
+ "based on",
47
+ "given the",
48
+ "to generate",
49
+ "to summarize",
50
+ "to title",
51
+ // Subject-led reasoning openers. A bare noun phrase ("The User Interface
52
+ // Redesign", "The Conversation API") is a valid title, so each subject only
53
+ // counts as leaked prose when a verb or possessive follows it, marking the
54
+ // output as a sentence rather than a topic.
55
+ "the user wants",
56
+ "the user asked",
57
+ "the user is",
58
+ "the user wanted",
59
+ "the user needs",
60
+ "the user said",
61
+ "the user has",
62
+ "the user would",
63
+ "the user's request",
64
+ "the conversation is",
65
+ "this conversation is",
66
+ "the conversation appears",
67
+ "the conversation seems",
68
+ "the conversation covers",
69
+ "the conversation discusses",
70
+ "the assistant should",
71
+ "the assistant is",
72
+ "the assistant wants",
73
+ "the assistant needs",
74
+ "the title should",
75
+ "the title is",
76
+ "the title would",
77
+ "the title for",
78
+ "here's ",
79
+ "here is ",
80
+ "here are ",
81
+ "sure,",
82
+ "okay,",
83
+ "ok,",
84
+ ];
85
+
86
+ /** Strip common markdown formatting so titles render as plain text. */
87
+ export function stripMarkdown(text: string): string {
88
+ return text
89
+ .replace(/\*\*(.+?)\*\*/g, "$1") // **bold**
90
+ .replace(/__(.+?)__/g, "$1") // __bold__
91
+ .replace(/\*(.+?)\*/g, "$1") // *italic*
92
+ .replace(/(?<!\w)_(.+?)_(?!\w)/g, "$1") // _italic_ (word-boundary-aware to preserve snake_case)
93
+ .replace(/~~(.+?)~~/g, "$1") // ~~strikethrough~~
94
+ .replace(/`(.+?)`/g, "$1") // `code`
95
+ .replace(/\[(.+?)\]\(.+?\)/g, "$1") // [link](url)
96
+ .replace(/^#{1,6}\s+/gm, ""); // # headings
97
+ }
98
+
99
+ /** Strip thinking tags so they don't bleed into generated titles. */
100
+ export function stripThinkingTags(text: string): string {
101
+ return text
102
+ .replace(/<thinking>[\s\S]*?<\/thinking>/gi, "")
103
+ .replace(/<thought>[\s\S]*?<\/thought>/gi, "")
104
+ .replace(/<think>[\s\S]*?<\/think>/gi, "")
105
+ .replace(/<thought>/gi, "")
106
+ .replace(/<\/thought>/gi, "")
107
+ .replace(/<thinking>/gi, "")
108
+ .replace(/<\/thinking>/gi, "")
109
+ .replace(/<think>/gi, "")
110
+ .replace(/<\/think>/gi, "")
111
+ .replace(/<:[^>]*>/gi, "");
112
+ }
113
+
114
+ /**
115
+ * Clamp a title to `MAX_TITLE_LENGTH` characters and `MAX_TITLE_WORDS` words.
116
+ * Both budgets apply: a wordy title is cut back to `TRIMMED_TITLE_WORDS` words
117
+ * and the survivors are still trimmed at a word boundary to fit the character
118
+ * budget, so the returned string is never longer than `MAX_TITLE_LENGTH`.
119
+ */
120
+ export function truncateTitle(title: string): string {
121
+ if (title.length <= MAX_TITLE_LENGTH) {
122
+ return title;
123
+ }
124
+ const words = title.split(/\s+/);
125
+ const kept =
126
+ words.length > MAX_TITLE_WORDS
127
+ ? words.slice(0, TRIMMED_TITLE_WORDS)
128
+ : words;
129
+ let result = "";
130
+ for (const word of kept) {
131
+ const candidate = result ? result + " " + word : word;
132
+ if (candidate.length > MAX_TITLE_LENGTH) {
133
+ break;
134
+ }
135
+ result = candidate;
136
+ }
137
+ // Empty when even the first word overflows: hard-slice rather than return "".
138
+ return result || title.slice(0, MAX_TITLE_LENGTH);
139
+ }
140
+
141
+ /**
142
+ * Heuristic guard for title outputs that are clearly prose: the model
143
+ * reasoning aloud or replying to the conversation rather than naming it. A real
144
+ * title is a single-line short noun phrase, so we reject multi-line output,
145
+ * embedded transcript markers, leading reasoning openers, and sentence-shaped
146
+ * clauses. Deliberately tight: a false reject only costs a deterministic
147
+ * fallback title, while a false accept persists a broken one.
148
+ */
149
+ function looksLikeLeakedProse(title: string): boolean {
150
+ if (/\n/.test(title)) {
151
+ return true;
152
+ }
153
+ if (/\b(?:user|assistant)\s*:/i.test(title)) {
154
+ return true;
155
+ }
156
+ const lower = title.toLowerCase();
157
+ if (LEAKED_PROSE_PREFIXES.some((prefix) => lower.startsWith(prefix))) {
158
+ return true;
159
+ }
160
+ // Sentence-shaped: terminal punctuation on a multi-word clause.
161
+ if (/[.?!]$/.test(title) && title.split(/\s+/).length > 5) {
162
+ return true;
163
+ }
164
+ return false;
165
+ }
166
+
167
+ /**
168
+ * Clean a raw title into a short, scannable label. Returns "" when the input is
169
+ * empty, reads as leaked prose, or is a meta-failure title, signalling that
170
+ * callers should fall back to a deterministic title.
171
+ */
172
+ export function normalizeTitle(raw: string): string {
173
+ let title = raw.trim().replace(/^["']|["']$/g, "");
174
+ title = stripMarkdown(title);
175
+ title = stripThinkingTags(title).trim();
176
+ if (!title) {
177
+ return "";
178
+ }
179
+ // Reject outputs that are the model reasoning aloud or continuing the
180
+ // conversation instead of naming it (e.g. "I need to generate a…", "I'll
181
+ // work through these files…"). Callers fall back to a deterministic title.
182
+ if (looksLikeLeakedProse(title)) {
183
+ return "";
184
+ }
185
+ if (META_FAILURE_TITLES.has(title.toLowerCase())) {
186
+ return "";
187
+ }
188
+ return truncateTitle(title);
189
+ }