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.
- package/bin/kc-beta.js +14 -2
- package/package.json +1 -1
- package/src/agent/context-window.js +151 -0
- package/src/agent/context.js +58 -88
- package/src/agent/engine.js +267 -38
- package/src/agent/event-log.js +111 -0
- package/src/agent/llm-client.js +352 -59
- package/src/agent/pipelines/_archive_v1/distillation.js +113 -0
- package/src/agent/pipelines/_archive_v1/extraction.js +92 -0
- package/src/agent/pipelines/_archive_v1/initializer.js +163 -0
- package/src/agent/pipelines/_archive_v1/production-qc.js +99 -0
- package/src/agent/pipelines/_archive_v1/skill-authoring.js +83 -0
- package/src/agent/pipelines/_archive_v1/skill-testing.js +111 -0
- package/src/agent/pipelines/base.js +6 -0
- package/src/agent/pipelines/distillation.js +25 -11
- package/src/agent/pipelines/extraction.js +26 -7
- package/src/agent/pipelines/initializer.js +30 -20
- package/src/agent/pipelines/production-qc.js +22 -5
- package/src/agent/pipelines/skill-authoring.js +19 -8
- package/src/agent/pipelines/skill-testing.js +26 -8
- package/src/agent/retry.js +83 -0
- package/src/agent/session-state.js +78 -0
- package/src/agent/skill-loader.js +139 -0
- package/src/agent/token-counter.js +62 -0
- package/src/agent/tools/document-parse.js +3 -3
- package/src/agent/tools/tier-downgrade.js +11 -2
- package/src/agent/tools/web-search.js +107 -0
- package/src/agent/tools/worker-llm-call.js +14 -5
- package/src/cli/components.js +16 -4
- package/src/cli/config.js +246 -0
- package/src/cli/index.js +99 -10
- package/src/cli/onboard.js +154 -48
- package/src/config.js +25 -7
- package/src/providers.js +370 -0
package/src/agent/llm-client.js
CHANGED
|
@@ -1,102 +1,268 @@
|
|
|
1
|
+
import { withRetry } from "./retry.js";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* Supports OpenAI-compatible APIs (SiliconFlow, Aliyun, OpenAI, etc.)
|
|
4
|
+
* Multi-protocol LLM client using native fetch + SSE parsing.
|
|
5
|
+
* Supports OpenAI-compatible APIs and Anthropic Messages API.
|
|
5
6
|
*/
|
|
6
7
|
export class LLMClient {
|
|
7
8
|
/**
|
|
8
9
|
* @param {object} opts
|
|
9
10
|
* @param {string} opts.apiKey
|
|
10
|
-
* @param {string} opts.baseUrl - e.g. "https://api.siliconflow.cn/v1"
|
|
11
|
+
* @param {string} opts.baseUrl - e.g. "https://api.siliconflow.cn/v1" or "https://api.anthropic.com"
|
|
12
|
+
* @param {string} [opts.authType] - "bearer" (default) | "x-api-key" (Anthropic)
|
|
13
|
+
* @param {string} [opts.apiFormat] - "openai" (default) | "anthropic"
|
|
11
14
|
*/
|
|
12
|
-
constructor({ apiKey, baseUrl }) {
|
|
15
|
+
constructor({ apiKey, baseUrl, authType = "bearer", apiFormat = "openai" }) {
|
|
13
16
|
this.apiKey = apiKey;
|
|
14
17
|
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
18
|
+
this.authType = authType;
|
|
19
|
+
this.apiFormat = apiFormat;
|
|
15
20
|
}
|
|
16
21
|
|
|
17
22
|
/**
|
|
18
|
-
*
|
|
23
|
+
* Build auth headers based on provider type.
|
|
24
|
+
* @returns {object}
|
|
25
|
+
*/
|
|
26
|
+
_buildHeaders() {
|
|
27
|
+
const headers = { "Content-Type": "application/json" };
|
|
28
|
+
if (this.authType === "x-api-key") {
|
|
29
|
+
headers["x-api-key"] = this.apiKey;
|
|
30
|
+
headers["anthropic-version"] = "2023-06-01";
|
|
31
|
+
} else if (this.authType === "aws-sigv4") {
|
|
32
|
+
throw new Error(
|
|
33
|
+
"AWS Bedrock authentication (SigV4) is not yet supported. " +
|
|
34
|
+
"Please use a different provider or an OpenAI-compatible proxy."
|
|
35
|
+
);
|
|
36
|
+
} else {
|
|
37
|
+
headers["Authorization"] = `Bearer ${this.apiKey}`;
|
|
38
|
+
}
|
|
39
|
+
return headers;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Get the chat endpoint for the configured API format.
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
_getEndpoint() {
|
|
47
|
+
if (this.apiFormat === "anthropic") {
|
|
48
|
+
return `${this.baseUrl}/v1/messages`;
|
|
49
|
+
}
|
|
50
|
+
return `${this.baseUrl}/chat/completions`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Build request body for the configured API format.
|
|
19
55
|
* @param {object} opts
|
|
20
|
-
* @
|
|
21
|
-
* @param {Array} opts.messages
|
|
22
|
-
* @param {Array} [opts.tools]
|
|
23
|
-
* @param {number} [opts.maxTokens]
|
|
24
|
-
* @yields {object} Parsed chunk from the SSE stream
|
|
56
|
+
* @returns {object}
|
|
25
57
|
*/
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
model,
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
58
|
+
_buildStreamBody({ model, messages, tools, maxTokens }) {
|
|
59
|
+
if (this.apiFormat === "anthropic") {
|
|
60
|
+
return this._buildAnthropicBody({ model, messages, tools, maxTokens, stream: true });
|
|
61
|
+
}
|
|
62
|
+
return this._buildOpenaiBody({ model, messages, tools, maxTokens, stream: true });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
_buildNonStreamBody({ model, messages, maxTokens }) {
|
|
66
|
+
if (this.apiFormat === "anthropic") {
|
|
67
|
+
return this._buildAnthropicBody({ model, messages, tools: null, maxTokens, stream: false });
|
|
68
|
+
}
|
|
69
|
+
return this._buildOpenaiBody({ model, messages, tools: null, maxTokens, stream: false });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
_buildOpenaiBody({ model, messages, tools, maxTokens, stream }) {
|
|
73
|
+
const body = { model, messages, stream };
|
|
32
74
|
if (maxTokens) body.max_tokens = maxTokens;
|
|
33
75
|
if (tools && tools.length > 0) body.tools = tools;
|
|
76
|
+
return body;
|
|
77
|
+
}
|
|
34
78
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
79
|
+
_buildAnthropicBody({ model, messages, tools, maxTokens, stream }) {
|
|
80
|
+
// Anthropic: system message is a top-level field, not in messages array
|
|
81
|
+
let system = undefined;
|
|
82
|
+
const filteredMessages = [];
|
|
83
|
+
for (const msg of messages) {
|
|
84
|
+
if (msg.role === "system") {
|
|
85
|
+
system = (system ? system + "\n\n" : "") + msg.content;
|
|
86
|
+
} else if (msg.role === "tool") {
|
|
87
|
+
// Anthropic expects tool results as user messages with tool_result content blocks
|
|
88
|
+
filteredMessages.push({
|
|
89
|
+
role: "user",
|
|
90
|
+
content: [{
|
|
91
|
+
type: "tool_result",
|
|
92
|
+
tool_use_id: msg.tool_call_id,
|
|
93
|
+
content: msg.content,
|
|
94
|
+
}],
|
|
95
|
+
});
|
|
96
|
+
} else if (msg.role === "assistant" && msg.tool_calls) {
|
|
97
|
+
// Convert OpenAI tool_calls to Anthropic content blocks
|
|
98
|
+
const content = [];
|
|
99
|
+
if (msg.content) content.push({ type: "text", text: msg.content });
|
|
100
|
+
for (const tc of msg.tool_calls) {
|
|
101
|
+
let input = {};
|
|
102
|
+
try { input = JSON.parse(tc.function.arguments || "{}"); } catch { /* ignore */ }
|
|
103
|
+
content.push({
|
|
104
|
+
type: "tool_use",
|
|
105
|
+
id: tc.id,
|
|
106
|
+
name: tc.function.name,
|
|
107
|
+
input,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
filteredMessages.push({ role: "assistant", content });
|
|
111
|
+
} else {
|
|
112
|
+
filteredMessages.push(msg);
|
|
113
|
+
}
|
|
47
114
|
}
|
|
48
115
|
|
|
49
|
-
|
|
50
|
-
|
|
116
|
+
const body = {
|
|
117
|
+
model,
|
|
118
|
+
messages: filteredMessages,
|
|
119
|
+
max_tokens: maxTokens || 8192,
|
|
120
|
+
stream,
|
|
121
|
+
};
|
|
122
|
+
if (system) body.system = system;
|
|
123
|
+
if (tools && tools.length > 0) {
|
|
124
|
+
// Convert OpenAI tool schema to Anthropic tool schema
|
|
125
|
+
body.tools = tools.map((t) => ({
|
|
126
|
+
name: t.function.name,
|
|
127
|
+
description: t.function.description || "",
|
|
128
|
+
input_schema: t.function.parameters || { type: "object", properties: {} },
|
|
129
|
+
}));
|
|
51
130
|
}
|
|
131
|
+
return body;
|
|
52
132
|
}
|
|
53
133
|
|
|
54
134
|
/**
|
|
55
|
-
*
|
|
135
|
+
* Streaming chat completion. Yields parsed SSE chunk objects
|
|
136
|
+
* normalized to OpenAI shape: { choices: [{ delta: { content?, tool_calls? } }] }
|
|
56
137
|
* @param {object} opts
|
|
57
138
|
* @param {string} opts.model
|
|
58
139
|
* @param {Array} opts.messages
|
|
140
|
+
* @param {Array} [opts.tools]
|
|
59
141
|
* @param {number} [opts.maxTokens]
|
|
60
|
-
* @
|
|
142
|
+
* @yields {object} Normalized chunk
|
|
143
|
+
*/
|
|
144
|
+
async *streamChat({ model, messages, tools, maxTokens }) {
|
|
145
|
+
const body = this._buildStreamBody({ model, messages, tools, maxTokens });
|
|
146
|
+
|
|
147
|
+
const resp = await withRetry(async () => {
|
|
148
|
+
const r = await fetch(this._getEndpoint(), {
|
|
149
|
+
method: "POST",
|
|
150
|
+
headers: this._buildHeaders(),
|
|
151
|
+
body: JSON.stringify(body),
|
|
152
|
+
});
|
|
153
|
+
if (!r.ok) {
|
|
154
|
+
const text = await r.text();
|
|
155
|
+
const err = new Error(`LLM API error ${r.status}: ${text}`);
|
|
156
|
+
err.status = r.status;
|
|
157
|
+
err.retryAfter = r.headers.get("retry-after");
|
|
158
|
+
throw err;
|
|
159
|
+
}
|
|
160
|
+
return r;
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
if (this.apiFormat === "anthropic") {
|
|
164
|
+
yield* this._parseAnthropicSSE(resp.body);
|
|
165
|
+
} else {
|
|
166
|
+
yield* this._parseOpenaiSSE(resp.body);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Non-streaming chat completion. Returns the full response
|
|
172
|
+
* normalized to OpenAI shape.
|
|
173
|
+
* @param {object} opts
|
|
174
|
+
* @returns {object}
|
|
61
175
|
*/
|
|
62
176
|
async chat({ model, messages, maxTokens }) {
|
|
63
|
-
const body = {
|
|
64
|
-
model,
|
|
65
|
-
messages,
|
|
66
|
-
};
|
|
67
|
-
if (maxTokens) body.max_tokens = maxTokens;
|
|
177
|
+
const body = this._buildNonStreamBody({ model, messages, maxTokens });
|
|
68
178
|
|
|
69
|
-
const resp = await
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
|
|
179
|
+
const resp = await withRetry(async () => {
|
|
180
|
+
const r = await fetch(this._getEndpoint(), {
|
|
181
|
+
method: "POST",
|
|
182
|
+
headers: this._buildHeaders(),
|
|
183
|
+
body: JSON.stringify(body),
|
|
184
|
+
});
|
|
185
|
+
if (!r.ok) {
|
|
186
|
+
const text = await r.text();
|
|
187
|
+
const err = new Error(`LLM API error ${r.status}: ${text}`);
|
|
188
|
+
err.status = r.status;
|
|
189
|
+
err.retryAfter = r.headers.get("retry-after");
|
|
190
|
+
throw err;
|
|
191
|
+
}
|
|
192
|
+
return r;
|
|
76
193
|
});
|
|
77
194
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
195
|
+
const data = await resp.json();
|
|
196
|
+
|
|
197
|
+
if (this.apiFormat === "anthropic") {
|
|
198
|
+
// Normalize Anthropic response to OpenAI shape
|
|
199
|
+
const textParts = [];
|
|
200
|
+
for (const block of data.content || []) {
|
|
201
|
+
if (block.type === "text") textParts.push(block.text);
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
choices: [{
|
|
205
|
+
message: {
|
|
206
|
+
role: "assistant",
|
|
207
|
+
content: textParts.join(""),
|
|
208
|
+
},
|
|
209
|
+
}],
|
|
210
|
+
usage: data.usage ? {
|
|
211
|
+
prompt_tokens: data.usage.input_tokens || 0,
|
|
212
|
+
completion_tokens: data.usage.output_tokens || 0,
|
|
213
|
+
} : undefined,
|
|
214
|
+
};
|
|
81
215
|
}
|
|
82
216
|
|
|
83
|
-
return
|
|
217
|
+
return data;
|
|
84
218
|
}
|
|
85
219
|
|
|
86
220
|
/**
|
|
87
|
-
*
|
|
88
|
-
*
|
|
221
|
+
* List available models from the provider.
|
|
222
|
+
* @returns {Promise<Array<{id: string, name: string, ownedBy: string}>>}
|
|
223
|
+
*/
|
|
224
|
+
async listModels() {
|
|
225
|
+
let endpoint;
|
|
226
|
+
if (this.apiFormat === "anthropic") {
|
|
227
|
+
endpoint = `${this.baseUrl}/v1/models`;
|
|
228
|
+
} else {
|
|
229
|
+
endpoint = `${this.baseUrl}/models`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
const resp = await fetch(endpoint, {
|
|
234
|
+
method: "GET",
|
|
235
|
+
headers: this._buildHeaders(),
|
|
236
|
+
signal: AbortSignal.timeout(5000),
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
if (!resp.ok) return [];
|
|
240
|
+
const data = await resp.json();
|
|
241
|
+
return (data.data || []).map((m) => ({
|
|
242
|
+
id: m.id,
|
|
243
|
+
name: m.id,
|
|
244
|
+
ownedBy: m.owned_by || "",
|
|
245
|
+
}));
|
|
246
|
+
} catch {
|
|
247
|
+
return [];
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// --- OpenAI SSE parsing ---
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Parse SSE stream from OpenAI-compatible API.
|
|
89
255
|
* @param {ReadableStream} body
|
|
90
|
-
* @yields {object} Parsed
|
|
256
|
+
* @yields {object} Parsed chunk
|
|
91
257
|
*/
|
|
92
|
-
async *
|
|
258
|
+
async *_parseOpenaiSSE(body) {
|
|
93
259
|
const decoder = new TextDecoder();
|
|
94
260
|
let buffer = "";
|
|
95
261
|
|
|
96
262
|
for await (const chunk of body) {
|
|
97
263
|
buffer += decoder.decode(chunk, { stream: true });
|
|
98
264
|
const lines = buffer.split("\n");
|
|
99
|
-
buffer = lines.pop();
|
|
265
|
+
buffer = lines.pop();
|
|
100
266
|
|
|
101
267
|
for (const line of lines) {
|
|
102
268
|
const trimmed = line.trim();
|
|
@@ -113,19 +279,146 @@ export class LLMClient {
|
|
|
113
279
|
}
|
|
114
280
|
}
|
|
115
281
|
|
|
116
|
-
// Process any remaining buffer
|
|
117
282
|
if (buffer.trim()) {
|
|
118
283
|
const trimmed = buffer.trim();
|
|
119
284
|
if (trimmed.startsWith("data: ")) {
|
|
120
285
|
const data = trimmed.slice(6).trim();
|
|
121
286
|
if (data !== "[DONE]") {
|
|
122
|
-
try {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
287
|
+
try { yield JSON.parse(data); } catch { /* skip */ }
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// --- Anthropic SSE parsing + normalization ---
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Parse Anthropic SSE stream and normalize to OpenAI chunk shape.
|
|
297
|
+
* Anthropic SSE uses event types: message_start, content_block_start,
|
|
298
|
+
* content_block_delta, content_block_stop, message_delta, message_stop.
|
|
299
|
+
*
|
|
300
|
+
* Normalizes everything to: { choices: [{ delta: { content?, tool_calls? } }] }
|
|
301
|
+
* so engine.js needs no changes.
|
|
302
|
+
*
|
|
303
|
+
* @param {ReadableStream} body
|
|
304
|
+
* @yields {object} Normalized OpenAI-shaped chunk
|
|
305
|
+
*/
|
|
306
|
+
async *_parseAnthropicSSE(body) {
|
|
307
|
+
const decoder = new TextDecoder();
|
|
308
|
+
let buffer = "";
|
|
309
|
+
let currentEventType = "";
|
|
310
|
+
|
|
311
|
+
// State for accumulating tool call content blocks
|
|
312
|
+
let toolCallIndex = -1;
|
|
313
|
+
|
|
314
|
+
for await (const rawChunk of body) {
|
|
315
|
+
buffer += decoder.decode(rawChunk, { stream: true });
|
|
316
|
+
const lines = buffer.split("\n");
|
|
317
|
+
buffer = lines.pop();
|
|
318
|
+
|
|
319
|
+
for (const line of lines) {
|
|
320
|
+
const trimmed = line.trim();
|
|
321
|
+
if (!trimmed || trimmed.startsWith(":")) continue;
|
|
322
|
+
|
|
323
|
+
if (trimmed.startsWith("event: ")) {
|
|
324
|
+
currentEventType = trimmed.slice(7).trim();
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (trimmed.startsWith("data: ")) {
|
|
329
|
+
const dataStr = trimmed.slice(6).trim();
|
|
330
|
+
let data;
|
|
331
|
+
try { data = JSON.parse(dataStr); } catch { continue; }
|
|
332
|
+
|
|
333
|
+
const normalized = this._normalizeAnthropicEvent(currentEventType, data, { toolCallIndex });
|
|
334
|
+
if (normalized) {
|
|
335
|
+
// Update tool call index tracking
|
|
336
|
+
if (normalized._newToolCallIndex !== undefined) {
|
|
337
|
+
toolCallIndex = normalized._newToolCallIndex;
|
|
338
|
+
delete normalized._newToolCallIndex;
|
|
339
|
+
}
|
|
340
|
+
yield normalized;
|
|
126
341
|
}
|
|
127
342
|
}
|
|
128
343
|
}
|
|
129
344
|
}
|
|
130
345
|
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Normalize a single Anthropic SSE event into OpenAI chunk shape.
|
|
349
|
+
* @param {string} eventType
|
|
350
|
+
* @param {object} data
|
|
351
|
+
* @param {object} state - Mutable state for tracking across events
|
|
352
|
+
* @returns {object|null} Normalized chunk or null if no output needed
|
|
353
|
+
*/
|
|
354
|
+
_normalizeAnthropicEvent(eventType, data, state) {
|
|
355
|
+
switch (eventType) {
|
|
356
|
+
case "content_block_start": {
|
|
357
|
+
const block = data.content_block;
|
|
358
|
+
if (block?.type === "text") {
|
|
359
|
+
// Text block starting — no content yet
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
if (block?.type === "tool_use") {
|
|
363
|
+
state.toolCallIndex++;
|
|
364
|
+
const chunk = {
|
|
365
|
+
choices: [{
|
|
366
|
+
delta: {
|
|
367
|
+
tool_calls: [{
|
|
368
|
+
index: state.toolCallIndex,
|
|
369
|
+
id: block.id,
|
|
370
|
+
type: "function",
|
|
371
|
+
function: { name: block.name, arguments: "" },
|
|
372
|
+
}],
|
|
373
|
+
},
|
|
374
|
+
}],
|
|
375
|
+
_newToolCallIndex: state.toolCallIndex,
|
|
376
|
+
};
|
|
377
|
+
return chunk;
|
|
378
|
+
}
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
case "content_block_delta": {
|
|
383
|
+
const delta = data.delta;
|
|
384
|
+
if (delta?.type === "text_delta") {
|
|
385
|
+
return {
|
|
386
|
+
choices: [{ delta: { content: delta.text } }],
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
if (delta?.type === "input_json_delta") {
|
|
390
|
+
return {
|
|
391
|
+
choices: [{
|
|
392
|
+
delta: {
|
|
393
|
+
tool_calls: [{
|
|
394
|
+
index: state.toolCallIndex,
|
|
395
|
+
function: { arguments: delta.partial_json },
|
|
396
|
+
}],
|
|
397
|
+
},
|
|
398
|
+
}],
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
case "message_delta": {
|
|
405
|
+
// End of message — contains stop_reason and usage
|
|
406
|
+
return {
|
|
407
|
+
choices: [{
|
|
408
|
+
delta: {},
|
|
409
|
+
finish_reason: data.delta?.stop_reason === "end_turn" ? "stop" : (data.delta?.stop_reason || null),
|
|
410
|
+
}],
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
case "message_start":
|
|
415
|
+
case "content_block_stop":
|
|
416
|
+
case "message_stop":
|
|
417
|
+
case "ping":
|
|
418
|
+
return null;
|
|
419
|
+
|
|
420
|
+
default:
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
131
424
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { Phase, PipelineEvent } from "./index.js";
|
|
4
|
+
import { Pipeline } from "./base.js";
|
|
5
|
+
|
|
6
|
+
export class DistillationEngine extends Pipeline {
|
|
7
|
+
constructor(workspace) {
|
|
8
|
+
super();
|
|
9
|
+
this._workspace = workspace;
|
|
10
|
+
this.skillsToDistill = [];
|
|
11
|
+
this.workflowsCreated = {};
|
|
12
|
+
this.workflowsTested = {};
|
|
13
|
+
this.workflowsPassing = [];
|
|
14
|
+
this.tierAssignments = {};
|
|
15
|
+
this._workflowAccuracy = 0.9;
|
|
16
|
+
this._scanWorkspace();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
_scanWorkspace() {
|
|
20
|
+
this._loadConfig();
|
|
21
|
+
this._loadSkills();
|
|
22
|
+
this._scanWorkflows();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
_loadConfig() {
|
|
26
|
+
const envPath = path.join(this._workspace.cwd, ".env");
|
|
27
|
+
if (!fs.existsSync(envPath)) return;
|
|
28
|
+
for (const line of fs.readFileSync(envPath, "utf-8").split("\n")) {
|
|
29
|
+
if (line.startsWith("WORKFLOW_ACCURACY=")) try { this._workflowAccuracy = parseFloat(line.split("=")[1]); } catch { /* skip */ }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
_loadSkills() {
|
|
34
|
+
this.skillsToDistill = [];
|
|
35
|
+
const dir = path.join(this._workspace.cwd, "rule_skills");
|
|
36
|
+
if (!fs.existsSync(dir)) return;
|
|
37
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
38
|
+
if (e.isDirectory() && !e.name.startsWith("__")) this.skillsToDistill.push(e.name);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
_scanWorkflows() {
|
|
43
|
+
this.workflowsCreated = {};
|
|
44
|
+
this.workflowsTested = {};
|
|
45
|
+
this.workflowsPassing = [];
|
|
46
|
+
this.tierAssignments = {};
|
|
47
|
+
const wfDir = path.join(this._workspace.cwd, "workflows");
|
|
48
|
+
if (!fs.existsSync(wfDir)) return;
|
|
49
|
+
|
|
50
|
+
for (const e of fs.readdirSync(wfDir, { withFileTypes: true })) {
|
|
51
|
+
if (e.isDirectory()) {
|
|
52
|
+
const ruleDir = path.join(wfDir, e.name);
|
|
53
|
+
const pyFiles = fs.readdirSync(ruleDir).filter((f) => f.endsWith(".py"));
|
|
54
|
+
if (pyFiles.length > 0) this.workflowsCreated[e.name] = pyFiles.length;
|
|
55
|
+
const cfgPath = path.join(ruleDir, "config.json");
|
|
56
|
+
if (fs.existsSync(cfgPath)) {
|
|
57
|
+
try {
|
|
58
|
+
const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
|
|
59
|
+
if (cfg.tier) this.tierAssignments[e.name] = cfg.tier;
|
|
60
|
+
if (cfg.accuracy != null) {
|
|
61
|
+
const acc = parseFloat(cfg.accuracy);
|
|
62
|
+
this.workflowsTested[e.name] = acc;
|
|
63
|
+
if (acc >= this._workflowAccuracy) this.workflowsPassing.push(e.name);
|
|
64
|
+
}
|
|
65
|
+
} catch { /* skip */ }
|
|
66
|
+
}
|
|
67
|
+
} else if (e.isFile() && e.name.endsWith(".py")) {
|
|
68
|
+
this.workflowsCreated[path.parse(e.name).name] = 1;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describeState() {
|
|
74
|
+
this._scanWorkspace();
|
|
75
|
+
const total = this.skillsToDistill.length;
|
|
76
|
+
const created = Object.keys(this.workflowsCreated).length;
|
|
77
|
+
const passing = this.workflowsPassing.length;
|
|
78
|
+
const parts = ["## Current Phase: DISTILLATION"];
|
|
79
|
+
parts.push(`### Progress\n- Skills to distill: ${total}\n- Workflows created: ${created}\n- Workflows passing (>=${this._workflowAccuracy}): ${passing}`);
|
|
80
|
+
|
|
81
|
+
if (this.exitCriteriaMet()) {
|
|
82
|
+
parts.push("### Ready\nAll workflows passing. Proceed to PRODUCTION_QC.");
|
|
83
|
+
} else if (created === 0) {
|
|
84
|
+
parts.push("### What to do now\nConvert proven skills into worker LLM workflows.\nFor each skill: write workflow script, write prompts, test vs ground truth, tier-downgrade test.");
|
|
85
|
+
} else {
|
|
86
|
+
const notCreated = this.skillsToDistill.filter((s) => !(s in this.workflowsCreated));
|
|
87
|
+
const notPassing = Object.keys(this.workflowsCreated).filter((s) => !this.workflowsPassing.includes(s));
|
|
88
|
+
let guidance = "### What to do now\n";
|
|
89
|
+
if (notCreated.length) guidance += `Create workflows for: ${notCreated.slice(0, 10).join(", ")}\n`;
|
|
90
|
+
if (notPassing.length) guidance += `Improve accuracy for: ${notPassing.slice(0, 10).join(", ")}\n`;
|
|
91
|
+
parts.push(guidance);
|
|
92
|
+
}
|
|
93
|
+
return parts.join("\n\n");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
onToolResult(toolName, toolInput, result) {
|
|
97
|
+
if (result.isError) return null;
|
|
98
|
+
const wasReady = this.exitCriteriaMet();
|
|
99
|
+
if (toolName === "workspace_file" && ((toolInput.path || "").includes("workflows/") || (toolInput.path || "").includes("output/"))) {
|
|
100
|
+
this._scanWorkflows();
|
|
101
|
+
}
|
|
102
|
+
if (!wasReady && this.exitCriteriaMet()) {
|
|
103
|
+
return new PipelineEvent({ type: "phase_ready", message: "Distillation complete. Ready for PRODUCTION_QC.", nextPhase: Phase.PRODUCTION_QC });
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
exitCriteriaMet() {
|
|
109
|
+
const total = this.skillsToDistill.length;
|
|
110
|
+
if (!total) return false;
|
|
111
|
+
return Object.keys(this.workflowsCreated).length >= total && this.workflowsPassing.length >= total;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { Phase, PipelineEvent } from "./index.js";
|
|
4
|
+
import { Pipeline } from "./base.js";
|
|
5
|
+
|
|
6
|
+
export class RuleExtractionPipeline extends Pipeline {
|
|
7
|
+
constructor(workspace) {
|
|
8
|
+
super();
|
|
9
|
+
this._workspace = workspace;
|
|
10
|
+
this.regulationsScanned = false;
|
|
11
|
+
this.rulesExtracted = [];
|
|
12
|
+
this.rulesWithTests = [];
|
|
13
|
+
this.coverageAudited = false;
|
|
14
|
+
this._scanWorkspace();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
_scanWorkspace() {
|
|
18
|
+
const rulesDir = path.join(this._workspace.cwd, "rules");
|
|
19
|
+
if (fs.existsSync(rulesDir)) {
|
|
20
|
+
const regFiles = fs.readdirSync(rulesDir).filter((f) => !f.endsWith(".json") && fs.statSync(path.join(rulesDir, f)).isFile());
|
|
21
|
+
this.regulationsScanned = regFiles.length > 0;
|
|
22
|
+
}
|
|
23
|
+
this._scanRules();
|
|
24
|
+
this._scanTests();
|
|
25
|
+
this.coverageAudited = fs.existsSync(path.join(this._workspace.cwd, "rules", "coverage_audit.md")) ||
|
|
26
|
+
fs.existsSync(path.join(this._workspace.cwd, "rules", "coverage_audit.json"));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_scanRules() {
|
|
30
|
+
this.rulesExtracted = [];
|
|
31
|
+
const catalogPath = path.join(this._workspace.cwd, "rules", "catalog.json");
|
|
32
|
+
if (fs.existsSync(catalogPath)) {
|
|
33
|
+
try {
|
|
34
|
+
const data = JSON.parse(fs.readFileSync(catalogPath, "utf-8"));
|
|
35
|
+
if (Array.isArray(data)) this.rulesExtracted = data.map((r, i) => r.id || `rule_${i}`);
|
|
36
|
+
} catch { /* skip */ }
|
|
37
|
+
}
|
|
38
|
+
const skillsDir = path.join(this._workspace.cwd, "rule_skills");
|
|
39
|
+
if (fs.existsSync(skillsDir)) {
|
|
40
|
+
for (const e of fs.readdirSync(skillsDir, { withFileTypes: true })) {
|
|
41
|
+
if (e.isDirectory() && !e.name.startsWith("__") && !this.rulesExtracted.includes(e.name)) {
|
|
42
|
+
this.rulesExtracted.push(e.name);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_scanTests() {
|
|
49
|
+
this.rulesWithTests = [];
|
|
50
|
+
const skillsDir = path.join(this._workspace.cwd, "rule_skills");
|
|
51
|
+
if (!fs.existsSync(skillsDir)) return;
|
|
52
|
+
for (const e of fs.readdirSync(skillsDir, { withFileTypes: true })) {
|
|
53
|
+
if (!e.isDirectory()) continue;
|
|
54
|
+
const testDir = path.join(skillsDir, e.name, "test_cases");
|
|
55
|
+
if (fs.existsSync(testDir) && fs.readdirSync(testDir).length > 0) {
|
|
56
|
+
this.rulesWithTests.push(e.name);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describeState() {
|
|
62
|
+
this._scanWorkspace();
|
|
63
|
+
const parts = ["## Current Phase: EXTRACTION"];
|
|
64
|
+
parts.push(`### Progress\n- Regulations scanned: ${this.regulationsScanned ? "yes" : "no"}\n- Rules extracted: ${this.rulesExtracted.length}\n- Rules with tests: ${this.rulesWithTests.length}\n- Coverage audit: ${this.coverageAudited ? "done" : "not yet"}`);
|
|
65
|
+
|
|
66
|
+
if (this.exitCriteriaMet()) {
|
|
67
|
+
parts.push("### Ready\nExtraction complete. Proceed to SKILL_AUTHORING phase.");
|
|
68
|
+
} else if (this.rulesExtracted.length === 0) {
|
|
69
|
+
parts.push("### What to do now\nDecompose regulations into atomic, testable rules.\n- One rule = one pass/fail outcome\n- Work top-down: major areas → chapters → sections → atomic rules\n- Save rules to rules/catalog.json via rule_catalog tool");
|
|
70
|
+
} else if (!this.coverageAudited) {
|
|
71
|
+
parts.push("### What to do now\nRun a coverage audit: which regulation sections are NOT covered? Save to rules/coverage_audit.md");
|
|
72
|
+
}
|
|
73
|
+
return parts.join("\n\n");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
onToolResult(toolName, toolInput, result) {
|
|
77
|
+
if (result.isError) return null;
|
|
78
|
+
const wasReady = this.exitCriteriaMet();
|
|
79
|
+
if (toolName === "workspace_file" || toolName === "rule_catalog") {
|
|
80
|
+
this._scanWorkspace();
|
|
81
|
+
}
|
|
82
|
+
if (!wasReady && this.exitCriteriaMet()) {
|
|
83
|
+
return new PipelineEvent({ type: "phase_ready", message: "Extraction complete. Ready for SKILL_AUTHORING.", nextPhase: Phase.SKILL_AUTHORING });
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
exitCriteriaMet() {
|
|
89
|
+
return this.regulationsScanned && this.rulesExtracted.length > 0 &&
|
|
90
|
+
this.rulesWithTests.length >= Math.max(this.rulesExtracted.length * 0.8, 1) && this.coverageAudited;
|
|
91
|
+
}
|
|
92
|
+
}
|