geotechcli 0.3.0 → 0.4.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.
@@ -1,10 +1,10 @@
1
1
  import { Command } from 'commander';
2
2
  import { readFileSync, writeFileSync } from 'node:fs';
3
- import ora from 'ora';
4
3
  import chalk from 'chalk';
5
- import { buildLLMConfig, analyzeCoreBox, classifyRMRFromImage, classifySoilFromDescription, interpretBoreholeLog, queryGBRDocument, interpretSensorImage, runAgent, runSwarm, AgentConversation, loadProject, getProjectAgentContext, addAgentSession, addArtifact, addNote, saveNamedDataset, saveDerivedParameter, setActiveAnalysisContext, generateReport, renderReportAsPdf, renderReportAsDocx, } from '@geotechcli/core';
6
- import { heading, keyValue, renderJSON, success, error, warn, renderTable } from '../ui/terminal.js';
4
+ import { buildLLMConfig, DEFAULT_LLM_VISION_MODEL, analyzeCoreBox, classifyRMRFromImage, classifySoilFromDescription, interpretBoreholeLog, queryGBRDocument, interpretSensorImage, runAgent, runSwarm, AgentConversation, loadProject, getProjectAgentContext, addAgentSession, addArtifact, addNote, saveNamedDataset, saveDerivedParameter, setActiveAnalysisContext, generateReport, generateReportFromCaseFile, renderReportAsPdf, renderReportAsDocx, buildSwarmSessionProjectRecord, persistSwarmCaseFile, persistCaseFileEvidence, } from '@geotechcli/core';
5
+ import { heading, keyValue, renderJSON, success, error, warn, renderTable, info } from '../ui/terminal.js';
7
6
  import { addGlobalFlags, getGlobalFlags } from '../util/flags.js';
7
+ import { estimateHostedBetaVisionBodyBytes, formatByteSize, HOSTED_BETA_REQUEST_LIMIT_BYTES, readVisionInput, readVisionPdfPageInputs, resolveStructuredOutputTarget, HOSTED_BETA_REQUEST_SAFE_BYTES, } from '../util/vision-output.js';
8
8
  async function checkQuota(_callType) {
9
9
  // Strong-beta hosted limits are enforced server-side by the beta proxy.
10
10
  // Keep the CLI permissive here so successful completions, retries, and
@@ -32,11 +32,251 @@ function loadImageBase64(filePath) {
32
32
  mimeType: mimeMap[ext] ?? 'image/png',
33
33
  };
34
34
  }
35
+ function describeVisionInput(file) {
36
+ if (file.kind !== 'pdf') {
37
+ return;
38
+ }
39
+ console.log('');
40
+ console.log(chalk.yellow(' PDF input detected.'));
41
+ console.log(chalk.gray(' GLM vision works best with PNG or JPG images.'));
42
+ console.log(chalk.gray(' For borehole logs, the CLI can split multi-page PDFs into page-level requests automatically.'));
43
+ console.log(chalk.gray(' Oversized PDF pages will still be blocked before upload to avoid the hosted-beta body limit.'));
44
+ console.log('');
45
+ }
46
+ function ensureHostedBetaVisionPayloadWithinLimit(file, details) {
47
+ const estimatedBytes = estimateHostedBetaVisionBodyBytes({
48
+ prompt: details.prompt,
49
+ systemPrompt: details.systemPrompt,
50
+ imageBase64: file.base64,
51
+ mimeType: file.mimeType,
52
+ model: details.model,
53
+ temperature: details.temperature,
54
+ maxTokens: details.maxTokens,
55
+ jsonMode: false,
56
+ });
57
+ if (estimatedBytes <= HOSTED_BETA_REQUEST_SAFE_BYTES) {
58
+ return;
59
+ }
60
+ const fileSize = formatByteSize(file.fileBytes);
61
+ const payloadSize = formatByteSize(estimatedBytes);
62
+ const limitSize = formatByteSize(HOSTED_BETA_REQUEST_SAFE_BYTES);
63
+ const capSize = formatByteSize(HOSTED_BETA_REQUEST_LIMIT_BYTES);
64
+ const baseMessage = file.kind === 'pdf'
65
+ ? 'PDF vision inputs are uploaded as a single base64 payload and are too large for the hosted beta proxy.'
66
+ : 'Image vision inputs are uploaded as a single base64 payload and are too large for the hosted beta proxy.';
67
+ const mitigation = file.kind === 'pdf'
68
+ ? 'Export one page as PNG or JPG, or split the PDF into smaller files, then retry.'
69
+ : 'Resize or crop the image to the relevant region, then retry.';
70
+ throw new Error(`${baseMessage} File: ${file.filePath} (${fileSize}). Estimated request body: ${payloadSize}. Safe limit: ${limitSize}. Hosted beta cap: ${capSize}.\n${mitigation}`);
71
+ }
72
+ function maybeCheckHostedBetaVisionPayload(config, file, details) {
73
+ if (config.provider !== 'hosted-beta') {
74
+ return;
75
+ }
76
+ ensureHostedBetaVisionPayloadWithinLimit(file, {
77
+ ...details,
78
+ model: config.visionModelId ?? DEFAULT_LLM_VISION_MODEL,
79
+ });
80
+ }
35
81
  function formatMaybe(value, suffix = '') {
36
82
  if (value == null || value === '')
37
83
  return 'Unavailable';
38
84
  return `${value}${suffix}`;
39
85
  }
