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
@@ -69,7 +69,7 @@ export class ProductionQCPipeline extends Pipeline {
69
69
 
70
70
  describeState() {
71
71
  this._scanWorkspace();
72
- const parts = ["## Current Phase: PRODUCTION_QC"];
72
+ const parts = ["## Phase: PRODUCTION_QC\nRun workflows on production documents from input/, monitor quality via confidence-based sampling. This phase transitions from active review to stable spot-checking as accuracy stabilizes."];
73
73
  parts.push(`### Progress\n- Batches: ${this.batchesProcessed}\n- Documents: ${this.totalDocuments}\n- Reviewed: ${this.documentsReviewed}\n- Monitoring: ${this.monitoringPhase}\n- Sampling rate: ${(this._samplingRate * 100).toFixed(0)}%`);
74
74
 
75
75
  if (Object.keys(this.accuracyByRule).length) {
@@ -77,10 +77,8 @@ export class ProductionQCPipeline extends Pipeline {
77
77
  parts.push("### Accuracy by rule\n" + lines.join("\n"));
78
78
  }
79
79
 
80
- if (this.monitoringPhase === "initial") {
81
- parts.push("### What to do now\nRun workflows on input/ documents. Save results to output/. Review and save QC to output/qc/.");
82
- } else if (this.monitoringPhase === "stable") {
83
- parts.push("### Status: Stable\nWorkflows running reliably. Spot-check only.");
80
+ if (this.monitoringPhase === "stable") {
81
+ parts.push("### Status: Stable monitoring. Spot-check only.");
84
82
  }
85
83
  return parts.join("\n\n");
86
84
  }
@@ -96,4 +94,23 @@ export class ProductionQCPipeline extends Pipeline {
96
94
  }
97
95
 
98
96
  exitCriteriaMet() { return this.monitoringPhase === "stable"; }
97
+
98
+ exportState() {
99
+ return {
100
+ batchesProcessed: this.batchesProcessed,
101
+ totalDocuments: this.totalDocuments,
102
+ documentsReviewed: this.documentsReviewed,
103
+ monitoringPhase: this.monitoringPhase,
104
+ accuracyByRule: this.accuracyByRule,
105
+ issuesCount: this.issuesFound.length,
106
+ };
107
+ }
108
+
109
+ importState(data) {
110
+ if (typeof data.batchesProcessed === "number" && data.batchesProcessed > this.batchesProcessed) this.batchesProcessed = data.batchesProcessed;
111
+ if (typeof data.totalDocuments === "number" && data.totalDocuments > this.totalDocuments) this.totalDocuments = data.totalDocuments;
112
+ if (typeof data.documentsReviewed === "number" && data.documentsReviewed > this.documentsReviewed) this.documentsReviewed = data.documentsReviewed;
113
+ if (data.monitoringPhase) this.monitoringPhase = data.monitoringPhase;
114
+ if (data.accuracyByRule && typeof data.accuracyByRule === "object") Object.assign(this.accuracyByRule, data.accuracyByRule);
115
+ }
99
116
  }
@@ -52,16 +52,13 @@ export class SkillAuthoringPipeline extends Pipeline {
52
52
  describeState() {
53
53
  this._scanWorkspace();
54
54
  const total = this.totalRules.length;
55
- const parts = ["## Current Phase: SKILL_AUTHORING"];
56
- parts.push(`### Progress\n- Rules from extraction: ${total}\n- Skills authored: ${this.skillsAuthored.length}\n- Skills with scripts/: ${this.skillsWithScripts.length}`);
55
+ const authored = this.skillsAuthored.length;
56
+ const remaining = this.totalRules.filter((r) => !this.skillsAuthored.includes(r));
57
+ const parts = ["## Phase: SKILL_AUTHORING\nWrite verification skills for each extracted rule. Skills are first-class deliverables — they may serve as the production solution when worker LLM workflows are insufficient. Follow Anthropic skill-creator format. This is BUILD mode."];
58
+ parts.push(`### Progress\n- Rules: ${total}\n- Skills authored: ${authored}\n- Skills with scripts/: ${this.skillsWithScripts.length}${remaining.length > 0 ? `\n- Remaining: ${remaining.slice(0, 10).join(", ")}` : ""}`);
57
59
 
58
60
  if (this.exitCriteriaMet()) {
59
- parts.push("### Ready\nAll rules have skills. Proceed to SKILL_TESTING.");
60
- } else if (this.skillsAuthored.length === 0) {
61
- parts.push("### What to do now\nWrite a SKILL.md for each rule in rule_skills/{rule_id}/.\nDescribe: what to check, where to look, what to extract, how to judge.");
62
- } else {
63
- const remaining = this.totalRules.filter((r) => !this.skillsAuthored.includes(r));
64
- parts.push(`### What to do now\n${total - this.skillsAuthored.length} rules still need skills. Remaining: ${remaining.slice(0, 10).join(", ")}`);
61
+ parts.push("### Exit\nAll rules have skills. Proceed to SKILL_TESTING.");
65
62
  }
66
63
  return parts.join("\n\n");
67
64
  }
@@ -80,4 +77,18 @@ export class SkillAuthoringPipeline extends Pipeline {
80
77
  if (!this.totalRules.length) return false;
81
78
  return this.skillsAuthored.length >= this.totalRules.length && this.skillsWithScripts.length >= this.skillsAuthored.length * 0.5;
82
79
  }
80
+
81
+ exportState() {
82
+ return {
83
+ totalRules: this.totalRules,
84
+ skillsAuthored: this.skillsAuthored,
85
+ skillsWithScripts: this.skillsWithScripts,
86
+ };
87
+ }
88
+
89
+ importState(data) {
90
+ if (Array.isArray(data.totalRules) && data.totalRules.length > this.totalRules.length) this.totalRules = data.totalRules;
91
+ if (Array.isArray(data.skillsAuthored) && data.skillsAuthored.length > this.skillsAuthored.length) this.skillsAuthored = data.skillsAuthored;
92
+ if (Array.isArray(data.skillsWithScripts) && data.skillsWithScripts.length > this.skillsWithScripts.length) this.skillsWithScripts = data.skillsWithScripts;
93
+ }
83
94
  }
@@ -78,17 +78,15 @@ export class SkillTestingPipeline extends Pipeline {
78
78
  const failing = Object.entries(this.skillsTested).filter(([, acc]) => acc < this._accuracyThreshold);
79
79
  const untested = this.skillsToTest.filter((s) => !(s in this.skillsTested));
80
80
 
81
- const parts = ["## Current Phase: SKILL_TESTING"];
81
+ const parts = ["## Phase: SKILL_TESTING\nTest skills against sample documents, iterate via evolution loop until accuracy threshold is met. This is BUILD mode — the results established here become the accuracy baseline for distillation."];
82
82
  parts.push(`### Progress\n- Skills to test: ${total}\n- Tested: ${tested}\n- Passing (>=${this._accuracyThreshold}): ${passing}\n- Evolution iterations: ${this.iterationCount}/${this._maxIterations}`);
83
+ if (untested.length) parts.push(`- Untested: ${untested.slice(0, 10).join(", ")}`);
84
+ if (failing.length) parts.push(`- Below threshold:\n${failing.map(([id, acc]) => ` - ${id}: ${acc.toFixed(2)}`).join("\n")}`);
83
85
 
84
86
  if (this.exitCriteriaMet()) {
85
- parts.push("### Ready\nAll skills passing. Proceed to DISTILLATION.");
86
- } else if (untested.length) {
87
- parts.push(`### What to do now\nTest these skills: ${untested.slice(0, 10).join(", ")}`);
88
- } else if (failing.length) {
89
- parts.push("### What to do now — Evolution Cycle\nFailing skills:\n" +
90
- failing.map(([id, acc]) => `- ${id}: ${acc.toFixed(2)}`).join("\n") +
91
- "\n\nFollow: diagnose -> classify -> fix -> retest -> log");
87
+ parts.push("### Exit\nAll skills passing. Proceed to DISTILLATION.");
88
+ } else if (this.iterationCount >= this._maxIterations) {
89
+ parts.push(`### Max iterations (${this._maxIterations}) reached. Discuss remaining failures with the developer user.`);
92
90
  }
93
91
  return parts.join("\n\n");
94
92
  }
@@ -108,4 +106,24 @@ export class SkillTestingPipeline extends Pipeline {
108
106
  if (!total) return false;
109
107
  return Object.keys(this.skillsTested).length >= total && this.skillsPassing.length >= total * this._accuracyThreshold;
110
108
  }
109
+
110
+ exportState() {
111
+ return {
112
+ skillsToTest: this.skillsToTest,
113
+ skillsTested: this.skillsTested,
114
+ skillsPassing: this.skillsPassing,
115
+ iterationCount: this.iterationCount,
116
+ };
117
+ }
118
+
119
+ importState(data) {
120
+ if (typeof data.iterationCount === "number" && data.iterationCount > this.iterationCount) this.iterationCount = data.iterationCount;
121
+ if (Array.isArray(data.skillsToTest) && data.skillsToTest.length > this.skillsToTest.length) this.skillsToTest = data.skillsToTest;
122
+ if (Array.isArray(data.skillsPassing) && data.skillsPassing.length > this.skillsPassing.length) this.skillsPassing = data.skillsPassing;
123
+ if (data.skillsTested && typeof data.skillsTested === "object") {
124
+ for (const [k, v] of Object.entries(data.skillsTested)) {
125
+ if (!this.skillsTested[k] || v > this.skillsTested[k]) this.skillsTested[k] = v;
126
+ }
127
+ }
128
+ }
111
129
  }
@@ -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,139 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+ const BUNDLED_SKILLS_DIR = path.resolve(__dirname, "../../template/skills");
7
+
8
+ /**
9
+ * Discover and index meta skills from template/skills/.
10
+ * Follows Claude Code's pattern: skills are NOT dumped into the system prompt.
11
+ * Instead, a brief index (name + description) is injected into context.
12
+ * The agent reads full SKILL.md content on demand via workspace_file or sandbox_exec.
13
+ *
14
+ * Skills are organized as:
15
+ * template/skills/{lang}/meta-meta/ — System architecture methodology
16
+ * template/skills/{lang}/meta/ — Verification domain methodology
17
+ * template/skills/{lang}/skill-creator/ — Anthropic's official skill creation toolkit
18
+ */
19
+ export class SkillLoader {
20
+ /**
21
+ * @param {string} [language] - "en" or "zh"
22
+ * @param {string} [skillsDir] - Override skills directory (default: bundled template)
23
+ */
24
+ constructor(language = "en", skillsDir) {
25
+ this._lang = language;
26
+ this._skillsDir = skillsDir || BUNDLED_SKILLS_DIR;
27
+ this._index = null;
28
+ }
29
+
30
+ /**
31
+ * Build the skill index by scanning SKILL.md frontmatter.
32
+ * Cached after first call.
33
+ * @returns {Array<{name: string, description: string, category: string, path: string}>}
34
+ */
35
+ getIndex() {
36
+ if (this._index) return this._index;
37
+
38
+ this._index = [];
39
+ const langDir = path.join(this._skillsDir, this._lang);
40
+ if (!fs.existsSync(langDir)) return this._index;
41
+
42
+ for (const category of ["meta-meta", "meta", "skill-creator"]) {
43
+ const catDir = path.join(langDir, category);
44
+ if (!fs.existsSync(catDir)) continue;
45
+
46
+ // skill-creator is a single skill, not a directory of skills
47
+ const skillMd = path.join(catDir, "SKILL.md");
48
+ if (fs.existsSync(skillMd)) {
49
+ const { name, description } = this._parseFrontmatter(skillMd);
50
+ if (name) {
51
+ this._index.push({
52
+ name: name || category,
53
+ description: description || "",
54
+ category,
55
+ path: path.relative(this._skillsDir, catDir),
56
+ });
57
+ }
58
+ }
59
+
60
+ // Check subdirectories (meta-meta/bootstrap-workspace/, etc.)
61
+ for (const entry of fs.readdirSync(catDir, { withFileTypes: true })) {
62
+ if (!entry.isDirectory()) continue;
63
+ const subSkillMd = path.join(catDir, entry.name, "SKILL.md");
64
+ if (!fs.existsSync(subSkillMd)) continue;
65
+
66
+ const { name, description } = this._parseFrontmatter(subSkillMd);
67
+ this._index.push({
68
+ name: name || entry.name,
69
+ description: description || "",
70
+ category,
71
+ path: path.relative(this._skillsDir, path.join(catDir, entry.name)),
72
+ });
73
+ }
74
+ }
75
+
76
+ return this._index;
77
+ }
78
+
79
+ /**
80
+ * Format the skill index for injection into agent context.
81
+ * Brief listing — agent reads full content on demand.
82
+ * @returns {string}
83
+ */
84
+ formatForContext() {
85
+ const index = this.getIndex();
86
+ if (index.length === 0) return "";
87
+
88
+ const metaMeta = index.filter((s) => s.category === "meta-meta");
89
+ const meta = index.filter((s) => s.category === "meta");
90
+ const other = index.filter((s) => s.category !== "meta-meta" && s.category !== "meta");
91
+
92
+ const lines = ["## Available Methodology Skills",
93
+ "Read full skill content from the skills/ directory when needed.\n"];
94
+
95
+ if (metaMeta.length) {
96
+ lines.push("**System Architecture (meta-meta):**");
97
+ for (const s of metaMeta) {
98
+ lines.push(`- **${s.name}**: ${s.description.slice(0, 120)}`);
99
+ }
100
+ lines.push("");
101
+ }
102
+
103
+ if (meta.length) {
104
+ lines.push("**Verification Methodology (meta):**");
105
+ for (const s of meta) {
106
+ lines.push(`- **${s.name}**: ${s.description.slice(0, 120)}`);
107
+ }
108
+ lines.push("");
109
+ }
110
+
111
+ if (other.length) {
112
+ lines.push("**Toolkits:**");
113
+ for (const s of other) {
114
+ lines.push(`- **${s.name}**: ${s.description.slice(0, 120)}`);
115
+ }
116
+ }
117
+
118
+ return lines.join("\n");
119
+ }
120
+
121
+ /**
122
+ * Parse YAML frontmatter from a SKILL.md file.
123
+ * Only extracts name and description — lightweight.
124
+ */
125
+ _parseFrontmatter(filePath) {
126
+ try {
127
+ const content = fs.readFileSync(filePath, "utf-8");
128
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
129
+ if (!match) return {};
130
+
131
+ const frontmatter = match[1];
132
+ const name = frontmatter.match(/^name:\s*(.+)$/m)?.[1]?.trim() || "";
133
+ const description = frontmatter.match(/^description:\s*(.+)$/m)?.[1]?.trim() || "";
134
+ return { name, description };
135
+ } catch {
136
+ return {};
137
+ }
138
+ }
139
+ }
@@ -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
 
@@ -72,14 +72,23 @@ export class TierDowngradeTool extends BaseTool {
72
72
  }
73
73
  }
74
74
 
75
- const recommend = targetAcc >= threshold && delta <= 0.05 ? "downgrade" : "keep_current";
75
+ // Read tier tolerance from .env (default from onboarding config)
76
+ let tolerance = 0.05;
77
+ if (fs.existsSync(envPath)) {
78
+ for (const line of fs.readFileSync(envPath, "utf-8").split("\n")) {
79
+ if (line.startsWith("TIER_TOLERANCE=")) {
80
+ try { tolerance = parseFloat(line.split("=")[1].trim()); }
81
+ catch { /* ignore */ }
82
+ }
83
+ }
84
+ }
76
85
 
77
86
  const report = {
78
87
  rule_id: ruleId, current_tier: currentTier, target_tier: targetTier,
79
88
  current_accuracy: Math.round(currentAcc * 1000) / 1000,
80
89
  target_accuracy: Math.round(targetAcc * 1000) / 1000,
81
90
  accuracy_delta: Math.round(delta * 1000) / 1000,
82
- threshold, recommendation: recommend, test_count: testInputs.length,
91
+ threshold, tolerance, test_count: testInputs.length,
83
92
  };
84
93
  return new ToolResult(JSON.stringify(report, null, 2));
85
94
  }
@@ -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
+ }