@vellumai/assistant 0.10.0-dev.202606200318.c052d10 → 0.10.0-dev.202606201453.1417592

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 (49) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/agent-loop-callsite-precedence.test.ts +1 -40
  3. package/src/__tests__/agent-wake-override-profile.test.ts +2 -0
  4. package/src/__tests__/app-source-watcher.test.ts +30 -10
  5. package/src/__tests__/config-schema.test.ts +34 -0
  6. package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +3 -0
  7. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +3 -0
  8. package/src/__tests__/conversation-agent-loop-overflow.test.ts +3 -0
  9. package/src/__tests__/conversation-agent-loop.test.ts +3 -0
  10. package/src/__tests__/conversation-process-callsite.test.ts +0 -14
  11. package/src/__tests__/db-llm-request-log-provider-migration.test.ts +6 -1
  12. package/src/__tests__/heartbeat-disk-pressure.test.ts +3 -0
  13. package/src/__tests__/heartbeat-service.test.ts +6 -0
  14. package/src/__tests__/list-messages-attachments.test.ts +41 -0
  15. package/src/__tests__/plugin-source-watcher.test.ts +33 -1
  16. package/src/__tests__/usage-cache-backfill-migration.test.ts +17 -2
  17. package/src/acp/__tests__/session-manager.test.ts +72 -1
  18. package/src/acp/index.ts +10 -0
  19. package/src/acp/session-manager.ts +35 -0
  20. package/src/agent/loop.ts +28 -22
  21. package/src/config/schemas/memory-lifecycle.ts +5 -3
  22. package/src/config/schemas/timeouts.ts +24 -0
  23. package/src/daemon/app-source-watcher.ts +31 -18
  24. package/src/daemon/conversation-agent-loop.ts +8 -5
  25. package/src/daemon/conversation.ts +30 -41
  26. package/src/daemon/handlers/conversations.ts +7 -0
  27. package/src/daemon/plugin-source-watcher.ts +5 -0
  28. package/src/daemon/workspace-tools-watcher.ts +4 -0
  29. package/src/heartbeat/__tests__/heartbeat-service.test.ts +6 -0
  30. package/src/heartbeat/heartbeat-service.ts +3 -4
  31. package/src/memory/__tests__/db-maintenance.test.ts +27 -35
  32. package/src/memory/conversation-crud.ts +9 -3
  33. package/src/memory/db-init.ts +33 -5
  34. package/src/memory/db-maintenance.ts +43 -38
  35. package/src/memory/job-handlers/cleanup.ts +6 -0
  36. package/src/memory/migrations/297-move-llm-request-logs-to-logs-db.ts +130 -0
  37. package/src/memory/migrations/__tests__/297-move-llm-request-logs.test.ts +159 -0
  38. package/src/memory/migrations/index.ts +1 -0
  39. package/src/plugin-api/index.ts +7 -0
  40. package/src/plugin-api/vision-support.ts +75 -0
  41. package/src/prompts/system-prompt.ts +1 -1
  42. package/src/runtime/__tests__/agent-wake.test.ts +6 -4
  43. package/src/runtime/agent-wake.ts +15 -7
  44. package/src/runtime/routes/conversation-routes.ts +24 -3
  45. package/src/runtime/routes/migration-routes.ts +35 -39
  46. package/src/schedule/scheduler.ts +5 -9
  47. package/src/tools/ask-question/ask-question-tool.test.ts +60 -52
  48. package/src/tools/ask-question/ask-question-tool.ts +14 -73
  49. package/src/util/fs-watcher-error.ts +36 -0
@@ -10,6 +10,7 @@ import { tmpdir } from "node:os";
10
10
  import { dirname, join } from "node:path";
11
11
 
12
12
  import { getLogger } from "../util/logger.js";
13
+ import { getLogsDbPath } from "../util/logs-db-path.js";
13
14
  import { ensureDataDir, getDbPath } from "../util/platform.js";
14
15
  import { backfillAppConversationIds } from "./app-store.js";
15
16
  import { getDb, getSqlite } from "./db-connection.js";
@@ -154,6 +155,7 @@ import {
154
155
  migrateMessagesConversationCreatedAtIndex,
155
156
  migrateMessagesFtsBackfill,
156
157
  migrateMessagesRoleCreatedAtIndex,
158
+ migrateMoveLlmRequestLogsToLogsDb,
157
159
  migrateNormalizePhoneIdentities,
158
160
  migrateNormalizeSlackExternalContent,
159
161
  migrateNormalizeUserFileByPrincipal,
@@ -265,12 +267,31 @@ function getTemplateDbPath(): string {
265
267
  );
266
268
  }
267
269
 