86
+ function startProgress(flags, text) {
87
+ if (flags.json || flags.quiet) {
88
+ return null;
89
+ }
90
+ info(text);
91
+ return {
92
+ succeed(message) {
93
+ success(message);
94
+ },
95
+ fail(message) {
96
+ error(message);
97
+ },
98
+ };
99
+ }
100
+ const SWARM_AGENT_LABELS = {
101
+ orchestrator: 'Mohr',
102
+ interpretation: 'Bieniawski',
103
+ simulation: 'Terzaghi',
104
+ reviewer: 'Hoek',
105
+ };
106
+ function formatToolPreview(args, limit) {
107
+ if (!args) {
108
+ return '';
109
+ }
110
+ const serialized = JSON.stringify(args);
111
+ return serialized.length > limit ? `${serialized.slice(0, limit)}...` : serialized;
112
+ }
113
+ function renderWarningsCompact(warnings) {
114
+ if (warnings.length === 0)
115
+ return;
116
+ const uniqueWarnings = [...new Set(warnings.map((warning) => warning.trim()).filter(Boolean))];
117
+ const visibleWarnings = uniqueWarnings.slice(0, 5);
118
+ console.log(chalk.yellow(' Warnings:'));
119
+ for (const warning of visibleWarnings) {
120
+ console.log(chalk.yellow(` - ${warning}`));
121
+ }
122
+ if (uniqueWarnings.length > visibleWarnings.length) {
123
+ console.log(chalk.yellow(` - ${uniqueWarnings.length - visibleWarnings.length} more warning(s) omitted.`));
124
+ }
125
+ }
126
+ function renderParseSafetyCompact(result) {
127
+ keyValue('Parse status', result.parseStatus);
128
+ keyValue('Confidence', `${result.confidence}%`);
129
+ keyValue('Auto proceed', result.canAutoProceed ? 'Yes' : 'No');
130
+ renderWarningsCompact(result.warnings);
131
+ }
132
+ function mergeBoreholeLayers(layers) {
133
+ const deduped = new Map();
134
+ for (const layer of layers) {
135
+ const key = [
136
+ layer.depthFrom ?? 'na',
137
+ layer.depthTo ?? 'na',
138
+ (layer.description ?? '').trim().toLowerCase(),
139
+ (layer.uscsSymbol ?? '').trim().toUpperCase(),
140
+ layer.sptN ?? 'na',
141
+ ].join('|');
142
+ if (!deduped.has(key)) {
143
+ deduped.set(key, layer);
144
+ }
145
+ }
146
+ return [...deduped.values()].sort((left, right) => {
147
+ const leftDepth = left.depthFrom ?? Number.POSITIVE_INFINITY;
148
+ const rightDepth = right.depthFrom ?? Number.POSITIVE_INFINITY;
149
+ return leftDepth - rightDepth;
150
+ });
151
+ }
152
+ function mergeBoreholeInterpretations(pages, overrideBoreholeId) {
153
+ const validPages = pages.filter(({ result }) => result.layers.length > 0 || result.totalDepth != null || result.summary);
154
+ const sourcePages = validPages.length > 0 ? validPages : pages;
155
+ const mergedLayers = mergeBoreholeLayers(sourcePages.flatMap(({ result }) => result.layers));
156
+ const summaries = [...new Set(sourcePages.map(({ result }) => result.summary?.trim()).filter((value) => Boolean(value)))];
157
+ const warnings = [...new Set(pages.flatMap(({ pageNumber, result }) => result.warnings.map((warning) => `Page ${pageNumber}: ${warning}`)))];
158
+ const confidences = sourcePages.map(({ result }) => result.confidence);
159
+ const averageConfidence = confidences.length > 0
160
+ ? Math.round(confidences.reduce((sum, value) => sum + value, 0) / confidences.length)
161
+ : 0;
162
+ const totalDepth = sourcePages.reduce((maxDepth, { result }) => {
163
+ if (result.totalDepth == null)
164
+ return maxDepth;
165
+ return maxDepth == null ? result.totalDepth : Math.max(maxDepth, result.totalDepth);
166
+ }, null);
167
+ const waterTableDepth = sourcePages.reduce((selected, { result }) => {
168
+ if (result.waterTableDepth == null)
169
+ return selected;
170
+ return selected == null ? result.waterTableDepth : Math.min(selected, result.waterTableDepth);
171
+ }, null);
172
+ const parseStatus = mergedLayers.length > 0 && totalDepth != null
173
+ ? 'parsed'
174
+ : mergedLayers.length > 0 || summaries.length > 0 || totalDepth != null
175
+ ? 'partial'
176
+ : 'failed';
177
+ return {
178
+ boreholeId: overrideBoreholeId
179
+ ?? sourcePages.map(({ result }) => result.boreholeId).find((value) => value && value !== 'BH-unknown')
180
+ ?? 'BH-unknown',
181
+ totalDepth,
182
+ waterTableDepth,
183
+ layers: mergedLayers,
184
+ summary: summaries.length > 0 ? summaries.join(' ') : null,
185
+ rawLLMText: pages.map(({ pageNumber, result }) => `[Page ${pageNumber}]\n${result.rawLLMText}`).join('\n\n'),
186
+ latencyMs: pages.reduce((sum, { result }) => sum + result.latencyMs, 0),
187
+ parseStatus,
188
+ confidence: averageConfidence,
189
+ warnings,
190
+ canAutoProceed: parseStatus === 'parsed' && averageConfidence >= 70,
191
+ };
192
+ }
193
+ function handleCommandErrorClean(err, flags, code = 'command_failed') {
194
+ const message = getErrorMessage(err);
195
+ process.exitCode = 1;
196
+ if (flags.json) {
197
+ renderJSON({ error: { code, message } });
198
+ return;
199
+ }
200
+ if (code.includes('vision') || code.includes('corebox') || code.includes('rmr') || code.includes('sensor') || code.includes('borehole')) {
201
+ const lowered = message.toLowerCase();
202
+ if (lowered.includes('no content') ||
203
+ lowered.includes('empty') ||
204
+ lowered.includes('upstream') ||
205
+ lowered.includes('hosted beta proxy') ||
206
+ lowered.includes('too large for the hosted beta proxy') ||
207
+ lowered.includes('safe limit')) {
208
+ error(message);
209
+ console.log('');
210
+ console.log(chalk.gray(' Vision troubleshooting tips:'));
211
+ console.log(chalk.gray(' - Use PNG or JPG images (not PDF or BMP)'));
212
+ console.log(chalk.gray(' - Ensure the image is well-lit and clearly shows the subject'));
213
+ console.log(chalk.gray(' - Try a smaller image file (< 5 MB)'));
214
+ console.log(chalk.gray(' - Wait a moment and retry; the AI provider may be busy'));
215
+ console.log(chalk.gray(' - Run with --verbose to see the raw response'));
216
+ return;
217
+ }
218
+ }
219
+ error(message);
220
+ }
221
+ function renderAgentStepPlain(step, json, quiet = false) {
222
+ if (json || quiet)
223
+ return;
224
+ switch (step.type) {
225
+ case 'thought':
226
+ return;
227
+ case 'tool_call':
228
+ console.log(chalk.cyan(` [Terzaghi] Tool: ${step.toolName}(${formatToolPreview(step.toolArgs, 120)})`));
229
+ return;
230
+ case 'tool_result':
231
+ if (step.toolResult?.success) {
232
+ console.log(chalk.green(` [Terzaghi] Result: ${step.content}`));
233
+ }
234
+ else {
235
+ console.log(chalk.red(` [Terzaghi] Error: ${step.content}`));
236
+ }
237
+ return;
238
+ case 'answer':
239
+ return;
240
+ case 'error':
241
+ console.log(chalk.red(` [Terzaghi] Error: ${step.content}`));
242
+ return;
243
+ }
244
+ }
245
+ function renderSwarmStepPlain(step, json, quiet = false) {
246
+ if (json || quiet)
247
+ return;
248
+ const label = SWARM_AGENT_LABELS[step.agent] ?? step.agent;
249
+ const tag = `[${label}]`;
250
+ switch (step.type) {
251
+ case 'thought':
252
+ return;
253
+ case 'tool_call':
254
+ console.log(chalk.cyan(` ${tag} Tool: ${step.toolName}(${formatToolPreview(step.toolArgs, 100)})`));
255
+ return;
256
+ case 'tool_result':
257
+ if (step.toolResult?.success) {
258
+ console.log(chalk.green(` ${tag} Result: ${step.content.slice(0, 180)}`));
259
+ }
260
+ else {
261
+ console.log(chalk.red(` ${tag} Error: ${step.content.slice(0, 180)}`));
262
+ }
263
+ return;
264
+ case 'handoff':
265
+ console.log(chalk.magenta(` ${tag} Handoff: ${step.content}`));
266
+ return;
267
+ case 'review':
268
+ console.log(chalk.green(` ${tag} Review: ${step.content}`));
269
+ return;
270
+ case 'correction':
271
+ console.log(chalk.red(` ${tag} Correction: ${step.content.slice(0, 200)}`));
272
+ return;
273
+ case 'answer':
274
+ return;
275
+ case 'error':
276
+ console.log(chalk.red(` ${tag} Error: ${step.content}`));
277
+ return;
278
+ }
279
+ }
40
280
  function sanitizeErrorMessage(message) {
41
281
  return message.replace(/(?:Bearer |sk-|zhipu-|api[_-]?key[=: ]*)[^\s"'\]},]*/gi, '***REDACTED***');
42
282
  }
@@ -52,7 +292,13 @@ function handleCommandError(err, flags, code = 'command_failed') {
52
292
  }
53
293
  // Provide specific guidance for common vision errors
54
294
  if (code.includes('vision') || code.includes('corebox') || code.includes('rmr') || code.includes('sensor') || code.includes('borehole')) {
55
- if (message.toLowerCase().includes('no content') || message.toLowerCase().includes('empty') || message.toLowerCase().includes('upstream')) {
295
+ const lowered = message.toLowerCase();
296
+ if (lowered.includes('no content') ||
297
+ lowered.includes('empty') ||
298
+ lowered.includes('upstream') ||
299
+ lowered.includes('hosted beta proxy') ||
300
+ lowered.includes('too large for the hosted beta proxy') ||
301
+ lowered.includes('safe limit')) {
56
302
  error(message);
57
303
  console.log('');
58
304
  console.log(chalk.gray(' Vision troubleshooting tips:'));
@@ -69,10 +315,15 @@ function handleCommandError(err, flags, code = 'command_failed') {
69
315
  function renderWarnings(warnings) {
70
316
  if (warnings.length === 0)
71
317
  return;
318
+ const uniqueWarnings = [...new Set(warnings.map((warning) => warning.trim()).filter(Boolean))];
319
+ const visibleWarnings = uniqueWarnings.slice(0, 5);
72
320
  console.log(chalk.yellow(' Warnings:'));
73
- for (const warning of warnings) {
321
+ for (const warning of visibleWarnings) {
74
322
  console.log(chalk.yellow(` - ${warning}`));
75
323
  }
324
+ if (uniqueWarnings.length > visibleWarnings.length) {
325
+ console.log(chalk.yellow(` - ${uniqueWarnings.length - visibleWarnings.length} more warning(s) omitted.`));
326
+ }
76
327
  }
77
328
  function renderParseSafety(result) {
78
329
  keyValue('Parse status', result.parseStatus);
@@ -98,17 +349,34 @@ function persistSessionToProject(projectId, mode, query, session) {
98
349
  corrections: session.corrections,
99
350
  }
100
351
  : undefined;
101
- addAgentSession(projectId, {
102
- mode,
103
- query,
104
- answer,
105
- summary: answer?.slice(0, 240) ?? `${mode} session for: ${query.slice(0, 120)}`,
106
- stepCount: session.steps.length,
107
- tokens: session.totalTokens,
108
- latencyMs: session.totalLatencyMs,
109
- context: session.context,
110
- metadata: reviewMetadata,
111
- });
352
+ if (mode === 'swarm') {
353
+ const swarmSession = session;
354
+ addAgentSession(projectId, buildSwarmSessionProjectRecord(query, swarmSession, {
355
+ mode,
356
+ answer,
357
+ summary: answer?.slice(0, 240) ?? `${mode} session for: ${query.slice(0, 120)}`,
358
+ metadata: reviewMetadata,
359
+ }));
360
+ const caseFileResult = persistSwarmCaseFile(projectId, query, swarmSession);
361
+ const evidenceRecords = persistCaseFileEvidence(projectId, caseFileResult.scenarioId);
362
+ addNote(projectId, `Updated swarm case file "${caseFileResult.scenarioId}" from the latest session.`);
363
+ if (evidenceRecords.length > 0) {
364
+ addNote(projectId, `Captured ${evidenceRecords.length} evidence record${evidenceRecords.length === 1 ? '' : 's'} for scenario "${caseFileResult.scenarioId}".`);
365
+ }
366
+ }
367
+ else {
368
+ addAgentSession(projectId, {
369
+ mode,
370
+ query,
371
+ answer,
372
+ summary: answer?.slice(0, 240) ?? `${mode} session for: ${query.slice(0, 120)}`,
373
+ stepCount: session.steps.length,
374
+ tokens: session.totalTokens,
375
+ latencyMs: session.totalLatencyMs,
376
+ context: session.context,
377
+ metadata: reviewMetadata,
378
+ });
379
+ }
112
380
  saveNamedDataset(projectId, {
113
381
  name: 'latest-agent-context',
114
382
  kind: 'agent-context',
@@ -151,6 +419,26 @@ function persistOutputArtifact(projectId, kind, title, path, metadata) {
151
419
  metadata,
152
420
  });
153
421
  }
422
+ function buildAnalysisDocument(title, task, answer, mode) {
423
+ const content = [
424
+ `## Task`,
425
+ task,
426
+ '',
427
+ `## ${mode === 'swarm' ? 'Swarm Report' : 'Analysis'}`,
428
+ answer,
429
+ ].join('\n');
430
+ return {
431
+ title,
432
+ sections: [
433
+ {
434
+ title: 'Summary',
435
+ content,
436
+ },
437
+ ],
438
+ fullMarkdown: `# ${title}\n\n${content}\n`,
439
+ latencyMs: 0,
440
+ };
441
+ }
154
442
  // ---------------------------------------------------------------------------
155
443
  // Vision Commands
156
444
  // ---------------------------------------------------------------------------
@@ -159,24 +447,31 @@ export function registerVisionCommand(program) {
159
447
  .description('AI vision analysis for geotechnical images');
160
448
  // Core box analysis
161
449
  const coreboxCmd = new Command('corebox')
162
- .description('Analyze core box image RQD, fracture spacing, weathering')
450
+ .description('Analyze a core box image for RQD, fracture spacing, and weathering')
163
451
  .argument('<image>', 'Path to core box image')
164
452
  .action(async (imagePath, opts) => {
165
453
  const flags = getGlobalFlags(opts);
166
454
  if (!(await checkQuota('visionCalls')))
167
455
  return;
168
- const spinner = flags.json ? null : ora({ text: 'Analyzing core box image...', indent: 2 }).start();
456
+ const spinner = startProgress(flags, 'Analyzing core box image...');
169
457
  try {
170
- const { base64, mimeType } = loadImageBase64(imagePath);
458
+ const file = readVisionInput(imagePath);
459
+ describeVisionInput(file);
171
460
  const config = buildLLMConfig();
172
- const result = await analyzeCoreBox(base64, mimeType, config);
461
+ maybeCheckHostedBetaVisionPayload(config, file, {
462
+ prompt: 'Analyze this geotechnical image.',
463
+ systemPrompt: 'You are analyzing a geotechnical image.',
464
+ temperature: 0.1,
465
+ maxTokens: 900,
466
+ });
467
+ const result = await analyzeCoreBox(file.base64, file.mimeType, config);
173
468
  spinner?.succeed(`Analysis complete (${result.latencyMs}ms)`);
174
469
  if (flags.json) {
175
470
  renderJSON(result);
176
471
  return;
177
472
  }
178
473
  heading('Core Box Analysis');
179
- renderParseSafety(result);
474
+ renderParseSafetyCompact(result);
180
475
  keyValue('RQD', formatMaybe(result.rqd, '%'));
181
476
  keyValue('Fracture spacing', formatMaybe(result.fractureSpacing));
182
477
  keyValue('Weathering grade', formatMaybe(result.weatheringGrade));
@@ -191,31 +486,38 @@ export function registerVisionCommand(program) {
191
486
  }
192
487
  catch (err) {
193
488
  spinner?.fail('Analysis failed');
194
- handleCommandError(err, flags, 'corebox_analysis_failed');
489
+ handleCommandErrorClean(err, flags, 'corebox_analysis_failed');
195
490
  }
196
491
  });
197
492
  addGlobalFlags(coreboxCmd);
198
493
  vision.addCommand(coreboxCmd);
199
494
  // Hybrid RMR from image
200
495
  const rmrImageCmd = new Command('rmr')
201
- .description('Hybrid RMR: vision extracts features deterministic RMR scoring')
496
+ .description('Hybrid RMR: vision extracts features, then deterministic RMR scoring')
202
497
  .argument('<image>', 'Path to rock face / core image')
203
498
  .action(async (imagePath, opts) => {
204
499
  const flags = getGlobalFlags(opts);
205
500
  if (!(await checkQuota('visionCalls')))
206
501
  return;
207
- const spinner = flags.json ? null : ora({ text: 'Extracting rock mass parameters from image...', indent: 2 }).start();
502
+ const spinner = startProgress(flags, 'Extracting rock mass parameters from image...');
208
503
  try {
209
- const { base64, mimeType } = loadImageBase64(imagePath);
504
+ const file = readVisionInput(imagePath);
505
+ describeVisionInput(file);
210
506
  const config = buildLLMConfig();
211
- const result = await classifyRMRFromImage(base64, mimeType, config);
507
+ maybeCheckHostedBetaVisionPayload(config, file, {
508
+ prompt: 'Estimate rock mass parameters from this image.',
509
+ systemPrompt: 'You are analyzing a geotechnical image.',
510
+ temperature: 0.1,
511
+ maxTokens: 700,
512
+ });
513
+ const result = await classifyRMRFromImage(file.base64, file.mimeType, config);
212
514
  spinner?.succeed(`RMR classification complete (${result.latencyMs}ms)`);
213
515
  if (flags.json) {
214
516
  renderJSON(result);
215
517
  return;
216
518
  }
217
519
  heading('Hybrid RMR Classification (Vision + Deterministic)');
218
- renderParseSafety(result);
520
+ renderParseSafetyCompact(result);
219
521
  console.log('');
220
522
  console.log(chalk.gray(' Vision-extracted parameters:'));
221
523
  keyValue(' Estimated UCS', formatMaybe(result.visionExtraction.estimatedUCS, ' MPa'));
@@ -237,7 +539,7 @@ export function registerVisionCommand(program) {
237
539
  }
238
540
  catch (err) {
239
541
  spinner?.fail('RMR classification failed');
240
- handleCommandError(err, flags, 'rmr_classification_failed');
542
+ handleCommandErrorClean(err, flags, 'rmr_classification_failed');
241
543
  }
242
544
  });
243
545
  addGlobalFlags(rmrImageCmd);
@@ -250,18 +552,25 @@ export function registerVisionCommand(program) {
250
552
  const flags = getGlobalFlags(opts);
251
553
  if (!(await checkQuota('visionCalls')))
252
554
  return;
253
- const spinner = flags.json ? null : ora({ text: 'Interpreting sensor data...', indent: 2 }).start();
555
+ const spinner = startProgress(flags, 'Interpreting sensor data...');
254
556
  try {
255
- const { base64, mimeType } = loadImageBase64(imagePath);
557
+ const file = readVisionInput(imagePath);
558
+ describeVisionInput(file);
256
559
  const config = buildLLMConfig();
257
- const result = await interpretSensorImage(base64, mimeType, config);
560
+ maybeCheckHostedBetaVisionPayload(config, file, {
561
+ prompt: 'Interpret this sensor data image.',
562
+ systemPrompt: 'You are analyzing a geotechnical image.',
563
+ temperature: 0.1,
564
+ maxTokens: 700,
565
+ });
566
+ const result = await interpretSensorImage(file.base64, file.mimeType, config);
258
567
  spinner?.succeed(`Interpretation complete (${result.latencyMs}ms)`);
259
568
  if (flags.json) {
260
569
  renderJSON(result);
261
570
  return;
262
571
  }
263
- heading(`Sensor Interpretation ${result.sensorType ?? 'Unknown'}`);
264
- renderParseSafety(result);
572
+ heading(`Sensor Interpretation - ${result.sensorType ?? 'Unknown'}`);
573
+ renderParseSafetyCompact(result);
265
574
  keyValue('Sensor type', formatMaybe(result.sensorType));
266
575
  keyValue('Measurements', formatMaybe(result.measurements));
267
576
  console.log('');
@@ -277,7 +586,7 @@ export function registerVisionCommand(program) {
277
586
  }
278
587
  catch (err) {
279
588
  spinner?.fail('Sensor interpretation failed');
280
- handleCommandError(err, flags, 'sensor_interpretation_failed');
589
+ handleCommandErrorClean(err, flags, 'sensor_interpretation_failed');
281
590
  }
282
591
  });
283
592
  addGlobalFlags(sensorCmd);
@@ -291,18 +600,63 @@ export function registerVisionCommand(program) {
291
600
  const flags = getGlobalFlags(opts);
292
601
  if (!(await checkQuota('visionCalls')))
293
602
  return;
294
- const spinner = flags.json ? null : ora({ text: 'Extracting borehole log data...', indent: 2 }).start();
603
+ const spinner = startProgress(flags, 'Extracting borehole log data...');
295
604
  try {
296
- const { base64, mimeType } = loadImageBase64(filePath);
605
+ const file = readVisionInput(filePath);
606
+ describeVisionInput(file);
297
607
  const config = buildLLMConfig();
298
- const result = await interpretBoreholeLog(base64, mimeType, config, opts.boreholeId);
299
- spinner?.succeed(`Extraction complete: ${result.layers.length} layers (${result.latencyMs}ms)`);
608
+ const requestDetails = {
609
+ prompt: 'Extract structured borehole log data.',
610
+ systemPrompt: 'You are analyzing a geotechnical image.',
611
+ temperature: 0.1,
612
+ maxTokens: 900,
613
+ };
614
+ let result;
615
+ if (file.kind === 'pdf') {
616
+ const pageInputs = await readVisionPdfPageInputs(filePath);
617
+ if (!flags.json && !flags.quiet && pageInputs.length > 1) {
618
+ info(`PDF contains ${pageInputs.length} pages. Processing borehole log pages sequentially.`);
619
+ }
620
+ const pageResults = [];
621
+ const pageFailures = [];
622
+ for (const pageInput of pageInputs) {
623
+ if (!flags.json && !flags.quiet && pageInputs.length > 1) {
624
+ info(`Processing PDF page ${pageInput.pageNumber}/${pageInput.totalPages}...`);
625
+ }
626
+ try {
627
+ maybeCheckHostedBetaVisionPayload(config, pageInput, requestDetails);
628
+ const pageResult = await interpretBoreholeLog(pageInput.base64, pageInput.mimeType, config, opts.boreholeId);
629
+ pageResults.push({
630
+ pageNumber: pageInput.pageNumber,
631
+ result: pageResult,
632
+ });
633
+ }
634
+ catch (pageError) {
635
+ pageFailures.push(`Page ${pageInput.pageNumber}: ${getErrorMessage(pageError)}`);
636
+ }
637
+ }
638
+ if (pageResults.length === 0) {
639
+ throw new Error(pageFailures.length > 0
640
+ ? `No PDF pages could be processed successfully.\n${pageFailures.join('\n')}`
641
+ : 'No PDF pages could be processed successfully.');
642
+ }
643
+ result = mergeBoreholeInterpretations(pageResults, opts.boreholeId);
644
+ if (pageFailures.length > 0) {
645
+ result.warnings = [...result.warnings, ...pageFailures];
646
+ }
647
+ spinner?.succeed(`Extraction complete: ${result.layers.length} layers from ${pageResults.length}/${pageInputs.length} page(s) (${result.latencyMs}ms)`);
648
+ }
649
+ else {
650
+ maybeCheckHostedBetaVisionPayload(config, file, requestDetails);
651
+ result = await interpretBoreholeLog(file.base64, file.mimeType, config, opts.boreholeId);
652
+ spinner?.succeed(`Extraction complete: ${result.layers.length} layers (${result.latencyMs}ms)`);
653
+ }
300
654
  if (flags.json) {
301
655
  renderJSON(result);
302
656
  return;
303
657
  }
304
- heading(`Borehole Log ${result.boreholeId}`);
305
- renderParseSafety(result);
658
+ heading(`Borehole Log - ${result.boreholeId}`);
659
+ renderParseSafetyCompact(result);
306
660
  keyValue('Total depth', formatMaybe(result.totalDepth, ' m'));
307
661
  keyValue('Water table', result.waterTableDepth != null ? `${result.waterTableDepth} m` : 'Not detected');
308
662
  renderTable(['From (m)', 'To (m)', 'Description', 'USCS', 'SPT-N'], result.layers.map((l) => [
@@ -323,7 +677,7 @@ export function registerVisionCommand(program) {
323
677
  }
324
678
  catch (err) {
325
679
  spinner?.fail('Extraction failed');
326
- handleCommandError(err, flags, 'borehole_extraction_failed');
680
+ handleCommandErrorClean(err, flags, 'borehole_extraction_failed');
327
681
  }
328
682
  });
329
683
  addGlobalFlags(logCmd);
@@ -342,7 +696,7 @@ export function registerAIClassifyCommand(program) {
342
696
  const description = descParts.join(' ');
343
697
  if (!(await checkQuota('llmCalls')))
344
698
  return;
345
- const spinner = flags.json ? null : ora({ text: 'Classifying soil from description...', indent: 2 }).start();
699
+ const spinner = startProgress(flags, 'Classifying soil from description...');
346
700
  try {
347
701
  const config = buildLLMConfig();
348
702
  const result = await classifySoilFromDescription(description, config);
@@ -353,7 +707,7 @@ export function registerAIClassifyCommand(program) {
353
707
  }
354
708
  heading('AI Soil Classification');
355
709
  keyValue('Input', `"${description}"`);
356
- renderParseSafety(result);
710
+ renderParseSafetyCompact(result);
357
711
  keyValue('USCS symbol', formatMaybe(result.uscsSymbol));
358
712
  keyValue('USCS name', formatMaybe(result.uscsName));
359
713
  keyValue('Friction angle', formatMaybe(result.estimatedProperties.frictionAngle, ' deg'));
@@ -367,7 +721,7 @@ export function registerAIClassifyCommand(program) {
367
721
  }
368
722
  catch (err) {
369
723
  spinner?.fail('Classification failed');
370
- handleCommandError(err, flags, 'ai_classification_failed');
724
+ handleCommandErrorClean(err, flags, 'ai_classification_failed');
371
725
  }
372
726
  });
373
727
  addGlobalFlags(cmd);
@@ -388,11 +742,18 @@ export function registerGBRCommand(program) {
388
742
  const question = questionParts.join(' ');
389
743
  if (!(await checkQuota('llmCalls')))
390
744
  return;
391
- const spinner = flags.json ? null : ora({ text: `Querying GBR: "${question.slice(0, 50)}..."`, indent: 2 }).start();
745
+ const spinner = startProgress(flags, `Querying GBR: "${question.slice(0, 50)}..."`);
392
746
  try {
393
- const { base64, mimeType } = loadImageBase64(opts.doc);
747
+ const file = readVisionInput(opts.doc);
748
+ describeVisionInput(file);
394
749
  const config = buildLLMConfig();
395
- const result = await queryGBRDocument(question, base64, mimeType, config);
750
+ maybeCheckHostedBetaVisionPayload(config, file, {
751
+ prompt: 'Answer questions about this GBR document.',
752
+ systemPrompt: 'You are analyzing a geotechnical document image.',
753
+ temperature: 0.1,
754
+ maxTokens: 700,
755
+ });
756
+ const result = await queryGBRDocument(question, file.base64, file.mimeType, config);
396
757
  spinner?.succeed(`Answer ready (${result.latencyMs}ms)`);
397
758
  if (flags.json) {
398
759
  renderJSON({ question, answer: result.answer, latencyMs: result.latencyMs });
@@ -406,7 +767,7 @@ export function registerGBRCommand(program) {
406
767
  }
407
768
  catch (err) {
408
769
  spinner?.fail('GBR query failed');
409
- handleCommandError(err, flags, 'gbr_query_failed');
770
+ handleCommandErrorClean(err, flags, 'gbr_query_failed');
410
771
  }
411
772
  });
412
773
  addGlobalFlags(chatCmd);
@@ -505,9 +866,9 @@ function renderSwarmStep(step, json, quiet = false) {
505
866
  }
506
867
  export function registerAgentCommand(program) {
507
868
  const cmd = new Command('agent')
508
- .description('Agentic AI reasons about your problem and executes real calculations')
869
+ .description('Agentic AI - reasons about your problem and executes real calculations')
509
870
  .argument('<task...>', 'Engineering task in natural language')
510
- .option('--swarm', 'Use multi-agent swarm (Interpretation Simulation Reviewer)')
871
+ .option('--swarm', 'Use multi-agent swarm (Bieniawski -> Terzaghi -> Hoek)')
511
872
  .option('--project <id>', 'Load and persist context to a stored project')
512
873
  .action(async (taskParts, opts) => {
513
874
  const flags = getGlobalFlags(opts);
@@ -518,10 +879,10 @@ export function registerAgentCommand(program) {
518
879
  if (!flags.json) {
519
880
  console.log('');
520
881
  if (useSwarm) {
521
- console.log(chalk.gray(' Swarm activated Interpretation Simulation Reviewer'));
882
+ console.log(chalk.gray(' Swarm activated - Bieniawski, Terzaghi, and Hoek coordinated by Mohr'));
522
883
  }
523
884
  else {
524
- console.log(chalk.gray(' Agent activated planning and executing...'));
885
+ console.log(chalk.gray(' Agent activated - Terzaghi is planning and executing'));
525
886
  }
526
887
  console.log('');
527
888
  }
@@ -535,7 +896,7 @@ export function registerAgentCommand(program) {
535
896
  if (useSwarm) {
536
897
  // Multi-agent swarm mode
537
898
  const session = await runSwarm(task, config, (step) => {
538
- renderSwarmStep(step, flags.json, flags.quiet);
899
+ renderSwarmStepPlain(step, flags.json, flags.quiet);
539
900
  }, projectState?.context);
540
901
  const answer = session.steps.find((s) => s.type === 'answer');
541
902
  if (projectState) {
@@ -570,18 +931,34 @@ export function registerAgentCommand(program) {
570
931
  const toolCalls = session.steps.filter((s) => s.type === 'tool_call').length;
571
932
  const agents = [...new Set(session.steps.map((s) => s.agent))];
572
933
  console.log(chalk.gray(` (${agents.length} agents, ${toolCalls} tools executed, review: ${session.reviewPassed ? 'PASSED' : 'ISSUES NOTED'}, ${session.totalTokens} tokens)`));
573
- console.log(chalk.cyan('\n Hint: To continue this session interactively, run: ') + chalk.white(`geotech chat${opts.project ? ` --project ${opts.project}` : ''}`));
934
+ console.log(chalk.cyan('\n Continue interactively with: ') + chalk.white(`geotech chat${opts.project ? ` --project ${opts.project}` : ''}`));
574
935
  }
575
936
  if (flags.output && answer) {
576
- writeFileSync(flags.output, answer.content);
577
- persistOutputArtifact(projectState?.id, 'swarm-report-file', 'swarm report output', flags.output, { task });
578
- success(`Report saved to ${flags.output}`);
937
+ const outputTarget = resolveStructuredOutputTarget({
938
+ outputPath: flags.output,
939
+ defaultBaseName: 'swarm-report',
940
+ });
941
+ if (outputTarget.warning) {
942
+ warn(outputTarget.warning);
943
+ }
944
+ if (outputTarget.kind === 'pdf' || outputTarget.kind === 'docx') {
945
+ const document = buildAnalysisDocument('Swarm Report', task, answer.content, 'swarm');
946
+ const buffer = outputTarget.kind === 'pdf'
947
+ ? await renderReportAsPdf(document)
948
+ : await renderReportAsDocx(document);
949
+ writeFileSync(outputTarget.outputPath, buffer);
950
+ }
951
+ else {
952
+ writeFileSync(outputTarget.outputPath, answer.content);
953
+ }
954
+ persistOutputArtifact(projectState?.id, 'swarm-report-file', 'swarm report output', outputTarget.outputPath, { task });
955
+ success(`Report saved to ${outputTarget.outputPath}`);
579
956
  }
580
957
  }
581
958
  else {
582
959
  // Single-agent ReAct mode (default)
583
960
  const session = await runAgent(task, config, (step) => {
584
- renderAgentStep(step, flags.json, flags.quiet);
961
+ renderAgentStepPlain(step, flags.json, flags.quiet);
585
962
  }, projectState?.context);
586
963
  const answer = session.steps.find((s) => s.type === 'answer');
587
964
  if (projectState) {
@@ -611,12 +988,28 @@ export function registerAgentCommand(program) {
611
988
  console.log(answer.content);
612
989
  console.log('');
613
990
  console.log(chalk.gray(` (${session.steps.filter((s) => s.type === 'tool_call').length} tools executed, ${session.totalTokens} tokens, ${session.totalLatencyMs}ms)`));
614
- console.log(chalk.cyan('\n Hint: To continue this session interactively, run: ') + chalk.white(`geotech chat${opts.project ? ` --project ${opts.project}` : ''}`));
991
+ console.log(chalk.cyan('\n Continue interactively with: ') + chalk.white(`geotech chat${opts.project ? ` --project ${opts.project}` : ''}`));
615
992
  }
616
993
  if (flags.output && answer) {
617
- writeFileSync(flags.output, answer.content);
618
- persistOutputArtifact(projectState?.id, 'agent-report-file', 'agent analysis output', flags.output, { task });
619
- success(`Report saved to ${flags.output}`);
994
+ const outputTarget = resolveStructuredOutputTarget({
995
+ outputPath: flags.output,
996
+ defaultBaseName: 'agent-analysis',
997
+ });
998
+ if (outputTarget.warning) {
999
+ warn(outputTarget.warning);
1000
+ }
1001
+ if (outputTarget.kind === 'pdf' || outputTarget.kind === 'docx') {
1002
+ const document = buildAnalysisDocument('Agent Analysis', task, answer.content, 'single');
1003
+ const buffer = outputTarget.kind === 'pdf'
1004
+ ? await renderReportAsPdf(document)
1005
+ : await renderReportAsDocx(document);
1006
+ writeFileSync(outputTarget.outputPath, buffer);
1007
+ }
1008
+ else {
1009
+ writeFileSync(outputTarget.outputPath, answer.content);
1010
+ }
1011
+ persistOutputArtifact(projectState?.id, 'agent-report-file', 'agent analysis output', outputTarget.outputPath, { task });
1012
+ success(`Report saved to ${outputTarget.outputPath}`);
620
1013
  }
621
1014
  }
622
1015
  if (!flags.json) {
@@ -624,7 +1017,7 @@ export function registerAgentCommand(program) {
624
1017
  }
625
1018
  }
626
1019
  catch (err) {
627
- handleCommandError(err, flags, useSwarm ? 'swarm_failed' : 'agent_failed');
1020
+ handleCommandErrorClean(err, flags, useSwarm ? 'swarm_failed' : 'agent_failed');
628
1021
  }
629
1022
  });
630
1023
  addGlobalFlags(cmd);
@@ -635,12 +1028,12 @@ export function registerAgentCommand(program) {
635
1028
  // ---------------------------------------------------------------------------
636
1029
  export function registerChatCommand(program) {
637
1030
  const cmd = new Command('chat')
638
- .description('Interactive agentic session type natural language, agent executes tools with memory')
1031
+ .description('Interactive agentic session - type natural language, agent executes tools with memory')
639
1032
  .option('--project <id>', 'Load and persist context to a stored project')
640
1033
  .action(async (opts) => {
641
1034
  const { createInterface } = await import('node:readline');
642
1035
  console.log('');
643
- console.log(chalk.bold.cyan(' geotech') + chalk.bold.white('CLI') + chalk.gray(' Agent Interactive Mode'));
1036
+ console.log(chalk.bold.cyan(' geotech') + chalk.bold.white('CLI') + chalk.gray(' Agent - Interactive Mode'));
644
1037
  console.log(chalk.gray(' Type engineering questions. The agent will reason and execute calculations.'));
645
1038
  console.log(chalk.gray(' Commands: /context (show memory), /clear (reset), /exit (quit)'));
646
1039
  console.log('');
@@ -731,7 +1124,7 @@ export function registerChatCommand(program) {
731
1124
  console.log('');
732
1125
  try {
733
1126
  const session = await conversation.ask(input, config, (step) => {
734
- renderAgentStep(step, false, false);
1127
+ renderAgentStepPlain(step, false, false);
735
1128
  });
736
1129
  if (projectState) {
737
1130
  persistSessionToProject(projectState.id, 'chat', input, session);
@@ -762,53 +1155,79 @@ export function registerChatCommand(program) {
762
1155
  export function registerReportCommand(program) {
763
1156
  const cmd = new Command('report')
764
1157
  .description('Generate AI-powered geotechnical report from analysis data')
765
- .requiredOption('--data <file>', 'JSON file with analysis results')
1158
+ .option('--data <file>', 'JSON file with analysis results')
1159
+ .option('--from-case-file <scenarioId>', 'Assemble a deterministic report from a stored case file')
1160
+ .option('--project-id <id>', 'Stored project id required with --from-case-file')
766
1161
  .option('--type <type>', 'Report type: borehole|site-investigation|tunnel-design|foundation|slope|custom', 'site-investigation')
767
1162
  .option('--project <name>', 'Project name')
768
1163
  .option('--location <loc>', 'Project location')
769
1164
  .option('--format <ext>', 'Export format: md|pdf|docx', 'md')
770
1165
  .action(async (opts) => {
771
1166
  const flags = getGlobalFlags(opts);
772
- if (!(await checkQuota('llmCalls')))
773
- return;
774
- const spinner = flags.json ? null : ora({ text: 'Generating report...', indent: 2 }).start();
1167
+ const useCaseFile = typeof opts.fromCaseFile === 'string' && opts.fromCaseFile.trim().length > 0;
1168
+ let spinner = null;
775
1169
  try {
776
- const data = JSON.parse(readFileSync(opts.data, 'utf-8'));
777
- const config = buildLLMConfig();
778
- const report = await generateReport(data, {
779
- type: opts.type,
780
- projectName: opts.project,
781
- location: opts.location,
782
- }, config);
783
- spinner?.succeed(`Report generated: ${report.sections.length} sections (${report.latencyMs}ms)`);
1170
+ if (!useCaseFile && !opts.data) {
1171
+ throw new Error('Provide either --data <file> or --from-case-file <scenarioId>.');
1172
+ }
1173
+ if (useCaseFile && !opts.projectId) {
1174
+ throw new Error('--project-id is required when using --from-case-file.');
1175
+ }
1176
+ if (!useCaseFile && !(await checkQuota('llmCalls'))) {
1177
+ return;
1178
+ }
1179
+ spinner = startProgress(flags, useCaseFile ? 'Assembling case-file report...' : 'Generating report...');
1180
+ const report = useCaseFile
1181
+ ? await generateReportFromCaseFile({
1182
+ projectId: opts.projectId,
1183
+ scenarioId: opts.fromCaseFile,
1184
+ projectName: opts.project,
1185
+ location: opts.location,
1186
+ })
1187
+ : await (async () => {
1188
+ const data = JSON.parse(readFileSync(opts.data, 'utf-8'));
1189
+ const config = buildLLMConfig();
1190
+ return generateReport(data, {
1191
+ type: opts.type,
1192
+ projectName: opts.project,
1193
+ location: opts.location,
1194
+ }, config);
1195
+ })();
1196
+ spinner?.succeed(`${useCaseFile ? 'Case-file report assembled' : 'Report generated'}: ${report.sections.length} sections (${report.latencyMs}ms)`);
784
1197
  if (flags.json) {
785
1198
  renderJSON(report);
786
1199
  return;
787
1200
  }
788
- const format = opts.format.toLowerCase();
789
- let outputFile = flags.output;
790
- if (!outputFile) {
791
- const baseName = (opts.project ? opts.project.replace(/\s+/g, '_') : 'report').toLowerCase();
792
- outputFile = `${baseName}.${format}`;
1201
+ const baseName = (opts.project
1202
+ ? opts.project.replace(/\s+/g, '_')
1203
+ : useCaseFile
1204
+ ? `${opts.projectId}_${opts.fromCaseFile}`
1205
+ : 'report').toLowerCase();
1206
+ const outputTarget = resolveStructuredOutputTarget({
1207
+ outputPath: flags.output,
1208
+ requestedFormat: opts.format,
1209
+ defaultBaseName: baseName,
1210
+ });
1211
+ if (outputTarget.warning) {
1212
+ warn(outputTarget.warning);
793
1213
  }
794
- if (format === 'pdf') {
1214
+ if (outputTarget.kind === 'pdf') {
795
1215
  const buf = await renderReportAsPdf(report);
796
- writeFileSync(outputFile, buf);
1216
+ writeFileSync(outputTarget.outputPath, buf);
797
1217
  }
798
- else if (format === 'docx') {
1218
+ else if (outputTarget.kind === 'docx') {
799
1219
  const buf = await renderReportAsDocx(report);
800
- writeFileSync(outputFile, buf);
1220
+ writeFileSync(outputTarget.outputPath, buf);
801
1221
  }
802
1222
  else {
803
- // Default to markdown
804
- writeFileSync(outputFile, report.fullMarkdown);
1223
+ writeFileSync(outputTarget.outputPath, report.fullMarkdown);
805
1224
  }
806
- success(`Report saved to ${outputFile}`);
1225
+ success(`Report saved to ${outputTarget.outputPath}`);
807
1226
  console.log('');
808
1227
  }
809
1228
  catch (err) {
810
1229
  spinner?.fail('Report generation failed');
811
- handleCommandError(err, flags, 'report_generation_failed');
1230
+ handleCommandErrorClean(err, flags, 'report_generation_failed');
812
1231
  }
813
1232
  });
814
1233
  addGlobalFlags(cmd);