vellum 0.2.7 → 0.2.9

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 (76) hide show
  1. package/bun.lock +4 -4
  2. package/package.json +4 -3
  3. package/src/__tests__/asset-materialize-tool.test.ts +2 -2
  4. package/src/__tests__/checker.test.ts +104 -0
  5. package/src/__tests__/config-schema.test.ts +0 -6
  6. package/src/__tests__/forbidden-legacy-symbols.test.ts +69 -0
  7. package/src/__tests__/gateway-only-enforcement.test.ts +538 -0
  8. package/src/__tests__/ingress-url-consistency.test.ts +214 -0
  9. package/src/__tests__/ipc-snapshot.test.ts +17 -5
  10. package/src/__tests__/oauth-callback-registry.test.ts +85 -0
  11. package/src/__tests__/oauth2-gateway-transport.test.ts +304 -0
  12. package/src/__tests__/provider-commit-message-generator.test.ts +51 -12
  13. package/src/__tests__/public-ingress-urls.test.ts +222 -0
  14. package/src/__tests__/runtime-events-sse-parity.test.ts +343 -0
  15. package/src/__tests__/runtime-events-sse.test.ts +162 -0
  16. package/src/__tests__/tool-executor.test.ts +88 -0
  17. package/src/__tests__/turn-commit.test.ts +64 -0
  18. package/src/__tests__/twilio-provider.test.ts +1 -1
  19. package/src/__tests__/twilio-routes.test.ts +4 -4
  20. package/src/__tests__/twitter-auth-handler.test.ts +87 -2
  21. package/src/calls/call-domain.ts +8 -6
  22. package/src/calls/twilio-config.ts +18 -3
  23. package/src/calls/twilio-routes.ts +10 -2
  24. package/src/config/bundled-skills/tasks/TOOLS.json +25 -0
  25. package/src/config/bundled-skills/tasks/tools/task-queue-run.ts +9 -0
  26. package/src/config/bundled-skills/transcribe/SKILL.md +25 -0
  27. package/src/config/bundled-skills/transcribe/TOOLS.json +32 -0
  28. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +370 -0
  29. package/src/config/defaults.ts +4 -1
  30. package/src/config/schema.ts +30 -6
  31. package/src/config/system-prompt.ts +1 -1
  32. package/src/config/types.ts +1 -0
  33. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +5 -4
  34. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +4 -2
  35. package/src/config/vellum-skills/telegram-setup/SKILL.md +3 -3
  36. package/src/daemon/computer-use-session.ts +2 -1
  37. package/src/daemon/handlers/config.ts +49 -17
  38. package/src/daemon/handlers/sessions.ts +2 -2
  39. package/src/daemon/handlers/shared.ts +1 -0
  40. package/src/daemon/handlers/subagents.ts +85 -2
  41. package/src/daemon/handlers/twitter-auth.ts +31 -2
  42. package/src/daemon/handlers/work-items.ts +1 -1
  43. package/src/daemon/ipc-contract-inventory.json +8 -4
  44. package/src/daemon/ipc-contract.ts +34 -15
  45. package/src/daemon/lifecycle.ts +9 -4
  46. package/src/daemon/server.ts +7 -0
  47. package/src/daemon/session-tool-setup.ts +8 -1
  48. package/src/inbound/public-ingress-urls.ts +112 -0
  49. package/src/memory/attachments-store.ts +0 -1
  50. package/src/memory/channel-delivery-store.ts +0 -1
  51. package/src/memory/conversation-key-store.ts +0 -1
  52. package/src/memory/db.ts +472 -148
  53. package/src/memory/llm-usage-store.ts +0 -1
  54. package/src/memory/runs-store.ts +51 -6
  55. package/src/memory/schema.ts +2 -6
  56. package/src/runtime/gateway-client.ts +7 -1
  57. package/src/runtime/http-server.ts +174 -7
  58. package/src/runtime/routes/channel-routes.ts +7 -2
  59. package/src/runtime/routes/events-routes.ts +79 -0
  60. package/src/runtime/routes/run-routes.ts +43 -0
  61. package/src/runtime/run-orchestrator.ts +64 -7
  62. package/src/security/oauth-callback-registry.ts +66 -0
  63. package/src/security/oauth2.ts +208 -58
  64. package/src/subagent/manager.ts +3 -1
  65. package/src/swarm/backend-claude-code.ts +1 -1
  66. package/src/tools/assets/search.ts +1 -36
  67. package/src/tools/claude-code/claude-code.ts +3 -3
  68. package/src/tools/tasks/work-item-list.ts +16 -2
  69. package/src/tools/tasks/work-item-run.ts +78 -0
  70. package/src/util/platform.ts +1 -1
  71. package/src/work-items/work-item-runner.ts +171 -0
  72. package/src/workspace/provider-commit-message-generator.ts +39 -23
  73. package/src/workspace/turn-commit.ts +6 -2
  74. package/src/__tests__/handlers-twilio-config.test.ts +0 -221
  75. package/src/calls/__tests__/twilio-webhook-urls.test.ts +0 -162
  76. package/src/calls/twilio-webhook-urls.ts +0 -50
