@flowdular/sandbox 0.3.1 → 0.4.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 (72) hide show
  1. package/internal/coding-agent/src/drivers/byok.ts +16 -1
  2. package/internal/coding-agent/src/index.ts +1 -0
  3. package/internal/coding-agent/src/roles/defaults.ts +9 -0
  4. package/internal/coding-agent/src/roles/skills.ts +1 -0
  5. package/internal/coding-agent/src/types.ts +11 -0
  6. package/package.json +2 -2
  7. package/public/og.png +0 -0
  8. package/src/App.tsrx +77 -56
  9. package/src/client/DiffPane.tsrx +3 -7
  10. package/src/client/EjectModal.tsrx +7 -9
  11. package/src/client/GitHubSettingsModal.tsrx +8 -8
  12. package/src/client/ModelSettingsModal.tsrx +5 -5
  13. package/src/client/NewSessionForm.tsrx +1 -1
  14. package/src/client/PendingQuestionsCard.tsrx +1 -1
  15. package/src/client/SessionPicker.tsrx +49 -36
  16. package/src/client/SpecEditor.tsrx +1 -1
  17. package/src/client/locales/en.json +30 -16
  18. package/src/client/locales/pl.json +78 -28
  19. package/src/server/delivery/configuration.ts +16 -0
  20. package/src/server/delivery/git-pr.ts +51 -22
  21. package/src/server/delivery/types.ts +2 -0
  22. package/src/server/gates.ts +34 -8
  23. package/src/server/preview-modules.ts +43 -0
  24. package/src/server/preview-revision.ts +1 -0
  25. package/src/server/preview-runtime.ts +41 -4
  26. package/src/server/preview-seed.ts +115 -0
  27. package/src/server/recorded-adapters.ts +132 -0
  28. package/src/server/sample-data.ts +403 -0
  29. package/src/server/turns.ts +16 -1
  30. package/dist/client/assets/App-ClMOrpyg.js +0 -11
  31. package/dist/client/assets/PreviewHost-BHiHrfbQ.js +0 -1
  32. package/dist/client/assets/auto-buGEq6de.js +0 -3
  33. package/dist/client/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
  34. package/dist/client/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
  35. package/dist/client/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
  36. package/dist/client/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
  37. package/dist/client/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
  38. package/dist/client/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
  39. package/dist/client/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
  40. package/dist/client/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
  41. package/dist/client/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  42. package/dist/client/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  43. package/dist/client/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  44. package/dist/client/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  45. package/dist/client/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
  46. package/dist/client/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
  47. package/dist/client/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
  48. package/dist/client/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
  49. package/dist/client/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
  50. package/dist/client/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
  51. package/dist/client/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
  52. package/dist/client/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
  53. package/dist/client/assets/ibm-plex-sans-cyrillic-ext-wght-normal-d45eAU9y.woff2 +0 -0
  54. package/dist/client/assets/ibm-plex-sans-cyrillic-wght-normal-BAAhND-U.woff2 +0 -0
  55. package/dist/client/assets/ibm-plex-sans-greek-wght-normal-CmyJS8uq.woff2 +0 -0
  56. package/dist/client/assets/ibm-plex-sans-latin-ext-wght-normal-CIII54If.woff2 +0 -0
  57. package/dist/client/assets/ibm-plex-sans-latin-wght-normal-IvpUvPa2.woff2 +0 -0
  58. package/dist/client/assets/ibm-plex-sans-vietnamese-wght-normal-Dg1JeJN0.woff2 +0 -0
  59. package/dist/client/assets/index-CvwhlQ87.js +0 -4
  60. package/dist/client/assets/styles-BPWaGf55.js +0 -3
  61. package/dist/client/assets/styles-Dx1_KOag.css +0 -1
  62. package/dist/client/favicon.svg +0 -1
  63. package/dist/server/assets/auto-CVGNYyb3.js +0 -12785
  64. package/dist/server/assets/chunk-DDJLRBDX-3OUNo51g.js +0 -4125
  65. package/dist/server/assets/nodefs-DVSI7kG0.js +0 -24
  66. package/dist/server/assets/opfs-ahp-D56iokt1.js +0 -388
  67. package/dist/server/assets/token-Bvaq7Anz.js +0 -55
  68. package/dist/server/assets/token-util-BqSvdIPQ.js +0 -3
  69. package/dist/server/assets/token-util-CktFFT4z.js +0 -494
  70. package/dist/server/entry.js +0 -91671
  71. package/dist/server/favicon.svg +0 -1
  72. package/dist/server/index.html +0 -161
