m3triq 0.1.0 → 0.2.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.
@@ -0,0 +1,151 @@
1
+ import { output, formatTable } from '../output.js';
2
+ import { createAgentsClient } from '../cli.js';
3
+ import { requireApiKey, requireProject } from '../config.js';
4
+ export function registerFoodbCommands(program) {
5
+ const foodb = program
6
+ .command('foodb')
7
+ .description('Query the M3TRIQ FooDB database (self-hosted, food compounds)')
8
+ .hook('preAction', () => { requireApiKey(); });
9
+ // ── m3t foodb search <food> ───────────────────────────────────
10
+ foodb
11
+ .command('search')
12
+ .argument('<food>', 'Food name (e.g., "tomato", "coffee", "soybean")')
13
+ .option('--category <cat>', 'Filter by category (e.g., flavonoid, alkaloid)')
14
+ .option('--limit <n>', 'Max results', '20')
15
+ .option('--save <name>', 'Save results as project dataset')
16
+ .description('Search compounds in a specific food')
17
+ .action(async (food, opts) => {
18
+ const agents = createAgentsClient();
19
+ const params = {
20
+ food_name: food,
21
+ limit: parseInt(opts.limit),
22
+ auto_save: false,
23
+ };
24
+ if (opts.category)
25
+ params.category = opts.category;
26
+ if (opts.save) {
27
+ const project = requireProject();
28
+ params.auto_save = true;
29
+ params.project_id = project.id;
30
+ }
31
+ const data = await agents.callMcpTool('foodb', 'search_food_compounds', params);
32
+ const compounds = extractArray(data, ['compounds', 'results', 'data']);
33
+ if (compounds.length === 0) {
34
+ // Might be text response
35
+ if (typeof data.result === 'string') {
36
+ output(data, data.result);
37
+ }
38
+ else {
39
+ output([], 'No compounds found.');
40
+ }
41
+ return;
42
+ }
43
+ const rows = compounds.map(c => [
44
+ str(c.name, 30),
45
+ str(c.smiles || c.canonical_smiles, 40),
46
+ str(c.category, 15),
47
+ str(c.concentration || c.content),
48
+ ]);
49
+ output(compounds, formatTable(['Compound', 'SMILES', 'Category', 'Content'], rows));
50
+ if (opts.save)
51
+ process.stderr.write(`Saved as dataset: ${opts.save}\n`);
52
+ });
53
+ // ── m3t foodb info <compound> ─────────────────────────────────
54
+ foodb
55
+ .command('info')
56
+ .argument('<compound>', 'Compound name (e.g., "caffeine", "quercetin")')
57
+ .description('Get detailed compound information')
58
+ .action(async (compound) => {
59
+ const agents = createAgentsClient();
60
+ const data = await agents.callMcpTool('foodb', 'get_compound_info', {
61
+ compound_name: compound,
62
+ });
63
+ if (typeof data.result === 'string') {
64
+ output(data, data.result);
65
+ }
66
+ else {
67
+ output(data, JSON.stringify(data, null, 2));
68
+ }
69
+ });
70
+ // ── m3t foodb export <food> ───────────────────────────────────
71
+ foodb
72
+ .command('export')
73
+ .argument('<food>', 'Food name to export compounds from')
74
+ .option('--limit <n>', 'Max compounds to export', '50')
75
+ .option('--name <name>', 'Dataset name')
76
+ .description('Export food compounds to project as dataset')
77
+ .action(async (food, opts) => {
78
+ const project = requireProject();
79
+ const agents = createAgentsClient();
80
+ const params = {
81
+ food_name: food,
82
+ limit: parseInt(opts.limit),
83
+ project_id: project.id,
84
+ };
85
+ if (opts.name)
86
+ params.dataset_name = opts.name;
87
+ process.stderr.write(`Exporting ${food} compounds to project...\n`);
88
+ const data = await agents.callMcpTool('foodb', 'export_compounds_to_project', params);
89
+ if (typeof data.result === 'string') {
90
+ output(data, data.result);
91
+ }
92
+ else {
93
+ output(data, JSON.stringify(data, null, 2));
94
+ }
95
+ });
96
+ // ── m3t foodb random ──────────────────────────────────────────
97
+ foodb
98
+ .command('random')
99
+ .option('--count <n>', 'Number of compounds', '20')
100
+ .option('--group <g>', 'Food group filter (e.g., Vegetables, Fruits, "Herbs and Spices")')
101
+ .description('Get random food compounds with SMILES')
102
+ .action(async (opts) => {
103
+ const agents = createAgentsClient();
104
+ const params = {
105
+ count: parseInt(opts.count),
106
+ with_smiles_only: true,
107
+ };
108
+ if (opts.group)
109
+ params.food_group = opts.group;
110
+ const data = await agents.callMcpTool('foodb', 'get_random_compounds', params);
111
+ const compounds = extractArray(data, ['compounds', 'results', 'data']);
112
+ if (compounds.length === 0) {
113
+ if (typeof data.result === 'string') {
114
+ output(data, data.result);
115
+ }
116
+ else {
117
+ output([], 'No compounds found.');
118
+ }
119
+ return;
120
+ }
121
+ const rows = compounds.map(c => [
122
+ str(c.name, 30),
123
+ str(c.smiles || c.canonical_smiles, 45),
124
+ str(c.food_name || c.food, 20),
125
+ ]);
126
+ output(compounds, formatTable(['Compound', 'SMILES', 'Source Food'], rows));
127
+ });
128
+ }
129
+ // ── Helpers ───────────────────────────────────────────────────────
130
+ function str(val, maxLen) {
131
+ const s = val != null ? String(val) : '-';
132
+ return maxLen ? s.substring(0, maxLen) : s;
133
+ }
134
+ function extractArray(data, keys) {
135
+ if (Array.isArray(data))
136
+ return data;
137
+ for (const key of keys) {
138
+ if (Array.isArray(data[key]))
139
+ return data[key];
140
+ }
141
+ if (typeof data.result === 'string') {
142
+ try {
143
+ return extractArray(JSON.parse(data.result), keys);
144
+ }
145
+ catch { /* not JSON */ }
146
+ }
147
+ if (typeof data.result === 'object' && data.result !== null) {
148
+ return extractArray(data.result, keys);
149
+ }
150
+ return [];
151
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerMdCommands(program: Command): void;
@@ -0,0 +1,107 @@
1
+ import fs from 'node:fs';
2
+ import { output } from '../output.js';
3
+ import { createAgentsClient } from '../cli.js';
4
+ import { requireApiKey, requireProject } from '../config.js';
5
+ export function registerMdCommands(program) {
6
+ const md = program
7
+ .command('md')
8
+ .description('Run molecular dynamics simulations (GPU-accelerated, OpenMM)')
9
+ .hook('preAction', () => { requireApiKey(); });
10
+ // ── m3t md run ────────────────────────────────────────────────
11
+ md
12
+ .command('run')
13
+ .option('--protein <pdb>', 'Protein PDB ID or path to .pdb file')
14
+ .option('--ligand-sdf <sdf>', 'Ligand SDF content or path to .sdf file')
15
+ .option('--ligand-smiles <smiles>', 'Ligand SMILES (fallback if no SDF)')
16
+ .option('--diffdock-job <id>', 'DiffDock job ID (auto-fetches protein + ligand)')
17
+ .option('--mode <mode>', 'Simulation mode: quick (10ns ~1hr) or standard (50ns ~5hrs)', 'quick')
18
+ .option('--ns <n>', 'Override simulation duration in nanoseconds (1-100)')
19
+ .option('--temperature <K>', 'Temperature in Kelvin', '300')
20
+ .description('Submit a molecular dynamics simulation job')
21
+ .action(async (opts) => {
22
+ const project = requireProject();
23
+ const agents = createAgentsClient();
24
+ const params = {
25
+ mode: opts.mode,
26
+ temperature_k: parseFloat(opts.temperature),
27
+ project_id: project.id,
28
+ };
29
+ // Input source: DiffDock job OR protein + ligand
30
+ if (opts.diffdockJob) {
31
+ params.diffdock_job_id = opts.diffdockJob;
32
+ }
33
+ else {
34
+ if (!opts.protein) {
35
+ process.stderr.write('Error: Provide --protein <pdb> or --diffdock-job <id>\n');
36
+ process.exit(1);
37
+ }
38
+ // Resolve protein: file path or PDB ID
39
+ if (fs.existsSync(opts.protein)) {
40
+ params.protein_pdb = fs.readFileSync(opts.protein, 'utf-8');
41
+ }
42
+ else {
43
+ params.protein_pdb = opts.protein;
44
+ }
45
+ // Resolve ligand
46
+ if (opts.ligandSdf) {
47
+ if (fs.existsSync(opts.ligandSdf)) {
48
+ params.ligand_sdf = fs.readFileSync(opts.ligandSdf, 'utf-8');
49
+ }
50
+ else {
51
+ params.ligand_sdf = opts.ligandSdf;
52
+ }
53
+ }
54
+ else if (opts.ligandSmiles) {
55
+ params.ligand_smiles = opts.ligandSmiles;
56
+ }
57
+ else {
58
+ process.stderr.write('Error: Provide --ligand-sdf or --ligand-smiles\n');
59
+ process.exit(1);
60
+ }
61
+ }
62
+ if (opts.ns)
63
+ params.simulation_ns = parseFloat(opts.ns);
64
+ process.stderr.write(`Submitting MD simulation (${opts.mode} mode)...\n`);
65
+ const data = await agents.callMcpTool('md', 'run_md_simulation', params);
66
+ // Extract job ID from response
67
+ const jobId = extractJobId(data);
68
+ if (jobId) {
69
+ output({ job_id: jobId, mode: opts.mode }, `MD simulation submitted\nJob ID: ${jobId.substring(0, 8)}\nMode: ${opts.mode}\nCheck status: m3t job ${jobId.substring(0, 8)}`);
70
+ }
71
+ else if (typeof data.result === 'string') {
72
+ output(data, data.result);
73
+ }
74
+ else {
75
+ output(data, JSON.stringify(data, null, 2));
76
+ }
77
+ });
78
+ // ── m3t md results <job_id> ───────────────────────────────────
79
+ md
80
+ .command('results')
81
+ .argument('<job_id>', 'MD simulation job ID')
82
+ .description('Get results from a completed MD simulation')
83
+ .action(async (jobId) => {
84
+ const agents = createAgentsClient();
85
+ const data = await agents.callMcpTool('md', 'get_md_results', { job_id: jobId });
86
+ if (typeof data.result === 'string') {
87
+ output(data, data.result);
88
+ }
89
+ else {
90
+ output(data, JSON.stringify(data, null, 2));
91
+ }
92
+ });
93
+ }
94
+ function extractJobId(data) {
95
+ if (typeof data.job_id === 'string')
96
+ return data.job_id;
97
+ if (typeof data.id === 'string')
98
+ return data.id;
99
+ if (typeof data.result === 'object' && data.result !== null) {
100
+ const r = data.result;
101
+ if (typeof r.job_id === 'string')
102
+ return r.job_id;
103
+ if (typeof r.id === 'string')
104
+ return r.id;
105
+ }
106
+ return null;
107
+ }
@@ -1,9 +1,10 @@
1
- import { createClient, getConsoleUrl } from '../cli.js';
1
+ import fs from 'node:fs';
2
+ import { createAgentsClient, createClient, getConsoleUrl } from '../cli.js';
2
3
  import { requireProject } from '../config.js';
3
4
  import { output } from '../output.js';
4
5
  import { jobUrl, maybeOpenBrowser } from '../url.js';
5
6
  export function registerPredictCommands(program) {
6
- const predict = program.command('predict').description('Predict 3D protein structure from sequence');
7
+ const predict = program.command('predict').description('Predict 3D structure from sequence (single protein or biomolecular complex)');
7
8
  // m3t predict esmfold
8
9
  predict.command('esmfold')
9
10
  .argument('<sequence>', 'Amino acid sequence (single letter codes, max 1024aa)')
@@ -16,10 +17,24 @@ export function registerPredictCommands(program) {
16
17
  predict.command('alphafold2')
17
18
  .argument('<sequence>', 'Amino acid sequence (single letter codes, max 2048aa)')
18
19
  .option('--name <name>', 'Name for the prediction')
19
- .description('High-accuracy prediction with AlphaFold2 (~5 minutes, scale-to-zero VM)')
20
+ .description('High-accuracy prediction with AlphaFold2 (~15-20 min, varies with length; scale-to-zero VM)')
20
21
  .action(async (sequence, opts) => {
21
22
  await runPredict(sequence, 'alphafold2', opts.name);
22
23
  });
24
+ // m3t predict boltz2
25
+ predict.command('boltz2')
26
+ .description('Predict biomolecular complex (protein + DNA/RNA + ligand) with binding affinity (Boltz-2 / NVIDIA NIM, ~1-5 min)')
27
+ .option('--protein <seq>', 'Protein sequence (or @path to file). Repeatable for multi-chain complexes.', collectArg, [])
28
+ .option('--dna <seq>', 'DNA sequence (or @path to file). Repeatable.', collectArg, [])
29
+ .option('--rna <seq>', 'RNA sequence (or @path to file). Repeatable.', collectArg, [])
30
+ .option('--ligand <smiles_or_ccd>', 'Ligand as SMILES or CCD code (e.g. ATP). Repeatable.', collectArg, [])
31
+ .option('--samples <n>', 'Number of structure samples (1-25, default: 1)', parseInt, 1)
32
+ .option('--recycling <n>', 'Recycling steps (1-10, default: 3)', parseInt, 3)
33
+ .option('--sampling <n>', 'Diffusion sampling steps (10-1000, default: 50)', parseInt, 50)
34
+ .option('--name <name>', 'Custom job title')
35
+ .action(async (opts) => {
36
+ await runBoltz2(opts);
37
+ });
23
38
  }
24
39
  async function runPredict(sequence, method, name) {
25
40
  const project = requireProject();
@@ -33,7 +48,123 @@ async function runPredict(sequence, method, name) {
33
48
  });
34
49
  const url = jobUrl(consoleUrl, project.id, result.job_id);
35
50
  maybeOpenBrowser(url);
36
- const timeEst = method === 'alphafold2' ? '~5 minutes' : '~10 seconds';
51
+ const timeEst = method === 'alphafold2' ? '~15-20 minutes (varies with length)' : '~10 seconds';
37
52
  const data = { job_id: result.job_id, method, sequence_length: sequence.length, url };
38
53
  output(data, `${method} prediction created\nJob ID: ${result.job_id.substring(0, 8)}\nLength: ${sequence.length} residues\nEstimated: ${timeEst}`);
39
54
  }
55
+ async function runBoltz2(opts) {
56
+ const polymers = [];
57
+ for (const seq of opts.protein)
58
+ polymers.push({ molecule_type: 'protein', sequence: resolveSequence(seq) });
59
+ for (const seq of opts.dna)
60
+ polymers.push({ molecule_type: 'dna', sequence: resolveSequence(seq) });
61
+ for (const seq of opts.rna)
62
+ polymers.push({ molecule_type: 'rna', sequence: resolveSequence(seq) });
63
+ if (polymers.length === 0) {
64
+ process.stderr.write('Error: At least one --protein, --dna, or --rna is required.\n');
65
+ process.exit(1);
66
+ }
67
+ const ligands = opts.ligand.map(parseLigand);
68
+ const project = requireProject();
69
+ const client = createClient();
70
+ const agents = createAgentsClient();
71
+ const consoleUrl = getConsoleUrl();
72
+ const summary = describeComplex(polymers, ligands);
73
+ process.stderr.write(`Boltz-2: ${summary}\n`);
74
+ process.stderr.write('Running prediction (NVIDIA NIM, ~1-5 min)...');
75
+ const startTime = Date.now();
76
+ const mcpData = await agents.callMcpTool('bionemo', 'predict_structure_boltz2', {
77
+ polymers,
78
+ ligands: ligands.length > 0 ? ligands : undefined,
79
+ diffusion_samples: opts.samples,
80
+ recycling_steps: opts.recycling,
81
+ sampling_steps: opts.sampling,
82
+ });
83
+ const result = mcpData;
84
+ if (result.error || !result.success) {
85
+ const msg = result.error ?? result.message ?? 'Unknown error';
86
+ process.stderr.write(`\nFailed: ${msg}\n`);
87
+ process.exit(1);
88
+ }
89
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
90
+ process.stderr.write(` done (${elapsed}s).\n`);
91
+ const title = opts.name ?? `Boltz-2: ${result.complex || summary}`;
92
+ const job = await client.createBoltz2Job({
93
+ project_id: project.id,
94
+ title,
95
+ description: `Boltz-2 biomolecular complex prediction. ${result.quality ?? ''}`.trim(),
96
+ result_data: {
97
+ complex: result.complex,
98
+ structure_data: result.structure_data,
99
+ output_format: result.output_format,
100
+ confidence_scores: result.confidence_scores,
101
+ best_confidence: result.best_confidence,
102
+ affinities: result.affinities,
103
+ metrics: result.metrics,
104
+ quality: result.quality,
105
+ num_structures_returned: result.num_structures_returned,
106
+ diffusion_samples: result.diffusion_samples,
107
+ model: 'boltz2',
108
+ },
109
+ });
110
+ const url = jobUrl(consoleUrl, project.id, job.job_id);
111
+ maybeOpenBrowser(url);
112
+ const lines = [
113
+ `Boltz-2 prediction complete in ${elapsed}s`,
114
+ `Job ID: ${job.job_id.substring(0, 8)}`,
115
+ `Complex: ${result.complex}`,
116
+ `Quality: ${result.quality ?? 'unknown'}`,
117
+ ];
118
+ if (result.best_confidence !== null && result.best_confidence !== undefined) {
119
+ lines.push(`Best confidence: ${result.best_confidence.toFixed(3)}`);
120
+ }
121
+ if (result.affinities && Object.keys(result.affinities).length > 0) {
122
+ lines.push(`Affinities: ${JSON.stringify(result.affinities)}`);
123
+ }
124
+ lines.push(`View: ${url}`);
125
+ output({
126
+ job_id: job.job_id,
127
+ elapsed_seconds: elapsed,
128
+ complex: result.complex,
129
+ quality: result.quality,
130
+ best_confidence: result.best_confidence,
131
+ affinities: result.affinities,
132
+ url,
133
+ }, lines.join('\n'));
134
+ }
135
+ function collectArg(value, prev) {
136
+ return [...prev, value];
137
+ }
138
+ function resolveSequence(input) {
139
+ // @path → read file (FASTA-aware: strip header/whitespace)
140
+ if (input.startsWith('@')) {
141
+ const path = input.slice(1);
142
+ if (!fs.existsSync(path)) {
143
+ throw new Error(`Sequence file not found: ${path}`);
144
+ }
145
+ const raw = fs.readFileSync(path, 'utf-8');
146
+ return raw
147
+ .split('\n')
148
+ .filter(line => !line.startsWith('>'))
149
+ .join('')
150
+ .replace(/\s+/g, '')
151
+ .toUpperCase();
152
+ }
153
+ return input.replace(/\s+/g, '').toUpperCase();
154
+ }
155
+ function parseLigand(input) {
156
+ // CCD codes are 1-5 uppercase letters/digits with no SMILES-specific chars.
157
+ // SMILES typically contain at least one of: ()=[]#+/\.@-/lowercase-atom-symbol.
158
+ const looksLikeCcd = /^[A-Z0-9]{1,5}$/.test(input);
159
+ const looksLikeSmiles = /[()\[\]=#@\\/.+-]/.test(input) || /[a-z]/.test(input);
160
+ if (looksLikeCcd && !looksLikeSmiles)
161
+ return { ccd_code: input };
162
+ return { smiles: input };
163
+ }
164
+ function describeComplex(polymers, ligands) {
165
+ const parts = polymers.map(p => `${p.molecule_type}(${p.sequence.length})`);
166
+ if (ligands.length > 0) {
167
+ parts.push(`ligand×${ligands.length}`);
168
+ }
169
+ return parts.join(' + ');
170
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerPricingCommands(program: Command): void;
@@ -0,0 +1,36 @@
1
+ import { createClient } from '../cli.js';
2
+ import { output, formatTable, error } from '../output.js';
3
+ export function registerPricingCommands(program) {
4
+ program
5
+ .command('pricing')
6
+ .description('Show what each operation costs in credits')
7
+ .action(async () => {
8
+ const client = createClient();
9
+ let p;
10
+ try {
11
+ p = await client.getPricing();
12
+ }
13
+ catch (e) {
14
+ error(e.message);
15
+ process.exit(1);
16
+ return;
17
+ }
18
+ const jobRows = p.jobs.map(j => [j.label, j.credits.toLocaleString(), j.unit]);
19
+ const mdRows = p.md_simulation.map(m => [m.label, m.credits.toLocaleString(), m.unit]);
20
+ const lines = [
21
+ 'What things cost (credits)',
22
+ '',
23
+ ` ${p.note}`,
24
+ '',
25
+ formatTable(['Service', 'Credits', 'Metering'], jobRows),
26
+ '',
27
+ formatTable(['Molecular dynamics', 'Credits', 'Duration'], mdRows),
28
+ '',
29
+ ` ${p.sandbox.label}: ${p.sandbox.credits.toLocaleString()} cr (${p.sandbox.unit})`,
30
+ ` ${p.ai.label}: ${p.ai.range} (${p.ai.unit})`,
31
+ '',
32
+ ' Runtime-metered jobs reserve an estimate up front and reconcile to actual usage.',
33
+ ];
34
+ output(p, lines.join('\n'));
35
+ });
36
+ }
@@ -20,6 +20,53 @@ export function registerProjectCommands(program) {
20
20
  ]);
21
21
  output(data, formatTable(['ID', 'Name', ''], rows));
22
22
  });