270
+ /**
271
+ * Template path for the attached `logs` database, kept alongside the main
272
+ * template. Both files must be captured/restored together: the migrated state
273
+ * now spans two files (llm_request_logs and its indexes live in `logs`), so
274
+ * restoring only the main DB would leave a fresh, empty logs DB with no
275
+ * `llm_request_logs` table.
276
+ */
277
+ function getLogsTemplateDbPath(): string {
278
+ return `${getTemplateDbPath()}.logs`;
279
+ }
280
+
268
281
  function tryRestoreTemplate(): boolean {
269
282
  const templatePath = getTemplateDbPath();
270
283
  if (!existsSync(templatePath)) return false;
271
284
  // getDb() hasn't run yet, so the data directory may not exist.
272
285
  ensureDataDir();
273
286
  copyFileSync(templatePath, getDbPath());
287
+ // Restore the attached logs DB before getDb() opens (and ATTACHes) it, so the
288
+ // relocated llm_request_logs table is present. Older templates may predate
289
+ // the split; the hash includes the migration files, so a stale template
290
+ // without this sibling won't be reused — but guard anyway.
291
+ const logsTemplate = getLogsTemplateDbPath();
292
+ if (existsSync(logsTemplate)) {
293
+ copyFileSync(logsTemplate, getLogsDbPath());
294
+ }
274
295
  // Open the pre-migrated copy — getDb() will set PRAGMAs but skip migrations.
275
296
  getDb();
276
297
  return true;
@@ -278,12 +299,18 @@ function tryRestoreTemplate(): boolean {
278
299
 
279
300
  function saveTemplate(): void {
280
301
  try {
281
- // Flush WAL to main DB file before copying.
302
+ // Flush each DB's WAL to its main file before copying.
282
303
  getSqlite().exec("PRAGMA wal_checkpoint(TRUNCATE)");
283
- const tmpFile = `${getTemplateDbPath()}.${process.pid}`;
284
- copyFileSync(getDbPath(), tmpFile);
285
- // Atomic rename — safe even with parallel test workers.
286
- renameSync(tmpFile, getTemplateDbPath());
304
+ getSqlite().exec("PRAGMA logs.wal_checkpoint(TRUNCATE)");
305
+
306
+ const mainTmp = `${getTemplateDbPath()}.${process.pid}`;
307
+ copyFileSync(getDbPath(), mainTmp);
308
+ const logsTmp = `${getLogsTemplateDbPath()}.${process.pid}`;
309
+ copyFileSync(getLogsDbPath(), logsTmp);
310
+
311
+ // Atomic renames — safe even with parallel test workers.
312
+ renameSync(mainTmp, getTemplateDbPath());
313
+ renameSync(logsTmp, getLogsTemplateDbPath());
287
314
  } catch {
288
315
  // Best effort — next file will just run migrations normally.
289
316
  }
@@ -531,6 +558,7 @@ export function initializeDb(): void {
531
558
  migrateDropExternalUserId,
532
559
  dropApprovalPromptTsTrackerTable,
533
560
  migrateRewriteBalancedEconomyProfilePins,
561
+ migrateMoveLlmRequestLogsToLogsDb,
534
562
  ];
535
563
 
536
564
  // Run each migration step, catching and logging individual failures so one
@@ -58,11 +58,10 @@ async function runDbMaintenance(): Promise<void> {
58
58
  );
59
59
 
60
60
  // Prune finished workflow runs (and their journals) past the retention
61
- // window BEFORE VACUUM so the freed pages are reclaimed in the same pass.
62
- // This is a fast bounded DELETE on the small workflow tables, so it runs on
63
- // the main connection (`rawRun`) — unlike VACUUM/optimize it doesn't need the
64
- // async/subprocess path. The maintenance scheduler below already defers this
65
- // whole routine to an idle window.
61
+ // window. This is a fast bounded DELETE on the small workflow tables, so it
62
+ // runs on the main connection (`rawRun`). SQLite reuses the pages it frees
63
+ // for later writes — we deliberately do not VACUUM to hand them back to the
64
+ // OS (see the WAL note below).
66
65
  try {
67
66
  const deletedRuns = pruneRuns(getConfig().workflows.journalRetentionDays);
68
67
  if (deletedRuns > 0) {
@@ -72,18 +71,9 @@ async function runDbMaintenance(): Promise<void> {
72
71
  log.warn({ err }, "Workflow run pruning failed (non-fatal)");
73
72
  }
74
73
 
75
- // VACUUM is the long-running one — minutes on a multi-GB DB. PRAGMA
76
- // optimize is fast but routed through the same async path for
77
- // consistency and to keep both off the main thread when the CLI
78
- // backend is available.
79
- const vacuumResult = await runAsyncSqlite("VACUUM");
80
- if (!vacuumResult.ok) {
81
- log.warn(
82
- { error: vacuumResult.error, backend: vacuumResult.backend },
83
- "VACUUM failed (non-fatal)",
84
- );
85
- }
86
-
74
+ // Refresh the query planner's statistics. PRAGMA optimize is cheap; it is
75
+ // routed through the async path for consistency and to keep it off the main
76
+ // thread when the sqlite3 CLI backend is available.
87
77
  const optimizeResult = await runAsyncSqlite("PRAGMA optimize");
88
78
  if (!optimizeResult.ok) {
89
79
  log.warn(
@@ -92,26 +82,42 @@ async function runDbMaintenance(): Promise<void> {
92
82
  );
93
83
  }
94
84
 
95
- const after = getDbStats();
96
- const reclaimedPages = before.pageCount - after.pageCount;
97
- const reclaimedBytes =
98
- before.fileSizeBytes != null && after.fileSizeBytes != null
99
- ? before.fileSizeBytes - after.fileSizeBytes
100
- : null;
85
+ // Truncate the WAL so it doesn't sit at its high-water mark. We intentionally
86
+ // do NOT run a full VACUUM: in WAL mode VACUUM rewrites the whole database
87
+ // through the WAL, inflating it to ~the database size and needing up to 2x the
88
+ // DB size in free disk to finish. SQLite already reuses freed pages for new
89
+ // writes, so eager space return isn't worth that cost on a multi-GB database.
90
+ //
91
+ // The checkpoint goes through the async path (sqlite3 subprocess when one is
92
+ // available) for the same reason VACUUM/optimize do: a synchronous
93
+ // wal_checkpoint(TRUNCATE) on the shared connection blocks the event loop
94
+ // while it checkpoints frames and waits out readers — the health/IPC stall
95
+ // runAsyncSqlite exists to avoid. A checkpoint from a separate connection
96
+ // still truncates the shared WAL; if a reader holds it back it's a
97
+ // best-effort no-op and the next maintenance pass retries.
98
+ const checkpointResult = await runAsyncSqlite(
99
+ "PRAGMA wal_checkpoint(TRUNCATE)",
100
+ );
101
+ if (!checkpointResult.ok) {
102
+ log.warn(
103
+ { error: checkpointResult.error, backend: checkpointResult.backend },
104
+ "WAL checkpoint failed (non-fatal)",
105
+ );
106
+ }
101
107
 
108
+ const after = getDbStats();
102
109
  log.info(
103
110
  {
104
- backend: vacuumResult.backend,
105
- vacuumOk: vacuumResult.ok,
106
111
  optimizeOk: optimizeResult.ok,
107
- vacuumElapsedMs: vacuumResult.elapsedMs,
112
+ optimizeBackend: optimizeResult.backend,
108
113
  optimizeElapsedMs: optimizeResult.elapsedMs,
109
- beforePageCount: before.pageCount,
110
- afterPageCount: after.pageCount,
111
- reclaimedPages,
112
- beforeFileSizeBytes: before.fileSizeBytes,
113
- afterFileSizeBytes: after.fileSizeBytes,
114
- reclaimedBytes,
114
+ checkpointOk: checkpointResult.ok,
115
+ checkpointBackend: checkpointResult.backend,
116
+ checkpointResult: checkpointResult.stdout?.trim(),
117
+ checkpointElapsedMs: checkpointResult.elapsedMs,
118
+ pageCount: after.pageCount,
119
+ freelistCount: after.freelistCount,
120
+ fileSizeBytes: after.fileSizeBytes,
115
121
  },
116
122
  "Database maintenance complete",
117
123
  );
@@ -126,12 +132,11 @@ export async function maybeRunDbMaintenance(nowMs = Date.now()): Promise<void> {
126
132
  );
127
133
  if (nowMs - lastRun < intervalMs) return;
128
134
 
129
- // VACUUM holds an exclusive lock on the database for its full duration —
130
- // minutes on a multi-GB DB — during which every other write fails with
131
- // SQLITE_BUSY ("database is locked"). Defer maintenance until the user has
132
- // been quiet for `quietPeriodMs` so that lock never lands mid-conversation.
133
- // The checkpoint below is only written once maintenance actually runs, so a
134
- // deferred run is simply retried on a later (still-idle) worker tick.
135
+ // Maintenance still takes brief write locks (PRAGMA optimize and the
136
+ // truncating WAL checkpoint), so defer it until the user has been quiet for
137
+ // `quietPeriodMs` and those locks never land mid-conversation. The checkpoint
138
+ // below is only written once maintenance actually runs, so a deferred run is
139
+ // simply retried on a later (still-idle) worker tick.
135
140
  if (quietPeriodMs > 0) {
136
141
  const lastUserMessageAt = getLastUserMessageTimestamp();
137
142
  if (lastUserMessageAt > 0 && nowMs - lastUserMessageAt < quietPeriodMs) {
@@ -1,5 +1,6 @@
1
1
  import type { AssistantConfig } from "../../config/types.js";
2
2
  import { getLogger } from "../../util/logger.js";
3
+ import { getLogsDbPath } from "../../util/logs-db-path.js";
3
4
  import { runAsyncSqlite } from "../db-async-query.js";
4
5
  import { getDb } from "../db-connection.js";
5
6
  import { enqueueMemoryJob, type MemoryJob } from "../jobs-store.js";
@@ -49,9 +50,14 @@ export async function pruneOldLlmRequestLogsJob(
49
50
  // fallback backend in `db-async-query.ts` synthesizes the same shape
50
51
  // by capturing `changes()` atomically after `exec()`. Both backends
51
52
  // end up on the parser path below.
53
+ // llm_request_logs lives in the attached logs database. Point the sqlite3
54
+ // subprocess at that file directly (it can't see the daemon connection's
55
+ // ATTACH); the in-process fallback runs on the daemon connection, where the
56
+ // unqualified name already resolves to the attached table.
52
57
  const result = await runAsyncSqlite(
53
58
  `DELETE FROM llm_request_logs WHERE rowid IN (SELECT rowid FROM llm_request_logs WHERE created_at < ${cutoffMs} LIMIT ${PRUNE_LOG_BATCH_LIMIT});
54
59
  SELECT changes();`,
60
+ { dbPath: getLogsDbPath() },
55
61
  );
56
62
  if (!result.ok) {
57
63
  log.warn(
@@ -0,0 +1,130 @@
1
+ import {
2
+ type DrizzleDb,
3
+ getSqliteFrom,
4
+ LOGS_DB_SCHEMA,
5
+ } from "../db-connection.js";
6
+
7
+ /**
8
+ * Column names of `llm_request_logs`, in a fixed order used for the
9
+ * cross-database copy. Listed explicitly (rather than `SELECT *`) so the copy
10
+ * is insensitive to the physical column order of the `main` table, which varies
11
+ * with the historical sequence of `ALTER TABLE ... ADD COLUMN` migrations.
12
+ *
13
+ * The first columns are the original base columns; the rest were added by later
14
+ * column migrations.
15
+ */
16
+ const COLUMN_NAMES = [
17
+ "id",
18
+ "conversation_id",
19
+ "message_id",
20
+ "provider",
21
+ "request_payload",
22
+ "response_payload",
23
+ "created_at",
24
+ "agent_loop_exit_reason",
25
+ "call_site",
26
+ ];
27
+ const COLUMNS = COLUMN_NAMES.join(", ");
28
+
29
+ const CREATE_TABLE = (schema: string): string => /*sql*/ `
30
+ CREATE TABLE IF NOT EXISTS ${schema}.llm_request_logs (
31
+ id TEXT PRIMARY KEY,
32
+ conversation_id TEXT NOT NULL,
33
+ message_id TEXT,
34
+ provider TEXT,
35
+ request_payload TEXT NOT NULL,
36
+ response_payload TEXT NOT NULL,
37
+ created_at INTEGER NOT NULL,
38
+ agent_loop_exit_reason TEXT,
39
+ call_site TEXT
40
+ )
41
+ `;
42
+
43
+ /**
44
+ * Create the three indexes in `schema`. The index names are schema-qualified;
45
+ * the table name in `CREATE INDEX` must be unqualified and is resolved within
46
+ * the index's schema — so the table must already resolve to `schema` (i.e. no
47
+ * same-named table shadowing it in `main`) when this runs.
48
+ */
49
+ function createIndexes(raw: ReturnType<typeof getSqliteFrom>, schema: string) {
50
+ raw.exec(
51
+ `CREATE INDEX IF NOT EXISTS ${schema}.idx_llm_request_logs_message_id ON llm_request_logs(message_id)`,
52
+ );
53
+ raw.exec(
54
+ `CREATE INDEX IF NOT EXISTS ${schema}.idx_llm_request_logs_created_at ON llm_request_logs(created_at)`,
55
+ );
56
+ raw.exec(
57
+ `CREATE INDEX IF NOT EXISTS ${schema}.idx_llm_request_logs_conv_created ON llm_request_logs(conversation_id, created_at)`,
58
+ );
59
+ }
60
+
61
+ function tableExists(
62
+ raw: ReturnType<typeof getSqliteFrom>,
63
+ schema: string,
64
+ ): boolean {
65
+ return (
66
+ raw
67
+ .query(
68
+ `SELECT name FROM ${schema}.sqlite_master WHERE type='table' AND name='llm_request_logs'`,
69
+ )
70
+ .get() != null
71
+ );
72
+ }
73
+
74
+ /**
75
+ * Copy every row of `llm_request_logs` from `main` into the `logs` database,
76
+ * substituting `NULL` for any target column missing in the source. The newer
77
+ * columns (message_id/provider/agent_loop_exit_reason/call_site) are all
78
+ * nullable, so NULL is correct for a legacy row that predates them.
79
+ */
80
+ function copyRowsFromMain(raw: ReturnType<typeof getSqliteFrom>): void {
81
+ const presentColumns = new Set(
82
+ (
83
+ raw
84
+ .query(`SELECT name FROM pragma_table_info('llm_request_logs', 'main')`)
85
+ .all() as Array<{ name: string }>
86
+ ).map((r) => r.name),
87
+ );
88
+ const selectList = COLUMN_NAMES.map((c) =>
89
+ presentColumns.has(c) ? c : "NULL",
90
+ ).join(", ");
91
+ raw.exec(/*sql*/ `
92
+ INSERT OR IGNORE INTO ${LOGS_DB_SCHEMA}.llm_request_logs (${COLUMNS})
93
+ SELECT ${selectList} FROM main.llm_request_logs
94
+ `);
95
+ }
96
+
97
+ /**
98
+ * Keep `llm_request_logs` housed in the attached append-only `logs` database
99
+ * (`assistant-logs.db`) rather than the main DB. Keeping this heavy table — and
100
+ * the request/response payloads it stores — in its own file stops it from
101
+ * bloating the main DB and its WAL, and lets the two files VACUUM and
102
+ * checkpoint independently. Once it lives only in `logs`, the unqualified name
103
+ * used by the Drizzle store resolves to the attached copy, so query code is
104
+ * unchanged.
105
+ *
106
+ * This step is idempotent and runs on every startup (it is not checkpoint-
107
+ * gated). It must, because the earlier `createWatchersAndLogsTables` migration
108
+ * recreates an empty `main.llm_request_logs` on every boot via
109
+ * `CREATE TABLE IF NOT EXISTS`; this step re-relocates and drops that shadow so
110
+ * the unqualified name keeps resolving to `logs`.
111
+ *
112
+ * Ordering within the step matters:
113
+ * 1. Create the table in `logs` (safe whether or not `main` has it).
114
+ * 2. If `main` still has the table, copy its rows over (`INSERT OR IGNORE` on
115
+ * the id PK, so a re-run is a no-op) and drop it.
116
+ * 3. Create the indexes — only now is `main` guaranteed not to shadow the
117
+ * table, so the unqualified reference resolves to `logs`.
118
+ */
119
+ export function migrateMoveLlmRequestLogsToLogsDb(database: DrizzleDb): void {
120
+ const raw = getSqliteFrom(database);
121
+
122
+ raw.exec(CREATE_TABLE(LOGS_DB_SCHEMA));
123
+
124
+ if (tableExists(raw, "main")) {
125
+ copyRowsFromMain(raw);
126
+ raw.exec(`DROP TABLE main.llm_request_logs`);
127
+ }
128
+
129
+ createIndexes(raw, LOGS_DB_SCHEMA);
130
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Tests for migration 297 — keeping `llm_request_logs` in the attached
3
+ * `logs` database.
4
+ *
5
+ * Covers:
6
+ * 1. End state after a full initializeDb(): the table lives in `logs`, not
7
+ * `main`, and the Drizzle store round-trips against it.
8
+ * 2. The relocation itself: rows in a `main.llm_request_logs` are copied into
9
+ * `logs` and the main-DB copy is dropped, with indexes built in `logs`.
10
+ * 3. A legacy base-only `main` table (predating the newer columns) is copied
11
+ * without error, NULL-filling the absent columns.
12
+ *
13
+ * The step is idempotent and not checkpoint-gated, so each test can drive it
14
+ * directly without clearing any checkpoint.
15
+ */
16
+ import { describe, expect, test } from "bun:test";
17
+
18
+ const { getDb, getSqlite, LOGS_DB_SCHEMA } =
19
+ await import("../../db-connection.js");
20
+ const { initializeDb } = await import("../../db-init.js");
21
+ const { migrateMoveLlmRequestLogsToLogsDb } =
22
+ await import("../297-move-llm-request-logs-to-logs-db.js");
23
+ const { recordRequestLog, getRequestLogById } =
24
+ await import("../../llm-request-log-store.js");
25
+
26
+ initializeDb();
27
+
28
+ function tableSchemas(name: string): string[] {
29
+ return getSqlite()
30
+ .query<{ schema: string }, []>(
31
+ `SELECT 'main' AS schema FROM main.sqlite_master WHERE type='table' AND name='${name}'
32
+ UNION ALL
33
+ SELECT '${LOGS_DB_SCHEMA}' FROM ${LOGS_DB_SCHEMA}.sqlite_master WHERE type='table' AND name='${name}'`,
34
+ )
35
+ .all()
36
+ .map((r) => r.schema);
37
+ }
38
+
39
+ describe("migration 297 — llm_request_logs lives in the logs database", () => {
40
+ test("after init, the table is in logs and not in main", () => {
41
+ expect(tableSchemas("llm_request_logs")).toEqual([LOGS_DB_SCHEMA]);
42
+ });
43
+
44
+ test("the store round-trips against the relocated table", () => {
45
+ const id = recordRequestLog(
46
+ "conv-297",
47
+ JSON.stringify({ req: 1 }),
48
+ JSON.stringify({ res: 1 }),
49
+ "msg-297",
50
+ "anthropic",
51
+ "mainAgent",
52
+ );
53
+ const row = getRequestLogById(id);
54
+ expect(row?.conversationId).toBe("conv-297");
55
+ expect(row?.messageId).toBe("msg-297");
56
+ expect(row?.provider).toBe("anthropic");
57
+ expect(row?.callSite).toBe("mainAgent");
58
+
59
+ // The written row physically lives in the logs database.
60
+ const inLogs = getSqlite()
61
+ .query<
62
+ { c: number },
63
+ [string]
64
+ >(`SELECT COUNT(*) AS c FROM ${LOGS_DB_SCHEMA}.llm_request_logs WHERE id = ?`)
65
+ .get(id);
66
+ expect(inLogs?.c).toBe(1);
67
+ });
68
+
69
+ test("relocates rows from a main-DB table and drops it", () => {
70
+ const sqlite = getSqlite();
71
+
72
+ // Simulate the shadow that createWatchersAndLogsTables recreates each boot:
73
+ // a main-DB table with a row, alongside the logs copy.
74
+ sqlite.exec(`DROP TABLE IF EXISTS ${LOGS_DB_SCHEMA}.llm_request_logs`);
75
+ sqlite.exec(`
76
+ CREATE TABLE main.llm_request_logs (
77
+ id TEXT PRIMARY KEY,
78
+ conversation_id TEXT NOT NULL,
79
+ message_id TEXT,
80
+ provider TEXT,
81
+ request_payload TEXT NOT NULL,
82
+ response_payload TEXT NOT NULL,
83
+ created_at INTEGER NOT NULL,
84
+ agent_loop_exit_reason TEXT,
85
+ call_site TEXT
86
+ )
87
+ `);
88
+ sqlite.exec(
89
+ `INSERT INTO main.llm_request_logs (id, conversation_id, request_payload, response_payload, created_at, provider) VALUES ('legacy-1', 'conv-legacy', '{}', '{}', 123, 'openai')`,
90
+ );
91
+
92
+ migrateMoveLlmRequestLogsToLogsDb(getDb());
93
+
94
+ // Main copy gone, logs copy has the row.
95
+ expect(tableSchemas("llm_request_logs")).toEqual([LOGS_DB_SCHEMA]);
96
+ const moved = sqlite
97
+ .query<
98
+ { conversation_id: string; provider: string },
99
+ []
100
+ >(`SELECT conversation_id, provider FROM ${LOGS_DB_SCHEMA}.llm_request_logs WHERE id = 'legacy-1'`)
101
+ .get();
102
+ expect(moved?.conversation_id).toBe("conv-legacy");
103
+ expect(moved?.provider).toBe("openai");
104
+
105
+ // Indexes were created in the logs database.
106
+ const indexes = sqlite
107
+ .query<{ name: string }, []>(
108
+ `SELECT name FROM ${LOGS_DB_SCHEMA}.sqlite_master WHERE type='index' AND tbl_name='llm_request_logs'`,
109
+ )
110
+ .all()
111
+ .map((r) => r.name);
112
+ expect(indexes).toContain("idx_llm_request_logs_message_id");
113
+ expect(indexes).toContain("idx_llm_request_logs_created_at");
114
+ expect(indexes).toContain("idx_llm_request_logs_conv_created");
115
+ });
116
+
117
+ test("copies a legacy base-only table, NULL-filling newer columns", () => {
118
+ const sqlite = getSqlite();
119
+
120
+ // A workspace upgrading from a build that predates the
121
+ // message_id/provider/agent_loop_exit_reason/call_site columns: the main
122
+ // table has only the original base columns.
123
+ sqlite.exec(`DROP TABLE IF EXISTS ${LOGS_DB_SCHEMA}.llm_request_logs`);
124
+ sqlite.exec(`
125
+ CREATE TABLE main.llm_request_logs (
126
+ id TEXT PRIMARY KEY,
127
+ conversation_id TEXT NOT NULL,
128
+ request_payload TEXT NOT NULL,
129
+ response_payload TEXT NOT NULL,
130
+ created_at INTEGER NOT NULL
131
+ )
132
+ `);
133
+ sqlite.exec(
134
+ `INSERT INTO main.llm_request_logs (id, conversation_id, request_payload, response_payload, created_at) VALUES ('base-1', 'conv-base', '{}', '{}', 7)`,
135
+ );
136
+
137
+ // Must not throw on the absent columns.
138
+ migrateMoveLlmRequestLogsToLogsDb(getDb());
139
+
140
+ expect(tableSchemas("llm_request_logs")).toEqual([LOGS_DB_SCHEMA]);
141
+ const moved = sqlite
142
+ .query<
143
+ {
144
+ conversation_id: string;
145
+ message_id: string | null;
146
+ provider: string | null;
147
+ call_site: string | null;
148
+ },
149
+ []
150
+ >(
151
+ `SELECT conversation_id, message_id, provider, call_site FROM ${LOGS_DB_SCHEMA}.llm_request_logs WHERE id = 'base-1'`,
152
+ )
153
+ .get();
154
+ expect(moved?.conversation_id).toBe("conv-base");
155
+ expect(moved?.message_id).toBeNull();
156
+ expect(moved?.provider).toBeNull();
157
+ expect(moved?.call_site).toBeNull();
158
+ });
159
+ });
@@ -288,6 +288,7 @@ export { migrateWorkflowJournalLeafTokens } from "./293-workflow-journal-leaf-to
288
288
  export { migrateDropExternalUserId } from "./294-drop-external-user-id.js";
289
289
  export { dropApprovalPromptTsTrackerTable } from "./295-drop-approval-prompt-ts-tracker.js";
290
290
  export { migrateRewriteBalancedEconomyProfilePins } from "./296-rewrite-balanced-economy-profile-pins.js";
291
+ export { migrateMoveLlmRequestLogsToLogsDb } from "./297-move-llm-request-logs-to-logs-db.js";
291
292
  export {
292
293
  MIGRATION_REGISTRY,
293
294
  type MigrationRegistryEntry,
@@ -132,6 +132,13 @@ export type {
132
132
  export { assistantEventHub } from "../runtime/assistant-event-hub.js";
133
133
  export { getSecureKeyAsync } from "../security/secure-keys.js";
134
134
  export { getModelProfiles } from "./model-profiles.js";
135
+ // Check whether a profile's resolved model can process image input. Resolves
136
+ // the effective (provider, model) by merging over the workspace default and
137
+ // inferring the provider for model-only profiles, then looks up the model
138
+ // catalog's `supportsVision` flag. Handles mix profiles (true if any arm
139
+ // supports vision). Fail-open for unknown models. Pair with
140
+ // `getModelProfiles()` to inspect the active or candidate profiles.
141
+ export { doesSupportVision } from "./vision-support.js";
135
142
  // Resolve a provider for a call site (optionally overriding the profile) so a
136
143
  // plugin can run inference through the workspace's configured profiles and
137
144
  // credentials — managed-proxy or BYOK — without supplying its own API key.
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Vision-support resolution for plugin consumption.
3
+ *
4
+ * A plugin that gates image processing on vision capability (e.g. an
5
+ * image-to-text fallback for text-only models) calls {@link doesSupportVision}
6
+ * instead of hardcoding model names. The function resolves the effective
7
+ * (provider, model) for a profile — merging with `llm.default` to fill gaps,
8
+ * inferring the provider for model-only profiles via the catalog — and then
9
+ * looks up `supportsVision` in the model catalog.
10
+ */
11
+
12
+ import { getConfig } from "../config/loader.js";
13
+ import {
14
+ getCatalogProviderForModel,
15
+ PROVIDER_CATALOG,
16
+ } from "../providers/model-catalog.js";
17
+ import type { ModelProfileInfo } from "./types.js";
18
+
19
+ /**
20
+ * Whether a profile's resolved model can process image input.
21
+ *
22
+ * Resolution mirrors the host's call-site resolver:
23
+ * - The profile's `(provider, model)` fields are merged over `llm.default` so
24
+ * a profile that only sets `model` (or only `provider`) inherits the other
25
+ * from the workspace default.
26
+ * - When `provider` is still missing but `model` is a known catalog model,
27
+ * the provider is inferred via `getCatalogProviderForModel` (same logic as
28
+ * the resolver's `withImpliedProviderForKnownModel`).
29
+ * - For a mix profile, returns `true` if any constituent arm supports vision
30
+ * (the mix can route to it) and `false` only if every arm is text-only.
31
+ * - Unknown `(provider, model)` pairs default to `true` (fail-open), matching
32
+ * the config GET route's `enrichProfilesWithVisionFlag`.
33
+ */
34
+ export function doesSupportVision(profile: ModelProfileInfo): boolean {
35
+ const { llm } = getConfig();
36
+ const entry = llm.profiles[profile.key];
37
+ if (entry == null) return true;
38
+
39
+ // Mix: fail-open if any arm supports vision.
40
+ if (entry.mix != null) {
41
+ return entry.mix.some((arm) => {
42
+ const armEntry = llm.profiles[arm.profile];
43
+ if (armEntry == null) return true;
44
+ return resolveEntrySupportsVision(armEntry, llm);
45
+ });
46
+ }
47
+
48
+ return resolveEntrySupportsVision(entry, llm);
49
+ }
50
+
51
+ /**
52
+ * Resolve whether a concrete (non-mix) profile entry supports vision by
53
+ * merging its fields over `llm.default` and inferring the provider when
54
+ * only the model is set.
55
+ */
56
+ function resolveEntrySupportsVision(
57
+ entry: { provider?: string; model?: string },
58
+ llm: { default?: { provider?: string; model?: string } },
59
+ ): boolean {
60
+ const provider = entry.provider ?? llm.default?.provider;
61
+ const model = entry.model ?? llm.default?.model;
62
+
63
+ // Infer provider from model when missing (mirrors the resolver's
64
+ // withImpliedProviderForKnownModel).
65
+ const effectiveProvider =
66
+ provider ?? (typeof model === "string" ? getCatalogProviderForModel(model) : undefined);
67
+
68
+ if (typeof effectiveProvider !== "string" || typeof model !== "string") {
69
+ return true; // fail-open
70
+ }
71
+
72
+ const catalogProvider = PROVIDER_CATALOG.find((p) => p.id === effectiveProvider);
73
+ const catalogModel = catalogProvider?.models.find((m) => m.id === model);
74
+ return catalogModel?.supportsVision ?? true;
75
+ }
@@ -274,7 +274,7 @@ export function maybeReseedBootstrap(templateFileName: string): boolean {
274
274
  * Marking happens here — at the single point where the bootstrap selection is
275
275
  * known — so it lands BEFORE the agent loop resolves tools and BEFORE the model
276
276
  * can call the emit tool on the first activation-rail turn. (`resolveTools`
277
- * runs before `resolveSystemPrompt` in the loop, so the system-prompt build is
277
+ * runs before the system-prompt build in the loop, so the system-prompt build is
278
278
  * too late to be the *only* marking site.) The marker write is best-effort and
279
279
  * idempotent (`markActivationSession` swallows errors and dedups on the PK), so
280
280
  * calling this from both `setOnboardingContext` and `buildSystemPrompt` is safe.
@@ -52,8 +52,8 @@ interface WakeConversationProbe {
52
52
  allowedTools?: string[];
53
53
  /**
54
54
  * `conversation.wakePersonaOverride` as observed at run start — the
55
- * field the conversation's resolveSystemPrompt callback reads when
56
- * building the wake's system prompt.
55
+ * field `buildCurrentSystemPrompt` reads when building the wake's
56
+ * system prompt before `agentLoop.run()`.
57
57
  */
58
58
  personaOverride?: unknown;
59
59
  order: number;
@@ -503,6 +503,8 @@ function makeWakeConversation(options: {
503
503
  getTurnChannelContext: () => null,
504
504
  getTurnInterfaceContext: () => null,
505
505
  trustContext: undefined,
506
+ buildCurrentSystemPrompt: () => "mock-system-prompt",
507
+ modelOverride: undefined,
506
508
  ...(drainQueue ? { drainQueue } : {}),
507
509
  };
508
510
 
@@ -693,8 +695,8 @@ describe("wakeAgentForOpportunity", () => {
693
695
  );
694
696
 
695
697
  expect(result.invoked).toBe(true);
696
- // The override was live on the conversation when the loop ran — the
697
- // resolveSystemPrompt callback reads this field at prompt-build time.
698
+ // The override was live on the conversation when the loop ran —
699
+ // `buildCurrentSystemPrompt` reads this field before `agentLoop.run()`.
698
700
  expect(conversation.runCalls[0]!.personaOverride).toEqual(override);
699
701
  // Applied exactly once and cleared before the wake released the
700
702
  // conversation, so a queued user turn can't build under it.