voyageai-cli 1.30.0 → 1.30.2

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 (82) hide show
  1. package/README.md +4 -4
  2. package/package.json +1 -1
  3. package/src/cli.js +8 -0
  4. package/src/commands/about.js +3 -3
  5. package/src/commands/chat.js +32 -11
  6. package/src/commands/code-search.js +751 -0
  7. package/src/commands/doctor.js +1 -1
  8. package/src/commands/export.js +124 -0
  9. package/src/commands/import.js +195 -0
  10. package/src/commands/index-workspace.js +243 -0
  11. package/src/commands/mcp-server.js +113 -3
  12. package/src/commands/playground.js +120 -4
  13. package/src/commands/quickstart.js +4 -4
  14. package/src/commands/workflow.js +132 -65
  15. package/src/lib/catalog.js +4 -2
  16. package/src/lib/code-search.js +315 -0
  17. package/src/lib/codegen.js +1 -1
  18. package/src/lib/explanations.js +3 -3
  19. package/src/lib/export/contexts/benchmark-export.js +27 -0
  20. package/src/lib/export/contexts/chat-export.js +41 -0
  21. package/src/lib/export/contexts/explore-export.js +22 -0
  22. package/src/lib/export/contexts/search-export.js +54 -0
  23. package/src/lib/export/contexts/workflow-export.js +80 -0
  24. package/src/lib/export/formats/clipboard-export.js +29 -0
  25. package/src/lib/export/formats/csv-export.js +45 -0
  26. package/src/lib/export/formats/json-export.js +50 -0
  27. package/src/lib/export/formats/markdown-export.js +189 -0
  28. package/src/lib/export/formats/mermaid-export.js +274 -0
  29. package/src/lib/export/formats/pdf-export.js +117 -0
  30. package/src/lib/export/formats/png-export.js +96 -0
  31. package/src/lib/export/formats/svg-export.js +116 -0
  32. package/src/lib/export/index.js +175 -0
  33. package/src/lib/github.js +226 -0
  34. package/src/lib/template-engine.js +154 -20
  35. package/src/lib/workflow-builder.js +753 -0
  36. package/src/lib/workflow-formatters.js +454 -0
  37. package/src/lib/workflow-input-cache.js +111 -0
  38. package/src/lib/workflow-scaffold.js +1 -1
  39. package/src/lib/workflow.js +297 -28
  40. package/src/mcp/install.js +280 -7
  41. package/src/mcp/schemas/index.js +170 -0
  42. package/src/mcp/server.js +19 -4
  43. package/src/mcp/tools/authoring.js +662 -0
  44. package/src/mcp/tools/code-search.js +620 -0
  45. package/src/mcp/tools/ingest.js +2 -5
  46. package/src/mcp/tools/retrieval.js +2 -15
  47. package/src/mcp/tools/workspace.js +452 -0
  48. package/src/mcp/utils.js +20 -0
  49. package/src/playground/announcements.md +52 -5
  50. package/src/playground/help/workflow-nodes.js +127 -2
  51. package/src/playground/index.html +17109 -12438
  52. package/src/playground/vendor/mermaid.min.js +2811 -0
  53. package/src/workflows/code-review.json +110 -0
  54. package/src/workflows/cost-analysis.json +5 -0
  55. package/src/workflows/rag-chat.json +165 -0
  56. package/src/workflows/tests/code-review.fresh-index.test.json +83 -0
  57. package/src/workflows/tests/code-review.happy-path.test.json +121 -0
  58. package/src/workflows/tests/code-review.no-question.test.json +70 -0
  59. package/src/workflows/tests/consistency-check.happy-path.test.json +28 -0
  60. package/src/workflows/tests/consistency-check.missing-source.test.json +26 -0
  61. package/src/workflows/tests/cost-analysis.happy-path.test.json +28 -0
  62. package/src/workflows/tests/enrich-and-ingest.happy-path.test.json +38 -0
  63. package/src/workflows/tests/enrich-and-ingest.notify-fails.test.json +38 -0
  64. package/src/workflows/tests/intelligent-ingest.all-filtered.test.json +26 -0
  65. package/src/workflows/tests/intelligent-ingest.happy-path.test.json +28 -0
  66. package/src/workflows/tests/kb-health-report.custom-queries.test.json +24 -0
  67. package/src/workflows/tests/kb-health-report.happy-path.test.json +26 -0
  68. package/src/workflows/tests/multi-collection-search.happy-path.test.json +40 -0
  69. package/src/workflows/tests/multi-collection-search.one-empty.test.json +28 -0
  70. package/src/workflows/tests/rag-chat.happy-path.test.json +26 -0
  71. package/src/workflows/tests/rag-chat.no-relevant-results.test.json +25 -0
  72. package/src/workflows/tests/research-and-summarize.happy-path.test.json +33 -0
  73. package/src/workflows/tests/research-and-summarize.no-results.test.json +29 -0
  74. package/src/workflows/tests/search-with-fallback.empty-both.test.json +24 -0
  75. package/src/workflows/tests/search-with-fallback.fallback-branch.test.json +24 -0
  76. package/src/workflows/tests/search-with-fallback.happy-path.test.json +27 -0
  77. package/src/workflows/tests/smart-ingest.duplicate-detected.test.json +34 -0
  78. package/src/workflows/tests/smart-ingest.happy-path.test.json +31 -0
  79. package/src/playground/assets/announcements/appstore.jpg +0 -0
  80. package/src/playground/assets/announcements/circuits.jpg +0 -0
  81. package/src/playground/assets/announcements/csvingest.jpg +0 -0
  82. package/src/playground/assets/announcements/green-wave.jpg +0 -0
