artifacty 0.10.9 → 0.11.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.
@@ -1,7 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { createHash } from "node:crypto";
3
3
  import { normalizeSourceAgent } from "./agents.js";
4
- import { ARTIFACT_TYPES, contentTypeForFormat, normalizeFormat } from "./storage.js";
4
+ import { ARTIFACT_TYPES, BUNDLE_BINARY_CONTENT_TYPES, contentTypeForFormat, normalizeFormat } from "./storage.js";
5
5
 
6
6
  const CONTINUATION_AGENTS = new Set(["codex", "copilot", "cursor"]);
7
7
 
@@ -119,6 +119,9 @@ export function detectFormat({ content = "", contentType = "", fileName = "" } =
119
119
  if (type.includes("vnd.ant.react") || type.includes("jsx")) {
120
120
  return "react";
121
121
  }
122
+ if (type.includes("ipynb") || type.includes("notebook")) {
123
+ return "notebook";
124
+ }
122
125
  if (type.includes("html")) {
123
126
  return "html";
124
127
  }
@@ -126,9 +129,12 @@ export function detectFormat({ content = "", contentType = "", fileName = "" } =
126
129
  return "markdown";
127
130
  }
128
131
  if (type.includes("json")) {
129
- return "json";
132
+ return looksLikeNotebook(content) ? "notebook" : "json";
130
133
  }
131
134
  const lowerName = optionalString(fileName).toLowerCase();
135
+ if (lowerName.endsWith(".ipynb")) {
136
+ return "notebook";
137
+ }
132
138
  if (lowerName.endsWith(".sarif") || lowerName.endsWith(".sarif.json")) {
133
139
  return "sarif";
134
140
  }
@@ -161,6 +167,9 @@ export function detectFormat({ content = "", contentType = "", fileName = "" } =
161
167
  if (extension === ".jsx" || extension === ".tsx") {
162
168
  return "react";
163
169
  }
170
+ if (extension === ".ipynb") {
171
+ return "notebook";
172
+ }
164
173
  if (isCodeExtension(extension)) {
165
174
  return "code";
166
175
  }
@@ -190,6 +199,9 @@ export function detectFormat({ content = "", contentType = "", fileName = "" } =
190
199
  if (looksLikeSarif(trimmed)) {
191
200
  return "sarif";
192
201
  }
202
+ if (looksLikeNotebook(trimmed)) {
203
+ return "notebook";
204
+ }
193
205
  if (looksLikeJson(trimmed)) {
194
206
  return "json";
195
207
  }
@@ -248,6 +260,21 @@ function decodeObjectPayload(value, agent) {
248
260
  };
249
261
  }
250
262
 
263
+ if (isNotebookObject(artifactObject)) {
264
+ return {
265
+ content: JSON.stringify(artifactObject, null, 2),
266
+ format: "notebook",
267
+ contentType: "application/x-ipynb+json; charset=utf-8",
268
+ title: artifactObject.title || artifactObject.name || "Notebook",
269
+ sourceAgent: artifactObject.sourceAgent || artifactObject.source_agent || artifactObject.agent || (agent === "auto" ? undefined : agent),
270
+ artifactType: artifactObject.artifactType || artifactObject.artifact_type || "analysis-report",
271
+ tags: artifactObject.tags,
272
+ metadata: {
273
+ originalPayloadShape: "notebook"
274
+ }
275
+ };
276
+ }
277
+
251
278
  if (typeof artifactObject.content === "string" && !Array.isArray(artifactObject.content)) {
252
279
  const sourceContentType = artifactObject.contentType ||
253
280
  artifactObject.content_type ||
@@ -440,13 +467,30 @@ function decodeBundlePayload(object, agent) {
440
467
  ? collectContinuationMetadata(continuationAgent, { agent: sourceAgent }, bundle, object)
441
468
  : {};
442
469
  const normalizedFiles = files.map((file, index) => {
470
+ const filePath = optionalString(file.path || file.name || `file-${index + 1}`);
471
+ const declaredContentType = optionalString(file.contentType || file.mimeType).toLowerCase().split(";")[0].trim();
472
+ const isBinary = file.encoding === "base64" || BUNDLE_BINARY_CONTENT_TYPES.has(declaredContentType);
473
+
474
+ if (isBinary) {
475
+ const base64 = normalizeBase64(typeof file.content === "string" ? file.content : "");
476
+ const decoded = base64 ? Buffer.from(base64, "base64") : Buffer.alloc(0);
477
+ return {
478
+ path: filePath,
479
+ content: base64,
480
+ encoding: "base64",
481
+ contentType: declaredContentType || "application/octet-stream",
482
+ sizeBytes: decoded.byteLength,
483
+ sha256: createHash("sha256").update(decoded).digest("hex")
484
+ };
485
+ }
486
+
443
487
  const content = typeof file.content === "string" ? file.content : "";
444
488
  return {
445
- path: optionalString(file.path || file.name || `file-${index + 1}`),
489
+ path: filePath,
446
490
  content,
447
491
  contentType: optionalString(file.contentType || file.mimeType) || contentTypeForFormat(detectFormat({
448
492
  content,
449
- fileName: file.path || file.name
493
+ fileName: filePath
450
494
  })),
451
495
  sizeBytes: Buffer.byteLength(content, "utf8"),
452
496
  sha256: createHash("sha256").update(content).digest("hex")
@@ -1098,7 +1142,7 @@ function inferArtifactType({ format, content, fileName, metadata }) {
1098
1142
  if (format === "code") {
1099
1143
  return "snippet";
1100
1144
  }
1101
- if (format === "sarif") {
1145
+ if (format === "sarif" || format === "notebook") {
1102
1146
  return "analysis-report";
1103
1147
  }
1104
1148
  if (format === "csv") {
@@ -1190,6 +1234,27 @@ function isSarifObject(value) {
1190
1234
  (typeof value.version === "string" || optionalString(value.$schema).toLowerCase().includes("sarif"));
1191
1235
  }
1192
1236
 
1237
+ function looksLikeNotebook(value) {
1238
+ if (!looksLikeJson(value)) {
1239
+ return false;
1240
+ }
1241
+ try {
1242
+ return isNotebookObject(JSON.parse(optionalString(value)));
1243
+ } catch {
1244
+ return false;
1245
+ }
1246
+ }
1247
+
1248
+ export function isNotebookObject(value) {
1249
+ return Boolean(
1250
+ value &&
1251
+ typeof value === "object" &&
1252
+ !Array.isArray(value) &&
1253
+ value.nbformat !== undefined &&
1254
+ Array.isArray(value.cells)
1255
+ );
1256
+ }
1257
+
1193
1258
  function looksLikeHtml(value) {
1194
1259
  const trimmed = optionalString(value);
1195
1260
  if (/^<!doctype html/i.test(trimmed) || /^<html[\s>]/i.test(trimmed)) {
@@ -1420,3 +1485,32 @@ function optionalString(value) {
1420
1485
  }
1421
1486
  return String(value).trim();
1422
1487
  }
1488
+
1489
+ // Resolves the best available image/video content type for a stored
1490
+ // artifact: an explicit media MIME type in version metadata, then the
1491
+ // version's own contentType if it is itself image/video, then a
1492
+ // format-based fallback. Used when serving raw media bytes (e.g. GET
1493
+ // /artifacts/:id/raw) so a stale or missing contentType still renders.
1494
+ export function mediaContentType(artifact) {
1495
+ const metadataType = String(artifact.version.metadata?.mimeType || "").toLowerCase();
1496
+ if (metadataType.startsWith("image/") || metadataType.startsWith("video/")) {
1497
+ return metadataType;
1498
+ }
1499
+ const versionType = String(artifact.version.contentType || "").toLowerCase().split(";")[0];
1500
+ if (versionType.startsWith("image/") || versionType.startsWith("video/")) {
1501
+ return versionType;
1502
+ }
1503
+ return artifact.version.format === "video" ? "video/mp4" : "image/png";
1504
+ }
1505
+
1506
+ // Decodes an image/video artifact's stored content (a data: URL or bare
1507
+ // base64 string) into raw bytes, or returns null if it isn't valid base64.
1508
+ export function decodeMediaContent(content) {
1509
+ const value = String(content || "").trim();
1510
+ const dataUrl = /^data:[^;,]+;base64,([A-Za-z0-9+/=_-\s]+)$/i.exec(value);
1511
+ const base64 = (dataUrl ? dataUrl[1] : value).replace(/\s+/g, "").replaceAll("-", "+").replaceAll("_", "/");
1512
+ if (!base64 || !/^[A-Za-z0-9+/]+={0,2}$/.test(base64)) {
1513
+ return null;
1514
+ }
1515
+ return Buffer.from(base64, "base64");
1516
+ }
package/src/lib/csv.js ADDED
@@ -0,0 +1,51 @@
1
+ // Single shared CSV parser used by the CSV renderer (render.js), the CSV
2
+ // diff (diff.js), and the SARIF/CSV export route (sarif-csv-export.js). All
3
+ // three previously carried byte-identical copies of this state machine that
4
+ // had already diverged in behavior on malformed input (an unterminated
5
+ // quoted field): the render.js copy failed closed while the diff.js copy
6
+ // silently emitted a bogus single-cell row for the same input, so the same
7
+ // artifact rendered as an error but diffed as data. This module is the one
8
+ // source of truth: it always fails closed on an unterminated quoted field.
9
+ export function parseCsv(content) {
10
+ const text = String(content || "");
11
+ const rows = [];
12
+ let row = [];
13
+ let cell = "";
14
+ let inQuotes = false;
15
+
16
+ for (let index = 0; index < text.length; index += 1) {
17
+ const char = text[index];
18
+ if (char === "\"") {
19
+ if (inQuotes && text[index + 1] === "\"") {
20
+ cell += "\"";
21
+ index += 1;
22
+ } else {
23
+ inQuotes = !inQuotes;
24
+ }
25
+ } else if (char === "," && !inQuotes) {
26
+ row.push(cell);
27
+ cell = "";
28
+ } else if ((char === "\n" || char === "\r") && !inQuotes) {
29
+ if (char === "\r" && text[index + 1] === "\n") {
30
+ index += 1;
31
+ }
32
+ row.push(cell);
33
+ if (row.length > 1 || row[0] !== "") {
34
+ rows.push(row);
35
+ }
36
+ row = [];
37
+ cell = "";
38
+ } else {
39
+ cell += char;
40
+ }
41
+ }
42
+
43
+ if (inQuotes) {
44
+ return { ok: false, error: "unterminated quoted field", rows: [] };
45
+ }
46
+ row.push(cell);
47
+ if (row.length > 1 || row[0] !== "") {
48
+ rows.push(row);
49
+ }
50
+ return { ok: true, rows };
51
+ }