botrun-horse 1.0.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 (64) hide show
  1. package/README.md +1 -0
  2. package/bin/bh.mjs +193 -0
  3. package/bin/commands/dag-cmd.mjs +74 -0
  4. package/bin/commands/db-cmd.mjs +73 -0
  5. package/bin/commands/doc.mjs +185 -0
  6. package/bin/commands/gemini.mjs +120 -0
  7. package/bin/commands/help.mjs +109 -0
  8. package/bin/commands/legal.mjs +174 -0
  9. package/bin/commands/nchc.mjs +212 -0
  10. package/bin/commands/openrouter.mjs +154 -0
  11. package/bin/commands/prompt.mjs +175 -0
  12. package/bin/commands/schema.mjs +258 -0
  13. package/bin/commands/search.mjs +46 -0
  14. package/bin/commands/writing.mjs +33 -0
  15. package/lib/core/adapters/base.mjs +52 -0
  16. package/lib/core/adapters/claude.mjs +13 -0
  17. package/lib/core/adapters/gemini-api.mjs +174 -0
  18. package/lib/core/adapters/gemini-shared.mjs +164 -0
  19. package/lib/core/adapters/gemini-vertex.mjs +232 -0
  20. package/lib/core/adapters/local.mjs +13 -0
  21. package/lib/core/adapters/nchc.mjs +236 -0
  22. package/lib/core/adapters/openai-shared.mjs +34 -0
  23. package/lib/core/adapters/openrouter.mjs +304 -0
  24. package/lib/core/ai-cache.mjs +277 -0
  25. package/lib/core/ai-router.mjs +217 -0
  26. package/lib/core/cli-utils.mjs +170 -0
  27. package/lib/core/dag.mjs +114 -0
  28. package/lib/core/db.mjs +412 -0
  29. package/lib/core/env.mjs +64 -0
  30. package/lib/core/llm.mjs +58 -0
  31. package/lib/core/paths.mjs +115 -0
  32. package/lib/core/proxy.mjs +46 -0
  33. package/lib/core/watermelon.mjs +9 -0
  34. package/lib/doc/index.mjs +419 -0
  35. package/lib/doc/office2text.mjs +234 -0
  36. package/lib/doc/pdf2text.mjs +133 -0
  37. package/lib/doc/split.mjs +132 -0
  38. package/lib/flows/draft-writing.mjs +29 -0
  39. package/lib/flows/gemini-ask.mjs +185 -0
  40. package/lib/flows/hatch-portal.mjs +13 -0
  41. package/lib/flows/legal-ask.mjs +325 -0
  42. package/lib/flows/openai-agent.mjs +167 -0
  43. package/lib/flows/opencode-agent.mjs +240 -0
  44. package/lib/flows/openrouter-ask.mjs +111 -0
  45. package/lib/flows/review-doc.mjs +18 -0
  46. package/lib/ocr/index.mjs +6 -0
  47. package/lib/portal/hatch.mjs +6 -0
  48. package/lib/portal/index.mjs +6 -0
  49. package/lib/prompt/prompt-search.mjs +55 -0
  50. package/lib/prompt/prompt-store.mjs +94 -0
  51. package/lib/prompt/prompts/zero-framework/coding.md +15 -0
  52. package/lib/prompt/prompts/zero-framework/search.md +12 -0
  53. package/lib/prompt/prompts/zero-framework/slice.md +11 -0
  54. package/lib/search/crawler.mjs +6 -0
  55. package/lib/search/index.mjs +7 -0
  56. package/lib/tools/fs-tools.mjs +268 -0
  57. package/lib/tools/index.mjs +27 -0
  58. package/lib/writing/generate.mjs +86 -0
  59. package/lib/writing/generators/nstc-generators.mjs +279 -0
  60. package/lib/writing/generators/nstc-top5.mjs +554 -0
  61. package/lib/writing/index.mjs +5 -0
  62. package/lib/writing/layouts/nstc-layout.mjs +249 -0
  63. package/lib/writing/renderer.mjs +61 -0
  64. package/package.json +35 -0