package/src/memory/db.ts CHANGED
@@ -184,7 +184,6 @@ export function initializeDb(): void {
184
184
  database.run(/*sql*/ `
185
185
  CREATE TABLE IF NOT EXISTS attachments (
186
186
  id TEXT PRIMARY KEY,
187
- assistant_id TEXT NOT NULL,
188
187
  original_filename TEXT NOT NULL,
189
188
  mime_type TEXT NOT NULL,
190
189
  size_bytes INTEGER NOT NULL,
@@ -223,7 +222,6 @@ export function initializeDb(): void {
223
222
  database.run(/*sql*/ `
224
223
  CREATE TABLE IF NOT EXISTS channel_inbound_events (
225
224
  id TEXT PRIMARY KEY,
226
- assistant_id TEXT NOT NULL,
227
225
  source_channel TEXT NOT NULL,
228
226
  external_chat_id TEXT NOT NULL,
229
227
  external_message_id TEXT NOT NULL,
@@ -232,18 +230,18 @@ export function initializeDb(): void {
232
230
  delivery_status TEXT NOT NULL DEFAULT 'pending',
233
231
  created_at INTEGER NOT NULL,
234
232
  updated_at INTEGER NOT NULL,
235
- UNIQUE (assistant_id, source_channel, external_chat_id, external_message_id)
233
+ UNIQUE (source_channel, external_chat_id, external_message_id)
236
234
  )
237
235
  `);
238
236
 
239
237
  database.run(/*sql*/ `
240
238
  CREATE TABLE IF NOT EXISTS message_runs (
241
239
  id TEXT PRIMARY KEY,
242
- assistant_id TEXT NOT NULL,
243
240
  conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
244
241
  message_id TEXT REFERENCES messages(id) ON DELETE CASCADE,
245
242
  status TEXT NOT NULL DEFAULT 'running',
246
243
  pending_confirmation TEXT,
244
+ pending_secret TEXT,
247
245
  input_tokens INTEGER NOT NULL DEFAULT 0,
248
246
  output_tokens INTEGER NOT NULL DEFAULT 0,
249
247
  estimated_cost REAL NOT NULL DEFAULT 0,
@@ -253,6 +251,8 @@ export function initializeDb(): void {
253
251
  )
254
252
  `);
255
253
 
254
+ try { database.run(/*sql*/ `ALTER TABLE message_runs ADD COLUMN pending_secret TEXT`); } catch (e) { log.debug({ err: e }, 'ALTER TABLE message_runs ADD COLUMN pending_secret (likely already exists)'); }
255
+
256
256
  database.run(/*sql*/ `
257
257
  CREATE TABLE IF NOT EXISTS reminders (
258
258
  id TEXT PRIMARY KEY,
@@ -421,7 +421,6 @@ export function initializeDb(): void {
421
421
  CREATE TABLE IF NOT EXISTS llm_usage_events (
422
422
  id TEXT PRIMARY KEY,
423
423
  created_at INTEGER NOT NULL,
424
- assistant_id TEXT,
425
424
  conversation_id TEXT,
426
425
  run_id TEXT,
427
426
  request_id TEXT,
@@ -507,11 +506,9 @@ export function initializeDb(): void {
507
506
  database.run(/*sql*/ `
508
507
  CREATE TABLE IF NOT EXISTS conversation_keys (
509
508
  id TEXT PRIMARY KEY,
510
- assistant_id TEXT NOT NULL,
511
- conversation_key TEXT NOT NULL,
509
+ conversation_key TEXT NOT NULL UNIQUE,
512
510
  conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
513
- created_at INTEGER NOT NULL,
514
- UNIQUE (assistant_id, conversation_key)
511
+ created_at INTEGER NOT NULL
515
512
  )
516
513
  `);
517
514
 
@@ -552,6 +549,8 @@ export function initializeDb(): void {
552
549
  migrateMemoryItemsFingerprintScopeUnique(database);
553
550
  migrateMemoryItemsScopeSaltedFingerprints(database);
554
551
  migrateAssistantIdToSelf(database);
552
+ migrateRemoveAssistantIdColumns(database);
553
+ migrateLlmUsageEventsDropAssistantId(database);
555
554
 
556
555
  // Indexes for query performance on large datasets
557
556
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_llm_request_logs_conv_created ON llm_request_logs(conversation_id, created_at)`);
@@ -592,17 +591,16 @@ export function initializeDb(): void {
592
591
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_memory_segments_scope_id ON memory_segments(scope_id)`);
593
592
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_memory_items_scope_id ON memory_items(scope_id)`);
594
593
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_memory_summaries_scope_id ON memory_summaries(scope_id)`);
595
- database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_conversation_keys_assistant_key ON conversation_keys(assistant_id, conversation_key)`);
596
- database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_attachments_assistant_id ON attachments(assistant_id)`);
597
- database.run(/*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_attachments_content_dedup ON attachments(assistant_id, content_hash) WHERE content_hash IS NOT NULL`);
594
+ database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_conversation_keys_key ON conversation_keys(conversation_key)`);
595
+ database.run(/*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_attachments_content_dedup ON attachments(content_hash) WHERE content_hash IS NOT NULL`);
598
596
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_message_attachments_message_id ON message_attachments(message_id)`);
599
597
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_message_attachments_attachment_id ON message_attachments(attachment_id)`);
600
- database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_channel_inbound_events_lookup ON channel_inbound_events(assistant_id, source_channel, external_chat_id, external_message_id)`);
598
+ database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_channel_inbound_events_lookup ON channel_inbound_events(source_channel, external_chat_id, external_message_id)`);
601
599
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_channel_inbound_events_conversation ON channel_inbound_events(conversation_id)`);
602
- database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_channel_inbound_events_source_msg ON channel_inbound_events(assistant_id, source_channel, external_chat_id, source_message_id)`);
600
+ database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_channel_inbound_events_source_msg ON channel_inbound_events(source_channel, external_chat_id, source_message_id)`);
603
601
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_channel_inbound_events_processing_retry ON channel_inbound_events(processing_status, retry_after)`);
604
602
 
605
- database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_message_runs_assistant_status ON message_runs(assistant_id, status)`);
603
+ database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_message_runs_status ON message_runs(status)`);
606
604
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_message_runs_conversation ON message_runs(conversation_id)`);
607
605
 
608
606
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_reminders_status_fire_at ON reminders(status, fire_at)`);
@@ -615,7 +613,6 @@ export function initializeDb(): void {
615
613
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_accounts_status ON accounts(status)`);
616
614
 
617
615
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_llm_usage_events_created_at ON llm_usage_events(created_at)`);
618
- database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_llm_usage_events_assistant_id ON llm_usage_events(assistant_id)`);
619
616
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_llm_usage_events_provider ON llm_usage_events(provider)`);
620
617
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_llm_usage_events_model ON llm_usage_events(model)`);
621
618
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_llm_usage_events_actor ON llm_usage_events(actor)`);
@@ -1224,119 +1221,162 @@ function migrateAssistantIdToSelf(database: ReturnType<typeof drizzle<typeof sch
1224
1221
  ).get(checkpointKey);
1225
1222
  if (checkpoint) return;
1226
1223
 
1224
+ // On fresh installs the tables are created without assistant_id (PR 7+). Skip the
1225
+ // migration if NONE of the four affected tables have the column — pre-seed the
1226
+ // checkpoint so subsequent startups are also skipped. Checking all four (not just
1227
+ // conversation_keys) avoids a false negative on very old installs where
1228
+ // conversation_keys may not exist yet but other tables still carry assistant_id data.
1229
+ const affectedTables = ['conversation_keys', 'attachments', 'channel_inbound_events', 'message_runs'];
1230
+ const anyHasAssistantId = affectedTables.some((tbl) => {
1231
+ const ddl = raw.query(
1232
+ `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?`,
1233
+ ).get(tbl) as { sql: string } | null;
1234
+ return ddl?.sql.includes('assistant_id') ?? false;
1235
+ });
1236
+ if (!anyHasAssistantId) {
1237
+ raw.query(
1238
+ `INSERT OR IGNORE INTO memory_checkpoints (key, value, updated_at) VALUES (?, '1', ?)`,
1239
+ ).run(checkpointKey, Date.now());
1240
+ return;
1241
+ }
1242
+
1243
+ // Helper: returns true if the given table's current DDL contains 'assistant_id'.
1244
+ const tableHasAssistantId = (tbl: string): boolean => {
1245
+ const ddl = raw.query(
1246
+ `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?`,
1247
+ ).get(tbl) as { sql: string } | null;
1248
+ return ddl?.sql.includes('assistant_id') ?? false;
1249
+ };
1250
+
1227
1251
  try {
1228
1252
  raw.exec('BEGIN');
1229
1253
 
1254
+ // Each section is guarded so that SQL referencing assistant_id is only executed
1255
+ // when the column still exists in that table. This handles mixed-schema states
1256
+ // (e.g., very old installs where some tables may already lack the column).
1257
+
1230
1258
  // conversation_keys: UNIQUE (assistant_id, conversation_key)
1231
- //
1232
- // Step 1: Among non-self rows, keep only one per conversation_key so the
1233
- // bulk UPDATE cannot hit a (non-self-A, key) + (non-self-B, key) collision.
1234
- raw.exec(/*sql*/ `
1235
- DELETE FROM conversation_keys
1236
- WHERE assistant_id != 'self'
1237
- AND rowid NOT IN (
1238
- SELECT MIN(rowid) FROM conversation_keys
1239
- WHERE assistant_id != 'self'
1240
- GROUP BY conversation_key
1241
- )
1242
- `);
1243
- // Step 2: For 'self' rows that have a non-self counterpart with the same
1244
- // conversation_key, update the 'self' row to use the non-self row's
1245
- // conversation_id. This preserves the historical conversation (which
1246
- // has the message history from before the route change) rather than
1247
- // discarding it in favour of a potentially-empty 'self' conversation.
1248
- raw.exec(/*sql*/ `
1249
- UPDATE conversation_keys
1250
- SET conversation_id = (
1251
- SELECT ck_ns.conversation_id
1252
- FROM conversation_keys ck_ns
1253
- WHERE ck_ns.assistant_id != 'self'
1254
- AND ck_ns.conversation_key = conversation_keys.conversation_key
1255
- ORDER BY ck_ns.rowid
1256
- LIMIT 1
1257
- )
1258
- WHERE assistant_id = 'self'
1259
- AND EXISTS (
1260
- SELECT 1 FROM conversation_keys ck_ns
1259
+ if (tableHasAssistantId('conversation_keys')) {
1260
+ // Step 1: Among non-self rows, keep only one per conversation_key so the
1261
+ // bulk UPDATE cannot hit a (non-self-A, key) + (non-self-B, key) collision.
1262
+ raw.exec(/*sql*/ `
1263
+ DELETE FROM conversation_keys
1264
+ WHERE assistant_id != 'self'
1265
+ AND rowid NOT IN (
1266
+ SELECT MIN(rowid) FROM conversation_keys
1267
+ WHERE assistant_id != 'self'
1268
+ GROUP BY conversation_key
1269
+ )
1270
+ `);
1271
+ // Step 2: For 'self' rows that have a non-self counterpart with the same
1272
+ // conversation_key, update the 'self' row to use the non-self row's
1273
+ // conversation_id. This preserves the historical conversation (which
1274
+ // has the message history from before the route change) rather than
1275
+ // discarding it in favour of a potentially-empty 'self' conversation.
1276
+ raw.exec(/*sql*/ `
1277
+ UPDATE conversation_keys
1278
+ SET conversation_id = (
1279
+ SELECT ck_ns.conversation_id
1280
+ FROM conversation_keys ck_ns
1261
1281
  WHERE ck_ns.assistant_id != 'self'
1262
1282
  AND ck_ns.conversation_key = conversation_keys.conversation_key
1283
+ ORDER BY ck_ns.rowid
1284
+ LIMIT 1
1263
1285
  )
1264
- `);
1265
- // Step 3: Delete the now-redundant non-self rows (their conversation_ids
1266
- // have been preserved in the 'self' rows above).
1267
- raw.exec(/*sql*/ `
1268
- DELETE FROM conversation_keys
1269
- WHERE assistant_id != 'self'
1270
- AND EXISTS (
1271
- SELECT 1 FROM conversation_keys ck2
1272
- WHERE ck2.assistant_id = 'self'
1273
- AND ck2.conversation_key = conversation_keys.conversation_key
1274
- )
1275
- `);
1276
- // Step 4: Remaining non-self rows have no 'self' counterpart — safe to bulk-update.
1277
- raw.exec(/*sql*/ `
1278
- UPDATE conversation_keys SET assistant_id = 'self' WHERE assistant_id != 'self'
1279
- `);
1286
+ WHERE assistant_id = 'self'
1287
+ AND EXISTS (
1288
+ SELECT 1 FROM conversation_keys ck_ns
1289
+ WHERE ck_ns.assistant_id != 'self'
1290
+ AND ck_ns.conversation_key = conversation_keys.conversation_key
1291
+ )
1292
+ `);
1293
+ // Step 3: Delete the now-redundant non-self rows (their conversation_ids
1294
+ // have been preserved in the 'self' rows above).
1295
+ raw.exec(/*sql*/ `
1296
+ DELETE FROM conversation_keys
1297
+ WHERE assistant_id != 'self'
1298
+ AND EXISTS (
1299
+ SELECT 1 FROM conversation_keys ck2
1300
+ WHERE ck2.assistant_id = 'self'
1301
+ AND ck2.conversation_key = conversation_keys.conversation_key
1302
+ )
1303
+ `);
1304
+ // Step 4: Remaining non-self rows have no 'self' counterpart — safe to bulk-update.
1305
+ raw.exec(/*sql*/ `
1306
+ UPDATE conversation_keys SET assistant_id = 'self' WHERE assistant_id != 'self'
1307
+ `);
1308
+ }
1280
1309
 
1281
1310
  // attachments: UNIQUE (assistant_id, content_hash) WHERE content_hash IS NOT NULL
1282
1311
  //
1283
1312
  // message_attachments rows reference attachment IDs with ON DELETE CASCADE, so we
1284
1313
  // must remap links to the surviving row BEFORE deleting duplicates to avoid
1285
1314
  // silently dropping attachment metadata from messages.
1286
- //
1287
- // Step 1: Remap message_attachments from non-self duplicates to their survivor
1288
- // (MIN rowid per content_hash group), then delete the duplicates.
1289
- raw.exec(/*sql*/ `
1290
- UPDATE message_attachments
1291
- SET attachment_id = (
1292
- SELECT a_survivor.id
1293
- FROM attachments a_survivor
1294
- WHERE a_survivor.assistant_id != 'self'
1295
- AND a_survivor.content_hash = (
1296
- SELECT a_dup.content_hash FROM attachments a_dup
1297
- WHERE a_dup.id = message_attachments.attachment_id
1298
- )
1299
- ORDER BY a_survivor.rowid
1300
- LIMIT 1
1301
- )
1302
- WHERE attachment_id IN (
1303
- SELECT id FROM attachments
1315
+ if (tableHasAssistantId('attachments')) {
1316
+ // Step 1: Remap message_attachments from non-self duplicates to their survivor
1317
+ // (MIN rowid per content_hash group), then delete the duplicates.
1318
+ raw.exec(/*sql*/ `
1319
+ UPDATE message_attachments
1320
+ SET attachment_id = (
1321
+ SELECT a_survivor.id
1322
+ FROM attachments a_survivor
1323
+ WHERE a_survivor.assistant_id != 'self'
1324
+ AND a_survivor.content_hash = (
1325
+ SELECT a_dup.content_hash FROM attachments a_dup
1326
+ WHERE a_dup.id = message_attachments.attachment_id
1327
+ )
1328
+ ORDER BY a_survivor.rowid
1329
+ LIMIT 1
1330
+ )
1331
+ WHERE attachment_id IN (
1332
+ SELECT id FROM attachments
1333
+ WHERE assistant_id != 'self'
1334
+ AND content_hash IS NOT NULL
1335
+ AND rowid NOT IN (
1336
+ SELECT MIN(rowid) FROM attachments
1337
+ WHERE assistant_id != 'self' AND content_hash IS NOT NULL
1338
+ GROUP BY content_hash
1339
+ )
1340
+ )
1341
+ `);
1342
+ raw.exec(/*sql*/ `
1343
+ DELETE FROM attachments
1304
1344
  WHERE assistant_id != 'self'
1305
1345
  AND content_hash IS NOT NULL
1306
1346
  AND rowid NOT IN (
1307
1347
  SELECT MIN(rowid) FROM attachments
1308
- WHERE assistant_id != 'self' AND content_hash IS NOT NULL
1348
+ WHERE assistant_id != 'self'
1349
+ AND content_hash IS NOT NULL
1309
1350
  GROUP BY content_hash
1310
1351
  )
1311
- )
1312
- `);
1313
- raw.exec(/*sql*/ `
1314
- DELETE FROM attachments
1315
- WHERE assistant_id != 'self'
1316
- AND content_hash IS NOT NULL
1317
- AND rowid NOT IN (
1318
- SELECT MIN(rowid) FROM attachments
1352
+ `);
1353
+ // Step 2: Remap message_attachments from non-self rows conflicting with a 'self'
1354
+ // row to the 'self' row, then delete the now-unlinked non-self rows.
1355
+ raw.exec(/*sql*/ `
1356
+ UPDATE message_attachments
1357
+ SET attachment_id = (
1358
+ SELECT a_self.id
1359
+ FROM attachments a_self
1360
+ WHERE a_self.assistant_id = 'self'
1361
+ AND a_self.content_hash = (
1362
+ SELECT a_ns.content_hash FROM attachments a_ns
1363
+ WHERE a_ns.id = message_attachments.attachment_id
1364
+ )
1365
+ LIMIT 1
1366
+ )
1367
+ WHERE attachment_id IN (
1368
+ SELECT id FROM attachments
1319
1369
  WHERE assistant_id != 'self'
1320
1370
  AND content_hash IS NOT NULL
1321
- GROUP BY content_hash
1371
+ AND EXISTS (
1372
+ SELECT 1 FROM attachments a2
1373
+ WHERE a2.assistant_id = 'self'
1374
+ AND a2.content_hash = attachments.content_hash
1375
+ )
1322
1376
  )
1323
- `);
1324
- // Step 2: Remap message_attachments from non-self rows conflicting with a 'self'
1325
- // row to the 'self' row, then delete the now-unlinked non-self rows.
1326
- raw.exec(/*sql*/ `
1327
- UPDATE message_attachments
1328
- SET attachment_id = (
1329
- SELECT a_self.id
1330
- FROM attachments a_self
1331
- WHERE a_self.assistant_id = 'self'
1332
- AND a_self.content_hash = (
1333
- SELECT a_ns.content_hash FROM attachments a_ns
1334
- WHERE a_ns.id = message_attachments.attachment_id
1335
- )
1336
- LIMIT 1
1337
- )
1338
- WHERE attachment_id IN (
1339
- SELECT id FROM attachments
1377
+ `);
1378
+ raw.exec(/*sql*/ `
1379
+ DELETE FROM attachments
1340
1380
  WHERE assistant_id != 'self'
1341
1381
  AND content_hash IS NOT NULL
1342
1382
  AND EXISTS (
@@ -1344,55 +1384,336 @@ function migrateAssistantIdToSelf(database: ReturnType<typeof drizzle<typeof sch
1344
1384
  WHERE a2.assistant_id = 'self'
1345
1385
  AND a2.content_hash = attachments.content_hash
1346
1386
  )
1347
- )
1348
- `);
1349
- raw.exec(/*sql*/ `
1350
- DELETE FROM attachments
1351
- WHERE assistant_id != 'self'
1352
- AND content_hash IS NOT NULL
1353
- AND EXISTS (
1354
- SELECT 1 FROM attachments a2
1355
- WHERE a2.assistant_id = 'self'
1356
- AND a2.content_hash = attachments.content_hash
1357
- )
1358
- `);
1359
- // Step 3: Bulk-update remaining non-self rows.
1360
- raw.exec(/*sql*/ `
1361
- UPDATE attachments SET assistant_id = 'self' WHERE assistant_id != 'self'
1362
- `);
1387
+ `);
1388
+ // Step 3: Bulk-update remaining non-self rows.
1389
+ raw.exec(/*sql*/ `
1390
+ UPDATE attachments SET assistant_id = 'self' WHERE assistant_id != 'self'
1391
+ `);
1392
+ }
1363
1393
 
1364
1394
  // channel_inbound_events: UNIQUE (assistant_id, source_channel, external_chat_id, external_message_id)
1365
- // Step 1: Dedup non-self rows sharing the same (source_channel, external_chat_id, external_message_id).
1366
- raw.exec(/*sql*/ `
1367
- DELETE FROM channel_inbound_events
1368
- WHERE assistant_id != 'self'
1369
- AND rowid NOT IN (
1370
- SELECT MIN(rowid) FROM channel_inbound_events
1371
- WHERE assistant_id != 'self'
1372
- GROUP BY source_channel, external_chat_id, external_message_id
1395
+ if (tableHasAssistantId('channel_inbound_events')) {
1396
+ // Step 1: Dedup non-self rows sharing the same (source_channel, external_chat_id, external_message_id).
1397
+ raw.exec(/*sql*/ `
1398
+ DELETE FROM channel_inbound_events
1399
+ WHERE assistant_id != 'self'
1400
+ AND rowid NOT IN (
1401
+ SELECT MIN(rowid) FROM channel_inbound_events
1402
+ WHERE assistant_id != 'self'
1403
+ GROUP BY source_channel, external_chat_id, external_message_id
1404
+ )
1405
+ `);
1406
+ // Step 2: Delete non-self rows conflicting with existing 'self' rows.
1407
+ raw.exec(/*sql*/ `
1408
+ DELETE FROM channel_inbound_events
1409
+ WHERE assistant_id != 'self'
1410
+ AND EXISTS (
1411
+ SELECT 1 FROM channel_inbound_events e2
1412
+ WHERE e2.assistant_id = 'self'
1413
+ AND e2.source_channel = channel_inbound_events.source_channel
1414
+ AND e2.external_chat_id = channel_inbound_events.external_chat_id
1415
+ AND e2.external_message_id = channel_inbound_events.external_message_id
1416
+ )
1417
+ `);
1418
+ // Step 3: Bulk-update remaining non-self rows.
1419
+ raw.exec(/*sql*/ `
1420
+ UPDATE channel_inbound_events SET assistant_id = 'self' WHERE assistant_id != 'self'
1421
+ `);
1422
+ }
1423
+
1424
+ // message_runs: no unique constraint on assistant_id — simple bulk update
1425
+ if (tableHasAssistantId('message_runs')) {
1426
+ raw.exec(/*sql*/ `
1427
+ UPDATE message_runs SET assistant_id = 'self' WHERE assistant_id != 'self'
1428
+ `);
1429
+ }
1430
+
1431
+ raw.query(
1432
+ `INSERT OR IGNORE INTO memory_checkpoints (key, value, updated_at) VALUES (?, '1', ?)`,
1433
+ ).run(checkpointKey, Date.now());
1434
+
1435
+ raw.exec('COMMIT');
1436
+ } catch (e) {
1437
+ try { raw.exec('ROLLBACK'); } catch { /* no active transaction */ }
1438
+ throw e;
1439
+ }
1440
+ }
1441
+
1442
+ /**
1443
+ * One-shot migration: rebuild tables that previously stored assistant_id to remove
1444
+ * that column now that all rows are keyed to the implicit single-tenant identity ("self").
1445
+ *
1446
+ * Must run AFTER migrateAssistantIdToSelf (which normalises all values to "self")
1447
+ * so there are no constraint violations when recreating the tables without the
1448
+ * assistant_id dimension.
1449
+ *
1450
+ * Each table section is guarded by a DDL check so this is safe on fresh installs
1451
+ * where the column was never created in the first place.
1452
+ *
1453
+ * Tables rebuilt:
1454
+ * - conversation_keys UNIQUE (conversation_key)
1455
+ * - attachments no structural unique; content-dedup index updated
1456
+ * - channel_inbound_events UNIQUE (source_channel, external_chat_id, external_message_id)
1457
+ * - message_runs no unique constraint on assistant_id
1458
+ * - llm_usage_events nullable column with no constraint
1459
+ */
1460
+ function migrateRemoveAssistantIdColumns(database: ReturnType<typeof drizzle<typeof schema>>): void {
1461
+ const raw = (database as unknown as { $client: Database }).$client;
1462
+ const checkpointKey = 'migration_remove_assistant_id_columns_v1';
1463
+ const checkpoint = raw.query(
1464
+ `SELECT 1 FROM memory_checkpoints WHERE key = ?`,
1465
+ ).get(checkpointKey);
1466
+ if (checkpoint) return;
1467
+
1468
+ raw.exec('PRAGMA foreign_keys = OFF');
1469
+ try {
1470
+ raw.exec('BEGIN');
1471
+
1472
+ // --- conversation_keys ---
1473
+ const ckDdl = raw.query(
1474
+ `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'conversation_keys'`,
1475
+ ).get() as { sql: string } | null;
1476
+ if (ckDdl?.sql.includes('assistant_id')) {
1477
+ raw.exec(/*sql*/ `
1478
+ CREATE TABLE conversation_keys_new (
1479
+ id TEXT PRIMARY KEY,
1480
+ conversation_key TEXT NOT NULL UNIQUE,
1481
+ conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
1482
+ created_at INTEGER NOT NULL
1373
1483
  )
1374
- `);
1375
- // Step 2: Delete non-self rows conflicting with existing 'self' rows.
1376
- raw.exec(/*sql*/ `
1377
- DELETE FROM channel_inbound_events
1378
- WHERE assistant_id != 'self'
1379
- AND EXISTS (
1380
- SELECT 1 FROM channel_inbound_events e2
1381
- WHERE e2.assistant_id = 'self'
1382
- AND e2.source_channel = channel_inbound_events.source_channel
1383
- AND e2.external_chat_id = channel_inbound_events.external_chat_id
1384
- AND e2.external_message_id = channel_inbound_events.external_message_id
1484
+ `);
1485
+ raw.exec(/*sql*/ `
1486
+ INSERT INTO conversation_keys_new (id, conversation_key, conversation_id, created_at)
1487
+ SELECT id, conversation_key, conversation_id, created_at FROM conversation_keys
1488
+ `);
1489
+ raw.exec(/*sql*/ `DROP TABLE conversation_keys`);
1490
+ raw.exec(/*sql*/ `ALTER TABLE conversation_keys_new RENAME TO conversation_keys`);
1491
+ }
1492
+
1493
+ // --- attachments ---
1494
+ const attDdl = raw.query(
1495
+ `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'attachments'`,
1496
+ ).get() as { sql: string } | null;
1497
+ if (attDdl?.sql.includes('assistant_id')) {
1498
+ raw.exec(/*sql*/ `
1499
+ CREATE TABLE attachments_new (
1500
+ id TEXT PRIMARY KEY,
1501
+ original_filename TEXT NOT NULL,
1502
+ mime_type TEXT NOT NULL,
1503
+ size_bytes INTEGER NOT NULL,
1504
+ kind TEXT NOT NULL,
1505
+ data_base64 TEXT NOT NULL,
1506
+ content_hash TEXT,
1507
+ thumbnail_base64 TEXT,
1508
+ created_at INTEGER NOT NULL
1385
1509
  )
1386
- `);
1387
- // Step 3: Bulk-update remaining non-self rows.
1510
+ `);
1511
+ raw.exec(/*sql*/ `
1512
+ INSERT INTO attachments_new (id, original_filename, mime_type, size_bytes, kind, data_base64, content_hash, thumbnail_base64, created_at)
1513
+ SELECT id, original_filename, mime_type, size_bytes, kind, data_base64, content_hash, thumbnail_base64, created_at FROM attachments
1514
+ `);
1515
+ raw.exec(/*sql*/ `DROP TABLE attachments`);
1516
+ raw.exec(/*sql*/ `ALTER TABLE attachments_new RENAME TO attachments`);
1517
+ }
1518
+
1519
+ // --- channel_inbound_events ---
1520
+ const cieDdl = raw.query(
1521
+ `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'channel_inbound_events'`,
1522
+ ).get() as { sql: string } | null;
1523
+ if (cieDdl?.sql.includes('assistant_id')) {
1524
+ raw.exec(/*sql*/ `
1525
+ CREATE TABLE channel_inbound_events_new (
1526
+ id TEXT PRIMARY KEY,
1527
+ source_channel TEXT NOT NULL,
1528
+ external_chat_id TEXT NOT NULL,
1529
+ external_message_id TEXT NOT NULL,
1530
+ source_message_id TEXT,
1531
+ conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
1532
+ message_id TEXT REFERENCES messages(id) ON DELETE CASCADE,
1533
+ delivery_status TEXT NOT NULL DEFAULT 'pending',
1534
+ processing_status TEXT NOT NULL DEFAULT 'pending',
1535
+ processing_attempts INTEGER NOT NULL DEFAULT 0,
1536
+ last_processing_error TEXT,
1537
+ retry_after INTEGER,
1538
+ raw_payload TEXT,
1539
+ created_at INTEGER NOT NULL,
1540
+ updated_at INTEGER NOT NULL,
1541
+ UNIQUE (source_channel, external_chat_id, external_message_id)
1542
+ )
1543
+ `);
1544
+ raw.exec(/*sql*/ `
1545
+ INSERT INTO channel_inbound_events_new (
1546
+ id, source_channel, external_chat_id, external_message_id, source_message_id,
1547
+ conversation_id, message_id, delivery_status, processing_status,
1548
+ processing_attempts, last_processing_error, retry_after, raw_payload,
1549
+ created_at, updated_at
1550
+ )
1551
+ SELECT
1552
+ id, source_channel, external_chat_id, external_message_id, source_message_id,
1553
+ conversation_id, message_id, delivery_status, processing_status,
1554
+ processing_attempts, last_processing_error, retry_after, raw_payload,
1555
+ created_at, updated_at
1556
+ FROM channel_inbound_events
1557
+ `);
1558
+ raw.exec(/*sql*/ `DROP TABLE channel_inbound_events`);
1559
+ raw.exec(/*sql*/ `ALTER TABLE channel_inbound_events_new RENAME TO channel_inbound_events`);
1560
+ }
1561
+
1562
+ // --- message_runs ---
1563
+ const mrDdl = raw.query(
1564
+ `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'message_runs'`,
1565
+ ).get() as { sql: string } | null;
1566
+ if (mrDdl?.sql.includes('assistant_id')) {
1567
+ raw.exec(/*sql*/ `
1568
+ CREATE TABLE message_runs_new (
1569
+ id TEXT PRIMARY KEY,
1570
+ conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
1571
+ message_id TEXT REFERENCES messages(id) ON DELETE CASCADE,
1572
+ status TEXT NOT NULL DEFAULT 'running',
1573
+ pending_confirmation TEXT,
1574
+ input_tokens INTEGER NOT NULL DEFAULT 0,
1575
+ output_tokens INTEGER NOT NULL DEFAULT 0,
1576
+ estimated_cost REAL NOT NULL DEFAULT 0,
1577
+ error TEXT,
1578
+ created_at INTEGER NOT NULL,
1579
+ updated_at INTEGER NOT NULL
1580
+ )
1581
+ `);
1582
+ raw.exec(/*sql*/ `
1583
+ INSERT INTO message_runs_new (
1584
+ id, conversation_id, message_id, status, pending_confirmation,
1585
+ input_tokens, output_tokens, estimated_cost, error, created_at, updated_at
1586
+ )
1587
+ SELECT
1588
+ id, conversation_id, message_id, status, pending_confirmation,
1589
+ input_tokens, output_tokens, estimated_cost, error, created_at, updated_at
1590
+ FROM message_runs
1591
+ `);
1592
+ raw.exec(/*sql*/ `DROP TABLE message_runs`);
1593
+ raw.exec(/*sql*/ `ALTER TABLE message_runs_new RENAME TO message_runs`);
1594
+ }
1595
+
1596
+ // --- llm_usage_events ---
1597
+ const lueDdl = raw.query(
1598
+ `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'llm_usage_events'`,
1599
+ ).get() as { sql: string } | null;
1600
+ if (lueDdl?.sql.includes('assistant_id')) {
1601
+ raw.exec(/*sql*/ `
1602
+ CREATE TABLE llm_usage_events_new (
1603
+ id TEXT PRIMARY KEY,
1604
+ created_at INTEGER NOT NULL,
1605
+ conversation_id TEXT,
1606
+ run_id TEXT,
1607
+ request_id TEXT,
1608
+ actor TEXT NOT NULL,
1609
+ provider TEXT NOT NULL,
1610
+ model TEXT NOT NULL,
1611
+ input_tokens INTEGER NOT NULL,
1612
+ output_tokens INTEGER NOT NULL,
1613
+ cache_creation_input_tokens INTEGER,
1614
+ cache_read_input_tokens INTEGER,
1615
+ estimated_cost_usd REAL,
1616
+ pricing_status TEXT NOT NULL,
1617
+ metadata_json TEXT
1618
+ )
1619
+ `);
1620
+ raw.exec(/*sql*/ `
1621
+ INSERT INTO llm_usage_events_new (
1622
+ id, created_at, conversation_id, run_id, request_id, actor, provider, model,
1623
+ input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens,
1624
+ estimated_cost_usd, pricing_status, metadata_json
1625
+ )
1626
+ SELECT
1627
+ id, created_at, conversation_id, run_id, request_id, actor, provider, model,
1628
+ input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens,
1629
+ estimated_cost_usd, pricing_status, metadata_json
1630
+ FROM llm_usage_events
1631
+ `);
1632
+ raw.exec(/*sql*/ `DROP TABLE llm_usage_events`);
1633
+ raw.exec(/*sql*/ `ALTER TABLE llm_usage_events_new RENAME TO llm_usage_events`);
1634
+ }
1635
+
1636
+ raw.query(
1637
+ `INSERT OR IGNORE INTO memory_checkpoints (key, value, updated_at) VALUES (?, '1', ?)`,
1638
+ ).run(checkpointKey, Date.now());
1639
+
1640
+ raw.exec('COMMIT');
1641
+ } catch (e) {
1642
+ try { raw.exec('ROLLBACK'); } catch { /* no active transaction */ }
1643
+ throw e;
1644
+ } finally {
1645
+ raw.exec('PRAGMA foreign_keys = ON');
1646
+ }
1647
+ }
1648
+
1649
+ /**
1650
+ * One-shot migration: rebuild llm_usage_events to drop the assistant_id column.
1651
+ *
1652
+ * This is a SEPARATE migration from migrateRemoveAssistantIdColumns so that installs
1653
+ * where the 4-table version of that migration already ran (checkpoint already set)
1654
+ * still get the llm_usage_events column removed. Without a separate checkpoint key,
1655
+ * those installs would skip the llm_usage_events rebuild entirely.
1656
+ *
1657
+ * Safe on fresh installs (DDL guard exits early) and idempotent via checkpoint.
1658
+ */
1659
+ function migrateLlmUsageEventsDropAssistantId(database: ReturnType<typeof drizzle<typeof schema>>): void {
1660
+ const raw = (database as unknown as { $client: Database }).$client;
1661
+ const checkpointKey = 'migration_remove_assistant_id_lue_v1';
1662
+ const checkpoint = raw.query(
1663
+ `SELECT 1 FROM memory_checkpoints WHERE key = ?`,
1664
+ ).get(checkpointKey);
1665
+ if (checkpoint) return;
1666
+
1667
+ // DDL guard: if the column was already removed (fresh install or migrateRemoveAssistantIdColumns
1668
+ // ran with the llm_usage_events block), just record the checkpoint and exit.
1669
+ const lueDdl = raw.query(
1670
+ `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'llm_usage_events'`,
1671
+ ).get() as { sql: string } | null;
1672
+
1673
+ if (!lueDdl?.sql.includes('assistant_id')) {
1674
+ raw.query(
1675
+ `INSERT OR IGNORE INTO memory_checkpoints (key, value, updated_at) VALUES (?, '1', ?)`,
1676
+ ).run(checkpointKey, Date.now());
1677
+ return;
1678
+ }
1679
+
1680
+ raw.exec('PRAGMA foreign_keys = OFF');
1681
+ try {
1682
+ raw.exec('BEGIN');
1683
+
1388
1684
  raw.exec(/*sql*/ `
1389
- UPDATE channel_inbound_events SET assistant_id = 'self' WHERE assistant_id != 'self'
1685
+ CREATE TABLE llm_usage_events_new (
1686
+ id TEXT PRIMARY KEY,
1687
+ created_at INTEGER NOT NULL,
1688
+ conversation_id TEXT,
1689
+ run_id TEXT,
1690
+ request_id TEXT,
1691
+ actor TEXT NOT NULL,
1692
+ provider TEXT NOT NULL,
1693
+ model TEXT NOT NULL,
1694
+ input_tokens INTEGER NOT NULL,
1695
+ output_tokens INTEGER NOT NULL,
1696
+ cache_creation_input_tokens INTEGER,
1697
+ cache_read_input_tokens INTEGER,
1698
+ estimated_cost_usd REAL,
1699
+ pricing_status TEXT NOT NULL,
1700
+ metadata_json TEXT
1701
+ )
1390
1702
  `);
1391
-
1392
- // message_runs: no unique constraint on assistant_id — simple bulk update
1393
1703
  raw.exec(/*sql*/ `
1394
- UPDATE message_runs SET assistant_id = 'self' WHERE assistant_id != 'self'
1704
+ INSERT INTO llm_usage_events_new (
1705
+ id, created_at, conversation_id, run_id, request_id, actor, provider, model,
1706
+ input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens,
1707
+ estimated_cost_usd, pricing_status, metadata_json
1708
+ )
1709
+ SELECT
1710
+ id, created_at, conversation_id, run_id, request_id, actor, provider, model,
1711
+ input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens,
1712
+ estimated_cost_usd, pricing_status, metadata_json
1713
+ FROM llm_usage_events
1395
1714
  `);
1715
+ raw.exec(/*sql*/ `DROP TABLE llm_usage_events`);
1716
+ raw.exec(/*sql*/ `ALTER TABLE llm_usage_events_new RENAME TO llm_usage_events`);
1396
1717
 
1397
1718
  raw.query(
1398
1719
  `INSERT OR IGNORE INTO memory_checkpoints (key, value, updated_at) VALUES (?, '1', ?)`,
@@ -1402,6 +1723,8 @@ function migrateAssistantIdToSelf(database: ReturnType<typeof drizzle<typeof sch
1402
1723
  } catch (e) {
1403
1724
  try { raw.exec('ROLLBACK'); } catch { /* no active transaction */ }
1404
1725
  throw e;
1726
+ } finally {
1727
+ raw.exec('PRAGMA foreign_keys = ON');
1405
1728
  }
1406
1729
  }
1407
1730
 
@@ -1463,3 +1786,4 @@ function migrateCallSessionsProviderSidDedup(database: ReturnType<typeof drizzle
1463
1786
  throw e;
1464
1787
  }
1465
1788
  }
1789
+