vellum 0.2.2 → 0.2.8

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 (60) hide show
  1. package/bun.lock +68 -100
  2. package/package.json +3 -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 +6 -0
  6. package/src/__tests__/gateway-only-enforcement.test.ts +458 -0
  7. package/src/__tests__/handlers-twilio-config.test.ts +221 -0
  8. package/src/__tests__/ipc-snapshot.test.ts +20 -0
  9. package/src/__tests__/memory-regressions.test.ts +100 -2
  10. package/src/__tests__/oauth-callback-registry.test.ts +85 -0
  11. package/src/__tests__/oauth2-gateway-transport.test.ts +298 -0
  12. package/src/__tests__/provider-commit-message-generator.test.ts +342 -0
  13. package/src/__tests__/public-ingress-urls.test.ts +206 -0
  14. package/src/__tests__/session-conflict-gate.test.ts +28 -25
  15. package/src/__tests__/tool-executor.test.ts +88 -0
  16. package/src/__tests__/turn-commit.test.ts +64 -0
  17. package/src/calls/__tests__/twilio-webhook-urls.test.ts +162 -0
  18. package/src/calls/call-domain.ts +3 -3
  19. package/src/calls/twilio-config.ts +25 -9
  20. package/src/calls/twilio-provider.ts +4 -4
  21. package/src/calls/twilio-routes.ts +10 -2
  22. package/src/calls/twilio-webhook-urls.ts +47 -0
  23. package/src/cli/map.ts +30 -6
  24. package/src/config/defaults.ts +5 -0
  25. package/src/config/schema.ts +34 -2
  26. package/src/config/system-prompt.ts +1 -1
  27. package/src/config/types.ts +1 -0
  28. package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -5
  29. package/src/daemon/computer-use-session.ts +2 -1
  30. package/src/daemon/handlers/config.ts +95 -4
  31. package/src/daemon/handlers/sessions.ts +2 -2
  32. package/src/daemon/handlers/work-items.ts +1 -1
  33. package/src/daemon/ipc-contract-inventory.json +8 -0
  34. package/src/daemon/ipc-contract.ts +39 -1
  35. package/src/daemon/ride-shotgun-handler.ts +2 -1
  36. package/src/daemon/session-agent-loop.ts +37 -2
  37. package/src/daemon/session-conflict-gate.ts +18 -109
  38. package/src/daemon/session-tool-setup.ts +7 -0
  39. package/src/inbound/public-ingress-urls.ts +106 -0
  40. package/src/memory/attachments-store.ts +0 -1
  41. package/src/memory/channel-delivery-store.ts +0 -1
  42. package/src/memory/conflict-intent.ts +114 -0
  43. package/src/memory/conversation-key-store.ts +0 -1
  44. package/src/memory/db.ts +346 -149
  45. package/src/memory/job-handlers/conflict.ts +23 -1
  46. package/src/memory/runs-store.ts +0 -3
  47. package/src/memory/schema.ts +0 -4
  48. package/src/runtime/gateway-client.ts +36 -0
  49. package/src/runtime/http-server.ts +140 -2
  50. package/src/runtime/routes/channel-routes.ts +121 -79
  51. package/src/security/oauth-callback-registry.ts +56 -0
  52. package/src/security/oauth2.ts +174 -58
  53. package/src/swarm/backend-claude-code.ts +1 -1
  54. package/src/tools/assets/search.ts +1 -36
  55. package/src/tools/browser/api-map.ts +123 -50
  56. package/src/tools/claude-code/claude-code.ts +131 -1
  57. package/src/tools/tasks/work-item-list.ts +16 -2
  58. package/src/workspace/commit-message-enrichment-service.ts +3 -3
  59. package/src/workspace/provider-commit-message-generator.ts +57 -14
  60. package/src/workspace/turn-commit.ts +6 -2
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,14 +230,13 @@ 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',
@@ -507,11 +504,9 @@ export function initializeDb(): void {
507
504
  database.run(/*sql*/ `
508
505
  CREATE TABLE IF NOT EXISTS conversation_keys (
509
506
  id TEXT PRIMARY KEY,
510
- assistant_id TEXT NOT NULL,
511
- conversation_key TEXT NOT NULL,
507
+ conversation_key TEXT NOT NULL UNIQUE,
512
508
  conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
513
- created_at INTEGER NOT NULL,
514
- UNIQUE (assistant_id, conversation_key)
509
+ created_at INTEGER NOT NULL
515
510
  )
516
511
  `);
517
512
 
@@ -552,6 +547,7 @@ export function initializeDb(): void {
552
547
  migrateMemoryItemsFingerprintScopeUnique(database);
553
548
  migrateMemoryItemsScopeSaltedFingerprints(database);
554
549
  migrateAssistantIdToSelf(database);
550
+ migrateRemoveAssistantIdColumns(database);
555
551
 
556
552
  // Indexes for query performance on large datasets
557
553
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_llm_request_logs_conv_created ON llm_request_logs(conversation_id, created_at)`);
@@ -592,17 +588,16 @@ export function initializeDb(): void {
592
588
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_memory_segments_scope_id ON memory_segments(scope_id)`);
593
589
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_memory_items_scope_id ON memory_items(scope_id)`);
594
590
  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`);
