@vellumai/assistant 0.10.4-dev.202607010028.1a4efcc → 0.10.4-dev.202607010119.ad15dbb

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.
@@ -21,6 +21,10 @@ const ALLOWED_PREFIXES = {
21
21
  // Status command's daemon-down fallback needs socket path + platform.
22
22
  "../../ipc/socket-path",
23
23
  "../../util/platform",
24
+ // App version constant (leaf module; reads package.json/env, no daemon
25
+ // deps) — status prints it to surface CLI-vs-runtime version drift.
26
+ "../../version",
27
+ "../../../version",
24
28
  // Logger / output at depth-1 and depth-2.
25
29
  "../logger",
26
30
  "../output",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.4-dev.202607010028.1a4efcc",
3
+ "version": "0.10.4-dev.202607010119.ad15dbb",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -37,22 +37,16 @@ const MEMORY_DIR = join(ASSISTANT_SRC, "plugins", "defaults", "memory");
37
37
  * TECH DEBT — residual `persistence/` → `memory/` feature back-imports.
38
38
  *
39
39
  * A genuine coupling to the memory *feature* that violates the one-way
40
- * memory → persistence direction, pinned here so the guard fails the moment a
41
- * NEW back-import is introduced.
42
- *
43
- * The lone remaining entry is the in-worker memory consolidation /
44
- * graph-maintenance SCHEDULER (`jobs-worker.ts` `v2/consolidation-job` for
45
- * `countBufferLines`). Inverting it means memory deciding what to enqueue on
46
- * each worker tick — that is roadmap bullet #4 (memory-jobs → schedule-jobs),
47
- * deferred until the Schedules worker + `/workspace/schedules` API land.
48
- * Decouple it then; do not add to this list without a decoupling plan.
40
+ * memory → persistence direction would be pinned here so the guard fails the
41
+ * moment a NEW back-import is introduced. It is currently EMPTY: persistence
42
+ * reaches the memory feature only through the `memory-lifecycle-hooks` seam
43
+ * (persistence calls registered handlers; memory never gets imported), never by
44
+ * importing memory internals. Do not add an entry without a decoupling plan.
49
45
  *
50
46
  * Keyed by the importing persistence file (relative to repo root); the value
51
47
  * is the set of allowed `memory/<specifier>` module paths it may import.
52
48
  */
53
- const PERSISTENCE_TO_MEMORY_ALLOWLIST: Record<string, ReadonlySet<string>> = {
54
- "assistant/src/persistence/jobs-worker.ts": new Set(["v2/consolidation-job"]),
55
- };
49
+ const PERSISTENCE_TO_MEMORY_ALLOWLIST: Record<string, ReadonlySet<string>> = {};
56
50
 
57
51
  /**
58
52
  * PERMANENT exception — the migration registry, NOT tech debt.
@@ -261,6 +261,7 @@ describe("per-surface disabled-state filtering", () => {
261
261
 
262
262
  test("persistence hooks no-op while the memory plugin is disabled", async () => {
263
263
  let calls = 0;
264
+ const bufferLines = 25;
264
265
  const guarded = guardPersistenceHooksByDisabledState("default-memory", {
265
266
  onMessagePersisted() {
266
267
  calls++;
@@ -270,6 +271,9 @@ describe("per-surface disabled-state filtering", () => {
270
271
  return 0;
271
272
  },
272
273
  onWorkerStartup() {},
274
+ countMemoryBufferLines() {
275
+ return bufferLines;
276
+ },
273
277
  });
274
278
  const event: MessagePersistedEvent = {
275
279
  messageId: "msg-1",
@@ -282,16 +286,21 @@ describe("per-surface disabled-state filtering", () => {
282
286
  // Enabled: the wrapped handler runs.
283
287
  await guarded.onMessagePersisted(event);
284
288
  expect(calls).toBe(1);
289
+ // The gated query returns the real buffer count while enabled.
290
+ expect(guarded.countMemoryBufferLines()).toBe(25);
285
291
 
286
292
  // Disable via sentinel — checked at call time, no restart needed.
287
293
  await createSentinel("default-memory");
288
294
  await guarded.onMessagePersisted(event);
289
295
  expect(calls).toBe(1);
296
+ // Gated: reports an empty buffer while disabled, so consolidation stays inert.
297
+ expect(guarded.countMemoryBufferLines()).toBe(0);
290
298
 
291
299
  // Re-enable.
292
300
  await removeSentinel("default-memory");
293
301
  await guarded.onMessagePersisted(event);
294
302
  expect(calls).toBe(2);
303
+ expect(guarded.countMemoryBufferLines()).toBe(25);
295
304
  });
296
305
 
297
306
  test("cleanup persistence hooks run even while the memory plugin is disabled", async () => {
@@ -307,6 +316,9 @@ describe("per-surface disabled-state filtering", () => {
307
316
  onWorkerStartup() {
308
317
  swept++;
309
318
  },
319
+ countMemoryBufferLines() {
320
+ return 0;
321
+ },
310
322
  });
311
323
 
312
324
  await createSentinel("default-memory");
@@ -334,13 +334,65 @@ describe("createSkillTool — required/type/enum validation", () => {
334
334
  BUNDLED,
335
335
  );
336
336
 
337
- const result = await tool.execute({ query: 123 }, makeContext());
337
+ const result = await tool.execute({ query: { nested: 1 } }, makeContext());
338
338
 
339
339
  expect(result.isError).toBe(true);
340
340
  expect(result.content).toContain('Invalid input for tool "test_tool"');
341
341
  expect(result.content).toContain("query must be a string");
342
342
  });
343
343
 
344
+ test("coerces finite numbers to strings before validation and passes the coerced value to the executor", async () => {
345
+ const hash = computeSkillVersionHash(tempDir);
346
+ const tool = createSkillTool(
347
+ makeEntry({
348
+ executor: "echo.ts",
349
+ input_schema: {
350
+ type: "object",
351
+ properties: { phone_number: { type: "string" } },
352
+ required: ["phone_number"],
353
+ },
354
+ }),
355
+ tempDir,
356
+ hash,
357
+ BUNDLED,
358
+ );
359
+
360
+ const result = await tool.execute(
361
+ { phone_number: 15550100 },
362
+ makeContext(),
363
+ );
364
+
365
+ expect(result.isError).toBe(false);
366
+ const parsed = JSON.parse(result.content);
367
+ expect(parsed.input).toEqual({ phone_number: "15550100" });
368
+ });
369
+
370
+ test("rejects integers outside the safe range instead of coercing a rounded value", async () => {
371
+ const hash = computeSkillVersionHash(tempDir);
372
+ const tool = createSkillTool(
373
+ makeEntry({
374
+ executor: "echo.ts",
375
+ input_schema: {
376
+ type: "object",
377
+ properties: { account_id: { type: "string" } },
378
+ required: ["account_id"],
379
+ },
380
+ }),
381
+ tempDir,
382
+ hash,
383
+ BUNDLED,
384
+ );
385
+
386
+ const result = await tool.execute(
387
+ { account_id: 12345678901234567890 },
388
+ makeContext(),
389
+ );
390
+
391
+ expect(result.isError).toBe(true);
392
+ expect(result.content).toContain('Invalid input for tool "test_tool"');
393
+ expect(result.content).toContain("account_id must be a string");
394
+ });
395
+
344
396
  test("rejects enum violation with `must be one of` message", async () => {
345
397
  const hash = computeSkillVersionHash(tempDir);
346
398
  const tool = createSkillTool(
@@ -51,6 +51,12 @@ mock.module("../../../util/platform.js", () => ({
51
51
  getWorkspaceDirDisplay: () => "~/.vellum/workspace",
52
52
  }));
53
53
 
54
+ // Fixed installed CLI version so drift-vs-match is deterministic.
55
+ const INSTALLED_CLI_VERSION = "9.9.9";
56
+ mock.module("../../../version.js", () => ({
57
+ APP_VERSION: INSTALLED_CLI_VERSION,
58
+ }));
59
+
54
60
  mock.module("../../../util/logger.js", () => ({
55
61
  getLogger: () => ({
56
62
  info: () => {},
@@ -182,13 +188,13 @@ describe("status command — daemon unreachable (ENOENT/ECONNREFUSED)", () => {
182
188
  expect(stdout).toContain("Assistant: running");
183
189
  });
184
190
 
185
- test("does not print version or memory when daemon is unreachable", async () => {
191
+ test("prints the installed CLI version but no runtime health when unreachable", async () => {
186
192
  mockResponse = { ok: false, error: DAEMON_UNREACHABLE_ERROR };
187
193
  socketExists = false;
188
194
 
189
195
  const { stdout, stderr } = await runStatusCommand();
190
196
 
191
- expect(stdout + stderr).not.toContain("Version");
197
+ expect(stdout).toContain(`CLI Version: ${INSTALLED_CLI_VERSION}`);
192
198
  expect(stdout + stderr).not.toContain("Memory");
193
199
  });
194
200
  });
@@ -242,8 +248,42 @@ describe("status command — daemon up", () => {
242
248
  const { stdout, stderr } = await runStatusCommand();
243
249
  const combined = stdout + stderr;
244
250
 
251
+ expect(combined).toContain("Assistant Version");
245
252
  expect(combined).toContain("1.2.3");
246
253
  expect(combined).toContain("100");
247
254
  expect(combined).toContain("500");
248
255
  });
256
+
257
+ test("flags the runtime as stale when it differs from the installed CLI", async () => {
258
+ mockResponse = {
259
+ ok: true,
260
+ result: {
261
+ version: "1.2.3",
262
+ memory: { currentMb: 100, maxMb: 500 },
263
+ disk: null,
264
+ },
265
+ };
266
+
267
+ const { stdout } = await runStatusCommand();
268
+
269
+ expect(stdout).toContain(
270
+ `1.2.3 (stale — restart to run ${INSTALLED_CLI_VERSION})`,
271
+ );
272
+ });
273
+
274
+ test("shows a bare version with no stale note when versions match", async () => {
275
+ mockResponse = {
276
+ ok: true,
277
+ result: {
278
+ version: INSTALLED_CLI_VERSION,
279
+ memory: { currentMb: 100, maxMb: 500 },
280
+ disk: null,
281
+ },
282
+ };
283
+
284
+ const { stdout } = await runStatusCommand();
285
+
286
+ expect(stdout).toContain(`Assistant Version ${INSTALLED_CLI_VERSION}`);
287
+ expect(stdout).not.toContain("stale");
288
+ });
249
289
  });
@@ -5,6 +5,7 @@ import type { Command } from "commander";
5
5
  import { cliIpcCall } from "../../ipc/cli-client.js";
6
6
  import { getAssistantSocketPath } from "../../ipc/socket-path.js";
7
7
  import { getWorkspaceDirDisplay } from "../../util/platform.js";
8
+ import { APP_VERSION } from "../../version.js";
8
9
  import { registerCommand } from "../lib/register-command.js";
9
10
 
10
11
  interface HealthResponse {
@@ -34,6 +35,9 @@ export function registerStatusCommand(program: Command): void {
34
35
  const socketPath = getAssistantSocketPath();
35
36
  const socketExists = existsSync(socketPath);
36
37
  const workspace = getWorkspaceDirDisplay();
38
+ // Daemon unreachable, so its runtime version is unknown; show the
39
+ // installed CLI version so there's always one reliable number.
40
+ process.stdout.write(`CLI Version: ${APP_VERSION}\n`);
37
41
  process.stdout.write(
38
42
  (socketExists ? "Assistant: running" : "Assistant: down") + "\n",
39
43
  );
@@ -52,8 +56,15 @@ export function registerStatusCommand(program: Command): void {
52
56
  const h = result.result;
53
57
  const workspace = getWorkspaceDirDisplay();
54
58
 
59
+ // h.version is the running runtime; APP_VERSION is the installed CLI.
60
+ // They drift mid-upgrade (CLI bumped, daemon not yet restarted).
61
+ const runtimeVersion =
62
+ h.version === APP_VERSION
63
+ ? h.version
64
+ : `${h.version} (stale — restart to run ${APP_VERSION})`;
65
+
55
66
  const rows: [string, string][] = [
56
- ["Version", h.version],
67
+ ["Assistant Version", runtimeVersion],
57
68
  ["Workspace", workspace],
58
69
  ["", ""],
59
70
  ["Memory", `${fmtMb(h.memory.currentMb)} / ${fmtMb(h.memory.maxMb)}`],
@@ -0,0 +1,77 @@
1
+ import { afterAll, afterEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ function makeLoggerStub(): Record<string, unknown> {
4
+ const stub: Record<string, unknown> = {};
5
+ for (const m of [
6
+ "info",
7
+ "warn",
8
+ "error",
9
+ "debug",
10
+ "trace",
11
+ "fatal",
12
+ "silent",
13
+ "child",
14
+ ]) {
15
+ stub[m] = m === "child" ? () => makeLoggerStub() : () => {};
16
+ }
17
+ return stub;
18
+ }
19
+
20
+ mock.module("../../util/logger.js", () => ({
21
+ getLogger: () => makeLoggerStub(),
22
+ }));
23
+
24
+ afterAll(() => {
25
+ mock.restore();
26
+ });
27
+
28
+ import { setOverridesForTesting } from "../../__tests__/feature-flag-test-helpers.js";
29
+ import { getMessagesSearchBackend } from "../assistant-feature-flags.js";
30
+ import type { AssistantConfig } from "../schema.js";
31
+
32
+ // The resolver reads the flag from the override cache + registry; it does not
33
+ // consult the passed config, so a minimal stub is sufficient.
34
+ const CONFIG = {} as AssistantConfig;
35
+
36
+ describe("getMessagesSearchBackend", () => {
37
+ afterEach(() => {
38
+ setOverridesForTesting({});
39
+ });
40
+
41
+ // Each case seeds the override cache so the value flows through the real
42
+ // `getAssistantFeatureFlagValue` plumbing. The flag is boolean, so enabled
43
+ // (`true`) selects qdrant and disabled/unset falls back to fts5. The string
44
+ // cases are defensive: the override plumbing is typed `boolean | string`, so
45
+ // a stray string could still arrive — only the exact `"qdrant"` selects
46
+ // qdrant; every other value falls back to the safe fts5 default.
47
+
48
+ test("defaults to fts5 when the flag is unset", () => {
49
+ setOverridesForTesting({});
50
+ expect(getMessagesSearchBackend(CONFIG)).toBe("fts5");
51
+ });
52
+
53
+ test("returns qdrant when the flag is enabled (boolean true)", () => {
54
+ setOverridesForTesting({ "messages-search-backend": true });
55
+ expect(getMessagesSearchBackend(CONFIG)).toBe("qdrant");
56
+ });
57
+
58
+ test("returns fts5 when the flag is disabled (boolean false)", () => {
59
+ setOverridesForTesting({ "messages-search-backend": false });
60
+ expect(getMessagesSearchBackend(CONFIG)).toBe("fts5");
61
+ });
62
+
63
+ test('defensively selects qdrant for a stray string "qdrant" override', () => {
64
+ setOverridesForTesting({ "messages-search-backend": "qdrant" });
65
+ expect(getMessagesSearchBackend(CONFIG)).toBe("qdrant");
66
+ });
67
+
68
+ test('defensively falls back to fts5 for a stray string "fts5" override', () => {
69
+ setOverridesForTesting({ "messages-search-backend": "fts5" });
70
+ expect(getMessagesSearchBackend(CONFIG)).toBe("fts5");
71
+ });
72
+
73
+ test("defensively falls back to fts5 for an unexpected non-empty string", () => {
74
+ setOverridesForTesting({ "messages-search-backend": "bogus" });
75
+ expect(getMessagesSearchBackend(CONFIG)).toBe("fts5");
76
+ });
77
+ });
@@ -326,3 +326,25 @@ export function isAssistantFeatureFlagEnabled(
326
326
  ): boolean {
327
327
  return !!getAssistantFeatureFlagValue(key, config);
328
328
  }
329
+
330
+ /**
331
+ * Backends that resolve lexical message-content search.
332
+ * fts5 — SQLite FTS5 full-text index (default)
333
+ * qdrant — BM25-style sparse Qdrant lexical index (messages_lexical)
334
+ */
335
+ export type MessagesSearchBackend = "fts5" | "qdrant";
336
+
337
+ /**
338
+ * Resolve the active messages search backend from the boolean
339
+ * `messages-search-backend` flag. Disabled (default) ⇒ `fts5`; enabled
340
+ * (boolean `true`) ⇒ `qdrant`. The raw comparison also treats a stray
341
+ * string override of `"qdrant"` as enabled; every other value (`false`,
342
+ * `"fts5"`, any other string, undefined) falls back to `fts5` so the safe
343
+ * default always wins for an unexpected override.
344
+ */
345
+ export function getMessagesSearchBackend(
346
+ config: AssistantConfig,
347
+ ): MessagesSearchBackend {
348
+ const raw = getAssistantFeatureFlagValue("messages-search-backend", config);
349
+ return raw === "qdrant" || raw === true ? "qdrant" : "fts5";
350
+ }
@@ -409,6 +409,14 @@
409
409
  "label": "New-thread suggestions library",
410
410
  "description": "Replace the empty-state conversation-starter chips with the scrollable, categorized suggestions library and detail drawer (currently mocked data).",
411
411
  "defaultEnabled": false
412
+ },
413
+ {
414
+ "id": "messages-search-backend",
415
+ "scope": "assistant",
416
+ "key": "messages-search-backend",
417
+ "label": "Messages Search Backend",
418
+ "description": "When disabled (default), message content search uses SQLite FTS5. When enabled, it uses the sparse Qdrant lexical index.",
419
+ "defaultEnabled": false
412
420
  }