@@ -0,0 +1,403 @@
1
+ import { lstat, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import type { CodingAgentTool } from '#coding-agent';
4
+ import { MAX_ATTACHMENT_BYTES } from './attachments.ts';
5
+ import type { SessionAttachment } from './sessions.ts';
6
+ import { SandboxSetupError } from './workspace-root.ts';
7
+
8
+ export const SAMPLE_DATA_TOOL = 'sample-data';
9
+ export const SAMPLE_DATA_ROWS = 20;
10
+ export const SAMPLE_DATA_REFERENCE = 'reference/sample-data.json';
11
+ const MAX_COLUMNS = 40;
12
+ const MAX_CELL = 200;
13
+ const MAX_JSON_DEPTH = 4;
14
+ export const MAX_SAMPLE_PREVIEW_BYTES = 32 * 1024;
15
+ export const MAX_SAMPLE_LISTING_BYTES = 64 * 1024;
16
+
17
+ export type SampleDataFormat = 'csv' | 'json' | 'text';
18
+
19
+ const FORMATS: Readonly<Record<string, SampleDataFormat>> = {
20
+ csv: 'csv',
21
+ json: 'json',
22
+ txt: 'text',
23
+ };
24
+
25
+ export interface SampleDataPreview {
26
+ readonly name: string;
27
+ readonly format: SampleDataFormat;
28
+ readonly size: number;
29
+ /* Data rows without the CSV header, items of the JSON collection, or lines. */
30
+ readonly rowCount: number;
31
+ readonly columns: readonly string[];
32
+ readonly rows: readonly unknown[];
33
+ /* True when a row, column or value was left out of the preview. */
34
+ readonly truncated: boolean;
35
+ readonly delimiter?: string;
36
+ /* Where the JSON rows came from: `$` for a top-level array, `$.key` otherwise. */
37
+ readonly path?: string;
38
+ readonly note?: string;
39
+ readonly error?: string;
40
+ }
41
+
42
+ export interface SampleDataListing {
43
+ readonly sampleData: readonly SampleDataPreview[];
44
+ }
45
+
46
+ export function sampleDataFormat(name: string): SampleDataFormat | null {
47
+ const dot = name.lastIndexOf('.');
48
+ return dot > 0 ? (FORMATS[name.slice(dot + 1).toLowerCase()] ?? null) : null;
49
+ }
50
+
51
+ export function sampleDataAttachments(
52
+ attachments: readonly SessionAttachment[],
53
+ ): readonly SessionAttachment[] {
54
+ return attachments.filter(
55
+ (item) => item.kind === 'file' && sampleDataFormat(item.name) !== null,
56
+ );
57
+ }
58
+
59
+ function cell(value: string): { value: string; cut: boolean } {
60
+ return value.length > MAX_CELL
61
+ ? { value: `${value.slice(0, MAX_CELL)}...`, cut: true }
62
+ : { value, cut: false };
63
+ }
64
+
65
+ function delimiterOf(text: string): string {
66
+ const counts = new Map([
67
+ [',', 0],
68
+ [';', 0],
69
+ ['\t', 0],
70
+ ]);
71
+ let quoted = false;
72
+ for (const character of text) {
73
+ if (character === '"') quoted = !quoted;
74
+ else if (!quoted && (character === '\n' || character === '\r')) break;
75
+ else if (!quoted && counts.has(character))
76
+ counts.set(character, counts.get(character)! + 1);
77
+ }
78
+ let best = ',';
79
+ for (const [candidate, count] of counts)
80
+ if (count > counts.get(best)!) best = candidate;
81
+ return best;
82
+ }
83
+
84
+ /* RFC 4180 with a detected delimiter: quoted fields may hold delimiters, line
85
+ breaks and doubled quotes. Every record is counted; only the header and the
86
+ first rows are kept. */
87
+ function previewCsv(
88
+ text: string,
89
+ ): Pick<
90
+ SampleDataPreview,
91
+ 'rowCount' | 'columns' | 'rows' | 'truncated' | 'delimiter'
92
+ > {
93
+ const delimiter = delimiterOf(text);
94
+ const kept: string[][] = [];
95
+ let records = 0;
96
+ let record: string[] = [];
97
+ let field = '';
98
+ let quoted = false;
99
+ let truncated = false;
100
+ const endField = () => {
101
+ record.push(field);
102
+ field = '';
103
+ };
104
+ const endRecord = () => {
105
+ endField();
106
+ const blank = record.length === 1 && record[0] === '';
107
+ if (!blank) {
108
+ if (kept.length <= SAMPLE_DATA_ROWS) kept.push(record);
109
+ records += 1;
110
+ }
111
+ record = [];
112
+ };
113
+ for (let index = 0; index < text.length; index += 1) {
114
+ const character = text[index]!;
115
+ if (quoted) {
116
+ if (character !== '"') field += character;
117
+ else if (text[index + 1] === '"') {
118
+ field += '"';
119
+ index += 1;
120
+ } else quoted = false;
121
+ continue;
122
+ }
123
+ if (character === '"' && field === '') quoted = true;
124
+ else if (character === delimiter) endField();
125
+ else if (character === '\n') endRecord();
126
+ else if (character !== '\r') field += character;
127
+ }
128
+ if (field !== '' || record.length > 0) endRecord();
129
+ const bound = (values: readonly string[]) => {
130
+ if (values.length > MAX_COLUMNS) truncated = true;
131
+ return values.slice(0, MAX_COLUMNS).map((value) => {
132
+ const bounded = cell(value);
133
+ if (bounded.cut) truncated = true;
134
+ return bounded.value;
135
+ });
136
+ };
137
+ const [header = [], ...rows] = kept;
138
+ const rowCount = Math.max(0, records - 1);
139
+ if (rowCount > rows.length) truncated = true;
140
+ return {
141
+ delimiter,
142
+ columns: bound(header),
143
+ rows: rows.map(bound),
144
+ rowCount,
145
+ truncated,
146
+ };
147
+ }
148
+
149
+ function boundJson(
150
+ value: unknown,
151
+ depth: number,
152
+ flag: { truncated: boolean },
153
+ ): unknown {
154
+ if (typeof value === 'string') {
155
+ const bounded = cell(value);
156
+ if (bounded.cut) flag.truncated = true;
157
+ return bounded.value;
158
+ }
159
+ if (value === null || typeof value !== 'object') return value;
160
+ if (depth >= MAX_JSON_DEPTH) {
161
+ flag.truncated = true;
162
+ return Array.isArray(value) ? '[array]' : '[object]';
163
+ }
164
+ if (Array.isArray(value)) {
165
+ if (value.length > SAMPLE_DATA_ROWS) flag.truncated = true;
166
+ return value
167
+ .slice(0, SAMPLE_DATA_ROWS)
168
+ .map((item) => boundJson(item, depth + 1, flag));
169
+ }
170
+ const entries = Object.entries(value);
171
+ if (entries.length > MAX_COLUMNS) flag.truncated = true;
172
+ return Object.fromEntries(
173
+ entries
174
+ .slice(0, MAX_COLUMNS)
175
+ .map(([key, item]) => [key, boundJson(item, depth + 1, flag)]),
176
+ );
177
+ }
178
+
179
+ function previewJson(
180
+ text: string,
181
+ ): Pick<
182
+ SampleDataPreview,
183
+ 'rowCount' | 'columns' | 'rows' | 'truncated' | 'path' | 'error'
184
+ > {
185
+ let parsed: unknown;
186
+ try {
187
+ parsed = JSON.parse(text);
188
+ } catch (error) {
189
+ return {
190
+ rowCount: 0,
191
+ columns: [],
192
+ rows: [],
193
+ truncated: false,
194
+ error: `The file is not valid JSON: ${(error as Error).message.slice(0, 200)}`,
195
+ };
196
+ }
197
+ let path = '$';
198
+ let collection: readonly unknown[] = [parsed];
199
+ if (Array.isArray(parsed)) collection = parsed;
200
+ else if (parsed && typeof parsed === 'object') {
201
+ const found = Object.entries(parsed).find(([, value]) =>
202
+ Array.isArray(value),
203
+ );
204
+ if (found) {
205
+ path = `$.${found[0]}`;
206
+ collection = found[1] as unknown[];
207
+ }
208
+ }
209
+ const flag = { truncated: collection.length > SAMPLE_DATA_ROWS };
210
+ const items = collection.slice(0, SAMPLE_DATA_ROWS);
211
+ const columns = new Set<string>();
212
+ for (const item of items) {
213
+ if (item && typeof item === 'object' && !Array.isArray(item))
214
+ for (const key of Object.keys(item)) columns.add(key);
215
+ }
216
+ if (columns.size > MAX_COLUMNS) flag.truncated = true;
217
+ return {
218
+ path,
219
+ rowCount: collection.length,
220
+ columns: [...columns]
221
+ .slice(0, MAX_COLUMNS)
222
+ .map((column) => cell(column).value),
223
+ rows: items.map((item) => boundJson(item, 1, flag)),
224
+ truncated: flag.truncated,
225
+ };
226
+ }
227
+
228
+ function previewText(
229
+ text: string,
230
+ ): Pick<SampleDataPreview, 'rowCount' | 'columns' | 'rows' | 'truncated'> {
231
+ const lines = text.split(/\r?\n/);
232
+ if (lines.at(-1) === '') lines.pop();
233
+ let truncated = lines.length > SAMPLE_DATA_ROWS;
234
+ return {
235
+ rowCount: lines.length,
236
+ columns: [],
237
+ rows: lines.slice(0, SAMPLE_DATA_ROWS).map((line) => {
238
+ const bounded = cell(line);
239
+ if (bounded.cut) truncated = true;
240
+ return bounded.value;
241
+ }),
242
+ truncated,
243
+ };
244
+ }
245
+
246
+ function byteLength(value: unknown): number {
247
+ return Buffer.byteLength(JSON.stringify(value), 'utf8');
248
+ }
249
+
250
+ /* A preview stays under its byte budget by dropping trailing rows, so a wide
251
+ file still answers with its columns and as many rows as fit. */
252
+ export function previewSampleData(
253
+ name: string,
254
+ text: string,
255
+ ): SampleDataPreview {
256
+ const format = sampleDataFormat(name);
257
+ if (!format) {
258
+ throw new SandboxSetupError(
259
+ 'SAMPLE_DATA_NOT_FOUND',
260
+ `${name} is not a CSV, JSON or text attachment.`,
261
+ );
262
+ }
263
+ const source = text.startsWith('\uFEFF') ? text.slice(1) : text;
264
+ const parsed =
265
+ format === 'csv'
266
+ ? previewCsv(source)
267
+ : format === 'json'
268
+ ? previewJson(source)
269
+ : previewText(source);
270
+ const preview: SampleDataPreview = {
271
+ name,
272
+ format,
273
+ size: Buffer.byteLength(text, 'utf8'),
274
+ ...parsed,
275
+ };
276
+ const rows = [...preview.rows];
277
+ let bounded = preview;
278
+ while (rows.length > 0 && byteLength(bounded) > MAX_SAMPLE_PREVIEW_BYTES) {
279
+ rows.pop();
280
+ bounded = { ...preview, rows: [...rows], truncated: true };
281
+ }
282
+ return bounded;
283
+ }
284
+
285
+ /* Reads the turn snapshot under reference/attachments, never the private store,
286
+ so the tool answers what the agent can already open and nothing newer. */
287
+ async function readSnapshot(
288
+ workspace: string,
289
+ attachment: SessionAttachment,
290
+ ): Promise<string> {
291
+ const path = join(workspace, 'reference', 'attachments', attachment.name);
292
+ const info = await lstat(path).catch(() => null);
293
+ if (!info?.isFile() || info.size > MAX_ATTACHMENT_BYTES) {
294
+ throw new SandboxSetupError(
295
+ 'SAMPLE_DATA_NOT_FOUND',
296
+ `${attachment.name} is not available in this turn.`,
297
+ );
298
+ }
299
+ return readFile(path, 'utf8');
300
+ }
301
+
302
+ export async function readSampleData(
303
+ workspace: string,
304
+ attachments: readonly SessionAttachment[],
305
+ name?: string,
306
+ ): Promise<SampleDataListing> {
307
+ const available = sampleDataAttachments(attachments);
308
+ if (name !== undefined) {
309
+ const attachment = available.find((item) => item.name === name);
310
+ if (!attachment) {
311
+ throw new SandboxSetupError(
312
+ 'SAMPLE_DATA_NOT_FOUND',
313
+ `No sample data named ${name.slice(0, 128)}. Available: ${available.map((item) => item.name).join(', ') || 'none'}.`,
314
+ );
315
+ }
316
+ return {
317
+ sampleData: [
318
+ previewSampleData(
319
+ attachment.name,
320
+ await readSnapshot(workspace, attachment),
321
+ ),
322
+ ],
323
+ };
324
+ }
325
+ const sampleData: SampleDataPreview[] = [];
326
+ let used = 0;
327
+ for (const attachment of available) {
328
+ const preview = previewSampleData(
329
+ attachment.name,
330
+ await readSnapshot(workspace, attachment),
331
+ );
332
+ const size = byteLength(preview);
333
+ if (used + size <= MAX_SAMPLE_LISTING_BYTES) {
334
+ sampleData.push(preview);
335
+ used += size;
336
+ continue;
337
+ }
338
+ const summary: SampleDataPreview = {
339
+ ...preview,
340
+ rows: [],
341
+ truncated: true,
342
+ note: `Rows left out to keep the listing small. Call ${SAMPLE_DATA_TOOL} with this name to read them.`,
343
+ };
344
+ sampleData.push(summary);
345
+ used += byteLength(summary);
346
+ }
347
+ return { sampleData };
348
+ }
349
+
350
+ export function sampleDataTool(
351
+ workspace: string,
352
+ attachments: readonly SessionAttachment[],
353
+ ): CodingAgentTool {
354
+ return {
355
+ name: SAMPLE_DATA_TOOL,
356
+ description: `Read-only. Lists the CSV, JSON and text files the operator attached as sample data, each with its columns and a parsed preview of the first ${SAMPLE_DATA_ROWS} rows. Pass name to preview one file.`,
357
+ inputSchema: {
358
+ type: 'object',
359
+ properties: {
360
+ name: {
361
+ type: 'string',
362
+ maxLength: 128,
363
+ description: 'One sample data file name.',
364
+ },
365
+ },
366
+ additionalProperties: false,
367
+ },
368
+ execute: async (input) =>
369
+ JSON.stringify(
370
+ await readSampleData(
371
+ workspace,
372
+ attachments,
373
+ typeof input.name === 'string' ? input.name : undefined,
374
+ ),
375
+ ),
376
+ };
377
+ }
378
+
379
+ /* Local CLI drivers have no channel for a lent tool, so the same listing is left
380
+ in the read-only reference tree of the turn, and removed when nothing is
381
+ attached. It stays inside the session workspace. */
382
+ export async function materializeSampleData(
383
+ workspace: string,
384
+ attachments: readonly SessionAttachment[],
385
+ ): Promise<boolean> {
386
+ const path = join(workspace, SAMPLE_DATA_REFERENCE);
387
+ await rm(path, { force: true });
388
+ if (sampleDataAttachments(attachments).length === 0) return false;
389
+ await writeFile(
390
+ path,
391
+ `${JSON.stringify(await readSampleData(workspace, attachments), null, 2)}\n`,
392
+ { flag: 'wx' },
393
+ );
394
+ return true;
395
+ }
396
+
397
+ export function sampleDataInstruction(
398
+ attachments: readonly SessionAttachment[],
399
+ ): string | null {
400
+ const names = sampleDataAttachments(attachments).map((item) => item.name);
401
+ if (names.length === 0) return null;
402
+ return `Sample data: ${names.join(', ')}. Call the ${SAMPLE_DATA_TOOL} tool, or read ${SAMPLE_DATA_REFERENCE}, for the columns and the first ${SAMPLE_DATA_ROWS} rows. Build the entities, tests/fixtures/*.json and preview/seed.json from its shape; replace real names, contacts and identifiers with invented values, because fixtures ship with the module and the sample itself never leaves this session.`;
403
+ }
@@ -55,6 +55,11 @@ import {
55
55
  import type { PlatformClient } from './platform-client.ts';
56
56
  import { readQuestions } from './questions.ts';
57
57
  import { listSkills, writeAgentPointer } from './reference.ts';
58
+ import {
59
+ materializeSampleData,
60
+ sampleDataInstruction,
61
+ sampleDataTool,
62
+ } from './sample-data.ts';
58
63
  import {
59
64
  appendChatEntry,
60
65
  basePathOf,
@@ -594,6 +599,11 @@ export async function* runTurn(
594
599
  paths.workspace,
595
600
  );
596
601
  const attachmentNote = attachmentInstruction(session.attachments);
602
+ const hasSampleData = await materializeSampleData(
603
+ paths.workspace,
604
+ session.attachments,
605
+ );
606
+ const sampleDataNote = sampleDataInstruction(session.attachments);
597
607
 
598
608
  yield await appendChatEntry(context.workspaceRoot, session, {
599
609
  kind: 'user',
@@ -767,10 +777,15 @@ export async function* runTurn(
767
777
  allowedPaths: turnAllowedPaths,
768
778
  role: roleId,
769
779
  systemInstruction: instruction,
770
- prompt: attachmentNote ? `${attachmentNote}\n\n${message}` : message,
780
+ prompt: [attachmentNote, sampleDataNote, message]
781
+ .filter(Boolean)
782
+ .join('\n\n'),
771
783
  resumeId,
772
784
  history: [{ role: 'user', text: session.brief }, ...history.slice(0, -1)],
773
785
  model: session.model,
786
+ ...(hasSampleData
787
+ ? { tools: [sampleDataTool(paths.workspace, session.attachments)] }
788
+ : {}),
774
789
  signal: input.signal,
775
790
  })) {
776
791
  const recorded = await appendChatEntry(context.workspaceRoot, session, {