591
+ database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_conversation_keys_key ON conversation_keys(conversation_key)`);
592
+ database.run(/*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_attachments_content_dedup ON attachments(content_hash) WHERE content_hash IS NOT NULL`);
598
593
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_message_attachments_message_id ON message_attachments(message_id)`);
599
594
  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)`);
595
+ 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
596
  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)`);
597
+ 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
598
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_channel_inbound_events_processing_retry ON channel_inbound_events(processing_status, retry_after)`);
604
599
 
605
- database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_message_runs_assistant_status ON message_runs(assistant_id, status)`);
600
+ database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_message_runs_status ON message_runs(status)`);
606
601
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_message_runs_conversation ON message_runs(conversation_id)`);
607
602
 
608
603
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_reminders_status_fire_at ON reminders(status, fire_at)`);
@@ -1224,119 +1219,162 @@ function migrateAssistantIdToSelf(database: ReturnType<typeof drizzle<typeof sch
1224
1219
  ).get(checkpointKey);
1225
1220
  if (checkpoint) return;
1226
1221
 
1222
+ // On fresh installs the tables are created without assistant_id (PR 7+). Skip the
1223
+ // migration if NONE of the four affected tables have the column — pre-seed the
1224
+ // checkpoint so subsequent startups are also skipped. Checking all four (not just
1225
+ // conversation_keys) avoids a false negative on very old installs where
1226
+ // conversation_keys may not exist yet but other tables still carry assistant_id data.
1227
+ const affectedTables = ['conversation_keys', 'attachments', 'channel_inbound_events', 'message_runs'];
1228
+ const anyHasAssistantId = affectedTables.some((tbl) => {
1229
+ const ddl = raw.query(
1230
+ `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?`,
1231
+ ).get(tbl) as { sql: string } | null;
1232
+ return ddl?.sql.includes('assistant_id') ?? false;
1233
+ });
1234
+ if (!anyHasAssistantId) {
1235
+ raw.query(
1236
+ `INSERT OR IGNORE INTO memory_checkpoints (key, value, updated_at) VALUES (?, '1', ?)`,
1237
+ ).run(checkpointKey, Date.now());
1238
+ return;
1239
+ }
1240
+
1241
+ // Helper: returns true if the given table's current DDL contains 'assistant_id'.
1242
+ const tableHasAssistantId = (tbl: string): boolean => {
1243
+ const ddl = raw.query(
1244
+ `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?`,
1245
+ ).get(tbl) as { sql: string } | null;
1246
+ return ddl?.sql.includes('assistant_id') ?? false;
1247
+ };
1248
+
1227
1249
  try {
1228
1250
  raw.exec('BEGIN');
1229
1251
 
1252
+ // Each section is guarded so that SQL referencing assistant_id is only executed
1253
+ // when the column still exists in that table. This handles mixed-schema states
1254
+ // (e.g., very old installs where some tables may already lack the column).
1255
+
1230
1256
  // 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
1257
+ if (tableHasAssistantId('conversation_keys')) {
1258
+ // Step 1: Among non-self rows, keep only one per conversation_key so the
1259
+ // bulk UPDATE cannot hit a (non-self-A, key) + (non-self-B, key) collision.
1260
+ raw.exec(/*sql*/ `
1261
+ DELETE FROM conversation_keys
1262
+ WHERE assistant_id != 'self'
1263
+ AND rowid NOT IN (
1264
+ SELECT MIN(rowid) FROM conversation_keys
1265
+ WHERE assistant_id != 'self'
1266
+ GROUP BY conversation_key
1267
+ )
1268
+ `);
1269
+ // Step 2: For 'self' rows that have a non-self counterpart with the same
1270
+ // conversation_key, update the 'self' row to use the non-self row's
1271
+ // conversation_id. This preserves the historical conversation (which
1272
+ // has the message history from before the route change) rather than
1273
+ // discarding it in favour of a potentially-empty 'self' conversation.
1274
+ raw.exec(/*sql*/ `
1275
+ UPDATE conversation_keys
1276
+ SET conversation_id = (
1277
+ SELECT ck_ns.conversation_id
1278
+ FROM conversation_keys ck_ns
1261
1279
  WHERE ck_ns.assistant_id != 'self'
1262
1280
  AND ck_ns.conversation_key = conversation_keys.conversation_key
1281
+ ORDER BY ck_ns.rowid
1282
+ LIMIT 1
1263
1283
  )
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
- `);
1284
+ WHERE assistant_id = 'self'
1285
+ AND EXISTS (
1286
+ SELECT 1 FROM conversation_keys ck_ns
1287
+ WHERE ck_ns.assistant_id != 'self'
1288
+ AND ck_ns.conversation_key = conversation_keys.conversation_key
1289
+ )
1290
+ `);
1291
+ // Step 3: Delete the now-redundant non-self rows (their conversation_ids
1292
+ // have been preserved in the 'self' rows above).
1293
+ raw.exec(/*sql*/ `
1294
+ DELETE FROM conversation_keys
1295
+ WHERE assistant_id != 'self'
1296
+ AND EXISTS (
1297
+ SELECT 1 FROM conversation_keys ck2
1298
+ WHERE ck2.assistant_id = 'self'
1299
+ AND ck2.conversation_key = conversation_keys.conversation_key
1300
+ )
1301
+ `);
1302
+ // Step 4: Remaining non-self rows have no 'self' counterpart — safe to bulk-update.
1303
+ raw.exec(/*sql*/ `
1304
+ UPDATE conversation_keys SET assistant_id = 'self' WHERE assistant_id != 'self'
1305
+ `);
1306
+ }
1280
1307
 
1281
1308
  // attachments: UNIQUE (assistant_id, content_hash) WHERE content_hash IS NOT NULL
1282
1309
  //
1283
1310
  // message_attachments rows reference attachment IDs with ON DELETE CASCADE, so we
1284
1311
  // must remap links to the surviving row BEFORE deleting duplicates to avoid
1285
1312
  // 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
1313
+ if (tableHasAssistantId('attachments')) {
1314
+ // Step 1: Remap message_attachments from non-self duplicates to their survivor
1315
+ // (MIN rowid per content_hash group), then delete the duplicates.
1316
+ raw.exec(/*sql*/ `
1317
+ UPDATE message_attachments
1318
+ SET attachment_id = (
1319
+ SELECT a_survivor.id
1320
+ FROM attachments a_survivor
1321
+ WHERE a_survivor.assistant_id != 'self'
1322
+ AND a_survivor.content_hash = (
1323
+ SELECT a_dup.content_hash FROM attachments a_dup
1324
+ WHERE a_dup.id = message_attachments.attachment_id
1325
+ )
1326
+ ORDER BY a_survivor.rowid
1327
+ LIMIT 1
1328
+ )
1329
+ WHERE attachment_id IN (
1330
+ SELECT id FROM attachments
1331
+ WHERE assistant_id != 'self'
1332
+ AND content_hash IS NOT NULL
1333
+ AND rowid NOT IN (
1334
+ SELECT MIN(rowid) FROM attachments
1335
+ WHERE assistant_id != 'self' AND content_hash IS NOT NULL
1336
+ GROUP BY content_hash
1337
+ )
1338
+ )
1339
+ `);
1340
+ raw.exec(/*sql*/ `
1341
+ DELETE FROM attachments
1304
1342
  WHERE assistant_id != 'self'
1305
1343
  AND content_hash IS NOT NULL
1306
1344
  AND rowid NOT IN (
1307
1345
  SELECT MIN(rowid) FROM attachments
1308
- WHERE assistant_id != 'self' AND content_hash IS NOT NULL
1346
+ WHERE assistant_id != 'self'
1347
+ AND content_hash IS NOT NULL
1309
1348
  GROUP BY content_hash
1310
1349
  )
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
1350
+ `);
1351
+ // Step 2: Remap message_attachments from non-self rows conflicting with a 'self'
1352
+ // row to the 'self' row, then delete the now-unlinked non-self rows.
1353
+ raw.exec(/*sql*/ `
1354
+ UPDATE message_attachments
1355
+ SET attachment_id = (
1356
+ SELECT a_self.id
1357
+ FROM attachments a_self
1358
+ WHERE a_self.assistant_id = 'self'
1359
+ AND a_self.content_hash = (
1360
+ SELECT a_ns.content_hash FROM attachments a_ns
1361
+ WHERE a_ns.id = message_attachments.attachment_id
1362
+ )
1363
+ LIMIT 1
1364
+ )
1365
+ WHERE attachment_id IN (
1366
+ SELECT id FROM attachments
1319
1367
  WHERE assistant_id != 'self'
1320
1368
  AND content_hash IS NOT NULL
1321
- GROUP BY content_hash
1369
+ AND EXISTS (
1370
+ SELECT 1 FROM attachments a2
1371
+ WHERE a2.assistant_id = 'self'
1372
+ AND a2.content_hash = attachments.content_hash
1373
+ )
1322
1374
  )
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
1375
+ `);
1376
+ raw.exec(/*sql*/ `
1377
+ DELETE FROM attachments
1340
1378
  WHERE assistant_id != 'self'
1341
1379
  AND content_hash IS NOT NULL
1342
1380
  AND EXISTS (
@@ -1344,55 +1382,211 @@ function migrateAssistantIdToSelf(database: ReturnType<typeof drizzle<typeof sch
1344
1382
  WHERE a2.assistant_id = 'self'
1345
1383
  AND a2.content_hash = attachments.content_hash
1346
1384
  )
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
- `);
1385
+ `);
1386
+ // Step 3: Bulk-update remaining non-self rows.
1387
+ raw.exec(/*sql*/ `
1388
+ UPDATE attachments SET assistant_id = 'self' WHERE assistant_id != 'self'
1389
+ `);
1390
+ }
1363
1391
 
1364
1392
  // 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
1393
+ if (tableHasAssistantId('channel_inbound_events')) {
1394
+ // Step 1: Dedup non-self rows sharing the same (source_channel, external_chat_id, external_message_id).
1395
+ raw.exec(/*sql*/ `
1396
+ DELETE FROM channel_inbound_events
1397
+ WHERE assistant_id != 'self'
1398
+ AND rowid NOT IN (
1399
+ SELECT MIN(rowid) FROM channel_inbound_events
1400
+ WHERE assistant_id != 'self'
1401
+ GROUP BY source_channel, external_chat_id, external_message_id
1402
+ )
1403
+ `);
1404
+ // Step 2: Delete non-self rows conflicting with existing 'self' rows.
1405
+ raw.exec(/*sql*/ `
1406
+ DELETE FROM channel_inbound_events
1407
+ WHERE assistant_id != 'self'
1408
+ AND EXISTS (
1409
+ SELECT 1 FROM channel_inbound_events e2
1410
+ WHERE e2.assistant_id = 'self'
1411
+ AND e2.source_channel = channel_inbound_events.source_channel
1412
+ AND e2.external_chat_id = channel_inbound_events.external_chat_id
1413
+ AND e2.external_message_id = channel_inbound_events.external_message_id
1414
+ )
1415
+ `);
1416
+ // Step 3: Bulk-update remaining non-self rows.
1417
+ raw.exec(/*sql*/ `
1418
+ UPDATE channel_inbound_events SET assistant_id = 'self' WHERE assistant_id != 'self'
1419
+ `);
1420
+ }
1421
+
1422
+ // message_runs: no unique constraint on assistant_id — simple bulk update
1423
+ if (tableHasAssistantId('message_runs')) {
1424
+ raw.exec(/*sql*/ `
1425
+ UPDATE message_runs SET assistant_id = 'self' WHERE assistant_id != 'self'
1426
+ `);
1427
+ }
1428
+
1429
+ raw.query(
1430
+ `INSERT OR IGNORE INTO memory_checkpoints (key, value, updated_at) VALUES (?, '1', ?)`,
1431
+ ).run(checkpointKey, Date.now());
1432
+
1433
+ raw.exec('COMMIT');
1434
+ } catch (e) {
1435
+ try { raw.exec('ROLLBACK'); } catch { /* no active transaction */ }
1436
+ throw e;
1437
+ }
1438
+ }
1439
+
1440
+ /**
1441
+ * One-shot migration: rebuild the four tables that previously stored assistant_id
1442
+ * to remove that column now that all rows are keyed to the implicit single-tenant
1443
+ * identity ("self").
1444
+ *
1445
+ * Must run AFTER migrateAssistantIdToSelf (which normalises all values to "self")
1446
+ * so there are no constraint violations when recreating the tables without the
1447
+ * assistant_id dimension.
1448
+ *
1449
+ * Tables rebuilt:
1450
+ * - conversation_keys UNIQUE (conversation_key)
1451
+ * - attachments no structural unique; content-dedup index updated
1452
+ * - channel_inbound_events UNIQUE (source_channel, external_chat_id, external_message_id)
1453
+ * - message_runs no unique constraint on assistant_id
1454
+ */
1455
+ function migrateRemoveAssistantIdColumns(database: ReturnType<typeof drizzle<typeof schema>>): void {
1456
+ const raw = (database as unknown as { $client: Database }).$client;
1457
+ const checkpointKey = 'migration_remove_assistant_id_columns_v1';
1458
+ const checkpoint = raw.query(
1459
+ `SELECT 1 FROM memory_checkpoints WHERE key = ?`,
1460
+ ).get(checkpointKey);
1461
+ if (checkpoint) return;
1462
+
1463
+ raw.exec('PRAGMA foreign_keys = OFF');
1464
+ try {
1465
+ raw.exec('BEGIN');
1466
+
1467
+ // --- conversation_keys ---
1468
+ const ckDdl = raw.query(
1469
+ `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'conversation_keys'`,
1470
+ ).get() as { sql: string } | null;
1471
+ if (ckDdl?.sql.includes('assistant_id')) {
1472
+ raw.exec(/*sql*/ `
1473
+ CREATE TABLE conversation_keys_new (
1474
+ id TEXT PRIMARY KEY,
1475
+ conversation_key TEXT NOT NULL UNIQUE,
1476
+ conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
1477
+ created_at INTEGER NOT NULL
1373
1478
  )
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
1479
+ `);
1480
+ raw.exec(/*sql*/ `
1481
+ INSERT INTO conversation_keys_new (id, conversation_key, conversation_id, created_at)
1482
+ SELECT id, conversation_key, conversation_id, created_at FROM conversation_keys
1483
+ `);
1484
+ raw.exec(/*sql*/ `DROP TABLE conversation_keys`);
1485
+ raw.exec(/*sql*/ `ALTER TABLE conversation_keys_new RENAME TO conversation_keys`);
1486
+ }
1487
+
1488
+ // --- attachments ---
1489
+ const attDdl = raw.query(
1490
+ `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'attachments'`,
1491
+ ).get() as { sql: string } | null;
1492
+ if (attDdl?.sql.includes('assistant_id')) {
1493
+ raw.exec(/*sql*/ `
1494
+ CREATE TABLE attachments_new (
1495
+ id TEXT PRIMARY KEY,
1496
+ original_filename TEXT NOT NULL,
1497
+ mime_type TEXT NOT NULL,
1498
+ size_bytes INTEGER NOT NULL,
1499
+ kind TEXT NOT NULL,
1500
+ data_base64 TEXT NOT NULL,
1501
+ content_hash TEXT,
1502
+ thumbnail_base64 TEXT,
1503
+ created_at INTEGER NOT NULL
1385
1504
  )
1386
- `);
1387
- // Step 3: Bulk-update remaining non-self rows.
1388
- raw.exec(/*sql*/ `
1389
- UPDATE channel_inbound_events SET assistant_id = 'self' WHERE assistant_id != 'self'
1390
- `);
1505
+ `);
1506
+ raw.exec(/*sql*/ `
1507
+ INSERT INTO attachments_new (id, original_filename, mime_type, size_bytes, kind, data_base64, content_hash, thumbnail_base64, created_at)
1508
+ SELECT id, original_filename, mime_type, size_bytes, kind, data_base64, content_hash, thumbnail_base64, created_at FROM attachments
1509
+ `);
1510
+ raw.exec(/*sql*/ `DROP TABLE attachments`);
1511
+ raw.exec(/*sql*/ `ALTER TABLE attachments_new RENAME TO attachments`);
1512
+ }
1391
1513
 
1392
- // message_runs: no unique constraint on assistant_id — simple bulk update
1393
- raw.exec(/*sql*/ `
1394
- UPDATE message_runs SET assistant_id = 'self' WHERE assistant_id != 'self'
1395
- `);
1514
+ // --- channel_inbound_events ---
1515
+ const cieDdl = raw.query(
1516
+ `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'channel_inbound_events'`,
1517
+ ).get() as { sql: string } | null;
1518
+ if (cieDdl?.sql.includes('assistant_id')) {
1519
+ raw.exec(/*sql*/ `
1520
+ CREATE TABLE channel_inbound_events_new (
1521
+ id TEXT PRIMARY KEY,
1522
+ source_channel TEXT NOT NULL,
1523
+ external_chat_id TEXT NOT NULL,
1524
+ external_message_id TEXT NOT NULL,
1525
+ source_message_id TEXT,
1526
+ conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
1527
+ message_id TEXT REFERENCES messages(id) ON DELETE CASCADE,
1528
+ delivery_status TEXT NOT NULL DEFAULT 'pending',
1529
+ processing_status TEXT NOT NULL DEFAULT 'pending',
1530
+ processing_attempts INTEGER NOT NULL DEFAULT 0,
1531
+ last_processing_error TEXT,
1532
+ retry_after INTEGER,
1533
+ raw_payload TEXT,
1534
+ created_at INTEGER NOT NULL,
1535
+ updated_at INTEGER NOT NULL,
1536
+ UNIQUE (source_channel, external_chat_id, external_message_id)
1537
+ )
1538
+ `);
1539
+ raw.exec(/*sql*/ `
1540
+ INSERT INTO channel_inbound_events_new (
1541
+ id, source_channel, external_chat_id, external_message_id, source_message_id,
1542
+ conversation_id, message_id, delivery_status, processing_status,
1543
+ processing_attempts, last_processing_error, retry_after, raw_payload,
1544
+ created_at, updated_at
1545
+ )
1546
+ SELECT
1547
+ id, source_channel, external_chat_id, external_message_id, source_message_id,
1548
+ conversation_id, message_id, delivery_status, processing_status,
1549
+ processing_attempts, last_processing_error, retry_after, raw_payload,
1550
+ created_at, updated_at
1551
+ FROM channel_inbound_events
1552
+ `);
1553
+ raw.exec(/*sql*/ `DROP TABLE channel_inbound_events`);
1554
+ raw.exec(/*sql*/ `ALTER TABLE channel_inbound_events_new RENAME TO channel_inbound_events`);
1555
+ }
1556
+
1557
+ // --- message_runs ---
1558
+ const mrDdl = raw.query(
1559
+ `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'message_runs'`,
1560
+ ).get() as { sql: string } | null;
1561
+ if (mrDdl?.sql.includes('assistant_id')) {
1562
+ raw.exec(/*sql*/ `
1563
+ CREATE TABLE message_runs_new (
1564
+ id TEXT PRIMARY KEY,
1565
+ conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
1566
+ message_id TEXT REFERENCES messages(id) ON DELETE CASCADE,
1567
+ status TEXT NOT NULL DEFAULT 'running',
1568
+ pending_confirmation TEXT,
1569
+ input_tokens INTEGER NOT NULL DEFAULT 0,
1570
+ output_tokens INTEGER NOT NULL DEFAULT 0,
1571
+ estimated_cost REAL NOT NULL DEFAULT 0,
1572
+ error TEXT,
1573
+ created_at INTEGER NOT NULL,
1574
+ updated_at INTEGER NOT NULL
1575
+ )
1576
+ `);
1577
+ raw.exec(/*sql*/ `
1578
+ INSERT INTO message_runs_new (
1579
+ id, conversation_id, message_id, status, pending_confirmation,
1580
+ input_tokens, output_tokens, estimated_cost, error, created_at, updated_at
1581
+ )
1582
+ SELECT
1583
+ id, conversation_id, message_id, status, pending_confirmation,
1584
+ input_tokens, output_tokens, estimated_cost, error, created_at, updated_at
1585
+ FROM message_runs
1586
+ `);
1587
+ raw.exec(/*sql*/ `DROP TABLE message_runs`);
1588
+ raw.exec(/*sql*/ `ALTER TABLE message_runs_new RENAME TO message_runs`);
1589
+ }
1396
1590
 
1397
1591
  raw.query(
1398
1592
  `INSERT OR IGNORE INTO memory_checkpoints (key, value, updated_at) VALUES (?, '1', ?)`,
@@ -1402,6 +1596,8 @@ function migrateAssistantIdToSelf(database: ReturnType<typeof drizzle<typeof sch
1402
1596
  } catch (e) {
1403
1597
  try { raw.exec('ROLLBACK'); } catch { /* no active transaction */ }
1404
1598
  throw e;
1599
+ } finally {
1600
+ raw.exec('PRAGMA foreign_keys = ON');
1405
1601
  }
1406
1602
  }
1407
1603
 
@@ -1463,3 +1659,4 @@ function migrateCallSessionsProviderSidDedup(database: ReturnType<typeof drizzle
1463
1659
  throw e;
1464
1660
  }
1465
1661
  }
1662
+
@@ -1,6 +1,11 @@
1
1
  import { and, asc, eq, inArray, lt, ne } from 'drizzle-orm';
2
2
  import type { AssistantConfig } from '../../config/types.js';
3
3
  import { getLogger } from '../../util/logger.js';
4
+ import {
5
+ computeConflictRelevance,
6
+ looksLikeClarificationReply,
7
+ shouldAttemptConflictResolution,
8
+ } from '../conflict-intent.js';
4
9
  import { getDb } from '../db.js';
5
10
  import { resolveConflictClarification } from '../clarification-resolver.js';
6
11
  import { applyConflictResolution, listPendingConflictDetails } from '../conflict-store.js';
@@ -12,6 +17,7 @@ import { memoryItemConflicts, messages } from '../schema.js';
12
17
  const log = getLogger('memory-jobs-worker');
13
18
 
14
19
  const CLEANUP_BATCH_LIMIT = 250;
20
+ const BACKGROUND_RECENT_ASK_WINDOW_MS = 6 * 60 * 60 * 1000;
15
21
 
16
22
  export async function resolvePendingConflictsForMessageJob(job: MemoryJob, config: AssistantConfig): Promise<void> {
17
23
  if (!config.memory.conflicts.enabled) return;
@@ -33,13 +39,28 @@ export async function resolvePendingConflictsForMessageJob(job: MemoryJob, confi
33
39
 
34
40
  const userMessage = extractTextFromStoredMessageContent(message.content).trim();
35
41
  if (userMessage.length === 0) return;
42
+ const clarificationReply = looksLikeClarificationReply(userMessage);
43
+ if (!clarificationReply) return;
36
44
 
37
45
  const pending = listPendingConflictDetails(scopeId, 25);
38
46
  const eligible = pending.filter((conflict) => conflict.createdAt <= message.createdAt);
39
47
  if (eligible.length === 0) return;
48
+ const candidates = eligible.filter((conflict) => {
49
+ const askedAt = conflict.lastAskedAt;
50
+ const wasRecentlyAsked = typeof askedAt === 'number'
51
+ && askedAt <= message.createdAt
52
+ && message.createdAt - askedAt <= BACKGROUND_RECENT_ASK_WINDOW_MS;
53
+ const relevance = computeConflictRelevance(userMessage, conflict);
54
+ return shouldAttemptConflictResolution({
55
+ clarificationReply,
56
+ relevance,
57
+ wasRecentlyAsked,
58
+ });
59
+ });
60
+ if (candidates.length === 0) return;
40
61
 
41
62
  let resolvedCount = 0;
42
- for (const conflict of eligible) {
63
+ for (const conflict of candidates) {
43
64
  const resolution = await resolveConflictClarification(
44
65
  {
45
66
  existingStatement: conflict.existingStatement,
@@ -63,6 +84,7 @@ export async function resolvePendingConflictsForMessageJob(job: MemoryJob, confi
63
84
  scopeId,
64
85
  pendingConflicts: pending.length,
65
86
  eligibleConflicts: eligible.length,
87
+ candidateConflicts: candidates.length,
66
88
  resolvedConflicts: resolvedCount,
67
89
  }, 'Processed pending conflict resolution job');
68
90
  }