@vellumai/assistant 0.3.14 → 0.3.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (295) hide show
  1. package/ARCHITECTURE.md +142 -0
  2. package/Dockerfile +2 -2
  3. package/README.md +5 -5
  4. package/docs/architecture/http-token-refresh.md +252 -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 +331 -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 +16 -14
  22. package/src/__tests__/checker.test.ts +24 -0
  23. package/src/__tests__/computer-use-skill-manifest-regression.test.ts +2 -2
  24. package/src/__tests__/config-watcher.test.ts +358 -0
  25. package/src/__tests__/conversation-pairing.test.ts +24 -24
  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 +294 -0
  37. package/src/__tests__/guardian-action-no-hardcoded-copy.test.ts +71 -0
  38. package/src/__tests__/guardian-action-sweep.test.ts +9 -9
  39. package/src/__tests__/guardian-control-plane-policy.test.ts +1 -3
  40. package/src/__tests__/guardian-outbound-http.test.ts +202 -10
  41. package/src/__tests__/guardian-verification-intent-routing.test.ts +179 -0
  42. package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +141 -0
  43. package/src/__tests__/handlers-telegram-config.test.ts +6 -6
  44. package/src/__tests__/hooks-runner.test.ts +13 -4
  45. package/src/__tests__/ingress-routes-http.test.ts +443 -0
  46. package/src/__tests__/intent-routing.test.ts +14 -0
  47. package/src/__tests__/ipc-snapshot.test.ts +2 -5
  48. package/src/__tests__/media-reuse-story.e2e.test.ts +7 -7
  49. package/src/__tests__/memory-regressions.test.ts +16 -12
  50. package/src/__tests__/non-member-access-request.test.ts +282 -0
  51. package/src/__tests__/notification-decision-strategy.test.ts +136 -0
  52. package/src/__tests__/notification-routing-intent.test.ts +11 -2
  53. package/src/__tests__/notification-thread-candidates.test.ts +166 -0
  54. package/src/__tests__/recording-intent-fallback.test.ts +0 -1
  55. package/src/__tests__/recording-intent-handler.test.ts +6 -3
  56. package/src/__tests__/recording-intent.test.ts +3 -2
  57. package/src/__tests__/recording-state-machine.test.ts +337 -26
  58. package/src/__tests__/registry.test.ts +17 -8
  59. package/src/__tests__/relay-server.test.ts +105 -0
  60. package/src/__tests__/reminder.test.ts +13 -0
  61. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -4
  62. package/src/__tests__/scheduler-recurrence.test.ts +50 -0
  63. package/src/__tests__/server-history-render.test.ts +8 -8
  64. package/src/__tests__/session-agent-loop.test.ts +1 -0
  65. package/src/__tests__/session-runtime-assembly.test.ts +49 -0
  66. package/src/__tests__/session-skill-tools.test.ts +1 -0
  67. package/src/__tests__/skill-projection.benchmark.test.ts +11 -3
  68. package/src/__tests__/slack-channel-config.test.ts +230 -0
  69. package/src/__tests__/subagent-manager-notify.test.ts +4 -4
  70. package/src/__tests__/swarm-session-integration.test.ts +2 -2
  71. package/src/__tests__/system-prompt.test.ts +43 -0
  72. package/src/__tests__/task-management-tools.test.ts +3 -3
  73. package/src/__tests__/task-tools.test.ts +3 -3
  74. package/src/__tests__/trust-store.test.ts +17 -1
  75. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +491 -0
  76. package/src/__tests__/trusted-contact-multichannel.test.ts +409 -0
  77. package/src/__tests__/trusted-contact-verification.test.ts +360 -0
  78. package/src/__tests__/update-bulletin-format.test.ts +119 -0
  79. package/src/__tests__/update-bulletin-state.test.ts +129 -0
  80. package/src/__tests__/update-bulletin.test.ts +260 -0
  81. package/src/__tests__/update-template-contract.test.ts +29 -0
  82. package/src/agent/loop.ts +2 -2
  83. package/src/amazon/client.ts +2 -3
  84. package/src/calls/call-controller.ts +115 -34
  85. package/src/calls/call-conversation-messages.ts +2 -2
  86. package/src/calls/call-domain.ts +10 -3
  87. package/src/calls/call-pointer-messages.ts +17 -5
  88. package/src/calls/guardian-action-sweep.ts +77 -36
  89. package/src/calls/relay-server.ts +51 -12
  90. package/src/calls/twilio-routes.ts +3 -1
  91. package/src/calls/types.ts +1 -1
  92. package/src/calls/voice-session-bridge.ts +4 -4
  93. package/src/cli/core-commands.ts +3 -3
  94. package/src/cli/map.ts +8 -5
  95. package/src/config/bundled-skills/phone-calls/SKILL.md +16 -1
  96. package/src/config/bundled-skills/tasks/SKILL.md +1 -1
  97. package/src/config/bundled-skills/tasks/TOOLS.json +4 -4
  98. package/src/config/bundled-skills/time-based-actions/SKILL.md +11 -1
  99. package/src/config/computer-use-prompt.ts +1 -0
  100. package/src/config/core-schema.ts +16 -0
  101. package/src/config/env-registry.ts +1 -0
  102. package/src/config/env.ts +16 -1
  103. package/src/config/memory-schema.ts +5 -0
  104. package/src/config/schema.ts +4 -0
  105. package/src/config/system-prompt.ts +69 -2
  106. package/src/config/templates/BOOTSTRAP.md +1 -1
  107. package/src/config/templates/IDENTITY.md +8 -4
  108. package/src/config/templates/SOUL.md +14 -0
  109. package/src/config/templates/UPDATES.md +16 -0
  110. package/src/config/templates/USER.md +5 -1
  111. package/src/config/types.ts +1 -0
  112. package/src/config/update-bulletin-format.ts +52 -0
  113. package/src/config/update-bulletin-state.ts +49 -0
  114. package/src/config/update-bulletin.ts +82 -0
  115. package/src/config/vellum-skills/catalog.json +6 -0
  116. package/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts +1 -1
  117. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +44 -10
  118. package/src/config/vellum-skills/telegram-setup/SKILL.md +4 -4
  119. package/src/config/vellum-skills/trusted-contacts/SKILL.md +147 -0
  120. package/src/config/vellum-skills/twilio-setup/SKILL.md +2 -2
  121. package/src/context/window-manager.ts +43 -3
  122. package/src/daemon/config-watcher.ts +1 -0
  123. package/src/daemon/connection-policy.ts +21 -1
  124. package/src/daemon/daemon-control.ts +164 -7
  125. package/src/daemon/date-context.ts +174 -1
  126. package/src/daemon/guardian-action-generators.ts +175 -0
  127. package/src/daemon/guardian-verification-intent.ts +120 -0
  128. package/src/daemon/handlers/apps.ts +1 -3
  129. package/src/daemon/handlers/config-channels.ts +8 -8
  130. package/src/daemon/handlers/config-heartbeat.ts +1 -1
  131. package/src/daemon/handlers/config-inbox.ts +55 -159
  132. package/src/daemon/handlers/config-ingress.ts +1 -1
  133. package/src/daemon/handlers/config-integrations.ts +1 -1
  134. package/src/daemon/handlers/config-platform.ts +1 -1
  135. package/src/daemon/handlers/config-scheduling.ts +2 -2
  136. package/src/daemon/handlers/config-slack-channel.ts +190 -0
  137. package/src/daemon/handlers/config-telegram.ts +1 -1
  138. package/src/daemon/handlers/config-twilio.ts +1 -1
  139. package/src/daemon/handlers/config-voice.ts +100 -0
  140. package/src/daemon/handlers/config.ts +3 -0
  141. package/src/daemon/handlers/index.ts +1 -1
  142. package/src/daemon/handlers/misc.ts +84 -6
  143. package/src/daemon/handlers/navigate-settings.ts +27 -0
  144. package/src/daemon/handlers/recording.ts +270 -144
  145. package/src/daemon/handlers/sessions.ts +107 -24
  146. package/src/daemon/handlers/subagents.ts +3 -3
  147. package/src/daemon/handlers/work-items.ts +10 -7
  148. package/src/daemon/ipc-contract/integrations.ts +9 -1
  149. package/src/daemon/ipc-contract/messages.ts +4 -0
  150. package/src/daemon/ipc-contract/sessions.ts +1 -1
  151. package/src/daemon/ipc-contract/settings.ts +26 -0
  152. package/src/daemon/ipc-contract/shared.ts +2 -0
  153. package/src/daemon/ipc-contract/work-items.ts +1 -7
  154. package/src/daemon/ipc-contract-inventory.json +5 -1
  155. package/src/daemon/ipc-contract.ts +5 -1
  156. package/src/daemon/lifecycle.ts +306 -266
  157. package/src/daemon/recording-executor.ts +1 -1
  158. package/src/daemon/recording-intent.ts +0 -41
  159. package/src/daemon/response-tier.ts +2 -2
  160. package/src/daemon/server.ts +6 -6
  161. package/src/daemon/session-agent-loop-handlers.ts +34 -9
  162. package/src/daemon/session-agent-loop.ts +15 -8
  163. package/src/daemon/session-history.ts +3 -2
  164. package/src/daemon/session-media-retry.ts +3 -0
  165. package/src/daemon/session-messaging.ts +38 -4
  166. package/src/daemon/session-notifiers.ts +2 -2
  167. package/src/daemon/session-process.ts +256 -23
  168. package/src/daemon/session-queue-manager.ts +2 -0
  169. package/src/daemon/session-runtime-assembly.ts +39 -0
  170. package/src/daemon/session-skill-tools.ts +13 -4
  171. package/src/daemon/session-tool-setup.ts +6 -7
  172. package/src/daemon/session.ts +19 -8
  173. package/src/daemon/tls-certs.ts +55 -13
  174. package/src/daemon/tool-side-effects.ts +13 -5
  175. package/src/gallery/default-gallery.ts +32 -9
  176. package/src/influencer/client.ts +2 -1
  177. package/src/memory/channel-delivery-store.ts +37 -567
  178. package/src/memory/channel-guardian-store.ts +66 -1317
  179. package/src/memory/conflict-store.ts +4 -4
  180. package/src/memory/conversation-attention-store.ts +4 -7
  181. package/src/memory/conversation-crud.ts +668 -0
  182. package/src/memory/conversation-queries.ts +361 -0
  183. package/src/memory/conversation-store.ts +45 -983
  184. package/src/memory/db-connection.ts +3 -0
  185. package/src/memory/db-init.ts +25 -0
  186. package/src/memory/delivery-channels.ts +175 -0
  187. package/src/memory/delivery-crud.ts +211 -0
  188. package/src/memory/delivery-status.ts +199 -0
  189. package/src/memory/embedding-backend.ts +70 -4
  190. package/src/memory/embedding-local.ts +12 -2
  191. package/src/memory/entity-extractor.ts +3 -8
  192. package/src/memory/fts-reconciler.ts +121 -0
  193. package/src/memory/guardian-action-store.ts +366 -3
  194. package/src/memory/guardian-approvals.ts +569 -0
  195. package/src/memory/guardian-bindings.ts +130 -0
  196. package/src/memory/guardian-rate-limits.ts +196 -0
  197. package/src/memory/guardian-verification.ts +520 -0
  198. package/src/memory/job-handlers/index-maintenance.ts +2 -1
  199. package/src/memory/job-utils.ts +8 -5
  200. package/src/memory/jobs-store.ts +66 -6
  201. package/src/memory/jobs-worker.ts +23 -1
  202. package/src/memory/migrations/030-guardian-action-followup.ts +21 -0
  203. package/src/memory/migrations/030-guardian-verification-purpose.ts +17 -0
  204. package/src/memory/migrations/031-conversations-thread-type-index.ts +5 -0
  205. package/src/memory/migrations/100-core-tables.ts +1 -1
  206. package/src/memory/migrations/101-watchers-and-logs.ts +4 -0
  207. package/src/memory/migrations/108-tasks-and-work-items.ts +1 -1
  208. package/src/memory/migrations/112-assistant-inbox.ts +1 -1
  209. package/src/memory/migrations/113-late-migrations.ts +1 -1
  210. package/src/memory/migrations/116-messages-fts.ts +13 -0
  211. package/src/memory/migrations/119-schema-indexes-and-columns.ts +37 -0
  212. package/src/memory/migrations/120-fk-cascade-rebuilds.ts +161 -0
  213. package/src/memory/migrations/index.ts +8 -3
  214. package/src/memory/migrations/validate-migration-state.ts +114 -15
  215. package/src/memory/qdrant-circuit-breaker.ts +105 -0
  216. package/src/memory/retriever.ts +46 -13
  217. package/src/memory/schema-migration.ts +3 -0
  218. package/src/memory/schema.ts +25 -7
  219. package/src/memory/search/semantic.ts +8 -90
  220. package/src/notifications/README.md +1 -1
  221. package/src/notifications/broadcaster.ts +20 -2
  222. package/src/notifications/conversation-pairing.ts +3 -3
  223. package/src/notifications/decision-engine.ts +173 -8
  224. package/src/notifications/deliveries-store.ts +27 -8
  225. package/src/notifications/preferences-store.ts +7 -7
  226. package/src/notifications/thread-candidates.ts +234 -0
  227. package/src/notifications/types.ts +18 -0
  228. package/src/permissions/defaults.ts +11 -1
  229. package/src/permissions/prompter.ts +17 -0
  230. package/src/permissions/trust-store.ts +2 -0
  231. package/src/providers/failover.ts +19 -0
  232. package/src/providers/registry.ts +46 -1
  233. package/src/runtime/approval-message-composer.ts +1 -1
  234. package/src/runtime/channel-guardian-service.ts +15 -3
  235. package/src/runtime/channel-retry-sweep.ts +7 -2
  236. package/src/runtime/guardian-action-conversation-turn.ts +85 -0
  237. package/src/runtime/guardian-action-followup-executor.ts +301 -0
  238. package/src/runtime/guardian-action-message-composer.ts +245 -0
  239. package/src/runtime/guardian-outbound-actions.ts +35 -15
  240. package/src/runtime/guardian-verification-templates.ts +15 -9
  241. package/src/runtime/http-errors.ts +93 -0
  242. package/src/runtime/http-server.ts +140 -51
  243. package/src/runtime/http-types.ts +53 -0
  244. package/src/runtime/ingress-service.ts +237 -0
  245. package/src/runtime/middleware/error-handler.ts +4 -3
  246. package/src/runtime/middleware/rate-limiter.ts +160 -0
  247. package/src/runtime/middleware/request-logger.ts +71 -0
  248. package/src/runtime/middleware/twilio-validation.ts +7 -6
  249. package/src/runtime/pending-interactions.ts +12 -0
  250. package/src/runtime/routes/access-request-decision.ts +215 -0
  251. package/src/runtime/routes/app-routes.ts +25 -18
  252. package/src/runtime/routes/approval-routes.ts +18 -47
  253. package/src/runtime/routes/attachment-routes.ts +15 -41
  254. package/src/runtime/routes/call-routes.ts +20 -20
  255. package/src/runtime/routes/channel-delivery-routes.ts +6 -5
  256. package/src/runtime/routes/contact-routes.ts +4 -9
  257. package/src/runtime/routes/conversation-attention-routes.ts +5 -4
  258. package/src/runtime/routes/conversation-routes.ts +26 -57
  259. package/src/runtime/routes/debug-routes.ts +71 -0
  260. package/src/runtime/routes/events-routes.ts +3 -2
  261. package/src/runtime/routes/guardian-approval-interception.ts +221 -0
  262. package/src/runtime/routes/identity-routes.ts +14 -10
  263. package/src/runtime/routes/inbound-conversation.ts +3 -2
  264. package/src/runtime/routes/inbound-message-handler.ts +527 -62
  265. package/src/runtime/routes/ingress-routes.ts +174 -0
  266. package/src/runtime/routes/integration-routes.ts +82 -20
  267. package/src/runtime/routes/pairing-routes.ts +11 -10
  268. package/src/runtime/routes/secret-routes.ts +10 -18
  269. package/src/runtime/verification-rate-limiter.ts +83 -0
  270. package/src/schedule/schedule-store.ts +13 -1
  271. package/src/schedule/scheduler.ts +2 -2
  272. package/src/security/secret-ingress.ts +5 -2
  273. package/src/security/secret-scanner.ts +72 -6
  274. package/src/subagent/manager.ts +6 -4
  275. package/src/swarm/plan-validator.ts +4 -1
  276. package/src/tasks/task-runner.ts +3 -1
  277. package/src/tools/browser/api-map.ts +9 -6
  278. package/src/tools/calls/call-start.ts +20 -0
  279. package/src/tools/executor.ts +50 -568
  280. package/src/tools/permission-checker.ts +272 -0
  281. package/src/tools/registry.ts +14 -6
  282. package/src/tools/reminder/reminder-store.ts +7 -7
  283. package/src/tools/reminder/reminder.ts +6 -3
  284. package/src/tools/secret-detection-handler.ts +301 -0
  285. package/src/tools/subagent/message.ts +1 -1
  286. package/src/tools/system/voice-config.ts +62 -0
  287. package/src/tools/tasks/index.ts +3 -3
  288. package/src/tools/tasks/work-item-list.ts +3 -3
  289. package/src/tools/tasks/work-item-update.ts +4 -5
  290. package/src/tools/tool-approval-handler.ts +192 -0
  291. package/src/tools/tool-manifest.ts +2 -0
  292. package/src/watcher/watcher-store.ts +9 -9
  293. package/src/work-items/work-item-runner.ts +9 -6
  294. /package/src/memory/migrations/{026-embeddings-nullable-vector-json.ts → 026a-embeddings-nullable-vector-json.ts} +0 -0
  295. /package/src/memory/migrations/{027-guardian-bootstrap-token.ts → 027a-guardian-bootstrap-token.ts} +0 -0