23
+ program
24
+ .command('project')
25
+ .argument('<id>', 'Project ID (full or short 8-char)')
26
+ .description('Show details for a specific project')
27
+ .action(async (id) => {
28
+ const client = createClient();
29
+ // Resolve short ID via list
30
+ let fullId = id;
31
+ if (!id.includes('-')) {
32
+ const projects = await client.listProjects();
33
+ const match = projects.find(p => p.id.startsWith(id));
34
+ if (!match) {
35
+ process.stderr.write(`Error: No project found matching "${id}"\n`);
36
+ process.exit(1);
37
+ }
38
+ fullId = match.id;
39
+ }
40
+ const project = await client.getProject(fullId);
41
+ const consoleUrl = getConsoleUrl();
42
+ const url = `${consoleUrl}/?project=${project.id.substring(0, 8)}`;
43
+ const lines = [
44
+ `Project: ${project.name}`,
45
+ `ID: ${project.id}`,
46
+ ];
47
+ if (project.description)
48
+ lines.push(`Summary: ${project.description}`);
49
+ if (project.owner_name || project.owner_email)
50
+ lines.push(`Owner: ${project.owner_name || ''} <${project.owner_email || ''}>`);
51
+ if (project.user_role)
52
+ lines.push(`Your role: ${project.user_role}${project.is_shared ? ' (shared)' : ''}`);
53
+ if (project.session_count !== undefined)
54
+ lines.push(`Sessions: ${project.session_count}`);
55
+ if (project.member_count !== undefined)
56
+ lines.push(`Members: ${project.member_count}`);
57
+ if (project.created_at)
58
+ lines.push(`Created: ${project.created_at}`);
59
+ if (project.last_activity_at)
60
+ lines.push(`Last used: ${project.last_activity_at}`);
61
+ lines.push(`View: ${url}`);
62
+ if (project.context) {
63
+ lines.push('');
64
+ lines.push('Project context (knowledge base):');
65
+ const ctx = project.context.length > 800 ? project.context.substring(0, 800) + '\n…(truncated)' : project.context;
66
+ lines.push(ctx);
67
+ }
68
+ output(project, lines.join('\n'));
69
+ });
23
70
  program
