@vellumai/assistant 0.3.15 → 0.3.18

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 (306) hide show
  1. package/ARCHITECTURE.md +211 -12
  2. package/Dockerfile +1 -1
  3. package/README.md +11 -5
  4. package/docs/architecture/http-token-refresh.md +274 -0
  5. package/docs/architecture/memory.md +5 -4
  6. package/docs/architecture/scheduling.md +4 -88
  7. package/docs/runbook-trusted-contacts.md +283 -0
  8. package/docs/trusted-contact-access.md +247 -0
  9. package/package.json +1 -1
  10. package/scripts/ipc/check-swift-decoder-drift.ts +2 -0
  11. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +2 -6
  12. package/src/__tests__/access-request-decision.test.ts +328 -0
  13. package/src/__tests__/asset-materialize-tool.test.ts +7 -7
  14. package/src/__tests__/asset-search-tool.test.ts +15 -15
  15. package/src/__tests__/attachments-store.test.ts +13 -13
  16. package/src/__tests__/call-controller.test.ts +150 -4
  17. package/src/__tests__/call-conversation-messages.test.ts +2 -2
  18. package/src/__tests__/call-pointer-messages.test.ts +28 -0
  19. package/src/__tests__/call-start-guardian-guard.test.ts +93 -0
  20. package/src/__tests__/channel-approval-routes.test.ts +108 -12
  21. package/src/__tests__/channel-guardian.test.ts +19 -15
  22. package/src/__tests__/checker.test.ts +103 -48
  23. package/src/__tests__/computer-use-skill-manifest-regression.test.ts +2 -2
  24. package/src/__tests__/config-watcher.test.ts +356 -0
  25. package/src/__tests__/conversation-pairing.test.ts +127 -27
  26. package/src/__tests__/conversation-store.test.ts +36 -36
  27. package/src/__tests__/date-context.test.ts +179 -1
  28. package/src/__tests__/db-migration-rollback.test.ts +4 -7
  29. package/src/__tests__/deterministic-verification-control-plane.test.ts +5 -5
  30. package/src/__tests__/emit-signal-routing-intent.test.ts +179 -0
  31. package/src/__tests__/gateway-only-guard.test.ts +188 -0
  32. package/src/__tests__/guardian-action-conversation-turn.test.ts +451 -0
  33. package/src/__tests__/guardian-action-copy-generator.test.ts +197 -0
  34. package/src/__tests__/guardian-action-followup-executor.test.ts +379 -0
  35. package/src/__tests__/guardian-action-followup-store.test.ts +376 -0
  36. package/src/__tests__/guardian-action-late-reply.test.ts +425 -0
  37. package/src/__tests__/guardian-action-no-hardcoded-copy.test.ts +71 -0
  38. package/src/__tests__/guardian-action-store.test.ts +182 -0
  39. package/src/__tests__/guardian-action-sweep.test.ts +9 -9
  40. package/src/__tests__/guardian-dispatch.test.ts +120 -0
  41. package/src/__tests__/guardian-outbound-http.test.ts +194 -2
  42. package/src/__tests__/guardian-verification-intent-routing.test.ts +179 -0
  43. package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +141 -0
  44. package/src/__tests__/handlers-telegram-config.test.ts +6 -6
  45. package/src/__tests__/hooks-runner.test.ts +13 -4
  46. package/src/__tests__/ingress-routes-http.test.ts +443 -0
  47. package/src/__tests__/intent-routing.test.ts +14 -0
  48. package/src/__tests__/ipc-snapshot.test.ts +23 -5
  49. package/src/__tests__/media-reuse-story.e2e.test.ts +7 -7
  50. package/src/__tests__/memory-regressions.test.ts +16 -12
  51. package/src/__tests__/non-member-access-request.test.ts +281 -0
  52. package/src/__tests__/notification-broadcaster.test.ts +115 -4
  53. package/src/__tests__/notification-decision-strategy.test.ts +138 -1
  54. package/src/__tests__/notification-deep-link.test.ts +44 -1
  55. package/src/__tests__/notification-guardian-path.test.ts +157 -0
  56. package/src/__tests__/notification-routing-intent.test.ts +11 -1
  57. package/src/__tests__/notification-thread-candidate-validation.test.ts +215 -0
  58. package/src/__tests__/notification-thread-candidates.test.ts +166 -0
  59. package/src/__tests__/recording-intent.test.ts +1 -0
  60. package/src/__tests__/recording-state-machine.test.ts +328 -17
  61. package/src/__tests__/registry.test.ts +17 -8
  62. package/src/__tests__/relay-server.test.ts +105 -0
  63. package/src/__tests__/reminder.test.ts +13 -0
  64. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -4
  65. package/src/__tests__/scheduler-recurrence.test.ts +50 -0
  66. package/src/__tests__/server-history-render.test.ts +8 -8
  67. package/src/__tests__/session-agent-loop.test.ts +1 -0
  68. package/src/__tests__/session-runtime-assembly.test.ts +49 -0
  69. package/src/__tests__/session-skill-tools.test.ts +1 -0
  70. package/src/__tests__/skill-projection.benchmark.test.ts +11 -3
  71. package/src/__tests__/slack-channel-config.test.ts +230 -0
  72. package/src/__tests__/subagent-manager-notify.test.ts +4 -4
  73. package/src/__tests__/swarm-session-integration.test.ts +2 -2
  74. package/src/__tests__/system-prompt.test.ts +43 -0
  75. package/src/__tests__/task-management-tools.test.ts +3 -3
  76. package/src/__tests__/task-tools.test.ts +3 -3
  77. package/src/__tests__/trust-store.test.ts +38 -22
  78. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +489 -0
  79. package/src/__tests__/trusted-contact-multichannel.test.ts +405 -0
  80. package/src/__tests__/trusted-contact-verification.test.ts +360 -0
  81. package/src/__tests__/update-bulletin-format.test.ts +119 -0
  82. package/src/__tests__/update-bulletin-state.test.ts +129 -0
  83. package/src/__tests__/update-bulletin.test.ts +323 -0
  84. package/src/__tests__/update-template-contract.test.ts +24 -0
  85. package/src/__tests__/voice-session-bridge.test.ts +109 -9
  86. package/src/agent/loop.ts +2 -2
  87. package/src/amazon/client.ts +2 -3
  88. package/src/calls/call-controller.ts +241 -39
  89. package/src/calls/call-conversation-messages.ts +2 -2
  90. package/src/calls/call-domain.ts +10 -3
  91. package/src/calls/call-pointer-messages.ts +17 -5
  92. package/src/calls/guardian-action-sweep.ts +77 -36
  93. package/src/calls/guardian-dispatch.ts +8 -0
  94. package/src/calls/relay-server.ts +51 -12
  95. package/src/calls/twilio-routes.ts +3 -1
  96. package/src/calls/types.ts +1 -1
  97. package/src/calls/voice-session-bridge.ts +8 -6
  98. package/src/cli/core-commands.ts +43 -3
  99. package/src/cli/map.ts +8 -5
  100. package/src/config/bundled-skills/phone-calls/SKILL.md +16 -1
  101. package/src/config/bundled-skills/tasks/SKILL.md +1 -1
  102. package/src/config/bundled-skills/tasks/TOOLS.json +4 -4
  103. package/src/config/bundled-skills/time-based-actions/SKILL.md +11 -1
  104. package/src/config/computer-use-prompt.ts +1 -0
  105. package/src/config/core-schema.ts +16 -0
  106. package/src/config/env-registry.ts +1 -0
  107. package/src/config/env.ts +16 -1
  108. package/src/config/memory-schema.ts +5 -0
  109. package/src/config/schema.ts +4 -0
  110. package/src/config/system-prompt.ts +69 -2
  111. package/src/config/templates/BOOTSTRAP.md +1 -1
  112. package/src/config/templates/IDENTITY.md +8 -4
  113. package/src/config/templates/SOUL.md +14 -0
  114. package/src/config/templates/UPDATES.md +15 -0
  115. package/src/config/templates/USER.md +5 -1
  116. package/src/config/types.ts +1 -0
  117. package/src/config/update-bulletin-format.ts +54 -0
  118. package/src/config/update-bulletin-state.ts +49 -0
  119. package/src/config/update-bulletin-template-path.ts +6 -0
  120. package/src/config/update-bulletin.ts +97 -0
  121. package/src/config/vellum-skills/catalog.json +6 -0
  122. package/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts +1 -1
  123. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +44 -10
  124. package/src/config/vellum-skills/telegram-setup/SKILL.md +4 -4
  125. package/src/config/vellum-skills/trusted-contacts/SKILL.md +147 -0
  126. package/src/config/vellum-skills/twilio-setup/SKILL.md +2 -2
  127. package/src/context/window-manager.ts +43 -3
  128. package/src/daemon/config-watcher.ts +4 -2
  129. package/src/daemon/connection-policy.ts +21 -1
  130. package/src/daemon/daemon-control.ts +219 -8
  131. package/src/daemon/date-context.ts +174 -1
  132. package/src/daemon/guardian-action-generators.ts +175 -0
  133. package/src/daemon/guardian-verification-intent.ts +120 -0
  134. package/src/daemon/handlers/apps.ts +1 -3
  135. package/src/daemon/handlers/config-channels.ts +2 -2
  136. package/src/daemon/handlers/config-heartbeat.ts +1 -1
  137. package/src/daemon/handlers/config-inbox.ts +55 -159
  138. package/src/daemon/handlers/config-ingress.ts +1 -1
  139. package/src/daemon/handlers/config-integrations.ts +1 -1
  140. package/src/daemon/handlers/config-platform.ts +1 -1
  141. package/src/daemon/handlers/config-scheduling.ts +2 -2
  142. package/src/daemon/handlers/config-slack-channel.ts +190 -0
  143. package/src/daemon/handlers/config-telegram.ts +1 -1
  144. package/src/daemon/handlers/config-twilio.ts +1 -1
  145. package/src/daemon/handlers/config-voice.ts +100 -0
  146. package/src/daemon/handlers/config.ts +3 -0
  147. package/src/daemon/handlers/identity.ts +45 -25
  148. package/src/daemon/handlers/misc.ts +83 -5
  149. package/src/daemon/handlers/navigate-settings.ts +27 -0
  150. package/src/daemon/handlers/recording.ts +270 -144
  151. package/src/daemon/handlers/sessions.ts +100 -17
  152. package/src/daemon/handlers/subagents.ts +3 -3
  153. package/src/daemon/handlers/work-items.ts +10 -7
  154. package/src/daemon/ipc-contract/integrations.ts +9 -1
  155. package/src/daemon/ipc-contract/messages.ts +4 -0
  156. package/src/daemon/ipc-contract/sessions.ts +1 -1
  157. package/src/daemon/ipc-contract/settings.ts +26 -0
  158. package/src/daemon/ipc-contract/shared.ts +2 -0
  159. package/src/daemon/ipc-contract/work-items.ts +1 -7
  160. package/src/daemon/ipc-contract/workspace.ts +12 -1
  161. package/src/daemon/ipc-contract-inventory.json +6 -1
  162. package/src/daemon/ipc-contract.ts +5 -1
  163. package/src/daemon/lifecycle.ts +314 -266
  164. package/src/daemon/recording-intent.ts +0 -41
  165. package/src/daemon/response-tier.ts +2 -2
  166. package/src/daemon/server.ts +31 -9
  167. package/src/daemon/session-agent-loop-handlers.ts +34 -9
  168. package/src/daemon/session-agent-loop.ts +15 -8
  169. package/src/daemon/session-history.ts +3 -2
  170. package/src/daemon/session-media-retry.ts +3 -0
  171. package/src/daemon/session-messaging.ts +38 -4
  172. package/src/daemon/session-notifiers.ts +2 -2
  173. package/src/daemon/session-process.ts +546 -59
  174. package/src/daemon/session-queue-manager.ts +2 -0
  175. package/src/daemon/session-runtime-assembly.ts +39 -0
  176. package/src/daemon/session-skill-tools.ts +13 -4
  177. package/src/daemon/session-tool-setup.ts +5 -6
  178. package/src/daemon/session.ts +19 -8
  179. package/src/daemon/tls-certs.ts +60 -13
  180. package/src/daemon/tool-side-effects.ts +13 -5
  181. package/src/gallery/default-gallery.ts +32 -9
  182. package/src/influencer/client.ts +2 -1
  183. package/src/memory/channel-delivery-store.ts +35 -567
  184. package/src/memory/channel-guardian-store.ts +63 -1317
  185. package/src/memory/conflict-store.ts +4 -4
  186. package/src/memory/conversation-attention-store.ts +0 -3
  187. package/src/memory/conversation-crud.ts +668 -0
  188. package/src/memory/conversation-queries.ts +361 -0
  189. package/src/memory/conversation-store.ts +44 -983
  190. package/src/memory/db-connection.ts +3 -0
  191. package/src/memory/db-init.ts +33 -0
  192. package/src/memory/delivery-channels.ts +175 -0
  193. package/src/memory/delivery-crud.ts +211 -0
  194. package/src/memory/delivery-status.ts +199 -0
  195. package/src/memory/embedding-backend.ts +70 -4
  196. package/src/memory/embedding-local.ts +12 -2
  197. package/src/memory/entity-extractor.ts +3 -8
  198. package/src/memory/fts-reconciler.ts +136 -0
  199. package/src/memory/guardian-action-store.ts +418 -5
  200. package/src/memory/guardian-approvals.ts +569 -0
  201. package/src/memory/guardian-bindings.ts +130 -0
  202. package/src/memory/guardian-rate-limits.ts +196 -0
  203. package/src/memory/guardian-verification.ts +521 -0
  204. package/src/memory/job-handlers/index-maintenance.ts +2 -1
  205. package/src/memory/job-utils.ts +8 -5
  206. package/src/memory/jobs-store.ts +66 -6
  207. package/src/memory/jobs-worker.ts +23 -1
  208. package/src/memory/migrations/030-guardian-action-followup.ts +21 -0
  209. package/src/memory/migrations/030-guardian-verification-purpose.ts +17 -0
  210. package/src/memory/migrations/031-conversations-thread-type-index.ts +5 -0
  211. package/src/memory/migrations/032-guardian-delivery-conversation-index.ts +15 -0
  212. package/src/memory/migrations/032-notification-delivery-thread-decision.ts +20 -0
  213. package/src/memory/migrations/100-core-tables.ts +1 -1
  214. package/src/memory/migrations/101-watchers-and-logs.ts +4 -0
  215. package/src/memory/migrations/108-tasks-and-work-items.ts +1 -1
  216. package/src/memory/migrations/112-assistant-inbox.ts +1 -1
  217. package/src/memory/migrations/113-late-migrations.ts +1 -1
  218. package/src/memory/migrations/116-messages-fts.ts +13 -0
  219. package/src/memory/migrations/119-schema-indexes-and-columns.ts +37 -0
  220. package/src/memory/migrations/120-fk-cascade-rebuilds.ts +161 -0
  221. package/src/memory/migrations/index.ts +10 -3
  222. package/src/memory/migrations/validate-migration-state.ts +114 -15
  223. package/src/memory/qdrant-circuit-breaker.ts +105 -0
  224. package/src/memory/retriever.ts +46 -13
  225. package/src/memory/schema-migration.ts +4 -0
  226. package/src/memory/schema.ts +31 -8
  227. package/src/memory/search/semantic.ts +8 -90
  228. package/src/notifications/README.md +159 -18
  229. package/src/notifications/broadcaster.ts +69 -33
  230. package/src/notifications/conversation-pairing.ts +99 -21
  231. package/src/notifications/decision-engine.ts +176 -8
  232. package/src/notifications/deliveries-store.ts +39 -8
  233. package/src/notifications/emit-signal.ts +1 -0
  234. package/src/notifications/preferences-store.ts +7 -7
  235. package/src/notifications/thread-candidates.ts +269 -0
  236. package/src/notifications/types.ts +19 -0
  237. package/src/permissions/checker.ts +1 -16
  238. package/src/permissions/defaults.ts +25 -5
  239. package/src/permissions/prompter.ts +17 -0
  240. package/src/permissions/trust-store.ts +2 -0
  241. package/src/providers/failover.ts +19 -0
  242. package/src/providers/registry.ts +46 -1
  243. package/src/runtime/approval-message-composer.ts +1 -1
  244. package/src/runtime/channel-guardian-service.ts +15 -3
  245. package/src/runtime/channel-retry-sweep.ts +7 -2
  246. package/src/runtime/guardian-action-conversation-turn.ts +85 -0
  247. package/src/runtime/guardian-action-followup-executor.ts +301 -0
  248. package/src/runtime/guardian-action-message-composer.ts +245 -0
  249. package/src/runtime/guardian-outbound-actions.ts +26 -6
  250. package/src/runtime/guardian-verification-templates.ts +15 -9
  251. package/src/runtime/http-errors.ts +93 -0
  252. package/src/runtime/http-server.ts +133 -44
  253. package/src/runtime/http-types.ts +53 -0
  254. package/src/runtime/ingress-service.ts +237 -0
  255. package/src/runtime/middleware/error-handler.ts +4 -3
  256. package/src/runtime/middleware/rate-limiter.ts +160 -0
  257. package/src/runtime/middleware/request-logger.ts +71 -0
  258. package/src/runtime/middleware/twilio-validation.ts +7 -6
  259. package/src/runtime/pending-interactions.ts +12 -0
  260. package/src/runtime/routes/access-request-decision.ts +215 -0
  261. package/src/runtime/routes/app-routes.ts +25 -18
  262. package/src/runtime/routes/approval-routes.ts +18 -47
  263. package/src/runtime/routes/attachment-routes.ts +15 -41
  264. package/src/runtime/routes/call-routes.ts +20 -20
  265. package/src/runtime/routes/channel-delivery-routes.ts +6 -5
  266. package/src/runtime/routes/contact-routes.ts +4 -9
  267. package/src/runtime/routes/conversation-attention-routes.ts +2 -1
  268. package/src/runtime/routes/conversation-routes.ts +26 -57
  269. package/src/runtime/routes/debug-routes.ts +71 -0
  270. package/src/runtime/routes/events-routes.ts +3 -2
  271. package/src/runtime/routes/guardian-approval-interception.ts +221 -0
  272. package/src/runtime/routes/identity-routes.ts +14 -10
  273. package/src/runtime/routes/inbound-conversation.ts +3 -2
  274. package/src/runtime/routes/inbound-message-handler.ts +527 -62
  275. package/src/runtime/routes/ingress-routes.ts +174 -0
  276. package/src/runtime/routes/integration-routes.ts +78 -16
  277. package/src/runtime/routes/pairing-routes.ts +11 -10
  278. package/src/runtime/routes/secret-routes.ts +10 -18
  279. package/src/runtime/verification-rate-limiter.ts +83 -0
  280. package/src/schedule/schedule-store.ts +13 -1
  281. package/src/schedule/scheduler.ts +1 -1
  282. package/src/security/secret-ingress.ts +5 -2
  283. package/src/security/secret-scanner.ts +72 -6
  284. package/src/subagent/manager.ts +6 -4
  285. package/src/swarm/plan-validator.ts +4 -1
  286. package/src/tasks/task-runner.ts +3 -1
  287. package/src/tools/browser/api-map.ts +9 -6
  288. package/src/tools/calls/call-start.ts +20 -0
  289. package/src/tools/executor.ts +50 -568
  290. package/src/tools/permission-checker.ts +271 -0
  291. package/src/tools/registry.ts +14 -6
  292. package/src/tools/reminder/reminder-store.ts +7 -7
  293. package/src/tools/reminder/reminder.ts +6 -3
  294. package/src/tools/secret-detection-handler.ts +301 -0
  295. package/src/tools/subagent/message.ts +1 -1
  296. package/src/tools/system/voice-config.ts +62 -0
  297. package/src/tools/tasks/index.ts +3 -3
  298. package/src/tools/tasks/work-item-list.ts +3 -3
  299. package/src/tools/tasks/work-item-update.ts +4 -5
  300. package/src/tools/tool-approval-handler.ts +192 -0
  301. package/src/tools/tool-manifest.ts +2 -0
  302. package/src/version.ts +29 -2
  303. package/src/watcher/watcher-store.ts +9 -9
  304. package/src/work-items/work-item-runner.ts +9 -6
  305. /package/src/memory/migrations/{026-embeddings-nullable-vector-json.ts → 026a-embeddings-nullable-vector-json.ts} +0 -0
  306. /package/src/memory/migrations/{027-guardian-bootstrap-token.ts → 027a-guardian-bootstrap-token.ts} +0 -0
