kc-beta 0.5.4 → 0.5.6

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kc-beta",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "description": "KC Agent — LLM document verification agent (pure Node.js CLI)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -38,8 +38,18 @@ export class ContextWindow {
38
38
  return { messages, wasWindowed: false, removedCount: 0 };
39
39
  }
40
40
 
41
- // Split into older and recent
42
- const splitPoint = Math.max(0, messages.length - this.recentWindowSize);
41
+ // Split into older and recent. The recent slice is fed directly to the
42
+ // LLM, so it must not begin with an orphan "tool" message — those carry a
43
+ // tool_call_id that references an assistant `tool_calls` entry, and if
44
+ // that assistant message ended up in the compressed older slice the
45
+ // provider rejects the request (OpenAI: "tool messages must follow an
46
+ // assistant with tool_calls"; Anthropic: unpaired tool_use/tool_result).
47
+ // Walk the split point forward past any leading tool rows so the recent
48
+ // window always starts on a turn boundary.
49
+ let splitPoint = Math.max(0, messages.length - this.recentWindowSize);
50
+ while (splitPoint < messages.length && messages[splitPoint]?.role === "tool") {
51
+ splitPoint++;
52
+ }
43
53
  const recentMessages = messages.slice(splitPoint);
44
54
  const olderMessages = messages.slice(0, splitPoint);
45
55
 
