@skillrecordings/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/.env.encrypted +0 -0
  2. package/CHANGELOG.md +35 -0
  3. package/README.md +214 -0
  4. package/bin/skill.ts +3 -0
  5. package/data/tt-archive-dataset.json +1 -0
  6. package/data/validate-test-dataset.json +97 -0
  7. package/docs/CLI-AUTH.md +504 -0
  8. package/package.json +38 -0
  9. package/preload.ts +18 -0
  10. package/src/__tests__/init.test.ts +74 -0
  11. package/src/alignment-test.ts +64 -0
  12. package/src/check-apps.ts +16 -0
  13. package/src/commands/auth/decrypt.ts +123 -0
  14. package/src/commands/auth/encrypt.ts +81 -0
  15. package/src/commands/auth/index.ts +50 -0
  16. package/src/commands/auth/keygen.ts +41 -0
  17. package/src/commands/auth/status.ts +164 -0
  18. package/src/commands/axiom/forensic.ts +868 -0
  19. package/src/commands/axiom/index.ts +697 -0
  20. package/src/commands/build-dataset.ts +311 -0
  21. package/src/commands/db-status.ts +47 -0
  22. package/src/commands/deploys.ts +219 -0
  23. package/src/commands/eval-local/compare.ts +171 -0
  24. package/src/commands/eval-local/health.ts +212 -0
  25. package/src/commands/eval-local/index.ts +76 -0
  26. package/src/commands/eval-local/real-tools.ts +416 -0
  27. package/src/commands/eval-local/run.ts +1168 -0
  28. package/src/commands/eval-local/score-production.ts +256 -0
  29. package/src/commands/eval-local/seed.ts +276 -0
  30. package/src/commands/eval-pipeline/index.ts +53 -0
  31. package/src/commands/eval-pipeline/real-tools.ts +492 -0
  32. package/src/commands/eval-pipeline/run.ts +1316 -0
  33. package/src/commands/eval-pipeline/seed.ts +395 -0
  34. package/src/commands/eval-prompt.ts +496 -0
  35. package/src/commands/eval.test.ts +253 -0
  36. package/src/commands/eval.ts +108 -0
  37. package/src/commands/faq-classify.ts +460 -0
  38. package/src/commands/faq-cluster.ts +135 -0
  39. package/src/commands/faq-extract.ts +249 -0
  40. package/src/commands/faq-mine.ts +432 -0
  41. package/src/commands/faq-review.ts +426 -0
  42. package/src/commands/front/index.ts +351 -0
  43. package/src/commands/front/pull-conversations.ts +275 -0
  44. package/src/commands/front/tags.ts +825 -0
  45. package/src/commands/front-cache.ts +1277 -0
  46. package/src/commands/front-stats.ts +75 -0
  47. package/src/commands/health.test.ts +82 -0
  48. package/src/commands/health.ts +362 -0
  49. package/src/commands/init.test.ts +89 -0
  50. package/src/commands/init.ts +106 -0
  51. package/src/commands/inngest/client.ts +294 -0
  52. package/src/commands/inngest/events.ts +296 -0
  53. package/src/commands/inngest/investigate.ts +382 -0
  54. package/src/commands/inngest/runs.ts +149 -0
  55. package/src/commands/inngest/signal.ts +143 -0
  56. package/src/commands/kb-sync.ts +498 -0
  57. package/src/commands/memory/find.ts +135 -0
  58. package/src/commands/memory/get.ts +87 -0
  59. package/src/commands/memory/index.ts +97 -0
  60. package/src/commands/memory/stats.ts +163 -0
  61. package/src/commands/memory/store.ts +49 -0
  62. package/src/commands/memory/vote.ts +159 -0
  63. package/src/commands/pipeline.ts +127 -0
  64. package/src/commands/responses.ts +856 -0
  65. package/src/commands/tools.ts +293 -0
  66. package/src/commands/wizard.ts +319 -0
  67. package/src/index.ts +172 -0
  68. package/src/lib/crypto.ts +56 -0
  69. package/src/lib/env-loader.ts +206 -0
  70. package/src/lib/onepassword.ts +137 -0
  71. package/src/test-agent-local.ts +115 -0
  72. package/tsconfig.json +11 -0
  73. package/vitest.config.ts +10 -0
