@agent-native/creative-context 0.1.0 → 0.3.0

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 (105) hide show
  1. package/agent-native.package.json +11 -0
  2. package/dist/actions/continue-context-import.d.ts +16 -1
  3. package/dist/actions/continue-context-import.js +4 -1
  4. package/dist/actions/continue-context-import.js.map +1 -1
  5. package/dist/actions/get-creative-context.d.ts +38 -0
  6. package/dist/actions/get-creative-context.d.ts.map +1 -1
  7. package/dist/actions/get-creative-context.js +59 -23
  8. package/dist/actions/get-creative-context.js.map +1 -1
  9. package/dist/actions/get-google-picker-session.d.ts +10 -0
  10. package/dist/actions/get-google-picker-session.d.ts.map +1 -0
  11. package/dist/actions/get-google-picker-session.js +38 -0
  12. package/dist/actions/get-google-picker-session.js.map +1 -0
  13. package/dist/actions/index.d.ts.map +1 -1
  14. package/dist/actions/index.js +2 -0
  15. package/dist/actions/index.js.map +1 -1
  16. package/dist/actions/start-context-enrichment.d.ts +16 -1
  17. package/dist/actions/start-context-enrichment.js +6 -4
  18. package/dist/actions/start-context-enrichment.js.map +1 -1
  19. package/dist/actions/start-context-import.d.ts +16 -1
  20. package/dist/actions/start-context-import.js +2 -1
  21. package/dist/actions/start-context-import.js.map +1 -1
  22. package/dist/client/CreativeContextPanel.d.ts +1 -59
  23. package/dist/client/CreativeContextPanel.d.ts.map +1 -1
  24. package/dist/client/CreativeContextPanel.js +111 -36
  25. package/dist/client/CreativeContextPanel.js.map +1 -1
  26. package/dist/client/actions.d.ts +8 -0
  27. package/dist/client/actions.d.ts.map +1 -1
  28. package/dist/client/actions.js +4 -0
  29. package/dist/client/actions.js.map +1 -1
  30. package/dist/client/google-slides-picker.d.ts +19 -0
  31. package/dist/client/google-slides-picker.d.ts.map +1 -0
  32. package/dist/client/google-slides-picker.js +106 -0
  33. package/dist/client/google-slides-picker.js.map +1 -0
  34. package/dist/client/index.d.ts +1 -1
  35. package/dist/client/index.d.ts.map +1 -1
  36. package/dist/client/index.js +1 -1
  37. package/dist/client/index.js.map +1 -1
  38. package/dist/client/messages.d.ts +1 -1
  39. package/dist/client/messages.d.ts.map +1 -1
  40. package/dist/client/messages.js +22 -0
  41. package/dist/client/messages.js.map +1 -1
  42. package/dist/connectors/google-slides.d.ts +1 -1
  43. package/dist/connectors/google-slides.d.ts.map +1 -1
  44. package/dist/connectors/google-slides.js +1 -2
  45. package/dist/connectors/google-slides.js.map +1 -1
  46. package/dist/connectors/normalize.d.ts +7 -0
  47. package/dist/connectors/normalize.d.ts.map +1 -1
  48. package/dist/connectors/normalize.js +122 -3
  49. package/dist/connectors/normalize.js.map +1 -1
  50. package/dist/schema/index.d.ts +30 -30
  51. package/dist/server/enrichment.d.ts +1 -0
  52. package/dist/server/enrichment.d.ts.map +1 -1
  53. package/dist/server/enrichment.js +26 -24
  54. package/dist/server/enrichment.js.map +1 -1
  55. package/dist/server/generation-context.d.ts +1 -1
  56. package/dist/server/generation-context.d.ts.map +1 -1
  57. package/dist/server/generation-context.js +1 -1
  58. package/dist/server/generation-context.js.map +1 -1
  59. package/dist/store/content.d.ts +13 -0
  60. package/dist/store/content.d.ts.map +1 -1
  61. package/dist/store/content.js +172 -1
  62. package/dist/store/content.js.map +1 -1
  63. package/dist/store/jobs.d.ts.map +1 -1
  64. package/dist/store/jobs.js +2 -2
  65. package/dist/store/jobs.js.map +1 -1
  66. package/dist/store/purge.d.ts.map +1 -1
  67. package/dist/store/purge.js +9 -8
  68. package/dist/store/purge.js.map +1 -1
  69. package/dist/store/suggestions.d.ts +1 -0
  70. package/dist/store/suggestions.d.ts.map +1 -1
  71. package/dist/store/suggestions.js +56 -38
  72. package/dist/store/suggestions.js.map +1 -1
  73. package/docs/skills/creative-context/SKILL.md +8 -0
  74. package/package.json +3 -3
  75. package/src/actions/continue-context-import.ts +4 -1
  76. package/src/actions/creative-context-a2a.spec.ts +98 -0
  77. package/src/actions/get-creative-context.spec.ts +121 -1
  78. package/src/actions/get-creative-context.ts +78 -29
  79. package/src/actions/get-google-picker-session.spec.ts +62 -0
  80. package/src/actions/get-google-picker-session.ts +45 -0
  81. package/src/actions/index.ts +2 -0
  82. package/src/actions/job-public-serialization.spec.ts +122 -0
  83. package/src/actions/start-context-enrichment.ts +6 -4
  84. package/src/actions/start-context-import.ts +2 -1
  85. package/src/client/CreativeContextPanel.tsx +163 -64
  86. package/src/client/actions.ts +18 -0
  87. package/src/client/google-slides-picker.spec.ts +36 -0
  88. package/src/client/google-slides-picker.ts +146 -0
  89. package/src/client/index.ts +2 -0
  90. package/src/client/messages.ts +35 -0
  91. package/src/client/source-config.spec.ts +24 -10
  92. package/src/connectors/google-slides.ts +1 -2
  93. package/src/connectors/normalize.spec.ts +144 -0
  94. package/src/connectors/normalize.ts +201 -3
  95. package/src/server/enrichment.spec.ts +94 -0
  96. package/src/server/enrichment.ts +27 -24
  97. package/src/server/generation-artifact-access.spec.ts +104 -0
  98. package/src/server/generation-context-a2a.spec.ts +20 -2
  99. package/src/server/generation-context.ts +6 -6
  100. package/src/server/isolated-a2a.spec.ts +21 -1
  101. package/src/store/access.integration.spec.ts +511 -26
  102. package/src/store/content.ts +213 -1
  103. package/src/store/jobs.ts +2 -0
  104. package/src/store/purge.ts +18 -9
  105. package/src/store/suggestions.ts +67 -41
