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 CHANGED
@@ -1,16 +1,28 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const subcommand = process.argv[2];
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kc-beta",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "KC Agent — LLM document verification agent (pure Node.js CLI)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
+ }
@@ -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.siliconflowApiKey,
89
- baseUrl: this.config.siliconflowBaseUrl,
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
- siliconflowApiKey: this.config.siliconflowApiKey,
101
- siliconflowBaseUrl: this.config.siliconflowBaseUrl,
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
- const messages = [{ role: "system", content: systemPrompt }, ...this.history.messages];
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
+ }