package/README.md ADDED
@@ -0,0 +1 @@
1
+ # official-letter
package/bin/bh.mjs ADDED
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env node
2
+ // bin/bh.mjs — botrun-horse CLI 總入口(路由分派)
3
+ // 多專案架構:lib 放腦、projects 放料、bin/commands/ 放各指令群組
4
+ //
5
+ // 用法:
6
+ // node bin/bh.mjs <指令群組> [子指令] [選項]
7
+ // node --experimental-sqlite bin/bh.mjs <指令> (需 SQLite 功能時)
8
+ //
9
+ // 所有指令皆支援 --project=<名稱>、--format=json、stdin pipe、--quiet、--print0
10
+
11
+ import { setupHttpProxy } from '../lib/core/proxy.mjs';
12
+ setupHttpProxy(); // 必須在所有 fetch 呼叫之前執行(含 @google/genai、Anthropic SDK)
13
+
14
+ import { parseArgs, VERSION } from '../lib/core/cli-utils.mjs';
15
+ import { COMMANDS } from './commands/schema.mjs';
16
+
17
+ const { command, subcommand, flags, positionals } = parseArgs(process.argv);
18
+
19
+ // ===== Help 快速路由 =====
20
+ if (flags.help && !command) {
21
+ const { showHelp } = await import('./commands/help.mjs');
22
+ showHelp(flags); process.exit(0);
23
+ }
24
+ if (flags.help && command) {
25
+ const { showCommandHelp } = await import('./commands/help.mjs');
26
+ showCommandHelp(command); process.exit(0);
27
+ }
28
+
29
+ // ===== 指令路由 =====
30
+ switch (command) {
31
+ case 'doc': {
32
+ const { cmdDocSplit, cmdDocText, cmdDocIngest } = await import('./commands/doc.mjs');
33
+ switch (subcommand) {
34
+ case 'split': await cmdDocSplit(positionals, flags); break;
35
+ case 'text': await cmdDocText(positionals, flags); break;
36
+ case 'ingest': await cmdDocIngest(positionals, flags); break;
37
+ default: {
38
+ const { showCommandHelp } = await import('./commands/help.mjs');
39
+ if (!subcommand) showCommandHelp('doc');
40
+ else { process.stderr.write(`doc: 未知子指令 ${subcommand}\n`); process.exit(1); }
41
+ }
42
+ }
43
+ break;
44
+ }
45
+
46
+ case 'writing': {
47
+ const { cmdWritingGenerate } = await import('./commands/writing.mjs');
48
+ switch (subcommand) {
49
+ case 'generate': await cmdWritingGenerate(flags); break;
50
+ default: {
51
+ const { showCommandHelp } = await import('./commands/help.mjs');
52
+ if (!subcommand) showCommandHelp('writing');
53
+ else { process.stderr.write(`writing: 未知子指令 ${subcommand}\n`); process.exit(1); }
54
+ }
55
+ }
56
+ break;
57
+ }
58
+
59
+ case 'gemini': {
60
+ const { cmdGeminiAsk } = await import('./commands/gemini.mjs');
61
+ switch (subcommand) {
62
+ case 'ask': await cmdGeminiAsk(positionals, flags); break;
63
+ default: {
64
+ const { showCommandHelp } = await import('./commands/help.mjs');
65
+ if (!subcommand) showCommandHelp('gemini');
66
+ else { process.stderr.write(`gemini: 未知子指令 ${subcommand}\n`); process.exit(1); }
67
+ }
68
+ }
69
+ break;
70
+ }
71
+
72
+ case 'nchc': {
73
+ const { cmdNchcAsk, cmdNchcAgent, cmdNchcCode, cmdNchcModels } = await import('./commands/nchc.mjs');
74
+ switch (subcommand) {
75
+ case 'ask': await cmdNchcAsk(positionals, flags); break;
76
+ case 'agent': await cmdNchcAgent(positionals, flags); break;
77
+ case 'code': await cmdNchcCode(positionals, flags); break;
78
+ case 'models': cmdNchcModels(flags); break;
79
+ default: {
80
+ const { showCommandHelp } = await import('./commands/help.mjs');
81
+ if (!subcommand) showCommandHelp('nchc');
82
+ else { process.stderr.write(`nchc: 未知子指令 ${subcommand}\n`); process.exit(1); }
83
+ }
84
+ }
85
+ break;
86
+ }
87
+
88
+ case 'openrouter': {
89
+ const { cmdOpenrouterAsk, cmdOpenrouterModels } = await import('./commands/openrouter.mjs');
90
+ switch (subcommand) {
91
+ case 'ask': await cmdOpenrouterAsk(positionals, flags); break;
92
+ case 'models': cmdOpenrouterModels(flags); break;
93
+ default: {
94
+ const { showCommandHelp } = await import('./commands/help.mjs');
95
+ if (!subcommand) showCommandHelp('openrouter');
96
+ else { process.stderr.write(`openrouter: 未知子指令 ${subcommand}\n`); process.exit(1); }
97
+ }
98
+ }
99
+ break;
100
+ }
101
+
102
+ case 'legal': {
103
+ const { cmdLegalAsk, cmdLegalCacheList, cmdLegalCacheStats } = await import('./commands/legal.mjs');
104
+ switch (subcommand) {
105
+ case 'ask': await cmdLegalAsk(positionals, flags); break;
106
+ case 'cache': {
107
+ // legal cache {list|stats}
108
+ const cacheSubcmd = positionals[0];
109
+ switch (cacheSubcmd) {
110
+ case 'list': await cmdLegalCacheList(flags); break;
111
+ case 'stats': await cmdLegalCacheStats(flags); break;
112
+ default: {
113
+ process.stderr.write(`legal cache: 未知子指令 ${cacheSubcmd}(可用: list, stats)\n`);
114
+ process.exit(1);
115
+ }
116
+ }
117
+ break;
118
+ }
119
+ default: {
120
+ const { showCommandHelp } = await import('./commands/help.mjs');
121
+ if (!subcommand) showCommandHelp('legal');
122
+ else { process.stderr.write(`legal: 未知子指令 ${subcommand}\n`); process.exit(1); }
123
+ }
124
+ }
125
+ break;
126
+ }
127
+
128
+ case 'dag': {
129
+ const { cmdDag } = await import('./commands/dag-cmd.mjs');
130
+ await cmdDag(subcommand, flags);
131
+ break;
132
+ }
133
+
134
+ case 'search': {
135
+ const { cmdSearch } = await import('./commands/search.mjs');
136
+ await cmdSearch(subcommand, positionals, flags);
137
+ break;
138
+ }
139
+
140
+ case 'db': {
141
+ const { cmdDb } = await import('./commands/db-cmd.mjs');
142
+ await cmdDb(subcommand, flags);
143
+ break;
144
+ }
145
+
146
+ case 'commands': {
147
+ const format = flags.format || 'json';
148
+ if (format === 'json') {
149
+ process.stdout.write(JSON.stringify({
150
+ _meta: { tool: 'bh', version: VERSION, description: 'botrun-horse 多專案文件處理系統' },
151
+ commands: COMMANDS,
152
+ }, null, 2) + '\n');
153
+ } else {
154
+ process.stdout.write(`botrun-horse v${VERSION} — 可用指令:\n\n`);
155
+ for (const [name, cmd] of Object.entries(COMMANDS)) {
156
+ const sqlite = cmd.requires_sqlite ? ' [需 --experimental-sqlite]' : '';
157
+ process.stdout.write(` ${name.padEnd(12)} ${cmd.description}${sqlite}\n`);
158
+ }
159
+ process.stdout.write(`\n各指令皆支援 --help、--format=json、--project=<名稱>、--quiet、--print0\n`);
160
+ }
161
+ break;
162
+ }
163
+
164
+ case 'prompt': {
165
+ const { cmdPrompt } = await import('./commands/prompt.mjs');
166
+ await cmdPrompt(subcommand, positionals, flags);
167
+ break;
168
+ }
169
+
170
+ case 'portal':
171
+ process.stdout.write('portal 模組尚在開發中\n');
172
+ break;
173
+
174
+ case 'ocr':
175
+ process.stdout.write('ocr 模組尚在開發中\n');
176
+ break;
177
+
178
+ case 'help':
179
+ case '--help':
180
+ case '-h':
181
+ case undefined: {
182
+ const { showHelp } = await import('./commands/help.mjs');
183
+ showHelp(flags);
184
+ break;
185
+ }
186
+
187
+ default: {
188
+ process.stderr.write(`未知指令: ${command}\n`);
189
+ const { showHelp } = await import('./commands/help.mjs');
190
+ showHelp();
191
+ process.exit(1);
192
+ }
193
+ }
@@ -0,0 +1,74 @@
1
+ // bin/commands/dag-cmd.mjs — dag 指令群組(init / status / ready / list)
2
+ import { timer, jsonOut, emitError } from '../../lib/core/cli-utils.mjs';
3
+
4
+ export async function cmdDag(subcommand, flags) {
5
+ if (!subcommand) {
6
+ const { showCommandHelp } = await import('./help.mjs');
7
+ showCommandHelp('dag'); return;
8
+ }
9
+ const {
10
+ loadDag, loadState, saveState, initState,
11
+ getReady, formatStatus,
12
+ } = await import('../../lib/core/dag.mjs');
13
+ const format = flags.format || 'text';
14
+ const project = flags.project || 'nstc';
15
+
16
+ switch (subcommand) {
17
+ case 'init': {
18
+ const { documents } = loadDag(project);
19
+ const state = initState(documents);
20
+ saveState(state, project);
21
+ if (format === 'json') {
22
+ process.stdout.write(jsonOut('dag init', { initialized: documents.length, project }, timer()()));
23
+ } else {
24
+ process.stdout.write(`已初始化 ${documents.length} 個文件任務的狀態(專案:${project})\n`);
25
+ }
26
+ break;
27
+ }
28
+ case 'status': {
29
+ const state = loadState(project);
30
+ if (!state) { emitError('dag status', `尚未初始化。請先執行: bh dag init --project=${project}`, format); process.exit(1); }
31
+ if (format === 'json') {
32
+ const all = Object.values(state);
33
+ const summary = {
34
+ total: all.length,
35
+ done: all.filter(t => t.status === 'done').length,
36
+ pending: all.filter(t => t.status === 'pending').length,
37
+ running: all.filter(t => t.status === 'running').length,
38
+ failed: all.filter(t => t.status === 'failed').length,
39
+ };
40
+ process.stdout.write(jsonOut('dag status', summary, timer()()));
41
+ } else {
42
+ process.stdout.write(formatStatus(state) + '\n');
43
+ }
44
+ break;
45
+ }
46
+ case 'ready': {
47
+ const state = loadState(project);
48
+ if (!state) { emitError('dag ready', `尚未初始化。請先執行: bh dag init --project=${project}`, format); process.exit(1); }
49
+ const ready = getReady(state);
50
+ if (format === 'json') {
51
+ process.stdout.write(jsonOut('dag ready', { count: ready.length, tasks: ready }, timer()()));
52
+ } else {
53
+ for (const t of ready) process.stdout.write(`${t.id}\t${t.type}\t${t.title}\n`);
54
+ }
55
+ break;
56
+ }
57
+ case 'list': {
58
+ const state = loadState(project);
59
+ if (!state) { emitError('dag list', `尚未初始化。請先執行: bh dag init --project=${project}`, format); process.exit(1); }
60
+ if (format === 'json') {
61
+ process.stdout.write(jsonOut('dag list', { count: Object.keys(state).length, tasks: Object.values(state) }, timer()()));
62
+ } else {
63
+ for (const t of Object.values(state)) {
64
+ const icon = { done: '✅', running: '🔄', pending: '⏳', failed: '❌' }[t.status] || '?';
65
+ process.stdout.write(`${icon}\t${t.id}\t${t.type}\t${t.title}\t${t.status}\n`);
66
+ }
67
+ }
68
+ break;
69
+ }
70
+ default:
71
+ emitError('dag', `未知子指令: ${subcommand}。可用: init, status, ready, list`, format);
72
+ process.exit(1);
73
+ }
74
+ }
@@ -0,0 +1,73 @@
1
+ // bin/commands/db-cmd.mjs — db 指令群組(stats / docs / pages)
2
+ import * as paths from '../../lib/core/paths.mjs';
3
+ import { timer, jsonOut, emitError } from '../../lib/core/cli-utils.mjs';
4
+
5
+ export async function cmdDb(subcommand, flags) {
6
+ if (!subcommand) {
7
+ const { showCommandHelp } = await import('./help.mjs');
8
+ showCommandHelp('db'); return;
9
+ }
10
+ const { DocStore } = await import('../../lib/core/db.mjs');
11
+ const project = flags.project || 'nstc';
12
+ const dbPathVal = flags.db || paths.dbPath(project);
13
+ const format = flags.format || 'text';
14
+ const elapsed = timer();
15
+
16
+ const store = new DocStore(dbPathVal);
17
+ store.initSchema();
18
+
19
+ switch (subcommand) {
20
+ case 'stats': {
21
+ const s = store.stats();
22
+ if (format === 'json') {
23
+ process.stdout.write(jsonOut('db stats', s, elapsed()));
24
+ } else {
25
+ process.stdout.write(`========== DB 統計 ==========\n`);
26
+ process.stdout.write(`文件數: ${s.documents}\n`);
27
+ process.stdout.write(`頁面數: ${s.pages}\n`);
28
+ process.stdout.write(`總字元: ${s.totalChars.toLocaleString()}\n`);
29
+ process.stdout.write(`各類型:\n`);
30
+ for (const t of s.byType) process.stdout.write(` ${t.doc_type}: ${t.count}\n`);
31
+ process.stdout.write(`============================\n`);
32
+ }
33
+ break;
34
+ }
35
+ case 'docs': {
36
+ const docs = store.db.prepare('SELECT * FROM documents ORDER BY id').all();
37
+ if (format === 'json') {
38
+ process.stdout.write(jsonOut('db docs', { count: docs.length, documents: docs }, elapsed()));
39
+ } else {
40
+ for (const d of docs) {
41
+ process.stdout.write(`${d.id}\t${d.doc_type || '-'}\t${d.total_pages}頁\t${d.filename}\t${d.title || '-'}\n`);
42
+ }
43
+ }
44
+ break;
45
+ }
46
+ case 'pages': {
47
+ const args = process.argv.slice(2);
48
+ const docId = args[2] && !args[2].startsWith('--') ? parseInt(args[2]) : null;
49
+ if (!docId) {
50
+ emitError('db pages', '請提供文件 ID: bh db pages <doc_id>', format);
51
+ process.exit(1);
52
+ }
53
+ const pages = store.getPages(docId);
54
+ if (pages.length === 0) {
55
+ emitError('db pages', `找不到 doc_id=${docId} 的頁面資料`, format);
56
+ process.exit(1);
57
+ }
58
+ if (format === 'json') {
59
+ process.stdout.write(jsonOut('db pages', { doc_id: docId, count: pages.length, pages }, elapsed()));
60
+ } else {
61
+ for (const p of pages) {
62
+ process.stdout.write(`${p.page_number}\t${p.char_count}字\t${p.source_file}:${p.source_page}\n`);
63
+ }
64
+ }
65
+ break;
66
+ }
67
+ default:
68
+ emitError('db', `未知子指令: ${subcommand}。可用: stats, docs, pages`, format);
69
+ process.exit(1);
70
+ }
71
+
72
+ store.close();
73
+ }
@@ -0,0 +1,185 @@
1
+ // bin/commands/doc.mjs — doc 指令群組(split / text / ingest)
2
+ import path from 'path';
3
+ import * as paths from '../../lib/core/paths.mjs';
4
+ import {
5
+ resolveFiles, parallelBatch, timer, jsonOut, emitError,
6
+ } from '../../lib/core/cli-utils.mjs';
7
+
8
+ export async function cmdDocSplit(positionals, flags) {
9
+ const elapsed = timer();
10
+ const format = flags.format || 'text';
11
+ const project = flags.project || 'nstc';
12
+ const { splitPdf } = await import('../../lib/doc/split.mjs');
13
+ const outdir = flags.outdir || path.join(paths.projectOutput(project), 'split');
14
+ const concurrency = parseInt(flags.concurrency) || 5;
15
+ const files = await resolveFiles(positionals, flags);
16
+
17
+ if (files.length === 0) {
18
+ emitError('doc split', '請提供 PDF 檔案路徑、--dir 或 stdin pipe', format);
19
+ process.exit(1);
20
+ }
21
+
22
+ let ok = 0, fail = 0;
23
+ const allPages = [];
24
+ const errors = [];
25
+
26
+ const results = await parallelBatch(files, concurrency, async (f) => {
27
+ const baseName = path.basename(f, '.pdf');
28
+ const pageDir = path.join(outdir, baseName);
29
+ const pages = await splitPdf(f, pageDir);
30
+ return { file: f, pages };
31
+ });
32
+
33
+ for (let i = 0; i < results.length; i++) {
34
+ const r = results[i];
35
+ const f = files[i];
36
+ if (r.status === 'fulfilled') {
37
+ ok++;
38
+ for (const p of r.value.pages) {
39
+ if (format === 'json') {
40
+ allPages.push({ file: path.basename(f), page: p.page, path: p.path });
41
+ } else {
42
+ process.stdout.write(`${p.page}\t${p.path}\t${path.basename(f)}\n`);
43
+ }
44
+ }
45
+ } else {
46
+ fail++;
47
+ errors.push({ file: path.basename(f), error: r.reason?.message });
48
+ if (format !== 'json') {
49
+ process.stderr.write(`FAIL\t${path.basename(f)}\t${r.reason?.message}\n`);
50
+ }
51
+ }
52
+ }
53
+
54
+ if (format === 'json') {
55
+ process.stdout.write(jsonOut('doc split', { ok, fail, total_pages: allPages.length, results: allPages, errors }, elapsed()));
56
+ } else {
57
+ if (!flags.quiet) process.stderr.write(`拆頁完成: ${ok} 成功, ${fail} 失敗 (${elapsed()}s)\n`);
58
+ }
59
+ }
60
+
61
+ export async function cmdDocText(positionals, flags) {
62
+ const elapsed = timer();
63
+ const format = flags.format || 'text';
64
+ const { extractAllPages, extractPage } = await import('../../lib/doc/pdf2text.mjs');
65
+ const files = await resolveFiles(positionals, flags);
66
+ const pageNum = flags.page ? parseInt(flags.page) : null;
67
+ const concurrency = parseInt(flags.concurrency) || 5;
68
+
69
+ if (files.length === 0) {
70
+ emitError('doc text', '請提供 PDF 檔案路徑、--dir 或 stdin pipe', format);
71
+ process.exit(1);
72
+ }
73
+
74
+ let ok = 0, fail = 0;
75
+
76
+ const results = await parallelBatch(files, concurrency, async (f) => {
77
+ const filename = path.basename(f);
78
+ if (pageNum) {
79
+ const result = await extractPage(f, pageNum);
80
+ return { filename, pages: [result] };
81
+ } else {
82
+ const pages = await extractAllPages(f);
83
+ return { filename, pages };
84
+ }
85
+ });
86
+
87
+ for (let i = 0; i < results.length; i++) {
88
+ const r = results[i];
89
+ if (r.status === 'fulfilled') {
90
+ ok++;
91
+ const { filename, pages } = r.value;
92
+ for (const p of pages) {
93
+ if (format === 'json') {
94
+ process.stdout.write(JSON.stringify({ file: filename, page: p.page, chars: p.text.length, text: p.text }) + '\n');
95
+ } else {
96
+ process.stdout.write(`### ${filename} 第 ${p.page} 頁 (${p.text.length} 字) ###\n${p.text}\n`);
97
+ }
98
+ }
99
+ } else {
100
+ fail++;
101
+ if (format !== 'json') {
102
+ process.stderr.write(`FAIL\t${path.basename(files[i])}\t${r.reason?.message}\n`);
103
+ }
104
+ }
105
+ }
106
+
107
+ if (!flags.quiet) {
108
+ process.stderr.write(format === 'json'
109
+ ? JSON.stringify({ _summary: { ok, fail, elapsed: elapsed() } }) + '\n'
110
+ : `轉文字完成: ${ok} 成功, ${fail} 失敗 (${elapsed()}s)\n`);
111
+ }
112
+ }
113
+
114
+ export async function cmdDocIngest(positionals, flags) {
115
+ const elapsed = timer();
116
+ const format = flags.format || 'text';
117
+ const project = flags.project || 'nstc';
118
+ const { ingestOne, ingestDir, ingestBatch } = await import('../../lib/doc/index.mjs');
119
+ const dbPathVal = flags.db || paths.dbPath(project);
120
+ const concurrency = parseInt(flags.concurrency) || 5;
121
+ const recursive = !!flags.recursive;
122
+ const force = !!flags.force;
123
+ const splitPdfFlag = !!flags['split-pdf'];
124
+
125
+ const splitDir = splitPdfFlag
126
+ ? (flags.splitdir || path.join(paths.projectOutput(project), 'split'))
127
+ : null;
128
+
129
+ const opts = { dbPath: dbPathVal, splitDir, force };
130
+
131
+ const dirsList = flags.dirs
132
+ ? flags.dirs.split(',').map(d => d.trim()).filter(Boolean)
133
+ : flags.dir
134
+ ? [flags.dir]
135
+ : null;
136
+
137
+ if (dirsList && dirsList.length > 0) {
138
+ const result = await ingestDir(dirsList, opts, concurrency, recursive);
139
+ if (format === 'json') {
140
+ process.stdout.write(jsonOut('doc ingest', {
141
+ ok: result.ok, fail: result.fail, skip: result.skip, results: result.results,
142
+ }, elapsed()));
143
+ } else {
144
+ if (!flags.quiet) process.stderr.write(
145
+ `匯入完成: ${result.ok} 成功, ${result.fail} 失敗, ${result.skip} 跳過 (${elapsed()}s)\n`
146
+ );
147
+ }
148
+ return;
149
+ }
150
+
151
+ const files = await resolveFiles(positionals, flags);
152
+ if (files.length === 0) {
153
+ emitError('doc ingest', '請提供檔案路徑、--dir、--dirs 或 stdin pipe', format);
154
+ process.exit(1);
155
+ }
156
+
157
+ if (files.length === 1) {
158
+ try {
159
+ const result = await ingestOne(files[0], opts);
160
+ const status = result.skipped ? 'SKIP' : 'OK';
161
+ if (format === 'json') {
162
+ process.stdout.write(jsonOut('doc ingest', {
163
+ ok: result.skipped ? 0 : 1, fail: 0, skip: result.skipped ? 1 : 0,
164
+ results: [{ file: path.basename(files[0]), docId: result.docId, pages: result.pages, status }],
165
+ }, elapsed()));
166
+ } else {
167
+ process.stdout.write(`${status}\t${path.basename(files[0])}\tdoc_id=${result.docId}\t${result.pages}頁\n`);
168
+ }
169
+ } catch (err) {
170
+ emitError('doc ingest', err.message, format);
171
+ process.exit(1);
172
+ }
173
+ } else {
174
+ const result = await ingestBatch(files, opts, concurrency);
175
+ if (format === 'json') {
176
+ process.stdout.write(jsonOut('doc ingest', {
177
+ ok: result.ok, fail: result.fail, skip: result.skip, results: result.results,
178
+ }, elapsed()));
179
+ } else {
180
+ if (!flags.quiet) process.stderr.write(
181
+ `匯入完成: ${result.ok} 成功, ${result.fail} 失敗, ${result.skip} 跳過 (${elapsed()}s)\n`
182
+ );
183
+ }
184
+ }
185
+ }
@@ -0,0 +1,120 @@
1
+ // bin/commands/gemini.mjs — gemini 指令群組(ask)
2
+ import { timer, emitError, readStdin } from '../../lib/core/cli-utils.mjs';
3
+
4
+ export async function cmdGeminiAsk(positionals, flags) {
5
+ const elapsed = timer();
6
+ const format = flags.format || 'text';
7
+
8
+ let prompt = positionals.join(' ');
9
+ if (!prompt || prompt === '-') prompt = await readStdin();
10
+ if (!prompt || !prompt.trim()) {
11
+ emitError('gemini ask', '請提供提問內容。用法: bh gemini ask "你的問題"', format);
12
+ process.exit(1);
13
+ }
14
+
15
+ const providerFlag = flags.provider || 'auto';
16
+ let provider;
17
+ if (providerFlag === 'auto') {
18
+ if (process.env.GOOGLE_CLOUD_PROJECT || flags['gcp-project']) {
19
+ provider = 'gemini';
20
+ } else if (process.env.GEMINI_API_KEY) {
21
+ provider = 'gemini-api';
22
+ } else {
23
+ emitError('gemini ask',
24
+ '無法自動偵測 LLM provider。請設定以下任一環境變數:\n' +
25
+ ' export GOOGLE_CLOUD_PROJECT=your-gcp-project # Vertex AI\n' +
26
+ ' export GEMINI_API_KEY=your-api-key # Direct API\n' +
27
+ '或使用 --provider=gemini-api / --provider=gemini 明確指定',
28
+ format);
29
+ process.exit(1);
30
+ }
31
+ } else {
32
+ provider = providerFlag;
33
+ }
34
+
35
+ const askParams = {
36
+ provider,
37
+ prompt: prompt.trim(),
38
+ systemInstruction: flags['system-prompt'] || undefined,
39
+ thinking: flags.thinking || 'MEDIUM',
40
+ grounding: !flags['no-grounding'],
41
+ urlContext: !flags['no-url-context'],
42
+ project: flags['gcp-project'] || undefined,
43
+ location: flags['gcp-location'] || undefined,
44
+ };
45
+
46
+ try {
47
+ if (flags['no-stream']) {
48
+ const { geminiAsk, formatTextOutput, formatJsonOutput } = await import('../../lib/flows/gemini-ask.mjs');
49
+ const result = await geminiAsk(askParams);
50
+ if (format === 'json') {
51
+ process.stdout.write(formatJsonOutput(result, 'gemini-3-flash-preview', elapsed()) + '\n');
52
+ } else {
53
+ process.stdout.write(formatTextOutput(result) + '\n');
54
+ const p = result.perf || {};
55
+ process.stderr.write(
56
+ `\n[gemini-3-flash-preview] thinking=${flags.thinking || 'MEDIUM'} ` +
57
+ `引證=${result.sources.length}筆 ` +
58
+ `${p.outputTokensPerSec || '?'}tok/s ` +
59
+ `${p.latencySec || '?'}s ` +
60
+ `(${result.usage.totalTokens}tok)\n`
61
+ );
62
+ }
63
+ } else {
64
+ const { geminiAskStream, formatJsonOutput } = await import('../../lib/flows/gemini-ask.mjs');
65
+ const stream = geminiAskStream(askParams);
66
+ let fullText = '';
67
+ let meta = null;
68
+ const isTTY = process.stderr.isTTY;
69
+ const t0 = performance.now();
70
+ let thinkingStarted = false;
71
+ let textStarted = false;
72
+
73
+ if (isTTY && !flags.quiet) process.stderr.write('\x1b[90m⏳ 等待 Gemini 回應中...\x1b[0m');
74
+
75
+ for await (const chunk of stream) {
76
+ const sec = ((performance.now() - t0) / 1000).toFixed(1);
77
+ if (chunk.type === 'thinking') {
78
+ if (!thinkingStarted && isTTY && !flags.quiet) {
79
+ process.stderr.write(`\r\x1b[K\x1b[90m💭 [${sec}s] 思考中...\x1b[0m\n`);
80
+ thinkingStarted = true;
81
+ }
82
+ if (format === 'text' && !flags.quiet) process.stderr.write(`\x1b[2m${chunk.text}\x1b[0m`);
83
+ } else if (chunk.type === 'text') {
84
+ if (!textStarted && isTTY && !flags.quiet) {
85
+ if (thinkingStarted) process.stderr.write('\n');
86
+ process.stderr.write(`\x1b[90m▶ [${sec}s] 串流回應中...\x1b[0m\n`);
87
+ textStarted = true;
88
+ }
89
+ if (format === 'text') process.stdout.write(chunk.text);
90
+ fullText += chunk.text;
91
+ } else if (chunk.type === 'metadata') {
92
+ meta = chunk;
93
+ }
94
+ }
95
+
96
+ if (format === 'json') {
97
+ const result = meta || {};
98
+ result.text = fullText;
99
+ process.stdout.write(formatJsonOutput(result, 'gemini-3-flash-preview', elapsed()) + '\n');
100
+ } else {
101
+ process.stdout.write('\n');
102
+ if (meta?.sources?.length > 0) {
103
+ process.stdout.write('\n--- 引證來源 ---\n');
104
+ for (const src of meta.sources) {
105
+ process.stdout.write(` [${src.title || src.domain}] ${src.uri}\n`);
106
+ }
107
+ }
108
+ const p = meta?.perf || {};
109
+ if (!flags.quiet) process.stderr.write(
110
+ `\n[gemini-3-flash-preview] thinking=${flags.thinking || 'MEDIUM'} ` +
111
+ `TTFT=${p.ttftSec ?? '?'}s 引證=${meta?.sources?.length || 0}筆 ` +
112
+ `${p.outputTokensPerSec || '?'}tok/s ${p.latencySec || '?'}s (${meta?.usage?.totalTokens || '?'}tok)\n`
113
+ );
114
+ }
115
+ }
116
+ } catch (err) {
117
+ emitError('gemini ask', err.message, format);
118
+ process.exit(1);
119
+ }
120
+ }