@@ -2,28 +2,22 @@ import { describe, expect, it } from "vitest";
2
2
 
3
3
  import {
4
4
  buildCreativeContextSourceConfig,
5
+ mergeRecommendationSelection,
5
6
  parseFigmaRecommendationBoundary,
6
7
  selectRenderableLayoutThumbnails,
7
8
  } from "./CreativeContextPanel.js";
8
9
 
9
10
  describe("creative context source configuration", () => {
10
- it("keeps Google Slides imports inside an explicit folder or shared drive", () => {
11
+ it("keeps Google Slides imports inside explicit presentation boundaries", () => {
11
12
  expect(
12
13
  buildCreativeContextSourceConfig(
13
14
  "google-slides",
14
- "https://drive.google.com/drive/folders/folder-1",
15
+ "https://docs.google.com/presentation/d/deck-1/edit\ndeck-2",
15
16
  [],
16
17
  ),
17
18
  ).toEqual({
18
- folderUrl: "https://drive.google.com/drive/folders/folder-1",
19
+ presentationIds: ["deck-1", "deck-2"],
19
20
  });
20
- expect(
21
- buildCreativeContextSourceConfig(
22
- "google-slides",
23
- "shared-drive:drive-1",
24
- [],
25
- ),
26
- ).toEqual({ sharedDriveId: "drive-1" });
27
21
  });
28
22
 
29
23
  it("turns confirmed provider recommendations into exact import boundaries", () => {
@@ -149,4 +143,24 @@ describe("creative context source configuration", () => {
149
143
  ]).map((thumbnail) => thumbnail.itemVersionId),
150
144
  ).toEqual(["v1", "v3", "v4"]);
151
145
  });
146
+
147
+ it("keeps explicit deck deselections while selecting newly discovered decks", () => {
148
+ expect(
149
+ mergeRecommendationSelection(
150
+ new Set(["still-selected"]),
151
+ new Set(["still-selected", "unchecked", "new-deck"]),
152
+ new Set(["still-selected", "unchecked"]),
153
+ ),
154
+ ).toEqual(new Set(["still-selected", "new-deck"]));
155
+ });
156
+
157
+ it("drops recommendations that are no longer available", () => {
158
+ expect(
159
+ mergeRecommendationSelection(
160
+ new Set(["available", "removed"]),
161
+ new Set(["available"]),
162
+ new Set(["available", "removed"]),
163
+ ),
164
+ ).toEqual(new Set(["available"]));
165
+ });
152
166
  });
@@ -37,8 +37,7 @@ import type {
37
37
 
38
38
  const SLIDES_MIME_TYPE = "application/vnd.google-apps.presentation";
39
39
  export const GOOGLE_SLIDES_CONTEXT_OAUTH_SCOPES = [
40
- "https://www.googleapis.com/auth/drive.metadata.readonly",
41
- "https://www.googleapis.com/auth/presentations.readonly",
40
+ "https://www.googleapis.com/auth/drive.file",
42
41
  ] as const;
43
42
 
44
43
  export class GoogleSlidesContextConnector implements ContextImportConnector {
@@ -0,0 +1,144 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ assertContextItemSqlTextLimits,
5
+ MAX_METADATA_BYTES,
6
+ MAX_MEDIA_LOCATOR_BYTES,
7
+ MAX_MEDIA_TEXT_BYTES,
8
+ MAX_NATIVE_CONTENT_BYTES,
9
+ MAX_SEARCHABLE_CONTENT_BYTES,
10
+ MAX_SUMMARY_BYTES,
11
+ normalizeContextItem,
12
+ } from "./normalize.js";
13
+
14
+ describe("normalizeContextItem SQL text limits", () => {
15
+ it("bounds emoji content and summaries by UTF-8 bytes without splitting code points", () => {
16
+ const item = normalizeContextItem({
17
+ externalId: "emoji",
18
+ kind: "document",
19
+ title: "Emoji",
20
+ content: "🙂".repeat(20_000),
21
+ summary: "🙂".repeat(3_000),
22
+ });
23
+
24
+ expect(Buffer.byteLength(item.content, "utf8")).toBe(
25
+ MAX_SEARCHABLE_CONTENT_BYTES,
26
+ );
27
+ expect(Array.from(item.content)).toHaveLength(
28
+ MAX_SEARCHABLE_CONTENT_BYTES / 4,
29
+ );
30
+ expect(Buffer.byteLength(item.summary!, "utf8")).toBe(MAX_SUMMARY_BYTES);
31
+ expect(Array.from(item.summary!)).toHaveLength(MAX_SUMMARY_BYTES / 4);
32
+ expect(item.content).not.toContain("�");
33
+ expect(item.summary).not.toContain("�");
34
+ });
35
+
36
+ it("uses the largest complete CJK prefix that fits the UTF-8 byte budget", () => {
37
+ const item = normalizeContextItem({
38
+ externalId: "cjk",
39
+ kind: "document",
40
+ title: "CJK",
41
+ content: "界".repeat(30_000),
42
+ });
43
+
44
+ const expectedCodePoints = Math.floor(MAX_SEARCHABLE_CONTENT_BYTES / 3);
45
+ expect(Array.from(item.content)).toHaveLength(expectedCodePoints);
46
+ expect(Buffer.byteLength(item.content, "utf8")).toBe(
47
+ expectedCodePoints * 3,
48
+ );
49
+ expect(Buffer.byteLength(item.content, "utf8")).toBeLessThanOrEqual(
50
+ MAX_SEARCHABLE_CONTENT_BYTES,
51
+ );
52
+ });
53
+
54
+ it("rejects oversized native HTML intact instead of truncating its markup", () => {
55
+ const html = `<div class="fmd-slide">${"界".repeat(MAX_NATIVE_CONTENT_BYTES / 3)}</div>`;
56
+
57
+ expect(() =>
58
+ normalizeContextItem({
59
+ externalId: "oversized-native",
60
+ kind: "google-slides-slide",
61
+ title: "Oversized native slide",
62
+ mimeType: "text/html",
63
+ content: html,
64
+ metadata: {
65
+ nativeArtifact: { app: "slides", format: "slides-html" },
66
+ },
67
+ }),
68
+ ).toThrow(/native content.*exceeds.*split the artifact/i);
69
+ });
70
+
71
+ it("rejects oversized structured metadata and media text before SQL writes", () => {
72
+ const oversizedMetadata = normalizeContextItem({
73
+ externalId: "oversized-metadata",
74
+ kind: "presentation",
75
+ title: "Oversized metadata",
76
+ content: "small",
77
+ metadata: { speakerNotes: "x".repeat(MAX_METADATA_BYTES) },
78
+ });
79
+ expect(() => assertContextItemSqlTextLimits(oversizedMetadata)).toThrow(
80
+ /item metadata.*exceeds.*private blob storage/i,
81
+ );
82
+
83
+ const oversizedMedia = normalizeContextItem({
84
+ externalId: "oversized-media",
85
+ kind: "presentation",
86
+ title: "Oversized media",
87
+ content: "small",
88
+ media: [
89
+ {
90
+ kind: "image",
91
+ url: "https://example.com/image.png",
92
+ ocrText: "x".repeat(MAX_MEDIA_TEXT_BYTES + 1),
93
+ metadata: { providerPayload: "small" },
94
+ },
95
+ ],
96
+ });
97
+ expect(() => assertContextItemSqlTextLimits(oversizedMedia)).toThrow(
98
+ /media OCR text.*exceeds.*private blob storage/i,
99
+ );
100
+
101
+ const oversizedProvenance = normalizeContextItem({
102
+ externalId: "oversized-provenance",
103
+ kind: "presentation",
104
+ title: "Oversized provenance",
105
+ content: "small",
106
+ provenance: { providerPayload: "x".repeat(MAX_METADATA_BYTES) },
107
+ });
108
+ expect(() => assertContextItemSqlTextLimits(oversizedProvenance)).toThrow(
109
+ /item provenance.*exceeds.*private blob storage/i,
110
+ );
111
+
112
+ const inlineDataUrl = normalizeContextItem({
113
+ externalId: "inline-media",
114
+ kind: "presentation",
115
+ title: "Inline media",
116
+ content: "small",
117
+ media: [
118
+ {
119
+ kind: "image",
120
+ url: "data:image/png;base64,AAAA",
121
+ },
122
+ ],
123
+ });
124
+ expect(() => assertContextItemSqlTextLimits(inlineDataUrl)).toThrow(
125
+ /media URL cannot be an inline data URL/i,
126
+ );
127
+
128
+ const oversizedLocator = normalizeContextItem({
129
+ externalId: "oversized-locator",
130
+ kind: "presentation",
131
+ title: "Oversized locator",
132
+ content: "small",
133
+ media: [
134
+ {
135
+ kind: "image",
136
+ url: `https://example.com/${"x".repeat(MAX_MEDIA_LOCATOR_BYTES)}`,
137
+ },
138
+ ],
139
+ });
140
+ expect(() => assertContextItemSqlTextLimits(oversizedLocator)).toThrow(
141
+ /media URL.*exceeds.*private blob storage/i,
142
+ );
143
+ });
144
+ });
@@ -11,6 +11,12 @@ import type {
11
11
  } from "../types.js";
12
12
 
13
13
  const DEFAULT_CHUNK_CHARS = 4_000;
14
+ export const MAX_SEARCHABLE_CONTENT_BYTES = 64 * 1024;
15
+ export const MAX_SUMMARY_BYTES = 8 * 1024;
16
+ export const MAX_NATIVE_CONTENT_BYTES = 128 * 1024;
17
+ export const MAX_METADATA_BYTES = 32 * 1024;
18
+ export const MAX_MEDIA_TEXT_BYTES = 64 * 1024;
19
+ export const MAX_MEDIA_LOCATOR_BYTES = 16 * 1024;
14
20
 
15
21
  export interface NormalizeContextItemInput {
16
22
  externalId: string;
@@ -56,13 +62,16 @@ export function normalizeContextItem(
56
62
  (input.mimeType === "text/html" &&
57
63
  (nativeArtifact?.format === "slides-html" ||
58
64
  nativeArtifact?.format === "design-html"));
59
- const content = preserveContent
65
+ const candidateContent = preserveContent
60
66
  ? input.content.replace(/\r\n?/g, "\n").trim()
61
67
  : normalizeWhitespace(input.content);
68
+ const content = preserveContent
69
+ ? requireBoundedNativeContent(candidateContent)
70
+ : boundText(candidateContent, MAX_SEARCHABLE_CONTENT_BYTES);
62
71
  const summary = input.summary
63
- ? normalizeWhitespace(input.summary)
72
+ ? boundText(normalizeWhitespace(input.summary), MAX_SUMMARY_BYTES)
64
73
  : undefined;
65
- const chunks = input.chunks ?? chunkContextText(content);
74
+ const chunks = boundChunks(input.chunks ?? chunkContextText(content));
66
75
  const normalized = {
67
76
  externalId,
68
77
  kind: required(input.kind, "kind"),
@@ -98,6 +107,195 @@ export function normalizeContextItem(
98
107
  };
99
108
  }
100
109
 
110
+ function boundText(value: string, maxBytes: number): string {
111
+ if (Buffer.byteLength(value, "utf8") <= maxBytes) return value;
112
+ let bytes = 0;
113
+ let endOffset = 0;
114
+ for (const codePoint of value) {
115
+ const codePointBytes = Buffer.byteLength(codePoint, "utf8");
116
+ if (bytes + codePointBytes > maxBytes) break;
117
+ bytes += codePointBytes;
118
+ endOffset += codePoint.length;
119
+ }
120
+ return value.slice(0, endOffset);
121
+ }
122
+
123
+ function requireBoundedNativeContent(value: string): string {
124
+ const bytes = Buffer.byteLength(value, "utf8");
125
+ if (bytes > MAX_NATIVE_CONTENT_BYTES) {
126
+ throw new Error(
127
+ `Creative context native content is ${bytes} bytes and exceeds the ${MAX_NATIVE_CONTENT_BYTES}-byte SQL inline limit; split the artifact or store it in private blob storage instead of truncating it`,
128
+ );
129
+ }
130
+ return value;
131
+ }
132
+
133
+ export function assertContextItemSqlTextLimits(
134
+ item: Pick<
135
+ NormalizedContextItem,
136
+ | "content"
137
+ | "summary"
138
+ | "mimeType"
139
+ | "provenance"
140
+ | "metadata"
141
+ | "chunks"
142
+ | "media"
143
+ | "edges"
144
+ >,
145
+ ): void {
146
+ const nativeArtifact = item.metadata?.nativeArtifact;
147
+ const isNativeContent =
148
+ item.mimeType === "text/html" &&
149
+ typeof nativeArtifact === "object" &&
150
+ nativeArtifact !== null &&
151
+ !Array.isArray(nativeArtifact) &&
152
+ ((nativeArtifact as Record<string, unknown>).format === "slides-html" ||
153
+ (nativeArtifact as Record<string, unknown>).format === "design-html");
154
+ const contentLimit = isNativeContent
155
+ ? MAX_NATIVE_CONTENT_BYTES
156
+ : MAX_SEARCHABLE_CONTENT_BYTES;
157
+ assertSqlTextLimit(
158
+ isNativeContent ? "native content" : "searchable content",
159
+ item.content,
160
+ contentLimit,
161
+ isNativeContent
162
+ ? "split the artifact or store it in private blob storage"
163
+ : "normalize or chunk the source before ingest",
164
+ );
165
+ if (item.summary) {
166
+ assertSqlTextLimit(
167
+ "summary",
168
+ item.summary,
169
+ MAX_SUMMARY_BYTES,
170
+ "normalize the summary before ingest",
171
+ );
172
+ }
173
+ assertJsonSqlTextLimit(
174
+ "item metadata",
175
+ item.metadata,
176
+ MAX_METADATA_BYTES,
177
+ "move raw payloads to private blob storage before ingest",
178
+ );
179
+ assertJsonSqlTextLimit(
180
+ "item provenance",
181
+ item.provenance,
182
+ MAX_METADATA_BYTES,
183
+ "move raw payloads to private blob storage before ingest",
184
+ );
185
+ let chunkBytes = 0;
186
+ for (const chunk of item.chunks ?? []) {
187
+ chunkBytes += Buffer.byteLength(chunk.text, "utf8");
188
+ if (chunkBytes > MAX_SEARCHABLE_CONTENT_BYTES) {
189
+ throw new Error(
190
+ `Creative context chunks exceed the ${MAX_SEARCHABLE_CONTENT_BYTES}-byte SQL text limit; normalize or split the chunks before ingest`,
191
+ );
192
+ }
193
+ assertJsonSqlTextLimit(
194
+ "chunk metadata",
195
+ chunk.metadata,
196
+ MAX_METADATA_BYTES,
197
+ "move raw payloads to private blob storage before ingest",
198
+ );
199
+ }
200
+ for (const media of item.media ?? []) {
201
+ for (const [label, value] of [
202
+ ["media alt text", media.altText],
203
+ ["media caption", media.caption],
204
+ ["media OCR text", media.ocrText],
205
+ ] as const) {
206
+ if (value) {
207
+ assertSqlTextLimit(
208
+ label,
209
+ value,
210
+ MAX_MEDIA_TEXT_BYTES,
211
+ "move raw payloads to private blob storage before ingest",
212
+ );
213
+ }
214
+ }
215
+ for (const [label, value] of [
216
+ ["media URL", media.url],
217
+ ["media storage key", media.storageKey],
218
+ ["media provenance URL", media.provenanceUrl],
219
+ ] as const) {
220
+ if (!value) continue;
221
+ if (value.trim().toLowerCase().startsWith("data:")) {
222
+ throw new Error(
223
+ `Creative context ${label} cannot be an inline data URL; store the payload in private blob storage instead`,
224
+ );
225
+ }
226
+ assertSqlTextLimit(
227
+ label,
228
+ value,
229
+ MAX_MEDIA_LOCATOR_BYTES,
230
+ "move raw payloads to private blob storage before ingest",
231
+ );
232
+ }
233
+ assertJsonSqlTextLimit(
234
+ "media metadata",
235
+ media.metadata,
236
+ MAX_METADATA_BYTES,
237
+ "move raw payloads to private blob storage before ingest",
238
+ );
239
+ }
240
+ for (const edge of item.edges ?? []) {
241
+ assertJsonSqlTextLimit(
242
+ "edge metadata",
243
+ edge.metadata,
244
+ MAX_METADATA_BYTES,
245
+ "move raw payloads to private blob storage before ingest",
246
+ );
247
+ }
248
+ }
249
+
250
+ function assertJsonSqlTextLimit(
251
+ label: string,
252
+ value: unknown,
253
+ maxBytes: number,
254
+ guidance: string,
255
+ ): void {
256
+ if (value === undefined) return;
257
+ const serialized = JSON.stringify(value);
258
+ if (serialized === undefined) return;
259
+ assertSqlTextLimit(label, serialized, maxBytes, guidance);
260
+ }
261
+
262
+ function assertSqlTextLimit(
263
+ label: string,
264
+ value: string,
265
+ maxBytes: number,
266
+ guidance: string,
267
+ ): void {
268
+ const bytes = Buffer.byteLength(value, "utf8");
269
+ if (bytes > maxBytes) {
270
+ throw new Error(
271
+ `Creative context ${label} is ${bytes} bytes and exceeds the ${maxBytes}-byte SQL text limit; ${guidance}`,
272
+ );
273
+ }
274
+ }
275
+
276
+ function boundChunks(
277
+ chunks: NormalizedContextChunk[],
278
+ ): NormalizedContextChunk[] {
279
+ let remaining = MAX_SEARCHABLE_CONTENT_BYTES;
280
+ return chunks.flatMap((chunk) => {
281
+ if (remaining <= 0) return [];
282
+ const text = boundText(chunk.text, remaining);
283
+ remaining -= Buffer.byteLength(text, "utf8");
284
+ return text
285
+ ? [
286
+ {
287
+ ...chunk,
288
+ text,
289
+ endOffset:
290
+ chunk.startOffset == null
291
+ ? chunk.endOffset
292
+ : chunk.startOffset + text.length,
293
+ },
294
+ ]
295
+ : [];
296
+ });
297
+ }
298
+
101
299
  export function hashContextContent(value: unknown): string {
102
300
  return createHash("sha256").update(stableJson(value)).digest("hex");
103
301
  }
@@ -0,0 +1,94 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const mocks = vi.hoisted(() => ({
4
+ appendVersion: vi.fn(),
5
+ availableFamilies: vi.fn(),
6
+ getActiveEmbeddingSet: vi.fn(),
7
+ getCreativeContext: vi.fn(),
8
+ readMedia: vi.fn(),
9
+ fingerprintMedia: vi.fn(),
10
+ extractDominantColors: vi.fn(),
11
+ }));
12
+
13
+ vi.mock("@agent-native/core/ingestion", () => ({
14
+ fingerprintMedia: mocks.fingerprintMedia,
15
+ extractDominantColors: mocks.extractDominantColors,
16
+ }));
17
+
18
+ vi.mock("../embeddings/providers.js", () => ({
19
+ availableEmbeddingFamilies: mocks.availableFamilies,
20
+ }));
21
+
22
+ vi.mock("../store/index.js", () => ({
23
+ appendMediaEnrichmentVersion: mocks.appendVersion,
24
+ getActiveEmbeddingSet: mocks.getActiveEmbeddingSet,
25
+ getCreativeContextItem: vi.fn(),
26
+ recordEmbeddingMetadata: vi.fn(),
27
+ }));
28
+
29
+ vi.mock("./context.js", () => ({
30
+ getCreativeContext: mocks.getCreativeContext,
31
+ }));
32
+
33
+ vi.mock("./media.js", () => ({
34
+ readCreativeContextMedia: mocks.readMedia,
35
+ }));
36
+
37
+ import { enrichCreativeContextMedia } from "./enrichment.js";
38
+
39
+ describe("creative context media enrichment", () => {
40
+ beforeEach(() => {
41
+ vi.clearAllMocks();
42
+ mocks.readMedia.mockResolvedValue({
43
+ data: new Uint8Array([1, 2, 3]),
44
+ mimeType: "image/png",
45
+ itemId: "item-1",
46
+ itemVersionId: "version-1",
47
+ mediaId: "media-1",
48
+ media: {
49
+ caption: null,
50
+ ocrText: null,
51
+ },
52
+ });
53
+ mocks.fingerprintMedia.mockReturnValue({ sha256: "image-hash" });
54
+ mocks.extractDominantColors.mockResolvedValue(["#663399"]);
55
+ mocks.availableFamilies.mockResolvedValue([]);
56
+ mocks.getActiveEmbeddingSet.mockResolvedValue(null);
57
+ mocks.appendVersion.mockResolvedValue({
58
+ itemId: "item-1",
59
+ itemVersionId: "version-2",
60
+ mediaId: "media-2",
61
+ appended: true,
62
+ });
63
+ mocks.getCreativeContext.mockReturnValue({
64
+ vectorAdapter: null,
65
+ projections: undefined,
66
+ enrichment: {
67
+ captionImage: vi.fn(async () => "A purple dashboard"),
68
+ ocrImage: vi.fn(async () => "Ship faster"),
69
+ },
70
+ });
71
+ });
72
+
73
+ it("writes enrichment as a new immutable version and returns its ids", async () => {
74
+ await expect(
75
+ enrichCreativeContextMedia({ mediaId: "media-1" }),
76
+ ).resolves.toMatchObject({
77
+ mediaId: "media-2",
78
+ itemId: "item-1",
79
+ itemVersionId: "version-2",
80
+ versionAppended: true,
81
+ caption: "A purple dashboard",
82
+ ocrText: "Ship faster",
83
+ palette: ["#663399"],
84
+ });
85
+ expect(mocks.appendVersion).toHaveBeenCalledWith({
86
+ mediaId: "media-1",
87
+ palette: ["#663399"],
88
+ contentHash: "image-hash",
89
+ caption: "A purple dashboard",
90
+ captionStatus: "complete",
91
+ ocrText: "Ship faster",
92
+ });
93
+ });
94
+ });
@@ -8,6 +8,7 @@ import { eq } from "drizzle-orm";
8
8
 
9
9
  import { availableEmbeddingFamilies } from "../embeddings/providers.js";
10
10
  import {
11
+ appendMediaEnrichmentVersion,
11
12
  getActiveEmbeddingSet,
12
13
  getCreativeContextItem,
13
14
  recordEmbeddingMetadata,
@@ -59,7 +60,7 @@ export async function enrichCreativeContextMedia(input: {
59
60
  "Creative context media enrichment supports PNG, JPEG, WebP, or GIF images.",
60
61
  );
61
62
  }
62
- const { getDb, schema, vectorAdapter, enrichment } = getCreativeContext();
63
+ const { vectorAdapter, enrichment } = getCreativeContext();
63
64
  const fingerprint = fingerprintMedia(loaded.data, loaded.mimeType);
64
65
  const palette = await extractDominantColors(
65
66
  loaded.data,
@@ -81,20 +82,21 @@ export async function enrichCreativeContextMedia(input: {
81
82
  mediaId: input.mediaId,
82
83
  }) ?? Promise.resolve(null),
83
84
  ]);
84
- await getDb()
85
- .update(schema.contextMedia)
86
- .set({
87
- palette: JSON.stringify(palette),
88
- contentHash: fingerprint.sha256,
89
- caption: caption ?? loaded.media?.caption ?? null,
90
- captionStatus: enrichment?.captionImage
91
- ? caption
92
- ? "complete"
93
- : "failed"
94
- : "pending",
95
- ocrText: ocrText ?? loaded.media?.ocrText ?? null,
96
- })
97
- .where(eq(schema.contextMedia.id, input.mediaId));
85
+ const nextCaption = caption ?? loaded.media?.caption ?? null;
86
+ const nextCaptionStatus = enrichment?.captionImage
87
+ ? caption
88
+ ? ("complete" as const)
89
+ : ("failed" as const)
90
+ : ("pending" as const);
91
+ const nextOcrText = ocrText ?? loaded.media?.ocrText ?? null;
92
+ const snapshot = await appendMediaEnrichmentVersion({
93
+ mediaId: input.mediaId,
94
+ palette,
95
+ contentHash: fingerprint.sha256,
96
+ caption: nextCaption,
97
+ captionStatus: nextCaptionStatus,
98
+ ocrText: nextOcrText,
99
+ });
98
100
 
99
101
  const families = await availableEmbeddingFamilies();
100
102
  const set = await getActiveEmbeddingSet();
@@ -134,24 +136,25 @@ export async function enrichCreativeContextMedia(input: {
134
136
  });
135
137
  await recordEmbeddingMetadata({
136
138
  embeddingSetId: set.id,
137
- itemId: loaded.itemId,
138
- itemVersionId: loaded.itemVersionId,
139
+ itemId: snapshot.itemId,
140
+ itemVersionId: snapshot.itemVersionId,
139
141
  targetType: "media",
140
- targetId: input.mediaId,
142
+ targetId: snapshot.mediaId,
141
143
  vectorKey: stored.vectorKey,
142
144
  dimensions: vector.length,
143
145
  checksum: fingerprint.sha256,
144
146
  });
145
147
  }
146
148
  }
147
- await projectCreativeContextMedia(input.mediaId);
149
+ await projectCreativeContextMedia(snapshot.mediaId);
148
150
  return {
149
- mediaId: input.mediaId,
150
- itemId: loaded.itemId,
151
- itemVersionId: loaded.itemVersionId,
151
+ mediaId: snapshot.mediaId,
152
+ itemId: snapshot.itemId,
153
+ itemVersionId: snapshot.itemVersionId,
154
+ versionAppended: snapshot.appended,
152
155
  palette,
153
- caption: caption ?? null,
154
- ocrText: ocrText ?? null,
156
+ caption: nextCaption,
157
+ ocrText: nextOcrText,
155
158
  contentHash: fingerprint.sha256,
156
159
  embeddingId,
157
160
  embeddingFamily: family?.id ?? null,