@@ -0,0 +1,361 @@
1
+ import { and, asc, count, desc, eq, gte, lt, ne, or, sql } from 'drizzle-orm';
2
+
3
+ import { getLogger } from '../util/logger.js';
4
+ import type { ConversationRow, MessageRow } from './conversation-crud.js';
5
+ import { parseConversation, parseMessage } from './conversation-crud.js';
6
+ import { getDb, rawAll } from './db.js';
7
+ import { conversations, messages } from './schema.js';
8
+ import { buildFtsMatchQuery } from './search/lexical.js';
9
+
10
+ const log = getLogger('conversation-store');
11
+
12
+ export function listConversations(limit?: number, includeBackground = false, offset = 0): ConversationRow[] {
13
+ const db = getDb();
14
+ const where = includeBackground ? undefined : sql`${conversations.threadType} != 'background'`;
15
+ const query = db
16
+ .select()
17
+ .from(conversations)
18
+ .where(where)
19
+ .orderBy(desc(conversations.updatedAt))
20
+ .limit(limit ?? 100)
21
+ .offset(offset);
22
+ return query.all().map(parseConversation);
23
+ }
24
+
25
+ export function countConversations(includeBackground = false): number {
26
+ const db = getDb();
27
+ const where = includeBackground ? undefined : sql`${conversations.threadType} != 'background'`;
28
+ const [{ total }] = db
29
+ .select({ total: count() })
30
+ .from(conversations)
31
+ .where(where)
32
+ .all();
33
+ return total;
34
+ }
35
+
36
+ export function getLatestConversation(): ConversationRow | null {
37
+ const db = getDb();
38
+ const row = db
39
+ .select()
40
+ .from(conversations)
41
+ .where(sql`${conversations.threadType} != 'background'`)
42
+ .orderBy(desc(conversations.updatedAt))
43
+ .limit(1)
44
+ .get();
45
+ return row ? parseConversation(row) : null;
46
+ }
47
+
48
+ /**
49
+ * Get the next message in a conversation after a given message.
50
+ * Uses gte + ne(id) instead of gt on timestamp so that messages sharing the
51
+ * same millisecond (common in legacy conversations where an assistant turn and
52
+ * the following user tool_result are saved in the same tick) are not skipped.
53
+ */
54
+ export function getNextMessage(conversationId: string, afterTimestamp: number, excludeMessageId: string): MessageRow | null {
55
+ const db = getDb();
56
+ const row = db
57
+ .select()
58
+ .from(messages)
59
+ .where(and(
60
+ eq(messages.conversationId, conversationId),
61
+ gte(messages.createdAt, afterTimestamp),
62
+ ne(messages.id, excludeMessageId),
63
+ ))
64
+ .orderBy(asc(messages.createdAt), asc(messages.id))
65
+ .limit(1)
66
+ .get();
67
+ return row ? parseMessage(row) : null;
68
+ }
69
+
70
+ export interface PaginatedMessagesResult {
71
+ messages: MessageRow[];
72
+ /** Whether older messages exist beyond the returned page. */
73
+ hasMore: boolean;
74
+ }
75
+
76
+ /**
77
+ * Paginated variant of getMessages. Returns the most recent `limit` messages
78
+ * (optionally before a cursor timestamp), in chronological order.
79
+ *
80
+ * When `limit` is undefined, all matching messages are returned (no pagination).
81
+ * When `beforeMessageId` is provided alongside `beforeTimestamp`, it acts as a
82
+ * tie-breaker to avoid skipping messages that share the same millisecond timestamp
83
+ * at page boundaries.
84
+ */
85
+ export function getMessagesPaginated(
86
+ conversationId: string,
87
+ limit: number | undefined,
88
+ beforeTimestamp?: number,
89
+ beforeMessageId?: string,
90
+ ): PaginatedMessagesResult {
91
+ const db = getDb();
92
+ const conditions = [eq(messages.conversationId, conversationId)];
93
+ if (beforeTimestamp !== undefined) {
94
+ if (beforeMessageId) {
95
+ // Proper compound cursor: fetch messages that are strictly older, OR
96
+ // share the same timestamp but have a smaller ID. This avoids both
97
+ // duplicates and skipped messages when multiple rows share a timestamp.
98
+ conditions.push(or(
99
+ lt(messages.createdAt, beforeTimestamp),
100
+ and(eq(messages.createdAt, beforeTimestamp), lt(messages.id, beforeMessageId)),
101
+ )!);
102
+ } else {
103
+ // Legacy callers without a message ID tie-breaker: use strict lt.
104
+ // This may skip same-millisecond messages at boundaries, but avoids
105
+ // re-fetching the boundary message. New callers should prefer the
106
+ // compound cursor (beforeTimestamp + beforeMessageId).
107
+ conditions.push(lt(messages.createdAt, beforeTimestamp));
108
+ }
109
+ }
110
+
111
+ if (limit === undefined) {
112
+ // Unlimited: return all messages in chronological order, no pagination.
113
+ const rows = db
114
+ .select()
115
+ .from(messages)
116
+ .where(and(...conditions))
117
+ .orderBy(asc(messages.createdAt), asc(messages.id))
118
+ .all()
119
+ .map(parseMessage);
120
+ return { messages: rows, hasMore: false };
121
+ }
122
+
123
+ // Fetch limit+1 rows ordered newest-first so we can detect hasMore
124
+ const rows = db
125
+ .select()
126
+ .from(messages)
127
+ .where(and(...conditions))
128
+ .orderBy(desc(messages.createdAt), desc(messages.id))
129
+ .limit(limit + 1)
130
+ .all()
131
+ .map(parseMessage);
132
+
133
+ const hasMore = rows.length > limit;
134
+ const page = hasMore ? rows.slice(0, limit) : rows;
135
+
136
+ // Return in chronological order (oldest first) for the client
137
+ page.reverse();
138
+
139
+ return { messages: page, hasMore };
140
+ }
141
+
142
+ /**
143
+ * Check whether the last user message in a conversation is a tool_result-only
144
+ * message (i.e., not a real user-typed message). This is used by undo() to
145
+ * determine if additional exchanges need to be deleted from the DB.
146
+ */
147
+ export function isLastUserMessageToolResult(conversationId: string): boolean {
148
+ const db = getDb();
149
+ const lastUserMsg = db
150
+ .select({ content: messages.content })
151
+ .from(messages)
152
+ .where(and(eq(messages.conversationId, conversationId), eq(messages.role, 'user')))
153
+ .orderBy(sql`rowid DESC`)
154
+ .limit(1)
155
+ .get();
156
+
157
+ if (!lastUserMsg) return false;
158
+
159
+ try {
160
+ const parsed = JSON.parse(lastUserMsg.content);
161
+ if (Array.isArray(parsed) && parsed.length > 0 && parsed.every((block: Record<string, unknown>) => block.type === 'tool_result')) {
162
+ return true;
163
+ }
164
+ } catch {
165
+ // Not JSON — it's a plain text user message
166
+ }
167
+ return false;
168
+ }
169
+
170
+ export interface ConversationSearchResult {
171
+ conversationId: string;
172
+ conversationTitle: string | null;
173
+ conversationUpdatedAt: number;
174
+ matchingMessages: Array<{
175
+ messageId: string;
176
+ role: string;
177
+ /** Plain-text excerpt around the match, truncated to ~200 chars. */
178
+ excerpt: string;
179
+ createdAt: number;
180
+ }>;
181
+ }
182
+
183
+ /**
184
+ * Full-text search across message content using FTS5.
185
+ * Uses the messages_fts virtual table for fast tokenized matching on message
186
+ * content, with a LIKE fallback on conversation titles. Returns matching
187
+ * conversations with their relevant messages, ordered by most recently updated.
188
+ */
189
+ export function searchConversations(
190
+ query: string,
191
+ opts?: { limit?: number; maxMessagesPerConversation?: number },
192
+ ): ConversationSearchResult[] {
193
+ if (!query.trim()) return [];
194
+
195
+ const db = getDb();
196
+ const limit = opts?.limit ?? 20;
197
+ const maxMsgsPerConv = opts?.maxMessagesPerConversation ?? 3;
198
+
199
+ const ftsMatch = buildFtsMatchQuery(query.trim());
200
+
201
+ // LIKE pattern for title matching (FTS only covers message content).
202
+ const titlePattern = `%${query.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_')}%`;
203
+
204
+ interface ConvIdRow {
205
+ conversation_id: string;
206
+ }
207
+
208
+ // Collect conversation IDs from FTS message matches and title LIKE matches,
209
+ // then merge them to produce the final set of matching conversations.
210
+ // Both paths LIMIT on distinct conversation_id to prevent a single
211
+ // conversation with many matching messages from crowding out others.
212
+ const ftsConvIds = new Set<string>();
213
+ if (ftsMatch) {
214
+ try {
215
+ const ftsRows = rawAll<ConvIdRow>(`
216
+ SELECT DISTINCT m.conversation_id
217
+ FROM messages_fts f
218
+ JOIN messages m ON m.id = f.message_id
219
+ JOIN conversations c ON c.id = m.conversation_id
220
+ WHERE messages_fts MATCH ? AND c.thread_type != 'background'
221
+ LIMIT 1000
222
+ `, ftsMatch);
223
+ for (const row of ftsRows) ftsConvIds.add(row.conversation_id);
224
+ } catch (err) {
225
+ log.warn({ err, query: query.slice(0, 80) }, 'searchConversations: FTS query failed — falling through to title matches');
226
+ }
227
+ } else if (query.trim()) {
228
+ // FTS tokens were all dropped (non-ASCII, single-char, etc.) — fall back to
229
+ // LIKE-based message content search so queries like "你", "é", or "C++" still
230
+ // match message text.
231
+ const likePattern = `%${query.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_')}%`;
232
+ const likeRows = rawAll<ConvIdRow>(`
233
+ SELECT DISTINCT m.conversation_id
234
+ FROM messages m
235
+ JOIN conversations c ON c.id = m.conversation_id
236
+ WHERE m.content LIKE ? ESCAPE '\\' AND c.thread_type != 'background'
237
+ LIMIT 1000
238
+ `, likePattern);
239
+ for (const row of likeRows) ftsConvIds.add(row.conversation_id);
240
+ }
241
+
242
+ // Title-only matches (FTS doesn't index conversation titles).
243
+ const titleMatchConvs = db
244
+ .select({ id: conversations.id })
245
+ .from(conversations)
246
+ .where(
247
+ and(
248
+ sql`${conversations.threadType} != 'background'`,
249
+ sql`${conversations.title} LIKE ${titlePattern} ESCAPE '\\'`,
250
+ ),
251
+ )
252
+ .all();
253
+ for (const row of titleMatchConvs) ftsConvIds.add(row.id);
254
+
255
+ if (ftsConvIds.size === 0) return [];
256
+
257
+ // Fetch the matching conversation rows, ordered by updatedAt, capped at limit.
258
+ const convIds = [...ftsConvIds];
259
+ const placeholders = convIds.map(() => '?').join(',');
260
+ interface ConvRow { id: string; title: string | null; updated_at: number }
261
+ const matchingConversations = rawAll<ConvRow>(
262
+ `SELECT id, title, updated_at FROM conversations
263
+ WHERE id IN (${placeholders})
264
+ ORDER BY updated_at DESC
265
+ LIMIT ?`,
266
+ ...convIds, limit,
267
+ );
268
+
269
+ if (matchingConversations.length === 0) return [];
270
+
271
+ const results: ConversationSearchResult[] = [];
272
+
273
+ for (const conv of matchingConversations) {
274
+ interface MsgRow { id: string; role: string; content: string; created_at: number }
275
+ let matchingMsgs: MsgRow[] = [];
276
+ if (ftsMatch) {
277
+ try {
278
+ matchingMsgs = rawAll<MsgRow>(`
279
+ SELECT m.id, m.role, m.content, m.created_at
280
+ FROM messages_fts f
281
+ JOIN messages m ON m.id = f.message_id
282
+ WHERE messages_fts MATCH ? AND m.conversation_id = ?
283
+ ORDER BY m.created_at ASC
284
+ LIMIT ?
285
+ `, ftsMatch, conv.id, maxMsgsPerConv);
286
+ } catch (err) {
287
+ log.warn({ err, conversationId: conv.id }, 'searchConversations: FTS per-conversation query failed');
288
+ }
289
+ } else if (query.trim()) {
290
+ // LIKE fallback for non-ASCII / short-token queries.
291
+ const msgLikePattern = `%${query.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_')}%`;
292
+ matchingMsgs = rawAll<MsgRow>(`
293
+ SELECT id, role, content, created_at
294
+ FROM messages
295
+ WHERE conversation_id = ? AND content LIKE ? ESCAPE '\\'
296
+ ORDER BY created_at ASC
297
+ LIMIT ?
298
+ `, conv.id, msgLikePattern, maxMsgsPerConv);
299
+ }
300
+
301
+ results.push({
302
+ conversationId: conv.id,
303
+ conversationTitle: conv.title,
304
+ conversationUpdatedAt: conv.updated_at,
305
+ matchingMessages: matchingMsgs.map((m) => ({
306
+ messageId: m.id,
307
+ role: m.role,
308
+ excerpt: buildExcerpt(m.content, query),
309
+ createdAt: m.created_at,
310
+ })),
311
+ });
312
+ }
313
+
314
+ return results;
315
+ }
316
+
317
+ /**
318
+ * Build a short excerpt from raw message content centered around the first
319
+ * occurrence of `query`. The content may be JSON (content blocks) or plain
320
+ * text; we extract a readable snippet in either case.
321
+ */
322
+ function buildExcerpt(rawContent: string, query: string): string {
323
+ // Try to extract plain text from JSON content blocks first.
324
+ let text = rawContent;
325
+ try {
326
+ const parsed = JSON.parse(rawContent);
327
+ if (Array.isArray(parsed)) {
328
+ const parts: string[] = [];
329
+ for (const block of parsed) {
330
+ if (typeof block === 'object' && block != null) {
331
+ if (block.type === 'text' && typeof block.text === 'string') {
332
+ parts.push(block.text);
333
+ } else if (block.type === 'tool_result') {
334
+ const inner = Array.isArray(block.content) ? block.content : [];
335
+ for (const ib of inner) {
336
+ if (ib?.type === 'text' && typeof ib.text === 'string') parts.push(ib.text);
337
+ }
338
+ }
339
+ }
340
+ }
341
+ if (parts.length > 0) text = parts.join(' ');
342
+ } else if (typeof parsed === 'string') {
343
+ text = parsed;
344
+ }
345
+ } catch {
346
+ // Not JSON — use as-is
347
+ }
348
+
349
+ const WINDOW = 100;
350
+ const lowerText = text.toLowerCase();
351
+ const lowerQuery = query.toLowerCase();
352
+ const idx = lowerText.indexOf(lowerQuery);
353
+ if (idx === -1) {
354
+ // Query matched the raw JSON but not the extracted text — fall back to raw start
355
+ return text.slice(0, WINDOW * 2).replace(/\s+/g, ' ').trim();
356
+ }
357
+ const start = Math.max(0, idx - WINDOW);
358
+ const end = Math.min(text.length, idx + query.length + WINDOW);
359
+ const excerpt = (start > 0 ? '\u2026' : '') + text.slice(start, end).replace(/\s+/g, ' ').trim() + (end < text.length ? '\u2026' : '');
360
+ return excerpt;
361
+ }