@vellumai/assistant 0.10.3 → 0.10.4-staging.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) hide show
  1. package/openapi.yaml +73 -56
  2. package/package.json +1 -1
  3. package/src/__tests__/actor-trust-resolver-address-fallback.test.ts +83 -31
  4. package/src/__tests__/assistant-stream-state.test.ts +3 -76
  5. package/src/__tests__/background-workers-disk-pressure.test.ts +4 -2
  6. package/src/__tests__/channel-approval-routes.test.ts +21 -26
  7. package/src/__tests__/channel-delivery-store.test.ts +28 -0
  8. package/src/__tests__/channel-guardian.test.ts +82 -32
  9. package/src/__tests__/channel-inbound-disk-pressure.test.ts +11 -19
  10. package/src/__tests__/channel-reply-delivery.test.ts +6 -2
  11. package/src/__tests__/compaction-ledger-store.test.ts +128 -0
  12. package/src/__tests__/config-loader-backfill.test.ts +148 -0
  13. package/src/__tests__/consult-deadline.test.ts +60 -0
  14. package/src/__tests__/contact-store-interaction-info.test.ts +156 -0
  15. package/src/__tests__/contact-store-user-file.test.ts +7 -10
  16. package/src/__tests__/contacts-relay-reads.test.ts +6 -9
  17. package/src/__tests__/contacts-write.test.ts +0 -2
  18. package/src/__tests__/conversation-agent-loop-overflow.test.ts +4 -2
  19. package/src/__tests__/conversation-agent-loop.test.ts +98 -7
  20. package/src/__tests__/conversation-attention-telegram.test.ts +9 -11
  21. package/src/__tests__/conversation-error.test.ts +18 -0
  22. package/src/__tests__/conversation-fork-crud.test.ts +354 -24
  23. package/src/__tests__/conversation-title-service.test.ts +222 -201
  24. package/src/__tests__/db-compaction-events-migration.test.ts +129 -0
  25. package/src/__tests__/delete-propagation.test.ts +5 -3
  26. package/src/__tests__/dm-backfill.test.ts +6 -4
  27. package/src/__tests__/emit-signal-routing-intent.test.ts +2 -6
  28. package/src/__tests__/guardian-binding-drift-heal.test.ts +43 -23
  29. package/src/__tests__/guardian-dispatch.test.ts +50 -5
  30. package/src/__tests__/guardian-routing-state.test.ts +6 -10
  31. package/src/__tests__/helpers/channel-test-adapter.ts +45 -12
  32. package/src/__tests__/helpers/create-guardian-binding.ts +15 -23
  33. package/src/__tests__/helpers/mock-logger.ts +1 -0
  34. package/src/__tests__/helpers/seed-contact-channel.ts +96 -0
  35. package/src/__tests__/inbound-invite-redemption.test.ts +87 -10
  36. package/src/__tests__/invite-redemption-service.test.ts +273 -53
  37. package/src/__tests__/invite-routes-http.test.ts +34 -0
  38. package/src/__tests__/invite-service-ipc.test.ts +65 -2
  39. package/src/__tests__/list-messages-page-latest.test.ts +173 -4
  40. package/src/__tests__/mcp-config-secret-boundary.test.ts +3 -0
  41. package/src/__tests__/non-member-access-request.test.ts +15 -13
  42. package/src/__tests__/onboarding-persona-write.test.ts +52 -22
  43. package/src/__tests__/persist-onboarding-artifacts.test.ts +1 -0
  44. package/src/__tests__/persona-resolver.test.ts +75 -45
  45. package/src/__tests__/plugin-bootstrap.test.ts +13 -5
  46. package/src/__tests__/plugin-disabled-state.test.ts +190 -0
  47. package/src/__tests__/provider-usage-tracking.test.ts +1 -1
  48. package/src/__tests__/reaction-intercept-cold-cache-warm.test.ts +135 -0
  49. package/src/__tests__/reaction-intercept-member-verdict-warm.test.ts +158 -0
  50. package/src/__tests__/reaction-persistence.test.ts +51 -4
  51. package/src/__tests__/relay-server.test.ts +88 -31
  52. package/src/__tests__/runtime-attachment-metadata.test.ts +9 -11
  53. package/src/__tests__/settings-routes.test.ts +32 -0
  54. package/src/__tests__/slack-block-formatting.test.ts +1 -38
  55. package/src/__tests__/sse-actor-principal-guardian-source.test.ts +13 -36
  56. package/src/__tests__/stt-hints.test.ts +6 -3
  57. package/src/__tests__/subagent-fork-prompt-role.test.ts +195 -0
  58. package/src/__tests__/subagent-fork-spawn.test.ts +6 -7
  59. package/src/__tests__/subagent-role-registry.test.ts +17 -4
  60. package/src/__tests__/subagent-spawn-and-await.test.ts +546 -0
  61. package/src/__tests__/subagent-tools.test.ts +398 -3
  62. package/src/__tests__/thread-backfill.test.ts +3 -3
  63. package/src/__tests__/tool-preview-lifecycle.test.ts +26 -10
  64. package/src/__tests__/tool-start-timestamp.test.ts +4 -3
  65. package/src/__tests__/trusted-contact-approval-notifier.test.ts +37 -51
  66. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +2 -2
  67. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +9 -7
  68. package/src/__tests__/trusted-contact-multichannel.test.ts +16 -7
  69. package/src/__tests__/trusted-contact-verification.test.ts +79 -54
  70. package/src/__tests__/voice-guardian-cold-cache-warm.test.ts +137 -0
  71. package/src/__tests__/voice-invite-redemption.test.ts +183 -20
  72. package/src/__tests__/workspace-migration-102-preserve-heartbeat-enabled-for-existing-workspaces.test.ts +3 -3
  73. package/src/__tests__/workspace-migration-111-prune-seeded-callsite-defaults.test.ts +2 -2
  74. package/src/__tests__/workspace-migration-112-remove-advisor-callsite-override.test.ts +170 -0
  75. package/src/__tests__/workspace-migration-drop-user-md.test.ts +196 -238
  76. package/src/a2a/__tests__/e2e-a2a-channel.test.ts +35 -47
  77. package/src/agent/loop-exclusive-tool.test.ts +19 -15
  78. package/src/agent/loop-native-web-search.test.ts +200 -0
  79. package/src/agent/loop.ts +108 -1
  80. package/src/api/responses/conversation-message.ts +9 -0
  81. package/src/approvals/guardian-request-resolvers.ts +16 -4
  82. package/src/calls/__tests__/relay-setup-router.test.ts +10 -18
  83. package/src/calls/guardian-dispatch.ts +14 -11
  84. package/src/calls/inbound-trust-reader.ts +7 -1
  85. package/src/calls/relay-access-wait.ts +6 -6
  86. package/src/calls/relay-server.ts +22 -2
  87. package/src/calls/relay-setup-router.ts +10 -10
  88. package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -0
  89. package/src/cli/commands/contacts.ts +10 -7
  90. package/src/cli/commands/memory/__tests__/worker.test.ts +147 -17
  91. package/src/cli/commands/memory/worker.ts +97 -30
  92. package/src/cli/commands/plugins.ts +3 -146
  93. package/src/cli/lib/__tests__/list-installed-plugins.test.ts +17 -17
  94. package/src/cli/lib/__tests__/publish-plugin.test.ts +98 -0
  95. package/src/cli/lib/publish-plugin.ts +231 -1
  96. package/src/config/__tests__/sync-gated-profiles.test.ts +5 -7
  97. package/src/config/bundled-skills/subagent/SKILL.md +16 -1
  98. package/src/config/bundled-skills/subagent/TOOLS.json +5 -4
  99. package/src/config/call-site-defaults.ts +0 -6
  100. package/src/config/llm-resolver.ts +0 -3
  101. package/src/config/schemas/call-site-catalog.ts +0 -7
  102. package/src/config/schemas/heartbeat.ts +2 -5
  103. package/src/config/schemas/llm.ts +3 -12
  104. package/src/config/schemas/memory-lifecycle.ts +1 -1
  105. package/src/config/seed-inference-profiles.ts +76 -35
  106. package/src/config/sync-gated-profiles.ts +0 -3
  107. package/src/contacts/__tests__/contacts-write-revoke-relay.test.ts +7 -8
  108. package/src/contacts/__tests__/member-write-relay.test.ts +35 -11
  109. package/src/contacts/contact-store.ts +27 -237
  110. package/src/contacts/contacts-write.ts +18 -58
  111. package/src/contacts/gateway-channel-read.ts +51 -0
  112. package/src/contacts/member-write-relay.ts +25 -31
  113. package/src/contacts/types.ts +3 -15
  114. package/src/daemon/__tests__/conversation-tool-setup.test.ts +0 -44
  115. package/src/daemon/conversation-agent-loop-handlers.ts +29 -10
  116. package/src/daemon/conversation-agent-loop.ts +68 -61
  117. package/src/daemon/conversation-error.ts +7 -10
  118. package/src/daemon/conversation-tool-setup.ts +0 -10
  119. package/src/daemon/conversation.ts +10 -0
  120. package/src/daemon/external-plugins-bootstrap.ts +8 -2
  121. package/src/daemon/handlers/__tests__/config-a2a-accept.test.ts +0 -1
  122. package/src/daemon/handlers/__tests__/config-a2a-complete.test.ts +0 -2
  123. package/src/daemon/handlers/__tests__/config-a2a-redeem.test.ts +0 -2
  124. package/src/daemon/handlers/__tests__/config-channels.test.ts +9 -14
  125. package/src/daemon/handlers/config-channels.ts +14 -29
  126. package/src/daemon/lifecycle.ts +16 -4
  127. package/src/daemon/message-types/surfaces.ts +2 -0
  128. package/src/heartbeat/heartbeat-service.ts +5 -0
  129. package/src/home/relationship-state-writer.ts +5 -0
  130. package/src/memory/__tests__/embedding-cache.test.ts +136 -0
  131. package/src/memory/compaction-ledger-store.ts +107 -0
  132. package/src/memory/conversation-crud.ts +136 -61
  133. package/src/memory/conversation-title-service.ts +173 -24
  134. package/src/memory/embedding-backend.ts +8 -1
  135. package/src/memory/embedding-cache.ts +139 -0
  136. package/src/memory/jobs-worker.ts +75 -29
  137. package/src/memory/memory-retrospective-job.ts +5 -0
  138. package/src/memory/migrations/209-strip-thinking-from-consolidated.ts +27 -5
  139. package/src/memory/migrations/302-create-compaction-events.ts +107 -0
  140. package/src/memory/migrations/303-add-conversation-creation-seq.ts +33 -0
  141. package/src/memory/migrations/__tests__/209-strip-thinking-from-consolidated.test.ts +79 -6
  142. package/src/memory/schema/contacts.ts +6 -2
  143. package/src/memory/schema/conversations.ts +39 -0
  144. package/src/memory/steps.ts +1090 -367
  145. package/src/memory/worker-control.ts +104 -18
  146. package/src/memory/worker-process.ts +17 -0
  147. package/src/messaging/channel-binding-metadata.ts +31 -0
  148. package/src/messaging/channel-binding-schema.ts +51 -0
  149. package/src/messaging/providers/__tests__/callback-routing.test.ts +45 -0
  150. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +195 -0
  151. package/src/messaging/providers/a2a/__tests__/deliver.test.ts +11 -0
  152. package/src/messaging/providers/a2a/deliver.ts +5 -1
  153. package/src/messaging/providers/a2a/transport.ts +10 -0
  154. package/src/messaging/providers/callback-routing.ts +48 -0
  155. package/src/messaging/providers/channel-transport.ts +55 -0
  156. package/src/messaging/providers/index.ts +65 -241
  157. package/src/messaging/providers/slack/binding-metadata.ts +62 -0
  158. package/src/messaging/providers/slack/transport.ts +92 -0
  159. package/src/messaging/providers/telegram-bot/transport.ts +51 -0
  160. package/src/messaging/providers/whatsapp/transport.ts +38 -0
  161. package/src/notifications/__tests__/broadcaster.test.ts +0 -8
  162. package/src/notifications/__tests__/connected-channels.test.ts +8 -36
  163. package/src/notifications/__tests__/destination-resolver.test.ts +12 -117
  164. package/src/notifications/destination-resolver.ts +7 -23
  165. package/src/notifications/emit-signal.ts +5 -11
  166. package/src/plugins/defaults/index.ts +0 -35
  167. package/src/plugins/defaults/memory-v3-shadow/__tests__/dense.test.ts +11 -0
  168. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-dense-store.test.ts +243 -2
  169. package/src/plugins/defaults/memory-v3-shadow/section-dense-store.ts +167 -14
  170. package/src/plugins/disabled-state.ts +31 -0
  171. package/src/plugins/registry.ts +55 -12
  172. package/src/prompts/persona-resolver.ts +43 -11
  173. package/src/providers/call-site-routing.ts +41 -0
  174. package/src/providers/provider-send-message.ts +6 -0
  175. package/src/providers/ratelimit.ts +6 -0
  176. package/src/providers/registry.ts +1 -1
  177. package/src/providers/retry.ts +6 -0
  178. package/src/providers/types.ts +13 -0
  179. package/src/providers/usage-tracking.ts +6 -0
  180. package/src/runtime/__tests__/guardian-vellum-migration.test.ts +30 -27
  181. package/src/runtime/__tests__/local-principal-trust.test.ts +16 -18
  182. package/src/runtime/__tests__/member-verdict-cache.test.ts +119 -0
  183. package/src/runtime/__tests__/trust-verdict-consumer.test.ts +115 -168
  184. package/src/runtime/access-request-helper.ts +1 -2
  185. package/src/runtime/actor-trust-resolver.ts +44 -17
  186. package/src/runtime/anchored-guardian.test.ts +7 -54
  187. package/src/runtime/anchored-guardian.ts +4 -53
  188. package/src/runtime/assistant-stream-state.ts +12 -74
  189. package/src/runtime/channel-reply-delivery.ts +3 -8
  190. package/src/runtime/guardian-vellum-migration.ts +18 -16
  191. package/src/runtime/invite-redemption-service.ts +25 -10
  192. package/src/runtime/local-actor-identity.test.ts +108 -0
  193. package/src/runtime/local-actor-identity.ts +27 -20
  194. package/src/runtime/member-verdict-cache.ts +0 -0
  195. package/src/runtime/routes/__tests__/contact-routes.test.ts +100 -7
  196. package/src/runtime/routes/__tests__/global-search-routes.test.ts +1 -2
  197. package/src/runtime/routes/__tests__/surface-action-routes.test.ts +2 -1
  198. package/src/runtime/routes/contact-routes.ts +40 -25
  199. package/src/runtime/routes/conversation-list-routes.ts +1 -29
  200. package/src/runtime/routes/conversation-routes.ts +27 -7
  201. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +0 -10
  202. package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -8
  203. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +19 -0
  204. package/src/runtime/routes/settings-routes.ts +8 -3
  205. package/src/runtime/services/conversation-serializer.ts +6 -49
  206. package/src/runtime/slack-block-formatting.ts +0 -15
  207. package/src/runtime/trust-verdict-consumer.ts +36 -41
  208. package/src/subagent/__tests__/consult-prompt.test.ts +35 -0
  209. package/src/{plugins/defaults/advisor/__tests__/transcript.test.ts → subagent/__tests__/consult-transcript.test.ts} +47 -10
  210. package/src/{plugins/defaults/advisor/steering.ts → subagent/consult-prompt.ts} +17 -39
  211. package/src/{plugins/defaults/advisor/transcript.ts → subagent/consult-transcript.ts} +18 -8
  212. package/src/subagent/index.ts +1 -1
  213. package/src/subagent/manager.ts +245 -33
  214. package/src/subagent/types.ts +8 -1
  215. package/src/tools/registry.ts +10 -3
  216. package/src/tools/subagent/consult-deadline.ts +49 -0
  217. package/src/tools/subagent/spawn.ts +234 -5
  218. package/src/util/logger.ts +9 -0
  219. package/src/util/platform.ts +14 -0
  220. package/src/workspace/migrations/031-drop-user-md.ts +232 -148
  221. package/src/workspace/migrations/112-remove-advisor-callsite-override.ts +64 -0
  222. package/src/workspace/migrations/registry.ts +2 -0
  223. package/src/plugins/defaults/advisor/__tests__/advisor-gate.test.ts +0 -56
  224. package/src/plugins/defaults/advisor/__tests__/advisor-state-store.test.ts +0 -43
  225. package/src/plugins/defaults/advisor/__tests__/agent-loop-integration.test.ts +0 -137
  226. package/src/plugins/defaults/advisor/__tests__/consult.test.ts +0 -314
  227. package/src/plugins/defaults/advisor/__tests__/context-pack-gating.test.ts +0 -106
  228. package/src/plugins/defaults/advisor/__tests__/context-pack.test.ts +0 -60
  229. package/src/plugins/defaults/advisor/__tests__/hooks.test.ts +0 -138
  230. package/src/plugins/defaults/advisor/advisor-gate.ts +0 -29
  231. package/src/plugins/defaults/advisor/advisor-state-store.ts +0 -94
  232. package/src/plugins/defaults/advisor/config.ts +0 -21
  233. package/src/plugins/defaults/advisor/consult.ts +0 -197
  234. package/src/plugins/defaults/advisor/context-pack.ts +0 -288
  235. package/src/plugins/defaults/advisor/hooks/post-model-call.ts +0 -34
  236. package/src/plugins/defaults/advisor/hooks/pre-model-call.ts +0 -30
  237. package/src/plugins/defaults/advisor/hooks/user-prompt-submit.ts +0 -19
  238. package/src/plugins/defaults/advisor/package.json +0 -14
  239. package/src/plugins/defaults/advisor/tools/advisor.ts +0 -92
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Tests for `assistant/src/memory/embedding-cache.ts` — the shared dense-vector
3
+ * cache over the `memory_embeddings` table. Exercises the real SQL against a
4
+ * temp workspace DB (round-trip, dim-mismatch miss, key isolation, upsert).
5
+ */
6
+
7
+ import { mkdtempSync, rmSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import {
11
+ afterAll,
12
+ beforeAll,
13
+ beforeEach,
14
+ describe,
15
+ expect,
16
+ mock,
17
+ test,
18
+ } from "bun:test";
19
+
20
+ mock.module("../../util/logger.js", () => ({
21
+ getLogger: () =>
22
+ new Proxy({} as Record<string, unknown>, { get: () => () => {} }),
23
+ }));
24
+
25
+ let tmpWorkspace: string;
26
+ let previousWorkspaceEnv: string | undefined;
27
+
28
+ beforeAll(() => {
29
+ tmpWorkspace = mkdtempSync(join(tmpdir(), "embedding-cache-test-"));
30
+ previousWorkspaceEnv = process.env.VELLUM_WORKSPACE_DIR;
31
+ process.env.VELLUM_WORKSPACE_DIR = tmpWorkspace;
32
+ });
33
+
34
+ afterAll(() => {
35
+ if (previousWorkspaceEnv === undefined) {
36
+ delete process.env.VELLUM_WORKSPACE_DIR;
37
+ } else {
38
+ process.env.VELLUM_WORKSPACE_DIR = previousWorkspaceEnv;
39
+ }
40
+ rmSync(tmpWorkspace, { recursive: true, force: true });
41
+ });
42
+
43
+ // Deferred so internal `getWorkspaceDir()` resolves to the tmpdir set above.
44
+ const { getDb } = await import("../db-connection.js");
45
+ const { resetDbForTesting } =
46
+ await import("../../__tests__/db-test-helpers.js");
47
+ const { initializeDb } = await import("../db-init.js");
48
+ const { memoryEmbeddings } = await import("../schema.js");
49
+ const { readEmbeddingCache, writeEmbeddingCache } =
50
+ await import("../embedding-cache.js");
51
+
52
+ beforeEach(async () => {
53
+ resetDbForTesting();
54
+ // The first run pays the full cold-start migration chain; bump the hook
55
+ // timeout above bun's 5s default so the leading test doesn't flake in CI.
56
+ await initializeDb();
57
+ }, 30_000);
58
+
59
+ const KEY = {
60
+ targetType: "v3_section",
61
+ targetId: "people/alice#0",
62
+ provider: "local",
63
+ model: "test-model",
64
+ expectedDim: 4,
65
+ } as const;
66
+
67
+ function seed(
68
+ overrides: Partial<{
69
+ dense: number[];
70
+ contentHash: string;
71
+ now: number;
72
+ }> = {},
73
+ ): void {
74
+ writeEmbeddingCache(getDb(), {
75
+ targetType: KEY.targetType,
76
+ targetId: KEY.targetId,
77
+ provider: KEY.provider,
78
+ model: KEY.model,
79
+ dense: overrides.dense ?? [1, 2, 3, 4],
80
+ contentHash: overrides.contentHash ?? "hash-a",
81
+ now: overrides.now ?? 1000,
82
+ });
83
+ }
84
+
85
+ describe("embedding-cache", () => {
86
+ test("read returns null when nothing is cached", () => {
87
+ expect(readEmbeddingCache(getDb(), KEY)).toBeNull();
88
+ });
89
+
90
+ test("write then read round-trips the vector and content hash", () => {
91
+ seed({ dense: [1, 2, 3, 4], contentHash: "hash-a" });
92
+
93
+ const got = readEmbeddingCache(getDb(), KEY);
94
+ expect(got).not.toBeNull();
95
+ expect(got!.dense).toEqual([1, 2, 3, 4]);
96
+ expect(got!.contentHash).toBe("hash-a");
97
+ });
98
+
99
+ test("read misses when the configured dimension differs from the stored row", () => {
100
+ seed();
101
+ expect(readEmbeddingCache(getDb(), { ...KEY, expectedDim: 8 })).toBeNull();
102
+ });
103
+
104
+ test("read is isolated by targetType / targetId / provider / model", () => {
105
+ seed();
106
+ const db = getDb();
107
+ expect(
108
+ readEmbeddingCache(db, { ...KEY, targetType: "concept_page" }),
109
+ ).toBeNull();
110
+ expect(
111
+ readEmbeddingCache(db, { ...KEY, targetId: "people/bob#0" }),
112
+ ).toBeNull();
113
+ expect(readEmbeddingCache(db, { ...KEY, provider: "openai" })).toBeNull();
114
+ expect(readEmbeddingCache(db, { ...KEY, model: "other-model" })).toBeNull();
115
+ });
116
+
117
+ test("re-writing the same key upserts in place (one row, latest content)", () => {
118
+ const db = getDb();
119
+ seed({ dense: [1, 2, 3, 4], contentHash: "hash-a", now: 1000 });
120
+ seed({ dense: [5, 6, 7, 8], contentHash: "hash-b", now: 2000 });
121
+
122
+ const got = readEmbeddingCache(db, KEY);
123
+ expect(got!.dense).toEqual([5, 6, 7, 8]);
124
+ expect(got!.contentHash).toBe("hash-b");
125
+
126
+ // The unique key collapses both writes onto a single row.
127
+ const rows = db
128
+ .select()
129
+ .from(memoryEmbeddings)
130
+ .all()
131
+ .filter(
132
+ (r) => r.targetType === KEY.targetType && r.targetId === KEY.targetId,
133
+ );
134
+ expect(rows).toHaveLength(1);
135
+ });
136
+ });
@@ -0,0 +1,107 @@
1
+ import { and, desc, eq, lte } from "drizzle-orm";
2
+ import { v4 as uuid } from "uuid";
3
+
4
+ import { type DrizzleDb, getDb } from "./db-connection.js";
5
+ import { conversationCompactionEvents } from "./schema.js";
6
+
7
+ export interface CompactionEvent {
8
+ /** Wall-clock time the compaction ran (= `conversations.context_compacted_at`). */
9
+ compactedAt: number;
10
+ summary: string;
11
+ /** Count of leading persisted messages behind this compaction's summary. */
12
+ compactedMessageCount: number;
13
+ }
14
+
15
+ /**
16
+ * Append a compaction event to the ledger. Called alongside the
17
+ * `conversations` cache update on every compaction.
18
+ */
19
+ export function appendCompactionEvent(
20
+ conversationId: string,
21
+ event: CompactionEvent,
22
+ ): void {
23
+ const db = getDb();
24
+ db.insert(conversationCompactionEvents)
25
+ .values({
26
+ id: uuid(),
27
+ conversationId,
28
+ compactedAt: event.compactedAt,
29
+ summary: event.summary,
30
+ compactedMessageCount: event.compactedMessageCount,
31
+ createdAt: Date.now(),
32
+ })
33
+ .run();
34
+ }
35
+
36
+ /**
37
+ * The most recent compaction event whose `compactedAt` is at-or-before
38
+ * `atOrBefore`, or null if none (or `atOrBefore` is null). This is the fork
39
+ * inheritance rule: a compaction applies to a fork only if it happened before
40
+ * the message being forked from.
41
+ */
42
+ export function getLatestCompactionEventAtOrBefore(
43
+ conversationId: string,
44
+ atOrBefore: number | null,
45
+ ): CompactionEvent | null {
46
+ if (atOrBefore == null) return null;
47
+ const db = getDb();
48
+ const row = db
49
+ .select({
50
+ compactedAt: conversationCompactionEvents.compactedAt,
51
+ summary: conversationCompactionEvents.summary,
52
+ compactedMessageCount: conversationCompactionEvents.compactedMessageCount,
53
+ })
54
+ .from(conversationCompactionEvents)
55
+ .where(
56
+ and(
57
+ eq(conversationCompactionEvents.conversationId, conversationId),
58
+ lte(conversationCompactionEvents.compactedAt, atOrBefore),
59
+ ),
60
+ )
61
+ .orderBy(desc(conversationCompactionEvents.compactedAt))
62
+ .limit(1)
63
+ .get();
64
+ return row ?? null;
65
+ }
66
+
67
+ /**
68
+ * Copy the source conversation's ledger events with `compactedAt <=
69
+ * boundaryCreatedAt` into the fork, so the fork owns a correct ledger for its
70
+ * own future forks/compactions. Takes the active `db` so the copy joins the
71
+ * caller's transaction. No-op when the boundary is null.
72
+ */
73
+ export function forkCompactionLedger(
74
+ db: DrizzleDb,
75
+ sourceConversationId: string,
76
+ forkConversationId: string,
77
+ boundaryCreatedAt: number | null,
78
+ ): void {
79
+ if (boundaryCreatedAt == null) return;
80
+ const events = db
81
+ .select({
82
+ compactedAt: conversationCompactionEvents.compactedAt,
83
+ summary: conversationCompactionEvents.summary,
84
+ compactedMessageCount: conversationCompactionEvents.compactedMessageCount,
85
+ })
86
+ .from(conversationCompactionEvents)
87
+ .where(
88
+ and(
89
+ eq(conversationCompactionEvents.conversationId, sourceConversationId),
90
+ lte(conversationCompactionEvents.compactedAt, boundaryCreatedAt),
91
+ ),
92
+ )
93
+ .all();
94
+ const now = Date.now();
95
+ for (const event of events) {
96
+ db.insert(conversationCompactionEvents)
97
+ .values({
98
+ id: uuid(),
99
+ conversationId: forkConversationId,
100
+ compactedAt: event.compactedAt,
101
+ summary: event.summary,
102
+ compactedMessageCount: event.compactedMessageCount,
103
+ createdAt: now,
104
+ })
105
+ .run();
106
+ }
107
+ }
@@ -34,6 +34,7 @@ import {
34
34
  MEMORY_V3_INJECTED_BLOCK_METADATA_KEY,
35
35
  seedEverInjectedFromSlugs,
36
36
  } from "../plugins/defaults/memory-v3-shadow/ever-injected-store.js";
37
+ import { getCurrentSeq } from "../runtime/assistant-stream-state.js";
37
38
  import { publishSyncInvalidation } from "../runtime/sync/sync-publisher.js";
38
39
  import { UserError } from "../util/errors.js";
39
40
  import { safeParseRecord } from "../util/json.js";
@@ -45,6 +46,11 @@ import {
45
46
  linkAttachmentToMessage,
46
47
  } from "./attachments-store.js";
47
48
  import { AUTO_ANALYSIS_SOURCE } from "./auto-analysis-constants.js";
49
+ import {
50
+ appendCompactionEvent,
51
+ forkCompactionLedger,
52
+ getLatestCompactionEventAtOrBefore,
53
+ } from "./compaction-ledger-store.js";
48
54
  import {
49
55
  projectAssistantMessage,
50
56
  seedForkedConversationAttention,
@@ -591,6 +597,7 @@ export function createConversation(
591
597
  ) {
592
598
  const db = getDb();
593
599
  const now = Date.now();
600
+ const initialSeq = getCurrentSeq();
594
601
  const opts =
595
602
  typeof titleOrOpts === "string"
596
603
  ? { title: titleOrOpts }
@@ -629,6 +636,10 @@ export function createConversation(
629
636
  memoryScopeId,
630
637
  scheduleJobId: opts.scheduleJobId ?? null,
631
638
  forkParentConversationId: opts.forkParentConversationId ?? null,
639
+ // Snapshot↔stream alignment baseline, captured at the creation instant.
640
+ // 0 (nothing stamped yet this process) is stored as NULL so `/messages`
641
+ // reports null and the client cold-starts rather than aligning to seq 0.
642
+ seq: initialSeq > 0 ? initialSeq : null,
632
643
  };
633
644
 
634
645
  // Retry on SQLITE_BUSY and SQLITE_IOERR — transient disk I/O errors or WAL
@@ -928,16 +939,6 @@ export function forkConversation(params: {
928
939
  initialBoundaryIndex,
929
940
  );
930
941
 
931
- const visibleWindowStartIndex = Math.max(
932
- 0,
933
- Math.min(
934
- sourceConversation.contextCompactedMessageCount,
935
- sourceMessages.length,
936
- ),
937
- );
938
- const preserveSourceCompactionState =
939
- copyBoundaryIndex >= visibleWindowStartIndex;
940
-
941
942
  const messagesToCopy =
942
943
  copyBoundaryIndex >= 0
943
944
  ? sourceMessages.slice(0, copyBoundaryIndex + 1)
@@ -953,6 +954,23 @@ export function forkConversation(params: {
953
954
  sourceHistoryStrippedAt != null &&
954
955
  boundaryMessageCreatedAt != null &&
955
956
  boundaryMessageCreatedAt >= sourceHistoryStrippedAt;
957
+
958
+ // Inherit compaction by the same temporal rule: apply the most recent
959
+ // compaction whose event time is at-or-before the forked-from message. A
960
+ // compaction that ran after the boundary message did not exist at that point
961
+ // in the conversation, so the fork branches from full uncompacted history.
962
+ const inheritedCompaction = getLatestCompactionEventAtOrBefore(
963
+ sourceConversation.id,
964
+ boundaryMessageCreatedAt,
965
+ );
966
+ // The Slack chronological-context watermark is single-valued on the source
967
+ // row and reflects only the latest compaction, so carry it only when the
968
+ // fork inherits that latest compaction. Pairing the latest watermark with an
969
+ // older inherited summary (a fork between two compactions) would filter out
970
+ // Slack messages the older summary does not cover.
971
+ const inheritsLatestCompaction =
972
+ inheritedCompaction != null &&
973
+ inheritedCompaction.compactedAt === sourceConversation.contextCompactedAt;
956
974
  const forkParentMessageId = messagesToCopy.at(-1)?.id ?? null;
957
975
  const forkTitle =
958
976
  params.title ?? `${sourceConversation.title ?? "Untitled"} (Fork)`;
@@ -984,19 +1002,14 @@ export function forkConversation(params: {
984
1002
  .set({
985
1003
  forkParentConversationId: sourceConversation.id,
986
1004
  forkParentMessageId,
987
- contextSummary: preserveSourceCompactionState
988
- ? sourceConversation.contextSummary
989
- : null,
990
- contextCompactedMessageCount: preserveSourceCompactionState
991
- ? sourceConversation.contextCompactedMessageCount
992
- : 0,
993
- contextCompactedAt: preserveSourceCompactionState
994
- ? sourceConversation.contextCompactedAt
995
- : null,
996
- slackContextCompactionWatermarkTs: preserveSourceCompactionState
1005
+ contextSummary: inheritedCompaction?.summary ?? null,
1006
+ contextCompactedMessageCount:
1007
+ inheritedCompaction?.compactedMessageCount ?? 0,
1008
+ contextCompactedAt: inheritedCompaction?.compactedAt ?? null,
1009
+ slackContextCompactionWatermarkTs: inheritsLatestCompaction
997
1010
  ? sourceConversation.slackContextCompactionWatermarkTs
998
1011
  : null,
999
- slackContextCompactionWatermarkAt: preserveSourceCompactionState
1012
+ slackContextCompactionWatermarkAt: inheritsLatestCompaction
1000
1013
  ? sourceConversation.slackContextCompactionWatermarkAt
1001
1014
  : null,
1002
1015
  historyStrippedAt: inheritsHistoryStrippedAt
@@ -1042,8 +1055,8 @@ export function forkConversation(params: {
1042
1055
  forkedMessageIds,
1043
1056
  latestForkedAssistant,
1044
1057
  isFullHistoryFork: copyBoundaryIndex === sourceMessages.length - 1,
1045
- preserveSourceCompactionState,
1046
- visibleWindowStartIndex,
1058
+ inheritedCompactedMessageCount:
1059
+ inheritedCompaction?.compactedMessageCount ?? 0,
1047
1060
  diskSyncQueue,
1048
1061
  });
1049
1062
 
@@ -1076,8 +1089,13 @@ interface PopulateForkContentsArgs {
1076
1089
  latestForkedAssistant: { messageId: string; messageAt: number } | null;
1077
1090
  /** `copyBoundaryIndex === sourceMessages.length - 1` for the source. */
1078
1091
  isFullHistoryFork: boolean;
1079
- preserveSourceCompactionState: boolean;
1080
- visibleWindowStartIndex: number;
1092
+ /**
1093
+ * Count of leading messages behind the compaction event this fork inherits
1094
+ * (0 when the fork branches from uncompacted history). Memory-slug seeding
1095
+ * skips this prefix, since rows behind the fork's summary are not rendered
1096
+ * and must stay re-injectable.
1097
+ */
1098
+ inheritedCompactedMessageCount: number;
1081
1099
  /**
1082
1100
  * When provided, a disk-sync entry is appended per copied message for the
1083
1101
  * caller to flush after commit. Omitted by the retrospective fork, whose
@@ -1109,8 +1127,7 @@ function populateForkContentsInProcess(args: PopulateForkContentsArgs): void {
1109
1127
  forkedMessageIds,
1110
1128
  latestForkedAssistant,
1111
1129
  isFullHistoryFork,
1112
- preserveSourceCompactionState,
1113
- visibleWindowStartIndex,
1130
+ inheritedCompactedMessageCount,
1114
1131
  diskSyncQueue,
1115
1132
  } = args;
1116
1133
  const db = getDb();
@@ -1218,9 +1235,10 @@ function populateForkContentsInProcess(args: PopulateForkContentsArgs): void {
1218
1235
  // The v2 and v3 layers persist under separate metadata keys with the
1219
1236
  // same `# memory/concepts/<slug>.md` header convention, so each seeds
1220
1237
  // its own dedup record from its own blocks.
1221
- const visibleStartIndex = preserveSourceCompactionState
1222
- ? visibleWindowStartIndex
1223
- : 0;
1238
+ const visibleStartIndex = Math.min(
1239
+ inheritedCompactedMessageCount,
1240
+ messagesToCopy.length,
1241
+ );
1224
1242
  const inheritedSlugs = new Set<string>();
1225
1243
  const inheritedV3Slugs = new Set<string>();
1226
1244
  for (const message of messagesToCopy.slice(visibleStartIndex)) {
@@ -1256,6 +1274,15 @@ function populateForkContentsInProcess(args: PopulateForkContentsArgs): void {
1256
1274
  forkedMessageIds,
1257
1275
  lastCopiedSourceMessageId: messagesToCopy.at(-1)?.id ?? null,
1258
1276
  });
1277
+
1278
+ // Carry the source's compaction events that predate the fork boundary so the
1279
+ // fork owns a correct ledger for its own future forks/compactions.
1280
+ forkCompactionLedger(
1281
+ db,
1282
+ sourceConversationId,
1283
+ fork.id,
1284
+ messagesToCopy.at(-1)?.createdAt ?? null,
1285
+ );
1259
1286
  }
1260
1287
 
1261
1288
  /**
@@ -1345,15 +1372,6 @@ export async function forkConversationForRetrospective(params: {
1345
1372
  sourceMessages,
1346
1373
  initialBoundaryIndex,
1347
1374
  );
1348
- const visibleWindowStartIndex = Math.max(
1349
- 0,
1350
- Math.min(
1351
- sourceConversation.contextCompactedMessageCount,
1352
- sourceMessages.length,
1353
- ),
1354
- );
1355
- const preserveSourceCompactionState =
1356
- copyBoundaryIndex >= visibleWindowStartIndex;
1357
1375
  const messagesToCopy =
1358
1376
  copyBoundaryIndex >= 0
1359
1377
  ? sourceMessages.slice(0, copyBoundaryIndex + 1)
@@ -1365,6 +1383,17 @@ export async function forkConversationForRetrospective(params: {
1365
1383
  sourceHistoryStrippedAt != null &&
1366
1384
  boundaryMessageCreatedAt != null &&
1367
1385
  boundaryMessageCreatedAt >= sourceHistoryStrippedAt;
1386
+ // Inherit the most recent compaction whose event time is at-or-before the
1387
+ // forked-from message (see `forkConversation`).
1388
+ const inheritedCompaction = getLatestCompactionEventAtOrBefore(
1389
+ sourceConversation.id,
1390
+ boundaryMessageCreatedAt,
1391
+ );
1392
+ // Carry the Slack watermark only when inheriting the latest compaction
1393
+ // (see `forkConversation`).
1394
+ const inheritsLatestCompaction =
1395
+ inheritedCompaction != null &&
1396
+ inheritedCompaction.compactedAt === sourceConversation.contextCompactedAt;
1368
1397
  const forkParentMessageId = messagesToCopy.at(-1)?.id ?? null;
1369
1398
  const forkTitle =
1370
1399
  params.title ?? `${sourceConversation.title ?? "Untitled"} (Fork)`;
@@ -1393,19 +1422,14 @@ export async function forkConversationForRetrospective(params: {
1393
1422
  .set({
1394
1423
  forkParentConversationId: sourceConversation.id,
1395
1424
  forkParentMessageId,
1396
- contextSummary: preserveSourceCompactionState
1397
- ? sourceConversation.contextSummary
1398
- : null,
1399
- contextCompactedMessageCount: preserveSourceCompactionState
1400
- ? sourceConversation.contextCompactedMessageCount
1401
- : 0,
1402
- contextCompactedAt: preserveSourceCompactionState
1403
- ? sourceConversation.contextCompactedAt
1404
- : null,
1405
- slackContextCompactionWatermarkTs: preserveSourceCompactionState
1425
+ contextSummary: inheritedCompaction?.summary ?? null,
1426
+ contextCompactedMessageCount:
1427
+ inheritedCompaction?.compactedMessageCount ?? 0,
1428
+ contextCompactedAt: inheritedCompaction?.compactedAt ?? null,
1429
+ slackContextCompactionWatermarkTs: inheritsLatestCompaction
1406
1430
  ? sourceConversation.slackContextCompactionWatermarkTs
1407
1431
  : null,
1408
- slackContextCompactionWatermarkAt: preserveSourceCompactionState
1432
+ slackContextCompactionWatermarkAt: inheritsLatestCompaction
1409
1433
  ? sourceConversation.slackContextCompactionWatermarkAt
1410
1434
  : null,
1411
1435
  historyStrippedAt: inheritsHistoryStrippedAt
@@ -1445,8 +1469,8 @@ export async function forkConversationForRetrospective(params: {
1445
1469
  forkedMessageIds,
1446
1470
  latestForkedAssistant,
1447
1471
  isFullHistoryFork: copyBoundaryIndex === sourceMessages.length - 1,
1448
- preserveSourceCompactionState,
1449
- visibleWindowStartIndex,
1472
+ inheritedCompactedMessageCount:
1473
+ inheritedCompaction?.compactedMessageCount ?? 0,
1450
1474
  });
1451
1475
  });
1452
1476
 
@@ -2117,15 +2141,27 @@ export function updateConversationContextWindow(
2117
2141
  contextCompactedMessageCount: number,
2118
2142
  ): void {
2119
2143
  const db = getDb();
2120
- db.update(conversations)
2121
- .set({
2122
- contextSummary,
2123
- contextCompactedMessageCount,
2124
- contextCompactedAt: Date.now(),
2125
- updatedAt: Date.now(),
2126
- })
2127
- .where(eq(conversations.id, id))
2128
- .run();
2144
+ const now = Date.now();
2145
+ // Update the hot-path cache columns and append the event to the ledger in a
2146
+ // single transaction so the latest ledger event always matches the cache.
2147
+ db.transaction(() => {
2148
+ db.update(conversations)
2149
+ .set({
2150
+ contextSummary,
2151
+ contextCompactedMessageCount,
2152
+ contextCompactedAt: now,
2153
+ updatedAt: now,
2154
+ })
2155
+ .where(eq(conversations.id, id))
2156
+ .run();
2157
+ if (contextCompactedMessageCount > 0) {
2158
+ appendCompactionEvent(id, {
2159
+ compactedAt: now,
2160
+ summary: contextSummary,
2161
+ compactedMessageCount: contextCompactedMessageCount,
2162
+ });
2163
+ }
2164
+ });
2129
2165
  }
2130
2166
 
2131
2167
  export function setConversationHistoryStrippedAt(
@@ -2218,6 +2254,45 @@ export function isConversationProcessing(id: string): boolean {
2218
2254
  return row?.processing_started_at != null;
2219
2255
  }
2220
2256
 
2257
+ /**
2258
+ * Highest stream `seq` whose content is durably persisted to this
2259
+ * conversation's message rows, read from the `conversations.seq` column. This
2260
+ * is the snapshot↔stream alignment baseline `/messages` returns so a client
2261
+ * applies only stream events with a higher `seq`. `null` when none was
2262
+ * recorded (created before any stream activity, row predates the column, or
2263
+ * the conversation row is absent), in which case the client cold-starts.
2264
+ *
2265
+ * Seeded at creation with the global high-water seq and advanced on each
2266
+ * persistence flush by {@link recordConversationPersistedSeq}.
2267
+ */
2268
+ export function getConversationPersistedSeq(id: string): number | null {
2269
+ const row = rawGet<{ seq: number | null }>(
2270
+ "SELECT seq FROM conversations WHERE id = ?",
2271
+ id,
2272
+ );
2273
+ return row?.seq ?? null;
2274
+ }
2275
+
2276
+ /**
2277
+ * Record that conversation `id` has durably persisted all of its events
2278
+ * through `seq`, writing the `conversations.seq` column. Called at each
2279
+ * persistence flush with the `seq` of the last event whose content the write
2280
+ * committed.
2281
+ *
2282
+ * Monotonic: the `WHERE seq IS NULL OR seq < ?` guard makes the update raise
2283
+ * the high-water mark only, so out-of-order async commits never regress it.
2284
+ * Non-positive or non-finite `seq` values are ignored.
2285
+ */
2286
+ export function recordConversationPersistedSeq(id: string, seq: number): void {
2287
+ if (!Number.isFinite(seq) || seq <= 0) return;
2288
+ rawRun(
2289
+ "UPDATE conversations SET seq = ? WHERE id = ? AND (seq IS NULL OR seq < ?)",
2290
+ seq,
2291
+ id,
2292
+ seq,
2293
+ );
2294
+ }
2295
+
2221
2296
  /**
2222
2297
  * Set or clear the `surfaced_at` promotion marker for a conversation.
2223
2298
  *