24
71
  .command('use')
25
72
  .argument('<id>', 'Project ID (full or short 8-char)')
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerSessionCommands(program: Command): void;
@@ -0,0 +1,98 @@
1
+ import { createClient, getConsoleUrl } from '../cli.js';
2
+ import { requireProject } from '../config.js';
3
+ import { output, formatTable } from '../output.js';
4
+ async function resolveSessionId(client, projectId, input) {
5
+ if (input.includes('-'))
6
+ return input;
7
+ const sessions = await client.listSessions(projectId, 200);
8
+ const match = sessions.find(s => s.id.startsWith(input));
9
+ if (!match) {
10
+ process.stderr.write(`Error: No session found matching "${input}" in the 200 most recent sessions.\nTry using the full UUID instead.\n`);
11
+ process.exit(1);
12
+ }
13
+ return match.id;
14
+ }
15
+ export function registerSessionCommands(program) {
16
+ program
17
+ .command('sessions')
18
+ .description('List past chat sessions in the active project')
19
+ .option('--limit <n>', 'Max sessions to return', '20')
20
+ .option('--search <query>', 'Case-insensitive substring filter on title')
21
+ .option('--with-notes', 'Only sessions that have context notes')
22
+ .action(async (opts) => {
23
+ const project = requireProject();
24
+ const client = createClient();
25
+ const limit = parseInt(opts.limit);
26
+ let sessions = await client.listSessions(project.id, limit);
27
+ if (opts.search) {
28
+ const needle = String(opts.search).toLowerCase();
29
+ sessions = sessions.filter(s => (s.title || '').toLowerCase().includes(needle));
30
+ }
31
+ if (opts.withNotes) {
32
+ sessions = sessions.filter(s => s.has_notes);
33
+ }
34
+ const data = sessions.map(s => ({
35
+ id: s.id,
36
+ title: s.title,
37
+ message_count: s.message_count,
38
+ has_notes: s.has_notes,
39
+ updated_at: s.updated_at,
40
+ }));
41
+ const rows = sessions.map(s => [
42
+ s.id.substring(0, 8),
43
+ (s.title || 'Untitled').substring(0, 50),
44
+ String(s.message_count ?? ''),
45
+ s.has_notes ? '📝' : '',
46
+ (s.updated_at || s.created_at).substring(0, 19).replace('T', ' '),
47
+ ]);
48
+ output(data, formatTable(['ID', 'Title', 'Msgs', 'Notes', 'Updated'], rows));
49
+ });
50
+ program
51
+ .command('session')
52
+ .argument('<id>', 'Session ID (full or short 8-char)')
53
+ .option('--full', 'Include full message contents (long output)')
54
+ .description('Show details for a specific chat session')
55
+ .action(async (id, opts) => {
56
+ const project = requireProject();
57
+ const client = createClient();
58
+ const fullId = await resolveSessionId(client, project.id, id);
59
+ const session = await client.getSession(fullId);
60
+ const consoleUrl = getConsoleUrl();
61
+ const url = `${consoleUrl}/?project=${project.id.substring(0, 8)}&session=${session.id.substring(0, 8)}`;
62
+ const lines = [
63
+ `Session: ${session.title || 'Untitled'}`,
64
+ `ID: ${session.id}`,
65
+ `Project: ${session.project_name || project.name}`,
66
+ ];
67
+ if (session.user_name)
68
+ lines.push(`User: ${session.user_name}`);
69
+ if (session.model)
70
+ lines.push(`Model: ${session.model}`);
71
+ if (session.created_at)
72
+ lines.push(`Created: ${session.created_at}`);
73
+ if (session.updated_at)
74
+ lines.push(`Updated: ${session.updated_at}`);
75
+ const msgCount = session.messages?.length ?? session.message_count ?? 0;
76
+ lines.push(`Messages: ${msgCount}`);
77
+ if (session.context_notes) {
78
+ lines.push('');
79
+ lines.push('Context notes:');
80
+ const notes = session.context_notes.length > 600
81
+ ? session.context_notes.substring(0, 600) + '\n…(truncated)'
82
+ : session.context_notes;
83
+ lines.push(notes);
84
+ }
85
+ lines.push(`View: ${url}`);
86
+ if (opts.full && session.messages?.length) {
87
+ lines.push('');
88
+ lines.push('— Conversation —');
89
+ for (const m of session.messages) {
90
+ const ts = (m.created_at || '').substring(11, 19);
91
+ lines.push('');
92
+ lines.push(`[${m.order}] ${m.role.toUpperCase()} ${ts}`);
93
+ lines.push(m.content);
94
+ }
95
+ }
96
+ output(session, lines.join('\n'));
97
+ });
98
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerZincCommands(program: Command): void;