@@ -1,983 +1,45 @@
1
- import { and, asc, count, desc, eq, gte, inArray, isNull, lt, ne, or, sql } from 'drizzle-orm';
2
- import { v4 as uuid } from 'uuid';
3
- import { z } from 'zod';
4
-
5
- import type { ChannelId, InterfaceId } from '../channels/types.js';
6
- import { parseChannelId, parseInterfaceId } from '../channels/types.js';
7
- import { CHANNEL_IDS, INTERFACE_IDS,isChannelId } from '../channels/types.js';
8
- import { getConfig } from '../config/loader.js';
9
- import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
10
- import { getLogger } from '../util/logger.js';
11
- import { createRowMapper } from '../util/row-mapper.js';
12
- import { deleteOrphanAttachments } from './attachments-store.js';
13
- import { projectAssistantMessage } from './conversation-attention-store.js';
14
- import { getDb, rawAll, rawExec,rawGet } from './db.js';
15
- import { indexMessageNow } from './indexer.js';
16
- import { channelInboundEvents, conversations, llmRequestLogs,memoryEmbeddings, memoryItemEntities, memoryItems, memoryItemSources, memorySegments, messageAttachments, messages, toolInvocations } from './schema.js';
17
- import { buildFtsMatchQuery } from './search/lexical.js';
18
-
19
- const log = getLogger('conversation-store');
20
-
21
- // ── Message metadata Zod schema ──────────────────────────────────────
22
- // Validates the JSON stored in messages.metadata. Known fields are typed;
23
- // extra keys are allowed via passthrough so callers can attach ad-hoc data.
24
-
25
- const channelIdSchema = z.enum(CHANNEL_IDS);
26
- const interfaceIdSchema = z.enum(INTERFACE_IDS);
27
-
28
- const subagentNotificationSchema = z.object({
29
- subagentId: z.string(),
30
- label: z.string(),
31
- status: z.enum(['completed', 'failed', 'aborted']),
32
- error: z.string().optional(),
33
- conversationId: z.string().optional(),
34
- });
35
-
36
- export const messageMetadataSchema = z.object({
37
- userMessageChannel: channelIdSchema.optional(),
38
- assistantMessageChannel: channelIdSchema.optional(),
39
- userMessageInterface: interfaceIdSchema.optional(),
40
- assistantMessageInterface: interfaceIdSchema.optional(),
41
- subagentNotification: subagentNotificationSchema.optional(),
42
- // Provenance fields for trust-aware memory gating (M3)
43
- provenanceActorRole: z.enum(['guardian', 'non-guardian', 'unverified_channel']).optional(),
44
- provenanceSourceChannel: channelIdSchema.optional(),
45
- provenanceGuardianExternalUserId: z.string().optional(),
46
- provenanceRequesterIdentifier: z.string().optional(),
47
- }).passthrough();
48
-
49
- export type MessageMetadata = z.infer<typeof messageMetadataSchema>;
50
-
51
- /**
52
- * Extract provenance metadata fields from a GuardianRuntimeContext.
53
- * When no guardian context is provided, defaults to 'unverified_channel'
54
- * because the absence of guardian context means we cannot verify trust —
55
- * callers with actual guardian trust should always supply a real context.
56
- */
57
- export function provenanceFromGuardianContext(ctx: GuardianRuntimeContext | null | undefined): Record<string, unknown> {
58
- if (!ctx) return { provenanceActorRole: 'unverified_channel' };
59
- return {
60
- provenanceActorRole: ctx.actorRole,
61
- provenanceSourceChannel: ctx.sourceChannel,
62
- provenanceGuardianExternalUserId: ctx.guardianExternalUserId,
63
- provenanceRequesterIdentifier: ctx.requesterIdentifier,
64
- };
65
- }
66
-
67
- export interface ConversationRow {
68
- id: string;
69
- title: string | null;
70
- createdAt: number;
71
- updatedAt: number;
72
- totalInputTokens: number;
73
- totalOutputTokens: number;
74
- totalEstimatedCost: number;
75
- contextSummary: string | null;
76
- contextCompactedMessageCount: number;
77
- contextCompactedAt: number | null;
78
- threadType: string;
79
- source: string;
80
- memoryScopeId: string;
81
- originChannel: string | null;
82
- originInterface: string | null;
83
- isAutoTitle: number;
84
- }
85
-
86
- const parseConversation = createRowMapper<typeof conversations.$inferSelect, ConversationRow>({
87
- id: 'id',
88
- title: 'title',
89
- createdAt: 'createdAt',
90
- updatedAt: 'updatedAt',
91
- totalInputTokens: 'totalInputTokens',
92
- totalOutputTokens: 'totalOutputTokens',
93
- totalEstimatedCost: 'totalEstimatedCost',
94
- contextSummary: 'contextSummary',
95
- contextCompactedMessageCount: 'contextCompactedMessageCount',
96
- contextCompactedAt: 'contextCompactedAt',
97
- threadType: 'threadType',
98
- source: 'source',
99
- memoryScopeId: 'memoryScopeId',
100
- originChannel: 'originChannel',
101
- originInterface: 'originInterface',
102
- isAutoTitle: 'isAutoTitle',
103
- });
104
-
105
- export interface MessageRow {
106
- id: string;
107
- conversationId: string;
108
- role: string;
109
- content: string;
110
- createdAt: number;
111
- metadata: string | null;
112
- }
113
-
114
- const parseMessage = createRowMapper<typeof messages.$inferSelect, MessageRow>({
115
- id: 'id',
116
- conversationId: 'conversationId',
117
- role: 'role',
118
- content: 'content',
119
- createdAt: 'createdAt',
120
- metadata: 'metadata',
121
- });
122
-
123
- /**
124
- * Monotonic timestamp source for message ordering. Two messages saved within
125
- * the same millisecond (e.g., tool_results user message + assistant message in
126
- * message_complete) would get the same Date.now(), making their reload order
127
- * non-deterministic. This counter ensures every call returns a strictly
128
- * increasing value so insertion order is always preserved.
129
- */
130
- let lastTimestamp = 0;
131
- function monotonicNow(): number {
132
- const now = Date.now();
133
- lastTimestamp = Math.max(now, lastTimestamp + 1);
134
- return lastTimestamp;
135
- }
136
-
137
- export function createConversation(titleOrOpts?: string | { title?: string; threadType?: 'standard' | 'private' | 'background'; source?: string }) {
138
- const db = getDb();
139
- const now = Date.now();
140
- const opts = typeof titleOrOpts === 'string' ? { title: titleOrOpts } : (titleOrOpts ?? {});
141
- const threadType = opts.threadType ?? 'standard';
142
- const source = opts.source ?? 'user';
143
- const id = uuid();
144
- const memoryScopeId = threadType === 'private' ? `private:${id}` : 'default';
145
- const conversation = {
146
- id,
147
- title: opts.title ?? null,
148
- createdAt: now,
149
- updatedAt: now,
150
- totalInputTokens: 0,
151
- totalOutputTokens: 0,
152
- totalEstimatedCost: 0,
153
- contextSummary: null as string | null,
154
- contextCompactedMessageCount: 0,
155
- contextCompactedAt: null as number | null,
156
- threadType,
157
- source,
158
- memoryScopeId,
159
- };
160
- db.insert(conversations).values(conversation).run();
161
- return conversation;
162
- }
163
-
164
- export function getConversation(id: string): ConversationRow | null {
165
- const db = getDb();
166
- const row = db
167
- .select()
168
- .from(conversations)
169
- .where(eq(conversations.id, id))
170
- .get();
171
- return row ? parseConversation(row) : null;
172
- }
173
-
174
- export function getConversationThreadType(conversationId: string): 'standard' | 'private' {
175
- const conv = getConversation(conversationId);
176
- const raw = conv?.threadType;
177
- return raw === 'private' ? 'private' : 'standard';
178
- }
179
-
180
- export function getConversationMemoryScopeId(conversationId: string): string {
181
- const conv = getConversation(conversationId);
182
- return conv?.memoryScopeId ?? 'default';
183
- }
184
-
185
- /**
186
- * Delete a conversation and all its messages.
187
- * Used for ephemeral conversations (e.g. secret-redirect placeholders)
188
- * that should not persist in session history.
189
- */
190
- export function deleteConversation(id: string): void {
191
- const db = getDb();
192
- db.transaction((tx) => {
193
- tx.delete(llmRequestLogs).where(eq(llmRequestLogs.conversationId, id)).run();
194
- tx.delete(toolInvocations).where(eq(toolInvocations.conversationId, id)).run();
195
- tx.delete(messages).where(eq(messages.conversationId, id)).run();
196
- tx.delete(conversations).where(eq(conversations.id, id)).run();
197
- });
198
- }
199
-
200
- export function listConversations(limit?: number, includeBackground = false, offset = 0): ConversationRow[] {
201
- const db = getDb();
202
- const where = includeBackground ? undefined : sql`${conversations.threadType} != 'background'`;
203
- const query = db
204
- .select()
205
- .from(conversations)
206
- .where(where)
207
- .orderBy(desc(conversations.updatedAt))
208
- .limit(limit ?? 100)
209
- .offset(offset);
210
- return query.all().map(parseConversation);
211
- }
212
-
213
- export function countConversations(includeBackground = false): number {
214
- const db = getDb();
215
- const where = includeBackground ? undefined : sql`${conversations.threadType} != 'background'`;
216
- const [{ total }] = db
217
- .select({ total: count() })
218
- .from(conversations)
219
- .where(where)
220
- .all();
221
- return total;
222
- }
223
-
224
- export function getLatestConversation(): ConversationRow | null {
225
- const db = getDb();
226
- const row = db
227
- .select()
228
- .from(conversations)
229
- .where(sql`${conversations.threadType} != 'background'`)
230
- .orderBy(desc(conversations.updatedAt))
231
- .limit(1)
232
- .get();
233
- return row ? parseConversation(row) : null;
234
- }
235
-
236
- export function addMessage(conversationId: string, role: string, content: string, metadata?: Record<string, unknown>, opts?: { skipIndexing?: boolean }) {
237
- const db = getDb();
238
- const messageId = uuid();
239
-
240
- if (metadata) {
241
- const result = messageMetadataSchema.safeParse(metadata);
242
- if (!result.success) {
243
- log.warn({ conversationId, messageId, issues: result.error.issues }, 'Invalid message metadata, storing as-is');
244
- }
245
- }
246
-
247
- const metadataStr = metadata ? JSON.stringify(metadata) : undefined;
248
- const originChannelCandidate =
249
- metadata && isChannelId(metadata.userMessageChannel)
250
- ? metadata.userMessageChannel
251
- : null;
252
- // Wrap insert + updatedAt bump in a transaction so they're atomic.
253
- // Retry on SQLITE_BUSY in case busy_timeout is exhausted under heavy contention.
254
- // Timestamp is recomputed each attempt so a late retry doesn't persist a stale updatedAt.
255
- const MAX_RETRIES = 3;
256
- let now!: number;
257
- for (let attempt = 0; ; attempt++) {
258
- now = monotonicNow();
259
- try {
260
- const values = {
261
- id: messageId,
262
- conversationId,
263
- role,
264
- content,
265
- createdAt: now,
266
- ...(metadataStr ? { metadata: metadataStr } : {}),
267
- };
268
- db.transaction((tx) => {
269
- tx.insert(messages).values(values).run();
270
- if (originChannelCandidate) {
271
- tx.update(conversations)
272
- .set({ originChannel: originChannelCandidate })
273
- .where(and(eq(conversations.id, conversationId), isNull(conversations.originChannel)))
274
- .run();
275
- }
276
- tx.update(conversations)
277
- .set({ updatedAt: now })
278
- .where(eq(conversations.id, conversationId))
279
- .run();
280
- });
281
- break;
282
- } catch (err) {
283
- if (attempt < MAX_RETRIES && (err as { code?: string }).code === 'SQLITE_BUSY') {
284
- log.warn({ attempt, conversationId }, 'addMessage: SQLITE_BUSY, retrying');
285
- Bun.sleepSync(50 * (attempt + 1));
286
- continue;
287
- }
288
- throw err;
289
- }
290
- }
291
- const message = { id: messageId, conversationId, role, content, createdAt: now, ...(metadataStr ? { metadata: metadataStr } : {}) };
292
-
293
- if (!opts?.skipIndexing) {
294
- try {
295
- const config = getConfig();
296
- const scopeId = getConversationMemoryScopeId(conversationId);
297
- const parsed = metadata ? messageMetadataSchema.safeParse(metadata) : null;
298
- const provenanceActorRole = parsed?.success ? parsed.data.provenanceActorRole : undefined;
299
- indexMessageNow({
300
- messageId: message.id,
301
- conversationId: message.conversationId,
302
- role: message.role,
303
- content: message.content,
304
- createdAt: message.createdAt,
305
- scopeId,
306
- provenanceActorRole,
307
- }, config.memory);
308
- } catch (err) {
309
- log.warn({ err, conversationId, messageId: message.id }, 'Failed to index message for memory');
310
- }
311
- }
312
-
313
- if (role === 'assistant') {
314
- try {
315
- projectAssistantMessage({
316
- conversationId,
317
- assistantId: 'self',
318
- messageId: message.id,
319
- messageAt: message.createdAt,
320
- });
321
- } catch (err) {
322
- log.warn({ err, conversationId, messageId: message.id }, 'Failed to project assistant message for attention tracking');
323
- }
324
- }
325
-
326
- return message;
327
- }
328
-
329
- export function getMessages(conversationId: string): MessageRow[] {
330
- const db = getDb();
331
- return db
332
- .select()
333
- .from(messages)
334
- .where(eq(messages.conversationId, conversationId))
335
- .orderBy(asc(messages.createdAt))
336
- .all()
337
- .map(parseMessage);
338
- }
339
-
340
- /** Fetch a single message by ID, optionally scoped to a specific conversation. */
341
- export function getMessageById(messageId: string, conversationId?: string): MessageRow | null {
342
- const db = getDb();
343
- const conditions = [eq(messages.id, messageId)];
344
- if (conversationId) {
345
- conditions.push(eq(messages.conversationId, conversationId));
346
- }
347
- const row = db
348
- .select()
349
- .from(messages)
350
- .where(and(...conditions))
351
- .get();
352
- return row ? parseMessage(row) : null;
353
- }
354
-
355
- /**
356
- * Get the next message in a conversation after a given message.
357
- * Uses gte + ne(id) instead of gt on timestamp so that messages sharing the
358
- * same millisecond (common in legacy conversations where an assistant turn and
359
- * the following user tool_result are saved in the same tick) are not skipped.
360
- */
361
- export function getNextMessage(conversationId: string, afterTimestamp: number, excludeMessageId: string): MessageRow | null {
362
- const db = getDb();
363
- const row = db
364
- .select()
365
- .from(messages)
366
- .where(and(
367
- eq(messages.conversationId, conversationId),
368
- gte(messages.createdAt, afterTimestamp),
369
- ne(messages.id, excludeMessageId),
370
- ))
371
- .orderBy(asc(messages.createdAt), asc(messages.id))
372
- .limit(1)
373
- .get();
374
- return row ? parseMessage(row) : null;
375
- }
376
-
377
- export interface PaginatedMessagesResult {
378
- messages: MessageRow[];
379
- /** Whether older messages exist beyond the returned page. */
380
- hasMore: boolean;
381
- }
382
-
383
- /**
384
- * Paginated variant of getMessages. Returns the most recent `limit` messages
385
- * (optionally before a cursor timestamp), in chronological order.
386
- *
387
- * When `limit` is undefined, all matching messages are returned (no pagination).
388
- * When `beforeMessageId` is provided alongside `beforeTimestamp`, it acts as a
389
- * tie-breaker to avoid skipping messages that share the same millisecond timestamp
390
- * at page boundaries.
391
- */
392
- export function getMessagesPaginated(
393
- conversationId: string,
394
- limit: number | undefined,
395
- beforeTimestamp?: number,
396
- beforeMessageId?: string,
397
- ): PaginatedMessagesResult {
398
- const db = getDb();
399
- const conditions = [eq(messages.conversationId, conversationId)];
400
- if (beforeTimestamp !== undefined) {
401
- if (beforeMessageId) {
402
- // Proper compound cursor: fetch messages that are strictly older, OR
403
- // share the same timestamp but have a smaller ID. This avoids both
404
- // duplicates and skipped messages when multiple rows share a timestamp.
405
- conditions.push(or(
406
- lt(messages.createdAt, beforeTimestamp),
407
- and(eq(messages.createdAt, beforeTimestamp), lt(messages.id, beforeMessageId)),
408
- )!);
409
- } else {
410
- // Legacy callers without a message ID tie-breaker: use strict lt.
411
- // This may skip same-millisecond messages at boundaries, but avoids
412
- // re-fetching the boundary message. New callers should prefer the
413
- // compound cursor (beforeTimestamp + beforeMessageId).
414
- conditions.push(lt(messages.createdAt, beforeTimestamp));
415
- }
416
- }
417
-
418
- if (limit === undefined) {
419
- // Unlimited: return all messages in chronological order, no pagination.
420
- const rows = db
421
- .select()
422
- .from(messages)
423
- .where(and(...conditions))
424
- .orderBy(asc(messages.createdAt), asc(messages.id))
425
- .all()
426
- .map(parseMessage);
427
- return { messages: rows, hasMore: false };
428
- }
429
-
430
- // Fetch limit+1 rows ordered newest-first so we can detect hasMore
431
- const rows = db
432
- .select()
433
- .from(messages)
434
- .where(and(...conditions))
435
- .orderBy(desc(messages.createdAt), desc(messages.id))
436
- .limit(limit + 1)
437
- .all()
438
- .map(parseMessage);
439
-
440
- const hasMore = rows.length > limit;
441
- const page = hasMore ? rows.slice(0, limit) : rows;
442
-
443
- // Return in chronological order (oldest first) for the client
444
- page.reverse();
445
-
446
- return { messages: page, hasMore };
447
- }
448
-
449
- export function updateConversationTitle(id: string, title: string, isAutoTitle?: number): void {
450
- const db = getDb();
451
- const set: Record<string, unknown> = { title, updatedAt: Date.now() };
452
- if (isAutoTitle !== undefined) set.isAutoTitle = isAutoTitle;
453
- db.update(conversations)
454
- .set(set)
455
- .where(eq(conversations.id, id))
456
- .run();
457
- }
458
-
459
- export function updateConversationUsage(
460
- id: string,
461
- totalInputTokens: number,
462
- totalOutputTokens: number,
463
- totalEstimatedCost: number,
464
- ): void {
465
- const db = getDb();
466
- db.update(conversations)
467
- .set({ totalInputTokens, totalOutputTokens, totalEstimatedCost, updatedAt: Date.now() })
468
- .where(eq(conversations.id, id))
469
- .run();
470
- }
471
-
472
- export function updateConversationContextWindow(
473
- id: string,
474
- contextSummary: string,
475
- contextCompactedMessageCount: number,
476
- ): void {
477
- const db = getDb();
478
- db.update(conversations)
479
- .set({
480
- contextSummary,
481
- contextCompactedMessageCount,
482
- contextCompactedAt: Date.now(),
483
- updatedAt: Date.now(),
484
- })
485
- .where(eq(conversations.id, id))
486
- .run();
487
- }
488
-
489
- /**
490
- * Delete the last user message and any subsequent assistant messages.
491
- * Uses rowid comparison instead of timestamps to avoid deleting messages
492
- * that share the same millisecond timestamp.
493
- * Returns the number of messages deleted.
494
- */
495
- /**
496
- * Delete all conversations, messages, and related data (tool invocations,
497
- * memory segments, etc.) from the daemon database.
498
- * Returns { conversations, messages } counts.
499
- */
500
- export function clearAll(): { conversations: number; messages: number } {
501
- const msgCount = rawGet<{ c: number }>('SELECT COUNT(*) AS c FROM messages')?.c ?? 0;
502
- const convCount = rawGet<{ c: number }>('SELECT COUNT(*) AS c FROM conversations')?.c ?? 0;
503
-
504
- // Delete in dependency order. Cascades handle memory_segments,
505
- // memory_item_sources, and tool_invocations, but we explicitly
506
- // clear non-cascading memory tables too.
507
- rawExec('DELETE FROM memory_segment_fts');
508
- rawExec('DELETE FROM memory_item_sources');
509
- rawExec('DELETE FROM memory_segments');
510
- rawExec('DELETE FROM memory_items');
511
- rawExec('DELETE FROM memory_summaries');
512
- rawExec('DELETE FROM memory_embeddings');
513
- rawExec('DELETE FROM memory_jobs');
514
- rawExec('DELETE FROM memory_checkpoints');
515
- rawExec('DELETE FROM llm_request_logs');
516
- rawExec('DELETE FROM llm_usage_events');
517
- rawExec('DELETE FROM message_attachments');
518
- rawExec('DELETE FROM attachments');
519
- rawExec('DELETE FROM tool_invocations');
520
- rawExec('DELETE FROM messages_fts');
521
- rawExec('DELETE FROM messages');
522
- rawExec('DELETE FROM conversations');
523
-
524
- return { conversations: convCount, messages: msgCount };
525
- }
526
-
527
- /**
528
- * Check whether the last user message in a conversation is a tool_result-only
529
- * message (i.e., not a real user-typed message). This is used by undo() to
530
- * determine if additional exchanges need to be deleted from the DB.
531
- */
532
- export function isLastUserMessageToolResult(conversationId: string): boolean {
533
- const db = getDb();
534
- const lastUserMsg = db
535
- .select({ content: messages.content })
536
- .from(messages)
537
- .where(and(eq(messages.conversationId, conversationId), eq(messages.role, 'user')))
538
- .orderBy(sql`rowid DESC`)
539
- .limit(1)
540
- .get();
541
-
542
- if (!lastUserMsg) return false;
543
-
544
- try {
545
- const parsed = JSON.parse(lastUserMsg.content);
546
- if (Array.isArray(parsed) && parsed.length > 0 && parsed.every((block: Record<string, unknown>) => block.type === 'tool_result')) {
547
- return true;
548
- }
549
- } catch {
550
- // Not JSON — it's a plain text user message
551
- }
552
- return false;
553
- }
554
-
555
- export function deleteLastExchange(conversationId: string): number {
556
- const db = getDb();
557
-
558
- // Find the last user message's id
559
- const lastUserMsg = db
560
- .select({ id: messages.id })
561
- .from(messages)
562
- .where(and(eq(messages.conversationId, conversationId), eq(messages.role, 'user')))
563
- .orderBy(sql`rowid DESC`)
564
- .limit(1)
565
- .get();
566
-
567
- if (!lastUserMsg) return 0;
568
-
569
- // Use rowid to identify the last user message and everything after it.
570
- // rowid is monotonically increasing for inserts, so this is safe even if
571
- // multiple messages share the same millisecond timestamp.
572
- const rowidSubquery = sql`(SELECT rowid FROM messages WHERE id = ${lastUserMsg.id})`;
573
- const condition = and(
574
- eq(messages.conversationId, conversationId),
575
- sql`rowid >= ${rowidSubquery}`,
576
- );
577
-
578
- const [{ deleted }] = db.select({ deleted: count() }).from(messages).where(condition).all();
579
- if (deleted === 0) return 0;
580
-
581
- // Collect attachment IDs linked to the messages being deleted so we can
582
- // scope orphan cleanup to only those candidates (not freshly uploaded ones).
583
- const messageIds = db.select({ id: messages.id }).from(messages).where(condition).all().map((r) => r.id);
584
- const candidateAttachmentIds = messageIds.length > 0
585
- ? db.select({ attachmentId: messageAttachments.attachmentId })
586
- .from(messageAttachments)
587
- .where(inArray(messageAttachments.messageId, messageIds))
588
- .all()
589
- .map((r) => r.attachmentId)
590
- .filter((id): id is string => id != null)
591
- : [];
592
-
593
- db.transaction((tx) => {
594
- tx.delete(messages).where(condition).run();
595
- tx.update(conversations)
596
- .set({ updatedAt: Date.now() })
597
- .where(eq(conversations.id, conversationId))
598
- .run();
599
- });
600
-
601
- deleteOrphanAttachments(candidateAttachmentIds);
602
-
603
- return deleted;
604
- }
605
-
606
- /**
607
- * IDs collected during message deletion for Qdrant vector cleanup.
608
- * Callers must delete these from the Qdrant collection after the
609
- * SQLite transaction commits.
610
- */
611
- export interface DeletedMemoryIds {
612
- segmentIds: string[];
613
- orphanedItemIds: string[];
614
- }
615
-
616
- /**
617
- * Update the content of an existing message. Used when consolidating
618
- * multiple assistant messages into one.
619
- */
620
- export function updateMessageContent(messageId: string, newContent: string): void {
621
- const db = getDb();
622
- db.update(messages)
623
- .set({ content: newContent })
624
- .where(eq(messages.id, messageId))
625
- .run();
626
- }
627
-
628
- /**
629
- * Re-link all attachments from a set of source messages to a target message.
630
- * Used during message consolidation so that attachments linked to deleted
631
- * messages survive the ON DELETE CASCADE on message_attachments.
632
- */
633
- export function relinkAttachments(fromMessageIds: string[], toMessageId: string): number {
634
- if (fromMessageIds.length === 0) return 0;
635
- const db = getDb();
636
-
637
- // Count how many links will be moved before updating.
638
- const [{ total }] = db
639
- .select({ total: count() })
640
- .from(messageAttachments)
641
- .where(inArray(messageAttachments.messageId, fromMessageIds))
642
- .all();
643
-
644
- if (total === 0) return 0;
645
-
646
- db.update(messageAttachments)
647
- .set({ messageId: toMessageId })
648
- .where(inArray(messageAttachments.messageId, fromMessageIds))
649
- .run();
650
-
651
- return total;
652
- }
653
-
654
- /**
655
- * Delete a single message by ID without cascading to message_runs or
656
- * channel_inbound_events. Nullable FK columns in those tables are set to
657
- * NULL before the message row is removed, so associated run and event
658
- * records survive.
659
- *
660
- * Also cleans up derived memory_items: if the memory worker has already
661
- * processed an extract_items job for this message, deleting the message
662
- * cascades memory_item_sources but leaves the memory_items active.
663
- * Without cleanup, those items would leak into summaries and recall.
664
- * We delete any memory_items that become orphaned (no remaining sources)
665
- * after this message is removed.
666
- *
667
- * Returns segment and orphaned item IDs so the caller can clean up the
668
- * corresponding Qdrant vector entries.
669
- */
670
- export function deleteMessageById(messageId: string): DeletedMemoryIds {
671
- const db = getDb();
672
- const result: DeletedMemoryIds = { segmentIds: [], orphanedItemIds: [] };
673
-
674
- // Collect attachment IDs linked to this message before cascade-delete
675
- // so we can scope orphan cleanup to only those candidates.
676
- const candidateAttachmentIds = db
677
- .select({ attachmentId: messageAttachments.attachmentId })
678
- .from(messageAttachments)
679
- .where(eq(messageAttachments.messageId, messageId))
680
- .all()
681
- .map((r) => r.attachmentId)
682
- .filter((id): id is string => id !== undefined);
683
-
684
- db.transaction((tx) => {
685
- // Collect memory segment IDs linked to this message before cascade.
686
- const linkedSegments = tx
687
- .select({ id: memorySegments.id })
688
- .from(memorySegments)
689
- .where(eq(memorySegments.messageId, messageId))
690
- .all();
691
- result.segmentIds = linkedSegments.map((r) => r.id);
692
-
693
- // Collect memory item IDs linked to this message before cascade.
694
- const linkedItems = tx
695
- .select({ memoryItemId: memoryItemSources.memoryItemId })
696
- .from(memoryItemSources)
697
- .where(eq(memoryItemSources.messageId, messageId))
698
- .all();
699
- const candidateItemIds = linkedItems.map((r) => r.memoryItemId);
700
-
701
- // Detach nullable FK references so the cascade doesn't destroy them.
702
- tx.update(channelInboundEvents)
703
- .set({ messageId: null })
704
- .where(eq(channelInboundEvents.messageId, messageId))
705
- .run();
706
-
707
- // Now safe to delete — NOT NULL cascades remove memory_item_sources,
708
- // memory_segments, and message_attachments.
709
- tx.delete(messages).where(eq(messages.id, messageId)).run();
710
-
711
- // Clean up segment embeddings from SQLite (Qdrant cleanup is the caller's job).
712
- if (result.segmentIds.length > 0) {
713
- tx.delete(memoryEmbeddings)
714
- .where(and(
715
- eq(memoryEmbeddings.targetType, 'segment'),
716
- inArray(memoryEmbeddings.targetId, result.segmentIds),
717
- ))
718
- .run();
719
- }
720
-
721
- // Clean up orphaned memory items whose only source was this message.
722
- if (candidateItemIds.length > 0) {
723
- // Find which items still have at least one remaining source.
724
- const surviving = tx
725
- .select({ memoryItemId: memoryItemSources.memoryItemId })
726
- .from(memoryItemSources)
727
- .where(inArray(memoryItemSources.memoryItemId, candidateItemIds))
728
- .all();
729
- const survivingIds = new Set(surviving.map((r) => r.memoryItemId));
730
- const orphanedIds = candidateItemIds.filter((id) => !survivingIds.has(id));
731
- result.orphanedItemIds = orphanedIds;
732
-
733
- if (orphanedIds.length > 0) {
734
- // Delete memory_item_entities (no FK cascade on this table).
735
- tx.delete(memoryItemEntities)
736
- .where(inArray(memoryItemEntities.memoryItemId, orphanedIds))
737
- .run();
738
- // Delete embeddings referencing these items.
739
- tx.delete(memoryEmbeddings)
740
- .where(and(
741
- eq(memoryEmbeddings.targetType, 'item'),
742
- inArray(memoryEmbeddings.targetId, orphanedIds),
743
- ))
744
- .run();
745
- // Delete the orphaned memory items themselves.
746
- tx.delete(memoryItems)
747
- .where(inArray(memoryItems.id, orphanedIds))
748
- .run();
749
- }
750
- }
751
- });
752
-
753
- deleteOrphanAttachments(candidateAttachmentIds);
754
-
755
- return result;
756
- }
757
-
758
- export interface ConversationSearchResult {
759
- conversationId: string;
760
- conversationTitle: string | null;
761
- conversationUpdatedAt: number;
762
- matchingMessages: Array<{
763
- messageId: string;
764
- role: string;
765
- /** Plain-text excerpt around the match, truncated to ~200 chars. */
766
- excerpt: string;
767
- createdAt: number;
768
- }>;
769
- }
770
-
771
- /**
772
- * Full-text search across message content using FTS5.
773
- * Uses the messages_fts virtual table for fast tokenized matching on message
774
- * content, with a LIKE fallback on conversation titles. Returns matching
775
- * conversations with their relevant messages, ordered by most recently updated.
776
- */
777
- export function searchConversations(
778
- query: string,
779
- opts?: { limit?: number; maxMessagesPerConversation?: number },
780
- ): ConversationSearchResult[] {
781
- if (!query.trim()) return [];
782
-
783
- const db = getDb();
784
- const limit = opts?.limit ?? 20;
785
- const maxMsgsPerConv = opts?.maxMessagesPerConversation ?? 3;
786
-
787
- const ftsMatch = buildFtsMatchQuery(query.trim());
788
-
789
- // LIKE pattern for title matching (FTS only covers message content).
790
- const titlePattern = `%${query.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_')}%`;
791
-
792
- interface ConvIdRow {
793
- conversation_id: string;
794
- }
795
-
796
- // Collect conversation IDs from FTS message matches and title LIKE matches,
797
- // then merge them to produce the final set of matching conversations.
798
- // Both paths LIMIT on distinct conversation_id to prevent a single
799
- // conversation with many matching messages from crowding out others.
800
- const ftsConvIds = new Set<string>();
801
- if (ftsMatch) {
802
- try {
803
- const ftsRows = rawAll<ConvIdRow>(`
804
- SELECT DISTINCT m.conversation_id
805
- FROM messages_fts f
806
- JOIN messages m ON m.id = f.message_id
807
- JOIN conversations c ON c.id = m.conversation_id
808
- WHERE messages_fts MATCH ? AND c.thread_type != 'background'
809
- LIMIT 1000
810
- `, ftsMatch);
811
- for (const row of ftsRows) ftsConvIds.add(row.conversation_id);
812
- } catch {
813
- // FTS parse failure — fall through, title matches may still produce results.
814
- }
815
- } else if (query.trim()) {
816
- // FTS tokens were all dropped (non-ASCII, single-char, etc.) — fall back to
817
- // LIKE-based message content search so queries like "你", "é", or "C++" still
818
- // match message text.
819
- const likePattern = `%${query.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_')}%`;
820
- const likeRows = rawAll<ConvIdRow>(`
821
- SELECT DISTINCT m.conversation_id
822
- FROM messages m
823
- JOIN conversations c ON c.id = m.conversation_id
824
- WHERE m.content LIKE ? ESCAPE '\\' AND c.thread_type != 'background'
825
- LIMIT 1000
826
- `, likePattern);
827
- for (const row of likeRows) ftsConvIds.add(row.conversation_id);
828
- }
829
-
830
- // Title-only matches (FTS doesn't index conversation titles).
831
- const titleMatchConvs = db
832
- .select({ id: conversations.id })
833
- .from(conversations)
834
- .where(
835
- and(
836
- sql`${conversations.threadType} != 'background'`,
837
- sql`${conversations.title} LIKE ${titlePattern} ESCAPE '\\'`,
838
- ),
839
- )
840
- .all();
841
- for (const row of titleMatchConvs) ftsConvIds.add(row.id);
842
-
843
- if (ftsConvIds.size === 0) return [];
844
-
845
- // Fetch the matching conversation rows, ordered by updatedAt, capped at limit.
846
- const convIds = [...ftsConvIds];
847
- const placeholders = convIds.map(() => '?').join(',');
848
- interface ConvRow { id: string; title: string | null; updated_at: number }
849
- const matchingConversations = rawAll<ConvRow>(
850
- `SELECT id, title, updated_at FROM conversations
851
- WHERE id IN (${placeholders})
852
- ORDER BY updated_at DESC
853
- LIMIT ?`,
854
- ...convIds, limit,
855
- );
856
-
857
- if (matchingConversations.length === 0) return [];
858
-
859
- const results: ConversationSearchResult[] = [];
860
-
861
- for (const conv of matchingConversations) {
862
- interface MsgRow { id: string; role: string; content: string; created_at: number }
863
- let matchingMsgs: MsgRow[] = [];
864
- if (ftsMatch) {
865
- try {
866
- matchingMsgs = rawAll<MsgRow>(`
867
- SELECT m.id, m.role, m.content, m.created_at
868
- FROM messages_fts f
869
- JOIN messages m ON m.id = f.message_id
870
- WHERE messages_fts MATCH ? AND m.conversation_id = ?
871
- ORDER BY m.created_at ASC
872
- LIMIT ?
873
- `, ftsMatch, conv.id, maxMsgsPerConv);
874
- } catch {
875
- // FTS parse failure — no matching messages for this conversation.
876
- }
877
- } else if (query.trim()) {
878
- // LIKE fallback for non-ASCII / short-token queries.
879
- const msgLikePattern = `%${query.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_')}%`;
880
- matchingMsgs = rawAll<MsgRow>(`
881
- SELECT id, role, content, created_at
882
- FROM messages
883
- WHERE conversation_id = ? AND content LIKE ? ESCAPE '\\'
884
- ORDER BY created_at ASC
885
- LIMIT ?
886
- `, conv.id, msgLikePattern, maxMsgsPerConv);
887
- }
888
-
889
- results.push({
890
- conversationId: conv.id,
891
- conversationTitle: conv.title,
892
- conversationUpdatedAt: conv.updated_at,
893
- matchingMessages: matchingMsgs.map((m) => ({
894
- messageId: m.id,
895
- role: m.role,
896
- excerpt: buildExcerpt(m.content, query),
897
- createdAt: m.created_at,
898
- })),
899
- });
900
- }
901
-
902
- return results;
903
- }
904
-
905
- /**
906
- * Build a short excerpt from raw message content centered around the first
907
- * occurrence of `query`. The content may be JSON (content blocks) or plain
908
- * text; we extract a readable snippet in either case.
909
- */
910
- function buildExcerpt(rawContent: string, query: string): string {
911
- // Try to extract plain text from JSON content blocks first.
912
- let text = rawContent;
913
- try {
914
- const parsed = JSON.parse(rawContent);
915
- if (Array.isArray(parsed)) {
916
- const parts: string[] = [];
917
- for (const block of parsed) {
918
- if (typeof block === 'object' && block != null) {
919
- if (block.type === 'text' && typeof block.text === 'string') {
920
- parts.push(block.text);
921
- } else if (block.type === 'tool_result') {
922
- const inner = Array.isArray(block.content) ? block.content : [];
923
- for (const ib of inner) {
924
- if (ib?.type === 'text' && typeof ib.text === 'string') parts.push(ib.text);
925
- }
926
- }
927
- }
928
- }
929
- if (parts.length > 0) text = parts.join(' ');
930
- } else if (typeof parsed === 'string') {
931
- text = parsed;
932
- }
933
- } catch {
934
- // Not JSON — use as-is
935
- }
936
-
937
- const WINDOW = 100;
938
- const lowerText = text.toLowerCase();
939
- const lowerQuery = query.toLowerCase();
940
- const idx = lowerText.indexOf(lowerQuery);
941
- if (idx === -1) {
942
- // Query matched the raw JSON but not the extracted text — fall back to raw start
943
- return text.slice(0, WINDOW * 2).replace(/\s+/g, ' ').trim();
944
- }
945
- const start = Math.max(0, idx - WINDOW);
946
- const end = Math.min(text.length, idx + query.length + WINDOW);
947
- const excerpt = (start > 0 ? '…' : '') + text.slice(start, end).replace(/\s+/g, ' ').trim() + (end < text.length ? '…' : '');
948
- return excerpt;
949
- }
950
-
951
- export function setConversationOriginChannelIfUnset(conversationId: string, channel: ChannelId): void {
952
- const db = getDb();
953
- db.update(conversations)
954
- .set({ originChannel: channel })
955
- .where(and(eq(conversations.id, conversationId), isNull(conversations.originChannel)))
956
- .run();
957
- }
958
-
959
- export function getConversationOriginChannel(conversationId: string): ChannelId | null {
960
- const db = getDb();
961
- const row = db.select({ originChannel: conversations.originChannel })
962
- .from(conversations)
963
- .where(eq(conversations.id, conversationId))
964
- .get();
965
- return parseChannelId(row?.originChannel) ?? null;
966
- }
967
-
968
- export function setConversationOriginInterfaceIfUnset(conversationId: string, interfaceId: InterfaceId): void {
969
- const db = getDb();
970
- db.update(conversations)
971
- .set({ originInterface: interfaceId })
972
- .where(and(eq(conversations.id, conversationId), isNull(conversations.originInterface)))
973
- .run();
974
- }
975
-
976
- export function getConversationOriginInterface(conversationId: string): InterfaceId | null {
977
- const db = getDb();
978
- const row = db.select({ originInterface: conversations.originInterface })
979
- .from(conversations)
980
- .where(eq(conversations.id, conversationId))
981
- .get();
982
- return parseInterfaceId(row?.originInterface) ?? null;
983
- }
1
+ // Re-export all conversation store functionality from focused sub-modules.
2
+ // Existing imports from this file continue to work without changes.
3
+
4
+ export {
5
+ messageMetadataSchema,
6
+ type MessageMetadata,
7
+ provenanceFromGuardianContext,
8
+ type ConversationRow,
9
+ parseConversation,
10
+ type MessageRow,
11
+ parseMessage,
12
+ createConversation,
13
+ getConversation,
14
+ getConversationThreadType,
15
+ getConversationMemoryScopeId,
16
+ deleteConversation,
17
+ addMessage,
18
+ getMessages,
19
+ getMessageById,
20
+ updateConversationTitle,
21
+ updateConversationUsage,
22
+ updateConversationContextWindow,
23
+ clearAll,
24
+ deleteLastExchange,
25
+ type DeletedMemoryIds,
26
+ updateMessageContent,
27
+ relinkAttachments,
28
+ deleteMessageById,
29
+ setConversationOriginChannelIfUnset,
30
+ getConversationOriginChannel,
31
+ setConversationOriginInterfaceIfUnset,
32
+ getConversationOriginInterface,
33
+ } from './conversation-crud.js';
34
+
35
+ export {
36
+ listConversations,
37
+ countConversations,
38
+ getLatestConversation,
39
+ getNextMessage,
40
+ type PaginatedMessagesResult,
41
+ getMessagesPaginated,
42
+ isLastUserMessageToolResult,
43
+ type ConversationSearchResult,
44
+ searchConversations,
45
+ } from './conversation-queries.js';