kc-beta 0.1.2 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/kc-beta.js +14 -2
- package/package.json +1 -1
- package/src/agent/context-window.js +151 -0
- package/src/agent/engine.js +202 -5
- package/src/agent/event-log.js +111 -0
- package/src/agent/llm-client.js +352 -59
- package/src/agent/pipelines/base.js +6 -0
- package/src/agent/pipelines/distillation.js +18 -0
- package/src/agent/pipelines/extraction.js +21 -0
- package/src/agent/pipelines/initializer.js +22 -6
- package/src/agent/pipelines/production-qc.js +19 -0
- package/src/agent/pipelines/skill-authoring.js +14 -0
- package/src/agent/pipelines/skill-testing.js +20 -0
- package/src/agent/retry.js +83 -0
- package/src/agent/session-state.js +78 -0
- package/src/agent/token-counter.js +62 -0
- package/src/agent/tools/document-parse.js +3 -3
- 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 +151 -57
- package/src/config.js +20 -7
- package/src/providers.js +370 -0
package/bin/kc-beta.js
CHANGED
|
@@ -1,16 +1,28 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// Parse --en / --zh from anywhere in argv (session-only language override)
|
|
4
|
+
const args = process.argv.slice(2);
|
|
5
|
+
let languageOverride = null;
|
|
6
|
+
const filtered = [];
|
|
7
|
+
for (const arg of args) {
|
|
8
|
+
if (arg === "--en") languageOverride = "en";
|
|
9
|
+
else if (arg === "--zh") languageOverride = "zh";
|
|
10
|
+
else filtered.push(arg);
|
|
11
|
+
}
|
|
12
|
+
const subcommand = filtered[0];
|
|
4
13
|
|
|
5
14
|
(async () => {
|
|
6
15
|
if (subcommand === "onboard" || subcommand === "setup") {
|
|
7
16
|
const { onboard } = await import("../src/cli/onboard.js");
|
|
8
17
|
await onboard();
|
|
18
|
+
} else if (subcommand === "config") {
|
|
19
|
+
const { configEditor } = await import("../src/cli/config.js");
|
|
20
|
+
await configEditor();
|
|
9
21
|
} else if (subcommand === "init") {
|
|
10
22
|
const { init } = await import("../src/cli/init.js");
|
|
11
23
|
await init();
|
|
12
24
|
} else {
|
|
13
25
|
const { main } = await import("../src/cli/index.js");
|
|
14
|
-
await main();
|
|
26
|
+
await main({ languageOverride });
|
|
15
27
|
}
|
|
16
28
|
})();
|
package/package.json
CHANGED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { estimateTokens, estimateMessagesTokens } from "./token-counter.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Automatic context windowing for long conversations.
|
|
5
|
+
* When messages approach the model's context limit, older messages
|
|
6
|
+
* are compressed into summaries while keeping recent messages intact.
|
|
7
|
+
*/
|
|
8
|
+
export class ContextWindow {
|
|
9
|
+
/**
|
|
10
|
+
* @param {object} opts
|
|
11
|
+
* @param {number} opts.contextLimit - Total model context limit in tokens
|
|
12
|
+
* @param {number} [opts.reserveForResponse=8192] - Tokens reserved for model output
|
|
13
|
+
* @param {number} [opts.recentWindowSize=30] - Number of recent messages to always keep
|
|
14
|
+
*/
|
|
15
|
+
constructor({ contextLimit, reserveForResponse = 8192, recentWindowSize = 30 }) {
|
|
16
|
+
this.contextLimit = contextLimit;
|
|
17
|
+
this.reserveForResponse = reserveForResponse;
|
|
18
|
+
this.recentWindowSize = recentWindowSize;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Apply windowing to a message array if it exceeds the token budget.
|
|
23
|
+
* @param {Array<object>} messages - Full message history
|
|
24
|
+
* @param {string[]} [phaseSummaries] - Summaries from completed pipeline phases
|
|
25
|
+
* @returns {{ messages: Array, wasWindowed: boolean, removedCount: number }}
|
|
26
|
+
*/
|
|
27
|
+
window(messages, phaseSummaries = []) {
|
|
28
|
+
const totalTokens = estimateMessagesTokens(messages);
|
|
29
|
+
const budget = this.contextLimit - this.reserveForResponse;
|
|
30
|
+
|
|
31
|
+
// If within budget, return as-is
|
|
32
|
+
if (totalTokens <= budget * 0.85) {
|
|
33
|
+
return { messages, wasWindowed: false, removedCount: 0 };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Split into older and recent
|
|
37
|
+
const splitPoint = Math.max(0, messages.length - this.recentWindowSize);
|
|
38
|
+
const recentMessages = messages.slice(splitPoint);
|
|
39
|
+
const olderMessages = messages.slice(0, splitPoint);
|
|
40
|
+
|
|
41
|
+
if (olderMessages.length === 0) {
|
|
42
|
+
return { messages, wasWindowed: false, removedCount: 0 };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Build a compact summary of older messages
|
|
46
|
+
const recentTokens = estimateMessagesTokens(recentMessages);
|
|
47
|
+
const summaryBudget = budget - recentTokens - 500; // 500 tokens buffer
|
|
48
|
+
const compactedSummary = this._compactMessages(olderMessages, phaseSummaries, summaryBudget);
|
|
49
|
+
|
|
50
|
+
const windowedMessages = [
|
|
51
|
+
{
|
|
52
|
+
role: "user",
|
|
53
|
+
content: `[Context Summary - Earlier conversation compressed]\n\n${compactedSummary}`,
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
role: "assistant",
|
|
57
|
+
content: "Understood. I have the context from the summary above. Continuing with the current work.",
|
|
58
|
+
},
|
|
59
|
+
...recentMessages,
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
messages: windowedMessages,
|
|
64
|
+
wasWindowed: true,
|
|
65
|
+
removedCount: olderMessages.length,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Create a mechanical compact summary of messages.
|
|
71
|
+
* Groups into conversational turns and extracts key info.
|
|
72
|
+
* @param {Array<object>} messages
|
|
73
|
+
* @param {string[]} phaseSummaries
|
|
74
|
+
* @param {number} tokenBudget
|
|
75
|
+
* @returns {string}
|
|
76
|
+
*/
|
|
77
|
+
_compactMessages(messages, phaseSummaries, tokenBudget) {
|
|
78
|
+
const parts = [];
|
|
79
|
+
|
|
80
|
+
// Phase summaries first (high signal)
|
|
81
|
+
if (phaseSummaries.length > 0) {
|
|
82
|
+
parts.push("## Phase History");
|
|
83
|
+
for (const s of phaseSummaries) {
|
|
84
|
+
parts.push(`- ${s}`);
|
|
85
|
+
}
|
|
86
|
+
parts.push("");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Extract key events from older messages
|
|
90
|
+
parts.push("## Conversation Summary");
|
|
91
|
+
const turns = this._groupIntoTurns(messages);
|
|
92
|
+
|
|
93
|
+
for (const turn of turns) {
|
|
94
|
+
const line = this._summarizeTurn(turn);
|
|
95
|
+
if (line) {
|
|
96
|
+
parts.push(`- ${line}`);
|
|
97
|
+
// Check budget
|
|
98
|
+
if (estimateTokens(parts.join("\n")) > tokenBudget * 0.9) {
|
|
99
|
+
parts.push("- [earlier history truncated]");
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return parts.join("\n");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Group messages into user-turn blocks.
|
|
110
|
+
* Each turn: { user: string, tools: [{name, summary}], assistantSummary: string }
|
|
111
|
+
*/
|
|
112
|
+
_groupIntoTurns(messages) {
|
|
113
|
+
const turns = [];
|
|
114
|
+
let current = null;
|
|
115
|
+
|
|
116
|
+
for (const msg of messages) {
|
|
117
|
+
if (msg.role === "user") {
|
|
118
|
+
if (current) turns.push(current);
|
|
119
|
+
current = { user: msg.content || "", tools: [], assistant: "" };
|
|
120
|
+
} else if (msg.role === "assistant" && current) {
|
|
121
|
+
if (msg.content) current.assistant = msg.content;
|
|
122
|
+
if (msg.tool_calls) {
|
|
123
|
+
for (const tc of msg.tool_calls) {
|
|
124
|
+
current.tools.push(tc.function?.name || "unknown");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// tool results are captured implicitly via tool names
|
|
129
|
+
}
|
|
130
|
+
if (current) turns.push(current);
|
|
131
|
+
return turns;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Summarize a single conversational turn into one line.
|
|
136
|
+
*/
|
|
137
|
+
_summarizeTurn(turn) {
|
|
138
|
+
const userSnippet = (turn.user || "").slice(0, 80).replace(/\n/g, " ");
|
|
139
|
+
if (!userSnippet) return null;
|
|
140
|
+
|
|
141
|
+
let line = `User: "${userSnippet}"`;
|
|
142
|
+
if (turn.tools.length > 0) {
|
|
143
|
+
line += ` → Tools: ${turn.tools.join(", ")}`;
|
|
144
|
+
}
|
|
145
|
+
if (turn.assistant) {
|
|
146
|
+
const aSnippet = turn.assistant.slice(0, 60).replace(/\n/g, " ");
|
|
147
|
+
line += ` → "${aSnippet}..."`;
|
|
148
|
+
}
|
|
149
|
+
return line;
|
|
150
|
+
}
|
|
151
|
+
}
|
package/src/agent/engine.js
CHANGED
|
@@ -18,6 +18,7 @@ import { DashboardRenderTool } from "./tools/dashboard-render.js";
|
|
|
18
18
|
import { EvolutionCycleTool } from "./tools/evolution-cycle.js";
|
|
19
19
|
import { TierDowngradeTool } from "./tools/tier-downgrade.js";
|
|
20
20
|
import { AgentTool } from "./tools/agent-tool.js";
|
|
21
|
+
import { WebSearchTool } from "./tools/web-search.js";
|
|
21
22
|
import { SkillLoader } from "./skill-loader.js";
|
|
22
23
|
import { Phase } from "./pipelines/index.js";
|
|
23
24
|
import { ProjectInitializer } from "./pipelines/initializer.js";
|
|
@@ -26,6 +27,10 @@ import { SkillAuthoringPipeline } from "./pipelines/skill-authoring.js";
|
|
|
26
27
|
import { SkillTestingPipeline } from "./pipelines/skill-testing.js";
|
|
27
28
|
import { DistillationEngine as DistillationPipeline } from "./pipelines/distillation.js";
|
|
28
29
|
import { ProductionQCPipeline } from "./pipelines/production-qc.js";
|
|
30
|
+
import { EventLog } from "./event-log.js";
|
|
31
|
+
import { ContextWindow } from "./context-window.js";
|
|
32
|
+
import { SessionState } from "./session-state.js";
|
|
33
|
+
import { estimateTokens, estimateMessagesTokens } from "./token-counter.js";
|
|
29
34
|
|
|
30
35
|
// Phases where worker LLM tools are available (DISTILL mode)
|
|
31
36
|
const DISTILL_PHASES = new Set([Phase.DISTILLATION, Phase.PRODUCTION_QC]);
|
|
@@ -57,8 +62,21 @@ export class AgentEngine {
|
|
|
57
62
|
this.cornerCases = new CornerCaseRegistry(this.workspace.cwd);
|
|
58
63
|
this.confidence = new ConfidenceScorer(this.workspace.cwd, this.cornerCases);
|
|
59
64
|
|
|
65
|
+
// Event log (append-only JSONL, source of truth)
|
|
66
|
+
this.eventLog = new EventLog(this.workspace.cwd);
|
|
67
|
+
|
|
68
|
+
// Context windowing
|
|
69
|
+
this.contextWindow = new ContextWindow({
|
|
70
|
+
contextLimit: config.kcContextLimit || 200000,
|
|
71
|
+
reserveForResponse: config.kcMaxTokens || 65536,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Session state persistence
|
|
75
|
+
this.sessionState = new SessionState(this.workspace.cwd);
|
|
76
|
+
|
|
60
77
|
// Build all tool instances (but register phase-appropriate ones)
|
|
61
78
|
this._buildTools = this._createAllTools();
|
|
79
|
+
this._phaseSummaries = [];
|
|
62
80
|
|
|
63
81
|
// Pipeline system (meta-meta skills as code)
|
|
64
82
|
this.currentPhase = Phase.BOOTSTRAP;
|
|
@@ -85,8 +103,9 @@ export class AgentEngine {
|
|
|
85
103
|
*/
|
|
86
104
|
_createAllTools() {
|
|
87
105
|
const workerLlm = new WorkerLLMCallTool(this.workspace, {
|
|
88
|
-
apiKey: this.config.
|
|
89
|
-
baseUrl: this.config.
|
|
106
|
+
apiKey: this.config.llmApiKey,
|
|
107
|
+
baseUrl: this.config.llmBaseUrl,
|
|
108
|
+
authType: this.config.authType,
|
|
90
109
|
});
|
|
91
110
|
|
|
92
111
|
return {
|
|
@@ -97,8 +116,8 @@ export class AgentEngine {
|
|
|
97
116
|
new DocumentParseTool(this.workspace, {
|
|
98
117
|
mineruApiUrl: this.config.mineruApiUrl,
|
|
99
118
|
mineruApiKey: this.config.mineruApiKey,
|
|
100
|
-
|
|
101
|
-
|
|
119
|
+
llmApiKey: this.config.llmApiKey,
|
|
120
|
+
llmBaseUrl: this.config.llmBaseUrl,
|
|
102
121
|
ocrModel: this.config.ocrModelTier1,
|
|
103
122
|
}),
|
|
104
123
|
new DocumentSearchTool(this.workspace),
|
|
@@ -108,6 +127,7 @@ export class AgentEngine {
|
|
|
108
127
|
new AgentTool(this.workspace, (sid) => new AgentEngine({
|
|
109
128
|
client: this.client, config: this.config, sessionId: sid,
|
|
110
129
|
})),
|
|
130
|
+
new WebSearchTool(this.config.tavilyApiKey),
|
|
111
131
|
],
|
|
112
132
|
// Distillation+ only (DISTILL mode)
|
|
113
133
|
distill: [
|
|
@@ -136,6 +156,144 @@ export class AgentEngine {
|
|
|
136
156
|
}
|
|
137
157
|
}
|
|
138
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Get current context usage statistics.
|
|
161
|
+
* @returns {{ totalTokens: number, limit: number, percentage: number }}
|
|
162
|
+
*/
|
|
163
|
+
getContextStats() {
|
|
164
|
+
const systemPrompt = this.context.build({
|
|
165
|
+
skillIndex: this._skillLoader.formatForContext(),
|
|
166
|
+
pipelineState: this.pipelines[this.currentPhase]?.describeState?.() || null,
|
|
167
|
+
workspaceState: `Your workspace directory is: ${this.workspace.cwd}`,
|
|
168
|
+
});
|
|
169
|
+
const systemTokens = estimateTokens(systemPrompt);
|
|
170
|
+
const messageTokens = estimateMessagesTokens(this.history.messages);
|
|
171
|
+
const totalTokens = systemTokens + messageTokens;
|
|
172
|
+
const limit = this.config.kcContextLimit || 200000;
|
|
173
|
+
return {
|
|
174
|
+
totalTokens,
|
|
175
|
+
limit,
|
|
176
|
+
percentage: Math.round((totalTokens / limit) * 100),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Compact conversation history by summarizing older messages via LLM.
|
|
182
|
+
* Keeps the most recent messages intact.
|
|
183
|
+
* @param {object} [opts]
|
|
184
|
+
* @param {number} [opts.recentCount=20] - Number of recent messages to keep
|
|
185
|
+
* @returns {Promise<{removedCount: number, retainedCount: number, summaryTokens: number}|null>}
|
|
186
|
+
*/
|
|
187
|
+
async compact({ recentCount = 20 } = {}) {
|
|
188
|
+
if (this.history.messages.length <= recentCount) return null;
|
|
189
|
+
|
|
190
|
+
const olderMessages = this.history.messages.slice(0, -recentCount);
|
|
191
|
+
const recentMessages = this.history.messages.slice(-recentCount);
|
|
192
|
+
|
|
193
|
+
let summary;
|
|
194
|
+
try {
|
|
195
|
+
const summaryResp = await this.client.chat({
|
|
196
|
+
model: this.config.kcModel,
|
|
197
|
+
messages: [
|
|
198
|
+
{
|
|
199
|
+
role: "system",
|
|
200
|
+
content:
|
|
201
|
+
"You are a conversation summarizer. Produce a concise summary of the following conversation. " +
|
|
202
|
+
"Focus on: decisions made, files created or modified, current state of work, key findings, " +
|
|
203
|
+
"unresolved questions. Be specific about file paths, rule IDs, and results. Keep under 2000 tokens.",
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
role: "user",
|
|
207
|
+
content: `Summarize this conversation:\n\n${JSON.stringify(olderMessages)}`,
|
|
208
|
+
},
|
|
209
|
+
],
|
|
210
|
+
maxTokens: 2048,
|
|
211
|
+
});
|
|
212
|
+
summary = summaryResp.choices?.[0]?.message?.content || null;
|
|
213
|
+
} catch {
|
|
214
|
+
// LLM summary failed — do mechanical fallback
|
|
215
|
+
summary = null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (!summary) {
|
|
219
|
+
// Mechanical fallback: extract tool names and outcomes
|
|
220
|
+
const lines = ["Previous conversation summary (mechanical):"];
|
|
221
|
+
for (const msg of olderMessages) {
|
|
222
|
+
if (msg.role === "user") {
|
|
223
|
+
lines.push(`- User: ${(msg.content || "").slice(0, 100)}`);
|
|
224
|
+
} else if (msg.role === "assistant" && msg.tool_calls) {
|
|
225
|
+
for (const tc of msg.tool_calls) {
|
|
226
|
+
lines.push(`- Tool call: ${tc.function?.name}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
summary = lines.join("\n");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Replace history
|
|
234
|
+
this.history._messages = [
|
|
235
|
+
{ role: "user", content: `[Previous conversation summary]\n${summary}` },
|
|
236
|
+
{ role: "assistant", content: "Understood. I have the context from the summary above. Continuing from where we left off." },
|
|
237
|
+
...recentMessages,
|
|
238
|
+
];
|
|
239
|
+
this.history._save();
|
|
240
|
+
|
|
241
|
+
// Log compaction event
|
|
242
|
+
this.eventLog.append("compact", {
|
|
243
|
+
removedCount: olderMessages.length,
|
|
244
|
+
retainedCount: recentMessages.length,
|
|
245
|
+
summary,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
removedCount: olderMessages.length,
|
|
250
|
+
retainedCount: recentMessages.length,
|
|
251
|
+
summaryTokens: estimateTokens(summary),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Restore an engine from a persisted session.
|
|
257
|
+
* @param {object} opts
|
|
258
|
+
* @param {import('./llm-client.js').LLMClient} opts.client
|
|
259
|
+
* @param {object} opts.config
|
|
260
|
+
* @param {string} opts.sessionId
|
|
261
|
+
* @returns {Promise<AgentEngine>}
|
|
262
|
+
*/
|
|
263
|
+
static async resume({ client, config, sessionId }) {
|
|
264
|
+
const engine = new AgentEngine({ client, config, sessionId });
|
|
265
|
+
const state = engine.sessionState;
|
|
266
|
+
|
|
267
|
+
if (state.exists) {
|
|
268
|
+
const data = state.load();
|
|
269
|
+
engine.currentPhase = data.currentPhase || Phase.BOOTSTRAP;
|
|
270
|
+
engine._phaseSummaries = data.phaseSummaries || [];
|
|
271
|
+
engine._registerToolsForPhase(engine.currentPhase);
|
|
272
|
+
|
|
273
|
+
// Restore pipeline milestones
|
|
274
|
+
const milestones = data.pipelineMilestones || {};
|
|
275
|
+
for (const [phase, mData] of Object.entries(milestones)) {
|
|
276
|
+
if (engine.pipelines[phase]?.importState) {
|
|
277
|
+
engine.pipelines[phase].importState(mData);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
engine.eventLog.append("session_resume", {
|
|
282
|
+
resumedPhase: engine.currentPhase,
|
|
283
|
+
resumedFromSeq: data.lastEventSeq,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return engine;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Save current session state for future resume.
|
|
292
|
+
*/
|
|
293
|
+
saveState() {
|
|
294
|
+
this.sessionState.save(this);
|
|
295
|
+
}
|
|
296
|
+
|
|
139
297
|
/**
|
|
140
298
|
* Run one conversation turn. Yields AgentEvent objects.
|
|
141
299
|
* Loops: LLM call -> tool execution -> LLM call ... until no tool calls.
|
|
@@ -144,6 +302,7 @@ export class AgentEngine {
|
|
|
144
302
|
*/
|
|
145
303
|
async *runTurn(userMessage) {
|
|
146
304
|
this.history.addUser(userMessage);
|
|
305
|
+
this.eventLog.append("user_message", { content: userMessage });
|
|
147
306
|
|
|
148
307
|
// Pipeline state injection
|
|
149
308
|
const pipeline = this.pipelines[this.currentPhase];
|
|
@@ -157,7 +316,21 @@ export class AgentEngine {
|
|
|
157
316
|
const tools = this.toolRegistry.schemasOpenai();
|
|
158
317
|
|
|
159
318
|
while (true) {
|
|
160
|
-
|
|
319
|
+
// Apply context windowing before sending to LLM
|
|
320
|
+
const windowed = this.contextWindow.window(this.history.messages, this._phaseSummaries);
|
|
321
|
+
const messages = [{ role: "system", content: systemPrompt }, ...windowed.messages];
|
|
322
|
+
|
|
323
|
+
if (windowed.wasWindowed) {
|
|
324
|
+
this.eventLog.append("context_windowed", {
|
|
325
|
+
removedCount: windowed.removedCount,
|
|
326
|
+
totalBefore: this.history.messages.length,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
this.eventLog.append("llm_start", {
|
|
331
|
+
model: this.config.kcModel,
|
|
332
|
+
messageCount: messages.length,
|
|
333
|
+
});
|
|
161
334
|
|
|
162
335
|
try {
|
|
163
336
|
let collectedText = "";
|
|
@@ -194,6 +367,7 @@ export class AgentEngine {
|
|
|
194
367
|
}
|
|
195
368
|
}
|
|
196
369
|
|
|
370
|
+
// Log the complete assistant message (coalesced, not per-delta)
|
|
197
371
|
const assistantMsg = { role: "assistant", content: collectedText || null };
|
|
198
372
|
if (toolCallsAcc.size > 0) {
|
|
199
373
|
assistantMsg.tool_calls = Array.from(toolCallsAcc.values()).map((tc) => ({
|
|
@@ -203,8 +377,14 @@ export class AgentEngine {
|
|
|
203
377
|
}));
|
|
204
378
|
}
|
|
205
379
|
this.history.addRaw(assistantMsg);
|
|
380
|
+
this.eventLog.append("assistant_message", {
|
|
381
|
+
content: collectedText || null,
|
|
382
|
+
toolCalls: assistantMsg.tool_calls || [],
|
|
383
|
+
});
|
|
206
384
|
|
|
207
385
|
if (toolCallsAcc.size === 0) {
|
|
386
|
+
this.eventLog.append("turn_complete", {});
|
|
387
|
+
this.saveState();
|
|
208
388
|
yield new AgentEvent({ type: "turn_complete" });
|
|
209
389
|
return;
|
|
210
390
|
}
|
|
@@ -216,8 +396,16 @@ export class AgentEngine {
|
|
|
216
396
|
inputData = tc.arguments ? JSON.parse(tc.arguments) : {};
|
|
217
397
|
} catch { /* ignore */ }
|
|
218
398
|
|
|
399
|
+
this.eventLog.append("tool_start", { name: tc.name, input: inputData });
|
|
219
400
|
yield new AgentEvent({ type: "tool_start", name: tc.name, input: inputData });
|
|
401
|
+
|
|
220
402
|
const result = await this.toolRegistry.execute(tc.name, inputData);
|
|
403
|
+
|
|
404
|
+
this.eventLog.append("tool_result", {
|
|
405
|
+
name: tc.name,
|
|
406
|
+
output: result.content?.slice(0, 5000) || "",
|
|
407
|
+
isError: result.isError,
|
|
408
|
+
});
|
|
221
409
|
yield new AgentEvent({
|
|
222
410
|
type: "tool_result",
|
|
223
411
|
name: tc.name,
|
|
@@ -236,8 +424,16 @@ export class AgentEngine {
|
|
|
236
424
|
const pEvent = pipeline.onToolResult(tc.name, inputData, result);
|
|
237
425
|
if (pEvent) {
|
|
238
426
|
if (pEvent.type === "phase_ready" && pEvent.nextPhase) {
|
|
427
|
+
const phaseSummary = `[${this.currentPhase.toUpperCase()} completed]: ${pEvent.message || ""}`;
|
|
428
|
+
this._phaseSummaries.push(phaseSummary);
|
|
429
|
+
this.eventLog.append("phase_transition", {
|
|
430
|
+
from: this.currentPhase,
|
|
431
|
+
to: pEvent.nextPhase,
|
|
432
|
+
summary: phaseSummary,
|
|
433
|
+
});
|
|
239
434
|
this.currentPhase = pEvent.nextPhase;
|
|
240
435
|
this._registerToolsForPhase(this.currentPhase);
|
|
436
|
+
this.saveState();
|
|
241
437
|
}
|
|
242
438
|
yield new AgentEvent({
|
|
243
439
|
type: "pipeline_event",
|
|
@@ -248,6 +444,7 @@ export class AgentEngine {
|
|
|
248
444
|
}
|
|
249
445
|
|
|
250
446
|
} catch (err) {
|
|
447
|
+
this.eventLog.append("error", { message: err.message });
|
|
251
448
|
yield new AgentEvent({ type: "error", message: err.message });
|
|
252
449
|
return;
|
|
253
450
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { estimateTokens } from "./token-counter.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Append-only JSONL event log for KC agent sessions.
|
|
7
|
+
* Each line is a JSON object: { seq, ts, type, data }
|
|
8
|
+
*
|
|
9
|
+
* This is the source of truth for session history. ConversationHistory
|
|
10
|
+
* and display logs become views over this log.
|
|
11
|
+
*/
|
|
12
|
+
export class EventLog {
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} workspacePath - Session workspace directory
|
|
15
|
+
*/
|
|
16
|
+
constructor(workspacePath) {
|
|
17
|
+
this._dir = path.join(workspacePath, "logs");
|
|
18
|
+
this._logPath = path.join(this._dir, "events.jsonl");
|
|
19
|
+
this._seq = 0;
|
|
20
|
+
this._estimatedTokens = 0;
|
|
21
|
+
this._initFromExisting();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Current sequence number */
|
|
25
|
+
get currentSeq() { return this._seq; }
|
|
26
|
+
|
|
27
|
+
/** Estimated total tokens across all events */
|
|
28
|
+
get estimatedTokens() { return this._estimatedTokens; }
|
|
29
|
+
|
|
30
|
+
/** Path to the log file */
|
|
31
|
+
get logPath() { return this._logPath; }
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Initialize sequence counter and token estimate from existing log file.
|
|
35
|
+
*/
|
|
36
|
+
_initFromExisting() {
|
|
37
|
+
if (!fs.existsSync(this._logPath)) return;
|
|
38
|
+
try {
|
|
39
|
+
const content = fs.readFileSync(this._logPath, "utf-8");
|
|
40
|
+
const lines = content.split("\n").filter((l) => l.trim());
|
|
41
|
+
for (const line of lines) {
|
|
42
|
+
try {
|
|
43
|
+
const event = JSON.parse(line);
|
|
44
|
+
if (event.seq > this._seq) this._seq = event.seq;
|
|
45
|
+
this._estimatedTokens += this._eventTokens(event);
|
|
46
|
+
} catch { /* skip malformed lines */ }
|
|
47
|
+
}
|
|
48
|
+
} catch { /* file read error, start fresh */ }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Append an event to the log.
|
|
53
|
+
* @param {string} type - Event type
|
|
54
|
+
* @param {object} [data] - Event payload
|
|
55
|
+
* @returns {number} The sequence number of the appended event
|
|
56
|
+
*/
|
|
57
|
+
append(type, data = {}) {
|
|
58
|
+
this._seq++;
|
|
59
|
+
const event = {
|
|
60
|
+
seq: this._seq,
|
|
61
|
+
ts: new Date().toISOString(),
|
|
62
|
+
type,
|
|
63
|
+
data,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
fs.mkdirSync(this._dir, { recursive: true });
|
|
67
|
+
fs.appendFileSync(this._logPath, JSON.stringify(event) + "\n", "utf-8");
|
|
68
|
+
|
|
69
|
+
this._estimatedTokens += this._eventTokens(event);
|
|
70
|
+
return this._seq;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Read events from the log with optional filtering.
|
|
75
|
+
* @param {object} [opts]
|
|
76
|
+
* @param {number} [opts.fromSeq] - Start reading from this sequence (inclusive)
|
|
77
|
+
* @param {number} [opts.toSeq] - Stop reading at this sequence (inclusive)
|
|
78
|
+
* @param {string[]} [opts.types] - Only return events of these types
|
|
79
|
+
* @returns {Array<object>}
|
|
80
|
+
*/
|
|
81
|
+
read({ fromSeq = 0, toSeq = Infinity, types } = {}) {
|
|
82
|
+
if (!fs.existsSync(this._logPath)) return [];
|
|
83
|
+
|
|
84
|
+
const events = [];
|
|
85
|
+
const content = fs.readFileSync(this._logPath, "utf-8");
|
|
86
|
+
const lines = content.split("\n").filter((l) => l.trim());
|
|
87
|
+
|
|
88
|
+
for (const line of lines) {
|
|
89
|
+
try {
|
|
90
|
+
const event = JSON.parse(line);
|
|
91
|
+
if (event.seq < fromSeq || event.seq > toSeq) continue;
|
|
92
|
+
if (types && !types.includes(event.type)) continue;
|
|
93
|
+
events.push(event);
|
|
94
|
+
} catch { /* skip */ }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return events;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Estimate tokens for a single event (for running total).
|
|
102
|
+
* @param {object} event
|
|
103
|
+
* @returns {number}
|
|
104
|
+
*/
|
|
105
|
+
_eventTokens(event) {
|
|
106
|
+
const dataStr = typeof event.data === "string"
|
|
107
|
+
? event.data
|
|
108
|
+
: JSON.stringify(event.data || {});
|
|
109
|
+
return estimateTokens(dataStr);
|
|
110
|
+
}
|
|
111
|
+
}
|