wtagent 0.1.0-alpha.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.
@@ -0,0 +1,569 @@
1
+ import fs from "node:fs/promises";
2
+ import { constants as fsConstants } from "node:fs";
3
+ import path from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import { isPathInside } from "../policy/path-guard.js";
6
+
7
+ const DIRECTORY_MODE = 0o700;
8
+ const FILE_MODE = 0o600;
9
+ const NO_FOLLOW = process.platform === "win32"
10
+ ? 0
11
+ : (fsConstants.O_NOFOLLOW ?? 0);
12
+
13
+ async function chmodOwnerOnly(target, mode) {
14
+ if (process.platform !== "win32") {
15
+ await fs.chmod(target, mode);
16
+ }
17
+ }
18
+
19
+ async function ensureSessionsRoot(sessionsDir) {
20
+ const requested = path.resolve(sessionsDir);
21
+ await fs.mkdir(requested, {
22
+ recursive: true,
23
+ mode: DIRECTORY_MODE,
24
+ });
25
+ await chmodOwnerOnly(requested, DIRECTORY_MODE);
26
+
27
+ const root = await fs.realpath(requested);
28
+ const stats = await fs.stat(root);
29
+ if (!stats.isDirectory()) {
30
+ throw new Error(`Sessions path is not a directory: ${root}`);
31
+ }
32
+ return root;
33
+ }
34
+
35
+ async function resolveSessionDirectory(sessionsDir, sessionId) {
36
+ const root = await fs.realpath(path.resolve(sessionsDir));
37
+ const directoryPath = path.join(root, sessionId);
38
+ const lexicalStats = await fs.lstat(directoryPath);
39
+
40
+ if (lexicalStats.isSymbolicLink()) {
41
+ throw new Error(`Session directory cannot be a symbolic link: ${directoryPath}`);
42
+ }
43
+ if (!lexicalStats.isDirectory()) {
44
+ throw new Error(`Session path is not a directory: ${directoryPath}`);
45
+ }
46
+
47
+ const directory = await fs.realpath(directoryPath);
48
+ if (!isPathInside(root, directory)) {
49
+ throw new Error(`Session directory escapes sessions directory: ${directory}`);
50
+ }
51
+
52
+ return { root, directory };
53
+ }
54
+
55
+ async function assertSafeFile(filePath, { allowMissing = false } = {}) {
56
+ try {
57
+ const stats = await fs.lstat(filePath);
58
+ if (stats.isSymbolicLink()) {
59
+ throw new Error(`Session file cannot be a symbolic link: ${filePath}`);
60
+ }
61
+ if (!stats.isFile()) {
62
+ throw new Error(`Session path is not a regular file: ${filePath}`);
63
+ }
64
+ } catch (error) {
65
+ if (allowMissing && error.code === "ENOENT") {
66
+ return;
67
+ }
68
+ throw error;
69
+ }
70
+ }
71
+
72
+ async function writeJsonAtomic(filePath, value) {
73
+ await assertSafeFile(filePath, { allowMissing: true });
74
+ const temporary = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
75
+ let handle;
76
+
77
+ try {
78
+ handle = await fs.open(
79
+ temporary,
80
+ fsConstants.O_WRONLY
81
+ | fsConstants.O_CREAT
82
+ | fsConstants.O_EXCL
83
+ | NO_FOLLOW,
84
+ FILE_MODE,
85
+ );
86
+ await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, "utf8");
87
+ if (process.platform !== "win32") {
88
+ await handle.chmod(FILE_MODE);
89
+ }
90
+ await handle.sync();
91
+ await handle.close();
92
+ handle = null;
93
+
94
+ try {
95
+ await fs.rename(temporary, filePath);
96
+ } catch (error) {
97
+ if (process.platform !== "win32") {
98
+ throw error;
99
+ }
100
+ await fs.rm(filePath, { force: true });
101
+ await fs.rename(temporary, filePath);
102
+ }
103
+ } finally {
104
+ await handle?.close().catch(() => {});
105
+ await fs.rm(temporary, { force: true }).catch(() => {});
106
+ }
107
+ }
108
+
109
+ async function appendOwnerOnly(filePath, content) {
110
+ await assertSafeFile(filePath, { allowMissing: true });
111
+ const handle = await fs.open(
112
+ filePath,
113
+ fsConstants.O_WRONLY
114
+ | fsConstants.O_APPEND
115
+ | fsConstants.O_CREAT
116
+ | NO_FOLLOW,
117
+ FILE_MODE,
118
+ );
119
+
120
+ try {
121
+ const stats = await handle.stat();
122
+ if (!stats.isFile()) {
123
+ throw new Error(`Session path is not a regular file: ${filePath}`);
124
+ }
125
+ if (process.platform !== "win32") {
126
+ await handle.chmod(FILE_MODE);
127
+ }
128
+ await handle.writeFile(content, "utf8");
129
+ await handle.sync();
130
+ } finally {
131
+ await handle.close();
132
+ }
133
+ }
134
+
135
+ function rolloutFileName(createdAt, sessionId) {
136
+ const stamp = createdAt.replaceAll(":", "-");
137
+ return `rollout-${stamp}-${sessionId}.jsonl`;
138
+ }
139
+
140
+ function sessionMetaRecord(state) {
141
+ return {
142
+ timestamp: state.createdAt,
143
+ type: "session_meta",
144
+ payload: {
145
+ id: state.sessionId,
146
+ session_id: state.sessionId,
147
+ timestamp: state.createdAt,
148
+ cwd: state.projectRoot,
149
+ originator: "wtagent",
150
+ source: "wtagent",
151
+ base_instructions: null,
152
+ },
153
+ };
154
+ }
155
+
156
+ export class AgentSession {
157
+ constructor({ sessionsDir, directory, state, stateFileName = "session.json" }) {
158
+ this.sessionsDir = sessionsDir;
159
+ this.directory = directory;
160
+ this.state = state;
161
+ this.sessionIdentifier = state.sessionId;
162
+ this.stateFileName = stateFileName;
163
+ }
164
+
165
+ static async create({ sessionsDir, tasksDir, task, projectRoot, mode }) {
166
+ const root = await ensureSessionsRoot(sessionsDir ?? tasksDir);
167
+ const sessionId = `session_${new Date().toISOString().replaceAll(/[-:.TZ]/g, "").slice(0, 14)}_${randomUUID().slice(0, 8)}`;
168
+ const directory = path.join(root, sessionId);
169
+ await fs.mkdir(directory, { mode: DIRECTORY_MODE });
170
+ await chmodOwnerOnly(directory, DIRECTORY_MODE);
171
+
172
+ const now = new Date().toISOString();
173
+ const state = {
174
+ sessionId,
175
+ task,
176
+ projectRoot: path.resolve(projectRoot),
177
+ mode,
178
+ phase: "idle",
179
+ turn: 0,
180
+ runCount: 0,
181
+ conversationUrl: null,
182
+ createdAt: now,
183
+ updatedAt: now,
184
+ rolloutFile: rolloutFileName(now, sessionId),
185
+ completedTools: {},
186
+ sideEffectTools: {},
187
+ pendingToolResult: null,
188
+ followUps: [],
189
+ lastMessage: null,
190
+ lastError: null,
191
+ };
192
+ const session = new AgentSession({
193
+ sessionsDir: root,
194
+ directory,
195
+ state,
196
+ });
197
+ await session.save();
198
+ await appendOwnerOnly(
199
+ path.join(directory, state.rolloutFile),
200
+ `${JSON.stringify(sessionMetaRecord(state))}\n`,
201
+ );
202
+ await session.appendEvent("session.created", {
203
+ task,
204
+ projectRoot: state.projectRoot,
205
+ mode,
206
+ });
207
+ return session;
208
+ }
209
+
210
+ static async load({ sessionsDir, tasksDir, sessionId, taskId }) {
211
+ const identifier = sessionId ?? taskId;
212
+ const rootDirectory = sessionsDir ?? tasksDir;
213
+ if (!/^[a-zA-Z0-9_-]+$/.test(identifier)) {
214
+ throw new Error(`Invalid session ID: ${identifier}`);
215
+ }
216
+
217
+ const { root, directory } = await resolveSessionDirectory(
218
+ rootDirectory,
219
+ identifier,
220
+ );
221
+ let stateFileName = "session.json";
222
+ let statePath = path.join(directory, stateFileName);
223
+ try {
224
+ await assertSafeFile(statePath);
225
+ } catch (error) {
226
+ if (error.code !== "ENOENT") {
227
+ throw error;
228
+ }
229
+ stateFileName = "task.json";
230
+ statePath = path.join(directory, stateFileName);
231
+ }
232
+ await assertSafeFile(statePath);
233
+ const state = JSON.parse(await fs.readFile(statePath, "utf8"));
234
+
235
+ state.sessionId ??= state.taskId;
236
+ if (state.sessionId !== identifier) {
237
+ throw new Error(
238
+ `Session ID mismatch: expected ${identifier}, found ${String(state.sessionId)}`,
239
+ );
240
+ }
241
+
242
+ state.phase ??= ["completed", "paused"].includes(state.status)
243
+ ? "idle"
244
+ : (state.status ?? "idle");
245
+ state.runCount ??= 0;
246
+ state.rolloutFile ??= "transcript.jsonl";
247
+ state.lastMessage ??= state.finalMessage ?? null;
248
+ state.completedTools ??= {};
249
+ state.sideEffectTools ??= {};
250
+ state.pendingToolResult ??= null;
251
+ state.followUps ??= [];
252
+ return new AgentSession({
253
+ sessionsDir: root,
254
+ directory,
255
+ state,
256
+ stateFileName,
257
+ });
258
+ }
259
+
260
+ static async list({ sessionsDir, tasksDir, limit = 20 }) {
261
+ const rootDirectory = sessionsDir ?? tasksDir;
262
+ const entries = await fs.readdir(rootDirectory, { withFileTypes: true })
263
+ .catch((error) => {
264
+ if (error.code === "ENOENT") {
265
+ return [];
266
+ }
267
+ throw error;
268
+ });
269
+ const states = [];
270
+
271
+ for (const entry of entries) {
272
+ if (!entry.isDirectory() || !/^[a-zA-Z0-9_-]+$/.test(entry.name)) {
273
+ continue;
274
+ }
275
+ try {
276
+ const session = await AgentSession.load({
277
+ sessionsDir: rootDirectory,
278
+ sessionId: entry.name,
279
+ });
280
+ states.push(session.state);
281
+ } catch {
282
+ // Ignore incomplete, corrupt, or unsafe session directories in listings.
283
+ }
284
+ }
285
+
286
+ return states
287
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
288
+ .slice(0, limit);
289
+ }
290
+
291
+ get sessionId() {
292
+ return this.sessionIdentifier;
293
+ }
294
+
295
+ // Backward-compatible alias for integrations created before sessions became
296
+ // the primary lifecycle entity.
297
+ get taskId() {
298
+ return this.sessionIdentifier;
299
+ }
300
+
301
+ async validateDirectory() {
302
+ if (this.state.sessionId !== this.sessionIdentifier) {
303
+ throw new Error("Session ID cannot be changed after creation.");
304
+ }
305
+
306
+ const { root, directory } = await resolveSessionDirectory(
307
+ this.sessionsDir,
308
+ this.sessionIdentifier,
309
+ );
310
+ if (root !== this.sessionsDir || directory !== this.directory) {
311
+ throw new Error(`Session directory identity changed: ${this.directory}`);
312
+ }
313
+ }
314
+
315
+ async update(patch) {
316
+ Object.assign(this.state, patch, { updatedAt: new Date().toISOString() });
317
+ await this.save();
318
+ }
319
+
320
+ async save() {
321
+ await this.validateDirectory();
322
+ await writeJsonAtomic(
323
+ path.join(this.directory, this.stateFileName),
324
+ this.state,
325
+ );
326
+ }
327
+
328
+ async appendEvent(type, payload = {}) {
329
+ await this.validateDirectory();
330
+ const event = {
331
+ type,
332
+ sessionId: this.sessionId,
333
+ timestamp: new Date().toISOString(),
334
+ payload,
335
+ };
336
+ await appendOwnerOnly(
337
+ path.join(this.directory, "events.jsonl"),
338
+ `${JSON.stringify(event)}\n`,
339
+ );
340
+ return event;
341
+ }
342
+
343
+ async appendToolOutput(payload) {
344
+ await this.validateDirectory();
345
+ const record = {
346
+ sessionId: this.sessionId,
347
+ timestamp: new Date().toISOString(),
348
+ ...payload,
349
+ };
350
+ await appendOwnerOnly(
351
+ path.join(this.directory, "tool-output.jsonl"),
352
+ `${JSON.stringify(record)}\n`,
353
+ );
354
+ return record;
355
+ }
356
+
357
+ // Appends one canonical transcript item directly to this conversation's
358
+ // Codex rollout JSONL. The XML sent to ChatGPT Web is transport-only.
359
+ async appendTranscriptItem(item, { kind = "response_item" } = {}) {
360
+ await this.validateDirectory();
361
+ const record = {
362
+ timestamp: new Date().toISOString(),
363
+ type: kind,
364
+ payload: item,
365
+ };
366
+ await appendOwnerOnly(
367
+ path.join(this.directory, this.state.rolloutFile),
368
+ `${JSON.stringify(record)}\n`,
369
+ );
370
+ return record;
371
+ }
372
+
373
+ // Reads the conversation rollout as { meta, items } for portable exporters.
374
+ async readTranscript() {
375
+ await this.validateDirectory();
376
+ const transcriptPath = path.join(this.directory, this.state.rolloutFile);
377
+ let raw;
378
+ try {
379
+ await assertSafeFile(transcriptPath);
380
+ raw = await fs.readFile(transcriptPath, "utf8");
381
+ } catch (error) {
382
+ if (error.code === "ENOENT") {
383
+ return { meta: this.#transcriptMeta(), items: [] };
384
+ }
385
+ throw error;
386
+ }
387
+
388
+ const items = [];
389
+ let storedMeta = null;
390
+ for (const line of raw.split(/\r?\n/)) {
391
+ if (!line.trim()) {
392
+ continue;
393
+ }
394
+ const record = JSON.parse(line);
395
+ if (record.type === "session_meta") {
396
+ storedMeta = record.payload ?? null;
397
+ continue;
398
+ }
399
+ if (record.type === "response_item") {
400
+ items.push({
401
+ timestamp: record.timestamp,
402
+ item: record.payload ?? record.item,
403
+ });
404
+ }
405
+ }
406
+ return { meta: this.#transcriptMeta(storedMeta), items };
407
+ }
408
+
409
+ #transcriptMeta(storedMeta = null) {
410
+ return {
411
+ sessionId: storedMeta?.id
412
+ ?? storedMeta?.session_id
413
+ ?? this.state.sessionId,
414
+ cwd: storedMeta?.cwd ?? this.state.projectRoot,
415
+ createdAt: storedMeta?.timestamp ?? this.state.createdAt,
416
+ baseInstructions: storedMeta?.base_instructions ?? null,
417
+ task: this.state.task,
418
+ mode: this.state.mode,
419
+ };
420
+ }
421
+
422
+ getToolResult(fingerprint) {
423
+ return this.state.completedTools[fingerprint]?.result ?? null;
424
+ }
425
+
426
+ getSideEffectTool(operationKey) {
427
+ return this.state.sideEffectTools[operationKey] ?? null;
428
+ }
429
+
430
+ async claimSideEffectTool(identity) {
431
+ const existing = this.getSideEffectTool(identity.operationKey);
432
+ if (existing) {
433
+ throw new Error(
434
+ `Tool operation is already claimed: ${identity.operationKey}`,
435
+ );
436
+ }
437
+
438
+ const claimedAt = new Date().toISOString();
439
+ this.state.sideEffectTools[identity.operationKey] = {
440
+ ...identity,
441
+ status: "running",
442
+ claimedAt,
443
+ updatedAt: claimedAt,
444
+ result: null,
445
+ };
446
+ await this.save();
447
+ return await this.appendEvent("tool.claimed", {
448
+ identity,
449
+ });
450
+ }
451
+
452
+ async recordToolResult(fingerprint, result, { identity = null } = {}) {
453
+ const completedAt = new Date().toISOString();
454
+ this.state.completedTools[fingerprint] = {
455
+ completedAt,
456
+ result,
457
+ };
458
+
459
+ if (identity) {
460
+ const existing = this.getSideEffectTool(identity.operationKey);
461
+ if (!existing || existing.fingerprint !== identity.fingerprint) {
462
+ throw new Error(
463
+ `Tool operation changed before completion: ${identity.operationKey}`,
464
+ );
465
+ }
466
+ this.state.sideEffectTools[identity.operationKey] = {
467
+ ...existing,
468
+ status: "completed",
469
+ completedAt,
470
+ updatedAt: completedAt,
471
+ result,
472
+ };
473
+ }
474
+
475
+ this.state.pendingToolResult = result;
476
+ await this.save();
477
+ return await this.appendEvent("tool.completed", {
478
+ fingerprint,
479
+ result,
480
+ });
481
+ }
482
+
483
+ async markSideEffectToolUnknown(identity, result) {
484
+ const existing = this.getSideEffectTool(identity.operationKey);
485
+ if (!existing || existing.fingerprint !== identity.fingerprint) {
486
+ throw new Error(
487
+ `Tool operation changed before unknown completion: ${identity.operationKey}`,
488
+ );
489
+ }
490
+
491
+ const unknownAt = new Date().toISOString();
492
+ this.state.sideEffectTools[identity.operationKey] = {
493
+ ...existing,
494
+ status: "unknown",
495
+ unknownAt,
496
+ updatedAt: unknownAt,
497
+ result,
498
+ };
499
+ this.state.pendingToolResult = result;
500
+ await this.save();
501
+ return await this.appendEvent("tool.completion_unknown", {
502
+ identity,
503
+ result,
504
+ });
505
+ }
506
+
507
+ async setPendingToolResult(result) {
508
+ await this.update({ pendingToolResult: result });
509
+ }
510
+
511
+ async clearPendingToolResult() {
512
+ if (this.state.pendingToolResult == null) {
513
+ return;
514
+ }
515
+ await this.update({ pendingToolResult: null });
516
+ }
517
+
518
+ async appendInstruction(instruction, { files = [] } = {}) {
519
+ const item = {
520
+ instruction,
521
+ createdAt: new Date().toISOString(),
522
+ };
523
+ if (files.length > 0) {
524
+ item.attachments = files.map((file) => ({
525
+ name: file.name ?? null,
526
+ path: file.path ?? null,
527
+ }));
528
+ }
529
+ this.state.followUps.push(item);
530
+ this.state.lastError = null;
531
+ await this.save();
532
+ await this.appendEvent("session.instruction_added", item);
533
+ }
534
+
535
+ async recoverInterruptedSideEffects() {
536
+ for (const [operationKey, entry] of Object.entries(this.state.sideEffectTools)) {
537
+ if (entry.status !== "running") {
538
+ continue;
539
+ }
540
+ const result = {
541
+ callId: entry.callId,
542
+ name: entry.name,
543
+ operationSignature: entry.requestSignature,
544
+ ok: false,
545
+ message:
546
+ "This tool operation may have started before the local process was interrupted. "
547
+ + "Its completion is unknown, so it will not be replayed automatically. "
548
+ + "Inspect local state before issuing a deliberate follow-up operation.",
549
+ meta: {
550
+ completionUnknown: true,
551
+ recoverable: true,
552
+ },
553
+ };
554
+ const now = new Date().toISOString();
555
+ this.state.sideEffectTools[operationKey] = {
556
+ ...entry,
557
+ status: "unknown",
558
+ unknownAt: now,
559
+ updatedAt: now,
560
+ result,
561
+ };
562
+ this.state.pendingToolResult ??= result;
563
+ }
564
+ await this.save();
565
+ }
566
+ }
567
+
568
+ // Compatibility export for callers using the pre-session class name.
569
+ export { AgentSession as TaskSession };
@@ -0,0 +1,145 @@
1
+ // Canonical conversation transcript.
2
+ //
3
+ // The source of truth for a task's dialogue is stored in this Codex-compatible
4
+ // shape from the very first exchange. ChatGPT Web only ever sees a rendered
5
+ // projection (XML for tool results, a marked prompt for instructions); the
6
+ // structured record below is what we persist and later export to Codex or
7
+ // Claude Code sessions.
8
+ //
9
+ // Item payloads follow the OpenAI Responses item schema that Codex writes into
10
+ // its rollout JSONL (`type: "response_item"`):
11
+ // - message { role, content: [{ type: "input_text"|"output_text", text }] }
12
+ // - function_call { name, arguments (JSON string), call_id }
13
+ // - function_call_output { call_id, output (string) }
14
+ //
15
+ // A leading `session_meta` record mirrors Codex's rollout header. Timestamps
16
+ // are attached by the persistence layer at append time (kept out of these pure
17
+ // builders so they stay deterministic and unit-testable).
18
+
19
+ export const TRANSCRIPT_VERSION = 1;
20
+
21
+ function inputText(text) {
22
+ return [{ type: "input_text", text: String(text ?? "") }];
23
+ }
24
+
25
+ function outputText(text) {
26
+ return [{ type: "output_text", text: String(text ?? "") }];
27
+ }
28
+
29
+ // A `developer` message carries WTAgent scaffolding (protocol + tool catalog).
30
+ // Exporters treat developer content as strippable / relocatable, never as user
31
+ // intent.
32
+ export function developerMessage(text) {
33
+ return {
34
+ type: "message",
35
+ role: "developer",
36
+ content: inputText(text),
37
+ };
38
+ }
39
+
40
+ // A user message. `attachments` records any @file uploads that accompanied the
41
+ // message on the web transport (name + local path), so exporters can note them
42
+ // even though the uploaded bytes live only in the ChatGPT conversation.
43
+ export function userMessage(text, { attachments = [] } = {}) {
44
+ const item = {
45
+ type: "message",
46
+ role: "user",
47
+ content: inputText(text),
48
+ };
49
+ if (attachments.length > 0) {
50
+ item.attachments = attachments.map((file) => ({
51
+ name: file.name ?? basename(file.path ?? ""),
52
+ path: file.path ?? null,
53
+ }));
54
+ }
55
+ return item;
56
+ }
57
+
58
+ function basename(filePath) {
59
+ const parts = String(filePath).split(/[/\\]/);
60
+ return parts[parts.length - 1] || String(filePath);
61
+ }
62
+
63
+ export function assistantMessage(text) {
64
+ return {
65
+ type: "message",
66
+ role: "assistant",
67
+ content: outputText(text),
68
+ };
69
+ }
70
+
71
+ export function functionCall({ name, args, callId }) {
72
+ return {
73
+ type: "function_call",
74
+ name: String(name ?? ""),
75
+ arguments: typeof args === "string" ? args : JSON.stringify(args ?? {}),
76
+ call_id: String(callId ?? ""),
77
+ };
78
+ }
79
+
80
+ export function functionCallOutput({ callId, output }) {
81
+ return {
82
+ type: "function_call_output",
83
+ call_id: String(callId ?? ""),
84
+ output: typeof output === "string" ? output : JSON.stringify(output ?? ""),
85
+ };
86
+ }
87
+
88
+ // Normalizes a WTAgent ToolResult (from the tool registry) into the compact
89
+ // text Codex/CC expect for a tool output, keeping the machine-readable JSON but
90
+ // avoiding the XML envelope used only for the web transport.
91
+ export function toolResultOutput(result) {
92
+ const lines = [];
93
+ lines.push(`status: ${result.ok ? "ok" : "error"}`);
94
+ if (result.message) {
95
+ lines.push(result.message);
96
+ }
97
+ if (result.stdout) {
98
+ lines.push(`stdout:\n${result.stdout}`);
99
+ }
100
+ if (result.stderr) {
101
+ lines.push(`stderr:\n${result.stderr}`);
102
+ }
103
+ if (result.data != null) {
104
+ const data = typeof result.data === "string"
105
+ ? result.data
106
+ : JSON.stringify(result.data);
107
+ lines.push(`data: ${data}`);
108
+ }
109
+ return lines.join("\n");
110
+ }
111
+
112
+ export function sessionMeta({
113
+ sessionId,
114
+ cwd,
115
+ createdAt,
116
+ baseInstructions,
117
+ task,
118
+ mode,
119
+ }) {
120
+ return {
121
+ id: sessionId,
122
+ session_id: sessionId,
123
+ timestamp: createdAt ?? null,
124
+ cwd,
125
+ originator: "wtagent",
126
+ source: "wtagent",
127
+ base_instructions: baseInstructions ?? null,
128
+ task: task ?? null,
129
+ mode: mode ?? null,
130
+ transcriptVersion: TRANSCRIPT_VERSION,
131
+ };
132
+ }
133
+
134
+ export function isDeveloper(item) {
135
+ return item?.type === "message" && item.role === "developer";
136
+ }
137
+
138
+ export function messageText(item) {
139
+ if (item?.type !== "message" || !Array.isArray(item.content)) {
140
+ return "";
141
+ }
142
+ return item.content
143
+ .map((part) => (typeof part?.text === "string" ? part.text : ""))
144
+ .join("");
145
+ }