kc-beta 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Retry wrapper with exponential backoff and jitter.
3
+ * Designed for LLM API calls — retries transient errors, fails fast on auth/validation errors.
4
+ */
5
+
6
+ const MAX_RETRIES = 10;
7
+ const INITIAL_DELAY_MS = 1000;
8
+ const MAX_DELAY_MS = 60000;
9
+ const BACKOFF_MULTIPLIER = 2;
10
+ const JITTER_FRACTION = 0.2;
11
+
12
+ const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504, 520, 522, 524]);
13
+ const NON_RETRYABLE_STATUS = new Set([400, 401, 403, 404, 422]);
14
+
15
+ /**
16
+ * Determine if an error is retryable.
17
+ * @param {Error} err
18
+ * @returns {boolean}
19
+ */
20
+ function isRetryable(err) {
21
+ if (err.status) {
22
+ if (NON_RETRYABLE_STATUS.has(err.status)) return false;
23
+ if (RETRYABLE_STATUS.has(err.status)) return true;
24
+ }
25
+ // Network errors (ECONNRESET, ETIMEDOUT, fetch TypeError, AbortError)
26
+ const msg = err.message || "";
27
+ if (/ECONNRESET|ETIMEDOUT|ENOTFOUND|ECONNREFUSED|UND_ERR|fetch failed|network|socket hang up/i.test(msg)) {
28
+ return true;
29
+ }
30
+ if (err.name === "AbortError" || err.name === "TimeoutError") return true;
31
+ // If we have a status code and it's not in our known sets, retry server errors (5xx)
32
+ if (err.status && err.status >= 500) return true;
33
+ // Unknown errors without status — retry conservatively
34
+ return !err.status;
35
+ }
36
+
37
+ /**
38
+ * Calculate delay for a given attempt using exponential backoff with jitter.
39
+ * @param {number} attempt - 0-indexed attempt number
40
+ * @param {number|null} retryAfterSec - Retry-After header value in seconds
41
+ * @returns {number} Delay in milliseconds
42
+ */
43
+ function calculateDelay(attempt, retryAfterSec) {
44
+ if (retryAfterSec && retryAfterSec > 0) {
45
+ return Math.min(retryAfterSec * 1000, MAX_DELAY_MS);
46
+ }
47
+ const base = Math.min(INITIAL_DELAY_MS * Math.pow(BACKOFF_MULTIPLIER, attempt), MAX_DELAY_MS);
48
+ const jitter = base * JITTER_FRACTION * Math.random();
49
+ return base + jitter;
50
+ }
51
+
52
+ /**
53
+ * Execute an async function with retry logic.
54
+ *
55
+ * @param {() => Promise<any>} fn - The async function to execute. Should throw with
56
+ * an error that has `.status` and optionally `.retryAfter` properties on failure.
57
+ * @returns {Promise<any>} The successful result
58
+ * @throws {Error} The last error after all retries exhausted, or a non-retryable error immediately
59
+ */
60
+ export async function withRetry(fn) {
61
+ let lastError;
62
+
63
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
64
+ try {
65
+ return await fn();
66
+ } catch (err) {
67
+ lastError = err;
68
+
69
+ if (!isRetryable(err)) throw err;
70
+ if (attempt === MAX_RETRIES) break;
71
+
72
+ const retryAfterSec = err.retryAfter ? parseFloat(err.retryAfter) : null;
73
+ const delay = calculateDelay(attempt, retryAfterSec);
74
+
75
+ await new Promise((resolve) => setTimeout(resolve, delay));
76
+ }
77
+ }
78
+
79
+ const wrapper = new Error(`LLM API call failed after ${MAX_RETRIES + 1} attempts: ${lastError.message}`);
80
+ wrapper.cause = lastError;
81
+ wrapper.status = lastError.status;
82
+ throw wrapper;
83
+ }
@@ -0,0 +1,78 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * Persists session state (phase, pipeline milestones, phase summaries)
6
+ * to enable cross-session resume.
7
+ *
8
+ * Stored as: workspace/{sessionId}/session-state.json
9
+ */
10
+ export class SessionState {
11
+ /**
12
+ * @param {string} workspacePath - Session workspace directory
13
+ */
14
+ constructor(workspacePath) {
15
+ this._path = path.join(workspacePath, "session-state.json");
16
+ }
17
+
18
+ /** Whether a session state file exists */
19
+ get exists() {
20
+ return fs.existsSync(this._path);
21
+ }
22
+
23
+ /**
24
+ * Save engine state to disk.
25
+ * @param {import('./engine.js').AgentEngine} engine
26
+ */
27
+ save(engine) {
28
+ const state = {
29
+ version: 1,
30
+ sessionId: engine.workspace.sessionId,
31
+ currentPhase: engine.currentPhase,
32
+ phaseSummaries: engine._phaseSummaries || [],
33
+ lastEventSeq: engine.eventLog?.currentSeq || 0,
34
+ createdAt: this._loadRaw()?.createdAt || new Date().toISOString(),
35
+ updatedAt: new Date().toISOString(),
36
+ pipelineMilestones: this._extractMilestones(engine.pipelines),
37
+ };
38
+
39
+ fs.writeFileSync(this._path, JSON.stringify(state, null, 2), "utf-8");
40
+ }
41
+
42
+ /**
43
+ * Load session state from disk.
44
+ * @returns {object} The persisted state
45
+ */
46
+ load() {
47
+ return this._loadRaw() || {};
48
+ }
49
+
50
+ /**
51
+ * Read raw file contents.
52
+ */
53
+ _loadRaw() {
54
+ if (!this.exists) return null;
55
+ try {
56
+ return JSON.parse(fs.readFileSync(this._path, "utf-8"));
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Serialize pipeline milestones for persistence.
64
+ * @param {object} pipelines - Map of phase -> pipeline instance
65
+ * @returns {object}
66
+ */
67
+ _extractMilestones(pipelines) {
68
+ const milestones = {};
69
+ for (const [phase, pipeline] of Object.entries(pipelines)) {
70
+ if (pipeline?.exportState) {
71
+ try {
72
+ milestones[phase] = pipeline.exportState();
73
+ } catch { /* skip if not implemented */ }
74
+ }
75
+ }
76
+ return milestones;
77
+ }
78
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Lightweight token estimation without external dependencies.
3
+ * Uses character-based heuristics: ~4 chars per token for Latin text,
4
+ * ~1.5 tokens per CJK character.
5
+ */
6
+
7
+ // CJK Unified Ideographs and extensions
8
+ const CJK_REGEX = /[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/g;
9
+
10
+ /**
11
+ * Estimate the number of tokens in a string.
12
+ * @param {string} text
13
+ * @returns {number}
14
+ */
15
+ export function estimateTokens(text) {
16
+ if (!text) return 0;
17
+ const cjkMatches = text.match(CJK_REGEX);
18
+ const cjkCount = cjkMatches ? cjkMatches.length : 0;
19
+ const nonCjkLength = text.length - cjkCount;
20
+ return Math.ceil(nonCjkLength / 4) + Math.ceil(cjkCount * 1.5);
21
+ }
22
+
23
+ /**
24
+ * Estimate total tokens for an array of OpenAI-format messages.
25
+ * Accounts for per-message overhead (~4 tokens for role/formatting).
26
+ * @param {Array<object>} messages
27
+ * @returns {number}
28
+ */
29
+ export function estimateMessagesTokens(messages) {
30
+ let total = 0;
31
+ for (const msg of messages) {
32
+ total += 4; // role + formatting overhead
33
+ if (typeof msg.content === "string") {
34
+ total += estimateTokens(msg.content);
35
+ } else if (Array.isArray(msg.content)) {
36
+ // Anthropic-style content blocks
37
+ for (const block of msg.content) {
38
+ if (block.text) total += estimateTokens(block.text);
39
+ if (block.content) total += estimateTokens(block.content);
40
+ }
41
+ }
42
+ if (msg.tool_calls) {
43
+ for (const tc of msg.tool_calls) {
44
+ total += estimateTokens(tc.function?.name || "");
45
+ total += estimateTokens(tc.function?.arguments || "");
46
+ }
47
+ }
48
+ }
49
+ return total;
50
+ }
51
+
52
+ /**
53
+ * Format a token count for display (e.g., "45.2k").
54
+ * @param {number} tokens
55
+ * @returns {string}
56
+ */
57
+ export function formatTokenCount(tokens) {
58
+ if (tokens >= 1000) {
59
+ return (tokens / 1000).toFixed(1) + "k";
60
+ }
61
+ return tokens.toString();
62
+ }
@@ -12,13 +12,13 @@ const MIN_CHARS_PER_PAGE = 50;
12
12
  * Level 3: OCR models via SiliconFlow — fallback via vision models
13
13
  */
14
14
  export class DocumentParseTool extends BaseTool {
15
- constructor(workspace, { mineruApiUrl, mineruApiKey, siliconflowApiKey, siliconflowBaseUrl, ocrModel } = {}) {
15
+ constructor(workspace, { mineruApiUrl, mineruApiKey, llmApiKey, llmBaseUrl, siliconflowApiKey, siliconflowBaseUrl, ocrModel } = {}) {
16
16
  super();
17
17
  this._workspace = workspace;
18
18
  this._mineruApiUrl = mineruApiUrl || "";
19
19
  this._mineruApiKey = mineruApiKey || "";
20
- this._sfApiKey = siliconflowApiKey || "";
21
- this._sfBaseUrl = siliconflowBaseUrl || "https://api.siliconflow.cn/v1";
20
+ this._sfApiKey = llmApiKey || siliconflowApiKey || "";
21
+ this._sfBaseUrl = llmBaseUrl || siliconflowBaseUrl || "https://api.siliconflow.cn/v1";
22
22
  this._ocrModel = ocrModel || "";
23
23
  }
24
24
 
@@ -0,0 +1,107 @@
1
+ import { BaseTool, ToolResult } from "./base.js";
2
+
3
+ /**
4
+ * Web search via Tavily API.
5
+ * Returns extracted text content from search results.
6
+ */
7
+ export class WebSearchTool extends BaseTool {
8
+ /**
9
+ * @param {string} apiKey - Tavily API key
10
+ */
11
+ constructor(apiKey) {
12
+ super();
13
+ this._apiKey = apiKey;
14
+ }
15
+
16
+ get name() { return "web_search"; }
17
+
18
+ get description() {
19
+ return (
20
+ "Search the web for information using Tavily. Returns extracted text from top results. " +
21
+ "IMPORTANT: Always prioritize information from user-provided domain documents " +
22
+ "(uploaded regulations, sample files, workspace documents) over web search results. " +
23
+ "Use web search only when: (1) the needed information is not in provided documents, " +
24
+ "(2) you need to verify or supplement document content with external sources, or " +
25
+ "(3) the user explicitly asks for web information (e.g., latest LLM model info, API docs)."
26
+ );
27
+ }
28
+
29
+ get inputSchema() {
30
+ return {
31
+ type: "object",
32
+ properties: {
33
+ query: {
34
+ type: "string",
35
+ description: "The search query",
36
+ },
37
+ search_depth: {
38
+ type: "string",
39
+ enum: ["basic", "advanced"],
40
+ description: "Search depth: 'basic' for fast results, 'advanced' for more thorough search (default: basic)",
41
+ },
42
+ max_results: {
43
+ type: "integer",
44
+ description: "Maximum number of results to return (default: 5, max: 10)",
45
+ },
46
+ },
47
+ required: ["query"],
48
+ };
49
+ }
50
+
51
+ async execute(input) {
52
+ const query = input.query || "";
53
+ if (!query.trim()) {
54
+ return new ToolResult("No query provided", true);
55
+ }
56
+
57
+ if (!this._apiKey) {
58
+ return new ToolResult(
59
+ "Web search is not configured. Set TAVILY_API_KEY in your .env file or global config.",
60
+ true,
61
+ );
62
+ }
63
+
64
+ const searchDepth = input.search_depth || "basic";
65
+ const maxResults = Math.min(input.max_results || 5, 10);
66
+
67
+ try {
68
+ const resp = await fetch("https://api.tavily.com/search", {
69
+ method: "POST",
70
+ headers: { "Content-Type": "application/json" },
71
+ body: JSON.stringify({
72
+ api_key: this._apiKey,
73
+ query,
74
+ search_depth: searchDepth,
75
+ max_results: maxResults,
76
+ }),
77
+ signal: AbortSignal.timeout(15000),
78
+ });
79
+
80
+ if (!resp.ok) {
81
+ const text = await resp.text();
82
+ return new ToolResult(`Tavily API error ${resp.status}: ${text}`, true);
83
+ }
84
+
85
+ const data = await resp.json();
86
+ const results = data.results || [];
87
+
88
+ if (results.length === 0) {
89
+ return new ToolResult(`No results found for: ${query}`);
90
+ }
91
+
92
+ const lines = [];
93
+ for (const r of results) {
94
+ lines.push(`--- ${r.title || "Untitled"} ---`);
95
+ lines.push(`URL: ${r.url || ""}`);
96
+ lines.push(r.content || "(no content)");
97
+ lines.push("");
98
+ }
99
+
100
+ return new ToolResult(
101
+ `Found ${results.length} result(s) for "${query}":\n\n${lines.join("\n")}`,
102
+ );
103
+ } catch (err) {
104
+ return new ToolResult(`Web search failed: ${err.message}`, true);
105
+ }
106
+ }
107
+ }
@@ -8,15 +8,27 @@ import { BaseTool, ToolResult } from "./base.js";
8
8
  * the configured API provider.
9
9
  */
10
10
  export class WorkerLLMCallTool extends BaseTool {
11
- constructor(workspace, { apiKey, baseUrl } = {}) {
11
+ constructor(workspace, { apiKey, baseUrl, authType = "bearer" } = {}) {
12
12
  super();
13
13
  this._workspace = workspace;
14
14
  this._apiKey = apiKey || "";
15
15
  this._baseUrl = (baseUrl || "https://api.siliconflow.cn/v1").replace(/\/+$/, "");
16
+ this._authType = authType;
16
17
  this._tierModels = {};
17
18
  this._loadTiers();
18
19
  }
19
20
 
21
+ _buildHeaders() {
22
+ const headers = { "Content-Type": "application/json" };
23
+ if (this._authType === "x-api-key") {
24
+ headers["x-api-key"] = this._apiKey;
25
+ headers["anthropic-version"] = "2023-06-01";
26
+ } else {
27
+ headers["Authorization"] = `Bearer ${this._apiKey}`;
28
+ }
29
+ return headers;
30
+ }
31
+
20
32
  _loadTiers() {
21
33
  const envPath = path.join(this._workspace.cwd, ".env");
22
34
  if (!fs.existsSync(envPath)) return;
@@ -78,10 +90,7 @@ export class WorkerLLMCallTool extends BaseTool {
78
90
  try {
79
91
  const resp = await fetch(`${this._baseUrl}/chat/completions`, {
80
92
  method: "POST",
81
- headers: {
82
- "Authorization": `Bearer ${this._apiKey}`,
83
- "Content-Type": "application/json",
84
- },
93
+ headers: this._buildHeaders(),
85
94
  body: JSON.stringify({ model, messages, max_tokens: maxTokens }),
86
95
  signal: AbortSignal.timeout(120000),
87
96
  });
@@ -12,7 +12,7 @@ const COOKING_WORDS = [
12
12
  "Stewing", "Tempering", "Whisking", "Zesting", "Garnishing", "Drizzling",
13
13
  ];
14
14
 
15
- export function CookingSpinner() {
15
+ export function CookingSpinner({ status }) {
16
16
  const [idx, setIdx] = useState(Math.floor(Math.random() * COOKING_WORDS.length));
17
17
 
18
18
  useEffect(() => {
@@ -20,9 +20,11 @@ export function CookingSpinner() {
20
20
  return () => clearInterval(timer);
21
21
  }, []);
22
22
 
23
+ const displayText = status || `${COOKING_WORDS[idx]}...`;
24
+
23
25
  return h(Box, null,
24
26
  h(Text, { color: "yellow" }, " * "),
25
- h(Text, { dimColor: true }, `${COOKING_WORDS[idx]}...`),
27
+ h(Text, { dimColor: true }, displayText),
26
28
  );
27
29
  }
28
30
 
@@ -30,13 +32,23 @@ export function CookingSpinner() {
30
32
 
31
33
  const LENAT_QUOTE = "Intelligence is ten million rules.";
32
34
 
33
- export function StatusBar({ sessionId, phase }) {
35
+ export function StatusBar({ sessionId, phase, contextTokens, contextLimit }) {
36
+ const pct = contextLimit ? Math.round((contextTokens / contextLimit) * 100) : 0;
37
+ const ctxColor = pct > 80 ? "red" : pct > 60 ? "yellow" : "green";
38
+ const ctxLabel = contextTokens >= 1000
39
+ ? `${(contextTokens / 1000).toFixed(1)}k`
40
+ : `${contextTokens || 0}`;
41
+ const limitLabel = contextLimit >= 1000
42
+ ? `${(contextLimit / 1000).toFixed(0)}k`
43
+ : `${contextLimit || 0}`;
44
+
34
45
  return h(Box, { marginTop: 0 },
35
46
  h(Text, { dimColor: true }, " ⏵⏵ KC Agent CLI "),
36
47
  h(Text, { dimColor: true }, sessionId ? `[${sessionId}]` : ""),
37
48
  phase ? h(Text, { color: "cyan" }, ` ${phase.toUpperCase()}`) : null,
38
49
  h(Text, { color: "green" }, " ● "),
39
- h(Text, { dimColor: true }, ${LENAT_QUOTE}`),
50
+ h(Text, { color: ctxColor }, `CTX: ${ctxLabel}/${limitLabel} (${pct}%)`),
51
+ h(Text, { dimColor: true }, ` · ${LENAT_QUOTE}`),
40
52
  );
41
53
  }
42
54
 
@@ -0,0 +1,246 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import readline from "node:readline";
4
+ import os from "node:os";
5
+ import { getProviders, getProviderById, getProviderLabels } from "../providers.js";
6
+
7
+ const CONFIG_DIR = path.join(os.homedir(), ".kc_agent");
8
+ const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
9
+
10
+ const ESC = "\x1b[";
11
+ const RESET = `${ESC}0m`;
12
+ const BOLD = `${ESC}1m`;
13
+ const DIM = `${ESC}2m`;
14
+ const GREEN = `${ESC}32m`;
15
+ const CYAN = `${ESC}36m`;
16
+ const GRAY = `${ESC}90m`;
17
+ const YELLOW = `${ESC}33m`;
18
+ const RED = `${ESC}31m`;
19
+
20
+ const L = {
21
+ en: {
22
+ title: "KC Agent Configuration",
23
+ noConfig: "No config found. Run 'kc-beta onboard' first.",
24
+ menu: "Configuration Categories",
25
+ choose: "Choose category (q to quit)",
26
+ categories: ["LLM Provider & API Key", "Model Tiers", "Quality Thresholds", "Language"],
27
+ saved: "Saved.",
28
+ back: "← Back to menu",
29
+ enterKeep: "Press Enter to keep",
30
+ enterDefault: "Press Enter to use default",
31
+ currentValue: "current",
32
+ provider: "Provider",
33
+ baseUrl: "Base URL",
34
+ apiKey: "API Key",
35
+ conductor: "Conductor Model",
36
+ language: "Language",
37
+ langOptions: ["English", "中文"],
38
+ },
39
+ zh: {
40
+ title: "KC Agent 配置",
41
+ noConfig: "未找到配置。请先运行 'kc-beta onboard'。",
42
+ menu: "配置类别",
43
+ choose: "选择类别(q 退出)",
44
+ categories: ["大模型服务商 & API 密钥", "模型分层", "质量阈值", "语言"],
45
+ saved: "已保存。",
46
+ back: "← 返回菜单",
47
+ enterKeep: "回车保留当前值",
48
+ enterDefault: "回车使用默认值",
49
+ currentValue: "当前",
50
+ provider: "服务商",
51
+ baseUrl: "接口地址",
52
+ apiKey: "API 密钥",
53
+ conductor: "主模型",
54
+ language: "语言",
55
+ langOptions: ["English", "中文"],
56
+ },
57
+ };
58
+
59
+ function loadConfig() {
60
+ if (fs.existsSync(CONFIG_PATH)) {
61
+ try { return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")); } catch { /* ignore */ }
62
+ }
63
+ return null;
64
+ }
65
+
66
+ function saveConfig(config) {
67
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
68
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf-8");
69
+ }
70
+
71
+ function ask(rl, question, defaultValue = "", hint = "") {
72
+ const suffix = defaultValue ? ` ${DIM}[${defaultValue}]${RESET}` : "";
73
+ const hintText = hint
74
+ ? ` ${GRAY}(${hint})${RESET}`
75
+ : defaultValue
76
+ ? ` ${GRAY}(Press Enter to keep)${RESET}`
77
+ : "";
78
+ return new Promise((resolve) => {
79
+ rl.question(`${question}${suffix}${hintText}: `, (answer) => resolve(answer.trim() || defaultValue));
80
+ });
81
+ }
82
+
83
+ function maskKey(key) {
84
+ if (!key || key.length < 10) return key || "";
85
+ return key.slice(0, 6) + "..." + key.slice(-4);
86
+ }
87
+
88
+ /**
89
+ * Category 1: LLM Provider & API Key
90
+ */
91
+ async function editProvider(rl, config, t) {
92
+ console.log();
93
+ console.log(` ${BOLD}${t.categories[0]}${RESET}`);
94
+ console.log(` ${GRAY}${"─".repeat(35)}${RESET}`);
95
+
96
+ // Show current
97
+ const currentProvider = getProviderById(config.provider);
98
+ console.log(` ${DIM}${t.currentValue}: ${config.provider} (${currentProvider?.name || "unknown"})${RESET}`);
99
+ console.log();
100
+
101
+ // Provider selection
102
+ const providers = getProviders();
103
+ const labels = getProviderLabels(config.language || "en");
104
+ console.log(` ${CYAN}${t.provider}:${RESET}`);
105
+ for (let i = 0; i < labels.length; i++) {
106
+ const marker = providers[i].id === config.provider ? ` ${GREEN}(${t.currentValue})${RESET}` : "";
107
+ console.log(` ${i + 1}. ${labels[i].label}${marker}`);
108
+ }
109
+ const currentIdx = providers.findIndex((p) => p.id === config.provider);
110
+ const providerChoice = await ask(rl, ` ${GRAY}>${RESET} ${t.choose.split(" (")[0]}`, String(currentIdx + 1 || 1));
111
+ const provIdx = parseInt(providerChoice, 10) - 1;
112
+ const provider = providers[Math.max(0, Math.min(provIdx, providers.length - 1))];
113
+ config.provider = provider.id;
114
+ config.auth_type = provider.authType;
115
+ config.api_format = provider.apiFormat;
116
+ console.log();
117
+
118
+ // Base URL
119
+ if (provider.id === "custom") {
120
+ config.base_url = await ask(rl, ` ${CYAN}${t.baseUrl}${RESET}`, config.base_url || "");
121
+ } else {
122
+ const defaultUrl = provider.baseUrl;
123
+ console.log(` ${CYAN}${t.baseUrl}${RESET}: ${DIM}${defaultUrl}${RESET}`);
124
+ config.base_url = defaultUrl;
125
+ }
126
+
127
+ // API Key
128
+ const masked = maskKey(config.api_key);
129
+ const keyPrompt = masked
130
+ ? ` ${CYAN}${t.apiKey}${RESET} ${DIM}(${masked})${RESET}`
131
+ : ` ${CYAN}${t.apiKey}${RESET}`;
132
+ const newKey = await ask(rl, keyPrompt, "", masked ? t.enterKeep : "");
133
+ if (newKey) config.api_key = newKey;
134
+
135
+ // Conductor model
136
+ console.log();
137
+ const defaultModel = provider.defaultModel || config.conductor_model || "";
138
+ config.conductor_model = await ask(rl, ` ${CYAN}${t.conductor}${RESET}`, config.conductor_model || defaultModel, t.enterKeep);
139
+ console.log();
140
+ }
141
+
142
+ /**
143
+ * Category 2: Model Tiers
144
+ */
145
+ async function editTiers(rl, config, t) {
146
+ console.log();
147
+ console.log(` ${BOLD}${t.categories[1]}${RESET}`);
148
+ console.log(` ${GRAY}${"─".repeat(35)}${RESET}`);
149
+ console.log();
150
+
151
+ const tiers = config.tiers || {};
152
+ const provider = getProviderById(config.provider);
153
+ const defaults = provider?.defaultTiers || {};
154
+
155
+ for (const tier of ["tier1", "tier2", "tier3", "tier4"]) {
156
+ const current = tiers[tier] || defaults[tier] || "";
157
+ tiers[tier] = await ask(rl, ` ${CYAN}${tier.toUpperCase()}${RESET}`, current, t.enterKeep);
158
+ }
159
+ config.tiers = tiers;
160
+ console.log();
161
+ }
162
+
163
+ /**
164
+ * Category 3: Quality Thresholds
165
+ */
166
+ async function editThresholds(rl, config, t) {
167
+ console.log();
168
+ console.log(` ${BOLD}${t.categories[2]}${RESET}`);
169
+ console.log(` ${GRAY}${"─".repeat(35)}${RESET}`);
170
+ console.log();
171
+
172
+ config.accuracy_threshold = parseFloat(
173
+ await ask(rl, ` ${CYAN}Accuracy threshold${RESET}`, String(config.accuracy_threshold ?? 0.9), t.enterKeep)
174
+ );
175
+ config.systemic_threshold = parseFloat(
176
+ await ask(rl, ` ${CYAN}Systemic threshold${RESET}`, String(config.systemic_threshold ?? 0.10), t.enterKeep)
177
+ );
178
+ config.spot_check_rate = parseFloat(
179
+ await ask(rl, ` ${CYAN}Spot-check rate${RESET}`, String(config.spot_check_rate ?? 0.10), t.enterKeep)
180
+ );
181
+ config.tier_tolerance = parseFloat(
182
+ await ask(rl, ` ${CYAN}Tier downgrade tolerance${RESET}`, String(config.tier_tolerance ?? 0.05), t.enterKeep)
183
+ );
184
+ console.log();
185
+ }
186
+
187
+ /**
188
+ * Category 4: Language
189
+ */
190
+ async function editLanguage(rl, config, t) {
191
+ console.log();
192
+ console.log(` ${BOLD}${t.categories[3]}${RESET}`);
193
+ console.log(` ${GRAY}${"─".repeat(35)}${RESET}`);
194
+ console.log();
195
+
196
+ console.log(` ${CYAN}${t.language}:${RESET}`);
197
+ console.log(` 1. ${t.langOptions[0]}${config.language === "en" ? ` ${GREEN}(${t.currentValue})${RESET}` : ""}`);
198
+ console.log(` 2. ${t.langOptions[1]}${config.language === "zh" ? ` ${GREEN}(${t.currentValue})${RESET}` : ""}`);
199
+ const langDefault = config.language === "zh" ? "2" : "1";
200
+ const langChoice = await ask(rl, ` ${GRAY}>${RESET}`, langDefault);
201
+ config.language = langChoice === "2" ? "zh" : "en";
202
+ console.log();
203
+ }
204
+
205
+ const CATEGORY_HANDLERS = [editProvider, editTiers, editThresholds, editLanguage];
206
+
207
+ /**
208
+ * Main config editor loop.
209
+ */
210
+ export async function configEditor() {
211
+ const config = loadConfig();
212
+ if (!config) {
213
+ console.log(`\n ${RED}${L.en.noConfig}${RESET}\n`);
214
+ process.exit(1);
215
+ }
216
+
217
+ const lang = config.language || "en";
218
+ const t = L[lang];
219
+
220
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
221
+
222
+ while (true) {
223
+ console.log();
224
+ console.log(` ${BOLD}${t.title}${RESET}`);
225
+ console.log(` ${GRAY}${"─".repeat(35)}${RESET}`);
226
+ console.log();
227
+ for (let i = 0; i < t.categories.length; i++) {
228
+ console.log(` ${i + 1}. ${t.categories[i]}`);
229
+ }
230
+ console.log();
231
+
232
+ const choice = await ask(rl, ` ${GRAY}>${RESET} ${t.choose}`, "");
233
+
234
+ if (choice === "q" || choice === "Q" || choice === "") break;
235
+
236
+ const idx = parseInt(choice, 10) - 1;
237
+ if (idx >= 0 && idx < CATEGORY_HANDLERS.length) {
238
+ await CATEGORY_HANDLERS[idx](rl, config, t);
239
+ saveConfig(config);
240
+ console.log(` ${GREEN}✓${RESET} ${t.saved}`);
241
+ }
242
+ }
243
+
244
+ rl.close();
245
+ console.log();
246
+ }