@vgai/sdk 0.5.15 → 0.5.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@vgai/sdk",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "Apache-2.0",
5
- "version": "0.5.15",
5
+ "version": "0.5.17",
6
6
  "type": "module",
7
7
  "repository": {
8
8
  "type": "git",
@@ -30,7 +30,7 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@modelcontextprotocol/sdk": "^1.30.0",
33
- "@vgai/engine": "0.5.15",
33
+ "@vgai/engine": "0.5.17",
34
34
  "playwright": "^1.58.2",
35
35
  "zod": "^4.3.6"
36
36
  }
@@ -68,7 +68,6 @@ const AssetKindSchema = z.enum([
68
68
  'json',
69
69
  'scene',
70
70
  'prefab',
71
- 'material',
72
71
  ]);
73
72
 
74
73
  const EditorAssetOpenInput = z.object({
@@ -1,8 +1,5 @@
1
1
  /**
2
- * Real-SDK stdio transport for the MCP projection (roadmap A1, bespoke-audit
3
- * F1) — the exact follow-up `mcp-projection.ts`'s header reserved: "If the
4
- * official SDK is later added, only a transport wrapper around `handle`
5
- * changes; the derivation below does not."
2
+ * Official-SDK stdio transport for the MCP projection.
6
3
  *
7
4
  * This module is that transport wrapper and NOTHING more: it binds
8
5
  * `@modelcontextprotocol/sdk`'s `Server` + `StdioServerTransport` around the
@@ -0,0 +1,105 @@
1
+ /**
2
+ * THE on-disk shape of `<project>/logs/play-*.jsonl` — one definition, shared
3
+ * by the writer (`editor-server.ts`'s `/__editor/log-session` +
4
+ * `/__editor/log-entries` handlers) and every reader (`play.log.*` here,
5
+ * `vgai status`'s play-error banner, the project Logs utility).
6
+ *
7
+ * A play log is JSONL with TWO record kinds:
8
+ *
9
+ * 1. THE HEADER — always the FIRST line, written when the file is opened:
10
+ * `{"kind":"session","v":1,"session":…,"project":…,"run":…,"startedAt":…}`.
11
+ * This is where the run's IDENTITY lives, recorded ONCE. The alternative
12
+ * (stamping session/project onto every entry) pays the same constant
13
+ * thousands of times per run for facts that cannot change while the file
14
+ * is open — the file is opened by one session, for one project, at one
15
+ * instant.
16
+ * 2. ENTRIES — every later line: `{t, level, source?, sub?, msg, meta?,
17
+ * tick?, simT?, world?, simSpeed?}`. Only genuinely per-entry facts go
18
+ * here: `tick`/`simT` (the frame the line was written on), `simSpeed`
19
+ * (the live time scale, which an instrument can change mid-run) and
20
+ * `world` (the world the run is presenting, unknown until the roots
21
+ * mount — so the entries written during boot honestly carry none).
22
+ *
23
+ * A reader must SKIP the header when counting or listing entries
24
+ * (`asPlayLogHeader` is the one test), and must tolerate its ABSENCE: logs
25
+ * written before the header existed are still on disk in real projects, and
26
+ * "no header" means "this run's identity was never recorded", never an error.
27
+ */
28
+
29
+ import { closeSync, openSync, readSync } from 'node:fs';
30
+
31
+ /** `kind` value marking a play log's header line. */
32
+ export const PLAY_LOG_HEADER_KIND = 'session';
33
+
34
+ /** Bumped only when the on-disk record shapes change incompatibly. */
35
+ export const PLAY_LOG_FORMAT_VERSION = 1;
36
+
37
+ /** The run identity a play log carries on its first line. */
38
+ export interface PlayLogHeader {
39
+ readonly kind: typeof PLAY_LOG_HEADER_KIND;
40
+ readonly v: number;
41
+ /** The editor session that opened the file (`processSessionId()`) — the same
42
+ * id `vgai sessions` lists, so a log file names the session that wrote it. */
43
+ readonly session: string;
44
+ /** Absolute project root the session was serving. */
45
+ readonly project: string;
46
+ /** The run's slug (`vgai play --name <text>`), `null` for an unnamed run —
47
+ * the same slug that goes in the filename and the session journal. */
48
+ readonly run: string | null;
49
+ /** Wall-clock ms at which the file was opened. */
50
+ readonly startedAt: number;
51
+ }
52
+
53
+ /** The header line (newline included) for a run's identity. */
54
+ export function playLogHeaderLine(identity: Omit<PlayLogHeader, 'kind' | 'v'>): string {
55
+ const header: PlayLogHeader = {
56
+ kind: PLAY_LOG_HEADER_KIND,
57
+ v: PLAY_LOG_FORMAT_VERSION,
58
+ ...identity,
59
+ };
60
+ return `${JSON.stringify(header)}\n`;
61
+ }
62
+
63
+ /** The parsed header, or `null` when this record is an ordinary entry (or a
64
+ * header from an unreadable/older shape). Every reader's one header test. */
65
+ export function asPlayLogHeader(record: unknown): PlayLogHeader | null {
66
+ if (typeof record !== 'object' || record === null || Array.isArray(record)) return null;
67
+ const rec = record as Record<string, unknown>;
68
+ if (rec['kind'] !== PLAY_LOG_HEADER_KIND) return null;
69
+ if (typeof rec['session'] !== 'string' || typeof rec['project'] !== 'string') return null;
70
+ return {
71
+ kind: PLAY_LOG_HEADER_KIND,
72
+ v: typeof rec['v'] === 'number' ? rec['v'] : 0,
73
+ session: rec['session'],
74
+ project: rec['project'],
75
+ run: typeof rec['run'] === 'string' ? rec['run'] : null,
76
+ startedAt: typeof rec['startedAt'] === 'number' ? rec['startedAt'] : 0,
77
+ };
78
+ }
79
+
80
+ /** How much of a log file's start is read to find its header. A header line is
81
+ * a few hundred bytes; anything longer is not one. */
82
+ const HEADER_READ_BYTES = 4096;
83
+
84
+ /**
85
+ * The header of the log file at `path`, read WITHOUT loading the file — a
86
+ * listing walks every log in `logs/`, and those files are unbounded (a long
87
+ * run writes megabytes). `null` for an unreadable file or one with no header.
88
+ */
89
+ export function readPlayLogHeaderFile(path: string): PlayLogHeader | null {
90
+ let fd: number | null = null;
91
+ try {
92
+ fd = openSync(path, 'r');
93
+ const buffer = Buffer.alloc(HEADER_READ_BYTES);
94
+ const bytes = readSync(fd, buffer, 0, HEADER_READ_BYTES, 0);
95
+ const head = buffer.subarray(0, bytes).toString('utf-8');
96
+ const newline = head.indexOf('\n');
97
+ const line = newline === -1 ? head : head.slice(0, newline);
98
+ if (line.trim() === '') return null;
99
+ return asPlayLogHeader(JSON.parse(line));
100
+ } catch {
101
+ return null;
102
+ } finally {
103
+ if (fd !== null) closeSync(fd);
104
+ }
105
+ }
@@ -12,25 +12,17 @@
12
12
  * (reusing "the logs/play-*.jsonl reader" per this unit's own brief, taken
13
13
  * literally: read the files).
14
14
  *
15
- * IDENTITY FIELDS (§8 B4 AC "Logs identify session, project, world, and
16
- * simulation speed"): the real persisted `LogEntry` shape
17
- * (`packages/editor/src/editor-api.ts`) is `{t, level, source?, sub?, msg,
18
- * meta?}` — NO session/project/world/simSpeed fields exist in the file
19
- * format today (a genuine gap against this AC in the CURRENT persistence
20
- * layer, which predates it; closing it for real would mean threading these
21
- * fields through `play-mode.ts`'s log sink and `editor-server.ts`'s
22
- * append handler both outside this unit's file ownership,
23
- * packages/editor/src|server). `play.log.read` satisfies the AC honestly,
24
- * without fabricating anything:
25
- * - `session`: the log FILENAME itself (e.g. "play-2026-...jsonl") the
26
- * closest real identity a persisted play run has today.
27
- * - `project`: `ctx.projectRoot`, always real (these ops require it).
28
- * - `world`/`simSpeed`: read from each entry's own `meta.world` /
29
- * `meta.simSpeed`, when the entry happens to carry them (`meta` is
30
- * already a free-form bag in the real format) — `null` otherwise. This
31
- * is the honest state of affairs: against TODAY's real files these are
32
- * always `null` (the current sink never stamps them); a test fixture
33
- * that DOES embed them in `meta` proves the read-through path for real.
15
+ * IDENTITY FIELDS: every one of them is now WRITTEN, so nothing here
16
+ * synthesizes any of them (`play/log-format.ts` is the format's one
17
+ * definition, shared with the writer):
18
+ * - `session`/`project`/`run`/`startedAt`: the file's HEADER line, written
19
+ * once when the editor server opens the file. Recorded once because they
20
+ * cannot change while it is open.
21
+ * - `world`/`simSpeed`: stamped per ENTRY by `play-mode.ts`'s writers, from
22
+ * the live run (the presented world; `game.loop.timeScale`) and only
23
+ * when they genuinely know them, so absence is a real answer.
24
+ * A log written before the header existed reads back with `null` identity and
25
+ * no error: those files are still on disk in real projects.
34
26
  *
35
27
  * `play.log.follow` describes the REAL, existing poll-based subscription
36
28
  * for the CURRENTLY ACTIVE play session's log (there is no push/SSE
@@ -53,6 +45,7 @@ import {
53
45
  resolveProjectPath,
54
46
  } from '../project/shared.js';
55
47
  import { defineTool, type ToolRegistry } from '../registry.js';
48
+ import { asPlayLogHeader, type PlayLogHeader, readPlayLogHeaderFile } from './log-format.js';
56
49
  import {
57
50
  getPlayTransport,
58
51
  PLAY_READ_TIMEOUT_MS,
@@ -75,10 +68,33 @@ const PlayLogDiscoverInput = z
75
68
  .object({})
76
69
  .describe("No input — lists every persisted play-mode log file under the project's logs/ dir.");
77
70
 
71
+ const PlayLogRunSchema = z.object({
72
+ file: z.string().describe('The log filename this run wrote.'),
73
+ session: z
74
+ .string()
75
+ .nullable()
76
+ .describe(
77
+ 'The editor session that wrote it (the id `vgai sessions` lists), from the file header — ' +
78
+ 'null for a log written before headers existed.',
79
+ ),
80
+ project: z.string().nullable().describe('Absolute project root the session was serving.'),
81
+ run: z
82
+ .string()
83
+ .nullable()
84
+ .describe('The run name slug (`vgai play --name`), null for an unnamed run.'),
85
+ startedAt: z.number().nullable().describe('Wall-clock ms at which the run opened its log.'),
86
+ });
87
+
78
88
  const PlayLogDiscoverResult = z.object({
79
89
  logFiles: z
80
90
  .array(z.string())
81
91
  .describe('Filenames (e.g. "play-2026-07-11T10-00-00.jsonl"), oldest first (name-sorted).'),
92
+ runs: z
93
+ .array(PlayLogRunSchema)
94
+ .describe(
95
+ "The same files with each one's recorded identity, read from its header line — this is " +
96
+ "how a caller picks ITS session's log out of the directory instead of guessing by name.",
97
+ ),
82
98
  });
83
99
 
84
100
  export const playLogDiscover = defineTool({
@@ -87,7 +103,8 @@ export const playLogDiscover = defineTool({
87
103
  description:
88
104
  'Reads <projectRoot>/logs/ directly off disk and filters play-*.jsonl (the exact naming ' +
89
105
  "convention editor-server.ts's log-session handler writes). Works with no editor/play " +
90
- 'session running — these files persist after play stops.',
106
+ 'session running — these files persist after play stops. Each file also reports the ' +
107
+ 'session/project/run identity from its own header line (null for pre-header logs).',
91
108
  input: PlayLogDiscoverInput,
92
109
  result: PlayLogDiscoverResult,
93
110
  errors: [NO_PROJECT_ROOT_ERROR, PATH_OUTSIDE_PROJECT_ERROR],
@@ -106,7 +123,17 @@ export const playLogDiscover = defineTool({
106
123
  names = [];
107
124
  }
108
125
  const logFiles = names.filter((f) => f.startsWith('play-') && f.endsWith('.jsonl')).sort();
109
- return { logFiles };
126
+ const runs = logFiles.map((file) => {
127
+ const header = readPlayLogHeaderFile(join(logsDir, file));
128
+ return {
129
+ file,
130
+ session: header?.session ?? null,
131
+ project: header?.project ?? null,
132
+ run: header?.run ?? null,
133
+ startedAt: header?.startedAt ?? null,
134
+ };
135
+ });
136
+ return { logFiles, runs };
110
137
  },
111
138
  });
112
139
 
@@ -121,21 +148,33 @@ const PlayLogEntrySchema = z.object({
121
148
  sub: z.string().nullable(),
122
149
  msg: z.string(),
123
150
  meta: z.record(z.string(), z.unknown()).nullable(),
124
- session: z.string().describe('The log filename identifying this play run (see module jsdoc).'),
151
+ tick: z
152
+ .number()
153
+ .nullable()
154
+ .describe('Engine fixed-step counter at write time, when the writer could read it.'),
155
+ simT: z.number().nullable().describe('Accumulated sim-seconds at write time — same source.'),
156
+ session: z
157
+ .string()
158
+ .nullable()
159
+ .describe(
160
+ "The editor session that wrote this log, from the file's header line — null for a log " +
161
+ 'written before headers existed.',
162
+ ),
125
163
  project: z.string().describe('Absolute project root this log belongs to.'),
126
164
  world: z
127
165
  .string()
128
166
  .nullable()
129
167
  .describe(
130
- 'Active world id for this entry, read from meta.world when the writer stamped it — null ' +
131
- "otherwise (today's real sink never stamps this; see module jsdoc).",
168
+ 'The world the run was presenting when this entry was written, as the play-mode writer ' +
169
+ 'stamped it null when it had no unambiguous world (boot-time entries, multi-root runs ' +
170
+ 'presenting none).',
132
171
  ),
133
172
  simSpeed: z
134
173
  .number()
135
174
  .nullable()
136
175
  .describe(
137
- 'Simulation time-scale for this entry, read from meta.simSpeed when the writer stamped it ' +
138
- "— null otherwise (today's real sink never stamps this; see module jsdoc).",
176
+ "The loop's live time scale when this entry was written (1 real time, 0 paused) null " +
177
+ 'when no game was running yet.',
139
178
  ),
140
179
  });
141
180
 
@@ -157,24 +196,25 @@ const PlayLogReadInput = z.object({
157
196
 
158
197
  const PlayLogReadResult = z.object({
159
198
  entries: z.array(PlayLogEntrySchema),
160
- totalEntries: z.number().describe('Total entries in the file, regardless of offset/limit.'),
199
+ totalEntries: z
200
+ .number()
201
+ .describe('Total entries in the file, regardless of offset/limit (the header is not one).'),
202
+ run: PlayLogRunSchema.describe("This log's own recorded identity, from its header line."),
161
203
  });
162
204
 
163
205
  function isRecord(v: unknown): v is Record<string, unknown> {
164
206
  return typeof v === 'object' && v !== null && !Array.isArray(v);
165
207
  }
166
208
 
167
- /** Parse one raw JSONL line into a `PlayLogEntrySchema`-shaped entry, stamping the identity fields (see module jsdoc). */
209
+ /** Parse one raw JSONL entry line, carrying the file-level session identity
210
+ * from the header onto it. Every field is read from what the writer actually
211
+ * wrote; nothing is derived from the filename or invented. */
168
212
  function parseLogLine(
169
- line: string,
170
- session: string,
213
+ rec: Record<string, unknown>,
214
+ session: string | null,
171
215
  project: string,
172
216
  ): z.infer<typeof PlayLogEntrySchema> {
173
- const parsed: unknown = JSON.parse(line);
174
- const rec = isRecord(parsed) ? parsed : {};
175
217
  const meta = isRecord(rec['meta']) ? (rec['meta'] as Record<string, unknown>) : null;
176
- const worldRaw = meta?.['world'];
177
- const simSpeedRaw = meta?.['simSpeed'];
178
218
  return {
179
219
  t: typeof rec['t'] === 'number' ? rec['t'] : 0,
180
220
  level: typeof rec['level'] === 'string' ? rec['level'] : 'log',
@@ -182,10 +222,12 @@ function parseLogLine(
182
222
  sub: typeof rec['sub'] === 'string' ? rec['sub'] : null,
183
223
  msg: typeof rec['msg'] === 'string' ? rec['msg'] : '',
184
224
  meta,
225
+ tick: typeof rec['tick'] === 'number' ? rec['tick'] : null,
226
+ simT: typeof rec['simT'] === 'number' ? rec['simT'] : null,
185
227
  session,
186
228
  project,
187
- world: typeof worldRaw === 'string' ? worldRaw : null,
188
- simSpeed: typeof simSpeedRaw === 'number' ? simSpeedRaw : null,
229
+ world: typeof rec['world'] === 'string' ? rec['world'] : null,
230
+ simSpeed: typeof rec['simSpeed'] === 'number' ? rec['simSpeed'] : null,
189
231
  };
190
232
  }
191
233
 
@@ -193,10 +235,10 @@ export const playLogRead = defineTool({
193
235
  name: 'play.log.read',
194
236
  summary: 'Read entries from a persisted play-mode log file.',
195
237
  description:
196
- 'Reads <projectRoot>/logs/<file> directly off disk, parsing each JSONL line. Each returned ' +
197
- 'entry is enriched with session (the log filename)/project (ctx.projectRoot) both real ' +
198
- "plus world/simSpeed read from the entry's own meta bag when present, null otherwise (see " +
199
- 'module jsdoc for why the raw persisted format has no dedicated fields for these yet).',
238
+ "Reads <projectRoot>/logs/<file> directly off disk, parsing each JSONL line. The file's " +
239
+ 'header line supplies the run identity (session/project/run/startedAt, reported as `run` and ' +
240
+ 'carried onto every entry); world/simSpeed/tick/simT come from each entry as the play-mode ' +
241
+ 'writer stamped them. A pre-header log reads back with null identity, never an error.',
200
242
  input: PlayLogReadInput,
201
243
  result: PlayLogReadResult,
202
244
  errors: [NO_PROJECT_ROOT_ERROR, PATH_OUTSIDE_PROJECT_ERROR, FILE_NOT_FOUND_ERROR],
@@ -209,12 +251,33 @@ export const playLogRead = defineTool({
209
251
  const projectRoot = requireProjectRoot(ctx);
210
252
  const absPath = resolveProjectPath(projectRoot, join('logs', input.file));
211
253
  const { raw } = readFileWithHash(absPath);
212
- const lines = raw.split('\n').filter((l) => l.trim().length > 0);
254
+ const records = raw
255
+ .split('\n')
256
+ .filter((l) => l.trim().length > 0)
257
+ .map((line): unknown => JSON.parse(line));
258
+ // The header is the file's identity, not one of its entries: it is peeled
259
+ // off before offset/limit and before totalEntries, so a caller paging
260
+ // through a log never has to know it is there.
261
+ const header: PlayLogHeader | null = records.length > 0 ? asPlayLogHeader(records[0]) : null;
262
+ const entryRecords = header === null ? records : records.slice(1);
213
263
  const offset = input.offset ?? 0;
214
- const limit = input.limit ?? lines.length;
215
- const slice = lines.slice(offset, offset + limit);
216
- const entries = slice.map((line) => parseLogLine(line, input.file, projectRoot));
217
- return { entries, totalEntries: lines.length };
264
+ const limit = input.limit ?? entryRecords.length;
265
+ const entries = entryRecords
266
+ .slice(offset, offset + limit)
267
+ .map((record) =>
268
+ parseLogLine(isRecord(record) ? record : {}, header?.session ?? null, projectRoot),
269
+ );
270
+ return {
271
+ entries,
272
+ totalEntries: entryRecords.length,
273
+ run: {
274
+ file: input.file,
275
+ session: header?.session ?? null,
276
+ project: header?.project ?? null,
277
+ run: header?.run ?? null,
278
+ startedAt: header?.startedAt ?? null,
279
+ },
280
+ };
218
281
  },
219
282
  });
220
283
 
@@ -235,7 +298,17 @@ const PlayLogFollowResult = z.object({
235
298
  .describe('POST {action:"start"|"end"} — already bracketing the active play session.'),
236
299
  bufferedLogCount: z
237
300
  .number()
238
- .describe('Live count of entries buffered for the active play session.'),
301
+ .describe('Live count of entries buffered for the active play session (header excluded).'),
302
+ file: z
303
+ .string()
304
+ .nullable()
305
+ .describe("The active run's log filename under logs/ — what play.log.read should be given."),
306
+ session: z
307
+ .string()
308
+ .nullable()
309
+ .describe("The editor session that opened it, from the file's header line."),
310
+ project: z.string().nullable().describe('Absolute project root that session is serving.'),
311
+ run: z.string().nullable().describe('The run name slug, null for an unnamed run.'),
239
312
  });
240
313
 
241
314
  export const playLogFollow = defineTool({
@@ -244,7 +317,8 @@ export const playLogFollow = defineTool({
244
317
  description:
245
318
  'Metadata only (no push/SSE for log lines exists today): reports the real ' +
246
319
  'GET/POST /__editor/log-entries + /__editor/log-session endpoints for the CURRENTLY active ' +
247
- 'play session, plus a live buffered-entry count same contract as editor.console.subscribe.',
320
+ 'play session, plus a live buffered-entry count and the run identity the active log file ' +
321
+ 'recorded in its header — same contract as editor.console.subscribe.',
248
322
  input: PlayLogFollowInput,
249
323
  result: PlayLogFollowResult,
250
324
  errors: [PLAY_RUNTIME_NOT_AVAILABLE_ERROR, PLAY_LOG_FOLLOW_UNAVAILABLE_ERROR],
@@ -268,7 +342,18 @@ export const playLogFollow = defineTool({
268
342
  {},
269
343
  );
270
344
  }
271
- return metadata;
345
+ // A transport that predates the header (or an older editor answering it)
346
+ // reports the endpoints and the count and says `null` for the identity —
347
+ // absence, never a name inferred from somewhere else.
348
+ return {
349
+ logEntriesUrl: metadata.logEntriesUrl,
350
+ logSessionUrl: metadata.logSessionUrl,
351
+ bufferedLogCount: metadata.bufferedLogCount,
352
+ file: metadata.file ?? null,
353
+ session: metadata.session ?? null,
354
+ project: metadata.project ?? null,
355
+ run: metadata.run ?? null,
356
+ };
272
357
  },
273
358
  });
274
359
 
@@ -79,6 +79,7 @@ import { z } from 'zod';
79
79
  import { ToolError } from '../errors.js';
80
80
  import type { ToolErrorDefinition } from '../registry.js';
81
81
  import type { ToolContext } from '../types.js';
82
+ import { asPlayLogHeader } from './log-format.js';
82
83
 
83
84
  // ---------------------------------------------------------------------------
84
85
  // Named timeouts
@@ -239,6 +240,13 @@ export interface PlayLogFollowMetadata {
239
240
  logEntriesUrl: string;
240
241
  logSessionUrl: string;
241
242
  bufferedLogCount: number;
243
+ /** The active log's filename and the identity its header line recorded
244
+ * (`@vgai/sdk`'s `play/log-format.ts`). Optional on the seam so a fake
245
+ * transport may omit what it has no wire for; the op reports `null`. */
246
+ file?: string | null;
247
+ session?: string | null;
248
+ project?: string | null;
249
+ run?: string | null;
242
250
  }
243
251
 
244
252
  export type InputInjectionKind = 'axis' | 'vector2' | 'pointerDelta' | 'pointerPosition';
@@ -633,13 +641,18 @@ export class HttpPlayTransport implements PlayTransport {
633
641
  timeoutMs: number,
634
642
  ): Promise<PlayLogFollowMetadata | undefined> {
635
643
  const body = (await fetchJson(`${baseUrl(session)}/__editor/log-entries`, timeoutMs)) as
636
- | { entries?: unknown[] }
644
+ | { entries?: unknown[]; header?: unknown; file?: unknown }
637
645
  | undefined;
638
646
  if (!body) return undefined;
647
+ const header = asPlayLogHeader(body.header);
639
648
  return {
640
649
  logEntriesUrl: `${baseUrl(session)}/__editor/log-entries`,
641
650
  logSessionUrl: `${baseUrl(session)}/__editor/log-session`,
642
651
  bufferedLogCount: Array.isArray(body.entries) ? body.entries.length : 0,
652
+ file: typeof body.file === 'string' ? body.file : null,
653
+ session: header?.session ?? null,
654
+ project: header?.project ?? null,
655
+ run: header?.run ?? null,
643
656
  };
644
657
  }
645
658