@vellumai/assistant 0.11.1-dev.202608031840.e66a55c → 0.11.1-dev.202608032034.aebd2da

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.
package/eslint.config.mjs CHANGED
@@ -49,14 +49,29 @@ const eslintConfig = defineConfig([
49
49
  "@typescript-eslint/no-explicit-any": "off",
50
50
  },
51
51
  },
52
- // Managed-column profile resolution (`getEffectiveProfile(s)`) resolves
53
- // default profiles against the vellum column only, ignoring
54
- // `llm.defaultProvider`; on a BYO install that is not the body that
55
- // dispatches. Runtime code must use `resolveDefaultProfileForProvider` /
56
- // `getEffectiveProfilesForProvider` instead. The allowlist below is the
57
- // full set of intentional managed-column consumers: the catalog itself,
58
- // hatch-time seeding (writes managed stubs by design), and write-path
59
- // validation.
52
+ // Two unrelated import bans share one `no-restricted-imports` entry on
53
+ // purpose: flat config replaces rule options rather than merging them, so a
54
+ // second block setting the same rule over overlapping files would silently
55
+ // drop the first one's patterns.
56
+ //
57
+ // 1. Managed-column profile resolution (`getEffectiveProfile(s)`) resolves
58
+ // default profiles against the vellum column only, ignoring
59
+ // `llm.defaultProvider`; on a BYO install that is not the body that
60
+ // dispatches. Runtime code must use `resolveDefaultProfileForProvider` /
61
+ // `getEffectiveProfilesForProvider` instead. The allowlist below is the
62
+ // full set of intentional managed-column consumers: the catalog itself,
63
+ // hatch-time seeding (writes managed stubs by design), and write-path
64
+ // validation.
65
+ //
66
+ // 2. `cli/logger` is the plain-stdout writer for short-lived `assistant …`
67
+ // invocations. It has no level filtering and no log-file routing, so a
68
+ // daemon-side module reaching for it writes debug output straight to a
69
+ // stdout the daemon may not own. Daemon code uses `getLogger()` from
70
+ // `util/logger.js`. The pattern matches on the import specifier, so
71
+ // CLI-internal files (which reach their logger relatively, as
72
+ // `../logger.js`) are unaffected. The companion guard test
73
+ // `src/__tests__/cli-logger-boundary-guard.test.ts` enforces the same
74
+ // boundary by resolved path, independent of how a specifier is spelled.
60
75
  {
61
76
  files: ["src/**/*.ts"],
62
77
  ignores: [
@@ -77,6 +92,11 @@ const eslintConfig = defineConfig([
77
92
  message:
78
93
  "Managed-column resolution ignores llm.defaultProvider. Use resolveDefaultProfileForProvider / getEffectiveProfilesForProvider with the parsed config's llm.defaultProvider ?? null.",
79
94
  },
95
+ {
96
+ group: ["**/cli/logger.js"],
97
+ message:
98
+ "cli/logger writes unfiltered plain text to stdout and is for src/cli/ only. Daemon and shared modules must use getLogger() from util/logger.js so output is level-filtered and routed to the log file. Within src/cli/, import the logger relatively (../logger.js).",
99
+ },
80
100
  ],
81
101
  },
82
102
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.1-dev.202608031840.e66a55c",
3
+ "version": "0.11.1-dev.202608032034.aebd2da",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,87 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { describe, expect, test } from "bun:test";
4
+
5
+ import { Glob } from "bun";
6
+
7
+ /**
8
+ * Guard test for the CLI-logger boundary.
9
+ *
10
+ * `src/cli/logger.ts` is the plain-stdout writer for short-lived `assistant …`
11
+ * invocations. It has no level filtering and no log-file routing, so a
12
+ * daemon-side module that reaches for it writes debug output straight to a
13
+ * stdout the daemon may not own. When the daemon runs detached and its
14
+ * spawning parent has closed the read end of the stdout pipe, those writes
15
+ * fail with `EPIPE` and take the process down through the fatal-error handler.
16
+ *
17
+ * Daemon and shared modules use `getLogger()` from `util/logger.js`, which
18
+ * routes to the rotating log file and skips stdout entirely when stdout is not
19
+ * a TTY.
20
+ *
21
+ * `no-restricted-imports` in `eslint.config.mjs` bans this too, but matches on
22
+ * the import specifier. This guard resolves each relative import to a path, so
23
+ * it holds regardless of how the specifier is spelled.
24
+ */
25
+
26
+ /** Tests run from `assistant/`. */
27
+ const ASSISTANT_ROOT = process.cwd();
28
+ const CLI_LOGGER_PATH = resolve(ASSISTANT_ROOT, "src/cli/logger.ts");
29
+
30
+ /** Modules allowed to import the CLI logger: the CLI itself, and tests. */
31
+ function isExempt(relPath: string): boolean {
32
+ return (
33
+ relPath.startsWith("src/cli/") ||
34
+ relPath.includes("/__tests__/") ||
35
+ relPath.endsWith(".test.ts")
36
+ );
37
+ }
38
+
39
+ function findCliLoggerImporters(): string[] {
40
+ const pattern = /\b(?:from|import)\s*\(?\s*["'](\.[^"']*logger\.js)["']/g;
41
+ const violations = new Set<string>();
42
+
43
+ for (const relPath of new Glob("src/**/*.ts").scanSync({
44
+ cwd: ASSISTANT_ROOT,
45
+ })) {
46
+ if (isExempt(relPath)) {
47
+ continue;
48
+ }
49
+ const filePath = join(ASSISTANT_ROOT, relPath);
50
+ for (const match of readFileSync(filePath, "utf-8").matchAll(pattern)) {
51
+ // Imports carry the compiled `.js` extension; sources are `.ts`.
52
+ const resolved = resolve(
53
+ dirname(filePath),
54
+ match[1]!.replace(/\.js$/, ".ts"),
55
+ );
56
+ if (resolved === CLI_LOGGER_PATH) {
57
+ violations.add(relPath);
58
+ break;
59
+ }
60
+ }
61
+ }
62
+ return Array.from(violations).sort();
63
+ }
64
+
65
+ describe("CLI logger boundary", () => {
66
+ test("no production module outside src/cli/ imports cli/logger", () => {
67
+ expect(findCliLoggerImporters()).toEqual([]);
68
+ });
69
+
70
+ test("the guard resolves the CLI logger it is guarding", () => {
71
+ // A typo in CLI_LOGGER_PATH would make the guard above vacuously pass.
72
+ expect(readFileSync(CLI_LOGGER_PATH, "utf-8")).toContain("getCliLogger");
73
+ });
74
+
75
+ test("pinned-tabs logs through the daemon logger", () => {
76
+ // The incident site: pinned-tab mutations run inside daemon host-browser
77
+ // request handling, on every tab pin, select, close, and invalidation.
78
+ const source = readFileSync(
79
+ join(ASSISTANT_ROOT, "src/tools/browser/pinned-tabs.ts"),
80
+ "utf-8",
81
+ );
82
+
83
+ expect(source).toContain('from "../../util/logger.js"');
84
+ expect(source).toContain('getLogger("pinned-tabs")');
85
+ expect(source).not.toContain("cli/logger");
86
+ });
87
+ });
@@ -52,6 +52,7 @@ afterAll(() => {
52
52
  });
53
53
 
54
54
  import { setStorePathForTesting } from "../../__tests__/encrypted-store-test-helpers.js";
55
+ import { enableMemoryV3LiveForNewWorkspacesMigration } from "../../workspace/migrations/105-enable-memory-v3-live-for-new-workspaces.js";
55
56
  import { invalidateConfigCache, loadConfig } from "../loader.js";
56
57
 
57
58
  // ---------------------------------------------------------------------------
@@ -221,7 +222,7 @@ describe("deployment-context embedding-provider default (via loadConfig)", () =>
221
222
  expect(config.memory.qdrant.vectorSize).toBe(384);
222
223
  });
223
224
 
224
- test("first launch seeds memory.v3 with only `live` — tuning knobs resolve from the schema, not disk", () => {
225
+ test("first launch persists nothing under memory.v3, not even `live`", () => {
225
226
  if (existsSync(CONFIG_PATH)) {
226
227
  rmSync(CONFIG_PATH, { force: true });
227
228
  }
@@ -233,16 +234,37 @@ describe("deployment-context embedding-provider default (via loadConfig)", () =>
233
234
  expect(config.memory.v3.gate.denseThreshold).toBe(0.66);
234
235
  expect(config.memory.v3.needleK).toBe(100);
235
236
 
236
- // Persisted config.json carries ONLY `live` under memory.v3 — no tuning knob
237
- // is frozen to disk, so a shipped schema-default change reaches this
238
- // assistant on its next load (mirrors the embedding-provider strip above).
237
+ // Nothing under memory.v3 is frozen to disk. Tuning knobs stay absent so a
238
+ // shipped schema-default change reaches this assistant on its next load
239
+ // (mirrors the embedding-provider strip above), and `live` stays absent so
240
+ // migration 105 can record the initial choice: 105 bails on any value
241
+ // already present, and this seed runs before workspace migrations.
239
242
  const raw = readConfig();
240
243
  const v3Raw = ((raw.memory as Record<string, unknown>).v3 ?? {}) as Record<
241
244
  string,
242
245
  unknown
243
246
  >;
244
- expect(Object.keys(v3Raw)).toEqual(["live"]);
247
+ expect(Object.keys(v3Raw)).toEqual([]);
245
248
  expect(v3Raw.gate).toBeUndefined();
246
249
  expect(v3Raw.needleK).toBeUndefined();
247
250
  });
251
+
252
+ test("migration 105 turns v3 on for a workspace this seed just created", () => {
253
+ if (existsSync(CONFIG_PATH)) {
254
+ rmSync(CONFIG_PATH, { force: true });
255
+ }
256
+ delete process.env.IS_PLATFORM;
257
+
258
+ loadConfig();
259
+ enableMemoryV3LiveForNewWorkspacesMigration.run(WORKSPACE_DIR, {
260
+ isNewWorkspace: true,
261
+ });
262
+
263
+ const raw = readConfig();
264
+ const v3Raw = ((raw.memory as Record<string, unknown>).v3 ?? {}) as Record<
265
+ string,
266
+ unknown
267
+ >;
268
+ expect(v3Raw.live).toBe(true);
269
+ });
248
270
  });
@@ -1119,17 +1119,24 @@ export function loadConfig(): AssistantConfig {
1119
1119
  // Drop the deployment-context embedding provider so it is never
1120
1120
  // persisted (see above); the schema default re-applies in memory.
1121
1121
  delete (seed.memory.embeddings as { provider?: unknown }).provider;
1122
- // Memory-v3 tuning knobs are globally-shipped defaults, not per-assistant
1123
- // config: persist only `live` (genuine per-assistant state — some
1124
- // workspaces predate the v3 migration and must not be flipped on) and let
1125
- // every tuning knob resolve from the schema on load. This way a shipped
1126
- // schema-default change reaches all assistants (mirrors the
1127
- // embedding-provider strip above); migration
1128
- // 119-strip-persisted-memory-v3-tuning-defaults handles already-seeded
1129
- // configs.
1130
- seed.memory.v3 = {
1131
- live: seed.memory.v3.live,
1132
- } as (typeof seed.memory)["v3"];
1122
+ // Nothing under memory.v3 is persisted at seed time.
1123
+ //
1124
+ // The tuning knobs are globally-shipped defaults: freezing them to
1125
+ // disk would stop a shipped default change from reaching this
1126
+ // assistant (mirrors the embedding-provider strip above; migration 119
1127
+ // strips them from configs that already carry them).
1128
+ //
1129
+ // `live` stays absent because an absent leaf is the only way to
1130
+ // express "no decision recorded". This seed runs before workspace
1131
+ // migrations, and migration 105 defers to any value already present
1132
+ // (`if ("live" in v3Config) return`), unable to tell a deliberate
1133
+ // choice from a schema default. Writing the leaf here would pin a
1134
+ // brand-new workspace to the schema default; leaving it absent lets
1135
+ // 105 decide regardless of boot ordering. The schema default still
1136
+ // applies in memory on every load, and a hatch-time
1137
+ // `memory.v3.live=false` override still wins by merging after
1138
+ // migrations.
1139
+ seed.memory.v3 = {} as (typeof seed.memory)["v3"];
1133
1140
  // Strip dataDir (runtime-derived) from the persisted config
1134
1141
  const { dataDir: _, ...persistable } = seed;
1135
1142
  writeFileSync(configPath, JSON.stringify(persistable, null, 2) + "\n");
@@ -3,9 +3,10 @@ import { existsSync } from "node:fs";
3
3
  import { getLogger } from "../util/logger.js";
4
4
  import { getDbPath } from "../util/platform.js";
5
5
  import { runAsyncSqlite } from "./db-async-query.js";
6
- import { getDb } from "./db-connection.js";
6
+ import { getDb, getSqliteFrom } from "./db-connection.js";
7
7
  import { runMigrationSteps } from "./migrations/run-migrations.js";
8
8
  import { validateMigrationState } from "./migrations/validate-migration-state.js";
9
+ import { PLANNER_OPTIMIZE_PRAGMA } from "./planner-statistics.js";
9
10
  import { migrationSteps } from "./steps.js";
10
11
 
11
12
  /**
@@ -101,6 +102,18 @@ export async function initializeDb(): Promise<{ migrationsOk: boolean }> {
101
102
  "DB migration steps complete",
102
103
  );
103
104
 
105
+ // An index a migration creates has no sqlite_stat1 entry until something
106
+ // analyzes it, which SQLite calls out as a case to handle after a schema
107
+ // change. Only boots that applied a step pay for it, and optimize analyzes
108
+ // just the tables that need it.
109
+ if (applied.length > 0) {
110
+ try {
111
+ getSqliteFrom(database).exec(PLANNER_OPTIMIZE_PRAGMA);
112
+ } catch (err) {
113
+ log.warn({ err }, "Post-migration PRAGMA optimize failed (non-fatal)");
114
+ }
115
+ }
116
+
104
117
  if (failed.length > 0) {
105
118
  log.error(
106
119
  { failedMigrations: failed, count: failed.length },
@@ -8,6 +8,7 @@ import { getMemoryCheckpoint, setMemoryCheckpoint } from "./checkpoints.js";
8
8
  import { getLastInteractiveUserMessageTimestamp } from "./conversation-crud.js";
9
9
  import { runAsyncSqlite } from "./db-async-query.js";
10
10
  import { getSqlite } from "./db-connection.js";
11
+ import { PLANNER_OPTIMIZE_PRAGMA } from "./planner-statistics.js";
11
12
 
12
13
  const log = getLogger("db-maintenance");
13
14
 
@@ -80,11 +81,11 @@ async function runDbMaintenance(): Promise<void> {
80
81
  log.warn({ err }, "Workflow run pruning failed (non-fatal)");
81
82
  }
82
83
 
83
- // Refresh the query planner's statistics. PRAGMA optimize is cheap; it is
84
- // routed through the async path for consistency and to keep it off the main
85
- // thread when the sqlite3 CLI backend is available.
84
+ // Refresh the query planner's statistics (see PLANNER_OPTIMIZE_PRAGMA for
85
+ // what the mask buys here). Routed through the async path to keep it off the
86
+ // main thread when the sqlite3 CLI backend is available.
86
87
  const optimizeResult = await runAsyncSqlite(
87
- "PRAGMA optimize",
88
+ PLANNER_OPTIMIZE_PRAGMA,
88
89
  "db-maintenance:optimize",
89
90
  );
90
91
  if (!optimizeResult.ok) {
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The `PRAGMA optimize` mask run by the daily maintenance sweep and by
3
+ * `db-init` after a boot that applied migrations.
4
+ *
5
+ * Both run on connections with no useful query history, so `0x10000` is what
6
+ * lets optimize look past tables missing from `sqlite_stat1` and reconsider
7
+ * ones whose entries have gone stale. `0x2` keeps the default "analyze where
8
+ * it helps" behavior and `0x10` bounds each ANALYZE with a temporary
9
+ * `analysis_limit`. Releases before 3.46 ignore bits they do not recognize
10
+ * rather than failing, so the mask is safe to send to any version.
11
+ *
12
+ * https://sqlite.org/lang_analyze.html#recommended_usage_patterns
13
+ */
14
+ export const PLANNER_OPTIMIZE_PRAGMA = "PRAGMA optimize=0x10012";
@@ -14,7 +14,9 @@
14
14
  * `host_browser_session_invalidated`.
15
15
  */
16
16
 
17
- import { log } from "../../cli/logger.js";
17
+ import { getLogger } from "../../util/logger.js";
18
+
19
+ const log = getLogger("pinned-tabs");
18
20
 
19
21
  const pinnedTabs = new Map<string, Map<string, string>>();
20
22