Files
2026-07-13 13:39:12 +08:00

707 lines
26 KiB
TypeScript

import { createCompressionStats, estimateCompressionTokens } from "../../stats.ts";
import { DEFAULT_RTK_CONFIG, type CompressionResult, type RtkConfig } from "../../types.ts";
import type { CompressionEngine } from "../types.ts";
import { detectCommandType } from "./commandDetector.ts";
import { RTK_SCHEMA, validateRtkEngineConfig } from "./configSchema.ts";
import { deduplicateRepeatedLines } from "./deduplicator.ts";
import { groupSimilarLines } from "./grouper.ts";
import { matchRtkFilter } from "./filterLoader.ts";
import { applyLineFilter } from "./lineFilter.ts";
import { smartTruncate } from "./smartTruncate.ts";
import { normalizeCodeLanguage, stripCode } from "./codeStripper.ts";
import { maybePersistRtkRawOutput, type RtkRawOutputPointer } from "./rawOutput.ts";
import { applyRenderer } from "./renderers/index.ts";
import { isTextBlock } from "../../messageContent.ts";
import { adaptBodyForCompression } from "../../bodyAdapter.ts";
import { isAnthropicToolResultBlock } from "../../toolResultCompressor.ts";
type Message = {
role: string;
content?: string | Array<{ type?: string; text?: string; [key: string]: unknown }>;
[key: string]: unknown;
};
type ToolMeta = { toolName: string; command: string | null };
// Same terminal-tool pattern as grok-web.ts isTerminalTool(): RTK's command-aware filters
// only apply to bash/shell tool results. Non-shell tools (read, glob, grep, edit, write…)
// skip filter matching to avoid content-based false positives (e.g. a .ts file matching
// the build-typescript filter).
const SHELL_TOOL_NAME_RE = /\b(bash|shell|terminal|run_command|execute_command|exec|command)\b/;
/**
* A content block (or text sub-block) carrying `cache_control` is an explicit upstream
* prompt-cache breakpoint — the provider caches the prefix up to and INCLUDING it. Rewriting
* such a block's text invalidates that cached prefix every turn (guaranteed cache miss), the
* "provider cache broken" regression once RTK started compressing Anthropic tool_result blocks.
* So we preserve any cache_control-marked block byte-for-byte. (#3936: under caching, only ever
* preserve more of the prefix — never rewrite a client-declared breakpoint.)
*/
function hasCacheControlMarker(part: unknown): boolean {
return (
!!part &&
typeof part === "object" &&
(part as Record<string, unknown>).cache_control !== undefined &&
(part as Record<string, unknown>).cache_control !== null
);
}
/**
* Resolve the shell command + whether to skip RTK filters for a tool result, given the
* tool id (OpenAI `tool_call_id` / Anthropic `tool_use_id`) and the lookup built from the
* preceding assistant tool calls. A missing entry runs filters with text-based command
* detection; a non-shell tool name skips filters.
*/
function resolveToolMeta(
toolId: string | null,
lookup: Map<string, ToolMeta>
): { command: string | null; skipFilters: boolean } {
const meta = toolId ? lookup.get(toolId) : null;
if (!meta) return { command: null, skipFilters: false };
if (SHELL_TOOL_NAME_RE.test(meta.toolName.toLowerCase())) {
return { command: meta.command, skipFilters: false };
}
return { command: null, skipFilters: true };
}
export interface RtkProcessResult {
text: string;
compressed: boolean;
originalTokens: number;
compressedTokens: number;
techniquesUsed: string[];
rulesApplied: string[];
rawOutputPointers?: RtkRawOutputPointer[];
}
function mergeRtkConfig(base?: Partial<RtkConfig>, override?: Record<string, unknown>): RtkConfig {
const merged = { ...DEFAULT_RTK_CONFIG, ...(base ?? {}), ...(override ?? {}) };
return {
...merged,
intensity:
merged.intensity === "minimal" ||
merged.intensity === "standard" ||
merged.intensity === "aggressive"
? merged.intensity
: DEFAULT_RTK_CONFIG.intensity,
enabledFilters: Array.isArray(merged.enabledFilters)
? merged.enabledFilters.filter((id): id is string => typeof id === "string")
: [],
disabledFilters: Array.isArray(merged.disabledFilters)
? merged.disabledFilters.filter((id): id is string => typeof id === "string")
: [],
maxLinesPerResult:
typeof merged.maxLinesPerResult === "number" && Number.isFinite(merged.maxLinesPerResult)
? Math.max(0, Math.floor(merged.maxLinesPerResult))
: DEFAULT_RTK_CONFIG.maxLinesPerResult,
maxCharsPerResult:
typeof merged.maxCharsPerResult === "number" && Number.isFinite(merged.maxCharsPerResult)
? Math.max(0, Math.floor(merged.maxCharsPerResult))
: DEFAULT_RTK_CONFIG.maxCharsPerResult,
deduplicateThreshold:
typeof merged.deduplicateThreshold === "number" &&
Number.isFinite(merged.deduplicateThreshold)
? Math.max(2, Math.floor(merged.deduplicateThreshold))
: DEFAULT_RTK_CONFIG.deduplicateThreshold,
customFiltersEnabled:
typeof merged.customFiltersEnabled === "boolean"
? merged.customFiltersEnabled
: DEFAULT_RTK_CONFIG.customFiltersEnabled,
trustProjectFilters:
typeof merged.trustProjectFilters === "boolean"
? merged.trustProjectFilters
: DEFAULT_RTK_CONFIG.trustProjectFilters,
rawOutputRetention:
merged.rawOutputRetention === "never" ||
merged.rawOutputRetention === "failures" ||
merged.rawOutputRetention === "always"
? merged.rawOutputRetention
: DEFAULT_RTK_CONFIG.rawOutputRetention,
rawOutputMaxBytes:
typeof merged.rawOutputMaxBytes === "number" && Number.isFinite(merged.rawOutputMaxBytes)
? Math.max(1024, Math.floor(merged.rawOutputMaxBytes))
: DEFAULT_RTK_CONFIG.rawOutputMaxBytes,
};
}
function shouldCompressMessage(message: Message, config: RtkConfig): boolean {
// Anthropic-shape tool results live in (typically role:"user") messages as `tool_result`
// content blocks — treat them like OpenAI tool messages (gated by applyToToolResults).
if (
config.applyToToolResults &&
Array.isArray(message.content) &&
message.content.some(isAnthropicToolResultBlock)
)
return true;
if (message.role === "tool")
return config.applyToToolResults || (config.applyToCodeBlocks && hasCodeFence(message.content));
if (message.role === "assistant")
return (
config.applyToAssistantMessages || (config.applyToCodeBlocks && hasCodeFence(message.content))
);
return false;
}
function hasCodeFence(content: Message["content"]): boolean {
if (!content) return false;
if (typeof content === "string") return /```/.test(content);
if (!Array.isArray(content)) return false;
return content.some(
(part) => isTextBlock(part) && typeof part.text === "string" && /```/.test(part.text)
);
}
function codeOnlyConfig(config: RtkConfig): boolean {
return config.applyToCodeBlocks && !config.applyToToolResults && !config.applyToAssistantMessages;
}
function processRtkCodeBlocksOnly(
content: Message["content"],
config: RtkConfig
): {
content: Message["content"];
compressed: boolean;
techniquesUsed: string[];
rulesApplied: string[];
rawOutputPointers: RtkRawOutputPointer[];
} {
const techniquesUsed: string[] = [];
const rulesApplied: string[] = [];
const rawOutputPointers: RtkRawOutputPointer[] = [];
const processText = (text: string) => {
let compressed = false;
const nextText = text.replace(/```([\s\S]*?)```/g, (match) => {
const processed = processRtkText(match, { config });
techniquesUsed.push(...processed.techniquesUsed);
rulesApplied.push(...processed.rulesApplied);
if (processed.rawOutputPointers) rawOutputPointers.push(...processed.rawOutputPointers);
if (!processed.compressed) return match;
compressed = true;
return processed.text;
});
return { text: compressed ? nextText : text, compressed };
};
if (typeof content === "string") {
const processed = processText(content);
return {
content: processed.text,
compressed: processed.compressed,
techniquesUsed,
rulesApplied,
rawOutputPointers,
};
}
if (!Array.isArray(content)) {
return { content, compressed: false, techniquesUsed, rulesApplied, rawOutputPointers };
}
let compressed = false;
const nextContent = content.map((part) => {
if (!isTextBlock(part) || !part.text) return part;
const processed = processText(part.text);
if (!processed.compressed) return part;
compressed = true;
return { ...part, text: processed.text };
});
return {
content: compressed ? nextContent : content,
compressed,
techniquesUsed,
rulesApplied,
rawOutputPointers,
};
}
export function processRtkText(
text: string,
options: { command?: string | null; config?: Partial<RtkConfig>; skipFilters?: boolean } = {}
): RtkProcessResult {
const config = mergeRtkConfig(options.config);
const originalTokens = estimateCompressionTokens(text);
const techniquesUsed: string[] = [];
const rulesApplied: string[] = [];
const rawOutputPointers: RtkRawOutputPointer[] = [];
let result = text;
const detection = detectCommandType(text, options.command);
// #4559: A document/file read (e.g. a Read tool returning a ~147-line code/prose
// file) is NOT repetitive command output, but the generic-output *fallback* filter
// and the final line/char hard-cap (designed for npm/make/docker logs) silently drop
// its middle. Treat content as a document read when RTK recognized no command,
// classified it "unknown", and it carries none of the generic error markers the
// generic-output filter keys on — then skip the truncating fallbacks. Genuine logs
// detect as a known command type (or carry a command / error markers), so RTK's
// value on those is preserved.
const hasGenericErrorMarkers = /Error:|Exception:|Traceback \(most recent call last\):/.test(
text
);
const isDocumentLikeRead =
detection.type === "unknown" && !detection.command && !hasGenericErrorMarkers;
let matchedFilterPatterns: string[] = [];
if (!options.skipFilters && !isDocumentLikeRead) {
const filter = matchRtkFilter(text, detection.command, {
customFiltersEnabled: config.customFiltersEnabled,
trustProjectFilters: config.trustProjectFilters,
});
if (filter && !config.disabledFilters.includes(filter.id)) {
if (config.enabledFilters.length === 0 || config.enabledFilters.includes(filter.id)) {
const filtered = applyLineFilter(result, {
...filter,
maxLines: effectiveMaxLines(filter.maxLines || config.maxLinesPerResult, config.intensity),
});
result = filtered.text;
if (filtered.appliedRules.length > 0) {
techniquesUsed.push("rtk-filter");
rulesApplied.push(...filtered.appliedRules);
}
matchedFilterPatterns = filter.priorityPatterns;
}
}
}
// #10: semantic renderers — opt-in via enableRenderers flag (default OFF), fail-open
if (config.enableRenderers) {
try {
const rendered = applyRenderer(result, detection, config);
if (rendered.changed) {
result = rendered.text;
techniquesUsed.push(`rtk-render:${rendered.renderer}`);
rulesApplied.push(`rtk:render:${rendered.renderer}`);
}
} catch {
// fail-open: renderer never brings down the request
}
}
if (config.applyToCodeBlocks) {
let strippedCodeBlocks = 0;
result = result.replace(
/```([A-Za-z0-9_+.-]*)\r?\n([\s\S]*?)```/g,
(match, languageHint: string, code: string) => {
const stripped = stripCode(code, normalizeCodeLanguage(languageHint), {
// Opt-in comment removal (default off = no silent production change). Docstrings/JSDoc
// are preserved unless explicitly disabled.
removeComments: config.stripCodeComments === true,
preserveDocstrings: config.preserveDocstrings !== false,
});
if (stripped.strippedLines <= 0 && stripped.text === code.trim()) return match;
strippedCodeBlocks++;
const fenceLanguage = languageHint?.trim() || stripped.language;
return `\`\`\`${fenceLanguage}\n${stripped.text}\n\`\`\``;
}
);
if (strippedCodeBlocks > 0) {
techniquesUsed.push("rtk-code-strip");
rulesApplied.push("rtk:code-strip");
}
}
const deduped = deduplicateRepeatedLines(result, { threshold: config.deduplicateThreshold });
if (deduped.collapsed > 0) {
result = deduped.text;
techniquesUsed.push("rtk-dedup");
rulesApplied.push("rtk:dedup");
}
// R5: grouping — opt-in via enableGrouping flag (default OFF)
if (config.enableGrouping) {
const grouped = groupSimilarLines(result, {
threshold: config.groupingThreshold,
});
if (grouped.grouped > 0) {
result = grouped.text;
techniquesUsed.push("rtk-grouping");
rulesApplied.push("rtk:grouping");
}
}
const defaultPriorityPatterns: RegExp[] = [/error|failed|exception|traceback|TS\d{4}|FAIL|✖/i];
const filterPriorityPatterns: RegExp[] = matchedFilterPatterns.flatMap((pattern) => {
try {
return [new RegExp(pattern, "i")];
} catch {
return [];
}
});
// #4559: skip the generic line/char hard-cap for document/file reads (see
// isDocumentLikeRead above) so the middle of a code/prose read is not dropped.
const truncated = isDocumentLikeRead
? { text: result, truncated: false, droppedLines: 0 }
: smartTruncate(result, {
maxLines: effectiveMaxLines(config.maxLinesPerResult, config.intensity),
maxChars: config.maxCharsPerResult,
preserveHead: config.intensity === "aggressive" ? 16 : 24,
preserveTail: config.intensity === "aggressive" ? 16 : 24,
priorityPatterns: [...defaultPriorityPatterns, ...filterPriorityPatterns],
});
if (truncated.truncated) {
result = truncated.text;
techniquesUsed.push("rtk-truncate");
rulesApplied.push("rtk:truncate");
}
const compressedTokens = estimateCompressionTokens(result);
if (compressedTokens < originalTokens) {
const pointer = maybePersistRtkRawOutput(text, {
retention: config.rawOutputRetention,
command: detection.command,
maxBytes: config.rawOutputMaxBytes,
});
if (pointer) {
rawOutputPointers.push(pointer);
techniquesUsed.push("rtk-raw-output-retention");
rulesApplied.push("rtk:raw-output-retention");
}
}
return {
text: result,
compressed: compressedTokens < originalTokens,
originalTokens,
compressedTokens,
techniquesUsed: [...new Set(techniquesUsed)],
rulesApplied: [...new Set(rulesApplied)],
...(rawOutputPointers.length > 0 ? { rawOutputPointers } : {}),
};
}
/**
* Compress the text inside Anthropic-shape `tool_result` content blocks (string or nested
* text-block content), resolving the shell command per block from its `tool_use_id`. The
* block type and `tool_use_id` are preserved exactly — only the inner text is rewritten.
*/
function processToolResultBlocks(
content: Message["content"],
config: RtkConfig,
toolCallLookup: Map<string, ToolMeta>
): {
content: Message["content"];
compressed: boolean;
techniquesUsed: string[];
rulesApplied: string[];
rawOutputPointers: RtkRawOutputPointer[];
} {
const techniquesUsed: string[] = [];
const rulesApplied: string[] = [];
const rawOutputPointers: RtkRawOutputPointer[] = [];
if (!Array.isArray(content)) {
return { content, compressed: false, techniquesUsed, rulesApplied, rawOutputPointers };
}
const collect = (processed: RtkProcessResult) => {
techniquesUsed.push(...processed.techniquesUsed);
rulesApplied.push(...processed.rulesApplied);
if (processed.rawOutputPointers) rawOutputPointers.push(...processed.rawOutputPointers);
};
let compressed = false;
const nextContent = content.map((part) => {
if (!isAnthropicToolResultBlock(part)) return part;
// The block itself is a cache breakpoint — preserve it byte-for-byte.
if (hasCacheControlMarker(part)) return part;
const toolUseId = typeof part.tool_use_id === "string" ? part.tool_use_id : null;
const { command, skipFilters } = resolveToolMeta(toolUseId, toolCallLookup);
const inner = part.content;
if (typeof inner === "string") {
if (!inner) return part;
const processed = processRtkText(inner, { config, command, skipFilters });
collect(processed);
if (!processed.compressed) return part;
compressed = true;
return { ...part, content: processed.text };
}
if (Array.isArray(inner)) {
let blockChanged = false;
const nextInner = inner.map((sub) => {
if (!isTextBlock(sub) || !sub.text) return sub;
// A text sub-block can carry its own cache breakpoint — preserve it byte-for-byte.
if (hasCacheControlMarker(sub)) return sub;
const processed = processRtkText(sub.text, { config, command, skipFilters });
collect(processed);
if (!processed.compressed) return sub;
blockChanged = true;
compressed = true;
return { ...sub, text: processed.text };
});
return blockChanged ? { ...part, content: nextInner } : part;
}
return part;
});
return {
content: compressed ? nextContent : content,
compressed,
techniquesUsed,
rulesApplied,
rawOutputPointers,
};
}
function processRtkContent(
content: Message["content"],
config: RtkConfig,
options?: { command?: string | null; skipFilters?: boolean }
): {
content: Message["content"];
compressed: boolean;
techniquesUsed: string[];
rulesApplied: string[];
rawOutputPointers: RtkRawOutputPointer[];
} {
if (codeOnlyConfig(config)) {
return processRtkCodeBlocksOnly(content, config);
}
const techniquesUsed: string[] = [];
const rulesApplied: string[] = [];
const rawOutputPointers: RtkRawOutputPointer[] = [];
const collect = (processed: RtkProcessResult) => {
techniquesUsed.push(...processed.techniquesUsed);
rulesApplied.push(...processed.rulesApplied);
if (processed.rawOutputPointers) rawOutputPointers.push(...processed.rawOutputPointers);
};
if (typeof content === "string") {
if (!content) {
return { content, compressed: false, techniquesUsed, rulesApplied, rawOutputPointers };
}
const processed = processRtkText(content, {
config,
command: options?.command,
skipFilters: options?.skipFilters,
});
collect(processed);
return {
content: processed.compressed ? processed.text : content,
compressed: processed.compressed,
techniquesUsed,
rulesApplied,
rawOutputPointers,
};
}
if (!Array.isArray(content)) {
return { content, compressed: false, techniquesUsed, rulesApplied, rawOutputPointers };
}
let compressed = false;
const nextContent = content.map((part) => {
if (!isTextBlock(part) || !part.text) return part;
const processed = processRtkText(part.text, {
config,
command: options?.command,
skipFilters: options?.skipFilters,
});
collect(processed);
if (!processed.compressed) return part;
compressed = true;
return { ...part, text: processed.text };
});
return {
content: compressed ? nextContent : content,
compressed,
techniquesUsed,
rulesApplied,
rawOutputPointers,
};
}
/**
* Scale a line budget by intensity so minimal / standard / aggressive produce
* meaningfully different output on truncation-based filters (B-RTK-INTENSITY).
* Truncation always runs through smartTruncate with priorityPatterns, so error /
* failure lines survive at EVERY intensity. (Include/collapse filters like docker-logs
* compress by content, not line budget, so they are intensity-independent by nature.)
*/
export function effectiveMaxLines(base: number, intensity: string | undefined): number {
const factor = intensity === "aggressive" ? 0.5 : intensity === "minimal" ? 1.5 : 1;
return Math.max(1, Math.round(base * factor));
}
export function applyRtkCompression(
body: Record<string, unknown>,
options: { config?: Partial<RtkConfig>; stepConfig?: Record<string, unknown> } = {}
): CompressionResult {
const start = performance.now();
const stepConfig =
options.stepConfig && options.stepConfig.enabled === undefined
? { enabled: true, ...options.stepConfig }
: options.stepConfig;
const explicitConfig = options.config && Object.keys(options.config).length > 0;
const baseConfig = !explicitConfig && !stepConfig ? { enabled: true } : (options.config ?? {});
const config = mergeRtkConfig(baseConfig, stepConfig);
if (!config.enabled) return { body, compressed: false, stats: null };
const adapter = adaptBodyForCompression(body);
const messages = adapter.body.messages as Message[] | undefined;
if (!Array.isArray(messages) || messages.length === 0) {
return { body, compressed: false, stats: null };
}
const allTechniques: string[] = [];
const allRules: string[] = [];
const rawOutputPointers: RtkRawOutputPointer[] = [];
// Build tool_call_id → tool metadata lookup from assistant messages.
// This lets us distinguish bash tool results (which RTK filters are designed for)
// from non-shell tool results (read, grep, glob, etc.) that should skip filters.
const toolCallLookup = new Map<string, ToolMeta>();
for (const msg of messages) {
if (msg.role !== "assistant") continue;
// OpenAI-shape: assistant.tool_calls[].function.{name, arguments(JSON).command}
const toolCalls = msg.tool_calls;
if (Array.isArray(toolCalls)) {
for (const tc of toolCalls as Array<Record<string, unknown>>) {
if (!tc || typeof tc !== "object") continue;
const id = typeof tc.id === "string" ? tc.id : null;
if (!id) continue;
const fn = tc.function as Record<string, unknown> | undefined;
if (!fn || typeof fn !== "object") continue;
const toolName = typeof fn.name === "string" ? fn.name : "";
let command: string | null = null;
if (typeof fn.arguments === "string") {
try {
const args = JSON.parse(fn.arguments);
command =
typeof args.command === "string"
? args.command
: typeof args.cmd === "string"
? args.cmd
: null;
} catch {
// non-JSON arguments
}
}
toolCallLookup.set(id, { toolName, command });
}
}
// Anthropic-shape: assistant.content[] holds { type:"tool_use", id, name, input.command }.
if (Array.isArray(msg.content)) {
for (const part of msg.content as Array<Record<string, unknown>>) {
if (!part || typeof part !== "object" || part.type !== "tool_use") continue;
const id = typeof part.id === "string" ? part.id : null;
if (!id) continue;
const toolName = typeof part.name === "string" ? part.name : "";
const input = part.input as Record<string, unknown> | undefined;
let command: string | null = null;
if (input && typeof input === "object") {
command =
typeof input.command === "string"
? input.command
: typeof input.cmd === "string"
? input.cmd
: null;
}
toolCallLookup.set(id, { toolName, command });
}
}
}
const compressedMessages = messages.map((message) => {
if (!shouldCompressMessage(message, config)) return message;
// Anthropic-shape tool results: `tool_result` content blocks inside a (typically
// role:"user") message. Compress each block's inner text, resolving the shell command
// per block from the matching assistant `tool_use` (mirrors the OpenAI tool path).
if (Array.isArray(message.content) && message.content.some(isAnthropicToolResultBlock)) {
const processed = processToolResultBlocks(message.content, config, toolCallLookup);
allTechniques.push(...processed.techniquesUsed);
allRules.push(...processed.rulesApplied);
rawOutputPointers.push(...processed.rawOutputPointers);
if (!processed.compressed) return message;
return { ...message, content: processed.content };
}
// OpenAI-shape tool message: resolve metadata from the preceding assistant tool_calls.
let command: string | null = null;
let skipFilters = false;
if (message.role === "tool") {
const callId = typeof message.tool_call_id === "string" ? message.tool_call_id : null;
({ command, skipFilters } = resolveToolMeta(callId, toolCallLookup));
}
const processed = processRtkContent(message.content, config, { command, skipFilters });
allTechniques.push(...processed.techniquesUsed);
allRules.push(...processed.rulesApplied);
rawOutputPointers.push(...processed.rawOutputPointers);
if (!processed.compressed) return message;
return {
...message,
content: processed.content,
};
});
const compressedBody = { ...adapter.body, messages: compressedMessages };
const stats = createCompressionStats(
adapter.body,
compressedBody,
"rtk",
[...new Set(allTechniques)],
allRules.length > 0 ? [...new Set(allRules)] : undefined,
Math.round((performance.now() - start) * 100) / 100
);
stats.engine = "rtk";
if (rawOutputPointers.length > 0) {
stats.rtkRawOutputPointers = rawOutputPointers;
}
return {
body: adapter.restore(compressedBody),
compressed: stats.compressedTokens < stats.originalTokens,
stats,
};
}
export const rtkEngine: CompressionEngine = {
id: "rtk",
name: "RTK",
description: "Command-aware tool output compression with declarative filters.",
icon: "filter_alt",
targets: ["tool_results", "code_blocks"],
stackable: true,
stackPriority: 10,
metadata: {
id: "rtk",
name: "RTK",
description: "Command-aware tool output compression with declarative filters.",
inputScope: "tool-results",
targetLatencyMs: 5,
supportsPreview: true,
stable: true,
},
apply(body, options) {
return applyRtkCompression(body, {
config: options?.config?.rtkConfig,
stepConfig: options?.stepConfig,
});
},
compress(body, config) {
return this.apply(body, { stepConfig: config });
},
getConfigSchema() {
return RTK_SCHEMA;
},
validateConfig(config) {
return validateRtkEngineConfig(config);
},
};
export {
detectCommandFromText,
detectCommandOutput,
detectCommandType,
} from "./commandDetector.ts";
export { runRtkFilterTests } from "./verify.ts";
export {
maybePersistRtkRawOutput,
readRtkRawOutput,
redactRtkRawOutput,
listRtkCommandSamples,
} from "./rawOutput.ts";
// RTK learn/discover: the sample-source adapter (rawOutput) feeds these pure miners.
export { discoverRepeatedNoise, type NoiseCandidate, type CommandSample } from "./discover.ts";
export { suggestFilter, commandToId, type SuggestedFilter } from "./learn.ts";