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
@@ -0,0 +1,109 @@
1
+ // bin/commands/help.mjs — 說明輸出(showHelp / showCommandHelp)
2
+ import { parseArgs, VERSION } from '../../lib/core/cli-utils.mjs';
3
+ import { COMMANDS } from './schema.mjs';
4
+
5
+ export function showCommandHelp(cmdName) {
6
+ const cmd = COMMANDS[cmdName];
7
+ if (!cmd) return;
8
+
9
+ const { flags } = parseArgs(process.argv);
10
+ if (flags.format === 'json') {
11
+ process.stdout.write(JSON.stringify({ _meta: { tool: 'bh', version: VERSION }, command: cmd }, null, 2) + '\n');
12
+ return;
13
+ }
14
+
15
+ const lines = [];
16
+ lines.push(`${cmd.name} — ${cmd.description}`);
17
+ lines.push('');
18
+ if (cmd.requires_sqlite) {
19
+ lines.push('注意: 需要 --experimental-sqlite flag');
20
+ lines.push('');
21
+ }
22
+ lines.push('用法:');
23
+ const prefix = cmd.requires_sqlite ? 'node --experimental-sqlite bin/bh.mjs' : 'node bin/bh.mjs';
24
+ if (cmd.subcommands) {
25
+ lines.push(` ${prefix} ${cmd.name} <子指令> [選項]`);
26
+ lines.push('');
27
+ lines.push('子指令:');
28
+ for (const [sub, desc] of Object.entries(cmd.subcommands)) {
29
+ lines.push(` ${sub.padEnd(20)} ${desc}`);
30
+ }
31
+ }
32
+ if (cmd.options && Object.keys(cmd.options).length > 0) {
33
+ lines.push('');
34
+ lines.push('選項:');
35
+ for (const [opt, info] of Object.entries(cmd.options)) {
36
+ const def = info.default !== undefined ? ` (預設: ${info.default})` : '';
37
+ lines.push(` ${opt.padEnd(24)} ${info.description}${def}`);
38
+ }
39
+ }
40
+ if (cmd.examples) {
41
+ lines.push('');
42
+ lines.push('範例:');
43
+ for (const ex of cmd.examples) lines.push(` ${ex}`);
44
+ }
45
+ lines.push('');
46
+ process.stdout.write(lines.join('\n') + '\n');
47
+ }
48
+
49
+ export function showHelp(flags = {}) {
50
+ if (flags.format === 'json') {
51
+ process.stdout.write(JSON.stringify({
52
+ _meta: { tool: 'bh', version: VERSION, description: 'botrun-horse 多專案文件處理系統' },
53
+ commands: COMMANDS,
54
+ global_options: {
55
+ '--help': '顯示說明',
56
+ '--project=<名稱>': '指定專案(預設: nstc)',
57
+ '--format=json|text': '輸出格式',
58
+ '--concurrency=N': '平行處理數量',
59
+ '--quiet': '靜默模式(不輸出進度到 stderr)',
60
+ '--print0': 'NUL 分隔路徑輸出(與 xargs -0 相容)',
61
+ },
62
+ }, null, 2) + '\n');
63
+ return;
64
+ }
65
+
66
+ process.stdout.write(`
67
+ botrun-horse — 多專案文件處理系統 CLI v${VERSION}
68
+ ===================================================
69
+
70
+ 用法:
71
+ node bin/bh.mjs <指令群組> [子指令] [選項]
72
+ node --experimental-sqlite bin/bh.mjs <指令> (SQLite 相關功能)
73
+
74
+ 指令群組:
75
+ doc <子指令> 文件處理 (split|text|ingest)
76
+ writing <子指令> 公文撰寫 (generate)
77
+ gemini <子指令> Gemini AI 提問 (ask) — thinking + grounding + url context
78
+ nchc <子指令> 國網 GenAI 提問 (ask|agent|code|models) — Mistral 系列
79
+ dag <子指令> DAG 依賴追蹤 (init|status|ready|list)
80
+ search <子指令> 全文檢索 (query) [需 --experimental-sqlite]
81
+ db <子指令> 資料庫管理 (stats|docs|pages) [需 --experimental-sqlite]
82
+ prompt [關鍵字] 提示詞管理(無參數=全列出,帶關鍵字=模糊搜尋)
83
+ portal <子指令> 智慧入口 (預留)
84
+ ocr <子指令> OCR 辨識 (預留)
85
+ commands 所有指令的結構化 JSON schema
86
+
87
+ 共通選項:
88
+ --help 顯示說明
89
+ --project=<名稱> 指定專案(預設: nstc)
90
+ --format=json|text 輸出格式
91
+ --concurrency=N 平行處理數量
92
+ --quiet 靜默模式(不輸出進度到 stderr,GNU parallel 友善)
93
+ --print0 NUL 分隔路徑輸出(與 xargs -0 相容)
94
+
95
+ 組合範例:
96
+ # GNU parallel 批次處理
97
+ find . -name "*.pdf" -print0 | xargs -0 -P 8 node bin/bh.mjs doc text --quiet
98
+
99
+ # 文件處理(PDF + Office 全系列)
100
+ node --experimental-sqlite bin/bh.mjs doc ingest --dir=pdfs/ --project=nstc
101
+
102
+ # Gemini AI 提問
103
+ node bin/bh.mjs gemini ask "量子糾纏的原理是什麼?"
104
+ echo "解釋 AI" | node bin/bh.mjs gemini ask --format=json
105
+
106
+ # AI/LLM schema 探索
107
+ node bin/bh.mjs commands --format=json | jq 'keys'
108
+ `);
109
+ }
@@ -0,0 +1,174 @@
1
+ // bin/commands/legal.mjs — legal 指令群組(ask / cache / stats)
2
+ //
3
+ // 設計原則:
4
+ // - Unix pipe 友善:支援 stdin 輸入、stdout 輸出、--quiet 靜默模式
5
+ // - KISS:指令層只做 I/O 與格式化,業務邏輯委派至 lib/flows/legal-ask.mjs
6
+ // - DDD:projects/legal-cache/config.json 為薄殼配置層
7
+ //
8
+ // 用法:
9
+ // node --experimental-sqlite bin/bh.mjs legal ask "問題" [--project=legal-cache]
10
+ // node --experimental-sqlite bin/bh.mjs legal cache list [--project=legal-cache]
11
+ // node --experimental-sqlite bin/bh.mjs legal cache stats [--project=legal-cache]
12
+ // node --experimental-sqlite bin/bh.mjs legal cache clear [--project=legal-cache]
13
+
14
+ import { timer, emitError, readStdin } from '../../lib/core/cli-utils.mjs';
15
+ import { projectConfig as resolveConfigPath, dbPath as resolveDbPath } from '../../lib/core/paths.mjs';
16
+ import fs from 'fs';
17
+
18
+ // ── 輔助:載入專案 config.json ──────────────────────────────────────────────
19
+
20
+ /**
21
+ * 讀取專案 config.json(不存在時回傳空物件)
22
+ * @param {string} project - 專案名稱
23
+ */
24
+ function loadConfig(project) {
25
+ const cfgPath = resolveConfigPath(project);
26
+ if (!fs.existsSync(cfgPath)) return {};
27
+ try { return JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch { return {}; }
28
+ }
29
+
30
+ // ─────────────────────────────────────────────────────────────────────────────
31
+
32
+ /**
33
+ * legal ask — 法律查詢(AI Router + 兩步驟提示工程 + SQLite 快取)
34
+ *
35
+ * @param {string[]} positionals - 位置參數(問題文字)
36
+ * @param {object} flags - 解析後的旗標
37
+ */
38
+ export async function cmdLegalAsk(positionals, flags) {
39
+ const elapsed = timer();
40
+ const format = flags.format || 'text';
41
+ const project = flags.project || 'legal-cache';
42
+
43
+ // ── 讀取問題(positionals 或 stdin pipe)──
44
+ let question = positionals.join(' ');
45
+ if (!question || question === '-') question = await readStdin();
46
+ if (!question || !question.trim()) {
47
+ emitError('legal ask', '請提供法律問題。用法: bh legal ask "房東可以隨意趕走我嗎?"', format);
48
+ process.exit(1);
49
+ }
50
+
51
+ // ── 讀取專案配置 ──
52
+ const config = loadConfig(project);
53
+
54
+ if (!flags.quiet) {
55
+ process.stderr.write(`\x1b[90m⏳ 法律查詢中(專案: ${project})...\x1b[0m\n`);
56
+ }
57
+
58
+ try {
59
+ const { legalAsk, formatLegalResult, formatLegalResultJson } = await import('../../lib/flows/legal-ask.mjs');
60
+
61
+ const result = await legalAsk({
62
+ question: question.trim(),
63
+ project,
64
+ config,
65
+ skipCache: !!flags['skip-cache'],
66
+ skipSave: !!flags['skip-save'],
67
+ });
68
+
69
+ if (format === 'json') {
70
+ process.stdout.write(formatLegalResultJson(result) + '\n');
71
+ } else {
72
+ process.stdout.write(formatLegalResult(result) + '\n');
73
+ }
74
+
75
+ if (!flags.quiet) {
76
+ const fromCache = result.fromCache ? '✓ 快取命中' : '→ 大模型搜尋';
77
+ process.stderr.write(
78
+ `\n[${fromCache}] 總耗時=${result.perf.totalMs}ms 標籤=${result.tags.join('、')}\n`
79
+ );
80
+ }
81
+ } catch (err) {
82
+ emitError('legal ask', err.message, format);
83
+ process.exit(1);
84
+ }
85
+ }
86
+
87
+ /**
88
+ * legal cache list — 列出快取問答
89
+ * @param {object} flags
90
+ */
91
+ export async function cmdLegalCacheList(flags) {
92
+ const format = flags.format || 'text';
93
+ const project = flags.project || 'legal-cache';
94
+ const limit = flags.limit ? parseInt(flags.limit) : 20;
95
+
96
+ try {
97
+ const { AiCache } = await import('../../lib/core/ai-cache.mjs');
98
+ const dbPath = resolveDbPath(project);
99
+
100
+ if (!fs.existsSync(dbPath)) {
101
+ process.stdout.write('快取資料庫尚未建立(尚無快取問答)\n');
102
+ return;
103
+ }
104
+
105
+ const cache = new AiCache(dbPath);
106
+ cache.initSchema();
107
+ const entries = cache.listAll(limit);
108
+ cache.close();
109
+
110
+ if (format === 'json') {
111
+ process.stdout.write(JSON.stringify({ entries }, null, 2) + '\n');
112
+ } else {
113
+ if (entries.length === 0) {
114
+ process.stdout.write('快取為空(尚無問答紀錄)\n');
115
+ return;
116
+ }
117
+ process.stdout.write(`快取問答列表(最近 ${entries.length} 筆):\n\n`);
118
+ for (const e of entries) {
119
+ process.stdout.write(
120
+ ` [${String(e.id).padStart(4)}] 命中=${e.hitCount} 標籤=[${e.tags.join('、')}]\n` +
121
+ ` 問題:${e.question.slice(0, 60)}\n`
122
+ );
123
+ }
124
+ }
125
+ } catch (err) {
126
+ emitError('legal cache list', err.message, format);
127
+ process.exit(1);
128
+ }
129
+ }
130
+
131
+ /**
132
+ * legal cache stats — 顯示快取統計
133
+ * @param {object} flags
134
+ */
135
+ export async function cmdLegalCacheStats(flags) {
136
+ const format = flags.format || 'text';
137
+ const project = flags.project || 'legal-cache';
138
+
139
+ try {
140
+ const { AiCache } = await import('../../lib/core/ai-cache.mjs');
141
+ const dbPath = resolveDbPath(project);
142
+
143
+ if (!fs.existsSync(dbPath)) {
144
+ process.stdout.write('快取資料庫尚未建立\n');
145
+ return;
146
+ }
147
+
148
+ const cache = new AiCache(dbPath);
149
+ cache.initSchema();
150
+ const stats = cache.stats();
151
+ cache.close();
152
+
153
+ if (format === 'json') {
154
+ process.stdout.write(JSON.stringify(stats, null, 2) + '\n');
155
+ } else {
156
+ process.stdout.write('── 法律快取統計 ──────────────────────────────\n');
157
+ process.stdout.write(` 已快取問答:${stats.cachedQuestions} 筆\n`);
158
+ process.stdout.write(` 總快取命中:${stats.totalCacheHits} 次\n`);
159
+ process.stdout.write('\n 路由決策分布:\n');
160
+ for (const d of stats.routerDecisions) {
161
+ process.stdout.write(` ${d.decision}: ${d.count} 次\n`);
162
+ }
163
+ if (stats.topHitQuestions.length > 0) {
164
+ process.stdout.write('\n 最常命中問題(Top 5):\n');
165
+ for (const q of stats.topHitQuestions) {
166
+ process.stdout.write(` [命中 ${q.hit_count} 次] ${q.question.slice(0, 50)}\n`);
167
+ }
168
+ }
169
+ }
170
+ } catch (err) {
171
+ emitError('legal cache stats', err.message, format);
172
+ process.exit(1);
173
+ }
174
+ }
@@ -0,0 +1,212 @@
1
+ // bin/commands/nchc.mjs — nchc 指令群組(ask / agent / code / models)
2
+ import { timer, emitError, readStdin } from '../../lib/core/cli-utils.mjs';
3
+
4
+ export async function cmdNchcAsk(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('nchc ask', '請提供提問內容。用法: bh nchc ask "你的問題"', format);
12
+ process.exit(1);
13
+ }
14
+
15
+ const { NchcAdapter } = await import('../../lib/core/adapters/nchc.mjs');
16
+ const adapter = new NchcAdapter({
17
+ model: flags.model || 'Mistral-Large-3-675B-Instruct-2512',
18
+ maxTokens: parseInt(flags['max-tokens']) || 8192,
19
+ temperature: parseFloat(flags.temperature) ?? 0.7,
20
+ });
21
+
22
+ const askParams = { prompt: prompt.trim(), systemInstruction: flags['system-prompt'] || undefined };
23
+
24
+ try {
25
+ if (flags['no-stream']) {
26
+ const result = await adapter.generateContent(askParams);
27
+ if (format === 'json') {
28
+ process.stdout.write(JSON.stringify({
29
+ _meta: { command: 'nchc ask', model: result.model, elapsed: elapsed() },
30
+ text: result.text, usage: result.usage, perf: result.perf,
31
+ }, null, 2) + '\n');
32
+ } else {
33
+ process.stdout.write(result.text + '\n');
34
+ if (!flags.quiet) process.stderr.write(
35
+ `\n[${result.model || adapter.model}] ${result.perf?.outputTokensPerSec || '?'}tok/s ` +
36
+ `${result.perf?.latencySec || '?'}s (${result.usage?.totalTokens || '?'}tok)\n`
37
+ );
38
+ }
39
+ } else {
40
+ const stream = adapter.generateContentStream(askParams);
41
+ let meta = null;
42
+ const isTTY = process.stderr.isTTY;
43
+ const t0 = performance.now();
44
+ let textStarted = false;
45
+
46
+ if (isTTY && !flags.quiet) process.stderr.write('\x1b[90m⏳ 等待國網 GenAI 回應中...\x1b[0m');
47
+
48
+ for await (const chunk of stream) {
49
+ const sec = ((performance.now() - t0) / 1000).toFixed(1);
50
+ if (chunk.type === 'text') {
51
+ if (!textStarted && isTTY && !flags.quiet) {
52
+ process.stderr.write(`\r\x1b[K\x1b[90m▶ [${sec}s] 串流回應中...\x1b[0m\n`);
53
+ textStarted = true;
54
+ }
55
+ if (format === 'text') process.stdout.write(chunk.text);
56
+ } else if (chunk.type === 'metadata') {
57
+ meta = chunk;
58
+ }
59
+ }
60
+
61
+ if (format === 'json') {
62
+ const result = meta || {};
63
+ process.stdout.write(JSON.stringify({
64
+ _meta: { command: 'nchc ask', model: result.model || adapter.model, elapsed: elapsed() },
65
+ text: result.text, usage: result.usage, perf: result.perf,
66
+ }, null, 2) + '\n');
67
+ } else {
68
+ process.stdout.write('\n');
69
+ const p = meta?.perf || {};
70
+ if (!flags.quiet) process.stderr.write(
71
+ `\n[${meta?.model || adapter.model}] TTFT=${p.ttftSec ?? '?'}s ` +
72
+ `${p.outputTokensPerSec || '?'}tok/s ${p.latencySec || '?'}s\n`
73
+ );
74
+ }
75
+ }
76
+ } catch (err) {
77
+ emitError('nchc ask', err.message, format);
78
+ process.exit(1);
79
+ }
80
+ }
81
+
82
+ export async function cmdNchcAgent(positionals, flags) {
83
+ const format = flags.format || 'text';
84
+
85
+ let task = positionals.join(' ');
86
+ if (!task || task === '-') task = await readStdin();
87
+ if (!task || !task.trim()) {
88
+ emitError('nchc agent', '請提供任務描述。用法: bh nchc agent "你的任務"', format);
89
+ process.exit(1);
90
+ }
91
+
92
+ const { NchcAgent } = await import('../../lib/flows/openai-agent.mjs');
93
+ const agent = new NchcAgent({
94
+ model: flags.model || 'Mistral-Large-3-675B-Instruct-2512',
95
+ maxTurns: parseInt(flags['max-turns']) || 20,
96
+ systemPrompt: flags['system-prompt'] || undefined,
97
+ cwd: process.cwd(),
98
+ });
99
+
100
+ const isTTY = process.stderr.isTTY;
101
+
102
+ try {
103
+ if (isTTY && !flags.quiet) process.stderr.write(`\x1b[90m🤖 [nchc agent] 啟動代理任務...\x1b[0m\n`);
104
+
105
+ for await (const event of agent.run(task.trim())) {
106
+ if (event.type === 'tool_call') {
107
+ if (format === 'text' && isTTY && !flags.quiet) {
108
+ process.stderr.write(`\x1b[33m🔧 [工具呼叫] ${event.name}(${JSON.stringify(event.args)})\x1b[0m\n`);
109
+ } else if (format === 'json') {
110
+ process.stdout.write(JSON.stringify({ event: 'tool_call', name: event.name, args: event.args }) + '\n');
111
+ }
112
+ } else if (event.type === 'tool_result') {
113
+ if (format === 'text' && isTTY && !flags.quiet) {
114
+ const preview = event.result.length > 120 ? event.result.slice(0, 120) + '...' : event.result;
115
+ process.stderr.write(`\x1b[32m✅ [工具結果] ${event.name}: ${preview}\x1b[0m\n`);
116
+ } else if (format === 'json') {
117
+ process.stdout.write(JSON.stringify({ event: 'tool_result', name: event.name, result: event.result }) + '\n');
118
+ }
119
+ } else if (event.type === 'text') {
120
+ if (format === 'text') {
121
+ if (isTTY && !flags.quiet) process.stderr.write('\x1b[90m▶ 最終回應:\x1b[0m\n');
122
+ process.stdout.write(event.text + '\n');
123
+ }
124
+ } else if (event.type === 'done') {
125
+ const turns = event.turns;
126
+ if (format === 'json') {
127
+ process.stdout.write(JSON.stringify({
128
+ _meta: { command: 'nchc agent', model: agent.model, turns },
129
+ text: event.text,
130
+ }, null, 2) + '\n');
131
+ } else if (isTTY && !flags.quiet) {
132
+ process.stderr.write(`\x1b[90m[完成] ${turns} 輪對話\x1b[0m\n`);
133
+ }
134
+ }
135
+ }
136
+ } catch (err) {
137
+ emitError('nchc agent', err.message, format);
138
+ process.exit(1);
139
+ }
140
+ }
141
+
142
+ export async function cmdNchcCode(positionals, flags) {
143
+ const format = flags.format || 'text';
144
+
145
+ let task = positionals.join(' ');
146
+ if (!task || task === '-') task = await readStdin();
147
+ if (!task || !task.trim()) {
148
+ emitError('nchc code', '請提供任務描述。用法: bh nchc code "你的 coding 任務"', format);
149
+ process.exit(1);
150
+ }
151
+
152
+ const { OpencodeAgent } = await import('../../lib/flows/opencode-agent.mjs');
153
+ const agent = new OpencodeAgent({
154
+ modelID: flags.model || 'Mistral-Large-3-675B-Instruct-2512',
155
+ serverUrl: flags['server-url'] || undefined,
156
+ port: parseInt(flags.port) || 14096,
157
+ cwd: process.cwd(),
158
+ });
159
+
160
+ const isTTY = process.stderr.isTTY;
161
+
162
+ try {
163
+ if (isTTY && !flags.quiet) process.stderr.write(`\x1b[90m🤖 [nchc code] 啟動 OpenCode Agentic Coding...\x1b[0m\n`);
164
+
165
+ let finalText = '';
166
+
167
+ for await (const event of agent.run(task.trim())) {
168
+ if (event.type === 'session_created') {
169
+ if (isTTY && !flags.quiet) process.stderr.write(`\x1b[90m📋 [Session] ${event.sessionID}\x1b[0m\n`);
170
+ else if (format === 'json') process.stdout.write(JSON.stringify({ event: 'session_created', sessionID: event.sessionID }) + '\n');
171
+ } else if (event.type === 'delta') {
172
+ if (format === 'text') process.stdout.write(event.text);
173
+ } else if (event.type === 'text') {
174
+ finalText = event.text;
175
+ if (format === 'text' && !finalText.endsWith('\n')) process.stdout.write('\n');
176
+ } else if (event.type === 'done') {
177
+ if (format === 'json') {
178
+ process.stdout.write(JSON.stringify({
179
+ _meta: { command: 'nchc code', model: agent.modelID, timedOut: event.timedOut },
180
+ text: event.text,
181
+ }, null, 2) + '\n');
182
+ } else if (isTTY && !flags.quiet) {
183
+ process.stderr.write(`\x1b[90m${event.timedOut ? '⚠️ 逾時' : '✅ 完成'}\x1b[0m\n`);
184
+ }
185
+ }
186
+ }
187
+ } catch (err) {
188
+ if (err.message.includes('opencode') || err.message.includes('spawn')) {
189
+ emitError('nchc code', `OpenCode CLI 未安裝或無法啟動。\n請執行:npm install -g opencode-ai\n原始錯誤:${err.message}`, format);
190
+ } else {
191
+ emitError('nchc code', err.message, format);
192
+ }
193
+ process.exit(1);
194
+ }
195
+ }
196
+
197
+ export function cmdNchcModels(flags) {
198
+ const format = flags.format || 'text';
199
+ const models = [
200
+ { id: 'Mistral-Large-3-675B-Instruct-2512', description: '最強旗艦模型(675B),適合複雜分析、長文生成' },
201
+ { id: 'Devstral-2-123B-Instruct-2512', description: '程式碼專精模型(123B),適合程式生成、技術文件' },
202
+ { id: 'Ministral-3-14B-Instruct-2512', description: '輕量快速模型(14B),適合簡單問答、快速測試' },
203
+ ];
204
+ if (format === 'json') {
205
+ process.stdout.write(JSON.stringify({ models }, null, 2) + '\n');
206
+ } else {
207
+ process.stdout.write('國網 GenAI 可用模型:\n\n');
208
+ for (const m of models) process.stdout.write(` ${m.id}\n ${m.description}\n\n`);
209
+ process.stdout.write('環境變數: NCHC_GENAI_API_KEY\n');
210
+ process.stdout.write('端點: https://portal.genai.nchc.org.tw/api/v1\n');
211
+ }
212
+ }
@@ -0,0 +1,154 @@
1
+ // bin/commands/openrouter.mjs — openrouter 指令群組(ask / models)
2
+ //
3
+ // 設計原則:
4
+ // - Unix pipe 友善:支援 stdin 輸入、stdout 輸出、--quiet 靜默模式
5
+ // - GNU parallel 友善:--quiet 關閉 stderr 進度、--format=json 結構化輸出
6
+ // - Agentic AI 友善:--format=json 回傳結構化 LlmResponse(含 sources、usage、perf)
7
+ // - KISS:指令層只做 I/O 與格式化,業務邏輯委派至 adapter
8
+
9
+ import { timer, emitError, readStdin } from '../../lib/core/cli-utils.mjs';
10
+
11
+ /**
12
+ * openrouter ask — 向 OpenRouter 任意模型提問
13
+ * 支援網路搜尋(--web-search)、串流(預設)、JSON 輸出
14
+ *
15
+ * @param {string[]} positionals - 命令列位置參數(提問文字)
16
+ * @param {object} flags - 解析後的旗標
17
+ */
18
+ export async function cmdOpenrouterAsk(positionals, flags) {
19
+ const elapsed = timer();
20
+ const format = flags.format || 'text';
21
+
22
+ // ── 讀取提問(positionals 或 stdin pipe)──
23
+ let prompt = positionals.join(' ');
24
+ if (!prompt || prompt === '-') prompt = await readStdin();
25
+ if (!prompt || !prompt.trim()) {
26
+ emitError('openrouter ask', '請提供提問內容。用法: bh openrouter ask "你的問題"', format);
27
+ process.exit(1);
28
+ }
29
+
30
+ // ── 組裝 adapter(直接使用,不經 createLLM factory,保留完整選項控制)──
31
+ const { OpenRouterAdapter } = await import('../../lib/core/adapters/openrouter.mjs');
32
+ const adapter = new OpenRouterAdapter({
33
+ model: flags.model || undefined, // 預設由 adapter DEFAULTS 決定
34
+ maxTokens: flags['max-tokens'] ? parseInt(flags['max-tokens']) : undefined,
35
+ temperature: flags.temperature ? parseFloat(flags.temperature) : undefined,
36
+ webSearch: !!flags['web-search'],
37
+ maxResults: flags['max-results'] ? parseInt(flags['max-results']) : undefined,
38
+ });
39
+
40
+ const askParams = {
41
+ prompt: prompt.trim(),
42
+ systemInstruction: flags['system-prompt'] || undefined,
43
+ };
44
+
45
+ try {
46
+ if (flags['no-stream']) {
47
+ // ── 非串流模式:等待完整回應 ──
48
+ const result = await adapter.generateContent(askParams);
49
+ if (format === 'json') {
50
+ const { formatJsonOutput } = await import('../../lib/flows/openrouter-ask.mjs');
51
+ process.stdout.write(formatJsonOutput(result, adapter.model, elapsed()) + '\n');
52
+ } else {
53
+ const { formatTextOutput } = await import('../../lib/flows/openrouter-ask.mjs');
54
+ process.stdout.write(formatTextOutput(result) + '\n');
55
+ if (!flags.quiet) {
56
+ const p = result.perf || {};
57
+ process.stderr.write(
58
+ `\n[${result.model}] 引證=${result.sources.length}筆 ` +
59
+ `${p.outputTokensPerSec || '?'}tok/s ${p.latencySec || '?'}s (${result.usage.totalTokens}tok)\n`
60
+ );
61
+ }
62
+ }
63
+ } else {
64
+ // ── 串流模式:即時輸出(預設)──
65
+ const stream = adapter.generateContentStream(askParams);
66
+ let meta = null;
67
+ const isTTY = process.stderr.isTTY;
68
+ const t0 = performance.now();
69
+ let textStarted = false;
70
+
71
+ if (isTTY && !flags.quiet) {
72
+ process.stderr.write(`\x1b[90m⏳ [${adapter.model}] 等待回應中...\x1b[0m`);
73
+ }
74
+
75
+ for await (const chunk of stream) {
76
+ const sec = ((performance.now() - t0) / 1000).toFixed(1);
77
+ if (chunk.type === 'text') {
78
+ if (!textStarted && isTTY && !flags.quiet) {
79
+ process.stderr.write(`\r\x1b[K\x1b[90m▶ [${sec}s] 串流回應中...\x1b[0m\n`);
80
+ textStarted = true;
81
+ }
82
+ if (format === 'text') process.stdout.write(chunk.text);
83
+ } else if (chunk.type === 'metadata') {
84
+ meta = chunk;
85
+ }
86
+ }
87
+
88
+ if (format === 'json') {
89
+ const { formatJsonOutput } = await import('../../lib/flows/openrouter-ask.mjs');
90
+ process.stdout.write(formatJsonOutput(meta || {}, adapter.model, elapsed()) + '\n');
91
+ } else {
92
+ process.stdout.write('\n');
93
+ // 引證來源(網路搜尋時)
94
+ if (meta?.sources?.length > 0) {
95
+ process.stdout.write('\n--- 引證來源 ---\n');
96
+ for (const src of meta.sources) {
97
+ process.stdout.write(` [${src.title || src.uri}] ${src.uri}\n`);
98
+ }
99
+ }
100
+ if (!flags.quiet) {
101
+ const p = meta?.perf || {};
102
+ process.stderr.write(
103
+ `\n[${meta?.model || adapter.model}] 引證=${meta?.sources?.length || 0}筆 ` +
104
+ `TTFT=${p.ttftSec ?? '?'}s ${p.outputTokensPerSec || '?'}tok/s ` +
105
+ `${p.latencySec || '?'}s (${meta?.usage?.totalTokens || '?'}tok)\n`
106
+ );
107
+ }
108
+ }
109
+ }
110
+ } catch (err) {
111
+ emitError('openrouter ask', err.message, format);
112
+ process.exit(1);
113
+ }
114
+ }
115
+
116
+ /**
117
+ * openrouter models — 列出常用 OpenRouter 模型(供參考)
118
+ * OpenRouter 支援 400+ 模型,此處僅列出常用代表性型號
119
+ *
120
+ * @param {object} flags
121
+ */
122
+ export function cmdOpenrouterModels(flags) {
123
+ const format = flags.format || 'text';
124
+ const models = [
125
+ // OpenAI 系列
126
+ { id: 'openai/gpt-4o', description: 'GPT-4o(旗艦,多模態)' },
127
+ { id: 'openai/gpt-4o-mini', description: 'GPT-4o Mini(輕量快速,預設)' },
128
+ { id: 'openai/o3-mini', description: 'o3-mini(推理專精)' },
129
+ // Anthropic 系列
130
+ { id: 'anthropic/claude-opus-4', description: 'Claude Opus 4(最強推理)' },
131
+ { id: 'anthropic/claude-sonnet-4-6', description: 'Claude Sonnet 4.6(平衡)' },
132
+ // Google 系列
133
+ { id: 'google/gemini-2.5-pro', description: 'Gemini 2.5 Pro(長文脈)' },
134
+ { id: 'google/gemini-2.0-flash-001', description: 'Gemini 2.0 Flash(快速)' },
135
+ // Meta 系列
136
+ { id: 'meta-llama/llama-3.3-70b-instruct', description: 'Llama 3.3 70B(開源最強)' },
137
+ // 網路搜尋模型(:online 後綴)
138
+ { id: 'openai/gpt-4o:online', description: 'GPT-4o + 網路搜尋(Exa)' },
139
+ { id: 'perplexity/sonar-pro', description: 'Perplexity Sonar Pro(原生網路搜尋)' },
140
+ ];
141
+
142
+ if (format === 'json') {
143
+ process.stdout.write(JSON.stringify({ models }, null, 2) + '\n');
144
+ } else {
145
+ process.stdout.write('OpenRouter 常用模型(完整列表:https://openrouter.ai/models):\n\n');
146
+ for (const m of models) {
147
+ process.stdout.write(` ${m.id.padEnd(48)} ${m.description}\n`);
148
+ }
149
+ process.stdout.write('\n提示:任何 model 名稱加上 :online 後綴可啟用網路搜尋\n');
150
+ process.stdout.write('提示:使用 --web-search 旗標透過 plugins 啟用 Exa 搜尋(適用所有模型)\n');
151
+ process.stdout.write('\n環境變數: OPENROUTER_API_KEY\n');
152
+ process.stdout.write('端點: https://openrouter.ai/api/v1\n');
153
+ }
154
+ }