deepline 0.0.1 → 0.1.1

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 (100) hide show
  1. package/README.md +324 -0
  2. package/dist/cli/index.js +6750 -503
  3. package/dist/cli/index.js.map +1 -1
  4. package/dist/cli/index.mjs +6735 -512
  5. package/dist/cli/index.mjs.map +1 -1
  6. package/dist/index.d.mts +2349 -32
  7. package/dist/index.d.ts +2349 -32
  8. package/dist/index.js +1631 -82
  9. package/dist/index.js.map +1 -1
  10. package/dist/index.mjs +1617 -83
  11. package/dist/index.mjs.map +1 -1
  12. package/dist/repo/apps/play-runner-workers/src/coordinator-entry.ts +3256 -0
  13. package/dist/repo/apps/play-runner-workers/src/dedup-do.ts +710 -0
  14. package/dist/repo/apps/play-runner-workers/src/entry.ts +5070 -0
  15. package/dist/repo/apps/play-runner-workers/src/runtime/README.md +21 -0
  16. package/dist/repo/apps/play-runner-workers/src/runtime/batching.ts +177 -0
  17. package/dist/repo/apps/play-runner-workers/src/runtime/execution-plan.ts +52 -0
  18. package/dist/repo/apps/play-runner-workers/src/runtime/tool-batch.ts +100 -0
  19. package/dist/repo/apps/play-runner-workers/src/runtime/tool-result.ts +184 -0
  20. package/dist/repo/sdk/src/cli/commands/auth.ts +482 -0
  21. package/dist/repo/sdk/src/cli/commands/billing.ts +188 -0
  22. package/dist/repo/sdk/src/cli/commands/csv.ts +123 -0
  23. package/dist/repo/sdk/src/cli/commands/db.ts +119 -0
  24. package/dist/repo/sdk/src/cli/commands/feedback.ts +40 -0
  25. package/dist/repo/sdk/src/cli/commands/org.ts +117 -0
  26. package/dist/repo/sdk/src/cli/commands/play.ts +3200 -0
  27. package/dist/repo/sdk/src/cli/commands/tools.ts +687 -0
  28. package/dist/repo/sdk/src/cli/dataset-stats.ts +341 -0
  29. package/dist/repo/sdk/src/cli/index.ts +138 -0
  30. package/dist/repo/sdk/src/cli/progress.ts +135 -0
  31. package/dist/repo/sdk/src/cli/trace.ts +61 -0
  32. package/dist/repo/sdk/src/cli/utils.ts +145 -0
  33. package/dist/repo/sdk/src/client.ts +1188 -0
  34. package/dist/repo/sdk/src/compat.ts +77 -0
  35. package/dist/repo/sdk/src/config.ts +285 -0
  36. package/dist/repo/sdk/src/errors.ts +125 -0
  37. package/dist/repo/sdk/src/http.ts +391 -0
  38. package/dist/repo/sdk/src/index.ts +139 -0
  39. package/dist/repo/sdk/src/play.ts +1330 -0
  40. package/dist/repo/sdk/src/plays/bundle-play-file.ts +133 -0
  41. package/dist/repo/sdk/src/plays/harness-stub.ts +210 -0
  42. package/dist/repo/sdk/src/plays/local-file-discovery.ts +326 -0
  43. package/dist/repo/sdk/src/tool-output.ts +489 -0
  44. package/dist/repo/sdk/src/types.ts +669 -0
  45. package/dist/repo/sdk/src/version.ts +2 -0
  46. package/dist/repo/sdk/src/worker-play-entry.ts +286 -0
  47. package/dist/repo/shared_libs/observability/node-tracing.ts +129 -0
  48. package/dist/repo/shared_libs/observability/tracing.ts +98 -0
  49. package/dist/repo/shared_libs/play-runtime/backend.ts +139 -0
  50. package/dist/repo/shared_libs/play-runtime/batch-runtime.ts +182 -0
  51. package/dist/repo/shared_libs/play-runtime/batching-types.ts +91 -0
  52. package/dist/repo/shared_libs/play-runtime/context.ts +3999 -0
  53. package/dist/repo/shared_libs/play-runtime/coordinator-headers.ts +78 -0
  54. package/dist/repo/shared_libs/play-runtime/ctx-contract.ts +250 -0
  55. package/dist/repo/shared_libs/play-runtime/ctx-types.ts +713 -0
  56. package/dist/repo/shared_libs/play-runtime/dataset-id.ts +10 -0
  57. package/dist/repo/shared_libs/play-runtime/db-session-crypto.ts +304 -0
  58. package/dist/repo/shared_libs/play-runtime/db-session.ts +462 -0
  59. package/dist/repo/shared_libs/play-runtime/dedup-backend.ts +0 -0
  60. package/dist/repo/shared_libs/play-runtime/default-batch-strategies.ts +124 -0
  61. package/dist/repo/shared_libs/play-runtime/execution-plan.ts +262 -0
  62. package/dist/repo/shared_libs/play-runtime/live-events.ts +214 -0
  63. package/dist/repo/shared_libs/play-runtime/live-state-contract.ts +50 -0
  64. package/dist/repo/shared_libs/play-runtime/map-execution-frame.ts +114 -0
  65. package/dist/repo/shared_libs/play-runtime/map-row-identity.ts +158 -0
  66. package/dist/repo/shared_libs/play-runtime/profiles.ts +90 -0
  67. package/dist/repo/shared_libs/play-runtime/progress-emitter.ts +172 -0
  68. package/dist/repo/shared_libs/play-runtime/protocol.ts +121 -0
  69. package/dist/repo/shared_libs/play-runtime/public-play-contract.ts +42 -0
  70. package/dist/repo/shared_libs/play-runtime/result-normalization.ts +33 -0
  71. package/dist/repo/shared_libs/play-runtime/runtime-actions.ts +208 -0
  72. package/dist/repo/shared_libs/play-runtime/runtime-api.ts +1873 -0
  73. package/dist/repo/shared_libs/play-runtime/runtime-constraints.ts +2 -0
  74. package/dist/repo/shared_libs/play-runtime/runtime-pg-driver-neon-serverless.ts +201 -0
  75. package/dist/repo/shared_libs/play-runtime/runtime-pg-driver-pg.ts +48 -0
  76. package/dist/repo/shared_libs/play-runtime/runtime-pg-driver.ts +84 -0
  77. package/dist/repo/shared_libs/play-runtime/scheduler-backend.ts +174 -0
  78. package/dist/repo/shared_libs/play-runtime/static-pipeline-types.ts +147 -0
  79. package/dist/repo/shared_libs/play-runtime/suspension.ts +68 -0
  80. package/dist/repo/shared_libs/play-runtime/tool-batch-executor.ts +146 -0
  81. package/dist/repo/shared_libs/play-runtime/tool-result.ts +387 -0
  82. package/dist/repo/shared_libs/play-runtime/tracing.ts +31 -0
  83. package/dist/repo/shared_libs/play-runtime/waterfall-replay.ts +75 -0
  84. package/dist/repo/shared_libs/play-runtime/worker-api-types.ts +140 -0
  85. package/dist/repo/shared_libs/plays/artifact-transport.ts +14 -0
  86. package/dist/repo/shared_libs/plays/artifact-types.ts +49 -0
  87. package/dist/repo/shared_libs/plays/bundling/index.ts +1346 -0
  88. package/dist/repo/shared_libs/plays/compiler-manifest.ts +186 -0
  89. package/dist/repo/shared_libs/plays/contracts.ts +51 -0
  90. package/dist/repo/shared_libs/plays/dataset.ts +308 -0
  91. package/dist/repo/shared_libs/plays/definition.ts +264 -0
  92. package/dist/repo/shared_libs/plays/file-refs.ts +11 -0
  93. package/dist/repo/shared_libs/plays/rate-limit-scheduler.ts +206 -0
  94. package/dist/repo/shared_libs/plays/resolve-static-pipeline.ts +164 -0
  95. package/dist/repo/shared_libs/plays/row-identity.ts +302 -0
  96. package/dist/repo/shared_libs/plays/runtime-validation.ts +415 -0
  97. package/dist/repo/shared_libs/plays/static-pipeline.ts +560 -0
  98. package/dist/repo/shared_libs/temporal/constants.ts +39 -0
  99. package/dist/repo/shared_libs/temporal/preview-config.ts +153 -0
  100. package/package.json +14 -12
