kc-beta 0.1.1 → 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.
Files changed (34) hide show
  1. package/bin/kc-beta.js +14 -2
  2. package/package.json +1 -1
  3. package/src/agent/context-window.js +151 -0
  4. package/src/agent/context.js +58 -88
  5. package/src/agent/engine.js +267 -38
  6. package/src/agent/event-log.js +111 -0
  7. package/src/agent/llm-client.js +352 -59
  8. package/src/agent/pipelines/_archive_v1/distillation.js +113 -0
  9. package/src/agent/pipelines/_archive_v1/extraction.js +92 -0
  10. package/src/agent/pipelines/_archive_v1/initializer.js +163 -0
  11. package/src/agent/pipelines/_archive_v1/production-qc.js +99 -0
  12. package/src/agent/pipelines/_archive_v1/skill-authoring.js +83 -0
  13. package/src/agent/pipelines/_archive_v1/skill-testing.js +111 -0
  14. package/src/agent/pipelines/base.js +6 -0
  15. package/src/agent/pipelines/distillation.js +25 -11
  16. package/src/agent/pipelines/extraction.js +26 -7
  17. package/src/agent/pipelines/initializer.js +30 -20
  18. package/src/agent/pipelines/production-qc.js +22 -5
  19. package/src/agent/pipelines/skill-authoring.js +19 -8
  20. package/src/agent/pipelines/skill-testing.js +26 -8
  21. package/src/agent/retry.js +83 -0
  22. package/src/agent/session-state.js +78 -0
  23. package/src/agent/skill-loader.js +139 -0
  24. package/src/agent/token-counter.js +62 -0
  25. package/src/agent/tools/document-parse.js +3 -3
  26. package/src/agent/tools/tier-downgrade.js +11 -2
  27. package/src/agent/tools/web-search.js +107 -0
  28. package/src/agent/tools/worker-llm-call.js +14 -5
  29. package/src/cli/components.js +16 -4
  30. package/src/cli/config.js +246 -0
  31. package/src/cli/index.js +99 -10
  32. package/src/cli/onboard.js +154 -48
  33. package/src/config.js +25 -7
  34. package/src/providers.js +370 -0
