dsh-analyze 0.0.0-stage → 0.1.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,44 @@
1
+ /**
2
+ * Host-agnostic attach path: probe CLI + register selected analysis tools
3
+ * on any agent-scope tools runtime. No expert-runtime / routing dependency —
4
+ * a bare plugin host can call this with an empty profile.
5
+ * @module @investigator/backend/analysis/register
6
+ */
7
+ import type { AnalysisToolName } from './policy.js';
8
+ import { type AnalysisRuntimeConfig } from './tools.js';
9
+ export interface AgentToolsRegisterLike {
10
+ register?(definition: unknown): () => void;
11
+ }
12
+ export interface AttachAnalysisToolsOptions {
13
+ agentTools: AgentToolsRegisterLike;
14
+ names: readonly AnalysisToolName[];
15
+ config: AnalysisRuntimeConfig;
16
+ /**
17
+ * Injected probe (tests / custom CLI resolution). Defaults to probeAnalyzeCliSync.
18
+ * Return shape is looser than AnalyzeCliProbeResult so host stubs can opt in.
19
+ */
20
+ probe?: (options: {
21
+ workspaceRoot: string;
22
+ cliPath: string;
23
+ cliPrefixArgs?: readonly string[];
24
+ timeoutMs?: number;
25
+ }) => {
26
+ ok: boolean;
27
+ version?: string;
28
+ capabilities?: string[];
29
+ error?: string;
30
+ };
31
+ timeoutMs?: number;
32
+ }
33
+ export interface AttachAnalysisToolsResult {
34
+ registered: boolean;
35
+ version?: string;
36
+ capabilities?: string[];
37
+ error?: string;
38
+ dispose?: () => void;
39
+ }
40
+ /**
41
+ * Probe the CLI, then register the named tools. On probe failure nothing is
42
+ * registered (fail-soft: caller logs and continues without analysis tools).
43
+ */
44
+ export declare function attachAnalysisTools(options: AttachAnalysisToolsOptions): AttachAnalysisToolsResult;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Host-agnostic attach path: probe CLI + register selected analysis tools
3
+ * on any agent-scope tools runtime. No expert-runtime / routing dependency —
4
+ * a bare plugin host can call this with an empty profile.
5
+ * @module @investigator/backend/analysis/register
6
+ */
7
+ import { probeAnalyzeCliSync } from './cli.js';
8
+ import { createAnalysisDefinitionsForNames } from './tools.js';
9
+ /**
10
+ * Probe the CLI, then register the named tools. On probe failure nothing is
11
+ * registered (fail-soft: caller logs and continues without analysis tools).
12
+ */
13
+ export function attachAnalysisTools(options) {
14
+ if (options.names.length === 0) {
15
+ return { registered: false, error: 'no analysis tools selected' };
16
+ }
17
+ if (typeof options.agentTools.register !== 'function') {
18
+ return { registered: false, error: 'agent tools register unavailable' };
19
+ }
20
+ const register = options.agentTools.register.bind(options.agentTools);
21
+ const probe = options.probe ?? probeAnalyzeCliSync;
22
+ const probed = probe({
23
+ workspaceRoot: options.config.workspaceRoot,
24
+ cliPath: options.config.cliPath,
25
+ cliPrefixArgs: options.config.cliPrefixArgs,
26
+ timeoutMs: options.timeoutMs ?? 10_000,
27
+ });
28
+ if (!probed.ok) {
29
+ return { registered: false, error: probed.error ?? 'probe failed' };
30
+ }
31
+ const defs = createAnalysisDefinitionsForNames(options.names, options.config);
32
+ const offs = defs.map((def) => register(def));
33
+ return {
34
+ registered: true,
35
+ ...(probed.version !== undefined ? { version: probed.version } : {}),
36
+ ...(probed.capabilities !== undefined ? { capabilities: probed.capabilities } : {}),
37
+ dispose: () => {
38
+ for (const off of offs) {
39
+ try {
40
+ off();
41
+ }
42
+ catch { /* ignore */ }
43
+ }
44
+ },
45
+ };
46
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Model-facing analysis tools (hand-written ToolDefinition; no defineTool dep).
3
+ * @module @investigator/backend/analysis/tools
4
+ */
5
+ import type { AnalysisLimits } from './limits.js';
6
+ import type { AnalysisToolName } from './policy.js';
7
+ export interface AnalysisRuntimeConfig {
8
+ cliPath: string;
9
+ /** Extra argv before analyze args (e.g. path to JS CLI when cliPath is node). */
10
+ cliPrefixArgs?: readonly string[];
11
+ /** Absolute session data root. Must not be the profile root itself. */
12
+ workspaceRoot: string;
13
+ /** Session that owns this tool scope (for audit / future checks). */
14
+ sessionId?: string;
15
+ /** Optional profile root used to hard-reject credential paths on ingest. */
16
+ profileRoot?: string;
17
+ limits?: Partial<AnalysisLimits>;
18
+ }
19
+ /** Reject profile credential / config paths even if they resolve under workspaceRoot. */
20
+ export declare function assertNotSensitiveSource(absPath: string, profileRoot?: string): void;
21
+ /** Validate agg spec string before CLI. */
22
+ export declare function validateAggSpec(spec: string, limits: AnalysisLimits): string;
23
+ export declare function createAnalysisToolDefinitions(config: AnalysisRuntimeConfig): Record<AnalysisToolName, unknown>;
24
+ export declare function createAnalysisDefinitionsForNames(names: readonly AnalysisToolName[], config: AnalysisRuntimeConfig): unknown[];
package/dist/tools.js ADDED
@@ -0,0 +1,566 @@
1
+ /**
2
+ * Model-facing analysis tools (hand-written ToolDefinition; no defineTool dep).
3
+ * @module @investigator/backend/analysis/tools
4
+ */
5
+ import { DEFAULT_ANALYSIS_LIMITS, clampHeadN } from './limits.js';
6
+ import { AnalyzeCliError, runAnalyzeCli } from './cli.js';
7
+ import { AnalysisPathError, newDatasetId, resolveInWorkspace, validateDatasetId, } from './paths.js';
8
+ import { resolve, sep } from 'node:path';
9
+ const MAX_LABEL_LEN = 200;
10
+ const MAX_PATH_LEN = 1024;
11
+ /** Per-workspace FIFO chain so one session's CLI calls never overlap. */
12
+ const cliLocks = new Map();
13
+ async function withWorkspaceCliLock(key, fn) {
14
+ const prev = cliLocks.get(key) ?? Promise.resolve();
15
+ const run = prev.then(fn, fn);
16
+ const guard = run.then(() => undefined, () => undefined);
17
+ cliLocks.set(key, guard);
18
+ try {
19
+ return await run;
20
+ }
21
+ finally {
22
+ if (cliLocks.get(key) === guard)
23
+ cliLocks.delete(key);
24
+ }
25
+ }
26
+ /** Profile-relative prefixes that must never be ingested as data sources. */
27
+ const SENSITIVE_PROFILE_PREFIXES = [
28
+ 'investigator',
29
+ '.bootstrap-token',
30
+ 'profiles',
31
+ '.credentials.yaml',
32
+ '.writer.lock',
33
+ ];
34
+ function isUnder(root, abs) {
35
+ const r = resolve(root);
36
+ const a = resolve(abs);
37
+ return a === r || a.startsWith(r + sep);
38
+ }
39
+ /** Reject profile credential / config paths even if they resolve under workspaceRoot. */
40
+ export function assertNotSensitiveSource(absPath, profileRoot) {
41
+ if (!profileRoot)
42
+ return;
43
+ const profile = resolve(profileRoot);
44
+ if (!isUnder(profile, absPath))
45
+ return;
46
+ const rel = absPath.slice(profile.length).replace(/^[\\/]+/, '');
47
+ const parts = rel.split(/[\\/]/).filter(Boolean);
48
+ if (parts.length === 0) {
49
+ throw new AnalysisPathError('analysis/invalid-path', 'profile root is not a data source');
50
+ }
51
+ const first = parts[0];
52
+ if (SENSITIVE_PROFILE_PREFIXES.includes(first)) {
53
+ throw new AnalysisPathError('analysis/invalid-path', `profile path not allowed as data source: ${first}`);
54
+ }
55
+ }
56
+ function textRender(text) {
57
+ return [{ type: 'text', text }];
58
+ }
59
+ function failMessage(e) {
60
+ if (e instanceof AnalyzeCliError || e instanceof AnalysisPathError) {
61
+ const err = new Error(e.message);
62
+ err.code = e.code;
63
+ throw err;
64
+ }
65
+ const msg = e instanceof Error ? e.message : String(e);
66
+ const err = new Error(msg);
67
+ err.code = 'analysis/cli-failed';
68
+ throw err;
69
+ }
70
+ /** Validate agg spec string before CLI. */
71
+ export function validateAggSpec(spec, limits) {
72
+ if (typeof spec !== 'string' || !spec.trim()) {
73
+ const err = new Error('analysis/invalid-args: spec is required');
74
+ err.code = 'analysis/invalid-args';
75
+ throw err;
76
+ }
77
+ if (spec.length > limits.maxSpecBytes) {
78
+ const err = new Error('analysis/limit: spec too large');
79
+ err.code = 'analysis/limit';
80
+ throw err;
81
+ }
82
+ let parsed;
83
+ try {
84
+ parsed = JSON.parse(spec);
85
+ }
86
+ catch {
87
+ const err = new Error('analysis/invalid-args: spec must be JSON object string');
88
+ err.code = 'analysis/invalid-args';
89
+ throw err;
90
+ }
91
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
92
+ const err = new Error('analysis/invalid-args: spec must be object');
93
+ err.code = 'analysis/invalid-args';
94
+ throw err;
95
+ }
96
+ const obj = parsed;
97
+ if (obj.limit !== undefined) {
98
+ const n = Number(obj.limit);
99
+ if (!Number.isFinite(n) || n < 1 || n > limits.maxAggLimit) {
100
+ const err = new Error(`analysis/invalid-args: limit must be 1..${limits.maxAggLimit}`);
101
+ err.code = 'analysis/invalid-args';
102
+ throw err;
103
+ }
104
+ }
105
+ if (Array.isArray(obj.filter)) {
106
+ const ops = new Set(['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'contains', 'is_null', 'not_null']);
107
+ for (const f of obj.filter) {
108
+ if (!f || typeof f !== 'object' || typeof f.op !== 'string' || !ops.has(f.op)) {
109
+ const err = new Error('analysis/invalid-args: bad filter op');
110
+ err.code = 'analysis/invalid-args';
111
+ throw err;
112
+ }
113
+ }
114
+ }
115
+ if (Array.isArray(obj.aggs)) {
116
+ const fns = new Set(['count', 'sum', 'mean', 'min', 'max', 'median', 'std']);
117
+ for (const a of obj.aggs) {
118
+ if (!a || typeof a !== 'object' || typeof a.fn !== 'string' || !fns.has(a.fn)) {
119
+ const err = new Error('analysis/invalid-args: bad agg fn');
120
+ err.code = 'analysis/invalid-args';
121
+ throw err;
122
+ }
123
+ }
124
+ }
125
+ return spec;
126
+ }
127
+ export function createAnalysisToolDefinitions(config) {
128
+ const limits = { ...DEFAULT_ANALYSIS_LIMITS, ...config.limits };
129
+ const ws = config.workspaceRoot;
130
+ const cliPath = config.cliPath;
131
+ const run = async (args, signal) => {
132
+ try {
133
+ return await withWorkspaceCliLock(ws, () => runAnalyzeCli({
134
+ workspaceRoot: ws,
135
+ cliPath,
136
+ cliPrefixArgs: config.cliPrefixArgs,
137
+ args,
138
+ limits,
139
+ signal,
140
+ }));
141
+ }
142
+ catch (e) {
143
+ failMessage(e);
144
+ }
145
+ };
146
+ const datasetIngstDescription = 'Ingest a local workspace file (csv/jsonl) into the analysis dataset store. ' +
147
+ 'Source path must be under this session analysis root (relative preferred). ' +
148
+ 'If the file was produced elsewhere (bash, connector, export), first copy/write it ' +
149
+ 'into the session analysis root, then ingest with a relative path. ' +
150
+ 'Returns a datasetId and schema summary only — never the full table. ' +
151
+ 'Use this before other dataset_* tools. Large data stays on disk.';
152
+ const defs = {
153
+ dataset_ingest: {
154
+ name: 'dataset_ingest',
155
+ description: datasetIngstDescription,
156
+ parameters: {
157
+ type: 'object',
158
+ properties: {
159
+ path: {
160
+ type: 'string',
161
+ description: 'File path under the session analysis root (relative preferred). ' +
162
+ 'Copy external outputs into the analysis root first, then pass a relative path.',
163
+ },
164
+ format: {
165
+ type: 'string',
166
+ enum: ['csv', 'jsonl', 'parquet'],
167
+ description: 'Source format. Omit to infer from extension.',
168
+ },
169
+ label: { type: 'string', description: 'Optional short label for humans.' },
170
+ },
171
+ required: ['path'],
172
+ additionalProperties: false,
173
+ },
174
+ output: {
175
+ schema: {
176
+ type: 'object',
177
+ properties: {
178
+ datasetId: { type: 'string' },
179
+ rowCount: { type: 'number' },
180
+ columnCount: { type: 'number' },
181
+ columns: {
182
+ type: 'array',
183
+ items: {
184
+ type: 'object',
185
+ properties: {
186
+ name: { type: 'string' },
187
+ dtype: { type: 'string' },
188
+ },
189
+ },
190
+ },
191
+ bytesOnDisk: { type: 'number' },
192
+ },
193
+ required: ['datasetId', 'rowCount', 'columnCount', 'columns'],
194
+ additionalProperties: true,
195
+ },
196
+ render(_args, value) {
197
+ const v = value;
198
+ const cols = (v.columns ?? []).map(c => `${c.name}:${c.dtype}`).join(', ');
199
+ return textRender(`dataset ${v.datasetId} ready (${v.rowCount} rows × ${v.columnCount} cols).\n` +
200
+ `columns: ${cols}\n` +
201
+ 'Next: dataset_head / stats_describe / transform_agg. Do not dump full rows into the conversation.');
202
+ },
203
+ },
204
+ async execute(args, exec) {
205
+ const a = args;
206
+ if (typeof a.path !== 'string' || a.path.length === 0 || a.path.length > MAX_PATH_LEN) {
207
+ const err = new Error('analysis/invalid-args: path length invalid');
208
+ err.code = 'analysis/invalid-args';
209
+ throw err;
210
+ }
211
+ if (a.label !== undefined && a.label !== null) {
212
+ if (typeof a.label !== 'string' || a.label.length > MAX_LABEL_LEN) {
213
+ const err = new Error(`analysis/invalid-args: label must be string ≤ ${MAX_LABEL_LEN}`);
214
+ err.code = 'analysis/invalid-args';
215
+ throw err;
216
+ }
217
+ }
218
+ const abs = resolveInWorkspace(ws, a.path);
219
+ assertNotSensitiveSource(abs, config.profileRoot);
220
+ if (a.format === 'parquet') {
221
+ const err = new Error('analysis/limit: parquet not enabled in T0 (use csv/jsonl)');
222
+ err.code = 'analysis/limit';
223
+ throw err;
224
+ }
225
+ const cliArgs = ['ingest', '--input', a.path, '--dataset-id', newDatasetId()];
226
+ if (a.format)
227
+ cliArgs.push('--format', a.format);
228
+ if (a.label)
229
+ cliArgs.push('--label', a.label);
230
+ const result = await run(cliArgs, exec?.signal);
231
+ return result.data;
232
+ },
233
+ },
234
+ dataset_head: {
235
+ name: 'dataset_head',
236
+ description: 'Return up to 20 sample rows and column names for a datasetId. Use for orientation only; not for bulk analysis.',
237
+ parameters: {
238
+ type: 'object',
239
+ properties: {
240
+ datasetId: { type: 'string' },
241
+ n: { type: 'number', description: '1–20, default 5' },
242
+ },
243
+ required: ['datasetId'],
244
+ additionalProperties: false,
245
+ },
246
+ output: {
247
+ schema: {
248
+ type: 'object',
249
+ properties: {
250
+ datasetId: { type: 'string' },
251
+ columns: { type: 'array', items: { type: 'string' } },
252
+ rows: { type: 'array', items: { type: 'array' } },
253
+ rowCount: { type: 'number' },
254
+ },
255
+ required: ['datasetId', 'columns', 'rows'],
256
+ additionalProperties: true,
257
+ },
258
+ render(_args, value) {
259
+ const v = value;
260
+ return textRender(`head of ${v.datasetId} (${v.rowCount} total rows)\n` +
261
+ JSON.stringify({ columns: v.columns, rows: v.rows }, null, 2));
262
+ },
263
+ },
264
+ async execute(args, exec) {
265
+ const a = args;
266
+ validateDatasetId(a.datasetId);
267
+ const n = clampHeadN(a.n, limits);
268
+ const result = await run(['head', '--dataset-id', a.datasetId, '--n', String(n)], exec?.signal);
269
+ return result.data;
270
+ },
271
+ },
272
+ dataset_schema: {
273
+ name: 'dataset_schema',
274
+ description: 'Column names, dtypes, and row count for a datasetId.',
275
+ parameters: {
276
+ type: 'object',
277
+ properties: {
278
+ datasetId: { type: 'string' },
279
+ },
280
+ required: ['datasetId'],
281
+ additionalProperties: false,
282
+ },
283
+ output: {
284
+ schema: {
285
+ type: 'object',
286
+ properties: {
287
+ datasetId: { type: 'string' },
288
+ rowCount: { type: 'number' },
289
+ columns: {
290
+ type: 'array',
291
+ items: {
292
+ type: 'object',
293
+ properties: {
294
+ name: { type: 'string' },
295
+ dtype: { type: 'string' },
296
+ },
297
+ },
298
+ },
299
+ },
300
+ required: ['datasetId', 'rowCount', 'columns'],
301
+ additionalProperties: true,
302
+ },
303
+ render(_args, value) {
304
+ const v = value;
305
+ const lines = (v.columns ?? []).map(c => `- ${c.name}: ${c.dtype}`);
306
+ return textRender(`${v.datasetId}: ${v.rowCount} rows\n${lines.join('\n')}`);
307
+ },
308
+ },
309
+ async execute(args, exec) {
310
+ const a = args;
311
+ validateDatasetId(a.datasetId);
312
+ const result = await run(['schema', '--dataset-id', a.datasetId], exec?.signal);
313
+ return result.data;
314
+ },
315
+ },
316
+ dataset_list: {
317
+ name: 'dataset_list',
318
+ description: 'List datasets in this session analysis store: datasetId, label, rowCount, columnCount, producedBy. ' +
319
+ 'Use to discover prior ingests and transform outputs before re-running work.',
320
+ parameters: {
321
+ type: 'object',
322
+ properties: {},
323
+ additionalProperties: false,
324
+ },
325
+ output: {
326
+ schema: {
327
+ type: 'object',
328
+ properties: {
329
+ datasets: {
330
+ type: 'array',
331
+ items: {
332
+ type: 'object',
333
+ properties: {
334
+ datasetId: { type: 'string' },
335
+ rowCount: { type: 'number' },
336
+ columnCount: { type: 'number' },
337
+ label: { type: ['string', 'null'] },
338
+ producedBy: { type: 'string' },
339
+ },
340
+ additionalProperties: true,
341
+ },
342
+ },
343
+ },
344
+ required: ['datasets'],
345
+ additionalProperties: true,
346
+ },
347
+ render(_args, value) {
348
+ const v = value;
349
+ const rows = v.datasets ?? [];
350
+ if (rows.length === 0) {
351
+ return textRender('no datasets in this session store. Use dataset_ingest first.');
352
+ }
353
+ const lines = rows.map((d) => `- ${d.datasetId}: ${d.rowCount} rows × ${d.columnCount} cols` +
354
+ (d.label ? ` · ${d.label}` : '') +
355
+ ` (${d.producedBy})`);
356
+ return textRender(`datasets (${rows.length}):\n${lines.join('\n')}`);
357
+ },
358
+ },
359
+ async execute(_args, exec) {
360
+ const result = await run(['list'], exec?.signal);
361
+ return result.data;
362
+ },
363
+ },
364
+ dataset_export: {
365
+ name: 'dataset_export',
366
+ description: 'Export a dataset to CSV under the session analysis root outputs/ (.dsh-analyze/outputs/). ' +
367
+ 'Returns outputPath relative to the workspace — does not print rows. ' +
368
+ 'Use when another tool or human needs the full table on disk.',
369
+ parameters: {
370
+ type: 'object',
371
+ properties: {
372
+ datasetId: { type: 'string' },
373
+ outPath: {
374
+ type: 'string',
375
+ description: 'Optional file name (or workspace-relative path; the file name is kept). ' +
376
+ 'Always written under .dsh-analyze/outputs/. Defaults to <datasetId>.csv.',
377
+ },
378
+ },
379
+ required: ['datasetId'],
380
+ additionalProperties: false,
381
+ },
382
+ output: {
383
+ schema: {
384
+ type: 'object',
385
+ properties: {
386
+ outputPath: { type: 'string' },
387
+ rowCount: { type: 'number' },
388
+ bytes: { type: 'number' },
389
+ },
390
+ required: ['outputPath', 'rowCount'],
391
+ additionalProperties: true,
392
+ },
393
+ render(_args, value) {
394
+ const v = value;
395
+ return textRender(`exported ${v.rowCount} rows → ${v.outputPath}` +
396
+ (typeof v.bytes === 'number' ? ` (${v.bytes} bytes)` : ''));
397
+ },
398
+ },
399
+ async execute(args, exec) {
400
+ const a = args;
401
+ validateDatasetId(a.datasetId);
402
+ const outPath = typeof a.outPath === 'string' && a.outPath.trim()
403
+ ? a.outPath.trim()
404
+ : `.dsh-analyze/outputs/${a.datasetId}.csv`;
405
+ if (outPath.length > MAX_PATH_LEN) {
406
+ const err = new Error('analysis/invalid-args: outPath too long');
407
+ err.code = 'analysis/invalid-args';
408
+ throw err;
409
+ }
410
+ // Double-check destination stays in workspace before spawning CLI.
411
+ resolveInWorkspace(ws, outPath);
412
+ const result = await run(['export', '--dataset-id', a.datasetId, '--output', outPath], exec?.signal);
413
+ return result.data;
414
+ },
415
+ },
416
+ dataset_rm: {
417
+ name: 'dataset_rm',
418
+ description: 'Permanently remove a dataset from this session store (meta + data files). Irreversible.',
419
+ parameters: {
420
+ type: 'object',
421
+ properties: {
422
+ datasetId: { type: 'string' },
423
+ },
424
+ required: ['datasetId'],
425
+ additionalProperties: false,
426
+ },
427
+ output: {
428
+ schema: {
429
+ type: 'object',
430
+ properties: {
431
+ datasetId: { type: 'string' },
432
+ removed: { type: 'boolean' },
433
+ },
434
+ required: ['datasetId', 'removed'],
435
+ additionalProperties: true,
436
+ },
437
+ render(_args, value) {
438
+ const v = value;
439
+ return textRender(`removed ${v.datasetId}`);
440
+ },
441
+ },
442
+ async execute(args, exec) {
443
+ const a = args;
444
+ validateDatasetId(a.datasetId);
445
+ const result = await run(['rm', '--dataset-id', a.datasetId], exec?.signal);
446
+ return result.data;
447
+ },
448
+ },
449
+ stats_describe: {
450
+ name: 'stats_describe',
451
+ description: 'Column statistics (null count, min/max/mean/std/quartiles for numeric columns). ' +
452
+ 'Prefer this over exporting raw rows when summarizing data.',
453
+ parameters: {
454
+ type: 'object',
455
+ properties: {
456
+ datasetId: { type: 'string' },
457
+ columns: {
458
+ type: 'array',
459
+ items: { type: 'string' },
460
+ description: 'Optional subset of column names.',
461
+ },
462
+ },
463
+ required: ['datasetId'],
464
+ additionalProperties: false,
465
+ },
466
+ output: {
467
+ schema: {
468
+ type: 'object',
469
+ properties: {
470
+ datasetId: { type: 'string' },
471
+ stats: {
472
+ type: 'array',
473
+ items: {
474
+ type: 'object',
475
+ properties: {
476
+ name: { type: 'string' },
477
+ dtype: { type: 'string' },
478
+ nullCount: { type: 'number' },
479
+ },
480
+ additionalProperties: true,
481
+ },
482
+ },
483
+ },
484
+ required: ['datasetId', 'stats'],
485
+ additionalProperties: true,
486
+ },
487
+ render(_args, value) {
488
+ const v = value;
489
+ return textRender(`describe ${v.datasetId}\n${JSON.stringify(v.stats, null, 2)}`);
490
+ },
491
+ },
492
+ async execute(args, exec) {
493
+ const a = args;
494
+ validateDatasetId(a.datasetId);
495
+ const cliArgs = ['describe', '--dataset-id', a.datasetId];
496
+ if (Array.isArray(a.columns) && a.columns.length > 0) {
497
+ cliArgs.push('--columns', a.columns.join(','));
498
+ }
499
+ const result = await run(cliArgs, exec?.signal);
500
+ return result.data;
501
+ },
502
+ },
503
+ transform_agg: {
504
+ name: 'transform_agg',
505
+ description: 'Filter / project / group-by / aggregate a dataset. ' +
506
+ 'Returns at most `limit` rows (default 1000). ' +
507
+ 'Set outLabel to persist a new datasetId for further steps.',
508
+ parameters: {
509
+ type: 'object',
510
+ properties: {
511
+ datasetId: { type: 'string' },
512
+ spec: {
513
+ type: 'string',
514
+ description: 'JSON string: { select?, filter?, groupBy?, aggs?, orderBy?, limit? }. ' +
515
+ 'filter ops: eq/ne/gt/gte/lt/lte/in/contains/is_null/not_null. ' +
516
+ 'agg fns: count/sum/mean/min/max/median/std.',
517
+ },
518
+ outLabel: {
519
+ type: 'string',
520
+ description: 'If set, persist result as a new dataset and return its datasetId.',
521
+ },
522
+ },
523
+ required: ['datasetId', 'spec'],
524
+ additionalProperties: false,
525
+ },
526
+ output: {
527
+ schema: {
528
+ type: 'object',
529
+ properties: {
530
+ datasetId: { type: 'string' },
531
+ outDatasetId: { type: 'string' },
532
+ rowCount: { type: 'number' },
533
+ columns: { type: 'array', items: { type: 'string' } },
534
+ rows: { type: 'array', items: { type: 'array' } },
535
+ truncated: { type: 'boolean' },
536
+ },
537
+ required: ['datasetId'],
538
+ additionalProperties: true,
539
+ },
540
+ render(_args, value) {
541
+ const v = value;
542
+ if (v.truncated || v.outDatasetId) {
543
+ return textRender(`agg → ${v.outDatasetId ?? v.datasetId}: ${v.rowCount} rows ` +
544
+ `(full result stored; use dataset_head or further agg). columns=${(v.columns ?? []).join(',')}`);
545
+ }
546
+ return textRender(JSON.stringify(value, null, 2));
547
+ },
548
+ },
549
+ async execute(args, exec) {
550
+ const a = args;
551
+ validateDatasetId(a.datasetId);
552
+ const spec = validateAggSpec(a.spec, limits);
553
+ const cliArgs = ['agg', '--dataset-id', a.datasetId, '--spec', spec];
554
+ if (a.outLabel)
555
+ cliArgs.push('--out', a.outLabel);
556
+ const result = await run(cliArgs, exec?.signal);
557
+ return result.data;
558
+ },
559
+ },
560
+ };
561
+ return defs;
562
+ }
563
+ export function createAnalysisDefinitionsForNames(names, config) {
564
+ const all = createAnalysisToolDefinitions(config);
565
+ return names.map(n => all[n]);
566
+ }