@@ -0,0 +1,249 @@
1
+ /**
2
+ * FAQ Extraction CLI Command
3
+ *
4
+ * Extracts FAQ candidates from clustered conversations.
5
+ * Part of Phase 1.3 of the FAQ Mining pipeline.
6
+ *
7
+ * Usage:
8
+ * bun src/index.ts faq-extract
9
+ * bun src/index.ts faq-extract --app total-typescript --push-redis
10
+ * bun src/index.ts faq-extract --dry-run
11
+ * bun src/index.ts faq-extract --version v2
12
+ */
13
+
14
+ import { existsSync } from 'fs'
15
+ import { join, resolve } from 'path'
16
+ import { createDuckDBSource } from '@skillrecordings/core/faq/duckdb-source'
17
+ import {
18
+ type ExtractionOptions,
19
+ extractFaqCandidates,
20
+ } from '@skillrecordings/core/faq/extractor'
21
+ import { closeDb } from '@skillrecordings/database'
22
+ import type { Command } from 'commander'
23
+
24
+ /** Project root */
25
+ const PROJECT_ROOT = resolve(__dirname, '../../../..')
26
+
27
+ /** Default paths */
28
+ const DEFAULT_CLUSTERING_PATH = join(
29
+ PROJECT_ROOT,
30
+ 'artifacts/phase-1/clustering/v1/clustering-result.json'
31
+ )
32
+ const DEFAULT_GOLDEN_PATH = join(
33
+ PROJECT_ROOT,
34
+ 'artifacts/phase-0/golden/latest/responses.json'
35
+ )
36
+ const DEFAULT_OUTPUT_PATH = join(PROJECT_ROOT, 'artifacts/phase-1/extraction')
37
+ const DEFAULT_CACHE_PATH = `${process.env.HOME}/skill/data/front-cache.db`
38
+
39
+ /**
40
+ * Validate required files exist
41
+ */
42
+ function validatePaths(clusteringPath: string, goldenPath?: string): void {
43
+ if (!existsSync(clusteringPath)) {
44
+ throw new Error(
45
+ `Clustering result not found at ${clusteringPath}\n` +
46
+ 'Run `bun src/index.ts faq-cluster` first to generate clustering.'
47
+ )
48
+ }
49
+
50
+ if (goldenPath && !existsSync(goldenPath)) {
51
+ console.warn(`⚠️ Golden responses not found at ${goldenPath}`)
52
+ console.warn(' Golden matching will be disabled.')
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Main command handler
58
+ */
59
+ async function faqExtract(options: {
60
+ clusteringPath?: string
61
+ goldenPath?: string
62
+ outputPath?: string
63
+ cachePath?: string
64
+ outputVersion?: string
65
+ minClusterSize?: number
66
+ topN?: number
67
+ dedupThreshold?: number
68
+ pushRedis?: boolean
69
+ app?: string
70
+ dryRun?: boolean
71
+ json?: boolean
72
+ filters?: boolean
73
+ }): Promise<void> {
74
+ const clusteringPath = options.clusteringPath ?? DEFAULT_CLUSTERING_PATH
75
+ const goldenPath = options.goldenPath ?? DEFAULT_GOLDEN_PATH
76
+ const outputPath = options.outputPath ?? DEFAULT_OUTPUT_PATH
77
+ const cachePath = options.cachePath ?? DEFAULT_CACHE_PATH
78
+ const version = options.outputVersion ?? 'v1'
79
+
80
+ const applyFilters = options.filters ?? true
81
+
82
+ console.log('🔬 FAQ Extraction Pipeline')
83
+ console.log('='.repeat(60))
84
+ console.log(` Clustering: ${clusteringPath}`)
85
+ console.log(` Golden: ${goldenPath}`)
86
+ console.log(` Output: ${outputPath}`)
87
+ console.log(` DuckDB cache: ${cachePath}`)
88
+ console.log(` Version: ${version}`)
89
+ console.log(` Apply filters: ${applyFilters}`)
90
+ console.log(` Push to Redis: ${options.pushRedis ?? false}`)
91
+ console.log(` Dry run: ${options.dryRun ?? false}`)
92
+ console.log('')
93
+
94
+ // Validate paths
95
+ validatePaths(clusteringPath, goldenPath)
96
+
97
+ // Check DuckDB cache
98
+ if (!existsSync(cachePath)) {
99
+ throw new Error(
100
+ `DuckDB cache not found at ${cachePath}\n` +
101
+ 'Run `bun src/index.ts front-cache sync` first to populate cache.'
102
+ )
103
+ }
104
+
105
+ let source
106
+
107
+ try {
108
+ // Create DuckDB source
109
+ console.log('📦 Connecting to DuckDB cache...')
110
+ source = await createDuckDBSource({ dbPath: cachePath })
111
+
112
+ // Get source stats
113
+ const stats = await source.getStats?.()
114
+ if (stats) {
115
+ console.log(
116
+ ` ${stats.totalConversations.toLocaleString()} conversations in cache`
117
+ )
118
+ }
119
+
120
+ // Run extraction
121
+ const extractionOptions: ExtractionOptions = {
122
+ clusteringPath,
123
+ goldenPath: existsSync(goldenPath) ? goldenPath : undefined,
124
+ source,
125
+ outputPath,
126
+ version,
127
+ minClusterSize: options.minClusterSize ?? 3,
128
+ topN: options.topN ?? 5,
129
+ dedupThreshold: options.dedupThreshold ?? 0.85,
130
+ pushToRedis: options.pushRedis ?? false,
131
+ appId: options.app,
132
+ dryRun: options.dryRun ?? false,
133
+ applyFilters,
134
+ }
135
+
136
+ const result = await extractFaqCandidates(extractionOptions)
137
+
138
+ // JSON output
139
+ if (options.json) {
140
+ console.log('\n📋 JSON Output:')
141
+ console.log(JSON.stringify(result.stats, null, 2))
142
+ }
143
+
144
+ // Check acceptance criteria
145
+ console.log('\n✅ Acceptance Criteria Check:')
146
+ const highConfidence = result.stats.highConfidenceCount
147
+ const target = 50
148
+
149
+ if (highConfidence >= target) {
150
+ console.log(
151
+ ` ✅ ${highConfidence} candidates with confidence ≥ 0.7 (target: ${target})`
152
+ )
153
+ } else {
154
+ console.log(
155
+ ` ⚠️ Only ${highConfidence} candidates with confidence ≥ 0.7 (target: ${target})`
156
+ )
157
+ console.log(
158
+ ' Consider lowering --min-cluster-size or --dedup-threshold'
159
+ )
160
+ }
161
+
162
+ console.log(
163
+ ` ✅ Golden match rate: ${(result.stats.goldenMatchRate * 100).toFixed(1)}%`
164
+ )
165
+ console.log(
166
+ ` ✅ Deduplication working: ${result.stats.deduplicatedCount} removed`
167
+ )
168
+
169
+ if (!options.dryRun) {
170
+ console.log(`\n✅ Extraction complete!`)
171
+ console.log(` Artifacts written to: ${join(outputPath, version)}`)
172
+
173
+ if (options.pushRedis && options.app) {
174
+ console.log(
175
+ ` Candidates pushed to Redis queue: faq:pending:${options.app}`
176
+ )
177
+ }
178
+ }
179
+ } catch (error) {
180
+ console.error(
181
+ '\n❌ Error:',
182
+ error instanceof Error ? error.message : String(error)
183
+ )
184
+ process.exit(1)
185
+ } finally {
186
+ if (source?.close) {
187
+ await source.close()
188
+ }
189
+ await closeDb()
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Register FAQ extraction commands with Commander
195
+ */
196
+ export function registerFaqExtractCommands(program: Command): void {
197
+ program
198
+ .command('faq-extract')
199
+ .description('Extract FAQ candidates from clustered conversations')
200
+ .option(
201
+ '--clustering-path <path>',
202
+ 'Path to clustering result file',
203
+ DEFAULT_CLUSTERING_PATH
204
+ )
205
+ .option(
206
+ '--golden-path <path>',
207
+ 'Path to golden responses file',
208
+ DEFAULT_GOLDEN_PATH
209
+ )
210
+ .option(
211
+ '--output-path <path>',
212
+ 'Path to write extraction artifacts',
213
+ DEFAULT_OUTPUT_PATH
214
+ )
215
+ .option(
216
+ '--cache-path <path>',
217
+ 'Path to DuckDB cache file',
218
+ DEFAULT_CACHE_PATH
219
+ )
220
+ .option(
221
+ '--output-version <version>',
222
+ 'Version tag for output (e.g., v1, v2)',
223
+ 'v1'
224
+ )
225
+ .option(
226
+ '--min-cluster-size <n>',
227
+ 'Minimum cluster size to process (default: 3)',
228
+ (val: string) => parseInt(val, 10)
229
+ )
230
+ .option(
231
+ '--top-n <n>',
232
+ 'Number of representative conversations per cluster (default: 5)',
233
+ (val: string) => parseInt(val, 10)
234
+ )
235
+ .option(
236
+ '--dedup-threshold <n>',
237
+ 'Similarity threshold for deduplication (default: 0.85)',
238
+ (val: string) => parseFloat(val)
239
+ )
240
+ .option('--push-redis', 'Push candidates to Redis review queue')
241
+ .option(
242
+ '-a, --app <slug>',
243
+ 'App ID for Redis queue (required with --push-redis)'
244
+ )
245
+ .option('-d, --dry-run', 'Show summary without writing artifacts')
246
+ .option('--json', 'Output stats as JSON')
247
+ .option('--no-filters', 'Disable preprocessing filters (for comparison)')
248
+ .action(faqExtract)
249
+ }
@@ -0,0 +1,432 @@
1
+ /**
2
+ * FAQ Mining CLI Command
3
+ *
4
+ * Mines resolved support conversations for FAQ candidates.
5
+ * Uses semantic clustering to identify recurring questions.
6
+ *
7
+ * Usage:
8
+ * skill faq-mine --app total-typescript --since 30d
9
+ * skill faq-mine --app epic-react --since 7d --unchanged-only
10
+ * skill faq-mine --app total-typescript --since 90d --json
11
+ * skill faq-mine --app epic-web --since 30d --export faq-candidates.json
12
+ */
13
+
14
+ import { writeFileSync } from 'fs'
15
+ import {
16
+ type FaqCandidate,
17
+ type MineResult,
18
+ filterAutoSurfaceCandidates,
19
+ mineConversations,
20
+ mineFaqCandidates,
21
+ } from '@skillrecordings/core/faq'
22
+ import { createDuckDBSource } from '@skillrecordings/core/faq/duckdb-source'
23
+ import type { DataSource } from '@skillrecordings/core/faq/types'
24
+ import { closeDb } from '@skillrecordings/database'
25
+ import type { Command } from 'commander'
26
+
27
+ /**
28
+ * Format timestamp for display
29
+ */
30
+ function formatDate(date: Date): string {
31
+ return date.toLocaleString('en-US', {
32
+ month: 'short',
33
+ day: 'numeric',
34
+ hour: '2-digit',
35
+ minute: '2-digit',
36
+ })
37
+ }
38
+
39
+ /**
40
+ * Truncate string with ellipsis
41
+ */
42
+ function truncate(str: string, len: number): string {
43
+ if (!str) return ''
44
+ if (str.length <= len) return str
45
+ return str.slice(0, len - 3) + '...'
46
+ }
47
+
48
+ /**
49
+ * Color codes for terminal output
50
+ */
51
+ const COLORS = {
52
+ reset: '\x1b[0m',
53
+ green: '\x1b[32m',
54
+ yellow: '\x1b[33m',
55
+ blue: '\x1b[34m',
56
+ cyan: '\x1b[36m',
57
+ dim: '\x1b[2m',
58
+ bold: '\x1b[1m',
59
+ } as const
60
+
61
+ /**
62
+ * Display human-readable mining results
63
+ */
64
+ function displayResults(result: MineResult): void {
65
+ console.log(`\n${COLORS.bold}📚 FAQ Mining Results${COLORS.reset}`)
66
+ console.log('='.repeat(60))
67
+
68
+ // Stats
69
+ console.log(`\n${COLORS.cyan}Statistics:${COLORS.reset}`)
70
+ console.log(` Total conversations: ${result.stats.totalConversations}`)
71
+ console.log(` Clustered: ${result.stats.clusteredConversations}`)
72
+ console.log(` Clusters formed: ${result.stats.clusterCount}`)
73
+ console.log(` FAQ candidates: ${result.stats.candidateCount}`)
74
+ console.log(
75
+ ` Avg cluster size: ${result.stats.averageClusterSize.toFixed(1)}`
76
+ )
77
+ console.log(
78
+ ` ${COLORS.green}Avg unchanged rate: ${(result.stats.averageUnchangedRate * 100).toFixed(1)}%${COLORS.reset}`
79
+ )
80
+
81
+ // Clusters
82
+ if (result.clusters.length > 0) {
83
+ console.log(`\n${COLORS.bold}📊 Clusters:${COLORS.reset}`)
84
+ console.log('-'.repeat(60))
85
+
86
+ for (const cluster of result.clusters.slice(0, 10)) {
87
+ const unchangedPct = (cluster.unchangedRate * 100).toFixed(0)
88
+ console.log(
89
+ `\n${COLORS.cyan}Cluster ${cluster.id.slice(0, 8)}${COLORS.reset} (${cluster.conversations.length} convos, ${unchangedPct}% unchanged)`
90
+ )
91
+ console.log(
92
+ ` ${COLORS.dim}Centroid: ${truncate(cluster.centroid, 150)}${COLORS.reset}`
93
+ )
94
+ console.log(
95
+ ` ${COLORS.dim}Period: ${formatDate(cluster.oldest)} - ${formatDate(cluster.mostRecent)}${COLORS.reset}`
96
+ )
97
+ }
98
+
99
+ if (result.clusters.length > 10) {
100
+ console.log(
101
+ `\n ${COLORS.dim}... and ${result.clusters.length - 10} more clusters${COLORS.reset}`
102
+ )
103
+ }
104
+ }
105
+
106
+ // Top candidates
107
+ if (result.candidates.length > 0) {
108
+ console.log(`\n${COLORS.bold}🏆 Top FAQ Candidates:${COLORS.reset}`)
109
+ console.log('-'.repeat(60))
110
+
111
+ // Filter to auto-surface candidates
112
+ const autoSurface = filterAutoSurfaceCandidates(result.candidates)
113
+
114
+ const displayCandidates =
115
+ autoSurface.length > 0
116
+ ? autoSurface.slice(0, 10)
117
+ : result.candidates.slice(0, 10)
118
+
119
+ const label =
120
+ autoSurface.length > 0
121
+ ? `(${autoSurface.length} auto-surface ready)`
122
+ : '(no auto-surface candidates)'
123
+
124
+ console.log(`${COLORS.dim}${label}${COLORS.reset}\n`)
125
+
126
+ for (const [i, candidate] of displayCandidates.entries()) {
127
+ if (!candidate) continue
128
+
129
+ const confPct = (candidate.confidence * 100).toFixed(0)
130
+ const unchangedPct = (candidate.unchangedRate * 100).toFixed(0)
131
+
132
+ console.log(
133
+ `${COLORS.bold}#${i + 1}${COLORS.reset} ${COLORS.dim}Confidence: ${confPct}% | ${candidate.clusterSize} occurrences | ${unchangedPct}% unchanged${COLORS.reset}`
134
+ )
135
+ console.log(
136
+ ` ${COLORS.bold}Q:${COLORS.reset} ${truncate(candidate.question, 200)}`
137
+ )
138
+ console.log(
139
+ ` ${COLORS.green}A:${COLORS.reset} ${truncate(candidate.answer, 300)}`
140
+ )
141
+ if (candidate.suggestedCategory) {
142
+ console.log(
143
+ ` ${COLORS.cyan}Category: ${candidate.suggestedCategory}${COLORS.reset}`
144
+ )
145
+ }
146
+ if (candidate.tags.length > 0) {
147
+ console.log(
148
+ ` ${COLORS.dim}Tags: ${candidate.tags.slice(0, 5).join(', ')}${COLORS.reset}`
149
+ )
150
+ }
151
+ console.log('')
152
+ }
153
+ }
154
+
155
+ console.log('')
156
+ }
157
+
158
+ /** Default DuckDB cache path */
159
+ const DEFAULT_CACHE_PATH = `${process.env.HOME}/skill/data/front-cache.db`
160
+
161
+ /**
162
+ * Create data source based on --source flag.
163
+ */
164
+ async function createSource(
165
+ sourceType: 'cache' | 'front' | undefined,
166
+ cachePath?: string
167
+ ): Promise<DataSource | undefined> {
168
+ if (sourceType === 'cache') {
169
+ const dbPath = cachePath ?? DEFAULT_CACHE_PATH
170
+ console.log(`📦 Using DuckDB cache: ${dbPath}`)
171
+ return createDuckDBSource({ dbPath })
172
+ }
173
+
174
+ // Default to Front API (undefined means use existing behavior)
175
+ return undefined
176
+ }
177
+
178
+ /**
179
+ * Main command handler
180
+ */
181
+ async function faqMine(options: {
182
+ app: string
183
+ since: string
184
+ limit?: number
185
+ unchangedOnly?: boolean
186
+ clusterThreshold?: number
187
+ json?: boolean
188
+ export?: string
189
+ raw?: boolean
190
+ source?: 'cache' | 'front'
191
+ cachePath?: string
192
+ dryRun?: boolean
193
+ }): Promise<void> {
194
+ if (!options.app) {
195
+ console.error('Error: --app is required')
196
+ process.exit(1)
197
+ }
198
+
199
+ if (!options.since) {
200
+ console.error('Error: --since is required (e.g., 30d, 7d, 90d)')
201
+ process.exit(1)
202
+ }
203
+
204
+ let source: DataSource | undefined
205
+
206
+ try {
207
+ // Create data source
208
+ source = await createSource(options.source ?? 'cache', options.cachePath)
209
+ // Dry run mode: show stats and sample data
210
+ if (options.dryRun) {
211
+ console.log(`\n🧪 DRY RUN MODE - ${options.app}`)
212
+ console.log(` Source: ${source?.name ?? 'front'}`)
213
+ console.log(` Since: ${options.since}`)
214
+ console.log(` Limit: ${options.limit ?? 500}`)
215
+
216
+ if (source?.getStats) {
217
+ const stats = await source.getStats()
218
+ console.log(`\n📊 Cache Statistics:`)
219
+ console.log(
220
+ ` Total conversations: ${stats.totalConversations.toLocaleString()}`
221
+ )
222
+ console.log(
223
+ ` Filtered (matching criteria): ${stats.filteredConversations.toLocaleString()}`
224
+ )
225
+ console.log(
226
+ ` Total messages: ${stats.totalMessages.toLocaleString()}`
227
+ )
228
+ console.log(` Inboxes: ${stats.inboxCount}`)
229
+ if (stats.dateRange.oldest && stats.dateRange.newest) {
230
+ console.log(
231
+ ` Date range: ${stats.dateRange.oldest.toLocaleDateString()} - ${stats.dateRange.newest.toLocaleDateString()}`
232
+ )
233
+ }
234
+ }
235
+
236
+ // Fetch a small sample
237
+ console.log(`\n📝 Sample conversations (limit 5):`)
238
+ const sample = await mineConversations({
239
+ appId: options.app,
240
+ since: options.since,
241
+ limit: 5,
242
+ unchangedOnly: options.unchangedOnly ?? false,
243
+ source,
244
+ })
245
+
246
+ for (const conv of sample) {
247
+ console.log(`\n [${conv.conversationId}]`)
248
+ console.log(` Q: ${truncate(conv.question, 100)}`)
249
+ console.log(` A: ${truncate(conv.answer, 100)}`)
250
+ console.log(` Tags: ${conv.tags.slice(0, 5).join(', ')}`)
251
+ }
252
+
253
+ console.log(
254
+ `\n✅ Dry run complete. ${sample.length} sample conversations loaded.`
255
+ )
256
+ return
257
+ }
258
+
259
+ // Raw mode: just export Q&A pairs without clustering
260
+ if (options.raw) {
261
+ console.log(`📚 Mining raw Q&A pairs for ${options.app}...`)
262
+ console.log(` Source: ${source?.name ?? 'front'}`)
263
+ console.log(` Since: ${options.since}`)
264
+ console.log(` Unchanged only: ${options.unchangedOnly ?? false}`)
265
+
266
+ const conversations = await mineConversations({
267
+ appId: options.app,
268
+ since: options.since,
269
+ limit: options.limit ?? 500,
270
+ unchangedOnly: options.unchangedOnly ?? false,
271
+ source,
272
+ })
273
+
274
+ const rawData = {
275
+ generatedAt: new Date().toISOString(),
276
+ options: {
277
+ appId: options.app,
278
+ since: options.since,
279
+ unchangedOnly: options.unchangedOnly ?? false,
280
+ },
281
+ stats: {
282
+ total: conversations.length,
283
+ },
284
+ conversations: conversations.map((c) => ({
285
+ conversationId: c.conversationId,
286
+ question: c.question,
287
+ answer: c.answer,
288
+ subject: c.subject,
289
+ tags: c.tags,
290
+ wasUnchanged: c.wasUnchanged,
291
+ resolvedAt: c.resolvedAt.toISOString(),
292
+ })),
293
+ }
294
+
295
+ if (options.export) {
296
+ writeFileSync(options.export, JSON.stringify(rawData, null, 2), 'utf-8')
297
+ console.log(
298
+ `\n✅ Exported ${conversations.length} raw Q&A pairs to ${options.export}`
299
+ )
300
+ } else {
301
+ console.log(JSON.stringify(rawData, null, 2))
302
+ }
303
+
304
+ return
305
+ }
306
+
307
+ const result = await mineFaqCandidates({
308
+ appId: options.app,
309
+ since: options.since,
310
+ limit: options.limit ?? 500,
311
+ unchangedOnly: options.unchangedOnly ?? false,
312
+ clusterThreshold: options.clusterThreshold,
313
+ source,
314
+ })
315
+
316
+ // JSON output
317
+ if (options.json) {
318
+ // Convert dates to ISO strings for JSON
319
+ const jsonResult = {
320
+ ...result,
321
+ conversations: result.conversations.map((c) => ({
322
+ ...c,
323
+ resolvedAt: c.resolvedAt.toISOString(),
324
+ _raw: undefined, // Don't include raw data in JSON
325
+ })),
326
+ clusters: result.clusters.map((c) => ({
327
+ ...c,
328
+ mostRecent: c.mostRecent.toISOString(),
329
+ oldest: c.oldest.toISOString(),
330
+ conversations: c.conversations.map((conv) => ({
331
+ conversationId: conv.conversationId,
332
+ question: conv.question.slice(0, 200),
333
+ wasUnchanged: conv.wasUnchanged,
334
+ })),
335
+ })),
336
+ candidates: result.candidates.map((c) => ({
337
+ ...c,
338
+ generatedAt: c.generatedAt.toISOString(),
339
+ })),
340
+ }
341
+ console.log(JSON.stringify(jsonResult, null, 2))
342
+ return
343
+ }
344
+
345
+ // Export to file
346
+ if (options.export) {
347
+ const exportData = {
348
+ generatedAt: new Date().toISOString(),
349
+ options: {
350
+ appId: options.app,
351
+ since: options.since,
352
+ unchangedOnly: options.unchangedOnly,
353
+ },
354
+ stats: result.stats,
355
+ candidates: result.candidates.map((c) => ({
356
+ id: c.id,
357
+ question: c.question,
358
+ answer: c.answer,
359
+ clusterSize: c.clusterSize,
360
+ unchangedRate: c.unchangedRate,
361
+ confidence: c.confidence,
362
+ suggestedCategory: c.suggestedCategory,
363
+ tags: c.tags,
364
+ generatedAt: c.generatedAt.toISOString(),
365
+ })),
366
+ }
367
+ writeFileSync(
368
+ options.export,
369
+ JSON.stringify(exportData, null, 2),
370
+ 'utf-8'
371
+ )
372
+ console.log(
373
+ `\n✅ Exported ${result.candidates.length} FAQ candidates to ${options.export}`
374
+ )
375
+ return
376
+ }
377
+
378
+ // Human-readable output
379
+ displayResults(result)
380
+ } catch (error) {
381
+ console.error(
382
+ 'Error:',
383
+ error instanceof Error ? error.message : String(error)
384
+ )
385
+ process.exit(1)
386
+ } finally {
387
+ // Close data source if needed
388
+ if (source?.close) {
389
+ await source.close()
390
+ }
391
+ await closeDb()
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Register FAQ mining commands with Commander
397
+ */
398
+ export function registerFaqMineCommands(program: Command): void {
399
+ program
400
+ .command('faq-mine')
401
+ .description('Mine FAQ candidates from resolved support conversations')
402
+ .requiredOption('-a, --app <slug>', 'App slug to mine from (required)')
403
+ .requiredOption(
404
+ '-s, --since <duration>',
405
+ 'Time window to mine (e.g., 30d, 7d, 90d)'
406
+ )
407
+ .option(
408
+ '-l, --limit <n>',
409
+ 'Maximum conversations to process (default: 500)',
410
+ parseInt
411
+ )
412
+ .option(
413
+ '-u, --unchanged-only',
414
+ 'Only include conversations where draft was sent unchanged'
415
+ )
416
+ .option(
417
+ '--cluster-threshold <n>',
418
+ 'Similarity threshold for clustering (default: 0.75)',
419
+ parseFloat
420
+ )
421
+ .option('--json', 'Output as JSON')
422
+ .option('-e, --export <file>', 'Export candidates to file')
423
+ .option('-r, --raw', 'Export raw Q&A pairs without clustering (faster)')
424
+ .option(
425
+ '--source <type>',
426
+ 'Data source: cache (DuckDB, default) or front (live API)',
427
+ 'cache'
428
+ )
429
+ .option('--cache-path <path>', 'Path to DuckDB cache file')
430
+ .option('-d, --dry-run', 'Show stats and sample data without full mining')
431
+ .action(faqMine)
432
+ }