@@ -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
+ }
package/src/cli/index.js CHANGED
@@ -29,11 +29,23 @@ function App({ engine, config }) {
29
29
  const [sessionId, setSessionId] = useState(engine.workspace.sessionId);
30
30
  const [phase, setPhase] = useState(engine.currentPhase);
31
31
  const [showWelcome, setShowWelcome] = useState(true);
32
+ const [spinnerStatus, setSpinnerStatus] = useState(null);
33
+ const [contextTokens, setContextTokens] = useState(0);
34
+ const [contextLimit, setContextLimit] = useState(config.kcContextLimit || 200000);
32
35
 
33
36
  const engineRef = useRef(engine);
34
37
  const streamingRef = useRef(false);
35
38
  const queueRef = useRef([]);
36
39
 
40
+ // Update context stats
41
+ const updateContextStats = useCallback(() => {
42
+ try {
43
+ const stats = engineRef.current.getContextStats();
44
+ setContextTokens(stats.totalTokens);
45
+ setContextLimit(stats.limit);
46
+ } catch { /* ignore */ }
47
+ }, []);
48
+
37
49
  const addMessage = useCallback((msg) => {
38
50
  setMessages((prev) => [...prev, msg]);
39
51
  }, []);
@@ -43,6 +55,7 @@ function App({ engine, config }) {
43
55
  setStreaming(true);
44
56
  setStreamingText("");
45
57
  setCurrentTool(null);
58
+ setSpinnerStatus("Thinking...");
46
59
 
47
60
  let accumulated = "";
48
61
 
@@ -52,6 +65,7 @@ function App({ engine, config }) {
52
65
  case "text_delta":
53
66
  accumulated += event.text ?? "";
54
67
  setStreamingText(accumulated);
68
+ setSpinnerStatus("Thinking...");
55
69
  break;
56
70
 
57
71
  case "turn_complete":
@@ -61,6 +75,8 @@ function App({ engine, config }) {
61
75
  accumulated = "";
62
76
  setStreamingText("");
63
77
  setCurrentTool(null);
78
+ setSpinnerStatus(null);
79
+ updateContextStats();
64
80
  break;
65
81
 
66
82
  case "tool_start":
@@ -71,6 +87,7 @@ function App({ engine, config }) {
71
87
  setStreamingText("");
72
88
  }
73
89
  setCurrentTool({ name: event.name, input: event.input, output: null, isError: false, isRunning: true });
90
+ setSpinnerStatus(`Running ${event.name}...`);
74
91
  break;
75
92
 
76
93
  case "tool_result":
@@ -83,6 +100,7 @@ function App({ engine, config }) {
83
100
  toolIsError: event.isError,
84
101
  });
85
102
  setCurrentTool(null);
103
+ setSpinnerStatus("Analyzing results...");
86
104
  break;
87
105
 
88
106
  case "pipeline_event": {
@@ -103,13 +121,15 @@ function App({ engine, config }) {
103
121
 
104
122
  streamingRef.current = false;
105
123
  setStreaming(false);
124
+ setSpinnerStatus(null);
125
+ updateContextStats();
106
126
 
107
127
  // Process queue
108
128
  if (queueRef.current.length > 0) {
109
129
  const next = queueRef.current.shift();
110
130
  runTurn(next);
111
131
  }
112
- }, [addMessage]);
132
+ }, [addMessage, updateContextStats]);
113
133
 
114
134
  const handleSlashCommand = useCallback((text) => {
115
135
  const parts = text.split(/\s+/);
@@ -125,6 +145,7 @@ function App({ engine, config }) {
125
145
  " /help Show this help\n" +
126
146
  " /status Show session info, model, phase, workspace\n" +
127
147
  " /clear Clear conversation history (keep workspace)\n" +
148
+ " /compact Summarize older messages to reduce context\n" +
128
149
  " /sessions List all sessions\n" +
129
150
  " /resume <name> Resume a previous session\n" +
130
151
  " /rename <name> Rename current session\n" +
@@ -132,26 +153,53 @@ function App({ engine, config }) {
132
153
  });
133
154
  return true;
134
155
 
135
- case "/status":
156
+ case "/status": {
157
+ const stats = engineRef.current.getContextStats();
136
158
  addMessage({
137
159
  role: "system",
138
160
  content:
139
161
  `Session: ${engineRef.current.workspace.sessionId}\n` +
140
162
  `Phase: ${engineRef.current.currentPhase.toUpperCase()}\n` +
141
163
  `Model: ${config.kcModel}\n` +
164
+ `Provider: ${config.provider || "unknown"}\n` +
142
165
  `LLM URL: ${config.llmBaseUrl}\n` +
143
166
  `Workspace: ${engineRef.current.workspace.cwd}\n` +
144
167
  `Tools: ${engineRef.current.toolRegistry.size} registered\n` +
145
- `History: ${engineRef.current.history.messages.length} messages`,
168
+ `History: ${engineRef.current.history.messages.length} messages\n` +
169
+ `Context: ~${stats.totalTokens} tokens (${stats.percentage}% of ${stats.limit})`,
146
170
  });
147
171
  return true;
172
+ }
148
173
 
149
174
  case "/clear":
150
175
  engineRef.current.history = new ConversationHistory(engineRef.current.workspace.cwd);
151
176
  setMessages([]);
152
177
  addMessage({ role: "system", content: "Conversation cleared. Workspace and pipeline state preserved." });
178
+ updateContextStats();
153
179
  return true;
154
180
 
181
+ case "/compact": {
182
+ addMessage({ role: "system", content: "Compacting conversation history..." });
183
+ // Run compact asynchronously
184
+ (async () => {
185
+ try {
186
+ const result = await engineRef.current.compact();
187
+ if (result) {
188
+ addMessage({
189
+ role: "system",
190
+ content: `Compacted: removed ${result.removedCount} messages, kept ${result.retainedCount}. Summary: ~${result.summaryTokens} tokens.`,
191
+ });
192
+ } else {
193
+ addMessage({ role: "system", content: "Nothing to compact (conversation is short enough)." });
194
+ }
195
+ updateContextStats();
196
+ } catch (err) {
197
+ addMessage({ role: "system", content: `Compact failed: ${err.message}` });
198
+ }
199
+ })();
200
+ return true;
201
+ }
202
+
155
203
  case "/rename":
156
204
  if (!arg) {
157
205
  addMessage({ role: "system", content: "Usage: /rename <new_name>" });
@@ -193,19 +241,46 @@ function App({ engine, config }) {
193
241
  addMessage({ role: "system", content: "Sessions:\n" + lines.join("\n") + "\n\nUsage: /resume <name>" });
194
242
  }
195
243
  } else {
196
- addMessage({ role: "system", content: `Resuming session: ${arg} (not yet implemented)` });
244
+ // Resume a previous session
245
+ (async () => {
246
+ try {
247
+ const client = new LLMClient({
248
+ apiKey: config.llmApiKey,
249
+ baseUrl: config.llmBaseUrl,
250
+ authType: config.authType,
251
+ apiFormat: config.apiFormat,
252
+ });
253
+ const resumed = await AgentEngine.resume({ client, config, sessionId: arg });
254
+ engineRef.current = resumed;
255
+ setSessionId(resumed.workspace.sessionId);
256
+ setPhase(resumed.currentPhase);
257
+ setMessages([]);
258
+ addMessage({
259
+ role: "system",
260
+ content:
261
+ `Resumed session: ${arg}\n` +
262
+ `Phase: ${resumed.currentPhase.toUpperCase()}\n` +
263
+ `History: ${resumed.history.messages.length} messages restored`,
264
+ });
265
+ updateContextStats();
266
+ } catch (err) {
267
+ addMessage({ role: "system", content: `Resume failed: ${err.message}` });
268
+ }
269
+ })();
197
270
  }
198
271
  return true;
199
272
 
200
273
  case "/exit":
201
274
  case "/quit":
275
+ // Save state before exit
276
+ try { engineRef.current.saveState(); } catch { /* ignore */ }
202
277
  exit();
203
278
  return true;
204
279
 
205
280
  default:
206
281
  return false;
207
282
  }
208
- }, [addMessage, config, exit]);
283
+ }, [addMessage, config, exit, updateContextStats]);
209
284
 