@@ -0,0 +1,315 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+ const { loadProject } = require('./project');
6
+
7
+ const DEFAULT_CODE_MODEL = 'voyage-code-3';
8
+ const DEFAULT_DB = 'vai_code_search';
9
+
10
+ const CODE_EXTENSIONS = [
11
+ '.js', '.ts', '.jsx', '.tsx', '.py', '.go', '.rs', '.java', '.c', '.cpp',
12
+ '.h', '.hpp', '.cs', '.rb', '.php', '.swift', '.kt', '.scala', '.ex',
13
+ '.exs', '.clj', '.hs', '.ml', '.fs', '.vue', '.svelte', '.sh', '.bash',
14
+ ];
15
+
16
+ const DOC_EXTENSIONS = ['.md', '.rst', '.txt', '.adoc', '.rdoc'];
17
+
18
+ const DEFAULT_IGNORE = [
19
+ 'node_modules', '.git', '.svn', '.hg', 'dist', 'build', 'out', 'target',
20
+ '__pycache__', '.cache', '.next', '.nuxt', 'coverage', '.nyc_output',
21
+ 'vendor', 'venv', '.venv', 'env', '.idea', '.vscode',
22
+ 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'Cargo.lock',
23
+ '*.min.js', '*.min.css', '*.map', '*.chunk.js',
24
+ ];
25
+
26
+ /**
27
+ * Language-aware function/class boundary patterns.
28
+ */
29
+ const BOUNDARY_PATTERNS = {
30
+ js: /^(?:(?:export\s+)?(?:async\s+)?function\s+\w+|(?:export\s+)?(?:const|let|var)\s+\w+\s*=\s*(?:async\s+)?(?:function|\()|(?:export\s+)?class\s+\w+|module\.exports)/m,
31
+ ts: /^(?:(?:export\s+)?(?:async\s+)?function\s+\w+|(?:export\s+)?(?:const|let)\s+\w+\s*[=:]|(?:export\s+)?(?:class|interface|type|enum)\s+\w+)/m,
32
+ py: /^(?:def\s+|async\s+def\s+|class\s+)/m,
33
+ go: /^(?:func\s+|type\s+\w+\s+(?:struct|interface))/m,
34
+ rs: /^(?:(?:pub\s+)?fn\s+|(?:pub\s+)?(?:struct|enum|trait|impl)\s+)/m,
35
+ java: /^(?:\s*(?:public|private|protected)\s+(?:static\s+)?(?:class|interface|void|\w+)\s+\w+)/m,
36
+ rb: /^(?:def\s+|class\s+|module\s+)/m,
37
+ php: /^(?:\s*(?:public|private|protected)?\s*(?:static\s+)?function\s+|class\s+)/m,
38
+ };
39
+
40
+ /**
41
+ * Get the boundary pattern for a file extension.
42
+ * @param {string} ext
43
+ * @returns {RegExp|null}
44
+ */
45
+ function getBoundaryPattern(ext) {
46
+ const lang = ext.replace('.', '');
47
+ const map = {
48
+ js: 'js', jsx: 'js', mjs: 'js', cjs: 'js',
49
+ ts: 'ts', tsx: 'ts', mts: 'ts',
50
+ py: 'py',
51
+ go: 'go',
52
+ rs: 'rs',
53
+ java: 'java', kt: 'java', scala: 'java',
54
+ rb: 'rb',
55
+ php: 'php',
56
+ };
57
+ const key = map[lang];
58
+ return key ? BOUNDARY_PATTERNS[key] : null;
59
+ }
60
+
61
+ /**
62
+ * Smart chunk code: try splitting by function/class boundaries first,
63
+ * fall back to recursive character-based chunking.
64
+ * @param {string} content
65
+ * @param {string} filePath
66
+ * @param {object} opts
67
+ * @returns {Array<{text: string, startLine: number, endLine: number, type: string}>}
68
+ */
69
+ function smartChunkCode(content, filePath, opts = {}) {
70
+ const { chunk } = require('./chunker');
71
+ const ext = path.extname(filePath).toLowerCase();
72
+ const pattern = getBoundaryPattern(ext);
73
+ const chunkSize = opts.chunkSize || 512;
74
+ const chunkOverlap = opts.chunkOverlap || 50;
75
+ const lines = content.split('\n');
76
+
77
+ // Try boundary-based splitting
78
+ if (pattern) {
79
+ const boundaries = [];
80
+ for (let i = 0; i < lines.length; i++) {
81
+ if (pattern.test(lines[i])) {
82
+ boundaries.push(i);
83
+ }
84
+ }
85
+
86
+ if (boundaries.length > 1) {
87
+ const chunks = [];
88
+ for (let i = 0; i < boundaries.length; i++) {
89
+ const start = boundaries[i];
90
+ const end = i + 1 < boundaries.length ? boundaries[i + 1] : lines.length;
91
+ const text = lines.slice(start, end).join('\n').trim();
92
+ if (text.length >= 20) {
93
+ if (text.length > chunkSize * 2) {
94
+ const subChunks = chunk(text, { strategy: 'recursive', size: chunkSize, overlap: chunkOverlap });
95
+ let lineOffset = start;
96
+ for (const sc of subChunks) {
97
+ const scLines = sc.split('\n').length;
98
+ chunks.push({ text: sc, startLine: lineOffset + 1, endLine: lineOffset + scLines, type: 'boundary' });
99
+ lineOffset += scLines;
100
+ }
101
+ } else {
102
+ chunks.push({ text, startLine: start + 1, endLine: end, type: 'boundary' });
103
+ }
104
+ }
105
+ }
106
+ if (boundaries[0] > 0) {
107
+ const preamble = lines.slice(0, boundaries[0]).join('\n').trim();
108
+ if (preamble.length >= 20) {
109
+ chunks.unshift({ text: preamble, startLine: 1, endLine: boundaries[0], type: 'preamble' });
110
+ }
111
+ }
112
+ if (chunks.length > 0) return chunks;
113
+ }
114
+ }
115
+
116
+ // Fallback: recursive chunking with line number tracking
117
+ const { chunk: chunkFn } = require('./chunker');
118
+ const textChunks = chunkFn(content, { strategy: 'recursive', size: chunkSize, overlap: chunkOverlap });
119
+ const result = [];
120
+ let searchFrom = 0;
121
+ for (const tc of textChunks) {
122
+ const firstLine = tc.split('\n')[0];
123
+ let startLine = searchFrom;
124
+ for (let i = searchFrom; i < lines.length; i++) {
125
+ if (lines[i].includes(firstLine.trim().slice(0, 40))) {
126
+ startLine = i;
127
+ break;
128
+ }
129
+ }
130
+ const chunkLines = tc.split('\n').length;
131
+ result.push({ text: tc, startLine: startLine + 1, endLine: startLine + chunkLines, type: 'character' });
132
+ searchFrom = startLine + 1;
133
+ }
134
+ return result;
135
+ }
136
+
137
+ /**
138
+ * Extract symbol names from code.
139
+ * @param {string} content
140
+ * @param {string} filePath
141
+ * @returns {string[]}
142
+ */
143
+ function extractSymbols(content, filePath) {
144
+ const ext = path.extname(filePath).toLowerCase().slice(1);
145
+ const patterns = {
146
+ js: [/(?:function\s+|const\s+|let\s+|var\s+)(\w+)\s*(?:=\s*(?:async\s+)?(?:function|\(|=>)|\()/g, /class\s+(\w+)/g],
147
+ ts: [/(?:function\s+|const\s+|let\s+)(\w+)\s*(?:=\s*(?:async\s+)?(?:function|\(|=>)|[<(])/g, /(?:class|interface|type)\s+(\w+)/g],
148
+ py: [/(?:def|async def)\s+(\w+)\s*\(/g, /class\s+(\w+)/g],
149
+ go: [/func\s+(?:\([^)]+\)\s+)?(\w+)\s*\(/g, /type\s+(\w+)\s+struct/g],
150
+ rs: [/fn\s+(\w+)\s*[<(]/g, /(?:struct|enum|trait)\s+(\w+)/g],
151
+ java: [/(?:public|private|protected)?\s*(?:static)?\s*\w+\s+(\w+)\s*\(/g, /class\s+(\w+)/g],
152
+ rb: [/def\s+(\w+)/g, /class\s+(\w+)/g],
153
+ php: [/function\s+(\w+)/g, /class\s+(\w+)/g],
154
+ };
155
+ const langMap = { jsx: 'js', mjs: 'js', cjs: 'js', tsx: 'ts', mts: 'ts', kt: 'java', scala: 'java' };
156
+ const lang = langMap[ext] || ext;
157
+ const langPatterns = patterns[lang] || patterns.js;
158
+ const symbols = [];
159
+ for (const p of langPatterns) {
160
+ let m;
161
+ while ((m = p.exec(content)) !== null) {
162
+ if (m[1] && !symbols.includes(m[1])) symbols.push(m[1]);
163
+ }
164
+ }
165
+ return symbols.slice(0, 50);
166
+ }
167
+
168
+ /**
169
+ * Parse .gitignore patterns from a directory.
170
+ * @param {string} dirPath
171
+ * @returns {string[]}
172
+ */
173
+ function loadGitignore(dirPath) {
174
+ const gitignorePath = path.join(dirPath, '.gitignore');
175
+ try {
176
+ const content = fs.readFileSync(gitignorePath, 'utf-8');
177
+ return content
178
+ .split('\n')
179
+ .map(l => l.trim())
180
+ .filter(l => l && !l.startsWith('#'));
181
+ } catch {
182
+ return [];
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Check if a path should be ignored.
188
+ * @param {string} filePath
189
+ * @param {string[]} patterns
190
+ * @returns {boolean}
191
+ */
192
+ function shouldIgnore(filePath, patterns) {
193
+ const basename = path.basename(filePath);
194
+ for (const pattern of patterns) {
195
+ if (pattern.startsWith('*')) {
196
+ if (basename.endsWith(pattern.slice(1))) return true;
197
+ } else if (filePath.includes(pattern) || basename === pattern) {
198
+ return true;
199
+ }
200
+ }
201
+ return false;
202
+ }
203
+
204
+ /**
205
+ * Recursively find code files respecting .gitignore.
206
+ * @param {string} dirPath
207
+ * @param {object} opts
208
+ * @returns {Promise<string[]>}
209
+ */
210
+ async function findCodeFiles(dirPath, opts = {}) {
211
+ const maxFiles = opts.maxFiles || 5000;
212
+ const maxFileSize = opts.maxFileSize || 100000;
213
+ const gitignorePatterns = loadGitignore(dirPath);
214
+ const allPatterns = [...DEFAULT_IGNORE, ...gitignorePatterns];
215
+ const files = [];
216
+
217
+ async function walk(dir) {
218
+ if (files.length >= maxFiles) return;
219
+ let entries;
220
+ try {
221
+ entries = await fs.promises.readdir(dir, { withFileTypes: true });
222
+ } catch { return; }
223
+ for (const entry of entries) {
224
+ if (files.length >= maxFiles) break;
225
+ const fullPath = path.join(dir, entry.name);
226
+ if (shouldIgnore(fullPath, allPatterns)) continue;
227
+ if (entry.isDirectory()) {
228
+ await walk(fullPath);
229
+ } else if (entry.isFile()) {
230
+ const ext = path.extname(entry.name).toLowerCase();
231
+ if (!CODE_EXTENSIONS.includes(ext)) continue;
232
+ try {
233
+ const stats = await fs.promises.stat(fullPath);
234
+ if (stats.size > maxFileSize || stats.size === 0) continue;
235
+ } catch { continue; }
236
+ files.push(fullPath);
237
+ }
238
+ }
239
+ }
240
+
241
+ await walk(dirPath);
242
+ return files;
243
+ }
244
+
245
+ /**
246
+ * Derive a collection name from a directory path.
247
+ * @param {string} dirPath
248
+ * @returns {string}
249
+ */
250
+ function deriveCollectionName(dirPath) {
251
+ try {
252
+ const pkg = JSON.parse(fs.readFileSync(path.join(dirPath, 'package.json'), 'utf-8'));
253
+ if (pkg.name) return pkg.name.replace(/[^a-zA-Z0-9_-]/g, '_') + '_code';
254
+ } catch { /* ignore */ }
255
+ return path.basename(path.resolve(dirPath)).replace(/[^a-zA-Z0-9_-]/g, '_') + '_code';
256
+ }
257
+
258
+ /**
259
+ * Resolve db/collection from options, .vai.json codeSearch config, or defaults.
260
+ * @param {object} opts
261
+ * @param {string} [workspacePath]
262
+ * @returns {{db: string, collection: string, model: string, projectConfig: object}}
263
+ */
264
+ function resolveConfig(opts, workspacePath) {
265
+ const { config: proj } = loadProject(workspacePath);
266
+ const cs = proj.codeSearch || {};
267
+ const db = opts.db || cs.db || proj.db || DEFAULT_DB;
268
+ const collection = opts.collection || cs.collection || deriveCollectionName(workspacePath || process.cwd());
269
+ const model = opts.model || cs.model || DEFAULT_CODE_MODEL;
270
+ return { db, collection, model, projectConfig: proj };
271
+ }
272
+
273
+ /**
274
+ * Auto-select the best embedding model based on file types.
275
+ * @param {string[]} files - Array of file paths
276
+ * @param {object} [projectConfig] - Project config from .vai.json
277
+ * @returns {string}
278
+ */
279
+ function selectCodeModel(files, projectConfig) {
280
+ // User override always wins
281
+ if (projectConfig?.codeSearch?.model) {
282
+ return projectConfig.codeSearch.model;
283
+ }
284
+
285
+ const total = files.length;
286
+ if (total === 0) return DEFAULT_CODE_MODEL;
287
+
288
+ const codeFiles = files.filter(f => CODE_EXTENSIONS.includes(path.extname(f).toLowerCase()));
289
+ const docFiles = files.filter(f => DOC_EXTENSIONS.includes(path.extname(f).toLowerCase()));
290
+
291
+ const codeRatio = codeFiles.length / total;
292
+ const docRatio = docFiles.length / total;
293
+
294
+ if (codeRatio >= 0.7) return 'voyage-code-3';
295
+ if (docRatio >= 0.7) return 'voyage-4-large';
296
+ return 'voyage-code-3';
297
+ }
298
+
299
+ module.exports = {
300
+ DEFAULT_CODE_MODEL,
301
+ DEFAULT_DB,
302
+ CODE_EXTENSIONS,
303
+ DOC_EXTENSIONS,
304
+ DEFAULT_IGNORE,
305
+ BOUNDARY_PATTERNS,
306
+ getBoundaryPattern,
307
+ smartChunkCode,
308
+ extractSymbols,
309
+ loadGitignore,
310
+ shouldIgnore,
311
+ findCodeFiles,
312
+ deriveCollectionName,
313
+ resolveConfig,
314
+ selectCodeModel,
315
+ };
@@ -302,7 +302,7 @@ function renderTemplate(target, name, context) {
302
302
  function buildContext(project, options = {}) {
303
303
  const context = {
304
304
  // Core config
305
- model: options.model || project.model || 'voyage-3-large',
305
+ model: options.model || project.model || 'voyage-4-large',
306
306
  db: options.db || project.db || 'myapp',
307
307
  collection: options.collection || project.collection || 'documents',
308
308
  field: options.field || project.field || 'embedding',
@@ -549,7 +549,7 @@ const concepts = {
549
549
  ``,
550
550
  `${pc.bold('In practice:')} You don't need to do anything special to use MoE — the API`,
551
551
  `interface is identical. The architecture difference shows up in quality and cost:`,
552
- ` ${pc.dim('•')} voyage-4-large: $0.12/1M tokens better quality than voyage-3-large ($0.18/1M)`,
552
+ ` ${pc.dim('•')} voyage-4-large: $0.12/1M tokens, best quality via MoE architecture`,
553
553
  ` ${pc.dim('•')} 40% cheaper than comparable dense models at the same quality tier`,
554
554
  ].join('\n'),
555
555
  links: [
@@ -616,9 +616,9 @@ const concepts = {
616
616
  ``,
617
617
  `${pc.bold('Current standings (Jan 2026):')}`,
618
618
  ` ${pc.cyan('voyage-4-large')} ${pc.bold('71.41')} ${pc.dim('— SOTA, MoE architecture')}`,
619
- ` ${pc.cyan('voyage-4')} ${pc.bold('70.07')} ${pc.dim('— near voyage-3-large quality')}`,
619
+ ` ${pc.cyan('voyage-4')} ${pc.bold('70.07')} ${pc.dim('— balanced quality/cost')}`,
620
620
  ` ${pc.cyan('Gemini Embedding 001')} ${pc.bold('68.66')} ${pc.dim('— Google')}`,
621
- ` ${pc.cyan('voyage-4-lite')} ${pc.bold('68.10')} ${pc.dim('— near voyage-3.5 quality')}`,
621
+ ` ${pc.cyan('voyage-4-lite')} ${pc.bold('68.10')} ${pc.dim('— best budget option')}`,
622
622
  ` ${pc.cyan('Cohere Embed v4')} ${pc.bold('65.75')} ${pc.dim('— Cohere')}`,
623
623
  ` ${pc.cyan('OpenAI v3 Large')} ${pc.bold('62.57')} ${pc.dim('— OpenAI')}`,
624
624
  ``,
@@ -0,0 +1,27 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Normalize benchmark data for export.
5
+ * @param {object} data - Raw benchmark data
6
+ * @param {object} options
7
+ * @returns {object} normalized
8
+ */
9
+ function normalizeBenchmark(data, options = {}) {
10
+ const results = data.results || data.rows || [];
11
+ const rows = results.map((r) => {
12
+ const row = { ...r };
13
+ return row;
14
+ });
15
+
16
+ return {
17
+ _context: 'benchmark',
18
+ name: data.name || data.title || 'Benchmark',
19
+ date: data.date || new Date().toISOString(),
20
+ results: rows,
21
+ rows, // alias for CSV renderer
22
+ };
23
+ }
24
+
25
+ const BENCHMARK_FORMATS = ['json', 'csv', 'markdown', 'svg', 'png', 'clipboard'];
26
+
27
+ module.exports = { normalizeBenchmark, BENCHMARK_FORMATS };
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Normalize a chat session for export.
5
+ * @param {object} data - Raw chat session data
6
+ * @param {object} options
7
+ * @returns {object} normalized
8
+ */
9
+ function normalizeChat(data, options = {}) {
10
+ const turns = (data.turns || data.messages || []).map((t) => {
11
+ const turn = {
12
+ role: t.role,
13
+ content: t.content,
14
+ timestamp: t.timestamp,
15
+ };
16
+ if (options.includeSources !== false && t.context) {
17
+ turn.context = t.context;
18
+ }
19
+ if (options.includeMetadata && t.metadata) {
20
+ turn.metadata = t.metadata;
21
+ }
22
+ if (options.includeContextChunks && t.contextChunks) {
23
+ turn.contextChunks = t.contextChunks;
24
+ }
25
+ return turn;
26
+ });
27
+
28
+ return {
29
+ _context: 'chat',
30
+ sessionId: data.sessionId || data.id,
31
+ startedAt: data.startedAt,
32
+ provider: data.provider,
33
+ model: data.model,
34
+ collection: data.collection,
35
+ turns,
36
+ };
37
+ }
38
+
39
+ const CHAT_FORMATS = ['json', 'markdown', 'pdf', 'clipboard'];
40
+
41
+ module.exports = { normalizeChat, CHAT_FORMATS };
@@ -0,0 +1,22 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Normalize explore/visualization data for export.
5
+ * Phase 1 stub — only JSON supported.
6
+ * @param {object} data
7
+ * @param {object} options
8
+ * @returns {object} normalized
9
+ */
10
+ function normalizeExplore(data, options = {}) {
11
+ return {
12
+ _context: 'explore',
13
+ points: data.points || [],
14
+ labels: data.labels || [],
15
+ dimensions: data.dimensions || 2,
16
+ method: data.method || 'pca',
17
+ };
18
+ }
19
+
20
+ const EXPLORE_FORMATS = ['json', 'svg', 'png'];
21
+
22
+ module.exports = { normalizeExplore, EXPLORE_FORMATS };
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Normalize search results for export.
5
+ * @param {object} data - Raw search results data
6
+ * @param {object} options
7
+ * @returns {object} normalized
8
+ */
9
+ function normalizeSearch(data, options = {}) {
10
+ const results = (data.results || []).map((r, i) => {
11
+ const item = {
12
+ rank: r.rank || i + 1,
13
+ score: r.score,
14
+ source: r.source || r.path || '',
15
+ };
16
+ if (r.rerankedScore !== undefined) item.rerankedScore = r.rerankedScore;
17
+ if (options.includeFullText) {
18
+ item.text = r.text || '';
19
+ } else {
20
+ item.text = (r.text || '').slice(0, 200);
21
+ }
22
+ if (options.includeMetadata !== false && r.metadata) {
23
+ item.metadata = r.metadata;
24
+ }
25
+ return item;
26
+ });
27
+
28
+ const normalized = {
29
+ _context: 'search',
30
+ results,
31
+ };
32
+
33
+ if (options.includeQuery !== false && data.query) {
34
+ normalized.query = data.query;
35
+ normalized._exportMeta = { query: data.query };
36
+ }
37
+ if (data.collection) normalized.collection = data.collection;
38
+ if (data.model) normalized.model = data.model;
39
+
40
+ // Flat rows for CSV
41
+ normalized.rows = results.map((r) => ({
42
+ rank: r.rank,
43
+ score: r.score,
44
+ reranked_score: r.rerankedScore ?? '',
45
+ source: r.source,
46
+ text_excerpt: (r.text || '').slice(0, 200),
47
+ }));
48
+
49
+ return normalized;
50
+ }
51
+
52
+ const SEARCH_FORMATS = ['json', 'jsonl', 'csv', 'markdown', 'clipboard'];
53
+
54
+ module.exports = { normalizeSearch, SEARCH_FORMATS };
@@ -0,0 +1,80 @@
1
+ 'use strict';
2
+
3
+ const { buildDependencyGraph } = require('../../workflow');
4
+
5
+ /**
6
+ * Normalize a workflow definition for export.
7
+ * @param {object} workflow - Raw workflow JSON
8
+ * @param {object} options
9
+ * @param {boolean} [options.includeExecution=false]
10
+ * @param {boolean} [options.includeMetadata=false]
11
+ * @returns {object} normalized
12
+ */
13
+ function normalizeWorkflow(workflow, options = {}) {
14
+ const normalized = {
15
+ _context: 'workflow',
16
+ name: workflow.name,
17
+ description: workflow.description,
18
+ version: workflow.version,
19
+ inputs: workflow.inputs || {},
20
+ defaults: workflow.defaults,
21
+ steps: workflow.steps || [],
22
+ output: workflow.output,
23
+ };
24
+
25
+ // Compute dependency map for markdown rendering (Map<string, Set> → plain obj)
26
+ const depGraphMap = buildDependencyGraph(workflow.steps || []);
27
+ const depGraph = {};
28
+ for (const [id, deps] of depGraphMap) {
29
+ depGraph[id] = [...deps];
30
+ }
31
+ normalized._dependencyMap = depGraph;
32
+
33
+ // Count execution layers
34
+ const layerCount = computeLayerCount(workflow.steps || [], depGraph);
35
+ normalized._executionLayers = layerCount;
36
+
37
+ if (options.includeExecution && workflow._execution) {
38
+ normalized._execution = workflow._execution;
39
+ }
40
+
41
+ if (options.includeMetadata) {
42
+ normalized._metadata = {
43
+ _exportedAt: new Date().toISOString(),
44
+ _source: workflow._source || 'local',
45
+ };
46
+ }
47
+
48
+ return normalized;
49
+ }
50
+
51
+ function computeLayerCount(steps, depGraph) {
52
+ if (steps.length === 0) return 0;
53
+ const inDegree = {};
54
+ const ids = steps.map((s) => s.id);
55
+ for (const id of ids) inDegree[id] = (depGraph[id] || []).length;
56
+ const remaining = new Set(ids);
57
+ let layers = 0;
58
+ while (remaining.size > 0) {
59
+ const layer = [];
60
+ for (const id of remaining) {
61
+ if ((inDegree[id] || 0) === 0) layer.push(id);
62
+ }
63
+ if (layer.length === 0) break;
64
+ layers++;
65
+ for (const id of layer) {
66
+ remaining.delete(id);
67
+ for (const [depId, deps] of Object.entries(depGraph)) {
68
+ if (remaining.has(depId) && deps.includes(id)) {
69
+ inDegree[depId]--;
70
+ }
71
+ }
72
+ }
73
+ }
74
+ return layers;
75
+ }
76
+
77
+ /** Supported export formats for workflows */
78
+ const WORKFLOW_FORMATS = ['json', 'markdown', 'mermaid', 'svg', 'png', 'clipboard'];
79
+
80
+ module.exports = { normalizeWorkflow, WORKFLOW_FORMATS };
@@ -0,0 +1,29 @@
1
+ 'use strict';
2
+
3
+ const { execSync } = require('child_process');
4
+ const os = require('os');
5
+
6
+ /**
7
+ * Copy text content to the system clipboard.
8
+ * @param {string} content - Text to copy
9
+ * @returns {boolean} success
10
+ */
11
+ function copyToClipboard(content) {
12
+ const platform = os.platform();
13
+ try {
14
+ if (platform === 'darwin') {
15
+ execSync('pbcopy', { input: content, stdio: ['pipe', 'ignore', 'ignore'] });
16
+ } else if (platform === 'linux') {
17
+ execSync('xclip -selection clipboard', { input: content, stdio: ['pipe', 'ignore', 'ignore'] });
18
+ } else if (platform === 'win32') {
19
+ execSync('clip', { input: content, stdio: ['pipe', 'ignore', 'ignore'] });
20
+ } else {
21
+ return false;
22
+ }
23
+ return true;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ module.exports = { copyToClipboard };
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Escape a CSV field value.
5
+ * Wraps in quotes if the value contains commas, quotes, or newlines.
6
+ * @param {*} value
7
+ * @returns {string}
8
+ */
9
+ function escapeField(value) {
10
+ if (value === null || value === undefined) return '';
11
+ const str = String(value);
12
+ if (str.includes('"') || str.includes(',') || str.includes('\n') || str.includes('\r')) {
13
+ return '"' + str.replace(/"/g, '""') + '"';
14
+ }
15
+ return str;
16
+ }
17
+
18
+ /**
19
+ * Render an array of objects as CSV.
20
+ * @param {object} normalized - Must have a `rows` or `results` array of flat objects
21
+ * @param {object} options
22
+ * @param {string[]} [options.columns] - Explicit column order; auto-detected if omitted
23
+ * @returns {{ content: string, mimeType: string }}
24
+ */
25
+ function renderCsv(normalized, options = {}) {
26
+ const rows = normalized.rows || normalized.results || [];
27
+ if (!Array.isArray(rows) || rows.length === 0) {
28
+ return { content: '', mimeType: 'text/csv' };
29
+ }
30
+
31
+ // Determine columns
32
+ const columns = options.columns || Object.keys(rows[0]);
33
+
34
+ const header = columns.map(escapeField).join(',');
35
+ const body = rows.map((row) =>
36
+ columns.map((col) => escapeField(row[col])).join(',')
37
+ );
38
+
39
+ return {
40
+ content: [header, ...body].join('\n'),
41
+ mimeType: 'text/csv',
42
+ };
43
+ }
44
+
45
+ module.exports = { renderCsv, escapeField };