413
421
  ]
414
422
  }
@@ -0,0 +1,103 @@
1
+ import { afterAll, describe, expect, mock, test } from "bun:test";
2
+
3
+ import type { SparseEmbedding } from "../embeddings/embedding-types.js";
4
+ import type { MessageLexicalSearchResult } from "../embeddings/messages-lexical-index.js";
5
+
6
+ const SPARSE: SparseEmbedding = { indices: [1, 2, 3], values: [0.5, 0.5, 0.5] };
7
+ const EMPTY_SPARSE: SparseEmbedding = { indices: [], values: [] };
8
+
9
+ // Mirror the real encoder: input that tokenizes to nothing (only whitespace
10
+ // or punctuation) yields an empty sparse vector.
11
+ const generateSparseEmbeddingMock = mock(
12
+ (text: string): SparseEmbedding =>
13
+ /[\p{L}\p{N}]/u.test(text) ? SPARSE : EMPTY_SPARSE,
14
+ );
15
+
16
+ const searchLexicalMock = mock(
17
+ async (
18
+ _sparse: SparseEmbedding,
19
+ _limit: number,
20
+ _opts?: { conversationId?: string },
21
+ ): Promise<MessageLexicalSearchResult[]> => [
22
+ { messageId: "msg-1", score: 0.9 },
23
+ { messageId: "msg-2", score: 0.4 },
24
+ ],
25
+ );
26
+
27
+ const getMessagesLexicalIndexMock = mock(() => ({
28
+ searchLexical: searchLexicalMock,
29
+ }));
30
+
31
+ // `embedding-backend` is mocked WITHOUT spreading the real module: importing
32
+ // it transitively pulls provider/security modules (and `packages/service-
33
+ // contracts` → `zod`) into the test for no benefit. The helper only imports
34
+ // `generateSparseEmbedding`, so a targeted replacement is complete for this
35
+ // file's needs.
36
+ mock.module("../embeddings/embedding-backend.js", () => ({
37
+ generateSparseEmbedding: generateSparseEmbeddingMock,
38
+ }));
39
+
40
+ // `messages-lexical-index` IS spread from the real module. Bun's
41
+ // `mock.module` is process-global and is not undone by `mock.restore()`, so a
42
+ // partial replacement would drop the module's other real exports
43
+ // (`messagePointId`, `initMessagesLexicalIndex`, `MessagesLexicalIndex`, …)
44
+ // for any test that runs later in the same process (e.g.
45
+ // `embeddings/__tests__/messages-lexical-index.test.ts`, which imports
46
+ // `messagePointId`). Spreading keeps those intact; its deps
47
+ // (`@qdrant/js-client-rest`, `uuid`) are installed, so importing it is safe.
48
+ const actualLexicalIndex =
49
+ await import("../embeddings/messages-lexical-index.js");
50
+ mock.module("../embeddings/messages-lexical-index.js", () => ({
51
+ ...actualLexicalIndex,
52
+ getMessagesLexicalIndex: getMessagesLexicalIndexMock,
53
+ }));
54
+
55
+ const { searchMessageIdsLexical } =
56
+ await import("../conversation-search-lexical.js");
57
+
58
+ afterAll(() => {
59
+ mock.restore();
60
+ });
61
+
62
+ describe("searchMessageIdsLexical", () => {
63
+ test("encodes the query and returns the index results", async () => {
64
+ generateSparseEmbeddingMock.mockClear();
65
+ searchLexicalMock.mockClear();
66
+
67
+ const results = await searchMessageIdsLexical("hello world", 5);
68
+
69
+ expect(generateSparseEmbeddingMock).toHaveBeenCalledTimes(1);
70
+ expect(generateSparseEmbeddingMock.mock.calls[0]).toEqual(["hello world"]);
71
+
72
+ // The encoded sparse vector and limit are passed straight through.
73
+ expect(searchLexicalMock).toHaveBeenCalledTimes(1);
74
+ expect(searchLexicalMock.mock.calls[0]![0]).toBe(SPARSE);
75
+ expect(searchLexicalMock.mock.calls[0]![1]).toBe(5);
76
+
77
+ expect(results).toEqual([
78
+ { messageId: "msg-1", score: 0.9 },
79
+ { messageId: "msg-2", score: 0.4 },
80
+ ]);
81
+ });
82
+
83
+ test("passes conversationId through to the index", async () => {
84
+ searchLexicalMock.mockClear();
85
+
86
+ await searchMessageIdsLexical("scoped", 3, { conversationId: "conv-xyz" });
87
+
88
+ expect(searchLexicalMock).toHaveBeenCalledTimes(1);
89
+ expect(searchLexicalMock.mock.calls[0]![2]).toEqual({
90
+ conversationId: "conv-xyz",
91
+ });
92
+ });
93
+
94
+ test("returns [] without querying the index for an empty-after-tokenize query", async () => {
95
+ searchLexicalMock.mockClear();
96
+
97
+ const results = await searchMessageIdsLexical(" ... !!! ", 5);
98
+
99
+ expect(results).toEqual([]);
100
+ // The empty sparse vector must never reach Qdrant.
101
+ expect(searchLexicalMock).not.toHaveBeenCalled();
102
+ });
103
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Shared lexical candidate helper for message-content search.
3
+ *
4
+ * Encodes a free-text query with the local TF-IDF sparse encoder and queries
5
+ * the BM25-style sparse Qdrant lexical index (`messages_lexical`) for matching
6
+ * message ids. This is the single entry point the message-search read sites use
7
+ * when the `messages-search-backend` flag selects `qdrant`, mirroring the
8
+ * candidate set SQLite FTS5 would return.
9
+ */
10
+
11
+ import { generateSparseEmbedding } from "./embeddings/embedding-backend.js";
12
+ import {
13
+ getMessagesLexicalIndex,
14
+ type MessageLexicalSearchResult,
15
+ } from "./embeddings/messages-lexical-index.js";
16
+
17
+ /**
18
+ * Resolve message-id candidates for `query` from the Qdrant lexical index,
19
+ * ranked by sparse similarity score (highest first).
20
+ *
21
+ * This is a **pure lexical candidate generator**: it returns the top-`limit`
22
+ * message ids by sparse score with NO visibility, source, or
23
+ * active-conversation filtering (the only scoping it applies is the optional
24
+ * `conversationId` restriction). Those exclusions (active conversation,
25
+ * private conversations, non-message sources) live in SQL at the read sites.
26
+ *
27
+ * Callers that apply such post-retrieval SQL filters MUST over-fetch: pass an
28
+ * inflated `limit` (the `CONVERSATION_SEARCH_PREFETCH_MULTIPLIER` prefetch
29
+ * pattern the recall source uses) and re-limit after filtering in SQL.
30
+ * Applying the caller's real limit here first would let excluded rows consume
31
+ * the candidate slots and drop valid visible matches below the fold.
32
+ *
33
+ * @param query free-text search query
34
+ * @param limit maximum number of candidates to return
35
+ * @param opts.conversationId restrict results to a single conversation
36
+ */
37
+ export async function searchMessageIdsLexical(
38
+ query: string,
39
+ limit: number,
40
+ opts?: { conversationId?: string },
41
+ ): Promise<MessageLexicalSearchResult[]> {
42
+ const sparse = generateSparseEmbedding(query);
43
+ // A query that tokenizes to nothing (whitespace/punctuation-only) yields an
44
+ // empty sparse vector; matching the FTS paths and other sparse callers,
45
+ // return no candidates without querying Qdrant on an empty vector.
46
+ if (sparse.indices.length === 0) return [];
47
+ return getMessagesLexicalIndex().searchLexical(sparse, limit, opts);
48
+ }
@@ -1,5 +1,3 @@
1
- import { join } from "node:path";
2
-
3
1
  import { getConfig } from "../config/loader.js";
4
2
  import { isMemoryV3Live } from "../config/memory-v3-gate.js";
5
3
  import type { AssistantConfig } from "../config/types.js";
@@ -8,9 +6,7 @@ import {
8
6
  diskPressureBackgroundSkipLogFields,
9
7
  shouldLogDiskPressureBackgroundSkip,
10
8
  } from "../daemon/disk-pressure-background-gate.js";
11
- import { countBufferLines } from "../plugins/defaults/memory/v2/consolidation-job.js";
12
9
  import { getLogger } from "../util/logger.js";
13
- import { getWorkspaceDir } from "../util/platform.js";
14
10
  import { getMemoryCheckpoint, setMemoryCheckpoint } from "./checkpoints.js";
15
11
  import {
16
12
  getLastScheduledCleanupEnqueueMs,
@@ -796,8 +792,10 @@ export function maybeEnqueueGraphMaintenanceJobs(
796
792
  // The checkpoint advances so the next check fires after the regular
797
793
  // interval. Manual "Run now" is unaffected (routes layer, not schedule).
798
794
  if (jobType === consolidateEntry.jobType) {
799
- const bufferPath = join(getWorkspaceDir(), "memory", "buffer.md");
800
- if (countBufferLines(bufferPath) < MIN_BUFFER_LINES_FOR_CONSOLIDATION) {
795
+ if (
796
+ getMemoryPersistenceHooks().countMemoryBufferLines() <
797
+ MIN_BUFFER_LINES_FOR_CONSOLIDATION
798
+ ) {
801
799
  log.debug(
802
800
  "Scheduled consolidation skipped: buffer under minimum line threshold",
803
801
  );
@@ -830,8 +828,7 @@ export function maybeEnqueueGraphMaintenanceJobs(
830
828
  maxLines !== null &&
831
829
  !hasActiveJobOfType(consolidateEntry.jobType)
832
830
  ) {
833
- const bufferPath = join(getWorkspaceDir(), "memory", "buffer.md");
834
- if (countBufferLines(bufferPath) >= maxLines) {
831
+ if (getMemoryPersistenceHooks().countMemoryBufferLines() >= maxLines) {
835
832
  enqueueMemoryJob(
836
833
  consolidateEntry.jobType,
837
834
  AUTOMATIC_CONSOLIDATION_JOB_PAYLOAD,
@@ -24,6 +24,9 @@ const baseHooks: MemoryPersistenceHooks = {
24
24
  return 0;
25
25
  },
26
26
  onWorkerStartup() {},
27
+ countMemoryBufferLines() {
28
+ return 0;
29
+ },
27
30
  };
28
31
 
29
32
  describe("memory persistence-lifecycle seam", () => {
@@ -34,6 +37,18 @@ describe("memory persistence-lifecycle seam", () => {
34
37
  await getMemoryPersistenceHooks().onMessagePersisted(event);
35
38
  });
36
39
 
40
+ test("countMemoryBufferLines defaults to 0 when memory is not present", () => {
41
+ expect(getMemoryPersistenceHooks().countMemoryBufferLines()).toBe(0);
42
+ });
43
+
44
+ test("countMemoryBufferLines returns the registered implementation's count", () => {
45
+ registerMemoryPersistenceHooks({
46
+ ...baseHooks,
47
+ countMemoryBufferLines: () => 42,
48
+ });
49
+ expect(getMemoryPersistenceHooks().countMemoryBufferLines()).toBe(42);
50
+ });
51
+
37
52
  test("getMemoryPersistenceHooks returns the registered implementation", async () => {
38
53
  const seen: MessagePersistedEvent[] = [];
39
54
  registerMemoryPersistenceHooks({
@@ -2,7 +2,8 @@ import type { TrustClass } from "../runtime/actor-trust-resolver.js";
2
2
  import type { DrizzleDb } from "./db-connection.js";
3
3
 
4
4
  /**
5
- * Seam for the memory feature to react to persistence-layer lifecycle events.
5
+ * Seam for the memory feature to react to persistence-layer lifecycle events
6
+ * and to answer memory-domain queries the persistence layer needs.
6
7
  *
7
8
  * Persistence is a layer below the memory plugin, so it cannot import memory
8
9
  * internals directly. Instead it calls into this registered-handler seam: the
@@ -89,6 +90,15 @@ export interface MemoryPersistenceHooks {
89
90
  * wraps it in try/catch. Cleanup — runs even while the plugin is disabled.
90
91
  */
91
92
  onWorkerStartup(): void;
93
+
94
+ /**
95
+ * Current line count of the memory buffer (`memory/buffer.md`). The
96
+ * maintenance scheduler reads it to gate scheduled consolidation — skip a
97
+ * scheduled run below a floor, force one above a ceiling. Returns 0 when
98
+ * memory is not present, which the scheduler reads as "no buffered work" and
99
+ * therefore no consolidation.
100
+ */
101
+ countMemoryBufferLines(): number;
92
102
  }
93
103
 
94
104
  const NOOP: MemoryPersistenceHooks = {
@@ -98,6 +108,9 @@ const NOOP: MemoryPersistenceHooks = {
98
108
  return 0;
99
109
  },
100
110
  onWorkerStartup() {},
111
+ countMemoryBufferLines() {
112
+ return 0;
113
+ },
101
114
  };
102
115
 
103
116
  let current: MemoryPersistenceHooks = NOOP;
@@ -527,6 +527,14 @@ export function guardPersistenceHooksByDisabledState(
527
527
  if (isPluginDisabled(pluginName)) return;
528
528
  return hooks.onConversationForked(event);
529
529
  },
530
+ // Gated like the active side effects above: a disabled plugin reports an
531
+ // empty buffer, so the maintenance scheduler treats it as "no buffered
532
+ // work" and skips consolidation — matching how disabled injectors/hooks go
533
+ // inert.
534
+ countMemoryBufferLines() {
535
+ if (isPluginDisabled(pluginName)) return 0;
536
+ return hooks.countMemoryBufferLines();
537
+ },
530
538
  // Cleanup hooks are NOT gated on disabled-state: they must run even while
531
539
  // the plugin is disabled, or jobs/conversations created while it was
532
540
  // enabled would be orphaned.
@@ -57,6 +57,7 @@ afterAll(() => {
57
57
  process.env.VELLUM_WORKSPACE_DIR = previousWorkspaceEnv;
58
58
  }
59
59
  rmSync(tmpWorkspace, { recursive: true, force: true });
60
+ resetMemoryPersistenceHooksForTests();
60
61
  });
61
62
 
62
63
  const { getMemoryDb } =
@@ -70,6 +71,14 @@ const { getMemoryCheckpoint, setMemoryCheckpoint, deleteMemoryCheckpoint } =
70
71
  await import("../../../../persistence/checkpoints.js");
71
72
  const { maybeEnqueueGraphMaintenanceJobs } =
72
73
  await import("../../../../persistence/jobs-worker.js");
74
+ const { registerMemoryPersistenceHooks, resetMemoryPersistenceHooksForTests } =
75
+ await import("../../../../persistence/memory-lifecycle-hooks.js");
76
+ const { memoryPersistenceHooks } = await import("../persistence-hooks.js");
77
+
78
+ // The scheduler reads the memory buffer's line count through the persistence
79
+ // seam; register the real memory implementation so `countMemoryBufferLines`
80
+ // reflects the buffer files these tests write.
81
+ registerMemoryPersistenceHooks(memoryPersistenceHooks);
73
82
 
74
83
  const CONSOLIDATE_CHECKPOINT_KEY = "memory_v2_consolidate_last_run";
75
84
 
@@ -1,9 +1,12 @@
1
+ import { join } from "node:path";
2
+
1
3
  import { getConfig } from "../../../config/loader.js";
2
4
  import type {
3
5
  ConversationForkedEvent,
4
6
  MemoryPersistenceHooks,
5
7
  MessagePersistedEvent,
6
8
  } from "../../../persistence/memory-lifecycle-hooks.js";
9
+ import { getWorkspaceDir } from "../../../util/platform.js";
7
10
  import { forkGraphMemoryState } from "./graph/graph-memory-state-store.js";
8
11
  import { indexMessageNow } from "./indexer.js";
9
12
  import { sweepOrphanMemoryRetrospectiveConversations } from "./memory-retrospective-startup-cleanup.js";
@@ -13,6 +16,7 @@ import {
13
16
  forkActivationState,
14
17
  seedForkActivationState,
15
18
  } from "./v2/activation-store.js";
19
+ import { countBufferLines } from "./v2/consolidation-job.js";
16
20
  import {
17
21
  extractInjectedConceptSlugs,
18
22
  readInjectedBlock,
@@ -116,4 +120,8 @@ export const memoryPersistenceHooks: MemoryPersistenceHooks = {
116
120
  onWorkerStartup(): void {
117
121
  sweepOrphanMemoryRetrospectiveConversations();
118
122
  },
123
+
124
+ countMemoryBufferLines(): number {
125
+ return countBufferLines(join(getWorkspaceDir(), "memory", "buffer.md"));
126
+ },
119
127
  };
@@ -102,6 +102,53 @@ export function coerceStringBooleans(
102
102
  return coerced ?? input;
103
103
  }
104
104
 
105
+ /**
106
+ * Coerce finite JSON numbers to strings for properties the schema declares as
107
+ * `type: "string"`.
108
+ *
109
+ * Some providers' models emit numeric-looking string values as unquoted JSON
110
+ * numbers (e.g. a phone number `15550100` instead of `"+15550100"`).
111
+ * Plain `JSON.parse` yields a JS `Number`, which then fails the validator's
112
+ * `typeof === "string"` check with a confusing "must be a string" error that
113
+ * sends the model down a wrong retry path. The value's intent is unambiguous —
114
+ * a string field received a number — so coerce it the same way we coerce
115
+ * string-encoded booleans. The E.164 / format / enum checks downstream still
116
+ * run on the coerced string, so a number missing a `+` prefix still gets a
117
+ * self-correcting format error rather than a type error.
118
+ *
119
+ * Only finite numbers are coerced; `NaN`/`Infinity` (which can't come from
120
+ * JSON.parse anyway) and non-number types are left untouched. Integers outside
121
+ * the safe range are also left untouched: `JSON.parse` has already rounded them
122
+ * (e.g. `12345678901234567890` → `12345678901234567000`), so `String()` would
123
+ * emit a corrupted identifier. Leaving them un-coerced makes the validator
124
+ * return a type error, prompting the model to retry with a quoted string that
125
+ * preserves every digit. Pure: returns a new object when a coercion applies,
126
+ * otherwise returns `input` unchanged. Never mutates `input` or `schema`.
127
+ */
128
+ export function coerceStringNumbers(
129
+ input: Record<string, unknown>,
130
+ schema: Record<string, unknown> | undefined,
131
+ ): Record<string, unknown> {
132
+ if (!schema) return input;
133
+ const properties = schema.properties;
134
+ if (!isPlainObject(properties)) return input;
135
+
136
+ let coerced: Record<string, unknown> | undefined;
137
+ for (const [key, rawSubSchema] of Object.entries(properties)) {
138
+ if (!isPlainObject(rawSubSchema)) continue;
139
+ if (rawSubSchema.type !== "string") continue;
140
+ const value = input[key];
141
+ if (typeof value !== "number" || !Number.isFinite(value)) continue;
142
+ // An integer beyond the safe range was already rounded by JSON.parse;
143
+ // coercing it would lock in a corrupted identifier. Skip it so validation
144
+ // fails and the model retries with a lossless quoted string.
145
+ if (Number.isInteger(value) && !Number.isSafeInteger(value)) continue;
146
+ coerced ??= { ...input };
147
+ coerced[key] = String(value);
148
+ }
149
+ return coerced ?? input;
150
+ }
151
+
105
152
  /**
106
153
  * Validate a tool input object against the (optional) JSON-schema definition
107
154
  * declared on the tool entry. Returns `{ ok: true }` if the input is valid (or
@@ -2,6 +2,7 @@ import type { SkillToolEntry } from "../../config/skills.js";
2
2
  import { RiskLevel } from "../../permissions/types.js";
3
3
  import {
4
4
  coerceStringBooleans,
5
+ coerceStringNumbers,
5
6
  validateInputAgainstSchema,
6
7
  } from "../../skills/validate-input.js";
7
8
  import type {
@@ -46,7 +47,10 @@ export function createSkillTool(
46
47
  context: ToolContext,
47
48
  ): Promise<ToolExecutionResult> {
48
49
  const schema = entry.input_schema as Record<string, unknown> | undefined;
49
- const coercedInput = coerceStringBooleans(input, schema);
50
+ const coercedInput = coerceStringNumbers(
51
+ coerceStringBooleans(input, schema),
52
+ schema,
53
+ );
50
54
  const validation = validateInputAgainstSchema(
51
55
  entry.name,
52
56
  coercedInput,
@@ -39,8 +39,16 @@ describe("deriveName", () => {
39
39
  expect(deriveName("bun run /worker.ts")).toBe("worker");
40
40
  });
41
41
 
42
- test("falls back to the interpreter when there is no script arg", () => {
43
- expect(deriveName("bun repl")).toBe("bun");
42
+ test("surfaces the arguments when an interpreter runs no script file", () => {
43
+ expect(deriveName("bun repl")).toBe("bun repl");
44
+ expect(deriveName("bun run dev")).toBe("bun run dev");
45
+ expect(deriveName("bun x prettier --write .")).toBe("bun x prettier .");
46
+ expect(deriveName("node --inspect server")).toBe("node server");
47
+ });
48
+
49
+ test("falls back to the bare interpreter when only flags follow", () => {
50
+ expect(deriveName("bun --version")).toBe("bun");
51
+ expect(deriveName("bun")).toBe("bun");
44
52
  });
45
53
 
46
54
  test("handles empty input", () => {
@@ -64,7 +64,10 @@ function scriptName(scriptPath: string): string {
64
64
  * Derive a readable name from a command line. For interpreter invocations
65
65
  * (`bun run /…/jobs/worker.ts`) the script path is far more useful than the
66
66
  * interpreter name, so prefer the first script-looking argument and summarize it
67
- * as `<parent-dir>-<filename>` (e.g. `jobs-worker`). Plain binaries
67
+ * as `<parent-dir>-<filename>` (e.g. `jobs-worker`). When an interpreter is run
68
+ * without a script file (`bun run dev`, `bun x prettier`, `bun repl`) the bare
69
+ * interpreter name says nothing about what is running, so surface the arguments
70
+ * — what was actually run — alongside it (e.g. `bun run dev`). Plain binaries
68
71
  * (`/…/vellum-qdrant`) keep their bare executable name.
69
72
  */
70
73
  export function deriveName(command: string): string {
@@ -73,8 +76,13 @@ export function deriveName(command: string): string {
73
76
 
74
77
  const argv0 = basename(tokens[0]);
75
78
  if (RUNTIMES.has(argv0)) {
76
- const script = tokens.slice(1).find((t) => SCRIPT_EXT_RE.test(t));
79
+ const args = tokens.slice(1);
80
+ const script = args.find((t) => SCRIPT_EXT_RE.test(t));
77
81
  if (script) return scriptName(script);
82
+ // No script to summarize: show the non-flag arguments so the entry reads as
83
+ // "what was run" rather than an opaque `bun`. Flags are dropped as noise.
84
+ const meaningful = args.filter((t) => !t.startsWith("-"));
85
+ if (meaningful.length > 0) return `${argv0} ${meaningful.join(" ")}`;
78
86
  }
79
87
  return argv0;
80
88
  }