210
285
  const handleSubmit = useCallback((text) => {
211
286
  const trimmed = text.trim();
@@ -233,10 +308,12 @@ function App({ engine, config }) {
233
308
  queueRef.current.length = 0;
234
309
  addMessage({ role: "system", content: "[Queue cleared]" });
235
310
  } else {
311
+ try { engineRef.current.saveState(); } catch { /* ignore */ }
236
312
  exit();
237
313
  }
238
314
  }
239
315
  if (key.ctrl && input === "d") {
316
+ try { engineRef.current.saveState(); } catch { /* ignore */ }
240
317
  exit();
241
318
  }
242
319
  });
@@ -291,9 +368,9 @@ function App({ engine, config }) {
291
368
  isRunning: true,
292
369
  }) : null,
293
370
 
294
- // Spinner while waiting
295
- streaming && !streamingText && !currentTool
296
- ? h(CookingSpinner)
371
+ // Activity indicator while KC is working
372
+ streaming
373
+ ? h(CookingSpinner, { status: spinnerStatus })
297
374
  : null,
298
375
 
299
376
  // Separator + Input
@@ -305,13 +382,18 @@ function App({ engine, config }) {
305
382
  isActive: !streaming,
306
383
  }),
307
384
  h(HRule),
308
- h(StatusBar, { sessionId, phase }),
385
+ h(StatusBar, { sessionId, phase, contextTokens, contextLimit }),
309
386
  );
310
387
  }
311
388
 
312
- export async function main() {
389
+ export async function main({ languageOverride } = {}) {
313
390
  const config = loadSettings();
314
391
 
392
+ // Session-only language override (does NOT persist to config)
393
+ if (languageOverride) {
394
+ config.language = languageOverride;
395
+ }
396
+
315
397
  if (!config.llmApiKey) {
316
398
  console.error("Error: No API key configured. Run 'kc-beta onboard' first.");
317
399
  process.exit(1);
@@ -320,10 +402,17 @@ export async function main() {
320
402
  const client = new LLMClient({
321
403
  apiKey: config.llmApiKey,
322
404
  baseUrl: config.llmBaseUrl,
405
+ authType: config.authType,
406
+ apiFormat: config.apiFormat,
323
407
  });
324
408
 
325
409
  const engine = new AgentEngine({ client, config });
326
410
 
411
+ // Save state on process exit
412
+ const saveOnExit = () => { try { engine.saveState(); } catch { /* ignore */ } };
413
+ process.on("SIGINT", saveOnExit);
414
+ process.on("SIGTERM", saveOnExit);
415
+
327
416
  const instance = render(h(App, { engine, config }));
328
417
  await instance.waitUntilExit();
329
418
  }