@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,107 @@
1
+ import { type DrizzleDb, getSqliteFrom } from "../db-connection.js";
2
+
3
+ /**
4
+ * Create the append-only `conversation_compaction_events` ledger and backfill
5
+ * one event per already-compacted conversation from its current cache columns.
6
+ *
7
+ * The `conversations` row keeps the latest compaction
8
+ * (`context_summary` / `context_compacted_message_count` /
9
+ * `context_compacted_at`) as the hot-path cache the load path reads; this table
10
+ * preserves the full history so a fork can inherit the most recent compaction
11
+ * whose event time predates the message it forks from. Pre-feature history
12
+ * beyond the latest compaction is unrecoverable, so existing rows seed exactly
13
+ * one event.
14
+ *
15
+ * Idempotent: table creation is guarded on sqlite_master; the backfill is
16
+ * guarded by a memory_checkpoints key and a per-conversation NOT EXISTS so a
17
+ * lost checkpoint cannot duplicate rows.
18
+ */
19
+ export function migrateCreateCompactionEvents(database: DrizzleDb): void {
20
+ const raw = getSqliteFrom(database);
21
+
22
+ const tableExists = raw
23
+ .query(
24
+ `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'conversation_compaction_events'`,
25
+ )
26
+ .get();
27
+
28
+ if (!tableExists) {
29
+ try {
30
+ raw.exec("BEGIN");
31
+
32
+ raw.exec(/*sql*/ `
33
+ CREATE TABLE conversation_compaction_events (
34
+ id TEXT PRIMARY KEY,
35
+ conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
36
+ compacted_at INTEGER NOT NULL,
37
+ summary TEXT NOT NULL,
38
+ compacted_message_count INTEGER NOT NULL,
39
+ created_at INTEGER NOT NULL
40
+ )
41
+ `);
42
+
43
+ raw.exec(/*sql*/ `
44
+ CREATE INDEX idx_compaction_events_conv_at
45
+ ON conversation_compaction_events(conversation_id, compacted_at)
46
+ `);
47
+
48
+ raw.exec("COMMIT");
49
+ } catch (e) {
50
+ try {
51
+ raw.exec("ROLLBACK");
52
+ } catch {
53
+ /* no active transaction */
54
+ }
55
+ throw e;
56
+ }
57
+ }
58
+
59
+ const checkpointKey = "backfill_conversation_compaction_events_v1";
60
+ const checkpoint = raw
61
+ .query(`SELECT 1 FROM memory_checkpoints WHERE key = ?`)
62
+ .get(checkpointKey);
63
+ if (checkpoint) return;
64
+
65
+ try {
66
+ raw.exec("BEGIN");
67
+
68
+ raw
69
+ .query(
70
+ /*sql*/ `
71
+ INSERT INTO conversation_compaction_events
72
+ (id, conversation_id, compacted_at, summary, compacted_message_count, created_at)
73
+ SELECT
74
+ lower(hex(randomblob(16))),
75
+ c.id,
76
+ c.context_compacted_at,
77
+ c.context_summary,
78
+ c.context_compacted_message_count,
79
+ ?
80
+ FROM conversations c
81
+ WHERE c.context_compacted_message_count > 0
82
+ AND c.context_compacted_at IS NOT NULL
83
+ AND c.context_summary IS NOT NULL
84
+ AND NOT EXISTS (
85
+ SELECT 1 FROM conversation_compaction_events e
86
+ WHERE e.conversation_id = c.id
87
+ )
88
+ `,
89
+ )
90
+ .run(Date.now());
91
+
92
+ raw
93
+ .query(
94
+ `INSERT OR IGNORE INTO memory_checkpoints (key, value, updated_at) VALUES (?, '1', ?)`,
95
+ )
96
+ .run(checkpointKey, Date.now());
97
+
98
+ raw.exec("COMMIT");
99
+ } catch (e) {
100
+ try {
101
+ raw.exec("ROLLBACK");
102
+ } catch {
103
+ /* no active transaction */
104
+ }
105
+ throw e;
106
+ }
107
+ }
@@ -0,0 +1,33 @@
1
+ import type { DrizzleDb } from "../db-connection.js";
2
+ import { tableHasColumn } from "./schema-introspection.js";
3
+
4
+ const COLUMN_NAME = "seq";
5
+ const COLUMN_DEFINITION = "seq INTEGER";
6
+
7
+ /**
8
+ * Add the `seq` column to the `conversations` table.
9
+ *
10
+ * The column holds the highest stream `seq` whose content is durably persisted
11
+ * to the conversation's message rows: seeded with the global high-water seq
12
+ * when the row is inserted and advanced on each persistence flush. It is the
13
+ * durable snapshot↔stream alignment baseline returned by `/messages` — a
14
+ * client aligns its snapshot with the `/events` stream by applying only events
15
+ * with a higher `seq`.
16
+ *
17
+ * Nullable: NULL means the conversation was created before any stream activity
18
+ * (global seq was 0) or predates this column, in which case the client
19
+ * cold-starts.
20
+ *
21
+ * No backfill is needed — existing rows default to NULL, which correctly maps
22
+ * to "no recorded baseline, cold-start".
23
+ *
24
+ * Idempotent: guarded with `tableHasColumn` so a crash between the `ALTER
25
+ * TABLE` and the checkpoint write doesn't cause a duplicate-column error on
26
+ * the next boot.
27
+ */
28
+ export function migrateAddConversationCreationSeq(database: DrizzleDb): void {
29
+ if (tableHasColumn(database, "conversations", COLUMN_NAME)) {
30
+ return;
31
+ }
32
+ database.run(`ALTER TABLE conversations ADD COLUMN ${COLUMN_DEFINITION}`);
33
+ }
@@ -12,8 +12,11 @@ import { describe, expect, test } from "bun:test";
12
12
 