@@ -0,0 +1,489 @@
1
+ /**
2
+ * Tool output processing utilities.
3
+ *
4
+ * Tools return data in varied shapes — some return flat objects, others
5
+ * wrap results in `{ result: { data: [...] } }` envelopes, and list
6
+ * responses can be nested at different depths. This module provides
7
+ * utilities to normalize, extract, and persist tool outputs.
8
+ *
9
+ * ## Key functions
10
+ *
11
+ * - {@link createToolCallResult} — Wrap a tool response with ergonomic accessors.
12
+ *
13
+ * - {@link tryConvertToList} — Extract a list of records from any tool response.
14
+ * Uses configured `listExtractorPaths` first, then falls back to auto-detection.
15
+ *
16
+ * - {@link writeCsvOutputFile} — Write records to a CSV file with automatic
17
+ * column ordering, cell escaping, and preview generation.
18
+ *
19
+ * - {@link writeJsonOutputFile} — Write any payload to a pretty-printed JSON file.
20
+ *
21
+ * - {@link extractSummaryFields} — Pull out scalar fields (string, number, boolean)
22
+ * for a quick summary of a tool response.
23
+ *
24
+ * All file outputs go to `~/.local/share/deepline/data/` with timestamped filenames.
25
+ *
26
+ * @module
27
+ */
28
+ import { mkdirSync, writeFileSync } from 'node:fs';
29
+ import { homedir } from 'node:os';
30
+ import { join } from 'node:path';
31
+
32
+ /**
33
+ * Result of converting a tool response to a list of records.
34
+ *
35
+ * @example
36
+ * ```typescript
37
+ * const conversion = tryConvertToList(toolResponse, {
38
+ * listExtractorPaths: ['people', 'result.data'],
39
+ * });
40
+ * if (conversion) {
41
+ * console.log(`Found ${conversion.rows.length} rows via ${conversion.strategy}`);
42
+ * console.log(`Source path: ${conversion.sourcePath}`);
43
+ * }
44
+ * ```
45
+ */
46
+ export type ListConversionResult = {
47
+ /** Normalized array of record objects. Scalars are wrapped as `{ value: <scalar> }`. */
48
+ rows: Array<Record<string, unknown>>;
49
+ /**
50
+ * How the list was found:
51
+ * - `'configured_paths'` — matched one of the `listExtractorPaths`
52
+ * - `'auto_detected'` — found via recursive DFS (longest array wins)
53
+ */
54
+ strategy: 'configured_paths' | 'auto_detected';
55
+ /** Dotted path to where the list was found (e.g. `"result.data"`, `"people"`). */
56
+ sourcePath: string | null;
57
+ };
58
+
59
+ type Scalar = string | number | boolean | null;
60
+
61
+ /** Ergonomic wrapper returned by high-level SDK tool execution. */
62
+ export type ToolCallResult = {
63
+ /** Raw tool response. Use this when a provider-specific shape matters. */
64
+ readonly value: unknown;
65
+ /** Best-effort email extraction from common response shapes. */
66
+ getEmail(): string | null;
67
+ /** Best-effort phone extraction from common response shapes. */
68
+ getPhone(): string | null;
69
+ /** Best-effort list extraction. Returns rows only; use `tryConvertToList` for metadata. */
70
+ tryList(options?: { listExtractorPaths?: string[] }): Array<Record<string, unknown>> | null;
71
+ };
72
+
73
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
74
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
75
+ }
76
+
77
+ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
78
+ const PHONE_KEY_PATTERN = /(^|[_-])(phone|mobile|cell|telephone|tel)([_-]|$)|phone|mobile|telephone/i;
79
+
80
+ function normalizeScalarString(value: unknown): string | null {
81
+ if (typeof value === 'string') {
82
+ const trimmed = value.trim();
83
+ return trimmed.length > 0 ? trimmed : null;
84
+ }
85
+ if (typeof value === 'number' && Number.isFinite(value)) {
86
+ return String(value);
87
+ }
88
+ return null;
89
+ }
90
+
91
+ function looksLikeEmail(value: unknown): string | null {
92
+ const candidate = normalizeScalarString(value);
93
+ if (!candidate || !EMAIL_PATTERN.test(candidate)) return null;
94
+ return candidate;
95
+ }
96
+
97
+ function looksLikePhone(value: unknown): string | null {
98
+ const candidate = normalizeScalarString(value);
99
+ if (!candidate) return null;
100
+ const digits = candidate.replace(/\D/g, '');
101
+ if (digits.length < 7 || digits.length > 16) return null;
102
+ return candidate;
103
+ }
104
+
105
+ function findEmail(value: unknown, depth = 0): string | null {
106
+ if (depth > 6) return null;
107
+
108
+ const direct = looksLikeEmail(value);
109
+ if (direct) return direct;
110
+
111
+ if (Array.isArray(value)) {
112
+ for (const entry of value) {
113
+ const nested = findEmail(entry, depth + 1);
114
+ if (nested) return nested;
115
+ }
116
+ return null;
117
+ }
118
+
119
+ if (!isPlainObject(value)) return null;
120
+
121
+ for (const [key, child] of Object.entries(value)) {
122
+ if (/email/i.test(key)) {
123
+ const keyed = looksLikeEmail(child);
124
+ if (keyed) return keyed;
125
+ }
126
+ }
127
+
128
+ for (const child of Object.values(value)) {
129
+ const nested = findEmail(child, depth + 1);
130
+ if (nested) return nested;
131
+ }
132
+
133
+ return null;
134
+ }
135
+
136
+ function findPhone(value: unknown, depth = 0): string | null {
137
+ if (depth > 6) return null;
138
+
139
+ if (Array.isArray(value)) {
140
+ for (const entry of value) {
141
+ const nested = findPhone(entry, depth + 1);
142
+ if (nested) return nested;
143
+ }
144
+ return null;
145
+ }
146
+
147
+ if (!isPlainObject(value)) return null;
148
+
149
+ for (const [key, child] of Object.entries(value)) {
150
+ if (PHONE_KEY_PATTERN.test(key)) {
151
+ const keyed = looksLikePhone(child);
152
+ if (keyed) return keyed;
153
+ }
154
+ }
155
+
156
+ for (const child of Object.values(value)) {
157
+ const nested = findPhone(child, depth + 1);
158
+ if (nested) return nested;
159
+ }
160
+
161
+ return null;
162
+ }
163
+
164
+ class DeeplineToolCallResult implements ToolCallResult {
165
+ constructor(readonly value: unknown) {}
166
+
167
+ getEmail(): string | null {
168
+ return findEmail(this.value);
169
+ }
170
+
171
+ getPhone(): string | null {
172
+ return findPhone(this.value);
173
+ }
174
+
175
+ tryList(options?: { listExtractorPaths?: string[] }): Array<Record<string, unknown>> | null {
176
+ return tryConvertToList(this.value, options)?.rows ?? null;
177
+ }
178
+ }
179
+
180
+ /** Wrap a raw tool response with the high-level SDK result API. */
181
+ export function createToolCallResult(value: unknown): ToolCallResult {
182
+ return new DeeplineToolCallResult(value);
183
+ }
184
+
185
+ /**
186
+ * Traverse a nested object by a dotted path string.
187
+ *
188
+ * @param root - Object to traverse
189
+ * @param dottedPath - Path like `"result.data.items"`
190
+ * @returns Value at the path, or `null` if not found
191
+ *
192
+ * @example
193
+ * ```typescript
194
+ * getByDottedPath({ a: { b: { c: 42 } } }, 'a.b.c') // 42
195
+ * getByDottedPath({ a: 1 }, 'a.b.c') // null
196
+ * ```
197
+ */
198
+ function getByDottedPath(root: unknown, dottedPath: string): unknown {
199
+ let current = root;
200
+ for (const segment of String(dottedPath || '').split('.').filter(Boolean)) {
201
+ if (!isPlainObject(current) || !(segment in current)) {
202
+ return null;
203
+ }
204
+ current = current[segment];
205
+ }
206
+ return current;
207
+ }
208
+
209
+ /**
210
+ * Normalize an array value to an array of record objects.
211
+ * Non-object entries are wrapped as `{ value: <entry> }`.
212
+ */
213
+ function normalizeRows(value: unknown): Array<Record<string, unknown>> | null {
214
+ if (!Array.isArray(value)) return null;
215
+ return value.map((entry) => {
216
+ if (isPlainObject(entry)) return entry;
217
+ return { value: entry };
218
+ });
219
+ }
220
+
221
+ /**
222
+ * Generate candidate root objects to search for lists.
223
+ * Tries: raw payload → payload.result → payload.result.data.
224
+ */
225
+ function candidateRoots(payload: unknown): Array<{ path: string | null; value: unknown }> {
226
+ const roots: Array<{ path: string | null; value: unknown }> = [{ path: null, value: payload }];
227
+ if (isPlainObject(payload) && isPlainObject(payload.result)) {
228
+ roots.push({ path: 'result', value: payload.result });
229
+ if (isPlainObject(payload.result.data)) {
230
+ roots.push({ path: 'result.data', value: payload.result.data });
231
+ }
232
+ }
233
+ return roots;
234
+ }
235
+
236
+ /**
237
+ * Recursively search for the largest array of objects in a nested structure.
238
+ * Depth-limited to 5 levels. Prefers arrays with real object entries
239
+ * (not just `{ value: ... }` wrappers).
240
+ */
241
+ function findBestArrayCandidate(
242
+ value: unknown,
243
+ pathPrefix = '',
244
+ depth = 0,
245
+ ): { path: string; rows: Array<Record<string, unknown>> } | null {
246
+ if (depth > 5) return null;
247
+
248
+ const directRows = normalizeRows(value);
249
+ const hasObjectRow = directRows?.some((row) => Object.keys(row).some((key) => key !== 'value')) ?? false;
250
+ let best: { path: string; rows: Array<Record<string, unknown>> } | null =
251
+ directRows && directRows.length > 0 && hasObjectRow
252
+ ? { path: pathPrefix, rows: directRows }
253
+ : null;
254
+
255
+ if (!isPlainObject(value)) {
256
+ return best;
257
+ }
258
+
259
+ for (const [key, child] of Object.entries(value)) {
260
+ const childPath = pathPrefix ? `${pathPrefix}.${key}` : key;
261
+ const candidate = findBestArrayCandidate(child, childPath, depth + 1);
262
+ if (!candidate) continue;
263
+ if (!best || candidate.rows.length > best.rows.length) {
264
+ best = candidate;
265
+ }
266
+ }
267
+
268
+ return best;
269
+ }
270
+
271
+ /**
272
+ * Extract a list of records from a tool response.
273
+ *
274
+ * Handles the common problem of tools returning data in varied shapes.
275
+ * First tries configured `listExtractorPaths` (from tool metadata), then
276
+ * falls back to automatic detection via recursive DFS.
277
+ *
278
+ * ## Extraction strategy
279
+ *
280
+ * 1. **Configured paths** — If `listExtractorPaths` is provided, each path is
281
+ * tried against multiple candidate roots (raw payload, `.result`, `.result.data`).
282
+ * First match wins.
283
+ *
284
+ * 2. **Auto-detection** — If no configured path matches, recursively searches
285
+ * the response for the largest array of objects (up to depth 5).
286
+ *
287
+ * @param payload - Raw tool response
288
+ * @param options - Optional extraction configuration
289
+ * @returns Extracted list with metadata, or `null` if no list found
290
+ *
291
+ * @example Using configured paths (from tool metadata)
292
+ * ```typescript
293
+ * const meta = await client.getTool('apollo_people_search');
294
+ * const result = await client.executeTool('apollo_people_search', { query: 'cto' });
295
+ *
296
+ * const list = tryConvertToList(result, {
297
+ * listExtractorPaths: meta.listExtractorPaths,
298
+ * });
299
+ * if (list) {
300
+ * console.log(`${list.rows.length} people found via ${list.strategy}`);
301
+ * // Write to CSV
302
+ * const csv = writeCsvOutputFile(list.rows, 'apollo-people');
303
+ * console.log(`Saved to ${csv.path}`);
304
+ * }
305
+ * ```
306
+ *
307
+ * @example Auto-detection (no configured paths)
308
+ * ```typescript
309
+ * const result = await client.executeTool('some_tool', { query: 'test' });
310
+ * const list = tryConvertToList(result);
311
+ * // Finds the largest array of objects anywhere in the response
312
+ * ```
313
+ */
314
+ export function tryConvertToList(
315
+ payload: unknown,
316
+ options?: { listExtractorPaths?: string[] },
317
+ ): ListConversionResult | null {
318
+ const listExtractorPaths = Array.isArray(options?.listExtractorPaths)
319
+ ? options?.listExtractorPaths.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
320
+ : [];
321
+
322
+ if (listExtractorPaths.length > 0) {
323
+ for (const root of candidateRoots(payload)) {
324
+ for (const extractorPath of listExtractorPaths) {
325
+ const resolved = getByDottedPath(root.value, extractorPath);
326
+ const rows = normalizeRows(resolved);
327
+ if (rows && rows.length > 0) {
328
+ const sourcePath = root.path ? `${root.path}.${extractorPath}` : extractorPath;
329
+ return { rows, strategy: 'configured_paths', sourcePath };
330
+ }
331
+ }
332
+ }
333
+ }
334
+
335
+ for (const root of candidateRoots(payload)) {
336
+ const candidate = findBestArrayCandidate(root.value, root.path ?? '');
337
+ if (!candidate || candidate.rows.length === 0) continue;
338
+ return {
339
+ rows: candidate.rows,
340
+ strategy: 'auto_detected',
341
+ sourcePath: candidate.path || root.path,
342
+ };
343
+ }
344
+
345
+ return null;
346
+ }
347
+
348
+ /** Ensure the shared output directory exists. Returns its path. */
349
+ function ensureOutputDir(): string {
350
+ const outputDir = join(homedir(), '.local', 'share', 'deepline', 'data');
351
+ mkdirSync(outputDir, { recursive: true });
352
+ return outputDir;
353
+ }
354
+
355
+ /**
356
+ * Write a JSON payload to a timestamped file.
357
+ *
358
+ * Output location: `~/.local/share/deepline/data/{stem}_{timestamp}.json`
359
+ *
360
+ * @param payload - Any JSON-serializable value
361
+ * @param stem - Filename prefix (e.g. tool ID or play name)
362
+ * @returns Absolute path to the written file
363
+ *
364
+ * @example
365
+ * ```typescript
366
+ * const result = await client.executeTool('test_company_search', { domain: 'stripe.com' });
367
+ * const path = writeJsonOutputFile(result, 'test_company_search');
368
+ * console.log(`Saved to ${path}`);
369
+ * // ~/.local/share/deepline/data/test_company_search_1713456789000.json
370
+ * ```
371
+ */
372
+ export function writeJsonOutputFile(payload: unknown, stem: string): string {
373
+ const outputDir = ensureOutputDir();
374
+ const outputPath = join(outputDir, `${stem}_${Date.now()}.json`);
375
+ writeFileSync(outputPath, JSON.stringify(payload, null, 2), 'utf-8');
376
+ return outputPath;
377
+ }
378
+
379
+ /**
380
+ * Write an array of records to a CSV file.
381
+ *
382
+ * Columns are ordered by first appearance across all rows. Cells containing
383
+ * commas, quotes, or newlines are properly escaped. Objects and arrays are
384
+ * JSON-serialized.
385
+ *
386
+ * Output location: `~/.local/share/deepline/data/{stem}_{timestamp}.csv`
387
+ *
388
+ * @param rows - Array of record objects
389
+ * @param stem - Filename prefix
390
+ * @returns File metadata including path, row count, columns, and a 5×5 preview
391
+ *
392
+ * @example
393
+ * ```typescript
394
+ * const list = tryConvertToList(toolResponse);
395
+ * if (list) {
396
+ * const csv = writeCsvOutputFile(list.rows, 'search-results');
397
+ * console.log(`Wrote ${csv.rowCount} rows, ${csv.columns.length} columns`);
398
+ * console.log(`File: ${csv.path}`);
399
+ * console.log(`Preview:\n${csv.preview}`);
400
+ * }
401
+ * ```
402
+ */
403
+ export function writeCsvOutputFile(
404
+ rows: Array<Record<string, unknown>>,
405
+ stem: string,
406
+ ): { path: string; rowCount: number; columns: string[]; preview: string } {
407
+ const outputDir = ensureOutputDir();
408
+ const outputPath = join(outputDir, `${stem}_${Date.now()}.csv`);
409
+ const seen = new Set<string>();
410
+ const columns: string[] = [];
411
+ for (const row of rows) {
412
+ for (const key of Object.keys(row)) {
413
+ if (!seen.has(key)) {
414
+ seen.add(key);
415
+ columns.push(key);
416
+ }
417
+ }
418
+ }
419
+
420
+ const escapeCell = (value: unknown): string => {
421
+ const normalized = value == null
422
+ ? ''
423
+ : typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
424
+ ? String(value)
425
+ : JSON.stringify(value);
426
+ if (/[",\n]/.test(normalized)) {
427
+ return `"${normalized.replace(/"/g, '""')}"`;
428
+ }
429
+ return normalized;
430
+ };
431
+
432
+ const lines: string[] = [];
433
+ lines.push(columns.map(escapeCell).join(','));
434
+ for (const row of rows) {
435
+ lines.push(columns.map((column) => escapeCell(row[column])).join(','));
436
+ }
437
+ writeFileSync(outputPath, `${lines.join('\n')}\n`, 'utf-8');
438
+
439
+ const previewRows = rows.slice(0, 5);
440
+ const previewColumns = columns.slice(0, 5);
441
+ const preview = [
442
+ previewColumns.join(','),
443
+ ...previewRows.map((row) =>
444
+ previewColumns.map((column) => escapeCell(row[column])).join(',')),
445
+ ].join('\n');
446
+
447
+ return {
448
+ path: outputPath,
449
+ rowCount: rows.length,
450
+ columns,
451
+ preview,
452
+ };
453
+ }
454
+
455
+ /**
456
+ * Extract scalar (non-nested) fields from a tool response for summary display.
457
+ *
458
+ * Searches through candidate roots (raw → `.result` → `.result.data`) and
459
+ * returns the first set of scalar fields found. Useful for displaying a
460
+ * quick summary of single-record responses.
461
+ *
462
+ * @param payload - Raw tool response
463
+ * @returns Object containing only scalar fields (string, number, boolean, null)
464
+ *
465
+ * @example
466
+ * ```typescript
467
+ * const result = await client.executeTool('test_company_search', { domain: 'stripe.com' });
468
+ * const summary = extractSummaryFields(result);
469
+ * // { name: "Stripe", industry: "Financial Services", employeeCount: 8000 }
470
+ * // (nested objects and arrays are excluded)
471
+ * ```
472
+ */
473
+ export function extractSummaryFields(payload: unknown): Record<string, Scalar> {
474
+ const candidates = candidateRoots(payload);
475
+ for (const candidate of candidates) {
476
+ if (!isPlainObject(candidate.value)) continue;
477
+ const summaryEntries = Object.entries(candidate.value).filter(([, value]) => {
478
+ return (
479
+ value == null ||
480
+ typeof value === 'string' ||
481
+ typeof value === 'number' ||
482
+ typeof value === 'boolean'
483
+ );
484
+ });
485
+ if (summaryEntries.length === 0) continue;
486
+ return Object.fromEntries(summaryEntries) as Record<string, Scalar>;
487
+ }
488
+ return {};
489
+ }