@@ -341,7 +341,10 @@ export class AgentEngine {
341
341
  if (!this.contextWindow) return;
342
342
  const windowed = this.contextWindow.window(this.history.messages, this._phaseSummaries);
343
343
  if (windowed.wasWindowed) {
344
- this.history.messages = windowed.messages;
344
+ // `messages` is a getter-only property on ConversationHistory; write the
345
+ // backing field and persist (same pattern as compact()).
346
+ this.history._messages = windowed.messages;
347
+ this.history._save();
345
348
  this.eventLog.append("context_windowed", {
346
349
  removed: windowed.removedCount,
347
350
  trigger: "post_tool_result",
package/src/cli/index.js CHANGED
@@ -235,6 +235,7 @@ function App({ engine, config }) {
235
235
  return true;
236
236
  }
237
237
  const ok = engine._advancePhase(next, "manual /phase advance");
238
+ if (ok) setPhase(engine.currentPhase);
238
239
  addMessage({
239
240
  role: "system",
240
241
  content: ok
@@ -246,12 +247,28 @@ function App({ engine, config }) {
246
247
  }
247
248
 
248
249
  // /phase <name> — force-jump. Uses {force:true} to allow backward jumps.
250
+ // Whitelist against known phases first so an unknown name doesn't
251
+ // silently corrupt engine state (_advancePhase with {force:true}
252
+ // would otherwise accept any string and mutate currentPhase).
253
+ const validPhases = Object.keys(engine.pipelines);
254
+ if (!validPhases.includes(sub)) {
255
+ addMessage({
256
+ role: "system",
257
+ content: `Unknown phase: ${sub}. Valid: ${validPhases.join(", ")}`,
258
+ });
259
+ return true;
260
+ }
261
+ if (sub === engine.currentPhase) {
262
+ addMessage({ role: "system", content: `Already in phase ${sub.toUpperCase()}.` });
263
+ return true;
264
+ }
249
265
  const ok = engine._advancePhase(sub, "manual /phase <name>", { force: true });
266
+ if (ok) setPhase(engine.currentPhase);
250
267
  addMessage({
251
268
  role: "system",
252
269
  content: ok
253
270
  ? `→ phase set to ${sub.toUpperCase()}.`
254
- : `Unknown phase: ${sub}. Valid: bootstrap, extraction, skill_authoring, skill_testing, distillation, production_qc`,
271
+ : `Failed to set phase to ${sub}.`,
255
272
  });
256
273
  updateContextStats();
257
274
  return true;
@@ -290,6 +307,15 @@ function App({ engine, config }) {
290
307
 
291
308
  case "/compact": {
292
309
  addMessage({ role: "system", content: "Compacting conversation history..." });
310
+ // Gate the prompt while compact() is in flight. Without this,
311
+ // InputPrompt stays active (isActive: !streaming) and a concurrent
312
+ // user submission routes into runTurn → history.addUser(...), which
313
+ // appends to _messages AFTER compact()'s pre-await snapshot. When
314
+ // compact resolves it overwrites _messages with [summary, ack,
315
+ // ...recentMessages] and silently drops the concurrent turn.
316
+ streamingRef.current = true;
317
+ setStreaming(true);
318
+ setSpinnerStatus("Compacting...");
293
319
  (async () => {
294
320
  try {
295
321
  const result = await engineRef.current.compact();
@@ -312,6 +338,14 @@ function App({ engine, config }) {
312
338
  updateContextStats();
313
339
  } catch (err) {
314
340
  addMessage({ role: "system", content: `Compact failed: ${err.message}` });
341
+ } finally {
342
+ streamingRef.current = false;
343
+ setStreaming(false);
344
+ setSpinnerStatus(null);
345
+ if (queueRef.current.length > 0) {
346
+ const next = queueRef.current.shift();
347
+ runTurn(next);
348
+ }
315
349
  }
316
350
  })();
317
351
  return true;
@@ -171,13 +171,19 @@ export async function onboard() {
171
171
  }
172
172
 
173
173
  // --- API Key ---
174
- const maskedExisting = existing.api_key ? existing.api_key.slice(0, 6) + "..." + existing.api_key.slice(-4) : "";
174
+ // Only offer to "keep" an existing key when the user hasn't switched
175
+ // providers. Otherwise an accidental Enter would silently save the OLD
176
+ // provider's key against the NEW provider's base URL — silent breakage.
177
+ const keyIsForSameProvider = existing.provider === provider.id;
178
+ const maskedExisting = keyIsForSameProvider && existing.api_key
179
+ ? existing.api_key.slice(0, 6) + "..." + existing.api_key.slice(-4)
180
+ : "";
175
181
  const keyHint = maskedExisting ? t.apiKeyKeep : t.apiKeyRequired;
176
182
  const keyPrompt = maskedExisting
177
183
  ? ` ${CYAN}${t.apiKey}${RESET} ${DIM}(${maskedExisting})${RESET}`
178
184
  : ` ${CYAN}${t.apiKey}${RESET} ${YELLOW}(${t.apiKeyRequired})${RESET}`;
179
185
  const apiKey = await ask(rl, keyPrompt, "", keyHint);
180
- const finalKey = apiKey || existing.api_key || "";
186
+ const finalKey = apiKey || (keyIsForSameProvider ? existing.api_key : "") || "";
181
187
  if (!finalKey) { console.log(` ${RED}${t.apiKeyMissing}${RESET}`); rl.close(); process.exit(1); }
182
188
  console.log();
183
189
 
@@ -245,34 +251,12 @@ export async function onboard() {
245
251
  );
246
252
  console.log();
247
253
 
248
- // --- Worker LLM tiers ---
249
- console.log(` ${CYAN}${t.workerTiers}${RESET} ${DIM}(${t.tierHint})${RESET}`);
250
- const tiers = {};
251
- for (const tier of ["tier1", "tier2", "tier3", "tier4"]) {
252
- const def = suggestedTiers?.[tier] || provider.defaultTiers[tier] || existing?.tiers?.[tier] || "";
253
- tiers[tier] = await ask(
254
- rl,
255
- ` ${tier.toUpperCase()}`,
256
- def,
257
- t.discoveryAccept ? "" : "",
258
- );
259
- }
260
- console.log();
261
-
262
- // --- VLM tiers (vision/OCR) ---
263
- console.log(` ${CYAN}${t.vlmTiers}${RESET} ${DIM}(${t.tierHint})${RESET}`);
264
- const vlmTiers = {};
265
- for (const tier of ["tier1", "tier2", "tier3"]) {
266
- const def = provider.defaultVlm?.[tier] || existing?.vlm_tiers?.[tier] || "";
267
- vlmTiers[tier] = await ask(
268
- rl,
269
- ` ${tier.toUpperCase()}`,
270
- def,
271
- );
272
- }
273
- console.log();
274
-
275
254
  // --- Worker LLM provider (optional) ---
255
+ // Ask worker-provider BEFORE tier prompts so that when worker differs from
256
+ // conductor (e.g. conductor=xfyun single-model, worker=siliconflow) the
257
+ // tier-default suggestions come from the WORKER provider's model-tiers.json
258
+ // entry, not the conductor's. Previous ordering defaulted tiers from the
259
+ // conductor and produced nonsensical defaults for single-model conductors.
276
260
  console.log(` ${CYAN}${t.workerConfig}${RESET}`);
277
261
  const sameProvider = await ask(rl, ` ${t.workerSameProvider}`, "Y", t.yesNo);
278
262
  let workerProvider = "";
@@ -280,6 +264,7 @@ export async function onboard() {
280
264
  let workerBaseUrl = "";
281
265
  let workerAuthType = "";
282
266
  let workerApiFormat = "";
267
+ let tierProviderDef = provider; // where tier defaults come from
283
268
 
284
269
  if (sameProvider.toLowerCase() === "n" || sameProvider.toLowerCase() === "no") {
285
270
  // Pick a different provider for workers
@@ -294,21 +279,58 @@ export async function onboard() {
294
279
  workerAuthType = wp.authType;
295
280
  workerApiFormat = wp.apiFormat;
296
281
  workerBaseUrl = wp.baseUrl;
282
+ tierProviderDef = wp;
297
283
 
298
284
  if (wp.id === "custom") {
299
285
  workerBaseUrl = await ask(rl, ` ${t.baseUrl}`, existing.worker_base_url || "");
300
286
  }
301
287
 
302
- // Worker API key
303
- const wMasked = existing.worker_api_key ? existing.worker_api_key.slice(0, 6) + "..." + existing.worker_api_key.slice(-4) : "";
288
+ // Worker API key. Show masked existing key in the prompt (matches the
289
+ // main-provider prompt style) so the user can confirm what's saved
290
+ // without guessing. Like the main key, only offer to "keep" the existing
291
+ // value if the WORKER provider itself hasn't changed — otherwise Enter
292
+ // would silently carry the previous worker provider's key across.
293
+ const workerKeyIsForSameProvider = existing.worker_provider === wp.id;
294
+ const wMasked = workerKeyIsForSameProvider && existing.worker_api_key
295
+ ? existing.worker_api_key.slice(0, 6) + "..." + existing.worker_api_key.slice(-4)
296
+ : "";
304
297
  const wKeyHint = wMasked ? t.apiKeyKeep : t.apiKeyRequired;
305
- workerApiKey = await ask(
298
+ const wKeyPrompt = wMasked
299
+ ? ` ${CYAN}${t.apiKey} (Worker)${RESET} ${DIM}(${wMasked})${RESET}`
300
+ : ` ${CYAN}${t.apiKey} (Worker)${RESET} ${YELLOW}(${t.apiKeyRequired})${RESET}`;
301
+ workerApiKey = await ask(rl, wKeyPrompt, "", wKeyHint);
302
+ workerApiKey = workerApiKey || (workerKeyIsForSameProvider ? existing.worker_api_key : "") || "";
303
+ }
304
+ console.log();
305
+
306
+ // --- Worker LLM tiers (defaults come from tierProviderDef set above) ---
307
+ // When worker==conductor, these default from the conductor's model-tiers.json
308
+ // entry. When worker is a separate provider, they default from the WORKER
309
+ // provider's entry — so e.g. siliconflow's GLM-5.1 tier1 defaults apply.
310
+ console.log(` ${CYAN}${t.workerTiers}${RESET} ${DIM}(${t.tierHint})${RESET}`);
311
+ const tiers = {};
312
+ const tierSuggested = tierProviderDef.id === provider.id ? suggestedTiers : null;
313
+ for (const tier of ["tier1", "tier2", "tier3", "tier4"]) {
314
+ const def = tierSuggested?.[tier] || tierProviderDef.defaultTiers?.[tier] || existing?.tiers?.[tier] || "";
315
+ tiers[tier] = await ask(
316
+ rl,
317
+ ` ${tier.toUpperCase()}`,
318
+ def,
319
+ t.discoveryAccept ? "" : "",
320
+ );
321
+ }
322
+ console.log();
323
+
324
+ // --- VLM tiers (vision/OCR) — also from worker provider when split ---
325
+ console.log(` ${CYAN}${t.vlmTiers}${RESET} ${DIM}(${t.tierHint})${RESET}`);
326
+ const vlmTiers = {};
327
+ for (const tier of ["tier1", "tier2", "tier3"]) {
328
+ const def = tierProviderDef.defaultVlm?.[tier] || existing?.vlm_tiers?.[tier] || "";
329
+ vlmTiers[tier] = await ask(
306
330
  rl,
307
- ` ${CYAN}${t.apiKey} (Worker)${RESET}`,
308
- "",
309
- wKeyHint,
331
+ ` ${tier.toUpperCase()}`,
332
+ def,
310
333
  );
311
- workerApiKey = workerApiKey || existing.worker_api_key || "";
312
334
  }
313
335
  console.log();
314
336
 
@@ -32,9 +32,10 @@
32
32
  },
33
33
 
34
34
  "volcanocloud": {
35
- "conductor": "doubao-seed-2-0-pro-260215",
35
+ "_comment": "Coding plan (api/coding/v3) serves glm-5.1 only. Regular plan (api/v3) serves doubao/deepseek/glm-4-7. Pick conductor per the plan you onboarded.",
36
+ "conductor": "glm-5.1",
36
37
  "llm": {
37
- "tier1": "doubao-seed-2-0-pro-260215, deepseek-v3-2-251201",
38
+ "tier1": "glm-5.1, doubao-seed-2-0-pro-260215, deepseek-v3-2-251201",
38
39
  "tier2": "glm-4-7-251222, doubao-1-5-pro-32k-250115",
39
40
  "tier3": "doubao-seed-2-0-mini-260215",
40
41
  "tier4": "doubao-seed-2-0-lite-260215, doubao-1-5-lite-32k-250115"
@@ -46,6 +47,22 @@
46
47
  }
47
48
  },
48
49
 
50
+ "xfyun": {
51
+ "_comment": "iFlytek Astro coding plan exposes only astron-code-latest; no VLM.",
52
+ "conductor": "astron-code-latest",
53
+ "llm": {
54
+ "tier1": "astron-code-latest",
55
+ "tier2": "",
56
+ "tier3": "",
57
+ "tier4": ""
58
+ },
59
+ "vlm": {
60
+ "tier1": "",
61
+ "tier2": "",
62
+ "tier3": ""
63
+ }
64
+ },
65
+
49
66
  "anthropic": {
50
67
  "conductor": "claude-sonnet-4-20250514",
51
68
  "llm": {
package/src/providers.js CHANGED
@@ -77,14 +77,20 @@ const PROVIDERS = [
77
77
  {
78
78
  id: "volcanocloud",
79
79
  name: "VolcanoCloud",
80
+ // Regular Ark API — serves doubao / deepseek / glm-4-7-251222.
81
+ // Coding plan uses api/coding/v3 and serves glm-5.1 (aliased to glm-4.7
82
+ // server-side, thinking model).
80
83
  baseUrl: "https://ark.cn-beijing.volces.com/api/v3",
84
+ codingPlanUrl: "https://ark.cn-beijing.volces.com/api/coding/v3",
81
85
  authType: "bearer",
82
86
  apiFormat: "openai",
83
- modelsEndpoint: null, // VolcanoCloud coding plan — use curated list
87
+ modelsEndpoint: null, // VolcanoCloud — use curated list
88
+ supportsCodingPlanKey: true,
84
89
  defaultModel: getTierConfig("volcanocloud").conductor || "doubao-seed-2-0-pro-260215",
85
90
  defaultTiers: getTierConfig("volcanocloud").llm,
86
91
  defaultVlm: getTierConfig("volcanocloud").vlm,
87
92
  curatedModels: [
93
+ { id: "glm-5.1", ownedBy: "zhipu" },
88
94
  { id: "doubao-seed-2-0-pro-260215", ownedBy: "bytedance" },
89
95
  { id: "deepseek-v3-2-251201", ownedBy: "deepseek" },
90
96
  { id: "glm-4-7-251222", ownedBy: "zhipu" },
@@ -98,6 +104,27 @@ const PROVIDERS = [
98
104
  zh: "火山云(字节跳动)",
99
105
  },
100
106
  },
107
+ {
108
+ id: "xfyun",
109
+ name: "XfYun Astro",
110
+ // iFlytek Astro coding plan — OpenAI-compatible endpoint. Only exposes
111
+ // a single model (astron-code-latest) today, so no /models discovery and
112
+ // the tier assignment in model-tiers.json only fills tier1 / conductor.
113
+ baseUrl: "https://maas-coding-api.cn-huabei-1.xf-yun.com/v2",
114
+ authType: "bearer",
115
+ apiFormat: "openai",
116
+ modelsEndpoint: null,
117
+ defaultModel: getTierConfig("xfyun").conductor || "astron-code-latest",
118
+ defaultTiers: getTierConfig("xfyun").llm,
119
+ defaultVlm: getTierConfig("xfyun").vlm,
120
+ curatedModels: [
121
+ { id: "astron-code-latest", ownedBy: "iflytek" },
122
+ ],
123
+ labels: {
124
+ en: "iFlytek XfYun Astro (coding plan, single-model)",
125
+ zh: "科大讯飞 Astro 编程套餐(单模型)",
126
+ },
127
+ },
101
128
  {
102
129
  id: "anthropic",
103
130
  name: "Anthropic",
@@ -238,12 +265,15 @@ const MODEL_RANKING = {
238
265
  "qwen3.5-122b": 75,
239
266
  "qwen3.5-35b": 65,
240
267
  // Zhipu
268
+ "glm-5.1": 92,
241
269
  "glm-5": 90,
242
270
  "glm-4.7": 80,
243
271
  "glm-4": 75,
244
272
  // Others
245
273
  "kimi-k2.5": 85,
246
274
  "kimi-k2": 80,
275
+ // iFlytek Astro
276
+ "astron-code": 90,
247
277
  "minimax-m2": 80,
248
278
  "deepseek-v3": 85,
249
279
  "deepseek-r1": 90,
Binary file