13
13
  const { getDb, getSqlite } = await import("../../db-connection.js");
14
14
  const { initializeDb } = await import("../../db-init.js");
15
- const { migrateStripThinkingFromConsolidated } =
16
- await import("../209-strip-thinking-from-consolidated.js");
15
+ const {
16
+ migrateStripThinkingFromConsolidated,
17
+ ROWID_WINDOW,
18
+ WINDOW_TIMEOUT_MS,
19
+ } = await import("../209-strip-thinking-from-consolidated.js");
17
20
 
18
21
  await initializeDb();
19
22
 
@@ -40,6 +43,22 @@ function insert(role: string, content: string): { id: string; rowid: number } {
40
43
  return { id, rowid };
41
44
  }
42
45
 
46
+ /** Insert at an explicit rowid so a test can place rows on either side of a
47
+ * window boundary without materializing every intervening row. */
48
+ function insertAt(
49
+ rowid: number,
50
+ role: string,
51
+ content: string,
52
+ ): { id: string; rowid: number } {
53
+ const id = `m209-at-${rowid}`;
54
+ getSqlite()
55
+ .query(
56
+ `INSERT INTO messages (rowid, id, conversation_id, role, content, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
57
+ )
58
+ .run(rowid, id, CONV, role, content, Date.now());
59
+ return { id, rowid };
60
+ }
61
+
43
62
  function content(id: string): string {
44
63
  return (
45
64
  getSqlite().query(`SELECT content FROM messages WHERE id = ?`).get(id) as {
@@ -52,6 +71,13 @@ function blocks(id: string): Array<Record<string, unknown>> {
52
71
  return JSON.parse(content(id));
53
72
  }
54
73
 
74
+ /** The persisted resume watermark, or null once the sweep has cleared it. */
75
+ function watermark(): unknown {
76
+ return getSqlite()
77
+ .query(`SELECT value FROM memory_checkpoints WHERE key = ?`)
78
+ .get("migration_209_strip_thinking_watermark");
79
+ }
80
+
55
81
  describe("migration 209 — strip thinking from consolidated assistant messages", () => {
56
82
  test("strips thinking blocks but keeps text and tool_use, preserving order", async () => {
57
83
  const { id } = insert(
@@ -216,9 +242,56 @@ describe("migration 209 — strip thinking from consolidated assistant messages"
216
242
  expect(blocks(above.id)).toEqual([{ type: "text", text: "above" }]);
217
243
 
218
244
  // The watermark is cleared once the sweep reaches the end of the table.
219
- const wm = getSqlite()
220
- .query(`SELECT value FROM memory_checkpoints WHERE key = ?`)
221
- .get("migration_209_strip_thinking_watermark");
222
- expect(wm).toBeNull();
245
+ expect(watermark()).toBeNull();
246
+ });
247
+
248
+ test("sweeps a rowid span wider than one window across multiple windows", async () => {
249
+ // Place rows on either side of a window boundary so the sweep must iterate
250
+ // the window loop more than once to reach them. The original 100k window
251
+ // collapsed any table smaller than itself into a single subprocess sweep —
252
+ // this exercises the bounded-window loop that replaces it.
253
+ const base =
254
+ (
255
+ getSqlite().query(`SELECT MAX(rowid) AS m FROM messages`).get() as {
256
+ m: number | null;
257
+ }
258
+ ).m ?? 0;
259
+ const first = insertAt(
260
+ base + ROWID_WINDOW,
261
+ "assistant",
262
+ JSON.stringify([
263
+ { type: "thinking", thinking: "a", signature: "s" },
264
+ { type: "text", text: "first" },
265
+ ]),
266
+ );
267
+ const second = insertAt(
268
+ base + 2 * ROWID_WINDOW + 1,
269
+ "assistant",
270
+ JSON.stringify([
271
+ { type: "thinking", thinking: "b", signature: "s" },
272
+ { type: "text", text: "second" },
273
+ ]),
274
+ );
275
+
276
+ await migrateStripThinkingFromConsolidated(getDb());
277
+
278
+ // Both rows are cleaned even though they sit in different windows, and the
279
+ // sweep runs to completion (watermark cleared) rather than stalling on the
280
+ // first window.
281
+ expect(blocks(first.id)).toEqual([{ type: "text", text: "first" }]);
282
+ expect(blocks(second.id)).toEqual([{ type: "text", text: "second" }]);
283
+ expect(watermark()).toBeNull();
284
+ });
285
+
286
+ test("keeps the sweep window and timeout bounded so it cannot overrun a whole table", () => {
287
+ // Regression guard: ROWID_WINDOW was 100_000, larger than a typical
288
+ // messages table, so the entire table swept in one subprocess that overran
289
+ // runAsyncSqlite's 1h cap, never advanced the watermark, and re-ran the
290
+ // identical doomed window every boot. The window must stay small enough that
291
+ // a single window finishes well within its timeout, and the per-window cap
292
+ // must stay below the 1h whole-process default so a stuck window surfaces in
293
+ // minutes instead of blocking startup for an hour.
294
+ expect(ROWID_WINDOW).toBeLessThanOrEqual(10_000);
295
+ expect(WINDOW_TIMEOUT_MS).toBeLessThan(60 * 60 * 1000);
223
296
  });
224
297
  });
@@ -8,10 +8,14 @@ export const contacts = sqliteTable("contacts", {
8
8
  notes: text("notes"),
9
9
  createdAt: integer("created_at").notNull(),
10
10
  updatedAt: integer("updated_at").notNull(),
11
- role: text("role").notNull().default("contact"), // 'guardian' | 'contact'
11
+ role: text("role", { enum: ["guardian", "contact"] })
12
+ .notNull()
13
+ .default("contact"),
12
14
  principalId: text("principal_id"), // internal auth principal (nullable)
13
15
  userFile: text("user_file"), // workspace-relative path to per-user persona file
14
- contactType: text("contact_type").notNull().default("human"), // 'human' | 'assistant'
16
+ contactType: text("contact_type", { enum: ["human", "assistant"] })
17
+ .notNull()
18
+ .default("human"),
15
19
  });
16
20
 
17
21
  export const contactChannels = sqliteTable(
@@ -61,6 +61,17 @@ export const conversations = sqliteTable(
61
61
  * callers read this column directly.
62
62
  */
63
63
  processingStartedAt: integer("processing_started_at"),
64
+ /**
65
+ * Highest stream `seq` whose content is durably persisted to this
66
+ * conversation's message rows. Seeded with the global high-water seq when
67
+ * the row is inserted and advanced on each persistence flush
68
+ * (`recordConversationPersistedSeq`). Returned by `/messages` as the
69
+ * snapshot↔stream alignment baseline so a client applies only stream
70
+ * events with a higher `seq`. NULL means the conversation was created
71
+ * before any stream activity (global seq 0) or predates this column — the
72
+ * client cold-starts in that case.
73
+ */
74
+ seq: integer("seq"),
64
75
  },
65
76
  (table) => [
66
77
  index("idx_conversations_updated_at").on(table.updatedAt),
@@ -170,6 +181,34 @@ export const conversationGraphMemoryState = sqliteTable(
170
181
  },
171
182
  );
172
183
 
184
+ /**
185
+ * Append-only ledger of every compaction event for a conversation. The
186
+ * `conversations` row keeps only the latest compaction (`context_summary` /
187
+ * `context_compacted_message_count` / `context_compacted_at`) as the hot-path
188
+ * cache the load path reads; this table preserves the full history so a fork
189
+ * can inherit the most recent compaction whose event time (`compacted_at`)
190
+ * is at-or-before the boundary message it forks from.
191
+ */
192
+ export const conversationCompactionEvents = sqliteTable(
193
+ "conversation_compaction_events",
194
+ {
195
+ id: text("id").primaryKey(),
196
+ conversationId: text("conversation_id")
197
+ .notNull()
198
+ .references(() => conversations.id, { onDelete: "cascade" }),
199
+ compactedAt: integer("compacted_at").notNull(),
200
+ summary: text("summary").notNull(),
201
+ compactedMessageCount: integer("compacted_message_count").notNull(),
202
+ createdAt: integer("created_at").notNull(),
203
+ },
204
+ (table) => [
205
+ index("idx_compaction_events_conv_at").on(
206
+ table.conversationId,
207
+ table.compactedAt,
208
+ ),
209
+ ],
210
+ );
211
+
173
212
  export const channelInboundEvents = sqliteTable("channel_inbound_events", {
174
213
  id: text("id").primaryKey(),
175
214
  sourceChannel: text("source_channel").notNull(),