@vellumai/assistant 0.10.4-dev.202607020106.aa66e63 → 0.10.4-dev.202607020525.d9ced46

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 (43) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/attachments-store-heic-normalize.test.ts +230 -0
  3. package/src/__tests__/background-workers-disk-pressure.test.ts +1 -0
  4. package/src/__tests__/clear-all-lexical.test.ts +7 -10
  5. package/src/__tests__/context-search-conversations-source.test.ts +3 -44
  6. package/src/__tests__/heic-fixture.ts +55 -0
  7. package/src/__tests__/image-conversion.test.ts +170 -0
  8. package/src/__tests__/job-handler-registry-guard.test.ts +6 -5
  9. package/src/__tests__/lexical-index-dual-write.test.ts +19 -11
  10. package/src/__tests__/list-messages-attachments.test.ts +92 -0
  11. package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -2
  12. package/src/agent/image-optimize.ts +17 -127
  13. package/src/daemon/conversation-agent-loop-handlers.ts +122 -43
  14. package/src/daemon/conversation-history.ts +25 -9
  15. package/src/daemon/conversation-messaging.ts +1 -1
  16. package/src/daemon/conversation-turn-finalize.ts +1 -1
  17. package/src/daemon/lifecycle.ts +12 -1
  18. package/src/jobs/register-job-handlers.ts +24 -1
  19. package/src/persistence/__tests__/conversation-queries-search.test.ts +14 -15
  20. package/src/persistence/__tests__/jobs-worker-memory-disabled.test.ts +95 -0
  21. package/src/persistence/attachments-store.ts +70 -8
  22. package/src/persistence/conversation-crud.ts +7 -0
  23. package/src/persistence/conversation-queries.ts +31 -21
  24. package/src/{plugins/defaults/memory/job-handlers/__tests__/backfill-lexical-index.test.ts → persistence/job-handlers/__tests__/message-lexical-backfill.test.ts} +50 -67
  25. package/src/persistence/job-handlers/__tests__/message-lexical-enqueue.test.ts +164 -0
  26. package/src/{plugins/defaults/memory/job-handlers/__tests__/index-message-lexical.test.ts → persistence/job-handlers/__tests__/message-lexical.test.ts} +29 -35
  27. package/src/{plugins/defaults/memory/job-handlers/backfill-lexical-index.ts → persistence/job-handlers/message-lexical-backfill.ts} +23 -28
  28. package/src/{plugins/defaults/memory/job-handlers/index-message-lexical.ts → persistence/job-handlers/message-lexical.ts} +56 -103
  29. package/src/persistence/jobs-store.ts +42 -6
  30. package/src/persistence/jobs-worker.ts +54 -20
  31. package/src/plugins/defaults/memory/context-search/sources/conversations.ts +22 -15
  32. package/src/plugins/defaults/memory/job-handlers.ts +3 -23
  33. package/src/plugins/defaults/memory/persistence-hooks.ts +6 -21
  34. package/src/plugins/defaults/memory/startup.ts +0 -9
  35. package/src/runtime/routes/attachment-routes.ts +54 -3
  36. package/src/runtime/routes/conversation-routes.ts +10 -2
  37. package/src/runtime/routes/conversations-import-routes.ts +4 -2
  38. package/src/runtime/routes/inbound-message-handler.ts +1 -0
  39. package/src/runtime/routes/inbound-stages/edit-intercept.ts +4 -2
  40. package/src/runtime/routes/index.ts +1 -1
  41. package/src/{plugins/defaults/memory → runtime}/routes/messages-lexical-routes.ts +11 -14
  42. package/src/util/image-conversion.ts +268 -0
  43. package/src/plugins/defaults/memory/job-handlers/__tests__/lexical-cleanup-disabled.test.ts +0 -197
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.4-dev.202607020106.aa66e63",
3
+ "version": "0.10.4-dev.202607020525.d9ced46",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Wiring tests for HEIF/HEIC storage normalization: user-sourced ingress
3
+ * paths store a JPEG master (rewritten filename, mime, size, bytes) while
4
+ * register and assistant-outbound paths keep content verbatim.
5
+ *
6
+ * The converter module is mocked (keyed on the declared mime type) so these
7
+ * tests run identically with and without macOS sips; real conversion is
8
+ * covered in image-conversion.test.ts. mock.module is process-global — keep
9
+ * these cases in this file.
10
+ */
11
+
12
+ import { beforeAll, describe, expect, mock, test } from "bun:test";
13
+
14
+ mock.module("../util/logger.js", () => ({
15
+ getLogger: () =>
16
+ new Proxy({} as Record<string, unknown>, {
17
+ get: () => () => {},
18
+ }),
19
+ }));
20
+
21
+ mock.module("../config/loader.js", () => ({
22
+ loadConfig: () => ({}),
23
+ getConfig: () => ({}),
24
+ invalidateConfigCache: () => {},
25
+ }));
26
+
27
+ mock.module("../config/env.js", () => ({
28
+ isHttpAuthDisabled: () => true,
29
+ getAssistantDomain: () => "vellum.me",
30
+ }));
31
+
32
+ const FAKE_JPEG = Buffer.from("fake-jpeg-master-bytes");
33
+ const FAKE_JPEG_B64 = FAKE_JPEG.toString("base64");
34
+
35
+ mock.module("../util/image-conversion.js", () => ({
36
+ convertImageToJpeg: () => FAKE_JPEG,
37
+ isHeifImage: (bytes: Uint8Array) => bytes.length >= 12,
38
+ jpegFilenameFor: (filename: string) =>
39
+ `${filename.replace(/\.[^./\\]+$/, "")}.jpg`,
40
+ normalizeImageBytes: (mimeType: string, bytes: Uint8Array) =>
41
+ mimeType === "image/heic"
42
+ ? { mimeType: "image/jpeg", bytes: FAKE_JPEG, converted: true }
43
+ : { mimeType, bytes, converted: false },
44
+ normalizeImageBase64: (mimeType: string, dataBase64: string) =>
45
+ mimeType === "image/heic"
46
+ ? { mimeType: "image/jpeg", dataBase64: FAKE_JPEG_B64, converted: true }
47
+ : { mimeType, dataBase64, converted: false },
48
+ }));
49
+
50
+ import {
51
+ existsSync,
52
+ mkdirSync,
53
+ mkdtempSync,
54
+ readFileSync,
55
+ writeFileSync,
56
+ } from "node:fs";
57
+ import { tmpdir } from "node:os";
58
+ import { join } from "node:path";
59
+
60
+ import {
61
+ attachInlineAttachmentToMessage,
62
+ getAttachmentById,
63
+ getFilePathForAttachment,
64
+ uploadAttachment,
65
+ uploadAttachmentFromBytes,
66
+ } from "../persistence/attachments-store.js";
67
+ import {
68
+ addMessage,
69
+ createConversation,
70
+ } from "../persistence/conversation-crud.js";
71
+ import { initializeDb } from "../persistence/db-init.js";
72
+ import {
73
+ attachmentMetadataSchema,
74
+ ROUTES,
75
+ } from "../runtime/routes/attachment-routes.js";
76
+ import type { RouteHandlerArgs } from "../runtime/routes/types.js";
77
+ import { getWorkspaceDir } from "../util/platform.js";
78
+ import { fakeHeifHeaderBytes } from "./heic-fixture.js";
79
+
80
+ const uploadRoute = ROUTES.find((r) => r.operationId === "attachment_upload")!;
81
+ const registerRoute = ROUTES.find(
82
+ (r) => r.operationId === "attachment_register",
83
+ )!;
84
+
85
+ const HEIC_BYTES = fakeHeifHeaderBytes();
86
+ const HEIC_B64 = HEIC_BYTES.toString("base64");
87
+
88
+ function jsonUploadArgs(body: Record<string, unknown>): RouteHandlerArgs {
89
+ return {
90
+ body,
91
+ rawBody: new TextEncoder().encode(JSON.stringify(body)),
92
+ headers: { "content-type": "application/json" },
93
+ queryParams: {},
94
+ };
95
+ }
96
+
97
+ describe("HEIC upload normalization wiring", () => {
98
+ beforeAll(async () => {
99
+ await initializeDb();
100
+ }, 30_000);
101
+
102
+ test("uploadAttachmentFromBytes stores a JPEG master for HEIC", () => {
103
+ const stored = uploadAttachmentFromBytes(
104
+ "IMG_5487.HEIC",
105
+ "image/heic",
106
+ HEIC_BYTES,
107
+ );
108
+
109
+ expect(stored.originalFilename).toBe("IMG_5487.jpg");
110
+ expect(stored.mimeType).toBe("image/jpeg");
111
+ expect(stored.sizeBytes).toBe(FAKE_JPEG.length);
112
+ const stagedPath = getFilePathForAttachment(stored.id);
113
+ expect(stagedPath).not.toBeNull();
114
+ expect(readFileSync(stagedPath!).equals(FAKE_JPEG)).toBe(true);
115
+ });
116
+
117
+ test("uploadAttachmentFromBytes leaves non-HEIC uploads verbatim", () => {
118
+ const stored = uploadAttachmentFromBytes(
119
+ "photo.png",
120
+ "image/png",
121
+ HEIC_BYTES,
122
+ );
123
+
124
+ expect(stored.originalFilename).toBe("photo.png");
125
+ expect(stored.mimeType).toBe("image/png");
126
+ expect(stored.sizeBytes).toBe(HEIC_BYTES.length);
127
+ });
128
+
129
+ test("uploadAttachment (base64) stores a JPEG master for HEIC", () => {
130
+ const stored = uploadAttachment("IMG_1.heic", "image/heic", HEIC_B64);
131
+
132
+ expect(stored.originalFilename).toBe("IMG_1.jpg");
133
+ expect(stored.mimeType).toBe("image/jpeg");
134
+ expect(stored.sizeBytes).toBe(FAKE_JPEG.length);
135
+ const row = getAttachmentById(stored.id);
136
+ expect(row?.dataBase64).toBe(FAKE_JPEG_B64);
137
+ });
138
+
139
+ test("uploadAttachment (base64) leaves non-HEIC uploads verbatim", () => {
140
+ const stored = uploadAttachment("photo.png", "image/png", HEIC_B64);
141
+
142
+ expect(stored.originalFilename).toBe("photo.png");
143
+ expect(stored.mimeType).toBe("image/png");
144
+ expect(stored.sizeBytes).toBe(HEIC_BYTES.length);
145
+ });
146
+
147
+ test("attachInlineAttachmentToMessage normalizes only when opted in", async () => {
148
+ const conv = createConversation();
149
+ const msg = await addMessage(
150
+ conv.id,
151
+ "user",
152
+ JSON.stringify([{ type: "text", text: "photos" }]),
153
+ );
154
+
155
+ const normalized = attachInlineAttachmentToMessage(
156
+ msg.id,
157
+ 0,
158
+ "IMG_2.HEIC",
159
+ "image/heic",
160
+ HEIC_B64,
161
+ { normalizeImage: true },
162
+ );
163
+ expect(normalized.originalFilename).toBe("IMG_2.jpg");
164
+ expect(normalized.mimeType).toBe("image/jpeg");
165
+
166
+ // Assistant-outbound attachments are stored verbatim: no normalizeImage
167
+ // flag, no rewrite, even for HEIC content.
168
+ const verbatim = attachInlineAttachmentToMessage(
169
+ msg.id,
170
+ 1,
171
+ "IMG_3.HEIC",
172
+ "image/heic",
173
+ HEIC_B64,
174
+ );
175
+ expect(verbatim.originalFilename).toBe("IMG_3.HEIC");
176
+ expect(verbatim.mimeType).toBe("image/heic");
177
+ expect(verbatim.sizeBytes).toBe(HEIC_BYTES.length);
178
+ });
179
+
180
+ test("JSON file-path upload converts the daemon-owned copy", async () => {
181
+ const sourceDir = mkdtempSync(join(tmpdir(), "vellum-heic-src-"));
182
+ const sourcePath = join(sourceDir, "IMG_4.HEIC");
183
+ writeFileSync(sourcePath, HEIC_BYTES);
184
+
185
+ const raw = await uploadRoute.handler(
186
+ jsonUploadArgs({
187
+ filename: "IMG_4.HEIC",
188
+ mimeType: "image/heic",
189
+ filePath: sourcePath,
190
+ }),
191
+ );
192
+ const result = attachmentMetadataSchema.parse(raw);
193
+
194
+ expect(result.filename).toBe("IMG_4.jpg");
195
+ expect(result.mimeType).toBe("image/jpeg");
196
+ expect(result.sizeBytes).toBe(FAKE_JPEG.length);
197
+ // The caller's source file is never mutated.
198
+ expect(readFileSync(sourcePath).equals(HEIC_BYTES)).toBe(true);
199
+ // The staged raw copy is replaced by the converted one.
200
+ const stagedPath = getFilePathForAttachment(result.id);
201
+ expect(stagedPath).not.toBeNull();
202
+ expect(stagedPath!.endsWith(".jpg")).toBe(true);
203
+ expect(readFileSync(stagedPath!).equals(FAKE_JPEG)).toBe(true);
204
+ });
205
+
206
+ test("attachment register keeps workspace files verbatim", async () => {
207
+ const registerDir = join(getWorkspaceDir(), "register-fixtures");
208
+ mkdirSync(registerDir, { recursive: true });
209
+ const registeredPath = join(registerDir, "IMG_5.HEIC");
210
+ writeFileSync(registeredPath, HEIC_BYTES);
211
+
212
+ const raw = await registerRoute.handler(
213
+ jsonUploadArgs({
214
+ path: registeredPath,
215
+ mimeType: "image/heic",
216
+ filename: "IMG_5.HEIC",
217
+ }),
218
+ );
219
+ const result = raw as {
220
+ originalFilename: string;
221
+ mimeType: string;
222
+ };
223
+
224
+ expect(result.originalFilename).toBe("IMG_5.HEIC");
225
+ expect(result.mimeType).toBe("image/heic");
226
+ // Registered files are referenced in place, never rewritten.
227
+ expect(existsSync(registeredPath)).toBe(true);
228
+ expect(readFileSync(registeredPath).equals(HEIC_BYTES)).toBe(true);
229
+ });
230
+ });
@@ -195,6 +195,7 @@ mock.module("../persistence/jobs-store.js", () => ({
195
195
  automatic: "automatic",
196
196
  manual: "manual",
197
197
  },
198
+ MESSAGE_LEXICAL_JOB_TYPES: [],
198
199
  resetRunningJobsToPending: mock(() => 0),
199
200
  SLOW_LLM_JOB_TYPES: [],
200
201
  upsertAutoAnalysisJob: mock(() => "job-auto-analysis"),
@@ -24,17 +24,14 @@ mock.module("../util/logger.js", () => ({
24
24
  // (`@qdrant/js-client-rest`, `uuid`, the local TF-IDF encoder) resolve in the
25
25
  // worktree.
26
26
  const actualLexical =
27
- await import("../plugins/defaults/memory/job-handlers/index-message-lexical.js");
27
+ await import("../persistence/job-handlers/message-lexical.js");
28
28
  let clearCalls = 0;
29
- mock.module(
30
- "../plugins/defaults/memory/job-handlers/index-message-lexical.js",
31
- () => ({
32
- ...actualLexical,
33
- clearMessagesLexicalIndex: async () => {
34
- clearCalls += 1;
35
- },
36
- }),
37
- );
29
+ mock.module("../persistence/job-handlers/message-lexical.js", () => ({
30
+ ...actualLexical,
31
+ clearMessagesLexicalIndex: async () => {
32
+ clearCalls += 1;
33
+ },
34
+ }));
38
35
 
39
36
  import { clearAll } from "../persistence/conversation-crud.js";
40
37
  import { initializeDb } from "../persistence/db-init.js";
@@ -35,22 +35,6 @@ mock.module("../persistence/conversation-search-lexical.js", () => ({
35
35
  },
36
36
  }));
37
37
 
38
- // Drives the real recall availability gate: when true the source yields no
39
- // evidence, because the lexical index write path is suppressed and the
40
- // collection is never populated. Defaults false so every other test exercises
41
- // the live path. Spread the real module so its other exports (job handlers,
42
- // enqueue helpers) stay intact for transitive importers.
43
- let suppressIndexing = false;
44
- const realLexicalModule =
45
- await import("../plugins/defaults/memory/job-handlers/index-message-lexical.js");
46
- mock.module(
47
- "../plugins/defaults/memory/job-handlers/index-message-lexical.js",
48
- () => ({
49
- ...realLexicalModule,
50
- isMemoryIndexingSuppressed: () => suppressIndexing,
51
- }),
52
- );
53
-
54
38
  import {
55
39
  deleteMemoryCheckpoint,
56
40
  LEXICAL_BACKFILL_COMPLETE_KEY,
@@ -70,15 +54,13 @@ describe("searchConversationSource (qdrant lexical index)", () => {
70
54
  getDb().run("DELETE FROM messages");
71
55
  getDb().run("DELETE FROM conversations");
72
56
  lexicalCalls = [];
73
- suppressIndexing = false;
74
- // Content evidence is available once the index is populated: not
75
- // suppressed + backfill complete. Most tests exercise that path, so mark
76
- // the backfill complete; the gates are covered by their own tests.
57
+ // Content evidence is available once the index is populated (backfill
58
+ // complete). Most tests exercise that path, so mark the backfill
59
+ // complete; the gate is covered by its own test.
77
60
  setMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY, "1");
78
61
  });
79
62
 
80
63
  afterEach(() => {
81
- suppressIndexing = false;
82
64
  deleteMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY);
83
65
  lexicalMockImpl = () => {
84
66
  throw new Error(
@@ -143,29 +125,6 @@ describe("searchConversationSource (qdrant lexical index)", () => {
143
125
  expect(unicodeResult.evidence).toEqual([]);
144
126
  });
145
127
 
146
- test("returns no evidence when memory indexing is suppressed", async () => {
147
- await seedConversation({
148
- title: "Suppressed indexing notes",
149
- content: "The suppressedtoken decision is recorded here.",
150
- });
151
-
152
- // The lexical index is never populated while indexing is suppressed, so
153
- // the source must yield no evidence without consulting Qdrant.
154
- suppressIndexing = true;
155
- lexicalMockImpl = async () => {
156
- throw new Error("searchMessageIdsLexical must not run while suppressed");
157
- };
158
-
159
- const result = await searchConversationSource(
160
- "suppressedtoken",
161
- makeContext(),
162
- 5,
163
- );
164
-
165
- expect(lexicalCalls).toHaveLength(0);
166
- expect(result.evidence).toEqual([]);
167
- });
168
-
169
128
  test("returns no evidence until the backfill completion checkpoint is set", async () => {
170
129
  await seedConversation({
171
130
  title: "Pre-backfill notes",
@@ -0,0 +1,55 @@
1
+ /**
2
+ * HEIC fixture helpers for image-conversion tests.
3
+ *
4
+ * Real HEIC bytes can only be produced where `sips` exists (macOS), so
5
+ * consumers gate those tests with `describe.skipIf(process.platform !==
6
+ * "darwin")`. The fake-header helper works everywhere and exercises the
7
+ * conversion-failure fallback: the ftyp sniff accepts it, sips rejects it.
8
+ */
9
+
10
+ import { execFileSync } from "node:child_process";
11
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ const PNG_1PX_BASE64 =
16
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
17
+
18
+ /**
19
+ * Create real HEIC bytes via macOS `sips` (1×1 PNG upscaled to 64×64, then
20
+ * HEIC-encoded). Returns null when sips is unavailable or fails.
21
+ */
22
+ export function makeHeicFixtureBytes(): Buffer | null {
23
+ try {
24
+ const dir = mkdtempSync(join(tmpdir(), "vellum-heic-fixture-"));
25
+ const pngPath = join(dir, "src.png");
26
+ const bigPngPath = join(dir, "big.png");
27
+ const heicPath = join(dir, "out.heic");
28
+ writeFileSync(pngPath, Buffer.from(PNG_1PX_BASE64, "base64"));
29
+ execFileSync("sips", ["-z", "64", "64", pngPath, "--out", bigPngPath], {
30
+ stdio: "pipe",
31
+ });
32
+ execFileSync(
33
+ "sips",
34
+ ["-s", "format", "heic", bigPngPath, "--out", heicPath],
35
+ { stdio: "pipe" },
36
+ );
37
+ return readFileSync(heicPath) as Buffer;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * A syntactically valid HEIF ftyp header followed by nothing decodable.
45
+ * Sniffs as HEIF but fails conversion on every platform.
46
+ */
47
+ export function fakeHeifHeaderBytes(brand = "heic"): Buffer {
48
+ const buf = Buffer.alloc(24);
49
+ buf.writeUInt32BE(24, 0);
50
+ buf.write("ftyp", 4, "ascii");
51
+ buf.write(brand, 8, "ascii");
52
+ return buf;
53
+ }
54
+
55
+ export const PNG_1PX_BYTES = Buffer.from(PNG_1PX_BASE64, "base64");
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Tests for the shared sips-backed image converter.
3
+ *
4
+ * Pure logic (ftyp sniffing, filename rewriting, passthrough behavior) runs
5
+ * everywhere; actual conversion requires macOS `sips`, so those cases are
6
+ * gated on darwin.
7
+ */
8
+
9
+ import { beforeAll, describe, expect, test } from "bun:test";
10
+
11
+ import {
12
+ convertImageToJpeg,
13
+ isHeifImage,
14
+ jpegFilenameFor,
15
+ normalizeImageBase64,
16
+ normalizeImageBytes,
17
+ } from "../util/image-conversion.js";
18
+ import {
19
+ fakeHeifHeaderBytes,
20
+ makeHeicFixtureBytes,
21
+ PNG_1PX_BYTES,
22
+ } from "./heic-fixture.js";
23
+
24
+ const JPEG_MAGIC = Buffer.from([0xff, 0xd8, 0xff]);
25
+
26
+ function startsWithJpegMagic(bytes: Uint8Array): boolean {
27
+ return Buffer.from(bytes.slice(0, 3)).equals(JPEG_MAGIC);
28
+ }
29
+
30
+ describe("isHeifImage", () => {
31
+ test("accepts HEIF ftyp brands", () => {
32
+ for (const brand of ["heic", "heix", "heif", "mif1", "msf1", "hevc"]) {
33
+ expect(isHeifImage(fakeHeifHeaderBytes(brand))).toBe(true);
34
+ }
35
+ });
36
+
37
+ test("rejects AVIF brands (Chromium decodes AVIF natively)", () => {
38
+ expect(isHeifImage(fakeHeifHeaderBytes("avif"))).toBe(false);
39
+ expect(isHeifImage(fakeHeifHeaderBytes("avis"))).toBe(false);
40
+ });
41
+
42
+ test("rejects non-ISO-BMFF content", () => {
43
+ expect(isHeifImage(PNG_1PX_BYTES)).toBe(false);
44
+ expect(isHeifImage(Buffer.from("plain text content"))).toBe(false);
45
+ });
46
+
47
+ test("rejects buffers shorter than the sniff window", () => {
48
+ expect(isHeifImage(Buffer.alloc(0))).toBe(false);
49
+ expect(isHeifImage(Buffer.from("ftypheic"))).toBe(false);
50
+ });
51
+ });
52
+
53
+ describe("jpegFilenameFor", () => {
54
+ test("rewrites the extension to .jpg", () => {
55
+ expect(jpegFilenameFor("IMG_5487.HEIC")).toBe("IMG_5487.jpg");
56
+ expect(jpegFilenameFor("photo.heif")).toBe("photo.jpg");
57
+ expect(jpegFilenameFor("archive.tar.gz")).toBe("archive.tar.jpg");
58
+ });
59
+
60
+ test("keeps existing .jpg/.jpeg extensions", () => {
61
+ expect(jpegFilenameFor("photo.jpg")).toBe("photo.jpg");
62
+ expect(jpegFilenameFor("photo.JPEG")).toBe("photo.JPEG");
63
+ });
64
+
65
+ test("handles missing or empty names", () => {
66
+ expect(jpegFilenameFor("photo")).toBe("photo.jpg");
67
+ expect(jpegFilenameFor("")).toBe("attachment.jpg");
68
+ expect(jpegFilenameFor(".heic")).toBe("attachment.jpg");
69
+ });
70
+ });
71
+
72
+ describe("normalizeImageBytes passthrough", () => {
73
+ test("non-HEIF bytes pass through untouched", () => {
74
+ const result = normalizeImageBytes("image/png", PNG_1PX_BYTES);
75
+ expect(result.converted).toBe(false);
76
+ expect(result.mimeType).toBe("image/png");
77
+ expect(result.bytes).toBe(PNG_1PX_BYTES);
78
+ });
79
+
80
+ test("HEIF header with undecodable payload passes through", () => {
81
+ // sips fails (or is absent off-macOS) → the original bytes are kept.
82
+ const fake = fakeHeifHeaderBytes();
83
+ const result = normalizeImageBytes("image/heic", fake);
84
+ expect(result.converted).toBe(false);
85
+ expect(result.mimeType).toBe("image/heic");
86
+ expect(result.bytes).toBe(fake);
87
+ });
88
+ });
89
+
90
+ describe("normalizeImageBase64 passthrough", () => {
91
+ test("non-HEIF payloads skip conversion", () => {
92
+ const b64 = PNG_1PX_BYTES.toString("base64");
93
+ const result = normalizeImageBase64("image/png", b64);
94
+ expect(result.converted).toBe(false);
95
+ expect(result.dataBase64).toBe(b64);
96
+ });
97
+ });
98
+
99
+ describe.skipIf(process.platform !== "darwin")(
100
+ "real HEIC conversion (sips)",
101
+ () => {
102
+ let heicBytes: Buffer;
103
+
104
+ beforeAll(() => {
105
+ const fixture = makeHeicFixtureBytes();
106
+ if (!fixture) {
107
+ throw new Error("sips failed to produce a HEIC fixture on darwin");
108
+ }
109
+ heicBytes = fixture;
110
+ });
111
+
112
+ test("fixture sniffs as HEIF", () => {
113
+ expect(isHeifImage(heicBytes)).toBe(true);
114
+ });
115
+
116
+ test("convertImageToJpeg produces JPEG bytes", () => {
117
+ const converted = convertImageToJpeg(heicBytes);
118
+ expect(converted).not.toBeNull();
119
+ expect(startsWithJpegMagic(converted!)).toBe(true);
120
+ });
121
+
122
+ test("conversion options produce distinct outputs (cache key isolation)", () => {
123
+ // A hash-only cache key would make the second call return the first
124
+ // call's cached full-resolution output.
125
+ const fullRes = convertImageToJpeg(heicBytes, { quality: 90 });
126
+ const downscaled = convertImageToJpeg(heicBytes, {
127
+ maxDimensionPx: 16,
128
+ quality: 90,
129
+ });
130
+ expect(fullRes).not.toBeNull();
131
+ expect(downscaled).not.toBeNull();
132
+ expect(fullRes!.equals(downscaled!)).toBe(false);
133
+ });
134
+
135
+ test("repeated conversion is stable (cache round-trip)", () => {
136
+ const first = convertImageToJpeg(heicBytes, { quality: 90 });
137
+ const second = convertImageToJpeg(heicBytes, { quality: 90 });
138
+ expect(first).not.toBeNull();
139
+ expect(second).not.toBeNull();
140
+ expect(first!.equals(second!)).toBe(true);
141
+ });
142
+
143
+ test("normalizeImageBytes converts to a JPEG master", () => {
144
+ const result = normalizeImageBytes("image/heic", heicBytes);
145
+ expect(result.converted).toBe(true);
146
+ expect(result.mimeType).toBe("image/jpeg");
147
+ expect(startsWithJpegMagic(result.bytes)).toBe(true);
148
+ });
149
+
150
+ test("normalizeImageBytes converts even when the declared mime is wrong", () => {
151
+ // Chromium reports empty file.type for .heic; clients coerce it to
152
+ // application/octet-stream. Detection is content-based.
153
+ const result = normalizeImageBytes("application/octet-stream", heicBytes);
154
+ expect(result.converted).toBe(true);
155
+ expect(result.mimeType).toBe("image/jpeg");
156
+ });
157
+
158
+ test("normalizeImageBase64 converts and re-encodes", () => {
159
+ const result = normalizeImageBase64(
160
+ "image/heic",
161
+ heicBytes.toString("base64"),
162
+ );
163
+ expect(result.converted).toBe(true);
164
+ expect(result.mimeType).toBe("image/jpeg");
165
+ expect(
166
+ startsWithJpegMagic(Buffer.from(result.dataBase64, "base64")),
167
+ ).toBe(true);
168
+ });
169
+ },
170
+ );
@@ -43,21 +43,22 @@ const MEMORY_JOB_TYPES = [
43
43
  "memory_v2_activation_recompute",
44
44
  "memory_v3_maintain",
45
45
  "memory_retrospective",
46
- "index_message_lexical",
47
- "purge_conversation_lexical",
48
- "delete_message_lexical",
49
- "backfill_lexical_index",
50
46
  ].sort();
51
47
 
52
48
  /**
53
49
  * Job types wired directly by `registerMemoryJobHandlers` for domains that are
54
- * not plugins (persistence cleanup, conversations, media, home, runtime).
50
+ * not plugins (persistence cleanup, message-content lexical indexing,
51
+ * conversations, media, home, runtime).
55
52
  */
56
53
  const NON_PLUGIN_JOB_TYPES = [
57
54
  "prune_old_conversations",
58
55
  "prune_old_llm_request_logs",
59
56
  "prune_old_trace_events",
60
57
  "prune_old_tool_invocations",
58
+ "index_message_lexical",
59
+ "purge_conversation_lexical",
60
+ "delete_message_lexical",
61
+ "backfill_lexical_index",
61
62
  "build_conversation_summary",
62
63
  "media_processing",
63
64
  "conversation_analyze",
@@ -1,9 +1,9 @@
1
- // Integration coverage for the messages lexical-index dual-write wiring:
2
- // - a real persist (`addMessage` `onMessagePersisted`) enqueues one
3
- // `index_message_lexical` job for the message when memory is enabled, and
4
- // none when memory is disabled;
1
+ // Integration coverage for the messages lexical-index write wiring:
2
+ // - a real persist (`addMessage`) enqueues one `index_message_lexical` job
3
+ // for the message, regardless of whether memory is enabled or disabled
4
+ // (message-content search is host infrastructure);
5
5
  // - forking a conversation copies message rows WITHOUT routing through
6
- // `onMessagePersisted`, so a fork enqueues ZERO `index_message_lexical`
6
+ // the persist path, so a fork enqueues ZERO `index_message_lexical`
7
7
  // jobs (the fork-exclusion regression);
8
8
  // - wiping a conversation enqueues one `purge_conversation_lexical` job.
9
9
  //
@@ -31,7 +31,9 @@ let throwFromIndex = false;
31
31
  mock.module("../plugins/defaults/memory/indexer.js", () => ({
32
32
  MIN_SEGMENT_CHARS: 50,
33
33
  indexMessageNow: async () => {
34
- if (throwFromIndex) throw new Error("simulated segment-indexing failure");
34
+ if (throwFromIndex) {
35
+ throw new Error("simulated segment-indexing failure");
36
+ }
35
37
  return { indexedSegments: 0, enqueuedJobs: 0 };
36
38
  },
37
39
  enqueueBackfillJob: () => "",
@@ -58,6 +60,7 @@ import {
58
60
  } from "../persistence/conversation-crud.js";
59
61
  import { getMemoryDb } from "../persistence/db-connection.js";
60
62
  import { initializeDb } from "../persistence/db-init.js";
63
+ import { enqueueLexicalIndexForMessage } from "../persistence/job-handlers/message-lexical.js";
61
64
  import type { MemoryJobType } from "../persistence/jobs-store.js";
62
65
  import {
63
66
  getMemoryPersistenceHooks,
@@ -66,7 +69,6 @@ import {
66
69
  } from "../persistence/memory-lifecycle-hooks.js";
67
70
  import { memoryJobs } from "../persistence/schema/index.js";
68
71
  import { registerDefaultPluginPersistenceHooks } from "../plugins/defaults/index.js";
69
- import { enqueueLexicalIndexForMessage } from "../plugins/defaults/memory/job-handlers/index-message-lexical.js";
70
72
 
71
73
  await initializeDb();
72
74
 
@@ -76,7 +78,9 @@ function countJobs(type: MemoryJobType, conversationId?: string): number {
76
78
  .from(memoryJobs)
77
79
  .where(eq(memoryJobs.type, type))
78
80
  .all();
79
- if (conversationId == null) return rows.length;
81
+ if (conversationId == null) {
82
+ return rows.length;
83
+ }
80
84
  return rows.filter((r) => {
81
85
  try {
82
86
  return (
@@ -157,12 +161,16 @@ describe("messages lexical-index dual-write", () => {
157
161
  expect(lexicalJobMessageIds()).toContain(message.id);
158
162
  });
159
163
 
160
- test("addMessage enqueues NO index_message_lexical job when memory is disabled", async () => {
164
+ test("addMessage still enqueues an index_message_lexical job when memory is disabled", async () => {
161
165
  setMemoryEnabled(false);
162
166
  const conv = createConversation("Disabled memory thread");
163
- await addMessage(conv.id, "user", "should not be lexically indexed");
167
+ const message = await addMessage(
168
+ conv.id,
169
+ "user",
170
+ "lexically indexed regardless of memory state",
171
+ );
164
172
 
165
- expect(countJobs("index_message_lexical")).toBe(0);
173
+ expect(lexicalJobMessageIds()).toContain(message.id);
166
174
  });
167
175
 
168
176
  test("enqueueLexicalIndexForMessage no-ops on an empty message id", () => {