= {
- "ai-resources": "AI Resources",
"cli-reference": "CLI Reference",
+ community: "Community & Resources",
};
if (labels[category]) {
diff --git a/docs-site/next.config.mjs b/docs-site/next.config.mjs
index 380dba85..f800bcf5 100644
--- a/docs-site/next.config.mjs
+++ b/docs-site/next.config.mjs
@@ -30,7 +30,36 @@ const config = {
};
},
async redirects() {
+ // Alias-host canonicalization MUST come before the generic root/docs
+ // redirects below. Those generic rules have no host guard, so if they ran
+ // first they would inject a "/ktx" basePath into the path on the alias
+ // hosts, which the alias catch-alls would then prepend a second time —
+ // producing https://docs.kaelio.com/ktx/ktx/docs/... Redirects also run
+ // before beforeFiles rewrites, so the ktx.sh catch-all must exclude
+ // /stars* to let the stars dashboard rewrite proxy through.
return [
+ {
+ source: "/slack",
+ has: [{ type: "host", value: "ktx.sh" }],
+ destination:
+ "https://join.slack.com/t/ktxcommunity/shared_invite/zt-3y9b44m1x-LVyNNJD5nwaZHq4XS29LMQ",
+ permanent: false,
+ basePath: false,
+ },
+ {
+ source: "/:path*",
+ has: [{ type: "host", value: "docs.ktx.sh" }],
+ destination: "https://docs.kaelio.com/ktx/:path*",
+ permanent: true,
+ basePath: false,
+ },
+ {
+ source: "/:path((?!stars(?:/|$)).*)",
+ has: [{ type: "host", value: "ktx.sh" }],
+ destination: "https://docs.kaelio.com/ktx/:path",
+ permanent: true,
+ basePath: false,
+ },
{
source: "/",
destination: "/ktx/docs/getting-started/introduction",
@@ -44,26 +73,30 @@ const config = {
basePath: false,
},
{
- source: "/:path*",
- has: [{ type: "host", value: "docs.ktx.sh" }],
- destination: "https://docs.kaelio.com/ktx/:path*",
+ // AI Resources collapsed from four pages to one and now lives under the
+ // Community & Resources section. Redirect the old top-level URL and the
+ // retired per-page slugs to the new home. Redirects run before the .md
+ // rewrite, so the Markdown variants must be matched first and keep their
+ // .md suffix; otherwise a cached Markdown URL would 308 to the HTML page
+ // and break the agent Markdown contract.
+ source: "/docs/ai-resources.md",
+ destination: "/docs/community/ai-resources.md",
permanent: true,
- basePath: false,
},
{
- source: "/slack",
- has: [{ type: "host", value: "ktx.sh" }],
- destination:
- "https://join.slack.com/t/ktxcommunity/shared_invite/zt-3y9b44m1x-LVyNNJD5nwaZHq4XS29LMQ",
- permanent: false,
- basePath: false,
+ source: "/docs/ai-resources/:slug([^/]+\\.md)",
+ destination: "/docs/community/ai-resources.md",
+ permanent: true,
},
{
- source: "/:path((?!stars(?:/|$)).*)",
- has: [{ type: "host", value: "ktx.sh" }],
- destination: "https://docs.kaelio.com/ktx/:path",
+ source: "/docs/ai-resources",
+ destination: "/docs/community/ai-resources",
+ permanent: true,
+ },
+ {
+ source: "/docs/ai-resources/:slug",
+ destination: "/docs/community/ai-resources",
permanent: true,
- basePath: false,
},
];
},
diff --git a/docs-site/tests/docs-index-route.test.mjs b/docs-site/tests/docs-index-route.test.mjs
index fdd8ec81..e2ab24f0 100644
--- a/docs-site/tests/docs-index-route.test.mjs
+++ b/docs-site/tests/docs-index-route.test.mjs
@@ -2,6 +2,8 @@ import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { once } from "node:events";
import { readFile, writeFile } from "node:fs/promises";
+import http from "node:http";
+import https from "node:https";
import { dirname, join } from "node:path";
import { createServer } from "node:net";
import { after, before, test } from "node:test";
@@ -100,6 +102,37 @@ after(async () => {
}
});
+// Node's fetch (undici) overwrites the Host header with the connection host,
+// so the alias-host redirect rules never match. The low-level http(s) client
+// sends Host verbatim, which is what the alias canonicalization keys off of.
+function requestWithHost(hostHeader, path) {
+ const target = new URL(docsSiteUrl);
+ const client = target.protocol === "https:" ? https : http;
+ const port =
+ target.port || (target.protocol === "https:" ? "443" : "80");
+
+ return new Promise((resolve, reject) => {
+ const request = client.request(
+ {
+ hostname: target.hostname,
+ port,
+ path,
+ method: "GET",
+ headers: { Host: hostHeader },
+ },
+ (response) => {
+ response.resume();
+ resolve({
+ status: response.statusCode,
+ location: response.headers.location,
+ });
+ },
+ );
+ request.on("error", reject);
+ request.end();
+ });
+}
+
test("/ktx/docs redirects to the docs introduction", async () => {
const response = await fetch(`${docsSiteUrl}${docsBasePath}/docs`, {
redirect: "manual",
@@ -112,6 +145,53 @@ test("/ktx/docs redirects to the docs introduction", async () => {
);
});
+test("retired AI Resources URLs redirect to the page under Community", async () => {
+ // The former top-level URL.
+ const bare = await fetch(
+ `${docsSiteUrl}${docsBasePath}/docs/ai-resources`,
+ { redirect: "manual" },
+ );
+
+ assert.equal(bare.status, 308);
+ assert.equal(
+ bare.headers.get("location"),
+ `${docsBasePath}/docs/community/ai-resources`,
+ );
+
+ // A retired per-page slug.
+ const slug = await fetch(
+ `${docsSiteUrl}${docsBasePath}/docs/ai-resources/agent-quickstart`,
+ { redirect: "manual" },
+ );
+
+ assert.equal(slug.status, 308);
+ assert.equal(
+ slug.headers.get("location"),
+ `${docsBasePath}/docs/community/ai-resources`,
+ );
+
+ // A retired per-page Markdown URL must stay Markdown: it has to redirect to
+ // the new .md route, not fall through to the HTML page.
+ const markdown = await fetch(
+ `${docsSiteUrl}${docsBasePath}/docs/ai-resources/agent-quickstart.md`,
+ { redirect: "manual" },
+ );
+
+ assert.equal(markdown.status, 308);
+ assert.equal(
+ markdown.headers.get("location"),
+ `${docsBasePath}/docs/community/ai-resources.md`,
+ );
+
+ // Following that redirect end to end must land on Markdown, not HTML.
+ const followed = await fetch(
+ `${docsSiteUrl}${docsBasePath}/docs/ai-resources/agent-quickstart.md`,
+ );
+
+ assert.equal(followed.status, 200);
+ assert.match(followed.headers.get("content-type") ?? "", /text\/markdown/);
+});
+
test("/ redirects into the /ktx docs site", async () => {
const response = await fetch(`${docsSiteUrl}/`, {
redirect: "manual",
@@ -141,3 +221,51 @@ test("/ktx/api/search returns docs search results", async () => {
"search should return at least one docs result",
);
});
+
+test("ktx.sh canonicalizes to a single /ktx basePath on the docs host", async () => {
+ const root = await requestWithHost("ktx.sh", "/");
+ assert.equal(root.status, 308);
+ assert.equal(root.location, "https://docs.kaelio.com/ktx/");
+ assert.ok(
+ !root.location.includes("/ktx/ktx"),
+ "the basePath must not be doubled",
+ );
+
+ const page = await requestWithHost(
+ "ktx.sh",
+ "/docs/getting-started/quickstart",
+ );
+ assert.equal(page.status, 308);
+ assert.equal(
+ page.location,
+ "https://docs.kaelio.com/ktx/docs/getting-started/quickstart",
+ );
+});
+
+test("docs.ktx.sh canonicalizes to a single /ktx basePath on the docs host", async () => {
+ const root = await requestWithHost("docs.ktx.sh", "/");
+ assert.equal(root.status, 308);
+ assert.equal(root.location, "https://docs.kaelio.com/ktx");
+ assert.ok(
+ !root.location.includes("/ktx/ktx"),
+ "the basePath must not be doubled",
+ );
+
+ const page = await requestWithHost("docs.ktx.sh", "/llms.txt");
+ assert.equal(page.status, 308);
+ assert.equal(page.location, "https://docs.kaelio.com/ktx/llms.txt");
+});
+
+test("ktx.sh keeps the /slack and /stars exceptions", async () => {
+ const slack = await requestWithHost("ktx.sh", "/slack");
+ assert.equal(slack.status, 307);
+ assert.match(slack.location, /^https:\/\/join\.slack\.com\//);
+
+ // /stars is proxied by a beforeFiles rewrite, so the apex catch-all must not
+ // canonicalize it to the docs host.
+ const stars = await requestWithHost("ktx.sh", "/stars");
+ assert.ok(
+ !(stars.location ?? "").startsWith("https://docs.kaelio.com"),
+ "the stars dashboard must not be redirected to the docs host",
+ );
+});
diff --git a/docs-site/tests/product-mechanics-content.test.mjs b/docs-site/tests/product-mechanics-content.test.mjs
index 5cce9001..d0c9471c 100644
--- a/docs-site/tests/product-mechanics-content.test.mjs
+++ b/docs-site/tests/product-mechanics-content.test.mjs
@@ -85,7 +85,7 @@ test("product mechanics component explains ingestion outputs", async () => {
"compile into SQL",
'"use client"',
"@xyflow/react",
- "
{
);
}
- assert.match(
- component,
+ // The ReactFlow canvas config lives in the shared FlowCanvas wrapper, which
+ // product-mechanics renders. Assert the static read-only behavior there.
+ const flowCanvas = await readDocsFile("components/flow-canvas.tsx");
+ for (const guard of [
/nodesDraggable=\{false\}/,
- "ReactFlow canvas should disable node dragging",
- );
- assert.match(
- component,
- /panOnDrag=\{false\}/,
- "ReactFlow canvas should disable panning",
- );
- assert.match(
- component,
+ /nodesConnectable=\{false\}/,
/zoomOnScroll=\{false\}/,
- "ReactFlow canvas should disable scroll zoom",
- );
+ /elementsSelectable=\{false\}/,
+ ]) {
+ assert.match(
+ flowCanvas,
+ guard,
+ `shared FlowCanvas should enforce static read-only behavior: ${guard}`,
+ );
+ }
assert.doesNotMatch(component, /raw-sources/);
assert.doesNotMatch(component, /\.ktx/);
diff --git a/docs-site/tests/product-runtime-content.test.mjs b/docs-site/tests/product-runtime-content.test.mjs
new file mode 100644
index 00000000..ac643faa
--- /dev/null
+++ b/docs-site/tests/product-runtime-content.test.mjs
@@ -0,0 +1,74 @@
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import { dirname, join } from "node:path";
+import { test } from "node:test";
+import { fileURLToPath } from "node:url";
+
+const docsSiteDir = join(dirname(fileURLToPath(import.meta.url)), "..");
+
+async function readDocsFile(path) {
+ return readFile(join(docsSiteDir, path), "utf8");
+}
+
+test("docs introduction renders the serving phase after ingestion", async () => {
+ const introduction = await readDocsFile(
+ "content/docs/getting-started/introduction.mdx",
+ );
+
+ assert.match(
+ introduction,
+ /import\s+\{\s*ProductRuntime\s*\}\s+from\s+"@\/components\/product-runtime";/,
+ );
+ assert.match(introduction, //);
+
+ const mechanicsIndex = introduction.indexOf("");
+ const runtimeIndex = introduction.indexOf("");
+ const useCaseIndex = introduction.indexOf("## Use it for");
+
+ assert.ok(
+ runtimeIndex > mechanicsIndex,
+ "serving diagram should appear after the ingestion diagram",
+ );
+ assert.ok(
+ runtimeIndex < useCaseIndex,
+ "serving diagram should appear before use-case sections",
+ );
+});
+
+test("product runtime component explains the serving cycle", async () => {
+ const component = await readDocsFile("components/product-runtime.tsx");
+
+ for (const expectedText of [
+ "How serving works",
+ "Serving flow",
+ "From an agent request to a governed answer",
+ "Your agent",
+ "Claude Code",
+ "Cursor",
+ "Codex",
+ "Search wiki + semantic layer",
+ "Return approved metrics",
+ "Compile metrics → SQL",
+ "Context layer",
+ "Database",
+ "search + read",
+ "read-only",
+ "wiki/*.md",
+ "semantic-layer/*.yaml",
+ '"use client"',
+ "@xyflow/react",
+ "FlowCanvas",
+ "getSmoothStepPath",
+ "animateMotion",
+ "runtime-particle",
+ "buildCyclePath",
+ ]) {
+ assert.ok(
+ component.includes(expectedText),
+ `component should include: ${expectedText}`,
+ );
+ }
+
+ assert.doesNotMatch(component, /raw-sources/);
+ assert.doesNotMatch(component, /
=22.0.0"
@@ -47,6 +51,7 @@
"@ai-sdk/devtools": "0.0.18",
"@ai-sdk/google-vertex": "^4.0.134",
"@anthropic-ai/claude-agent-sdk": "0.3.146",
+ "@clack/core": "1.3.1",
"@clack/prompts": "1.4.0",
"@clickhouse/client": "^1.18.5",
"@commander-js/extra-typings": "14.0.0",
@@ -72,6 +77,7 @@
"pg": "^8.21.0",
"posthog-node": "^5.34.9",
"react": "^19.2.6",
+ "semver": "^7.8.1",
"simple-git": "3.36.0",
"snowflake-sdk": "^2.4.2",
"yaml": "^2.9.0",
@@ -85,6 +91,7 @@
"@types/node": "^25.9.1",
"@types/pg": "^8.20.0",
"@types/react": "^19.2.15",
+ "@types/semver": "^7.7.1",
"@vitest/coverage-v8": "^4.1.7",
"ajv": "8.20.0",
"ink-testing-library": "^4.0.0",
diff --git a/packages/cli/src/clack.ts b/packages/cli/src/clack.ts
index 2ad51e6c..31be2e1b 100644
--- a/packages/cli/src/clack.ts
+++ b/packages/cli/src/clack.ts
@@ -3,6 +3,30 @@ import type { KtxCliIo } from './cli-runtime.js';
const ESC = String.fromCharCode(0x1b);
+export interface CliStyleEnv {
+ NO_COLOR?: string;
+ TERM?: string;
+}
+
+function ansiEnabled(env: CliStyleEnv = process.env): boolean {
+ return !env.NO_COLOR && env.TERM !== 'dumb';
+}
+
+function ansiColor(text: string, open: number, close: number, env?: CliStyleEnv): string {
+ if (!ansiEnabled(env)) {
+ return text;
+ }
+ return `${ESC}[${open}m${text}${ESC}[${close}m`;
+}
+
+export function dim(text: string, env?: CliStyleEnv): string {
+ return ansiColor(text, 2, 22, env);
+}
+
+export function cyan(text: string, env?: CliStyleEnv): string {
+ return ansiColor(text, 36, 39, env);
+}
+
export interface RailBufferedSource {
stdoutText(): string;
stderrText(): string;
@@ -61,11 +85,11 @@ export function createClackSpinner(): KtxCliSpinner {
}
function magenta(text: string): string {
- return `${ESC}[35m${text}${ESC}[39m`;
+ return ansiColor(text, 35, 39);
}
function red(text: string): string {
- return `${ESC}[31m${text}${ESC}[39m`;
+ return ansiColor(text, 31, 39);
}
export function createStaticCliSpinner(io: KtxCliSpinnerIo): KtxCliSpinner {
diff --git a/packages/cli/src/cli-program.ts b/packages/cli/src/cli-program.ts
index 31ab8a03..07010f07 100644
--- a/packages/cli/src/cli-program.ts
+++ b/packages/cli/src/cli-program.ts
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { Command, type CommandUnknownOpts, InvalidArgumentError } from '@commander-js/extra-typings';
import type { KtxCliDeps, KtxCliIo, KtxCliPackageInfo } from './cli-runtime.js';
+import { SLACK_HELP_FOOTER, writeErrorCommunityHint } from './community-cta.js';
import { registerCompletionCommands } from './commands/completion-commands.js';
import { registerConnectionCommands } from './commands/connection-commands.js';
import { registerIngestCommands } from './commands/ingest-commands.js';
@@ -16,6 +17,7 @@ import { renderMissingProjectMessage } from './doctor.js';
import { findNearestKtxProjectDir, resolveKtxProjectDir } from './project-resolver.js';
import { profileMark, profileSpan } from './startup-profile.js';
import type { CommandOutcome } from './telemetry/index.js';
+import { prepareUpdateCheckNotice, type PrepareUpdateCheckNoticeOptions } from './update-check/update-check.js';
profileMark('module:cli-program');
@@ -39,6 +41,8 @@ interface KtxCommanderProgramOptions {
runInit: (args: { projectDir: string; force: boolean }, io: KtxCliIo) => Promise;
}
+type KtxCliUpdateCheckOptions = Pick;
+
export interface BuildKtxProgramOptions {
io: KtxCliIo;
deps: KtxCliDeps;
@@ -47,6 +51,7 @@ export interface BuildKtxProgramOptions {
setExitCode?: (code: number) => void;
argv?: string[];
setTelemetryModule?: (telemetry: typeof import('./telemetry/index.js')) => void;
+ updateCheck?: KtxCliUpdateCheckOptions;
}
type CommanderExitLike = { exitCode: number; code: string; message: string };
@@ -254,6 +259,7 @@ function createBaseProgram(info: KtxCliPackageInfo, io: KtxCliIo): Command {
.helpOption('-h, --help', 'Show this help text')
.configureHelp({ showGlobalOptions: true })
.showHelpAfterError()
+ .addHelpText('after', `\n${SLACK_HELP_FOOTER}`)
.exitOverride()
.configureOutput({
writeOut: (chunk) => io.stdout.write(chunk),
@@ -431,16 +437,29 @@ export function collectCommandFlagsPresent(command: CommandUnknownOpts): Record<
export function buildKtxProgram(options: BuildKtxProgramOptions): Command {
const program = createBaseProgram(options.packageInfo, options.io);
+ let pendingUpdateNotice: string | null = null;
+
program.hook('preAction', async (_thisCommand, actionCommand) => {
// The hidden completion command must stay silent and side-effect free: skip
- // the telemetry notice, command span, and project checks entirely.
+ // the telemetry notice, command span, project checks, and update checks entirely.
if (commandPath(actionCommand as CommandPathNode).includes('__complete')) {
return;
}
+ const commandNode = actionCommand as CommandPathNode;
+ const updateCheck = await prepareUpdateCheckNotice({
+ io: options.io,
+ env: options.updateCheck?.env,
+ fetchDistTags: options.updateCheck?.fetchDistTags,
+ homeDir: options.updateCheck?.homeDir,
+ installedVersion: options.packageInfo.version,
+ now: options.updateCheck?.now,
+ commandOptions: commandOptions(commandNode),
+ });
+ pendingUpdateNotice = updateCheck.notice;
+
const telemetry = await import('./telemetry/index.js');
options.setTelemetryModule?.(telemetry);
await telemetry.showTelemetryNoticeIfNeeded(options.io, options.packageInfo);
- const commandNode = actionCommand as CommandPathNode;
const path = commandPath(commandNode);
const projectDir = resolveCommandProjectDir(commandNode);
const hasProject = ktxYamlExists(projectDir);
@@ -457,6 +476,13 @@ export function buildKtxProgram(options: BuildKtxProgramOptions): Command {
ensureProjectAvailable(options.io, commandNode);
});
+ program.hook('postAction', () => {
+ if (pendingUpdateNotice) {
+ options.io.stderr.write(pendingUpdateNotice);
+ pendingUpdateNotice = null;
+ }
+ });
+
const context: KtxCliCommandContext = {
io: options.io,
deps: options.deps,
@@ -529,7 +555,15 @@ export async function runCommanderKtxCli(
try {
return await runBareInteractiveCommand(program, io, context);
} catch (error) {
+ const telemetry = await import('./telemetry/index.js');
+ await telemetry.reportException({
+ error,
+ context: { source: 'bare-interactive', handled: true, fatal: false },
+ packageInfo: info,
+ io,
+ });
io.stderr.write(`${formatCliError(error)}\n`);
+ writeErrorCommunityHint(io, 'error');
return 1;
}
}
@@ -554,6 +588,7 @@ export async function runCommanderKtxCli(
exitCode = error.exitCode === 0 ? 0 : 1;
} else {
io.stderr.write(`${formatCliError(error)}\n`);
+ writeErrorCommunityHint(io, 'error');
exitCode = 1;
}
} finally {
@@ -563,6 +598,23 @@ export async function runCommanderKtxCli(
outcome: commandOutcomeForParseResult(parseError, exitCode),
error: parseError,
});
+ if (
+ parseError &&
+ !isCommanderExit(parseError) &&
+ !isKtxProjectMissingAbortError(parseError)
+ ) {
+ await telemetryModule.reportException({
+ error: parseError,
+ context: {
+ source: completed?.commandPath.join(' ') ?? 'commander parseAsync',
+ handled: true,
+ fatal: false,
+ },
+ projectDir: completed?.projectGroupAttached ? completed.projectDir : undefined,
+ packageInfo: info,
+ io,
+ });
+ }
await telemetryModule.emitCompletedCommand({ completed, packageInfo: info, io });
await telemetryModule.shutdownTelemetryEmitter();
}
diff --git a/packages/cli/src/cli-runtime.ts b/packages/cli/src/cli-runtime.ts
index 7043143b..69416006 100644
--- a/packages/cli/src/cli-runtime.ts
+++ b/packages/cli/src/cli-runtime.ts
@@ -12,6 +12,7 @@ import type { KtxSqlArgs } from './sql.js';
import { profileMark, profileSpan } from './startup-profile.js';
import type { KtxTextIngestArgs } from './text-ingest.js';
import { assertCliVersion } from './release-version.js';
+import { writeErrorCommunityHint } from './community-cta.js';
profileMark('module:cli-runtime');
@@ -129,6 +130,54 @@ function installTelemetrySignalFlush(io: KtxCliIo, info: KtxCliPackageInfo): ()
};
}
+/** @internal */
+export function createGlobalExceptionReporter(io: KtxCliIo, info: KtxCliPackageInfo) {
+ return async (source: 'uncaughtException' | 'unhandledRejection', error: unknown): Promise => {
+ const { reportException, shutdownTelemetryEmitter } = await import('./telemetry/index.js');
+ await reportException({
+ error,
+ context: { source, handled: false, fatal: true },
+ io,
+ packageInfo: info,
+ immediate: true,
+ });
+ await shutdownTelemetryEmitter();
+ };
+}
+
+/** @internal */
+export function writeGlobalExceptionToStderr(io: KtxCliIo, error: unknown): void {
+ if (error instanceof Error && error.stack) {
+ io.stderr.write(`${error.stack}\n`);
+ } else {
+ io.stderr.write(`${String(error)}\n`);
+ }
+ writeErrorCommunityHint(io, 'crash');
+}
+
+export function installGlobalExceptionHandlers(io: KtxCliIo, info: KtxCliPackageInfo): () => void {
+ const report = createGlobalExceptionReporter(io, info);
+ const handle = (source: 'uncaughtException' | 'unhandledRejection', error: unknown): void => {
+ void (async () => {
+ try {
+ await report(source, error);
+ } catch {
+ // Best-effort: preserve Node's process termination behavior.
+ }
+ writeGlobalExceptionToStderr(io, error);
+ process.exit(1);
+ })();
+ };
+ const onUncaught = (error: Error): void => handle('uncaughtException', error);
+ const onUnhandled = (reason: unknown): void => handle('unhandledRejection', reason);
+ process.on('uncaughtException', onUncaught);
+ process.on('unhandledRejection', onUnhandled);
+ return () => {
+ process.off('uncaughtException', onUncaught);
+ process.off('unhandledRejection', onUnhandled);
+ };
+}
+
export async function runKtxCli(
argv = process.argv.slice(2),
io: KtxCliIo = process,
@@ -141,11 +190,14 @@ export async function runKtxCli(
// Real-process entry only: flush telemetry if interrupted. Test/programmatic
// callers pass their own `io`, so they never install process-level handlers.
const removeSignalFlush = (io as unknown) === process ? installTelemetrySignalFlush(io, info) : undefined;
+ const removeGlobalExceptionHandlers =
+ (io as unknown) === process ? installGlobalExceptionHandlers(io, info) : undefined;
try {
return await runCommanderKtxCli(argv, io, deps, info, {
runInit: runInitForCommander,
});
} finally {
+ removeGlobalExceptionHandlers?.();
removeSignalFlush?.();
}
}
diff --git a/packages/cli/src/commands/setup-commands.ts b/packages/cli/src/commands/setup-commands.ts
index 1619a80a..e8510210 100644
--- a/packages/cli/src/commands/setup-commands.ts
+++ b/packages/cli/src/commands/setup-commands.ts
@@ -2,7 +2,7 @@ import { type Command, InvalidArgumentError, Option } from '@commander-js/extra-
import type { KtxCliCommandContext } from '../cli-program.js';
import { resolveCommandProjectDir } from '../cli-program.js';
import type { KtxSetupDatabaseDriver } from '../setup-databases.js';
-import type { KtxSetupLlmBackend } from '../setup-models.js';
+import { isKtxSetupLlmBackend, type KtxSetupLlmBackend } from '../setup-models.js';
import type { KtxSetupSourceType } from '../setup-sources.js';
async function runSetupArgs(
@@ -29,7 +29,7 @@ function embeddingBackend(value: string): 'openai' | 'sentence-transformers' {
}
function llmBackend(value: string): KtxSetupLlmBackend {
- if (value === 'anthropic' || value === 'vertex' || value === 'claude-code' || value === 'codex') {
+ if (isKtxSetupLlmBackend(value)) {
return value;
}
throw new InvalidArgumentError(`invalid choice '${value}'`);
@@ -95,7 +95,6 @@ function shouldShowSetupEntryMenu(
llmBackend?: KtxSetupLlmBackend;
anthropicApiKeyEnv?: string;
anthropicApiKeyFile?: string;
- llmModel?: string;
vertexProject?: string;
vertexLocation?: string;
skipLlm?: boolean;
@@ -166,7 +165,6 @@ function shouldShowSetupEntryMenu(
'llmBackend',
'anthropicApiKeyEnv',
'anthropicApiKeyFile',
- 'llmModel',
'vertexProject',
'vertexLocation',
'skipLlm',
@@ -229,7 +227,6 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
.addOption(
new Option('--anthropic-api-key-file ', 'File containing the Anthropic API key').hideHelp(),
)
- .addOption(new Option('--llm-model ', 'LLM model ID or backend model alias').hideHelp())
.addOption(new Option('--vertex-project ', 'Google Vertex AI project ID, env:NAME, or file:/path').hideHelp())
.addOption(new Option('--vertex-location ', 'Google Vertex AI location, env:NAME, or file:/path').hideHelp())
.addOption(new Option('--skip-llm', 'Leave LLM setup incomplete for now').hideHelp().default(false))
@@ -406,6 +403,8 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
}
const resolvedAgentScope = options.local ? 'local' : options.global ? 'global' : 'project';
+ const debugEnabled =
+ ((command.optsWithGlobals ? command.optsWithGlobals() : command.opts()) as { debug?: unknown }).debug === true;
await runSetupArgs(context, {
command: 'run',
projectDir: resolveCommandProjectDir(command),
@@ -415,12 +414,12 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
agentScope: resolvedAgentScope,
skipAgents: options.skipAgents === true,
inputMode: options.input === false ? 'disabled' : 'auto',
+ ...(debugEnabled ? { debug: true } : {}),
yes: options.yes === true,
cliVersion: context.packageInfo.version,
...(options.llmBackend ? { llmBackend: options.llmBackend } : {}),
...(options.anthropicApiKeyEnv ? { anthropicApiKeyEnv: options.anthropicApiKeyEnv } : {}),
...(options.anthropicApiKeyFile ? { anthropicApiKeyFile: options.anthropicApiKeyFile } : {}),
- ...(options.llmModel ? { llmModel: options.llmModel } : {}),
...(options.vertexProject ? { vertexProject: options.vertexProject } : {}),
...(options.vertexLocation ? { vertexLocation: options.vertexLocation } : {}),
skipLlm: options.skipLlm === true,
diff --git a/packages/cli/src/community-cta.ts b/packages/cli/src/community-cta.ts
new file mode 100644
index 00000000..b4702542
--- /dev/null
+++ b/packages/cli/src/community-cta.ts
@@ -0,0 +1,28 @@
+import type { KtxCliIo } from './cli-runtime.js';
+import { isWritableTtyOutput } from './io/tty.js';
+import { dim } from './io/symbols.js';
+import { SLACK_URL } from './links.js';
+
+type ErrorCtaVariant = 'error' | 'crash';
+
+/** @internal */
+export const SLACK_HELP_FOOTER = `Community & support: ${SLACK_URL}`;
+
+/** @internal */
+export const SLACK_SETUP_NOTE = {
+ title: 'Community',
+ body: `Questions or feedback? Join the ktx Slack: ${SLACK_URL}`,
+} as const;
+
+export function writeErrorCommunityHint(io: KtxCliIo, variant: ErrorCtaVariant): void {
+ if (!isWritableTtyOutput(io.stderr)) {
+ return;
+ }
+
+ const line =
+ variant === 'crash'
+ ? `This may be a bug - report it or ask in the ktx community: ${SLACK_URL}`
+ : `Stuck? The ktx community can help: ${SLACK_URL}`;
+
+ io.stderr.write(`${dim(line)}\n`);
+}
diff --git a/packages/cli/src/connection.ts b/packages/cli/src/connection.ts
index 96281e82..9b6b4294 100644
--- a/packages/cli/src/connection.ts
+++ b/packages/cli/src/connection.ts
@@ -16,7 +16,8 @@ import { bold, dim, green, red, SYMBOLS } from './io/symbols.js';
import { createKtxCliScanConnector } from './local-scan-connectors.js';
import { profileMark } from './startup-profile.js';
import { isDemoConnection } from './telemetry/demo-detect.js';
-import { emitTelemetryEvent } from './telemetry/index.js';
+import { emitTelemetryEvent, reportException } from './telemetry/index.js';
+import { collectTelemetryRedactionSecrets } from './telemetry/redaction-secrets.js';
import { formatErrorDetail, scrubErrorClass } from './telemetry/scrubber.js';
profileMark('module:connection');
@@ -74,6 +75,12 @@ async function testNativeConnection(
}
const result = await connector.testConnection();
if (!result.success) {
+ // Re-throw the driver's original error so connection_test telemetry records
+ // its real class (e.g. ConnectionError) and code (e.g. ELOGIN) instead of
+ // collapsing every native failure to a generic Error with no code.
+ if (result.cause instanceof Error) {
+ throw result.cause;
+ }
throw new Error(result.error ?? 'connection test failed');
}
return { driver: connector.driver };
@@ -318,6 +325,21 @@ async function emitConnectionTest(input: {
...(errorDetail ? { errorDetail } : {}),
},
});
+ if (input.error) {
+ await reportException({
+ error: input.error,
+ context: { source: 'connection test', handled: true, fatal: false },
+ projectDir: input.project.projectDir,
+ io: input.io,
+ redactionSecrets: await collectTelemetryRedactionSecrets({
+ project: input.project,
+ connectionId: input.connectionId,
+ includeLlm: false,
+ includeEmbeddings: false,
+ env: process.env,
+ }),
+ });
+ }
}
function visualWidth(text: string): number {
diff --git a/packages/cli/src/connectors/bigquery/connector.ts b/packages/cli/src/connectors/bigquery/connector.ts
index edebe284..eae0f2ed 100644
--- a/packages/cli/src/connectors/bigquery/connector.ts
+++ b/packages/cli/src/connectors/bigquery/connector.ts
@@ -5,7 +5,9 @@ import { assertReadOnlySql, limitSqlForExecution } from '../../context/connectio
import { tryConstraintQuery } from '../../context/scan/constraint-discovery.js';
import { scopedTableNames } from '../../context/scan/table-ref.js';
import {
+ connectorTestFailure,
createKtxConnectorCapabilities,
+ type KtxConnectorTestResult,
type KtxColumnSampleInput,
type KtxColumnSampleResult,
type KtxColumnStatsInput,
@@ -320,7 +322,7 @@ export class KtxBigQueryScanConnector implements KtxScanConnector {
this.id = `bigquery:${options.connectionId}`;
}
- async testConnection(): Promise<{ success: boolean; error?: string }> {
+ async testConnection(): Promise {
try {
const client = this.getClient();
await client.getDatasets({ maxResults: 1 });
@@ -329,7 +331,7 @@ export class KtxBigQueryScanConnector implements KtxScanConnector {
}
return { success: true };
} catch (error) {
- return { success: false, error: error instanceof Error ? error.message : String(error) };
+ return connectorTestFailure(error);
}
}
diff --git a/packages/cli/src/connectors/clickhouse/connector.ts b/packages/cli/src/connectors/clickhouse/connector.ts
index 74ef7a77..23622701 100644
--- a/packages/cli/src/connectors/clickhouse/connector.ts
+++ b/packages/cli/src/connectors/clickhouse/connector.ts
@@ -1,7 +1,7 @@
import { createClient } from '@clickhouse/client';
import { getDialectForDriver } from '../../context/connections/dialects.js';
import { assertReadOnlySql, limitSqlForExecution } from '../../context/connections/read-only-sql.js';
-import { createKtxConnectorCapabilities, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, type KtxColumnStatsResult, type KtxQueryResult, type KtxReadOnlyQueryInput, type KtxScanConnector, type KtxScanContext, type KtxScanInput, type KtxSchemaColumn, type KtxSchemaSnapshot, type KtxSchemaTable, type KtxTableRef, type KtxTableSampleInput, type KtxTableListEntry, type KtxTableSampleResult } from '../../context/scan/types.js';
+import { connectorTestFailure, createKtxConnectorCapabilities, type KtxConnectorTestResult, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, type KtxColumnStatsResult, type KtxQueryResult, type KtxReadOnlyQueryInput, type KtxScanConnector, type KtxScanContext, type KtxScanInput, type KtxSchemaColumn, type KtxSchemaSnapshot, type KtxSchemaTable, type KtxTableRef, type KtxTableSampleInput, type KtxTableListEntry, type KtxTableSampleResult } from '../../context/scan/types.js';
import { scopedTableNames } from '../../context/scan/table-ref.js';
import { readFileSync } from 'node:fs';
import { Agent as HttpsAgent } from 'node:https';
@@ -317,12 +317,12 @@ export class KtxClickHouseScanConnector implements KtxScanConnector {
this.id = `clickhouse:${options.connectionId}`;
}
- async testConnection(): Promise<{ success: boolean; error?: string }> {
+ async testConnection(): Promise {
try {
await this.query('SELECT 1');
return { success: true };
} catch (error) {
- return { success: false, error: error instanceof Error ? error.message : String(error) };
+ return connectorTestFailure(error);
}
}
diff --git a/packages/cli/src/connectors/mysql/connector.ts b/packages/cli/src/connectors/mysql/connector.ts
index 29dacc26..080c5cdd 100644
--- a/packages/cli/src/connectors/mysql/connector.ts
+++ b/packages/cli/src/connectors/mysql/connector.ts
@@ -11,7 +11,9 @@ import {
} from '../../context/scan/constraint-discovery.js';
import { scopedTableNames } from '../../context/scan/table-ref.js';
import {
+ connectorTestFailure,
createKtxConnectorCapabilities,
+ type KtxConnectorTestResult,
type KtxColumnSampleInput,
type KtxColumnSampleResult,
type KtxColumnStatsInput,
@@ -157,6 +159,15 @@ interface MysqlDistinctValueRow extends RowDataPacket {
val: unknown;
}
+interface MysqlStatsRow extends RowDataPacket {
+ column_name: string;
+ estimated_cardinality: number | null;
+}
+
+export interface KtxMysqlColumnStatisticsResult {
+ cardinalityByColumn: Map;
+}
+
class DefaultMysqlPoolFactory implements KtxMysqlPoolFactory {
createPool(config: KtxMysqlPoolConfig): KtxMysqlPool {
return mysql.createPool(config) as Pool;
@@ -382,7 +393,7 @@ export class KtxMysqlScanConnector implements KtxScanConnector {
readonly capabilities = createKtxConnectorCapabilities({
tableSampling: true,
columnSampling: true,
- columnStats: false,
+ columnStats: true,
readOnlySql: true,
nestedAnalysis: true,
formalForeignKeys: true,
@@ -413,12 +424,12 @@ export class KtxMysqlScanConnector implements KtxScanConnector {
this.id = `mysql:${options.connectionId}`;
}
- async testConnection(): Promise<{ success: boolean; error?: string }> {
+ async testConnection(): Promise {
try {
await this.query('SELECT 1');
return { success: true };
} catch (error) {
- return { success: false, error: error instanceof Error ? error.message : String(error) };
+ return connectorTestFailure(error);
}
}
@@ -560,8 +571,29 @@ export class KtxMysqlScanConnector implements KtxScanConnector {
return { values, nullCount: null, distinctCount: null };
}
- async columnStats(_input: KtxColumnStatsInput, _ctx: KtxScanContext): Promise {
- return null;
+ async columnStats(input: KtxColumnStatsInput, _ctx: KtxScanContext): Promise {
+ const stats = await this.getColumnStatistics(input.table);
+ const value = stats?.cardinalityByColumn.get(input.column);
+ return value === undefined
+ ? null
+ : { min: null, max: null, average: null, nullCount: null, distinctCount: value };
+ }
+
+ async getColumnStatistics(table: KtxTableRef): Promise {
+ const schema = table.db ?? this.poolConfig.database;
+ const sql = this.dialect.generateColumnStatisticsQuery(schema, table.name);
+ if (!sql) {
+ return null;
+ }
+ const rows = await this.queryRaw(sql);
+ const cardinalityByColumn = new Map();
+ for (const row of rows) {
+ const cardinality = Number(row.estimated_cardinality);
+ if (Number.isFinite(cardinality) && cardinality >= 0) {
+ cardinalityByColumn.set(row.column_name, cardinality);
+ }
+ }
+ return cardinalityByColumn.size > 0 ? { cardinalityByColumn } : null;
}
async executeReadOnly(input: KtxMysqlReadOnlyQueryInput, _ctx: KtxScanContext): Promise {
diff --git a/packages/cli/src/connectors/mysql/dialect.ts b/packages/cli/src/connectors/mysql/dialect.ts
index 7f9cc725..6b26c97a 100644
--- a/packages/cli/src/connectors/mysql/dialect.ts
+++ b/packages/cli/src/connectors/mysql/dialect.ts
@@ -171,8 +171,18 @@ export class KtxMysqlDialect implements KtxDialect {
`;
}
- generateColumnStatisticsQuery(_schemaName: string, _tableName: string): string | null {
- return null;
+ generateColumnStatisticsQuery(schemaName: string, tableName: string): string | null {
+ return `
+ SELECT
+ COLUMN_NAME AS column_name,
+ MAX(CARDINALITY) AS estimated_cardinality
+ FROM INFORMATION_SCHEMA.STATISTICS
+ WHERE TABLE_SCHEMA = '${schemaName.replace(/'/g, "''")}'
+ AND TABLE_NAME = '${tableName.replace(/'/g, "''")}'
+ AND CARDINALITY IS NOT NULL
+ AND SEQ_IN_INDEX = 1
+ GROUP BY COLUMN_NAME
+ `;
}
generateRandomizedCardinalitySampleQuery(tableName: string, columnName: string, sampleSize: number): string {
diff --git a/packages/cli/src/connectors/postgres/connector.ts b/packages/cli/src/connectors/postgres/connector.ts
index f206fa6a..1a956a3d 100644
--- a/packages/cli/src/connectors/postgres/connector.ts
+++ b/packages/cli/src/connectors/postgres/connector.ts
@@ -6,7 +6,9 @@ import { assertReadOnlySql, limitSqlForExecution } from '../../context/connectio
import { tryConstraintQuery } from '../../context/scan/constraint-discovery.js';
import { scopedTableNames } from '../../context/scan/table-ref.js';
import {
+ connectorTestFailure,
createKtxConnectorCapabilities,
+ type KtxConnectorTestResult,
type KtxColumnSampleInput,
type KtxColumnSampleResult,
type KtxColumnStatsInput,
@@ -442,12 +444,12 @@ export class KtxPostgresScanConnector implements KtxScanConnector {
this.id = `postgres:${options.connectionId}`;
}
- async testConnection(): Promise<{ success: boolean; error?: string }> {
+ async testConnection(): Promise {
try {
await this.query('SELECT 1');
return { success: true };
} catch (error) {
- return { success: false, error: error instanceof Error ? error.message : String(error) };
+ return connectorTestFailure(error);
}
}
diff --git a/packages/cli/src/connectors/snowflake/connector.ts b/packages/cli/src/connectors/snowflake/connector.ts
index 86d7ebe7..51a91e52 100644
--- a/packages/cli/src/connectors/snowflake/connector.ts
+++ b/packages/cli/src/connectors/snowflake/connector.ts
@@ -7,7 +7,9 @@ import { assertReadOnlySql, limitSqlForExecution } from '../../context/connectio
import { tryConstraintQuery } from '../../context/scan/constraint-discovery.js';
import { scopedTableNames } from '../../context/scan/table-ref.js';
import {
+ connectorTestFailure,
createKtxConnectorCapabilities,
+ type KtxConnectorTestResult,
type KtxColumnSampleInput,
type KtxColumnSampleResult,
type KtxColumnStatsInput,
@@ -464,7 +466,7 @@ class SnowflakeSdkDriver implements KtxSnowflakeDriver {
await this.query('SELECT 1');
return { success: true };
} catch (error) {
- return { success: false, error: error instanceof Error ? error.message : String(error) };
+ return connectorTestFailure(error);
}
}
@@ -573,7 +575,7 @@ export class KtxSnowflakeScanConnector implements KtxScanConnector {
}
}
- async testConnection(): Promise<{ success: boolean; error?: string }> {
+ async testConnection(): Promise {
return this.getDriver().test();
}
diff --git a/packages/cli/src/connectors/sqlite/connector.ts b/packages/cli/src/connectors/sqlite/connector.ts
index e996bc25..f5ba2a55 100644
--- a/packages/cli/src/connectors/sqlite/connector.ts
+++ b/packages/cli/src/connectors/sqlite/connector.ts
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url';
import { getDialectForDriver } from '../../context/connections/dialects.js';
import { assertReadOnlySql, limitSqlForExecution } from '../../context/connections/read-only-sql.js';
import { normalizeQueryRows } from '../../context/connections/query-executor.js';
-import { createKtxConnectorCapabilities, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, type KtxColumnStatsResult, type KtxQueryResult, type KtxReadOnlyQueryInput, type KtxScanConnector, type KtxScanContext, type KtxScanInput, type KtxSchemaForeignKey, type KtxSchemaSnapshot, type KtxSchemaTable, type KtxTableListEntry, type KtxTableRef, type KtxTableSampleInput, type KtxTableSampleResult } from '../../context/scan/types.js';
+import { connectorTestFailure, createKtxConnectorCapabilities, type KtxConnectorTestResult, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, type KtxColumnStatsResult, type KtxQueryResult, type KtxReadOnlyQueryInput, type KtxScanConnector, type KtxScanContext, type KtxScanInput, type KtxSchemaForeignKey, type KtxSchemaSnapshot, type KtxSchemaTable, type KtxTableListEntry, type KtxTableRef, type KtxTableSampleInput, type KtxTableSampleResult } from '../../context/scan/types.js';
import { scopedTableNames } from '../../context/scan/table-ref.js';
export interface KtxSqliteConnectionConfig {
@@ -167,7 +167,7 @@ export class KtxSqliteScanConnector implements KtxScanConnector {
this.id = `sqlite:${options.connectionId}`;
}
- async testConnection(): Promise<{ success: boolean; error?: string }> {
+ async testConnection(): Promise {
try {
if (!existsSync(this.dbPath) || !statSync(this.dbPath).isFile()) {
return { success: false, error: `File not found: ${this.dbPath}` };
@@ -175,7 +175,7 @@ export class KtxSqliteScanConnector implements KtxScanConnector {
this.database().prepare('SELECT 1').get();
return { success: true };
} catch (error) {
- return { success: false, error: error instanceof Error ? error.message : String(error) };
+ return connectorTestFailure(error);
}
}
diff --git a/packages/cli/src/connectors/sqlserver/connector.ts b/packages/cli/src/connectors/sqlserver/connector.ts
index 0115781d..5dd9969b 100644
--- a/packages/cli/src/connectors/sqlserver/connector.ts
+++ b/packages/cli/src/connectors/sqlserver/connector.ts
@@ -3,7 +3,9 @@ import { getDialectForDriver } from '../../context/connections/dialects.js';
import { tryConstraintQuery } from '../../context/scan/constraint-discovery.js';
import { scopedTableNames } from '../../context/scan/table-ref.js';
import {
+ connectorTestFailure,
createKtxConnectorCapabilities,
+ type KtxConnectorTestResult,
type KtxColumnSampleInput,
type KtxColumnSampleResult,
type KtxColumnStatsInput,
@@ -384,12 +386,12 @@ export class KtxSqlServerScanConnector implements KtxScanConnector {
this.id = `sqlserver:${options.connectionId}`;
}
- async testConnection(): Promise<{ success: boolean; error?: string }> {
+ async testConnection(): Promise {
try {
await this.query('SELECT 1');
return { success: true };
} catch (error) {
- return { success: false, error: error instanceof Error ? error.message : String(error) };
+ return connectorTestFailure(error);
}
}
diff --git a/packages/cli/src/context-build-view.ts b/packages/cli/src/context-build-view.ts
index 0ddd4922..0991cecb 100644
--- a/packages/cli/src/context-build-view.ts
+++ b/packages/cli/src/context-build-view.ts
@@ -12,6 +12,13 @@ import { buildPublicIngestPlan, executePublicIngestTarget, publicProgressMessage
import { createAggregateProgressPort } from './progress-port-adapter.js';
import { formatDuration } from './demo-metrics.js';
import { profileMark } from './startup-profile.js';
+import {
+ isFreshStarCountCache,
+ readStarCountCache,
+ writeStarCountCache,
+} from './star-prompt/cache.js';
+import { fetchGitHubStarCount as defaultFetchGitHubStarCount } from './star-prompt/star-count.js';
+import { renderStarPromptLine } from './star-prompt/star-line.js';
profileMark('module:context-build-view');
@@ -79,6 +86,7 @@ export interface ContextBuildViewState {
frame: number;
startedAt: number | null;
totalElapsedMs: number;
+ starCount: number | null;
}
export interface ContextBuildArgs {
@@ -121,6 +129,8 @@ interface CompletedItemName {
interface ContextBuildRenderOptions {
styled?: boolean;
showHint?: boolean;
+ showStarPrompt?: boolean;
+ columns?: number;
hintText?: string;
projectDir?: string;
title?: string;
@@ -138,6 +148,15 @@ export interface ContextBuildDeps {
now?: () => number;
onSourceProgress?: (sources: ContextBuildSourceProgressUpdate[]) => void;
sourceProgressThrottleMs?: number;
+ fetchStarCount?: typeof defaultFetchGitHubStarCount;
+ starPromptEnv?: StarPromptEnv;
+ starPromptHomeDir?: string;
+}
+
+interface StarPromptEnv extends NodeJS.ProcessEnv {
+ CI?: string;
+ DO_NOT_TRACK?: string;
+ KTX_NO_STAR?: string;
}
// --- Rendering ---
@@ -427,6 +446,14 @@ export function renderContextBuildView(
lines.push('');
}
+ if (options.showStarPrompt && hasActive) {
+ const starPrompt = renderStarPromptLine({
+ count: state.starCount,
+ columns: options.columns ?? 80,
+ });
+ lines.push(styled ? dim(starPrompt) : starPrompt);
+ }
+
if (options.showHint && hasActive) {
const hintContent = options.hintText ?? 'Ctrl+C to stop';
const hint = ` ${hintContent}`;
@@ -584,6 +611,7 @@ export function viewStateFromSourceProgress(
frame: 0,
startedAt: startedAtMs ?? null,
totalElapsedMs: startedAtMs ? now - startedAtMs : 0,
+ starCount: null,
};
}
@@ -631,6 +659,9 @@ export function createRepainter(io: KtxCliIo) {
hasPainted = true;
lastCursorUpRows = cursorUpRowsAfterWrite(content);
},
+ columns() {
+ return terminalColumns();
+ },
};
}
@@ -806,6 +837,7 @@ export function initViewState(targets: KtxPublicIngestPlanTarget[]): ContextBuil
frame: 0,
startedAt: null,
totalElapsedMs: 0,
+ starCount: null,
};
}
@@ -817,6 +849,50 @@ function formatProgressDetail(
return `[${percent}%] ${publicProgressMessage(update.message, target)}`;
}
+const STAR_COUNT_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
+
+function envFlag(value: string | undefined): boolean {
+ return value !== undefined && value !== '' && value !== '0' && value !== 'false';
+}
+
+function shouldSuppressStarPrompt(env: StarPromptEnv): boolean {
+ return envFlag(env.CI) || envFlag(env.DO_NOT_TRACK) || envFlag(env.KTX_NO_STAR);
+}
+
+function startStarPromptCountRefresh(input: {
+ fetchStarCount: typeof defaultFetchGitHubStarCount;
+ homeDir?: string;
+ now: () => number;
+ paint: () => void;
+ state: ContextBuildViewState;
+}): void {
+ const cached = readStarCountCache({ homeDir: input.homeDir });
+ if (cached) {
+ input.state.starCount = cached.count;
+ }
+
+ if (isFreshStarCountCache(cached, new Date(input.now()), STAR_COUNT_CACHE_TTL_MS)) {
+ return;
+ }
+
+ void input.fetchStarCount()
+ .then((count) => {
+ if (typeof count !== 'number' || !Number.isFinite(count)) {
+ return;
+ }
+ input.state.starCount = count;
+ input.paint();
+ void writeStarCountCache(
+ {
+ count,
+ fetchedAt: new Date(input.now()).toISOString(),
+ },
+ { homeDir: input.homeDir },
+ );
+ })
+ .catch(() => undefined);
+}
+
export async function runContextBuild(
project: KtxPublicIngestProject,
args: ContextBuildArgs,
@@ -838,13 +914,31 @@ export async function runContextBuild(
state.startedAt = nowFn();
const repainter = isTTY ? createRepainter(io) : null;
+ const starPromptEnabled = repainter !== null && !shouldSuppressStarPrompt(deps.starPromptEnv ?? process.env);
const viewOpts = {
styled: true,
projectDir: args.projectDir,
notices: plan.notices ?? [],
warnings: plan.warnings,
};
- const paint = (hint: boolean) => repainter?.paint(renderContextBuildView(state, { ...viewOpts, showHint: hint }));
+ const paint = (hint: boolean) =>
+ repainter?.paint(
+ renderContextBuildView(state, {
+ ...viewOpts,
+ showHint: hint,
+ showStarPrompt: starPromptEnabled && hint,
+ columns: repainter.columns(),
+ }),
+ );
+ if (starPromptEnabled) {
+ startStarPromptCountRefresh({
+ fetchStarCount: deps.fetchStarCount ?? defaultFetchGitHubStarCount,
+ homeDir: deps.starPromptHomeDir,
+ now: nowFn,
+ paint: () => paint(true),
+ state,
+ });
+ }
paint(true);
let spinnerInterval: ReturnType | null = null;
diff --git a/packages/cli/src/context/connections/drivers.ts b/packages/cli/src/context/connections/drivers.ts
index 1b87984b..3fbeb058 100644
--- a/packages/cli/src/context/connections/drivers.ts
+++ b/packages/cli/src/context/connections/drivers.ts
@@ -17,7 +17,6 @@ export interface KtxDriverRegistration {
readonly driver: KtxConnectionDriver;
readonly scopeConfigKey: KtxScopeConfigKey | null;
readonly hasHistoricSqlReader: boolean;
- readonly hasLocalQueryExecutor: boolean;
load(): Promise;
}
@@ -31,7 +30,6 @@ export const driverRegistrations: Record {
const m = await import('../../connectors/bigquery/connector.js');
return {
@@ -53,7 +51,6 @@ export const driverRegistrations: Record {
const m = await import('../../connectors/clickhouse/connector.js');
return {
@@ -75,7 +72,6 @@ export const driverRegistrations: Record {
const m = await import('../../connectors/mysql/connector.js');
return {
@@ -97,7 +93,6 @@ export const driverRegistrations: Record {
const m = await import('../../connectors/postgres/connector.js');
return {
@@ -119,7 +114,6 @@ export const driverRegistrations: Record {
const m = await import('../../connectors/sqlite/connector.js');
return {
@@ -141,7 +135,6 @@ export const driverRegistrations: Record {
const m = await import('../../connectors/snowflake/connector.js');
return {
@@ -163,7 +156,6 @@ export const driverRegistrations: Record {
const m = await import('../../connectors/sqlserver/connector.js');
return {
diff --git a/packages/cli/src/context/connections/local-query-executor.ts b/packages/cli/src/context/connections/local-query-executor.ts
deleted file mode 100644
index 3a2e34c9..00000000
--- a/packages/cli/src/context/connections/local-query-executor.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-import { driverRegistrations, getDriverRegistration } from './drivers.js';
-import { createPostgresQueryExecutor } from './postgres-query-executor.js';
-import type {
- KtxSqlQueryExecutionInput,
- KtxSqlQueryExecutionResult,
- KtxSqlQueryExecutorPort,
-} from './query-executor.js';
-import { createSqliteQueryExecutor } from './sqlite-query-executor.js';
-import type { KtxConnectionDriver } from '../scan/types.js';
-
-export interface DefaultLocalQueryExecutorOptions {
- postgres?: KtxSqlQueryExecutorPort;
- sqlite?: KtxSqlQueryExecutorPort;
-}
-
-function driverFor(input: KtxSqlQueryExecutionInput): string {
- return String(input.connection?.driver ?? '').toLowerCase();
-}
-
-function localExecutorMap(
- options: DefaultLocalQueryExecutorOptions,
-): Partial> {
- const wiredExecutors: Partial> = {
- postgres: options.postgres ?? createPostgresQueryExecutor(),
- sqlite: options.sqlite ?? createSqliteQueryExecutor(),
- };
-
- const executors: Partial> = {};
- for (const registration of Object.values(driverRegistrations)) {
- if (!registration.hasLocalQueryExecutor) continue;
- const executor = wiredExecutors[registration.driver];
- if (executor) {
- executors[registration.driver] = executor;
- }
- }
- return executors;
-}
-
-export function createDefaultLocalQueryExecutor(options: DefaultLocalQueryExecutorOptions = {}): KtxSqlQueryExecutorPort {
- const executors = localExecutorMap(options);
-
- return {
- async execute(input: KtxSqlQueryExecutionInput): Promise {
- const driver = driverFor(input);
- const registration = getDriverRegistration(driver);
- if (!registration?.hasLocalQueryExecutor) {
- throw new Error(`No local query executor is configured for driver "${input.connection?.driver ?? 'unknown'}".`);
- }
-
- const executor = executors[registration.driver];
- if (!executor) {
- throw new Error(
- `Local query executor flag is enabled for driver "${registration.driver}", but no executor factory is wired.`,
- );
- }
- return executor.execute(input);
- },
- };
-}
diff --git a/packages/cli/src/context/connections/postgres-query-executor.ts b/packages/cli/src/context/connections/postgres-query-executor.ts
deleted file mode 100644
index 842609f4..00000000
--- a/packages/cli/src/context/connections/postgres-query-executor.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-import { Client, type ClientConfig } from 'pg';
-import type {
- KtxSqlQueryExecutionInput,
- KtxSqlQueryExecutionResult,
- KtxSqlQueryExecutorPort,
-} from './query-executor.js';
-import { limitSqlForExecution } from './read-only-sql.js';
-
-interface PgClientLike {
- connect(): Promise;
- query(input: string | { text: string; rowMode: 'array' }): Promise<{
- fields: Array<{ name: string }>;
- rows: unknown[][];
- command: string;
- rowCount: number | null;
- }>;
- end(): Promise;
-}
-
-interface PostgresQueryExecutorOptions {
- statementTimeoutMs?: number;
- queryTimeoutMs?: number;
- connectionTimeoutMs?: number;
- clientFactory?: (config: ClientConfig) => PgClientLike;
-}
-
-function connectionDriver(input: KtxSqlQueryExecutionInput): string {
- return String(input.connection?.driver ?? '').toLowerCase();
-}
-
-function createDefaultClient(config: ClientConfig): PgClientLike {
- return new Client(config);
-}
-
-export function createPostgresQueryExecutor(options: PostgresQueryExecutorOptions = {}): KtxSqlQueryExecutorPort {
- const clientFactory = options.clientFactory ?? createDefaultClient;
- return {
- async execute(input: KtxSqlQueryExecutionInput): Promise {
- const driver = connectionDriver(input);
- const connection = input.connection;
- if (driver !== 'postgres') {
- throw new Error(`Local Postgres execution cannot run driver "${connection?.driver ?? 'unknown'}".`);
- }
- if (typeof connection?.url !== 'string' || connection.url.trim().length === 0) {
- throw new Error(`Local Postgres execution requires connections.${input.connectionId}.url.`);
- }
-
- const client = clientFactory({
- connectionString: connection.url,
- statement_timeout: options.statementTimeoutMs ?? 30_000,
- query_timeout: options.queryTimeoutMs ?? 35_000,
- connectionTimeoutMillis: options.connectionTimeoutMs ?? 5_000,
- application_name: 'ktx-local-query',
- });
- await client.connect();
- try {
- await client.query('BEGIN READ ONLY');
- const result = await client.query({
- text: limitSqlForExecution(input.sql, input.maxRows),
- rowMode: 'array',
- });
- await client.query('COMMIT');
- return {
- headers: result.fields.map((field) => field.name),
- rows: result.rows,
- totalRows: result.rows.length,
- command: result.command,
- rowCount: result.rowCount,
- };
- } catch (error) {
- await client.query('ROLLBACK').catch(() => undefined);
- throw error;
- } finally {
- await client.end();
- }
- },
- };
-}
diff --git a/packages/cli/src/context/connections/query-executor.ts b/packages/cli/src/context/connections/query-executor.ts
index e169d164..a397dfc3 100644
--- a/packages/cli/src/context/connections/query-executor.ts
+++ b/packages/cli/src/context/connections/query-executor.ts
@@ -8,7 +8,7 @@ export interface KtxSqlQueryExecutionInput {
maxRows?: number;
}
-export interface KtxSqlQueryExecutionResult {
+interface KtxSqlQueryExecutionResult {
headers: string[];
rows: unknown[][];
totalRows: number;
diff --git a/packages/cli/src/context/connections/sqlite-query-executor.ts b/packages/cli/src/context/connections/sqlite-query-executor.ts
deleted file mode 100644
index 40710c96..00000000
--- a/packages/cli/src/context/connections/sqlite-query-executor.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-import { isAbsolute, resolve } from 'node:path';
-import { fileURLToPath } from 'node:url';
-import Database from 'better-sqlite3';
-import { readFileSync } from 'node:fs';
-import { homedir } from 'node:os';
-import type {
- KtxSqlQueryExecutionInput,
- KtxSqlQueryExecutionResult,
- KtxSqlQueryExecutorPort,
-} from './query-executor.js';
-import { normalizeQueryRows } from './query-executor.js';
-import { limitSqlForExecution } from './read-only-sql.js';
-
-type SqliteConnectionConfig = Record | undefined;
-
-function connectionDriver(input: KtxSqlQueryExecutionInput): string {
- return String(input.connection?.driver ?? '').toLowerCase();
-}
-
-function stringConfigValue(connection: SqliteConnectionConfig, key: string): string | undefined {
- const value = connection?.[key];
- return typeof value === 'string' && value.trim().length > 0 ? resolveStringReference(key, value.trim()) : undefined;
-}
-
-function resolveStringReference(key: string, value: string): string {
- if (value.startsWith('env:')) {
- return process.env[value.slice('env:'.length)] ?? '';
- }
- if (key !== 'url' && value.startsWith('file:')) {
- const rawPath = value.slice('file:'.length);
- const path = rawPath.startsWith('~') ? resolve(homedir(), rawPath.slice(1)) : rawPath;
- return readFileSync(path, 'utf-8').trim();
- }
- return value;
-}
-
-function sqlitePathFromUrl(url: string): string {
- if (url.startsWith('file:')) {
- return fileURLToPath(url);
- }
-
- if (url.startsWith('sqlite:')) {
- const parsed = new URL(url);
- if (parsed.pathname.length > 0) {
- return decodeURIComponent(parsed.pathname);
- }
- }
-
- return url;
-}
-
-/** @internal */
-export function sqliteDatabasePathFromConnection(input: KtxSqlQueryExecutionInput): string {
- const driver = connectionDriver(input);
- if (driver !== 'sqlite') {
- throw new Error(`Local SQLite execution cannot run driver "${input.connection?.driver ?? 'unknown'}".`);
- }
-
- const pathValue = stringConfigValue(input.connection, 'path');
- const urlValue = stringConfigValue(input.connection, 'url');
- if (!pathValue && !urlValue) {
- throw new Error(
- `Local SQLite execution requires connections.${input.connectionId}.path or connections.${input.connectionId}.url.`,
- );
- }
-
- const candidate = pathValue ?? sqlitePathFromUrl(urlValue as string);
- return isAbsolute(candidate) ? candidate : resolve(input.projectDir ?? process.cwd(), candidate);
-}
-
-export function createSqliteQueryExecutor(): KtxSqlQueryExecutorPort {
- return {
- async execute(input: KtxSqlQueryExecutionInput): Promise {
- const sql = limitSqlForExecution(input.sql, input.maxRows);
- const dbPath = sqliteDatabasePathFromConnection(input);
- const db = new Database(dbPath, { readonly: true, fileMustExist: true });
- try {
- const statement = db.prepare(sql);
- const rows = statement.all() as unknown[];
- return {
- headers: statement.columns().map((column) => column.name),
- rows: normalizeQueryRows(rows),
- totalRows: rows.length,
- command: 'SELECT',
- rowCount: rows.length,
- };
- } finally {
- db.close();
- }
- },
- };
-}
diff --git a/packages/cli/src/context/core/abort.ts b/packages/cli/src/context/core/abort.ts
new file mode 100644
index 00000000..95467c52
--- /dev/null
+++ b/packages/cli/src/context/core/abort.ts
@@ -0,0 +1,39 @@
+/** @internal */
+export function createAbortError(message = 'Aborted'): DOMException {
+ return new DOMException(message, 'AbortError');
+}
+
+export function isAbortError(error: unknown): boolean {
+ if (error instanceof DOMException && error.name === 'AbortError') {
+ return true;
+ }
+ if (!error || typeof error !== 'object') {
+ return false;
+ }
+ const record = error as { name?: unknown; code?: unknown };
+ return record.name === 'AbortError' || record.code === 'ABORT_ERR';
+}
+
+/** @internal */
+export function throwIfAborted(signal?: AbortSignal): void {
+ if (signal?.aborted) {
+ throw createAbortError();
+ }
+}
+
+export function linkAbortSignal(parent?: AbortSignal): { controller: AbortController; dispose: () => void } {
+ const controller = new AbortController();
+ if (!parent) {
+ return { controller, dispose: () => undefined };
+ }
+ if (parent.aborted) {
+ controller.abort(createAbortError());
+ return { controller, dispose: () => undefined };
+ }
+ const onAbort = () => controller.abort(createAbortError());
+ parent.addEventListener('abort', onAbort, { once: true });
+ return {
+ controller,
+ dispose: () => parent.removeEventListener('abort', onAbort),
+ };
+}
diff --git a/packages/cli/src/context/core/git-env.ts b/packages/cli/src/context/core/git-env.ts
index 9ad3f121..645a29cc 100644
--- a/packages/cli/src/context/core/git-env.ts
+++ b/packages/cli/src/context/core/git-env.ts
@@ -24,6 +24,21 @@ function sanitizedGitEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEn
return sanitized;
}
-export function createSimpleGit(baseDir: string): SimpleGit {
- return simpleGit({ baseDir, unsafe: { allowUnsafeAskPass: true } }).env(sanitizedGitEnv());
+/**
+ * Create a simple-git client scoped to `baseDir`. When an identity is provided, ktx's own
+ * commits carry it through the GIT_AUTHOR and GIT_COMMITTER environment variables instead of
+ * relying on repo-local or global git config. This keeps commits working when the project
+ * directory is an existing repo ktx did not create and the machine has no configured git
+ * identity (e.g. a fresh Mac with no ~/.gitconfig), without mutating the user's repo config.
+ * Explicit `--author` flags on individual commits still take precedence over GIT_AUTHOR_NAME.
+ */
+export function createSimpleGit(baseDir: string, identity?: { name: string; email: string }): SimpleGit {
+ const env = sanitizedGitEnv();
+ if (identity?.name && identity.email) {
+ env.GIT_AUTHOR_NAME = identity.name;
+ env.GIT_AUTHOR_EMAIL = identity.email;
+ env.GIT_COMMITTER_NAME = identity.name;
+ env.GIT_COMMITTER_EMAIL = identity.email;
+ }
+ return simpleGit({ baseDir, unsafe: { allowUnsafeAskPass: true } }).env(env);
}
diff --git a/packages/cli/src/context/core/git.service.ts b/packages/cli/src/context/core/git.service.ts
index 216183ff..a9638ea5 100644
--- a/packages/cli/src/context/core/git.service.ts
+++ b/packages/cli/src/context/core/git.service.ts
@@ -1,6 +1,6 @@
import { promises as fs } from 'node:fs';
import { dirname, join } from 'node:path';
-import type { SimpleGit } from 'simple-git';
+import { CheckRepoActions, type SimpleGit } from 'simple-git';
import { noopLogger, resolveConfigDir, type KtxCoreConfig, type KtxLogger } from './config.js';
import { createSimpleGit } from './git-env.js';
@@ -85,8 +85,12 @@ export class GitService {
await fs.mkdir(this.configDir, { recursive: true });
this.logger.log(`Config directory ensured at: ${this.configDir}`);
- // Initialize simple-git
- this.git = createSimpleGit(this.configDir);
+ // Initialize simple-git. Carry ktx's identity in the environment so commits succeed even
+ // when this repo already exists and the machine has no configured git identity.
+ this.git = createSimpleGit(this.configDir, {
+ name: this.config.git.userName,
+ email: this.config.git.userEmail,
+ });
// Initialize git repository
await this.initialize();
@@ -94,14 +98,16 @@ export class GitService {
private async initialize(): Promise {
try {
- // Check if already initialized
- const isRepo = await this.git.checkIsRepo();
+ // Adopt an existing repo ONLY when this directory is itself that repo's root.
+ // When it sits below an enclosing repo, a plain checkIsRepo() is true and ktx
+ // would silently piggyback on the enclosing tree — but every ktx relative path
+ // (file-store writes, session worktrees, squash-merges, reindex scans) assumes
+ // this directory IS the working-tree root. So treat "inside an enclosing repo"
+ // the same as "no repo" and initialize a dedicated repo rooted here.
+ const isRepoRoot = await this.git.checkIsRepo(CheckRepoActions.IS_REPO_ROOT);
- if (!isRepo) {
+ if (!isRepoRoot) {
await this.git.init();
- const gitConfig = this.config.git;
- await this.git.addConfig('user.name', gitConfig.userName);
- await this.git.addConfig('user.email', gitConfig.userEmail);
this.logger.log('Initialized git repository');
}
@@ -125,7 +131,11 @@ export class GitService {
}
} catch (error) {
this.logger.error('Failed to initialize git repository', error);
- throw new Error('Failed to initialize git repository');
+ // Preserve the underlying git error: the generic message alone is undiagnosable in
+ // telemetry and unactionable for the user. The exception reporter walks `cause` and
+ // redacts secrets before send.
+ const detail = error instanceof Error ? error.message : String(error);
+ throw new Error(`Failed to initialize git repository: ${detail}`, { cause: error });
}
}
@@ -899,7 +909,10 @@ export class GitService {
*/
forWorktree(workdir: string): GitService {
const scoped = new GitService(this.config, this.logger);
- scoped.git = createSimpleGit(workdir);
+ scoped.git = createSimpleGit(workdir, {
+ name: this.config.git.userName,
+ email: this.config.git.userEmail,
+ });
scoped.configDir = workdir;
return scoped;
}
diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/query-history-filter-picker.ts b/packages/cli/src/context/ingest/adapters/historic-sql/query-history-filter-picker.ts
index bb296513..3f77900d 100644
--- a/packages/cli/src/context/ingest/adapters/historic-sql/query-history-filter-picker.ts
+++ b/packages/cli/src/context/ingest/adapters/historic-sql/query-history-filter-picker.ts
@@ -23,6 +23,7 @@ export interface QueryHistoryFilterProposal {
consideredRoleCount: number;
skipped: { reason: 'no-llm' | 'no-daemon' | 'no-in-scope-history' | 'user-block-present' } | null;
warnings: string[];
+ parseFailedTemplateIds: string[];
}
export interface ProposeQueryHistoryServiceAccountFiltersInput {
@@ -74,7 +75,7 @@ const queryHistoryFilterAdjudicationSchema = z.object({
type QueryHistoryFilterAdjudication = z.infer;
function emptyProposal(skipped: QueryHistoryFilterProposal['skipped'], warnings: string[] = []): QueryHistoryFilterProposal {
- return { excludedRoles: [], consideredRoleCount: 0, skipped, warnings };
+ return { excludedRoles: [], consideredRoleCount: 0, skipped, warnings, parseFailedTemplateIds: [] };
}
function displayTableRef(ref: KtxTableRef): string {
@@ -180,6 +181,7 @@ export async function proposeQueryHistoryServiceAccountFilters(
const windowDays = 'windowDays' in config ? config.windowDays : 90;
const windowStart = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000);
const warnings: string[] = [];
+ const parseFailedTemplateIds: string[] = [];
const snapshot: AggregatedTemplate[] = [];
try {
@@ -212,7 +214,7 @@ export async function proposeQueryHistoryServiceAccountFilters(
for (const template of snapshot) {
const parsed = analysis.get(template.templateId);
if (!parsed || parsed.error) {
- warnings.push(`query_history_filter_picker_parse_failed:${template.templateId}`);
+ parseFailedTemplateIds.push(template.templateId);
continue;
}
const tablesTouched = [...new Map(parsed.tablesTouched.map((ref) => [tableRefKey(ref), ref])).values()]
@@ -236,6 +238,7 @@ export async function proposeQueryHistoryServiceAccountFilters(
consideredRoleCount: records.length,
skipped: { reason: 'no-in-scope-history' },
warnings,
+ parseFailedTemplateIds,
};
}
@@ -256,6 +259,7 @@ export async function proposeQueryHistoryServiceAccountFilters(
...warnings,
`query_history_filter_picker_llm_failed:${error instanceof Error ? error.message : String(error)}`,
],
+ parseFailedTemplateIds,
};
}
@@ -274,5 +278,6 @@ export async function proposeQueryHistoryServiceAccountFilters(
consideredRoleCount: records.length,
skipped: input.userServiceAccountsPresent ? { reason: 'user-block-present' } : null,
warnings,
+ parseFailedTemplateIds,
};
}
diff --git a/packages/cli/src/context/ingest/context-candidates/curator-pagination.service.ts b/packages/cli/src/context/ingest/context-candidates/curator-pagination.service.ts
index 348544ca..fbeab08c 100644
--- a/packages/cli/src/context/ingest/context-candidates/curator-pagination.service.ts
+++ b/packages/cli/src/context/ingest/context-candidates/curator-pagination.service.ts
@@ -39,7 +39,7 @@ export interface CuratorPaginationInput {
buildUserPrompt: (input: CuratorPaginationPromptInput) => string;
buildToolSet: (passNumber: number) => KtxRuntimeToolSet;
getReconciliationActions: () => MemoryAction[];
- onStepFinish?: (info: { passNumber: number; stepIndex: number; stepBudget: number }) => void;
+ abortSignal?: AbortSignal;
}
interface CuratorPaginationResult extends ReconciliationOutcome {
@@ -243,10 +243,7 @@ export class CuratorPaginationService implements CuratorPaginationPort {
sourceKey: params.input.sourceKey,
jobId: params.input.jobId,
forceRun: params.forceRun,
- onStepFinish: params.input.onStepFinish
- ? ({ stepIndex, stepBudget }) =>
- params.input.onStepFinish?.({ passNumber: params.passNumber, stepIndex, stepBudget })
- : undefined,
+ abortSignal: params.input.abortSignal,
});
}
diff --git a/packages/cli/src/context/ingest/final-gate-repair.ts b/packages/cli/src/context/ingest/final-gate-repair.ts
index 1c373aa6..f32178d8 100644
--- a/packages/cli/src/context/ingest/final-gate-repair.ts
+++ b/packages/cli/src/context/ingest/final-gate-repair.ts
@@ -21,6 +21,7 @@ export interface RepairFinalGateFailureInput {
repairKind: FinalGateRepairKind;
maxAttempts?: number;
stepBudget?: number;
+ abortSignal?: AbortSignal;
}
const readRepairFileSchema = z.object({
@@ -200,6 +201,7 @@ export async function repairFinalGateFailure(
jobId: input.trace.context.jobId,
repairKind: input.repairKind,
},
+ abortSignal: input.abortSignal,
}),
);
diff --git a/packages/cli/src/context/ingest/ingest-bundle.runner.ts b/packages/cli/src/context/ingest/ingest-bundle.runner.ts
index 3f2b41d3..6f5372d2 100644
--- a/packages/cli/src/context/ingest/ingest-bundle.runner.ts
+++ b/packages/cli/src/context/ingest/ingest-bundle.runner.ts
@@ -3,6 +3,7 @@ import { dirname, join } from 'node:path';
import pLimit from 'p-limit';
import { z } from 'zod';
import { type KtxLogger, noopLogger } from '../../context/core/config.js';
+import type { RateLimitWaitState } from '../../context/llm/rate-limit-governor.js';
import { createRuntimeToolDescriptorFromAiTool } from '../../context/llm/runtime-tools.js';
import type { KtxRuntimeToolSet } from '../../context/llm/runtime-port.js';
import type { CaptureSession, MemoryAction } from '../../context/memory/types.js';
@@ -219,6 +220,10 @@ export class IngestBundleRunner {
}
async run(job: IngestBundleJob, ctx?: IngestJobContext): Promise {
+ const unsubscribeRateLimitGovernor = this.subscribeRateLimitGovernor({
+ trace: this.createTrace(job),
+ memoryFlow: ctx?.memoryFlow,
+ });
const key = job.connectionId;
const previous = this.chainByConnection.get(key);
if (previous) {
@@ -241,10 +246,72 @@ export class IngestBundleRunner {
ctx?.memoryFlow?.finish('error', [sanitizeMemoryFlowError(error)]);
throw error;
} finally {
+ unsubscribeRateLimitGovernor();
await this.maybeEmitIngestProfile(job.jobId);
}
}
+ private formatRateLimitWait(
+ state: Extract,
+ ): string {
+ const seconds = Math.ceil(state.remainingMs / 1_000);
+ const minutes = Math.floor(seconds / 60);
+ const remainder = seconds % 60;
+ const duration = minutes > 0 ? `${minutes}m${String(remainder).padStart(2, '0')}s` : `${seconds}s`;
+ const type = state.rateLimitType ? ` ${state.rateLimitType}` : '';
+ return `Rate-limited (${state.provider}${type}); resuming in ${duration}; Ctrl+C to stop`;
+ }
+
+ private subscribeRateLimitGovernor(input: {
+ trace: IngestTraceWriter;
+ memoryFlow?: MemoryFlowEventSink;
+ }): () => void {
+ const governor = this.deps.settings.rateLimitGovernor;
+ if (!governor) {
+ return () => undefined;
+ }
+ return governor.subscribe((state: RateLimitWaitState) => {
+ if (state.kind === 'rate_limit_observed') {
+ void input.trace.event('info', 'rate_limit', 'rate_limit_observed', { ...state });
+ return;
+ }
+ if (state.kind === 'concurrency_adjusted') {
+ void input.trace.event('info', 'rate_limit', 'concurrency_adjusted', { ...state });
+ return;
+ }
+ void input.trace.event('info', 'rate_limit', state.kind, { ...state });
+ if (state.kind === 'wait_tick' || state.kind === 'wait_started') {
+ input.memoryFlow?.emit({
+ type: 'rate_limit_wait',
+ provider: state.provider,
+ ...(state.rateLimitType ? { rateLimitType: state.rateLimitType } : {}),
+ resumeAtMs: state.resumeAtMs,
+ remainingMs: state.remainingMs,
+ });
+ input.memoryFlow?.emit({
+ type: 'stage_progress',
+ stage: 'integration',
+ percent: 50,
+ message: this.formatRateLimitWait(state),
+ transient: true,
+ });
+ }
+ });
+ }
+
+ private async withRateLimitWorkSlot(abortSignal: AbortSignal | undefined, fn: () => Promise): Promise {
+ const governor = this.deps.settings.rateLimitGovernor;
+ if (!governor) {
+ return fn();
+ }
+ const release = await governor.acquireWorkSlot(abortSignal);
+ try {
+ return await fn();
+ } finally {
+ release();
+ }
+ }
+
/**
* When profiling is enabled — via the `KTX_PROFILE_INGEST` env var or the
* `ingest.profile` config setting — read the job's trace + tool transcripts
@@ -872,13 +939,13 @@ export class IngestBundleRunner {
workUnitSettings: { maxConcurrency: number; stepBudget: number; failureMode: 'abort' | 'continue' };
transcriptDir: string;
transcriptSummaries: Map;
- recordTranscriptEntry(path: string): (entry: ToolCallLogEntry) => void;
+ recordTranscriptEntry(path: string): (entry: ToolCallLogEntry) => MutableToolTranscriptSummary;
stageIndex: StageIndex;
includeContextEvidenceTools: boolean;
currentTableExists(tableRef: string): Promise;
memoryFlow?: MemoryFlowEventSink;
+ abortSignal?: AbortSignal;
wuSkillNames: string[];
- onStepFinish?: (info: { stepIndex: number; stepBudget: number }) => void;
}): Promise {
const session: CaptureSession = {
userId: 'system',
@@ -982,7 +1049,6 @@ export class IngestBundleRunner {
type: 'work_unit_started',
unitKey: input.wu.unitKey,
skills: input.wuSkillNames,
- stepBudget: input.workUnitSettings.stepBudget,
});
return executeWorkUnit(
{
@@ -1006,8 +1072,10 @@ export class IngestBundleRunner {
slIndex: input.slIndex,
priorProvenance: input.priorProvenance,
}),
- buildToolSet: (wuInner) =>
- wrapToolsWithLogger(
+ buildToolSet: (wuInner) => {
+ const transcriptPath = join(input.transcriptDir, `${wuInner.unitKey}.jsonl`);
+ const record = input.recordTranscriptEntry(transcriptPath);
+ return wrapToolsWithLogger(
buildWuToolSet({
sourceKey: input.job.sourceKey,
stagedDir: input.stagedDir,
@@ -1016,10 +1084,23 @@ export class IngestBundleRunner {
emitUnmappedFallbackTool: wuEmitUnmappedFallbackTool,
toolsetTools: wuToolset.toRuntimeTools(wuToolContext),
}),
- join(input.transcriptDir, `${wuInner.unitKey}.jsonl`),
+ transcriptPath,
wuInner.unitKey,
- { onEntry: input.recordTranscriptEntry(join(input.transcriptDir, `${wuInner.unitKey}.jsonl`)) },
- ),
+ {
+ // Drive the live HUD heartbeat from real tool calls: each invocation
+ // ticks the running per-unit count. This is an observed signal, not a
+ // re-derived turn count, so it can never overshoot a budget.
+ onEntry: (entry) => {
+ const summary = record(entry);
+ input.memoryFlow?.emit({
+ type: 'work_unit_step',
+ unitKey: wuInner.unitKey,
+ toolCalls: summary.toolCallCount,
+ });
+ },
+ },
+ );
+ },
captureSession: session,
sessionActions,
modelRole: 'candidateExtraction',
@@ -1028,7 +1109,7 @@ export class IngestBundleRunner {
connectionId: input.job.connectionId,
jobId: input.job.jobId,
toolFailureCount: (unitKey) => input.transcriptSummaries.get(unitKey)?.fatalErrorCount ?? 0,
- onStepFinish: input.onStepFinish,
+ abortSignal: input.abortSignal,
},
input.wu,
);
@@ -1097,11 +1178,12 @@ export class IngestBundleRunner {
const transcriptDir = this.deps.storage.resolveTranscriptDir(job.jobId);
const recordTranscriptEntry =
(path: string) =>
- (entry: ToolCallLogEntry): void => {
+ (entry: ToolCallLogEntry): MutableToolTranscriptSummary => {
const current =
transcriptSummaries.get(entry.wuKey) ?? createMutableToolTranscriptSummary(entry.wuKey, path);
recordToolTranscriptEntry(current, entry);
transcriptSummaries.set(entry.wuKey, current);
+ return current;
};
const overrideReport = await this.loadOverrideReport(job);
@@ -1524,7 +1606,8 @@ export class IngestBundleRunner {
try {
await Promise.all(
workUnits.map((wu, index) =>
- limitWorkUnit(async () => {
+ limitWorkUnit(() =>
+ this.withRateLimitWorkSlot(ctx?.abortSignal, async () => {
const outcome = await runIsolatedWorkUnit({
unitIndex: index,
ingestionBaseSha,
@@ -1532,6 +1615,7 @@ export class IngestBundleRunner {
patchDir,
trace: runTrace,
workUnit: wu,
+ abortSignal: ctx?.abortSignal,
afterSuccess: (child) => copyTransientIngestEvidence(child.workdir, sessionWorktree.workdir),
run: async (child) => {
const scopedWikiService = this.deps.wikiService.forWorktree(child.workdir);
@@ -1565,11 +1649,9 @@ export class IngestBundleRunner {
includeContextEvidenceTools: adapter.evidenceIndexing === 'documents' && !!contextReport,
currentTableExists: (tableRef) =>
this.tableRefExistsInSemanticLayer(scopedSemanticLayerService, slConnectionIds, tableRef),
+ abortSignal: ctx?.abortSignal,
memoryFlow,
wuSkillNames,
- onStepFinish: ({ stepIndex, stepBudget }) => {
- memoryFlow?.emit({ type: 'work_unit_step', unitKey: wu.unitKey, stepIndex, stepBudget });
- },
});
},
});
@@ -1594,7 +1676,8 @@ export class IngestBundleRunner {
completedWorkUnits / workUnits.length,
`${completedWorkUnits} of ${workUnits.length} work units complete`,
);
- }),
+ }),
+ ),
),
);
} catch (error) {
@@ -1693,6 +1776,7 @@ export class IngestBundleRunner {
reason: context.reason,
maxAttempts: 1,
stepBudget: 12,
+ abortSignal: ctx?.abortSignal,
});
emitStageProgress(
'integration',
@@ -1714,6 +1798,7 @@ export class IngestBundleRunner {
repairKind: 'patch_semantic_gate',
maxAttempts: 1,
stepBudget: 16,
+ abortSignal: ctx?.abortSignal,
});
emitStageProgress(
'integration',
@@ -1938,6 +2023,45 @@ export class IngestBundleRunner {
let curatorWarnings: string[] = [];
let reconcileOutcome: Awaited>;
+ // Reconcile shares the work-unit liveness model: the HUD heartbeat is driven
+ // by real tool calls (a monotonic, observed count), not a re-derived turn
+ // counter. The soft cap only paces the phase progress bar; it is never shown
+ // to the user, so it cannot read as a misleading "X/Y" fraction.
+ const reconcileTranscriptPath = join(transcriptDir, 'reconcile.jsonl');
+ const reconcileProgressSoftCap = 40;
+ const buildReconcileToolSetWithHeartbeat = (): KtxRuntimeToolSet => {
+ const record = recordTranscriptEntry(reconcileTranscriptPath);
+ return wrapToolsWithLogger(
+ buildReconcileToolSet({
+ loadSkillTool: rcLoadSkill,
+ stageListTool: rcStageListTool,
+ stageDiffTool: rcStageDiffTool,
+ evictionListTool: rcEvictionListTool,
+ emitConflictResolutionTool: rcEmitConflictResolutionTool,
+ emitEvictionDecisionTool: rcEmitEvictionDecisionTool,
+ emitArtifactResolutionTool: rcEmitArtifactResolutionTool,
+ emitUnmappedFallbackTool: rcEmitUnmappedFallbackTool,
+ readRawSpanTool: rcRawSpanTool,
+ toolsetTools: rcToolset.toRuntimeTools(rcToolContext),
+ }),
+ reconcileTranscriptPath,
+ 'reconcile',
+ {
+ onEntry: (entry) => {
+ const summary = record(entry);
+ if (!stage4) {
+ return;
+ }
+ const label = `Reconciling results · ${summary.toolCallCount} action${
+ summary.toolCallCount === 1 ? '' : 's'
+ }`;
+ emitStageProgress('reconciliation', 85, label, { transient: true });
+ void stage4.updateProgress(Math.min(0.95, summary.toolCallCount / reconcileProgressSoftCap), label);
+ },
+ },
+ );
+ };
+
const reconcileStartedAt = Date.now();
const reconcileMode = contextReport && this.deps.curatorPagination ? 'curator' : 'single';
if (contextReport && this.deps.curatorPagination) {
@@ -1960,39 +2084,9 @@ export class IngestBundleRunner {
}),
buildUserPrompt: ({ summary, items, runState }) =>
buildReconcileUserPrompt(stageIndex, eviction, { summary, items }, reconcileNotes, runState),
- buildToolSet: (_passNumber) =>
- wrapToolsWithLogger(
- buildReconcileToolSet({
- loadSkillTool: rcLoadSkill,
- stageListTool: rcStageListTool,
- stageDiffTool: rcStageDiffTool,
- evictionListTool: rcEvictionListTool,
- emitConflictResolutionTool: rcEmitConflictResolutionTool,
- emitEvictionDecisionTool: rcEmitEvictionDecisionTool,
- emitArtifactResolutionTool: rcEmitArtifactResolutionTool,
- emitUnmappedFallbackTool: rcEmitUnmappedFallbackTool,
- readRawSpanTool: rcRawSpanTool,
- toolsetTools: rcToolset.toRuntimeTools(rcToolContext),
- }),
- join(transcriptDir, 'reconcile.jsonl'),
- 'reconcile',
- { onEntry: recordTranscriptEntry(join(transcriptDir, 'reconcile.jsonl')) },
- ),
+ buildToolSet: (_passNumber) => buildReconcileToolSetWithHeartbeat(),
getReconciliationActions: () => reconcileActions,
- onStepFinish: stage4
- ? ({ passNumber, stepIndex, stepBudget }) => {
- emitStageProgress(
- 'reconciliation',
- 85,
- `Reconciling results: pass ${passNumber} step ${stepIndex}/${stepBudget}`,
- { transient: true },
- );
- void stage4.updateProgress(
- stepIndex / stepBudget,
- `Reconciling results · pass ${passNumber} step ${stepIndex}`,
- );
- }
- : undefined,
+ abortSignal: ctx?.abortSignal,
});
curatorReport = curatorOutcome.report;
curatorWarnings = curatorOutcome.warnings;
@@ -2015,37 +2109,13 @@ export class IngestBundleRunner {
canonicalPins: relevantCanonicalPins,
}),
buildUserPrompt: (idx, ev) => buildReconcileUserPrompt(idx, ev, undefined, reconcileNotes),
- buildToolSet: () =>
- wrapToolsWithLogger(
- buildReconcileToolSet({
- loadSkillTool: rcLoadSkill,
- stageListTool: rcStageListTool,
- stageDiffTool: rcStageDiffTool,
- evictionListTool: rcEvictionListTool,
- emitConflictResolutionTool: rcEmitConflictResolutionTool,
- emitEvictionDecisionTool: rcEmitEvictionDecisionTool,
- emitArtifactResolutionTool: rcEmitArtifactResolutionTool,
- emitUnmappedFallbackTool: rcEmitUnmappedFallbackTool,
- readRawSpanTool: rcRawSpanTool,
- toolsetTools: rcToolset.toRuntimeTools(rcToolContext),
- }),
- join(transcriptDir, 'reconcile.jsonl'),
- 'reconcile',
- { onEntry: recordTranscriptEntry(join(transcriptDir, 'reconcile.jsonl')) },
- ),
+ buildToolSet: () => buildReconcileToolSetWithHeartbeat(),
modelRole: 'reconcile',
stepBudget: 60,
sourceKey: job.sourceKey,
jobId: job.jobId,
force: !!overrideReport,
- onStepFinish: stage4
- ? ({ stepIndex, stepBudget }) => {
- emitStageProgress('reconciliation', 85, `Reconciling results: step ${stepIndex}/${stepBudget}`, {
- transient: true,
- });
- void stage4.updateProgress(stepIndex / stepBudget, `Reconciling results · step ${stepIndex}`);
- }
- : undefined,
+ abortSignal: ctx?.abortSignal,
});
}
await runTrace.event(
@@ -2470,6 +2540,7 @@ export class IngestBundleRunner {
repairKind: 'final_artifact_gate',
maxAttempts: 1,
stepBudget: 16,
+ abortSignal: ctx?.abortSignal,
});
isolatedDiffSummary.gateRepairAttempts += gateRepair.attempts;
diff --git a/packages/cli/src/context/ingest/isolated-diff/textual-conflict-resolver.ts b/packages/cli/src/context/ingest/isolated-diff/textual-conflict-resolver.ts
index 5ae551d1..c4a00448 100644
--- a/packages/cli/src/context/ingest/isolated-diff/textual-conflict-resolver.ts
+++ b/packages/cli/src/context/ingest/isolated-diff/textual-conflict-resolver.ts
@@ -19,6 +19,7 @@ export interface ResolveTextualConflictInput {
reason: string;
maxAttempts?: number;
stepBudget?: number;
+ abortSignal?: AbortSignal;
}
const readIntegrationFileSchema = z.object({
@@ -208,6 +209,7 @@ export async function resolveTextualConflict(
jobId: input.trace.context.jobId,
unitKey: input.unitKey,
},
+ abortSignal: input.abortSignal,
}),
);
diff --git a/packages/cli/src/context/ingest/isolated-diff/work-unit-executor.ts b/packages/cli/src/context/ingest/isolated-diff/work-unit-executor.ts
index 7475612e..5ab52102 100644
--- a/packages/cli/src/context/ingest/isolated-diff/work-unit-executor.ts
+++ b/packages/cli/src/context/ingest/isolated-diff/work-unit-executor.ts
@@ -14,6 +14,7 @@ export interface RunIsolatedWorkUnitInput {
patchDir: string;
trace: IngestTraceWriter;
workUnit: WorkUnit;
+ abortSignal?: AbortSignal;
run(child: IngestSessionWorktree): Promise;
afterSuccess?(child: IngestSessionWorktree): Promise;
}
diff --git a/packages/cli/src/context/ingest/local-bundle-runtime.ts b/packages/cli/src/context/ingest/local-bundle-runtime.ts
index 9d6aba95..69b9159a 100644
--- a/packages/cli/src/context/ingest/local-bundle-runtime.ts
+++ b/packages/cli/src/context/ingest/local-bundle-runtime.ts
@@ -12,6 +12,7 @@ import type { KtxSemanticLayerComputePort } from '../../context/daemon/semantic-
import { createRuntimeToolDescriptorFromAiTool } from '../../context/llm/runtime-tools.js';
import { createLocalKtxLlmRuntimeFromConfig } from '../../context/llm/local-config.js';
import { KtxIngestEmbeddingPortAdapter } from '../../context/llm/embedding-port.js';
+import { createRateLimitGovernorConfig, RateLimitGovernor } from '../../context/llm/rate-limit-governor.js';
import { RuntimeAgentRunner, type AgentRunnerPort, type KtxLlmRuntimePort, type KtxRuntimeToolSet } from '../../context/llm/runtime-port.js';
import type { KtxEmbeddingProvider } from '../../llm/types.js';
import type { KtxLocalProject } from '../../context/project/project.js';
@@ -614,12 +615,12 @@ function localIngestLlmProviderGuardMessage(projectDir: string): string {
'ktx ingest requires llm.provider.backend: anthropic, vertex, gateway, claude-code, or codex, or an injected agentRunner.',
'Configure a local Claude Code/Codex session or API-backed LLM, then rerun ingest:',
` ktx setup --project-dir ${projectDir} --llm-backend claude-code --no-input`,
- ` ktx setup --project-dir ${projectDir} --llm-backend codex --llm-model gpt-5.5 --no-input`,
- ` ktx setup --project-dir ${projectDir} --llm-backend anthropic --anthropic-api-key-env ANTHROPIC_API_KEY --llm-model claude-sonnet-4-6 --no-input`,
+ ` ktx setup --project-dir ${projectDir} --llm-backend codex --no-input`,
+ ` ktx setup --project-dir ${projectDir} --llm-backend anthropic --anthropic-api-key-env ANTHROPIC_API_KEY --no-input`,
].join('\n');
}
-function resolveAgentRunner(options: CreateLocalBundleIngestRuntimeOptions): {
+function resolveAgentRunner(options: CreateLocalBundleIngestRuntimeOptions, rateLimitGovernor: RateLimitGovernor): {
agentRunner: AgentRunnerPort;
llmRuntime?: KtxLlmRuntimePort;
} {
@@ -628,6 +629,7 @@ function resolveAgentRunner(options: CreateLocalBundleIngestRuntimeOptions): {
(options.createLlmRuntime ?? createLocalKtxLlmRuntimeFromConfig)(options.project.config.llm, {
projectDir: options.project.projectDir,
env: process.env,
+ rateLimitGovernor,
}) ??
undefined;
@@ -677,7 +679,13 @@ export function createLocalBundleIngestRuntime(
const knowledgeIndex = new LocalKnowledgeIndex(options.project, embedding);
const knowledgeEvents = new NoopKnowledgeEventPort();
const wikiService = new KnowledgeWikiService(rootFileStore, embedding, knowledgeIndex, options.project.git, logger);
- const { agentRunner, llmRuntime } = resolveAgentRunner(options);
+ const rateLimitGovernor = new RateLimitGovernor(
+ createRateLimitGovernorConfig({
+ ...options.project.config.ingest.rateLimit,
+ maxConcurrency: options.project.config.ingest.workUnits.maxConcurrency,
+ }),
+ );
+ const { agentRunner, llmRuntime } = resolveAgentRunner(options, rateLimitGovernor);
const promptService = new PromptService({ promptsDir, partials: [], logger });
const storage = new LocalIngestStorage(options.project);
const registry = registerAdapters(options.adapters);
@@ -717,6 +725,7 @@ export function createLocalBundleIngestRuntime(
workUnitMaxConcurrency: options.project.config.ingest.workUnits.maxConcurrency,
workUnitStepBudget: options.project.config.ingest.workUnits.stepBudget,
workUnitFailureMode: options.project.config.ingest.workUnits.failureMode,
+ rateLimitGovernor,
profileIngest: options.project.config.ingest.profile,
ingestTraceLevel: ingestTraceLevelFromEnv(),
},
diff --git a/packages/cli/src/context/ingest/local-ingest.ts b/packages/cli/src/context/ingest/local-ingest.ts
index ec8a72f4..1a219629 100644
--- a/packages/cli/src/context/ingest/local-ingest.ts
+++ b/packages/cli/src/context/ingest/local-ingest.ts
@@ -3,6 +3,7 @@ import { cp, mkdir, rm } from 'node:fs/promises';
import { isAbsolute, resolve } from 'node:path';
import type { KtxSqlQueryExecutorPort } from '../../context/connections/query-executor.js';
import type { KtxLogger } from '../../context/core/config.js';
+import { createAbortError, isAbortError } from '../../context/core/abort.js';
import type { KtxSemanticLayerComputePort } from '../../context/daemon/semantic-layer-compute.js';
import type { AgentRunnerPort, KtxLlmRuntimePort } from '../../context/llm/runtime-port.js';
import type { KtxLocalProject } from '../../context/project/project.js';
@@ -36,6 +37,7 @@ export interface RunLocalIngestOptions {
queryExecutor?: KtxSqlQueryExecutorPort;
logger?: KtxLogger;
embeddingProvider?: import('../../llm/types.js').KtxEmbeddingProvider | null;
+ abortSignal?: AbortSignal;
}
export interface LocalIngestResult {
@@ -123,10 +125,11 @@ function findAdapter(adapters: SourceAdapter[], source: string): SourceAdapter {
return adapter;
}
-function localJobContext(jobId: string, memoryFlow?: MemoryFlowEventSink): IngestJobContext {
+function localJobContext(jobId: string, memoryFlow?: MemoryFlowEventSink, abortSignal?: AbortSignal): IngestJobContext {
return {
jobId,
...(memoryFlow ? { memoryFlow } : {}),
+ ...(abortSignal ? { abortSignal } : {}),
startPhase() {
return new LocalIngestPhase();
},
@@ -158,6 +161,7 @@ async function runScheduledPullJob(options: {
queryExecutor?: KtxSqlQueryExecutorPort;
logger?: KtxLogger;
embeddingProvider?: import('../../llm/types.js').KtxEmbeddingProvider | null;
+ abortSignal?: AbortSignal;
}): Promise {
const runtime = createLocalBundleIngestRuntime(options);
const jobId = options.jobId ?? runtime.nextJobId();
@@ -169,7 +173,7 @@ async function runScheduledPullJob(options: {
trigger: options.trigger ?? 'manual_resync',
bundleRef: { kind: 'scheduled_pull', config: options.pullConfig },
},
- localJobContext(jobId, options.memoryFlow),
+ localJobContext(jobId, options.memoryFlow, options.abortSignal),
);
const report = await runtime.store.findByJobId(jobId);
if (!report) {
@@ -212,6 +216,7 @@ export async function runLocalIngest(options: RunLocalIngestOptions): Promise [
- { type: 'work_unit_started', unitKey: workUnit.unitKey, skills: [], stepBudget: 0 } satisfies MemoryFlowEvent,
+ { type: 'work_unit_started', unitKey: workUnit.unitKey, skills: [] } satisfies MemoryFlowEvent,
...workUnit.actions.map(
(action): MemoryFlowEvent => ({
type: 'candidate_action',
diff --git a/packages/cli/src/context/ingest/memory-flow/schema.ts b/packages/cli/src/context/ingest/memory-flow/schema.ts
index 0268a53f..939d5a18 100644
--- a/packages/cli/src/context/ingest/memory-flow/schema.ts
+++ b/packages/cli/src/context/ingest/memory-flow/schema.ts
@@ -70,17 +70,22 @@ const memoryFlowEventSchema = z.discriminatedUnion('type', [
message: z.string().min(1),
transient: z.boolean().optional(),
}),
+ eventSchema({
+ type: z.literal('rate_limit_wait'),
+ provider: z.string(),
+ rateLimitType: z.string().optional(),
+ resumeAtMs: z.number().int().nonnegative(),
+ remainingMs: z.number().int().nonnegative(),
+ }),
eventSchema({
type: z.literal('work_unit_started'),
unitKey: z.string().min(1),
skills: z.array(z.string().min(1)),
- stepBudget: z.number().int().min(0),
}),
eventSchema({
type: z.literal('work_unit_step'),
unitKey: z.string().min(1),
- stepIndex: z.number().int().min(0),
- stepBudget: z.number().int().min(0),
+ toolCalls: z.number().int().min(0),
}),
eventSchema({
type: z.literal('candidate_action'),
diff --git a/packages/cli/src/context/ingest/memory-flow/types.ts b/packages/cli/src/context/ingest/memory-flow/types.ts
index ab4619a6..e620189e 100644
--- a/packages/cli/src/context/ingest/memory-flow/types.ts
+++ b/packages/cli/src/context/ingest/memory-flow/types.ts
@@ -60,17 +60,22 @@ type MemoryFlowEventPayload =
message: string;
transient?: boolean;
}
+ | {
+ type: 'rate_limit_wait';
+ provider: string;
+ rateLimitType?: string;
+ resumeAtMs: number;
+ remainingMs: number;
+ }
| {
type: 'work_unit_started';
unitKey: string;
skills: string[];
- stepBudget: number;
}
| {
type: 'work_unit_step';
unitKey: string;
- stepIndex: number;
- stepBudget: number;
+ toolCalls: number;
}
| {
type: 'candidate_action';
diff --git a/packages/cli/src/context/ingest/ports.ts b/packages/cli/src/context/ingest/ports.ts
index e37e7460..88294f59 100644
--- a/packages/cli/src/context/ingest/ports.ts
+++ b/packages/cli/src/context/ingest/ports.ts
@@ -5,6 +5,7 @@ import type { KtxFileStorePort } from '../../context/core/file-store.js';
import type { KtxLogger } from '../../context/core/config.js';
import type { SessionOutcome } from '../../context/core/session-worktree.service.js';
import type { AgentRunnerPort, KtxLlmRuntimePort, KtxRuntimeToolSet } from '../../context/llm/runtime-port.js';
+import type { RateLimitGovernor } from '../llm/rate-limit-governor.js';
import type { MemoryAction, MemoryKnowledgeSlRefsPort } from '../../context/memory/types.js';
import type { PromptService } from '../../context/prompts/prompt.service.js';
import type { SkillsRegistryService } from '../../context/skills/skills-registry.service.js';
@@ -144,6 +145,7 @@ interface IngestSettingsPort {
workUnitMaxConcurrency?: number;
workUnitStepBudget?: number;
workUnitFailureMode?: 'abort' | 'continue';
+ rateLimitGovernor?: RateLimitGovernor;
/** Print a timing breakdown to stderr at the end of each run (config-driven; see also KTX_PROFILE_INGEST). `'json'` emits the raw structured profile. */
profileIngest?: boolean | 'json';
ingestTraceLevel?: IngestTraceLevel;
@@ -322,7 +324,7 @@ export interface CuratorPaginationPort {
}) => string;
buildToolSet: (passNumber: number) => KtxRuntimeToolSet;
getReconciliationActions: () => MemoryAction[];
- onStepFinish?: (info: { passNumber: number; stepIndex: number; stepBudget: number }) => void;
+ abortSignal?: AbortSignal;
}): Promise;
}
diff --git a/packages/cli/src/context/ingest/stages/stage-3-work-units.ts b/packages/cli/src/context/ingest/stages/stage-3-work-units.ts
index ec514a02..9e4bbbc6 100644
--- a/packages/cli/src/context/ingest/stages/stage-3-work-units.ts
+++ b/packages/cli/src/context/ingest/stages/stage-3-work-units.ts
@@ -1,4 +1,5 @@
import type { KtxModelRole } from '../../../llm/types.js';
+import { isAbortError } from '../../core/abort.js';
import type { AgentRunnerPort, KtxRuntimeToolSet, RunLoopMetrics } from '../../../context/llm/runtime-port.js';
import type { CaptureSession, MemoryAction } from '../../../context/memory/types.js';
import { listTouchedSlSources, type TouchedSlSource } from '../../../context/tools/touched-sl-sources.js';
@@ -27,7 +28,7 @@ export interface WorkUnitExecutionDeps {
sourceKey: string;
connectionId: string;
jobId: string;
- onStepFinish?: (info: { stepIndex: number; stepBudget: number }) => void;
+ abortSignal?: AbortSignal;
toolFailureCount?: (unitKey: string) => number;
}
@@ -105,9 +106,12 @@ export async function executeWorkUnit(deps: WorkUnitExecutionDeps, wu: WorkUnit)
unitKey: wu.unitKey,
jobId: deps.jobId,
},
- onStepFinish: deps.onStepFinish,
+ abortSignal: deps.abortSignal,
});
} catch (error) {
+ if (isAbortError(error)) {
+ throw error;
+ }
return failWithResetFromCurrentHead(error instanceof Error ? error.message : String(error));
}
diff --git a/packages/cli/src/context/ingest/stages/stage-4-reconciliation.ts b/packages/cli/src/context/ingest/stages/stage-4-reconciliation.ts
index 5abc9bfb..d87a8b80 100644
--- a/packages/cli/src/context/ingest/stages/stage-4-reconciliation.ts
+++ b/packages/cli/src/context/ingest/stages/stage-4-reconciliation.ts
@@ -15,7 +15,7 @@ export interface ReconciliationContext {
sourceKey: string;
jobId: string;
force?: boolean;
- onStepFinish?: (info: { stepIndex: number; stepBudget: number }) => void;
+ abortSignal?: AbortSignal;
forceRun?: boolean;
}
@@ -39,7 +39,7 @@ export async function runReconciliationStage4(ctx: ReconciliationContext): Promi
toolSet: ctx.buildToolSet(),
stepBudget: ctx.stepBudget,
telemetryTags: { operationName: 'ingest-bundle-reconcile', source: ctx.sourceKey, jobId: ctx.jobId },
- onStepFinish: ctx.onStepFinish,
+ abortSignal: ctx.abortSignal,
});
return { skipped: false, stopReason: run.stopReason, error: run.error, ...(run.metrics ? { metrics: run.metrics } : {}) };
}
diff --git a/packages/cli/src/context/ingest/types.ts b/packages/cli/src/context/ingest/types.ts
index 337885af..925f3d82 100644
--- a/packages/cli/src/context/ingest/types.ts
+++ b/packages/cli/src/context/ingest/types.ts
@@ -220,5 +220,6 @@ export interface IngestJobPhase {
export interface IngestJobContext {
jobId: string;
memoryFlow?: MemoryFlowEventSink;
+ abortSignal?: AbortSignal;
startPhase(weight: number): IngestJobPhase;
}
diff --git a/packages/cli/src/context/llm/ai-sdk-runtime.ts b/packages/cli/src/context/llm/ai-sdk-runtime.ts
index f5752355..81ada6ea 100644
--- a/packages/cli/src/context/llm/ai-sdk-runtime.ts
+++ b/packages/cli/src/context/llm/ai-sdk-runtime.ts
@@ -3,7 +3,9 @@ import type { KtxLlmProvider } from '../../llm/types.js';
import { generateText, Output, stepCountIs, type FlexibleSchema, type TelemetrySettings, type ToolSet } from 'ai';
import type { z } from 'zod';
import { noopLogger, type KtxLogger } from '../../context/core/config.js';
+import { isAbortError } from '../core/abort.js';
import { summarizeKtxLlmDebugRequest, type KtxLlmDebugRequestRecorder } from './debug-request-recorder.js';
+import type { RateLimitGovernor, RateLimitProvider, RateLimitSignal } from './rate-limit-governor.js';
import { createAiSdkToolSet } from './runtime-tools.js';
import type {
KtxGenerateObjectInput,
@@ -40,12 +42,129 @@ export interface AiSdkKtxLlmRuntimeDeps {
telemetry?: AgentTelemetryPort;
logger?: KtxLogger;
debugRequestRecorder?: KtxLlmDebugRequestRecorder;
+ rateLimitGovernor?: Pick;
}
function hasTools(tools: Record): boolean {
return Object.keys(tools).length > 0;
}
+function modelProviderName(model: unknown): RateLimitProvider {
+ const provider = (model as { provider?: string }).provider ?? '';
+ return provider.includes('vertex') || provider.includes('google') ? 'vertex' : 'anthropic-api';
+}
+
+interface HeaderLimitPair {
+ limit: string;
+ remaining: string;
+ rateLimitType: string;
+}
+
+const RATE_LIMIT_HEADER_PAIRS: HeaderLimitPair[] = [
+ {
+ limit: 'anthropic-ratelimit-requests-limit',
+ remaining: 'anthropic-ratelimit-requests-remaining',
+ rateLimitType: 'rpm',
+ },
+ {
+ limit: 'anthropic-ratelimit-tokens-limit',
+ remaining: 'anthropic-ratelimit-tokens-remaining',
+ rateLimitType: 'tpm',
+ },
+ {
+ limit: 'anthropic-ratelimit-input-tokens-limit',
+ remaining: 'anthropic-ratelimit-input-tokens-remaining',
+ rateLimitType: 'itpm',
+ },
+ {
+ limit: 'anthropic-ratelimit-output-tokens-limit',
+ remaining: 'anthropic-ratelimit-output-tokens-remaining',
+ rateLimitType: 'otpm',
+ },
+ {
+ limit: 'x-ratelimit-limit-requests',
+ remaining: 'x-ratelimit-remaining-requests',
+ rateLimitType: 'rpm',
+ },
+ {
+ limit: 'x-ratelimit-limit-tokens',
+ remaining: 'x-ratelimit-remaining-tokens',
+ rateLimitType: 'tpm',
+ },
+];
+
+function normalizeHeaders(headers: unknown): Record {
+ if (!headers || typeof headers !== 'object') {
+ return {};
+ }
+ const get = (headers as { get?: unknown }).get;
+ if (typeof get === 'function') {
+ const out: Record = {};
+ for (const pair of RATE_LIMIT_HEADER_PAIRS) {
+ const limit = get.call(headers, pair.limit);
+ const remaining = get.call(headers, pair.remaining);
+ if (typeof limit === 'string') out[pair.limit] = limit;
+ if (typeof remaining === 'string') out[pair.remaining] = remaining;
+ }
+ return out;
+ }
+ return Object.fromEntries(
+ Object.entries(headers as Record)
+ .filter((entry): entry is [string, string | number] => typeof entry[1] === 'string' || typeof entry[1] === 'number')
+ .map(([key, value]) => [key.toLowerCase(), String(value)]),
+ );
+}
+
+function numericHeader(headers: Record, key: string): number | undefined {
+ const value = Number(headers[key]);
+ return Number.isFinite(value) && value >= 0 ? value : undefined;
+}
+
+function utilizationForPair(headers: Record, pair: HeaderLimitPair): number | undefined {
+ const limit = numericHeader(headers, pair.limit);
+ const remaining = numericHeader(headers, pair.remaining);
+ if (limit === undefined || remaining === undefined || limit <= 0) {
+ return undefined;
+ }
+ return 1 - Math.min(limit, remaining) / limit;
+}
+
+function aiSdkHeaderRateLimitSignal(provider: RateLimitProvider, result: unknown): RateLimitSignal | undefined {
+ const headers = normalizeHeaders((result as { response?: { headers?: unknown } }).response?.headers);
+ let best: { utilization: number; rateLimitType: string } | undefined;
+ for (const pair of RATE_LIMIT_HEADER_PAIRS) {
+ const utilization = utilizationForPair(headers, pair);
+ if (utilization === undefined) {
+ continue;
+ }
+ if (!best || utilization > best.utilization) {
+ best = { utilization, rateLimitType: pair.rateLimitType };
+ }
+ }
+ if (!best) {
+ return undefined;
+ }
+ return {
+ provider,
+ status: 'allowed',
+ rateLimitType: best.rateLimitType,
+ utilization: Number(best.utilization.toFixed(4)),
+ };
+}
+
+function retryAfterMs(error: unknown): number | undefined {
+ const value = (error as { retryAfter?: unknown }).retryAfter;
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
+ return value < 1_000 ? value * 1_000 : value;
+ }
+ return undefined;
+}
+
+function isAiSdkRateLimitError(error: unknown): boolean {
+ const record = error as { name?: string; statusCode?: number; status?: number };
+ return record.name === 'TooManyRequestsError' || record.statusCode === 429 || record.status === 429;
+}
+
export class AiSdkKtxLlmRuntime implements KtxLlmRuntimePort {
private readonly logger: KtxLogger;
@@ -53,6 +172,41 @@ export class AiSdkKtxLlmRuntime implements KtxLlmRuntimePort {
this.logger = deps.logger ?? noopLogger;
}
+ private async generateTextWithRateLimitRetry(
+ provider: RateLimitProvider,
+ abortSignal: AbortSignal | undefined,
+ run: () => Promise,
+ ): Promise {
+ // maxRetryAttempts() returns 1 when no governor is present or pacing is
+ // disabled, so a 429 throws immediately instead of hammering the provider
+ // with no backoff; the AI SDK's own maxRetries still handles transient 429s.
+ const maxAttempts = this.deps.rateLimitGovernor?.maxRetryAttempts() ?? 1;
+ let attempt = 0;
+ while (true) {
+ await this.deps.rateLimitGovernor?.waitForReady(abortSignal);
+ try {
+ const result = await run();
+ const signal = aiSdkHeaderRateLimitSignal(provider, result);
+ if (signal) {
+ this.deps.rateLimitGovernor?.report(signal);
+ }
+ return result;
+ } catch (error) {
+ if (isAbortError(error) || !isAiSdkRateLimitError(error) || attempt >= maxAttempts - 1) {
+ throw error;
+ }
+ attempt += 1;
+ const retryAfter = retryAfterMs(error);
+ this.deps.rateLimitGovernor?.report({
+ provider,
+ status: 'rejected',
+ rateLimitType: 'http_429',
+ ...(retryAfter !== undefined ? { retryAfterMs: retryAfter } : {}),
+ });
+ }
+ }
+ }
+
async generateText(input: KtxGenerateTextInput): Promise {
const model = this.deps.llmProvider.getModel(input.role);
if ((model as { provider?: string }).provider === 'deterministic') {
@@ -67,12 +221,13 @@ export class AiSdkKtxLlmRuntime implements KtxLlmRuntimePort {
});
const split = splitKtxSystemMessages(built.messages);
const startedAt = Date.now();
- const result = await generateText({
+ const request = {
model,
temperature: input.temperature ?? 0,
...(split.system ? { system: split.system } : {}),
messages: split.messages,
tools: built.tools as ToolSet,
+ ...(input.abortSignal ? { abortSignal: input.abortSignal } : {}),
...(hasTools(tools)
? {
experimental_repairToolCall: this.deps.llmProvider.repairToolCallHandler({
@@ -80,7 +235,8 @@ export class AiSdkKtxLlmRuntime implements KtxLlmRuntimePort {
}),
}
: {}),
- });
+ };
+ const result = await this.generateTextWithRateLimitRetry(modelProviderName(model), input.abortSignal, () => generateText(request));
input.onMetrics?.({ totalMs: Date.now() - startedAt, usage: toLlmTokenUsage(result.totalUsage ?? result.usage) });
if (typeof result.text !== 'string') {
throw new Error('KTX LLM text generation returned no text');
@@ -101,12 +257,13 @@ export class AiSdkKtxLlmRuntime implements KtxLlmRuntimePort {
});
const split = splitKtxSystemMessages(built.messages);
const startedAt = Date.now();
- const result = await generateText({
+ const request = {
model,
temperature: input.temperature ?? 0,
...(split.system ? { system: split.system } : {}),
messages: split.messages,
tools: built.tools as ToolSet,
+ ...(input.abortSignal ? { abortSignal: input.abortSignal } : {}),
...(hasTools(tools)
? {
experimental_repairToolCall: this.deps.llmProvider.repairToolCallHandler({
@@ -115,7 +272,8 @@ export class AiSdkKtxLlmRuntime implements KtxLlmRuntimePort {
}
: {}),
output: Output.object({ schema: input.schema as unknown as FlexibleSchema }),
- });
+ };
+ const result = await this.generateTextWithRateLimitRetry(modelProviderName(model), input.abortSignal, () => generateText(request));
input.onMetrics?.({ totalMs: Date.now() - startedAt, usage: toLlmTokenUsage(result.totalUsage ?? result.usage) });
if (result.output == null) {
throw new Error('KTX LLM object generation returned no output');
@@ -152,7 +310,7 @@ export class AiSdkKtxLlmRuntime implements KtxLlmRuntimePort {
}),
);
- const result = await generateText({
+ const request = {
model,
temperature: 0,
stopWhen: stepCountIs(params.stepBudget),
@@ -163,23 +321,15 @@ export class AiSdkKtxLlmRuntime implements KtxLlmRuntimePort {
...(promptMessages.system ? { system: promptMessages.system } : {}),
messages: promptMessages.messages,
tools: built.tools as ToolSet,
- onStepFinish: async () => {
+ ...(params.abortSignal ? { abortSignal: params.abortSignal } : {}),
+ // Count model round-trips locally for metrics. `stepCountIs(stepBudget)`
+ // caps the loop, so this counter never exceeds the budget.
+ onStepFinish: () => {
stepIndex += 1;
stepBoundariesMs.push(Date.now() - startedAt);
- if (!params.onStepFinish) {
- return;
- }
- try {
- await params.onStepFinish({ stepIndex, stepBudget: params.stepBudget });
- } catch (err) {
- this.logger.warn(
- `[agent-runner] onStepFinish callback threw; ignoring: ${
- err instanceof Error ? err.message : String(err)
- }`,
- );
- }
},
- });
+ };
+ const result = await this.generateTextWithRateLimitRetry(modelProviderName(model), params.abortSignal, () => generateText(request));
return {
stopReason: 'natural',
metrics: {
@@ -190,6 +340,9 @@ export class AiSdkKtxLlmRuntime implements KtxLlmRuntimePort {
},
};
} catch (error) {
+ if (isAbortError(error)) {
+ throw error;
+ }
const err = error instanceof Error ? error : new Error(String(error));
this.logger.warn(`[agent-runner] loop failed: ${err.message}`);
return {
diff --git a/packages/cli/src/context/llm/claude-code-runtime.ts b/packages/cli/src/context/llm/claude-code-runtime.ts
index 0c1e6881..9d0cff70 100644
--- a/packages/cli/src/context/llm/claude-code-runtime.ts
+++ b/packages/cli/src/context/llm/claude-code-runtime.ts
@@ -6,9 +6,10 @@ import {
type SDKResultMessage,
} from '@anthropic-ai/claude-agent-sdk';
import { z } from 'zod';
-import { noopLogger, type KtxLogger } from '../../context/core/config.js';
+import { createAbortError, isAbortError, throwIfAborted } from '../core/abort.js';
import { createKtxClaudeCodeEnv } from './claude-code-env.js';
import { resolveClaudeCodeModel } from './claude-code-models.js';
+import type { RateLimitGovernor, RateLimitSignal } from './rate-limit-governor.js';
import { createClaudeSdkTools, mcpToolIds } from './runtime-tools.js';
import type {
KtxGenerateObjectInput,
@@ -21,7 +22,16 @@ import type {
RunLoopStopReason,
} from './runtime-port.js';
-type QueryFn = (params: Parameters[0]) => AsyncIterable;
+type QueryResult = AsyncIterable & {
+ interrupt?: () => void | Promise;
+};
+
+type QueryFn = (params: Parameters[0]) => QueryResult;
+
+interface ClaudeQueryOutcome {
+ result: SDKResultMessage;
+ rejectedRateLimitSignal?: RateLimitSignal;
+}
function claudeTokenUsage(result: SDKResultMessage): LlmTokenUsage {
const usage = (result as { usage?: { input_tokens?: number; output_tokens?: number } }).usage;
@@ -42,7 +52,7 @@ export interface ClaudeCodeKtxLlmRuntimeDeps {
modelSlots: { default: string } & Partial>;
query?: QueryFn;
env?: NodeJS.ProcessEnv;
- logger?: KtxLogger;
+ rateLimitGovernor?: Pick;
}
const BUILTIN_TOOLS = [
@@ -73,22 +83,6 @@ function isResult(message: SDKMessage): message is SDKResultMessage {
return message.type === 'result';
}
-// Skip emissions the SDK does not count toward `num_turns`: `pause_turn` continuations and
-// errored partials (e.g. `max_output_tokens`) it retries internally. Without this, the
-// runtime's step counter outruns `maxTurns` and the HUD renders e.g. `step 69/40`.
-function countsAsAssistantTurn(message: SDKMessage): boolean {
- if (message.type !== 'assistant' || message.parent_tool_use_id !== null) {
- return false;
- }
- if (message.error !== undefined) {
- return false;
- }
- if (message.message.stop_reason === 'pause_turn') {
- return false;
- }
- return true;
-}
-
function resultError(result: SDKResultMessage): Error | undefined {
if (result.subtype === 'success') {
return undefined;
@@ -157,6 +151,74 @@ function expectedMcpServerNames(tools: KtxRuntimeToolSet | undefined): Set 0 ? new Set([KTX_MCP_SERVER_NAME]) : new Set();
}
+const CLAUDE_RATE_LIMIT_ERROR_MARKERS = /\b429\b|rate limit|too many requests|quota exceeded|overloaded|max_retries/i;
+
+function normalizeClaudeResetAtMs(value: unknown): number | undefined {
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
+ return Math.round(value < 10_000_000_000 ? value * 1_000 : value);
+ }
+ if (typeof value === 'string') {
+ const numeric = Number(value);
+ if (Number.isFinite(numeric) && numeric > 0) {
+ return normalizeClaudeResetAtMs(numeric);
+ }
+ const parsed = Date.parse(value);
+ return Number.isFinite(parsed) ? parsed : undefined;
+ }
+ return undefined;
+}
+
+function isClaudeRateLimitResult(result: SDKResultMessage, rejectedSignal: RateLimitSignal | undefined): boolean {
+ const error = resultError(result);
+ if (!error) {
+ return false;
+ }
+ if (rejectedSignal?.status === 'rejected') {
+ return true;
+ }
+ const resultDetails = result as {
+ stop_reason?: unknown;
+ terminal_reason?: unknown;
+ errors?: unknown[];
+ };
+ const details = [
+ error.message,
+ resultDetails.stop_reason,
+ resultDetails.terminal_reason,
+ ...(resultDetails.errors ?? []),
+ ]
+ .filter((value): value is string => typeof value === 'string' && value.length > 0)
+ .join('\n');
+ return CLAUDE_RATE_LIMIT_ERROR_MARKERS.test(details);
+}
+
+function claudeRateLimitSignal(message: SDKMessage): RateLimitSignal | null {
+ const record = message as unknown as Record;
+ if (record.type === 'rate_limit_event') {
+ const info = record.rate_limit_info as Record | undefined;
+ if (!info) return null;
+ const rawStatus = typeof info.status === 'string' ? info.status : 'allowed';
+ const resetAtMs = normalizeClaudeResetAtMs(info.resetsAt);
+ return {
+ provider: 'claude-subscription',
+ status: rawStatus === 'rejected' ? 'rejected' : rawStatus === 'allowed_warning' ? 'warning' : 'allowed',
+ ...(resetAtMs !== undefined ? { resetAtMs } : {}),
+ ...(typeof info.rateLimitType === 'string' ? { rateLimitType: info.rateLimitType } : {}),
+ ...(typeof info.utilization === 'number' ? { utilization: info.utilization } : {}),
+ };
+ }
+ if (record.subtype === 'api_retry' || record.type === 'api_retry') {
+ const retryDelayMs = typeof record.retry_delay_ms === 'number' ? record.retry_delay_ms : undefined;
+ return {
+ provider: 'claude-subscription',
+ status: 'warning',
+ ...(retryDelayMs !== undefined ? { retryAfterMs: retryDelayMs } : {}),
+ rateLimitType: 'api_retry',
+ };
+ }
+ return null;
+}
+
function managedMcpSettings(serverNames: string[]): NonNullable {
return {
allowManagedMcpServersOnly: true,
@@ -216,31 +278,67 @@ async function collectResult(params: {
options: Options;
allowedToolIds: Set;
expectedMcpServerNames: Set;
- onAssistantTurn?: () => Promise;
-}): Promise {
+ rateLimitGovernor?: Pick;
+ abortSignal?: AbortSignal;
+}): Promise {
let result: SDKResultMessage | undefined;
- for await (const message of params.query({ prompt: params.prompt, options: params.options })) {
- assertInitIsolation(message, params.allowedToolIds, params.expectedMcpServerNames);
- if (countsAsAssistantTurn(message)) {
- await params.onAssistantTurn?.();
- }
- if (isResult(message)) {
- result = message;
+ let rejectedRateLimitSignal: RateLimitSignal | undefined;
+ throwIfAborted(params.abortSignal);
+ await params.rateLimitGovernor?.waitForReady(params.abortSignal);
+ throwIfAborted(params.abortSignal);
+ const queryResult = params.query({ prompt: params.prompt, options: params.options });
+ const onAbort = () => {
+ void Promise.resolve(queryResult.interrupt?.()).catch(() => undefined);
+ };
+ params.abortSignal?.addEventListener('abort', onAbort, { once: true });
+ try {
+ for await (const message of queryResult) {
+ throwIfAborted(params.abortSignal);
+ const rateLimitSignal = claudeRateLimitSignal(message);
+ if (rateLimitSignal) {
+ if (rateLimitSignal.status === 'rejected') {
+ rejectedRateLimitSignal = rateLimitSignal;
+ }
+ params.rateLimitGovernor?.report(rateLimitSignal);
+ }
+ assertInitIsolation(message, params.allowedToolIds, params.expectedMcpServerNames);
+ if (isResult(message)) {
+ result = message;
+ }
}
+ } finally {
+ params.abortSignal?.removeEventListener('abort', onAbort);
+ }
+ if (params.abortSignal?.aborted) {
+ throw createAbortError();
}
if (!result) {
throw new Error('Claude Code query returned no result message');
}
- return result;
+ return {
+ result,
+ ...(rejectedRateLimitSignal ? { rejectedRateLimitSignal } : {}),
+ };
+}
+
+async function collectResultWithRateLimitRetry(params: Parameters[0]): Promise {
+ // maxRetryAttempts() returns 1 when no governor is present or pacing is
+ // disabled, so a rate-limited result surfaces without an extra query; the
+ // Claude Code SDK applies its own backoff for transient rejections.
+ const maxAttempts = params.rateLimitGovernor?.maxRetryAttempts() ?? 1;
+ for (let attempt = 0; ; attempt += 1) {
+ const outcome = await collectResult(params);
+ if (!isClaudeRateLimitResult(outcome.result, outcome.rejectedRateLimitSignal) || attempt >= maxAttempts - 1) {
+ return outcome.result;
+ }
+ }
}
export class ClaudeCodeKtxLlmRuntime implements KtxLlmRuntimePort {
private readonly runQuery: QueryFn;
- private readonly logger: KtxLogger;
constructor(private readonly deps: ClaudeCodeKtxLlmRuntimeDeps) {
this.runQuery = deps.query ?? defaultQuery;
- this.logger = deps.logger ?? noopLogger;
}
async generateText(input: KtxGenerateTextInput): Promise {
@@ -252,12 +350,14 @@ export class ClaudeCodeKtxLlmRuntime implements KtxLlmRuntimePort {
tools: input.tools,
});
const startedAt = Date.now();
- const result = await collectResult({
+ const result = await collectResultWithRateLimitRetry({
query: this.runQuery,
prompt: [input.system, input.prompt].filter(Boolean).join('\n\n'),
options,
allowedToolIds: new Set(mcpToolIds(input.tools ?? {})),
expectedMcpServerNames: expectedMcpServerNames(input.tools),
+ rateLimitGovernor: this.deps.rateLimitGovernor,
+ abortSignal: input.abortSignal,
});
input.onMetrics?.({ totalMs: Date.now() - startedAt, usage: claudeTokenUsage(result) });
const error = resultError(result);
@@ -289,12 +389,14 @@ export class ClaudeCodeKtxLlmRuntime implements KtxLlmRuntimePort {
outputFormat: { type: 'json_schema' as const, schema: jsonSchema(input.schema as z.ZodType) },
};
const startedAt = Date.now();
- const result = await collectResult({
+ const result = await collectResultWithRateLimitRetry({
query: this.runQuery,
prompt: [input.system, input.prompt].filter(Boolean).join('\n\n'),
options,
allowedToolIds: new Set([...mcpToolIds(input.tools ?? {}), STRUCTURED_OUTPUT_TOOL_NAME]),
expectedMcpServerNames: expectedMcpServerNames(input.tools),
+ rateLimitGovernor: this.deps.rateLimitGovernor,
+ abortSignal: input.abortSignal,
});
input.onMetrics?.({ totalMs: Date.now() - startedAt, usage: claudeTokenUsage(result) });
const error = resultError(result);
@@ -308,9 +410,7 @@ export class ClaudeCodeKtxLlmRuntime implements KtxLlmRuntimePort {
}
async runAgentLoop(params: RunLoopParams): Promise {
- let stepIndex = 0;
const startedAt = Date.now();
- const stepBoundariesMs: number[] = [];
try {
const options = baseOptions({
projectDir: this.deps.projectDir,
@@ -319,28 +419,14 @@ export class ClaudeCodeKtxLlmRuntime implements KtxLlmRuntimePort {
maxTurns: params.stepBudget,
tools: params.toolSet,
});
- const result = await collectResult({
+ const result = await collectResultWithRateLimitRetry({
query: this.runQuery,
prompt: params.userPrompt,
options: { ...options, systemPrompt: params.systemPrompt },
allowedToolIds: new Set(mcpToolIds(params.toolSet)),
expectedMcpServerNames: expectedMcpServerNames(params.toolSet),
- onAssistantTurn: async () => {
- stepIndex += 1;
- stepBoundariesMs.push(Date.now() - startedAt);
- if (!params.onStepFinish) {
- return;
- }
- try {
- await params.onStepFinish({ stepIndex, stepBudget: params.stepBudget });
- } catch (error) {
- this.logger.warn(
- `[claude-code-runner] onStepFinish callback threw; ignoring: ${
- error instanceof Error ? error.message : String(error)
- }`,
- );
- }
- },
+ rateLimitGovernor: this.deps.rateLimitGovernor,
+ abortSignal: params.abortSignal,
});
const stopReason = mapClaudeCodeStopReason(result);
const error = resultError(result);
@@ -349,17 +435,24 @@ export class ClaudeCodeKtxLlmRuntime implements KtxLlmRuntimePort {
...(stopReason === 'error' && error ? { error } : {}),
metrics: {
totalMs: Date.now() - startedAt,
- stepCount: stepIndex,
- stepBoundariesMs,
+ // Authoritative turn count from the SDK result. The runtime no longer
+ // re-derives a per-turn counter: it could not match the SDK's `num_turns`
+ // and overshot `maxTurns` (the source of the misleading `step 70/40`).
+ // Per-step boundaries require that counter and are not consumed anywhere.
+ stepCount: result.num_turns,
+ stepBoundariesMs: [],
usage: claudeTokenUsage(result),
},
};
} catch (error) {
+ if (isAbortError(error)) {
+ throw error;
+ }
const err = error instanceof Error ? error : new Error(String(error));
return {
stopReason: 'error',
error: err,
- metrics: { totalMs: Date.now() - startedAt, stepCount: stepIndex, stepBoundariesMs, usage: {} },
+ metrics: { totalMs: Date.now() - startedAt, stepCount: 0, stepBoundariesMs: [], usage: {} },
};
}
}
@@ -388,7 +481,7 @@ export async function runClaudeCodeAuthProbe(input: {
env: input.env,
maxTurns: 1,
});
- const result = await collectResult({
+ const result = await collectResultWithRateLimitRetry({
query: input.query ?? defaultQuery,
prompt: 'Reply with exactly: ok',
options,
diff --git a/packages/cli/src/context/llm/codex-runtime.ts b/packages/cli/src/context/llm/codex-runtime.ts
index 3535072b..ce6f609c 100644
--- a/packages/cli/src/context/llm/codex-runtime.ts
+++ b/packages/cli/src/context/llm/codex-runtime.ts
@@ -1,5 +1,5 @@
import { z } from 'zod';
-import { noopLogger, type KtxLogger } from '../core/config.js';
+import { isAbortError, linkAbortSignal } from '../core/abort.js';
import { isCompletedAgentStep, summarizeCodexExecEvents, type CodexExecEventSummary } from './codex-exec-events.js';
import {
startCodexRuntimeMcpServer,
@@ -8,6 +8,7 @@ import {
import { resolveCodexModel } from './codex-models.js';
import { buildCodexRuntimeConfig } from './codex-runtime-config.js';
import { CodexSdkCliRunner, type CodexSdkRunner } from './codex-sdk-runner.js';
+import type { RateLimitGovernor } from './rate-limit-governor.js';
import type {
KtxGenerateObjectInput,
KtxGenerateTextInput,
@@ -23,7 +24,7 @@ export interface CodexKtxLlmRuntimeDeps {
modelSlots: { default: string } & Partial>;
runner?: CodexSdkRunner;
startMcpServer?: (input: { projectDir: string; toolSet: KtxRuntimeToolSet }) => Promise;
- logger?: KtxLogger;
+ rateLimitGovernor?: Pick;
}
function modelForRole(modelSlots: CodexKtxLlmRuntimeDeps['modelSlots'], role: string): string {
@@ -37,7 +38,6 @@ function promptWithSystem(system: string | undefined, prompt: string): string {
interface CollectCodexEventsOptions {
stepBudget?: number;
abortController?: AbortController;
- onStep?: (stepIndex: number) => void | Promise;
}
interface CollectCodexEventsResult {
@@ -55,8 +55,8 @@ function isTurnCompleted(event: unknown): boolean {
}
/**
- * Drains the Codex stream once, emitting a step as each agent action completes
- * so callers see live progress and the step budget is enforced mid-run. Every
+ * Drains the Codex stream once, counting each completed agent action so the
+ * step budget is enforced mid-run. Every
* completed agent-action item counts (see {@link isCompletedAgentStep}), so
* built-in `command_execution` steps decrement the budget the same as
* `mcp_tool_call`s. A turn that produced no actions still counts as one step,
@@ -90,7 +90,6 @@ async function collectEvents(
}
completedSteps += 1;
- await options.onStep?.(completedSteps);
if (isActionStep && options.stepBudget !== undefined && completedSteps >= options.stepBudget) {
budgetExceeded = true;
options.abortController?.abort();
@@ -159,13 +158,48 @@ function runtimeToolNames(toolSet: KtxRuntimeToolSet | undefined): string[] {
return Object.values(toolSet ?? {}).map((descriptor) => descriptor.name);
}
+const CODEX_RATE_LIMIT_MARKERS = /\b429\b|rate limit|too many requests|quota exceeded|temporarily overloaded/i;
+
+function isCodexRateLimitError(error: Error | undefined): boolean {
+ return !!error && CODEX_RATE_LIMIT_MARKERS.test(error.message);
+}
+
export class CodexKtxLlmRuntime implements KtxLlmRuntimePort {
private readonly runner: CodexSdkRunner;
- private readonly logger: KtxLogger;
constructor(private readonly deps: CodexKtxLlmRuntimeDeps) {
this.runner = deps.runner ?? new CodexSdkCliRunner();
- this.logger = deps.logger ?? noopLogger;
+ }
+
+ private async runWithRateLimitRetry(
+ abortSignal: AbortSignal | undefined,
+ run: () => Promise,
+ getError: (result: T) => Error | undefined,
+ ): Promise {
+ // maxRetryAttempts() returns 1 when no governor is present or pacing is
+ // disabled, so an opaque rate-limit failure surfaces on the first attempt
+ // instead of being retried with no backoff.
+ const maxAttempts = this.deps.rateLimitGovernor?.maxRetryAttempts() ?? 1;
+ for (let attempt = 0; ; attempt += 1) {
+ await this.deps.rateLimitGovernor?.waitForReady(abortSignal);
+ const lastAttempt = attempt >= maxAttempts - 1;
+ try {
+ const result = await run();
+ const error = getError(result);
+ if (!isCodexRateLimitError(error) || lastAttempt) {
+ return result;
+ }
+ } catch (error) {
+ if (isAbortError(error)) {
+ throw error;
+ }
+ const err = error instanceof Error ? error : new Error(String(error));
+ if (!isCodexRateLimitError(err) || lastAttempt) {
+ throw error;
+ }
+ }
+ this.deps.rateLimitGovernor?.report({ provider: 'codex', status: 'rejected', rateLimitType: 'opaque' });
+ }
}
async generateText(input: KtxGenerateTextInput): Promise {
@@ -190,18 +224,26 @@ export class CodexKtxLlmRuntime implements KtxLlmRuntimePort {
}
: {}),
});
- const collected = await collectEvents(
- await this.runner.runStreamed({
- projectDir: this.deps.projectDir,
- model,
- prompt: promptWithSystem(input.system, input.prompt),
- configOverrides: config.configOverrides,
- env: config.env,
- }),
+ const result = await this.runWithRateLimitRetry(
+ input.abortSignal,
+ async () => {
+ const collected = await collectEvents(
+ await this.runner.runStreamed({
+ projectDir: this.deps.projectDir,
+ model,
+ prompt: promptWithSystem(input.system, input.prompt),
+ configOverrides: config.configOverrides,
+ env: config.env,
+ ...(input.abortSignal ? { signal: input.abortSignal } : {}),
+ }),
+ );
+ const summary = summarizeCodexExecEvents(collected.events, { startedAt });
+ return { collected, summary };
+ },
+ ({ collected, summary }) => summaryError(summary, collected.streamError),
);
- const summary = summarizeCodexExecEvents(collected.events, { startedAt });
- input.onMetrics?.(metrics(summary, startedAt));
- return assertSuccessfulText(summary, collected.streamError);
+ input.onMetrics?.(metrics(result.summary, startedAt));
+ return assertSuccessfulText(result.summary, result.collected.streamError);
} finally {
await mcp?.close();
}
@@ -231,19 +273,27 @@ export class CodexKtxLlmRuntime implements KtxLlmRuntimePort {
}
: {}),
});
- const collected = await collectEvents(
- await this.runner.runStreamed({
- projectDir: this.deps.projectDir,
- model,
- prompt: promptWithSystem(input.system, input.prompt),
- configOverrides: config.configOverrides,
- env: config.env,
- outputSchema: z.toJSONSchema(input.schema, { target: 'draft-7' }) as Record,
- }),
+ const result = await this.runWithRateLimitRetry(
+ input.abortSignal,
+ async () => {
+ const collected = await collectEvents(
+ await this.runner.runStreamed({
+ projectDir: this.deps.projectDir,
+ model,
+ prompt: promptWithSystem(input.system, input.prompt),
+ configOverrides: config.configOverrides,
+ env: config.env,
+ outputSchema: z.toJSONSchema(input.schema, { target: 'draft-7' }) as Record,
+ ...(input.abortSignal ? { signal: input.abortSignal } : {}),
+ }),
+ );
+ const summary = summarizeCodexExecEvents(collected.events, { startedAt });
+ return { collected, summary };
+ },
+ ({ collected, summary }) => summaryError(summary, collected.streamError),
);
- const summary = summarizeCodexExecEvents(collected.events, { startedAt });
- input.onMetrics?.(metrics(summary, startedAt));
- return parseStructuredOutput(input.schema, assertSuccessfulText(summary, collected.streamError));
+ input.onMetrics?.(metrics(result.summary, startedAt));
+ return parseStructuredOutput(input.schema, assertSuccessfulText(result.summary, result.collected.streamError));
} finally {
await mcp?.close();
}
@@ -272,41 +322,50 @@ export class CodexKtxLlmRuntime implements KtxLlmRuntimePort {
}
: {}),
});
- const abortController = new AbortController();
- const onStep = async (stepIndex: number): Promise => {
- try {
- await params.onStepFinish?.({ stepIndex, stepBudget: params.stepBudget });
- } catch (error) {
- this.logger.warn(
- `[codex-runner] onStepFinish callback threw; ignoring: ${error instanceof Error ? error.message : String(error)}`,
- );
- }
- };
- const collected = await collectEvents(
- await this.runner.runStreamed({
- projectDir: this.deps.projectDir,
- model,
- prompt: promptWithSystem(params.systemPrompt, params.userPrompt),
- configOverrides: config.configOverrides,
- env: config.env,
- signal: abortController.signal,
- }),
- { stepBudget: params.stepBudget, abortController, onStep },
+ const result = await this.runWithRateLimitRetry(
+ params.abortSignal,
+ async () => {
+ const linked = linkAbortSignal(params.abortSignal);
+ const abortController = linked.controller;
+ try {
+ const collected = await collectEvents(
+ await this.runner.runStreamed({
+ projectDir: this.deps.projectDir,
+ model,
+ prompt: promptWithSystem(params.systemPrompt, params.userPrompt),
+ configOverrides: config.configOverrides,
+ env: config.env,
+ signal: abortController.signal,
+ }),
+ { stepBudget: params.stepBudget, abortController },
+ );
+ const summary = summarizeCodexExecEvents(collected.events, { startedAt });
+ return { collected, summary };
+ } finally {
+ linked.dispose();
+ }
+ },
+ ({ collected, summary }) => summaryError(summary, collected.streamError),
);
- const summary = summarizeCodexExecEvents(collected.events, { startedAt });
- const error = summaryError(summary, collected.streamError);
- const stopReason = collected.budgetExceeded ? 'budget' : error ? 'error' : summary.stopReason;
+ const error = summaryError(result.summary, result.collected.streamError);
+ if (isAbortError(error)) {
+ throw error;
+ }
+ const stopReason = result.collected.budgetExceeded ? 'budget' : error ? 'error' : result.summary.stopReason;
return {
stopReason,
...(stopReason === 'error' && error ? { error } : {}),
metrics: {
totalMs: Date.now() - startedAt,
- usage: summary.usage,
- stepCount: summary.stepCount,
- stepBoundariesMs: summary.stepBoundariesMs,
+ usage: result.summary.usage,
+ stepCount: result.summary.stepCount,
+ stepBoundariesMs: result.summary.stepBoundariesMs,
},
};
} catch (error) {
+ if (isAbortError(error)) {
+ throw error;
+ }
const err = error instanceof Error ? error : new Error(String(error));
return {
stopReason: 'error',
diff --git a/packages/cli/src/context/llm/local-config.ts b/packages/cli/src/context/llm/local-config.ts
index 58bd29a5..4c2502d1 100644
--- a/packages/cli/src/context/llm/local-config.ts
+++ b/packages/cli/src/context/llm/local-config.ts
@@ -6,16 +6,28 @@ import type { KtxProjectEmbeddingConfig, KtxProjectLlmConfig } from '../project/
import { AiSdkKtxLlmRuntime } from './ai-sdk-runtime.js';
import { ClaudeCodeKtxLlmRuntime } from './claude-code-runtime.js';
import { CodexKtxLlmRuntime } from './codex-runtime.js';
+import type { RateLimitGovernor } from './rate-limit-governor.js';
import type { KtxLlmRuntimePort } from './runtime-port.js';
+type ClaudeCodeRuntimeDeps = ConstructorParameters[0] & {
+ rateLimitGovernor?: RateLimitGovernor;
+};
+type CodexRuntimeDeps = ConstructorParameters[0] & {
+ rateLimitGovernor?: RateLimitGovernor;
+};
+type AiSdkRuntimeDeps = ConstructorParameters[0] & {
+ rateLimitGovernor?: RateLimitGovernor;
+};
+
interface LocalConfigDeps {
env?: NodeJS.ProcessEnv;
projectDir?: string;
+ rateLimitGovernor?: RateLimitGovernor;
createKtxLlmProvider?: typeof createKtxLlmProvider;
createKtxEmbeddingProvider?: typeof createKtxEmbeddingProvider;
- createClaudeCodeRuntime?: (deps: ConstructorParameters[0]) => KtxLlmRuntimePort;
- createCodexRuntime?: (deps: ConstructorParameters[0]) => KtxLlmRuntimePort;
- createAiSdkRuntime?: (deps: { llmProvider: KtxLlmProvider }) => KtxLlmRuntimePort;
+ createClaudeCodeRuntime?: (deps: ClaudeCodeRuntimeDeps) => KtxLlmRuntimePort;
+ createCodexRuntime?: (deps: CodexRuntimeDeps) => KtxLlmRuntimePort;
+ createAiSdkRuntime?: (deps: AiSdkRuntimeDeps) => KtxLlmRuntimePort;
}
function resolveOptional(value: string | undefined, env: NodeJS.ProcessEnv): string | undefined {
@@ -129,6 +141,7 @@ export function createLocalKtxLlmRuntimeFromConfig(
projectDir,
modelSlots: resolved.modelSlots,
env: deps.env,
+ rateLimitGovernor: deps.rateLimitGovernor,
});
}
if (resolved.backend === 'codex') {
@@ -139,10 +152,14 @@ export function createLocalKtxLlmRuntimeFromConfig(
return (deps.createCodexRuntime ?? ((runtimeDeps) => new CodexKtxLlmRuntime(runtimeDeps)))({
projectDir,
modelSlots: resolved.modelSlots,
+ rateLimitGovernor: deps.rateLimitGovernor,
});
}
const llmProvider = (deps.createKtxLlmProvider ?? createKtxLlmProvider)(resolved);
- return (deps.createAiSdkRuntime ?? ((runtimeDeps) => new AiSdkKtxLlmRuntime(runtimeDeps)))({ llmProvider });
+ return (deps.createAiSdkRuntime ?? ((runtimeDeps) => new AiSdkKtxLlmRuntime(runtimeDeps)))({
+ llmProvider,
+ rateLimitGovernor: deps.rateLimitGovernor,
+ });
}
export function resolveLocalKtxEmbeddingConfig(
diff --git a/packages/cli/src/context/llm/rate-limit-governor.ts b/packages/cli/src/context/llm/rate-limit-governor.ts
new file mode 100644
index 00000000..909e4c44
--- /dev/null
+++ b/packages/cli/src/context/llm/rate-limit-governor.ts
@@ -0,0 +1,387 @@
+import { createAbortError, throwIfAborted } from '../core/abort.js';
+
+export type RateLimitProvider = 'claude-subscription' | 'anthropic-api' | 'vertex' | 'codex';
+type RateLimitSignalStatus = 'allowed' | 'warning' | 'rejected';
+
+export interface RateLimitSignal {
+ provider: RateLimitProvider;
+ status: RateLimitSignalStatus;
+ resetAtMs?: number;
+ retryAfterMs?: number;
+ utilization?: number;
+ rateLimitType?: string;
+}
+
+export interface RateLimitRetryConfig {
+ maxAttempts: number;
+ baseDelayMs: number;
+ maxDelayMs: number;
+ jitter: boolean;
+}
+
+export interface RateLimitGovernorConfig {
+ enabled: boolean;
+ maxConcurrency: number;
+ throttleThreshold: number;
+ minConcurrencyUnderPressure: number;
+ maxWaitMs?: number;
+ waitStateTickMs: number;
+ retry: RateLimitRetryConfig;
+}
+
+export type RateLimitWaitState =
+ | {
+ kind: 'rate_limit_observed';
+ provider: RateLimitProvider;
+ status: RateLimitSignalStatus;
+ rateLimitType?: string;
+ resetAtMs?: number;
+ retryAfterMs?: number;
+ utilization?: number;
+ }
+ | {
+ kind: 'concurrency_adjusted';
+ provider: RateLimitProvider;
+ from: number;
+ to: number;
+ reason: string;
+ rateLimitType?: string;
+ utilization?: number;
+ }
+ | {
+ kind: 'wait_started' | 'wait_tick' | 'wait_finished';
+ provider: RateLimitProvider;
+ rateLimitType?: string;
+ resumeAtMs: number;
+ remainingMs: number;
+ };
+
+export interface RateLimitGovernorDeps {
+ now?: () => number;
+ sleep?: (ms: number, signal?: AbortSignal) => Promise;
+ random?: () => number;
+}
+
+export type RateLimitRelease = () => void;
+type Subscriber = (state: RateLimitWaitState) => void;
+
+const defaultSleep = (ms: number, signal?: AbortSignal): Promise =>
+ new Promise((resolve, reject) => {
+ if (signal?.aborted) {
+ reject(createAbortError());
+ return;
+ }
+ const timeout = setTimeout(resolve, ms);
+ signal?.addEventListener(
+ 'abort',
+ () => {
+ clearTimeout(timeout);
+ reject(createAbortError());
+ },
+ { once: true },
+ );
+ });
+
+export function createRateLimitGovernorConfig(
+ input: Partial & { retry?: Partial } = {},
+): RateLimitGovernorConfig {
+ return {
+ enabled: input.enabled ?? true,
+ maxConcurrency: input.maxConcurrency ?? 1,
+ throttleThreshold: input.throttleThreshold ?? 0.8,
+ minConcurrencyUnderPressure: input.minConcurrencyUnderPressure ?? 1,
+ ...(input.maxWaitMs !== undefined ? { maxWaitMs: input.maxWaitMs } : {}),
+ waitStateTickMs: input.waitStateTickMs ?? 1_000,
+ retry: {
+ maxAttempts: input.retry?.maxAttempts ?? 6,
+ baseDelayMs: input.retry?.baseDelayMs ?? 1_000,
+ maxDelayMs: input.retry?.maxDelayMs ?? 60_000,
+ jitter: input.retry?.jitter ?? true,
+ },
+ };
+}
+
+export class RateLimitGovernor {
+ private readonly now: () => number;
+ private readonly sleep: (ms: number, signal?: AbortSignal) => Promise;
+ private readonly random: () => number;
+ private readonly subscribers = new Set();
+ private waiters: Array<() => void> = [];
+ private active = 0;
+ private effectiveLimit: number;
+ private pausedUntilMs: number | null = null;
+ private pausedProvider: RateLimitProvider | null = null;
+ private pausedRateLimitType: string | undefined;
+ private pausedTickMs: number | null = null;
+ private opaqueAttempts = new Map();
+ private pauseGeneration = 0;
+ private visibleWaitAbort: AbortController | null = null;
+
+ constructor(
+ private readonly config: RateLimitGovernorConfig,
+ deps: RateLimitGovernorDeps = {},
+ ) {
+ this.now = deps.now ?? Date.now;
+ this.sleep = deps.sleep ?? defaultSleep;
+ this.random = deps.random ?? Math.random;
+ this.effectiveLimit = Math.max(1, config.maxConcurrency);
+ }
+
+ currentLimit(): number {
+ return this.config.enabled ? this.effectiveLimit : this.config.maxConcurrency;
+ }
+
+ /**
+ * Total attempts a runtime should make for a single rate-limited LLM call,
+ * including the first try. Returns 1 (no outer retry) when pacing is disabled:
+ * the outer retry loop only exists to cooperate with this governor's pause, so
+ * without active pacing there is no backoff to apply and the backend's own
+ * retry handles transient rejections.
+ */
+ maxRetryAttempts(): number {
+ return this.config.enabled ? Math.max(1, this.config.retry.maxAttempts) : 1;
+ }
+
+ activeSlots(): number {
+ return this.active;
+ }
+
+ subscribe(cb: Subscriber): () => void {
+ this.subscribers.add(cb);
+ if (this.pausedUntilMs !== null) {
+ this.startVisibleWaitTicker();
+ }
+ return () => {
+ this.subscribers.delete(cb);
+ if (this.subscribers.size === 0) {
+ this.stopVisibleWaitTicker();
+ this.wakeWaiters();
+ }
+ };
+ }
+
+ report(signal: RateLimitSignal): void {
+ if (!this.config.enabled) {
+ return;
+ }
+ this.emit({
+ kind: 'rate_limit_observed',
+ provider: signal.provider,
+ status: signal.status,
+ ...(signal.rateLimitType ? { rateLimitType: signal.rateLimitType } : {}),
+ ...(signal.resetAtMs !== undefined ? { resetAtMs: signal.resetAtMs } : {}),
+ ...(signal.retryAfterMs !== undefined ? { retryAfterMs: signal.retryAfterMs } : {}),
+ ...(signal.utilization !== undefined ? { utilization: signal.utilization } : {}),
+ });
+
+ if (signal.status === 'rejected') {
+ this.applyPause(signal);
+ return;
+ }
+
+ if (signal.status === 'warning' || (signal.utilization ?? 0) >= this.config.throttleThreshold) {
+ this.adjustLimit(Math.max(1, this.config.minConcurrencyUnderPressure), signal, 'provider pressure');
+ return;
+ }
+
+ this.opaqueAttempts.delete(signal.provider);
+ if ((signal.utilization ?? 0) < this.config.throttleThreshold) {
+ this.adjustLimit(Math.max(1, this.config.maxConcurrency), signal, 'provider recovered');
+ }
+ }
+
+ async waitForReady(signal?: AbortSignal): Promise {
+ throwIfAborted(signal);
+ if (!this.config.enabled) {
+ return;
+ }
+ await this.waitForPause(signal);
+ throwIfAborted(signal);
+ }
+
+ async acquireWorkSlot(signal?: AbortSignal): Promise {
+ throwIfAborted(signal);
+ if (!this.config.enabled) {
+ this.active += 1;
+ return () => {
+ this.active -= 1;
+ };
+ }
+
+ while (true) {
+ throwIfAborted(signal);
+ await this.waitForPause(signal);
+ throwIfAborted(signal);
+ if (this.active < this.effectiveLimit) {
+ this.active += 1;
+ let released = false;
+ return () => {
+ if (released) return;
+ released = true;
+ this.active -= 1;
+ this.wakeWaiters();
+ };
+ }
+ await this.waitForSlot(signal);
+ }
+ }
+
+ private applyPause(signal: RateLimitSignal): void {
+ const resumeAtMs = this.resumeAtMsFor(signal);
+ const boundedResumeAtMs =
+ this.config.maxWaitMs === undefined ? resumeAtMs : Math.min(resumeAtMs, this.now() + this.config.maxWaitMs);
+ if (this.pausedUntilMs === null || boundedResumeAtMs > this.pausedUntilMs) {
+ this.pausedUntilMs = boundedResumeAtMs;
+ this.pausedProvider = signal.provider;
+ this.pausedRateLimitType = signal.rateLimitType;
+ this.pausedTickMs = signal.rateLimitType === 'opaque' ? Math.max(1, boundedResumeAtMs - this.now()) : null;
+ this.emitWait('wait_started');
+ this.startVisibleWaitTicker();
+ this.wakeWaiters();
+ }
+ this.adjustLimit(Math.max(1, this.config.minConcurrencyUnderPressure), signal, 'provider rejected');
+ }
+
+ private resumeAtMsFor(signal: RateLimitSignal): number {
+ if (signal.resetAtMs !== undefined) {
+ return signal.resetAtMs;
+ }
+ if (signal.retryAfterMs !== undefined) {
+ return this.now() + signal.retryAfterMs;
+ }
+ const attempts = this.opaqueAttempts.get(signal.provider) ?? 0;
+ this.opaqueAttempts.set(signal.provider, Math.min(attempts + 1, this.config.retry.maxAttempts));
+ const base = Math.min(
+ this.config.retry.maxDelayMs,
+ this.config.retry.baseDelayMs * 2 ** Math.min(attempts, this.config.retry.maxAttempts - 1),
+ );
+ const jitterMultiplier = this.config.retry.jitter ? 0.75 + this.random() * 0.5 : 1;
+ return this.now() + Math.round(base * jitterMultiplier);
+ }
+
+ private adjustLimit(to: number, signal: RateLimitSignal, reason: string): void {
+ const bounded = Math.max(1, Math.min(this.config.maxConcurrency, to));
+ if (bounded === this.effectiveLimit) {
+ return;
+ }
+ const from = this.effectiveLimit;
+ this.effectiveLimit = bounded;
+ this.emit({
+ kind: 'concurrency_adjusted',
+ provider: signal.provider,
+ from,
+ to: bounded,
+ reason,
+ ...(signal.rateLimitType ? { rateLimitType: signal.rateLimitType } : {}),
+ ...(signal.utilization !== undefined ? { utilization: signal.utilization } : {}),
+ });
+ this.wakeWaiters();
+ }
+
+ private startVisibleWaitTicker(): void {
+ if (this.subscribers.size === 0 || this.pausedUntilMs === null) {
+ return;
+ }
+ this.stopVisibleWaitTicker();
+ const generation = (this.pauseGeneration += 1);
+ const controller = new AbortController();
+ this.visibleWaitAbort = controller;
+ void this.runVisibleWaitTicker(generation, controller.signal).catch(() => undefined);
+ }
+
+ private stopVisibleWaitTicker(): void {
+ this.visibleWaitAbort?.abort();
+ this.visibleWaitAbort = null;
+ }
+
+ private async runVisibleWaitTicker(generation: number, signal: AbortSignal): Promise {
+ while (!signal.aborted && generation === this.pauseGeneration && this.pausedUntilMs !== null) {
+ const remainingMs = this.pausedUntilMs - this.now();
+ if (remainingMs <= 0) {
+ this.finishPause(generation);
+ return;
+ }
+ this.emitWait('wait_tick');
+ await this.sleep(Math.min(this.pausedTickMs ?? this.config.waitStateTickMs, remainingMs), signal);
+ }
+ }
+
+ private finishPause(generation?: number): void {
+ if (generation !== undefined && generation !== this.pauseGeneration) {
+ return;
+ }
+ this.emitWait('wait_finished');
+ this.pausedUntilMs = null;
+ this.pausedProvider = null;
+ this.pausedRateLimitType = undefined;
+ this.pausedTickMs = null;
+ this.stopVisibleWaitTicker();
+ this.wakeWaiters();
+ }
+
+ private async waitForPause(signal?: AbortSignal): Promise {
+ throwIfAborted(signal);
+ while (this.pausedUntilMs !== null) {
+ const remainingMs = this.pausedUntilMs - this.now();
+ if (remainingMs <= 0) {
+ this.finishPause();
+ return;
+ }
+ if (this.visibleWaitAbort !== null) {
+ await this.waitForSlot(signal);
+ } else {
+ await this.sleep(Math.min(this.pausedTickMs ?? this.config.waitStateTickMs, remainingMs), signal);
+ }
+ throwIfAborted(signal);
+ }
+ }
+
+ private waitForSlot(signal?: AbortSignal): Promise {
+ if (signal?.aborted) {
+ return Promise.reject(createAbortError());
+ }
+ return new Promise((resolve, reject) => {
+ const wake = () => {
+ cleanup();
+ resolve();
+ };
+ const onAbort = () => {
+ cleanup();
+ reject(createAbortError());
+ };
+ const cleanup = () => {
+ this.waiters = this.waiters.filter((candidate) => candidate !== wake);
+ signal?.removeEventListener('abort', onAbort);
+ };
+ this.waiters.push(wake);
+ signal?.addEventListener('abort', onAbort, { once: true });
+ });
+ }
+
+ private wakeWaiters(): void {
+ const waiters = this.waiters;
+ this.waiters = [];
+ for (const waiter of waiters) {
+ waiter();
+ }
+ }
+
+ private emitWait(kind: Extract): void {
+ if (this.pausedUntilMs === null || this.pausedProvider === null) {
+ return;
+ }
+ this.emit({
+ kind,
+ provider: this.pausedProvider,
+ ...(this.pausedRateLimitType ? { rateLimitType: this.pausedRateLimitType } : {}),
+ resumeAtMs: this.pausedUntilMs,
+ remainingMs: Math.max(0, this.pausedUntilMs - this.now()),
+ });
+ }
+
+ private emit(state: RateLimitWaitState): void {
+ for (const subscriber of this.subscribers) {
+ subscriber(state);
+ }
+ }
+}
diff --git a/packages/cli/src/context/llm/runtime-port.ts b/packages/cli/src/context/llm/runtime-port.ts
index db648448..c55e3c7a 100644
--- a/packages/cli/src/context/llm/runtime-port.ts
+++ b/packages/cli/src/context/llm/runtime-port.ts
@@ -17,12 +17,6 @@ export type KtxRuntimeToolSet = Record;
export type RunLoopStopReason = 'budget' | 'natural' | 'error';
-/** @internal */
-export interface RunLoopStepInfo {
- stepIndex: number;
- stepBudget: number;
-}
-
export interface LlmTokenUsage {
inputTokens?: number;
outputTokens?: number;
@@ -48,7 +42,7 @@ export interface RunLoopParams {
toolSet: KtxRuntimeToolSet;
stepBudget: number;
telemetryTags: Record;
- onStepFinish?: (info: RunLoopStepInfo) => void | Promise;
+ abortSignal?: AbortSignal;
}
export interface RunLoopResult {
@@ -64,6 +58,7 @@ export interface KtxGenerateTextInput {
tools?: KtxRuntimeToolSet;
temperature?: number;
onMetrics?: (metrics: { totalMs: number; usage: LlmTokenUsage }) => void;
+ abortSignal?: AbortSignal;
}
export interface KtxGenerateObjectInput> {
@@ -74,6 +69,7 @@ export interface KtxGenerateObjectInput void;
+ abortSignal?: AbortSignal;
}
export interface KtxLlmRuntimePort {
diff --git a/packages/cli/src/context/mcp/context-tools.ts b/packages/cli/src/context/mcp/context-tools.ts
index 03cd2ad4..2d07d121 100644
--- a/packages/cli/src/context/mcp/context-tools.ts
+++ b/packages/cli/src/context/mcp/context-tools.ts
@@ -3,7 +3,13 @@ import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
import type { KtxCliIo } from '../../cli-runtime.js';
import type { MemoryAgentInput } from '../../context/memory/types.js';
-import { emitTelemetryEvent, mcpTelemetrySampleRate, shouldEmitMcpTelemetry } from '../../telemetry/index.js';
+import {
+ emitTelemetryEvent,
+ mcpTelemetrySampleRate,
+ reportException,
+ shouldEmitMcpTelemetry,
+} from '../../telemetry/index.js';
+import { collectTelemetryRedactionSecrets } from '../../telemetry/redaction-secrets.js';
import { scrubErrorClass } from '../../telemetry/scrubber.js';
import type {
KtxMcpClientInfo,
@@ -518,11 +524,26 @@ function registerParsedTool(
},
schema: TSchema,
handler: (input: z.infer, context?: KtxMcpToolHandlerContext) => Promise,
+ telemetry?: { projectDir?: string; io?: KtxCliIo },
): void {
server.registerTool(name, config, async (input, context) => {
try {
return await handler(schema.parse(input), context);
} catch (error) {
+ if (telemetry?.io) {
+ await reportException({
+ error,
+ context: { source: `mcp:${name}`, handled: true, fatal: false },
+ projectDir: telemetry.projectDir,
+ io: telemetry.io,
+ redactionSecrets: await collectTelemetryRedactionSecrets({
+ projectDir: telemetry.projectDir,
+ includeLlm: true,
+ includeEmbeddings: true,
+ env: process.env,
+ }),
+ });
+ }
return jsonErrorToolResult(formatToolError(error));
}
});
@@ -571,6 +592,20 @@ function instrumentMcpServer(
}
return result;
} catch (error) {
+ if (telemetry.io) {
+ await reportException({
+ error,
+ context: { source: `mcp:${name}`, handled: true, fatal: false },
+ projectDir: telemetry.projectDir,
+ io: telemetry.io,
+ redactionSecrets: await collectTelemetryRedactionSecrets({
+ projectDir: telemetry.projectDir,
+ includeLlm: true,
+ includeEmbeddings: true,
+ env: process.env,
+ }),
+ });
+ }
if (telemetry.io && telemetry.projectDir && shouldEmitMcpTelemetry()) {
const errorClass = scrubErrorClass(error);
await emitTelemetryEvent({
@@ -596,6 +631,7 @@ function instrumentMcpServer(
export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void {
const { ports, userContext } = deps;
+ const toolTelemetry = { projectDir: deps.projectDir, io: deps.io };
const server = instrumentMcpServer(deps.server, {
projectDir: deps.projectDir,
io: deps.io,
@@ -616,6 +652,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
},
connectionListSchema,
async () => jsonToolResult({ connections: await connections.list() }),
+ toolTelemetry,
);
}
@@ -640,6 +677,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
limit: input.limit,
}),
),
+ toolTelemetry,
);
registerParsedTool(
@@ -657,6 +695,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
const page = await knowledge.read({ userId: userContext.userId, key: input.key });
return page ? jsonToolResult(page) : jsonErrorToolResult(`Wiki page "${input.key}" was not found.`);
},
+ toolTelemetry,
);
}
@@ -679,6 +718,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
? jsonToolResult(source)
: jsonErrorToolResult(`Semantic-layer source "${input.sourceName}" was not found.`);
},
+ toolTelemetry,
);
registerParsedTool(
@@ -711,6 +751,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
);
return jsonToolResult(projectSlQueryResult(result, input.include));
},
+ toolTelemetry,
);
}
@@ -728,6 +769,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
},
entityDetailsSchema,
async (input) => jsonToolResult(await entityDetails.read(input)),
+ toolTelemetry,
);
}
@@ -745,6 +787,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
},
dictionarySearchSchema,
async (input) => jsonToolResult(await dictionarySearch.search(input)),
+ toolTelemetry,
);
}
@@ -762,6 +805,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
},
discoverDataSchema,
async (input) => jsonToolResult({ refs: await discover.search(input) }),
+ toolTelemetry,
);
}
@@ -791,6 +835,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
),
);
},
+ toolTelemetry,
);
}
@@ -818,6 +863,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
};
return jsonToolResult(await memoryIngest.ingest(ingestInput));
},
+ toolTelemetry,
);
registerParsedTool(
@@ -835,6 +881,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
const status = await memoryIngest.status(input.runId);
return status ? jsonToolResult(status) : jsonErrorToolResult(`Memory ingest run "${input.runId}" was not found.`);
},
+ toolTelemetry,
);
}
}
diff --git a/packages/cli/src/context/project/config.ts b/packages/cli/src/context/project/config.ts
index cbea79b6..fd7f482c 100644
--- a/packages/cli/src/context/project/config.ts
+++ b/packages/cli/src/context/project/config.ts
@@ -100,6 +100,44 @@ const workUnitsSchema = z
})
.describe('Concurrency and failure handling for ingest work units.');
+const ingestRateLimitRetrySchema = z
+ .strictObject({
+ maxAttempts: z
+ .int()
+ .positive()
+ .default(6)
+ .describe(
+ 'Maximum attempts for a single rate-limited LLM call before the failure surfaces, counting the first try. Also bounds how far opaque backoff grows for providers that do not expose a reset time.',
+ ),
+ baseDelayMs: z.int().positive().default(1_000).describe('Initial opaque retry delay in milliseconds.'),
+ maxDelayMs: z.int().positive().default(60_000).describe('Maximum opaque retry delay in milliseconds.'),
+ jitter: z.boolean().default(true).describe('When true, apply bounded jitter to opaque retry delays.'),
+ })
+ .describe('Retry policy for rate-limit responses that do not include a reset time or retry-after value.');
+
+const ingestRateLimitSchema = z
+ .strictObject({
+ enabled: z.boolean().default(true).describe('Master switch for ingest LLM rate-limit pacing and visible waits.'),
+ throttleThreshold: z
+ .number()
+ .min(0)
+ .max(1)
+ .default(0.8)
+ .describe('Provider utilization at or above which ingest throttles new work-unit starts.'),
+ minConcurrencyUnderPressure: z
+ .int()
+ .positive()
+ .default(1)
+ .describe('Effective work-unit concurrency while a provider is under rate-limit pressure.'),
+ maxWaitMs: z
+ .int()
+ .positive()
+ .optional()
+ .describe('Optional cap on a single provider reset wait. Omit to wait indefinitely until the provider reset time.'),
+ retry: ingestRateLimitRetrySchema.prefault({}).describe('Opaque retry policy for providers without reset hints.'),
+ })
+ .describe('Rate-limit pacing and wait policy for ingest LLM calls.');
+
const ingestSchema = z
.strictObject({
adapters: z
@@ -110,6 +148,7 @@ const ingestSchema = z
.prefault({ backend: 'none' })
.describe('Embedding configuration used when ingest adapters need to embed documents.'),
workUnits: workUnitsSchema.prefault({}).describe('Concurrency and failure handling for ingest work units.'),
+ rateLimit: ingestRateLimitSchema.prefault({}).describe('LLM rate-limit pacing and visible-wait policy for ingest.'),
profile: z
.union([z.boolean(), z.literal('json')])
.default(false)
diff --git a/packages/cli/src/context/scan/types.ts b/packages/cli/src/context/scan/types.ts
index 1d9e6d6a..fc445b5e 100644
--- a/packages/cli/src/context/scan/types.ts
+++ b/packages/cli/src/context/scan/types.ts
@@ -303,9 +303,29 @@ export interface KtxTableListEntry {
kind: 'table' | 'view';
}
-interface KtxConnectorTestResult {
+export interface KtxConnectorTestResult {
success: boolean;
error?: string;
+ /**
+ * The original error thrown by the driver, preserved unflattened so the
+ * connection-test path can re-throw it. Keeping the real error object lets
+ * telemetry record the driver's actual error class (e.g. `ConnectionError`)
+ * and `.code` (e.g. `ELOGIN`) instead of collapsing every failure to `Error`.
+ */
+ cause?: unknown;
+}
+
+/**
+ * Single source of truth for a failed connector test result. Captures the
+ * driver's message for display while preserving the original error as `cause`
+ * so callers can surface its real class and code.
+ */
+export function connectorTestFailure(error: unknown): KtxConnectorTestResult {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : String(error),
+ cause: error,
+ };
}
export interface KtxScanConnector {
diff --git a/packages/cli/src/context/sl/description-normalization.ts b/packages/cli/src/context/sl/description-normalization.ts
index ef657fdd..04bf41ba 100644
--- a/packages/cli/src/context/sl/description-normalization.ts
+++ b/packages/cli/src/context/sl/description-normalization.ts
@@ -50,13 +50,6 @@ function humanizeIdentifier(value: string): string {
.toLowerCase();
}
-function formatCount(count: number, singular: string, plural = `${singular}s`): string | null {
- if (count <= 0) {
- return null;
- }
- return `${count} ${count === 1 ? singular : plural}`;
-}
-
function sourceFallback(source: Record, sourceName: string): string {
const table = cleanText(source.table);
const sql = cleanText(source.sql);
@@ -66,15 +59,10 @@ function sourceFallback(source: Record, sourceName: string): st
if (sql) {
return `Semantic-layer source for ${sourceName} backed by curated SQL.`;
}
-
- const counts = [
- formatCount(Array.isArray(source.measures) ? source.measures.length : 0, 'measure'),
- formatCount(Array.isArray(source.segments) ? source.segments.length : 0, 'segment'),
- formatCount(Array.isArray(source.columns) ? source.columns.length : 0, 'computed column'),
- ].filter((item): item is string => Boolean(item));
- return counts.length > 0
- ? `Semantic-layer overlay for ${sourceName} defining ${counts.join(', ')}.`
- : `Semantic-layer overlay for ${sourceName}.`;
+ // Measure/segment/column counts are rendered live from the body at list/read
+ // time, so baking them into stored prose freezes a derived value that drifts
+ // as the source later gains measures. Keep the auto fallback count-free.
+ return `Semantic-layer overlay for ${sourceName}.`;
}
function columnFallback(column: Record, sourceName: string): string {
diff --git a/packages/cli/src/demo-metrics.ts b/packages/cli/src/demo-metrics.ts
index d6f9c207..70b0328c 100644
--- a/packages/cli/src/demo-metrics.ts
+++ b/packages/cli/src/demo-metrics.ts
@@ -15,8 +15,6 @@ interface DemoMetricsTuning {
interface DemoMetricsSnapshot {
elapsedMs: number;
etaMs: number | null;
- agentSteps: number;
- agentStepBudget: number;
toolCalls: number;
workUnitsStarted: number;
workUnitsFinished: number;
@@ -37,18 +35,6 @@ function eventsOf(
return events.filter((event): event is Extract => event.type === type);
}
-function maxAgentStep(events: MemoryFlowEvent[]): { step: number; budget: number } {
- const steps = eventsOf(events, 'work_unit_step');
- const started = eventsOf(events, 'work_unit_started');
- const stepIndex = steps.reduce((max, event) => Math.max(max, event.stepIndex), 0);
- const stepBudget = Math.max(
- 0,
- ...steps.map((event) => event.stepBudget),
- ...started.map((event) => event.stepBudget),
- );
- return { step: stepIndex, budget: stepBudget };
-}
-
function totalToolCalls(input: MemoryFlowReplayInput): number {
return input.details.transcripts.reduce((total, transcript) => total + transcript.toolCallCount, 0);
}
@@ -96,11 +82,10 @@ export function buildDemoMetrics(
const nowMs = (options.now ?? Date.now)();
const elapsedMs = elapsedMsFromEvents(input.events, nowMs);
- const { step, budget } = maxAgentStep(input.events);
const toolCalls = totalToolCalls(input);
const progress = workUnitProgress(input);
const finishedCount = eventsOf(input.events, 'work_unit_finished').length;
- const stepDriver = Math.max(step, toolCalls, finishedCount * 4);
+ const stepDriver = Math.max(toolCalls, finishedCount * 4);
const inputTokens = stepDriver * inputTokensPerStep;
const outputTokens = stepDriver * outputTokensPerStep;
@@ -113,8 +98,6 @@ export function buildDemoMetrics(
return {
elapsedMs,
etaMs: estimateEtaMs(elapsedMs, progress.finished, progress.total, input.status),
- agentSteps: step,
- agentStepBudget: budget,
toolCalls,
workUnitsStarted: progress.started,
workUnitsFinished: progress.finished,
diff --git a/packages/cli/src/ingest.ts b/packages/cli/src/ingest.ts
index ad5ba270..233b1b6e 100644
--- a/packages/cli/src/ingest.ts
+++ b/packages/cli/src/ingest.ts
@@ -78,6 +78,7 @@ export interface KtxIngestDeps {
readReportFile?: typeof readIngestReportSnapshotFile;
renderStoredMemoryFlow?: typeof renderMemoryFlowTui;
startLiveMemoryFlow?: typeof startLiveMemoryFlowTui;
+ abortSignal?: AbortSignal;
env?: NodeJS.ProcessEnv;
localIngestOptions?: Pick<
RunLocalIngestOptions,
@@ -93,6 +94,23 @@ export interface KtxIngestDeps {
runtimeIo?: KtxIngestIo;
}
+function createCliAbortSignal(): { signal: AbortSignal; dispose: () => void } {
+ const controller = new AbortController();
+ let interrupted = false;
+ const onSigint = () => {
+ if (interrupted) {
+ process.exit(130);
+ }
+ interrupted = true;
+ controller.abort(new DOMException('Aborted', 'AbortError'));
+ };
+ process.on('SIGINT', onSigint);
+ return {
+ signal: controller.signal,
+ dispose: () => process.off('SIGINT', onSigint),
+ };
+}
+
const REPORT_SOURCE_LABELS = new Map([
['live-database', 'Database schema'],
['historic-sql', 'Query history'],
@@ -364,6 +382,12 @@ function plainIngestEventProgress(
message: event.message,
...(event.transient !== undefined ? { transient: event.transient } : {}),
};
+ case 'rate_limit_wait':
+ return {
+ percent: 50,
+ message: `Rate-limited (${event.provider}${event.rateLimitType ? ` ${event.rateLimitType}` : ''}); resuming in ${Math.ceil(event.remainingMs / 1_000)}s`,
+ transient: true,
+ };
case 'work_unit_started': {
const total = plannedWorkUnitCountThrough(snapshot, eventIndex);
const ordinal = workUnitOrdinalThrough(snapshot, eventIndex, event.unitKey);
@@ -374,9 +398,8 @@ function plainIngestEventProgress(
const total = plannedWorkUnitCountThrough(snapshot, eventIndex);
const completed = completedWorkUnitCountThrough(snapshot, eventIndex);
const active = activeWorkUnitCountThrough(snapshot, eventIndex);
- const stepFraction = event.stepBudget > 0 ? Math.min(1, event.stepIndex / event.stepBudget) : 0;
- const percent = total > 0 ? 55 + Math.ceil(((completed + stepFraction) / total) * 25) : 55;
- const latest = `${event.unitKey} step ${event.stepIndex}/${event.stepBudget}`;
+ const percent = total > 0 ? 55 + Math.ceil((completed / total) * 25) : 55;
+ const latest = `${event.unitKey} · ${pluralize(event.toolCalls, 'action')}`;
return {
percent,
message: `Processing tasks: ${completed}/${total} complete, ${active} active; latest ${latest}`,
@@ -750,6 +773,8 @@ export async function runKtxIngest(
);
plainProgress?.start();
structuredProgress?.start();
+ const cliAbort = deps.abortSignal ? null : createCliAbortSignal();
+ const abortSignal = deps.abortSignal ?? cliAbort?.signal;
let result: LocalMetabaseFanoutResult;
try {
result = await executeMetabaseFanout({
@@ -763,6 +788,7 @@ export async function runKtxIngest(
embeddingProvider,
...(memoryFlow ? { memoryFlow } : {}),
...(progress ? { progress } : {}),
+ ...(abortSignal ? { abortSignal } : {}),
});
plainProgress?.flush();
if (args.outputMode === 'json') {
@@ -772,6 +798,7 @@ export async function runKtxIngest(
}
} finally {
plainProgress?.flush();
+ cliAbort?.dispose();
}
return result.status === 'all_failed' ? 1 : 0;
}
@@ -820,6 +847,8 @@ export async function runKtxIngest(
plainProgress?.start();
structuredProgress?.start();
+ const cliAbort = deps.abortSignal ? null : createCliAbortSignal();
+ const abortSignal = deps.abortSignal ?? cliAbort?.signal;
try {
const result = await executeLocalIngest({
@@ -836,6 +865,7 @@ export async function runKtxIngest(
embeddingProvider,
...(args.debugLlmRequestFile ? { llmDebugRequestFile: args.debugLlmRequestFile } : {}),
...(memoryFlow ? { memoryFlow } : {}),
+ ...(abortSignal ? { abortSignal } : {}),
});
if (shouldUseLiveViz && memoryFlow) {
latestMemoryFlowSnapshot = finalRunMemoryFlowInput(memoryFlow.snapshot(), result.report);
@@ -854,6 +884,7 @@ export async function runKtxIngest(
} finally {
plainProgress?.flush();
liveTui?.close();
+ cliAbort?.dispose();
}
}
diff --git a/packages/cli/src/io/symbols.ts b/packages/cli/src/io/symbols.ts
index ba93a436..fe6045b3 100644
--- a/packages/cli/src/io/symbols.ts
+++ b/packages/cli/src/io/symbols.ts
@@ -17,6 +17,8 @@ const unicode = detectUnicodeSupport();
export const SYMBOLS = {
middot: unicode ? '·' : '-',
emDash: unicode ? '—' : '--',
+ star: unicode ? '★' : '*',
+ rightArrow: unicode ? '→' : '->',
} as const;
export function dim(text: string): string {
diff --git a/packages/cli/src/io/tty.ts b/packages/cli/src/io/tty.ts
new file mode 100644
index 00000000..43467d4e
--- /dev/null
+++ b/packages/cli/src/io/tty.ts
@@ -0,0 +1,17 @@
+import type { Writable } from 'node:stream';
+
+import type { KtxCliIo } from '../cli-runtime.js';
+
+type KtxCliOutput = (KtxCliIo['stdout'] | KtxCliIo['stderr']) & {
+ isTTY?: boolean;
+ columns?: number;
+ on?: unknown;
+};
+
+export function isWritableTtyOutput(output: KtxCliOutput): output is KtxCliOutput & Writable {
+ return (
+ (output as { isTTY?: unknown }).isTTY === true &&
+ typeof (output as { on?: unknown }).on === 'function' &&
+ typeof (output as { columns?: unknown }).columns !== 'undefined'
+ );
+}
diff --git a/packages/cli/src/links.ts b/packages/cli/src/links.ts
new file mode 100644
index 00000000..1ca62c59
--- /dev/null
+++ b/packages/cli/src/links.ts
@@ -0,0 +1 @@
+export const SLACK_URL = 'https://ktx.sh/slack';
diff --git a/packages/cli/src/memory-flow-hud.tsx b/packages/cli/src/memory-flow-hud.tsx
index 8b044122..5a09bf08 100644
--- a/packages/cli/src/memory-flow-hud.tsx
+++ b/packages/cli/src/memory-flow-hud.tsx
@@ -139,31 +139,21 @@ function sourceDescription(input: MemoryFlowReplayInput): SourceInfo {
return { type: info.type, name: conn, sourceCount: count, itemNounPlural: info.plural, readingVerb: info.verb, ingestDescription: info.description };
}
-function activeWorkUnits(
- input: MemoryFlowReplayInput,
-): Array<{ unitKey: string; stepIndex: number; stepBudget: number }> {
+function activeWorkUnits(input: MemoryFlowReplayInput): string[] {
const finishedKeys = new Set();
- const unitMap = new Map();
-
for (const e of input.events) {
- if (e.type === 'work_unit_started') {
- unitMap.set(e.unitKey, { stepIndex: 0, stepBudget: e.stepBudget });
- }
- if (e.type === 'work_unit_step') {
- const existing = unitMap.get(e.unitKey);
- if (existing) {
- existing.stepIndex = e.stepIndex;
- existing.stepBudget = e.stepBudget;
- }
- }
if (e.type === 'work_unit_finished') finishedKeys.add(e.unitKey);
}
- const result: Array<{ unitKey: string; stepIndex: number; stepBudget: number }> = [];
- for (const [unitKey, data] of unitMap) {
- if (!finishedKeys.has(unitKey)) result.push({ unitKey, ...data });
+ const active: string[] = [];
+ const seen = new Set();
+ for (const e of input.events) {
+ if (e.type === 'work_unit_started' && !finishedKeys.has(e.unitKey) && !seen.has(e.unitKey)) {
+ seen.add(e.unitKey);
+ active.push(e.unitKey);
+ }
}
- return result;
+ return active;
}
function queuedWorkUnits(input: MemoryFlowReplayInput): string[] {
diff --git a/packages/cli/src/public-ingest.ts b/packages/cli/src/public-ingest.ts
index 44a2b024..07f805b8 100644
--- a/packages/cli/src/public-ingest.ts
+++ b/packages/cli/src/public-ingest.ts
@@ -23,7 +23,8 @@ import type { KtxScanArgs, KtxScanDeps } from './scan.js';
import type { KtxTableRef } from './context/scan/types.js';
import { profileMark } from './startup-profile.js';
import { isDemoConnection } from './telemetry/demo-detect.js';
-import { emitProjectStackSnapshot, emitTelemetryEvent } from './telemetry/index.js';
+import { emitProjectStackSnapshot, emitTelemetryEvent, reportException } from './telemetry/index.js';
+import { collectTelemetryRedactionSecrets } from './telemetry/redaction-secrets.js';
import { formatErrorDetail } from './telemetry/scrubber.js';
profileMark('module:public-ingest');
@@ -1119,30 +1120,63 @@ export async function runKtxPublicIngest(
feature,
});
} catch (error) {
+ await reportException({
+ error,
+ context: { source: 'ingest runtime', handled: true, fatal: false },
+ projectDir: args.projectDir,
+ io,
+ redactionSecrets: await collectTelemetryRedactionSecrets({
+ project,
+ projectDir: args.projectDir,
+ connectionId: args.targetConnectionId,
+ includeLlm: true,
+ includeEmbeddings: true,
+ env: deps.env ?? process.env,
+ }),
+ });
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 1;
}
}
const { runContextBuild } = await import('./context-build-view.js');
const contextBuild = deps.runContextBuild ?? runContextBuild;
- const result = await contextBuild(
- project,
- {
+ try {
+ const result = await contextBuild(
+ project,
+ {
+ projectDir: args.projectDir,
+ ...(args.targetConnectionId ? { targetConnectionId: args.targetConnectionId } : {}),
+ all: args.all,
+ entrypoint: 'ingest',
+ inputMode: args.inputMode,
+ ...(args.queryHistory ? { queryHistory: args.queryHistory } : {}),
+ ...(args.queryHistoryWindowDays !== undefined ? { queryHistoryWindowDays: args.queryHistoryWindowDays } : {}),
+ ...(args.scanMode ? { scanMode: args.scanMode } : {}),
+ ...(args.detectRelationships !== undefined ? { detectRelationships: args.detectRelationships } : {}),
+ ...(args.cliVersion ? { cliVersion: args.cliVersion } : {}),
+ ...(args.runtimeInstallPolicy ? { runtimeInstallPolicy: args.runtimeInstallPolicy } : {}),
+ },
+ io,
+ );
+ return result.exitCode;
+ } catch (error) {
+ await reportException({
+ error,
+ context: { source: 'ingest context-build', handled: true, fatal: false },
projectDir: args.projectDir,
- ...(args.targetConnectionId ? { targetConnectionId: args.targetConnectionId } : {}),
- all: args.all,
- entrypoint: 'ingest',
- inputMode: args.inputMode,
- ...(args.queryHistory ? { queryHistory: args.queryHistory } : {}),
- ...(args.queryHistoryWindowDays !== undefined ? { queryHistoryWindowDays: args.queryHistoryWindowDays } : {}),
- ...(args.scanMode ? { scanMode: args.scanMode } : {}),
- ...(args.detectRelationships !== undefined ? { detectRelationships: args.detectRelationships } : {}),
- ...(args.cliVersion ? { cliVersion: args.cliVersion } : {}),
- ...(args.runtimeInstallPolicy ? { runtimeInstallPolicy: args.runtimeInstallPolicy } : {}),
- },
- io,
- );
- return result.exitCode;
+ io,
+ redactionSecrets: await collectTelemetryRedactionSecrets({
+ project,
+ projectDir: args.projectDir,
+ connectionId: args.targetConnectionId,
+ includeLlm: true,
+ includeEmbeddings: true,
+ env: deps.env ?? process.env,
+ }),
+ });
+ io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
+ return 1;
+ }
}
const plan = buildPublicIngestPlan(project, args);
diff --git a/packages/cli/src/reveal-password-prompt.ts b/packages/cli/src/reveal-password-prompt.ts
new file mode 100644
index 00000000..3fe3ed66
--- /dev/null
+++ b/packages/cli/src/reveal-password-prompt.ts
@@ -0,0 +1,93 @@
+import { styleText } from 'node:util';
+import { PasswordPrompt, type PasswordOptions } from '@clack/core';
+import { S_BAR, S_BAR_END, S_PASSWORD_MASK, settings, symbol } from '@clack/prompts';
+
+// How many trailing characters of a pasted secret to leave visible so the user
+// can confirm what landed (e.g. `••••••a1b2`). Kept small on purpose.
+const REVEAL_TAIL_COUNT = 4;
+
+/**
+ * Mask every character of `userInput` except the last `tail`, but only reveal the
+ * tail once the secret is long enough that the hidden portion still dominates
+ * (`length > tail * 2`). Short secrets stay fully masked so we never expose most
+ * of a small value. The returned string keeps the same code-unit length as the
+ * input so clack's cursor slicing in `userInputWithCursor` stays aligned.
+ *
+ * @internal
+ */
+export function maskRevealingTail(userInput: string, maskChar: string, tail: number): string {
+ const revealLength = userInput.length > tail * 2 ? tail : 0;
+ const hiddenLength = userInput.length - revealLength;
+ return maskChar.repeat(hiddenLength) + userInput.slice(hiddenLength);
+}
+
+class RevealTailPasswordPrompt extends PasswordPrompt {
+ readonly #maskChar: string;
+ readonly #tail: number;
+
+ constructor(options: PasswordOptions & { tail: number }) {
+ super(options);
+ this.#maskChar = options.mask ?? S_PASSWORD_MASK;
+ this.#tail = options.tail;
+ }
+
+ override get masked(): string {
+ return maskRevealingTail(this.userInput, this.#maskChar, this.#tail);
+ }
+}
+
+// Reproduces the @clack/prompts password frame (pinned to the installed version)
+// so this prompt is visually identical to every other setup prompt; the only
+// behavioral change is the tail-revealing `masked` getter above.
+function renderPasswordFrame(prompt: Omit, message: string): string {
+ const withGuide = settings.withGuide;
+ const title = `${withGuide ? `${styleText('gray', S_BAR)}\n` : ''}${symbol(prompt.state)} ${message}\n`;
+ const masked = prompt.masked;
+ switch (prompt.state) {
+ case 'error': {
+ const bar = withGuide ? `${styleText('yellow', S_BAR)} ` : '';
+ const end = withGuide ? `${styleText('yellow', S_BAR_END)} ` : '';
+ return `${title.trim()}\n${bar}${masked}\n${end}${styleText('yellow', prompt.error)}\n`;
+ }
+ case 'submit': {
+ const bar = withGuide ? `${styleText('gray', S_BAR)} ` : '';
+ return `${title}${bar}${masked ? styleText('dim', masked) : ''}`;
+ }
+ case 'cancel': {
+ const bar = withGuide ? `${styleText('gray', S_BAR)} ` : '';
+ const body = masked ? styleText(['strikethrough', 'dim'], masked) : '';
+ return `${title}${bar}${body}${masked && withGuide ? `\n${styleText('gray', S_BAR)}` : ''}`;
+ }
+ default: {
+ const bar = withGuide ? `${styleText('cyan', S_BAR)} ` : '';
+ const end = withGuide ? styleText('cyan', S_BAR_END) : '';
+ return `${title}${bar}${prompt.userInputWithCursor}\n${end}\n`;
+ }
+ }
+}
+
+export interface RevealPasswordOptions {
+ message: string;
+ mask?: string;
+ tail?: number;
+ validate?: PasswordOptions['validate'];
+ signal?: AbortSignal;
+}
+
+/**
+ * Drop-in replacement for clack's `password()` that reveals the last few
+ * characters of the entered value while typing. Resolves to the raw value or the
+ * clack cancel symbol, matching `password()`'s contract.
+ */
+export function revealPassword(options: RevealPasswordOptions): Promise {
+ const prompt = new RevealTailPasswordPrompt({
+ mask: options.mask ?? S_PASSWORD_MASK,
+ tail: options.tail ?? REVEAL_TAIL_COUNT,
+ validate: options.validate,
+ signal: options.signal,
+ render() {
+ return renderPasswordFrame(this, options.message);
+ },
+ });
+ return prompt.prompt() as Promise;
+}
diff --git a/packages/cli/src/scan.ts b/packages/cli/src/scan.ts
index 4f973e57..5961e3f1 100644
--- a/packages/cli/src/scan.ts
+++ b/packages/cli/src/scan.ts
@@ -1,6 +1,6 @@
import type { KtxProgressPort, KtxScanMode, KtxScanReport, KtxScanWarning } from './context/scan/types.js';
import { runLocalScan } from './context/scan/local-scan.js';
-import { loadKtxProject } from './context/project/project.js';
+import { loadKtxProject, type KtxLocalProject } from './context/project/project.js';
import { getKtxCliPackageInfo } from './cli-runtime.js';
import { resolveProjectEmbeddingProvider } from './embedding-resolution.js';
import type { KtxCliIo } from './index.js';
@@ -8,7 +8,8 @@ import { createKtxCliLocalIngestAdapters } from './local-adapters.js';
import { createKtxCliScanConnector } from './local-scan-connectors.js';
import type { KtxManagedPythonInstallPolicy } from './managed-python-command.js';
import { profileMark } from './startup-profile.js';
-import { emitTelemetryEvent } from './telemetry/index.js';
+import { emitTelemetryEvent, reportException } from './telemetry/index.js';
+import { collectTelemetryRedactionSecrets } from './telemetry/redaction-secrets.js';
import { formatErrorDetail, scrubErrorClass } from './telemetry/scrubber.js';
profileMark('module:scan');
@@ -322,8 +323,9 @@ export function createCliScanProgress(
export async function runKtxScan(args: KtxScanArgs, io: KtxCliIo = process, deps: KtxScanDeps = {}): Promise {
const startedAt = performance.now();
+ let project: KtxLocalProject | undefined;
try {
- const project = await loadKtxProject({ projectDir: args.projectDir });
+ project = await loadKtxProject({ projectDir: args.projectDir });
const resolveEmbeddingProvider = deps.resolveEmbeddingProvider ?? resolveProjectEmbeddingProvider;
const resolution = await resolveEmbeddingProvider(project, {
mode: 'ensure',
@@ -397,6 +399,20 @@ export async function runKtxScan(args: KtxScanArgs, io: KtxCliIo = process, deps
...(errorDetail ? { errorDetail } : {}),
},
});
+ await reportException({
+ error,
+ context: { source: 'scan run', handled: true, fatal: false },
+ projectDir: args.projectDir,
+ io,
+ redactionSecrets: await collectTelemetryRedactionSecrets({
+ project,
+ projectDir: args.projectDir,
+ connectionId: args.connectionId,
+ includeLlm: true,
+ includeEmbeddings: true,
+ env: process.env,
+ }),
+ });
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 1;
}
diff --git a/packages/cli/src/setup-agents.ts b/packages/cli/src/setup-agents.ts
index a671ba4b..3c7e5bad 100644
--- a/packages/cli/src/setup-agents.ts
+++ b/packages/cli/src/setup-agents.ts
@@ -1,7 +1,6 @@
import { existsSync } from 'node:fs';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { dirname, join, relative, resolve } from 'node:path';
-import type { Writable } from 'node:stream';
import { fileURLToPath } from 'node:url';
import { styleText } from 'node:util';
import { log, outro } from '@clack/prompts';
@@ -11,6 +10,7 @@ import { serializeKtxProjectConfig } from './context/project/config.js';
import { strToU8, zipSync } from 'fflate';
import type { KtxCliIo } from './cli-runtime.js';
import { errorMessage, writePrefixedLines } from './clack.js';
+import { isWritableTtyOutput } from './io/tty.js';
import {
createKtxSetupPromptAdapter,
createKtxSetupUiAdapter,
@@ -84,14 +84,6 @@ interface KtxCliLauncher {
args: string[];
}
-function isWritableTtyOutput(output: KtxCliIo['stdout']): output is KtxCliIo['stdout'] & Writable {
- return (
- output.isTTY === true &&
- typeof (output as { on?: unknown }).on === 'function' &&
- typeof (output as { columns?: unknown }).columns !== 'undefined'
- );
-}
-
function writeSetupInfo(io: KtxCliIo, message: string): void {
if (isWritableTtyOutput(io.stdout)) {
log.info(message, { output: io.stdout });
diff --git a/packages/cli/src/setup-databases.ts b/packages/cli/src/setup-databases.ts
index 3cb6c5d2..002ead30 100644
--- a/packages/cli/src/setup-databases.ts
+++ b/packages/cli/src/setup-databases.ts
@@ -73,6 +73,7 @@ export type KtxSetupDatabaseDriver =
export interface KtxSetupDatabasesArgs {
projectDir: string;
inputMode: 'auto' | 'disabled';
+ debug?: boolean;
yes?: boolean;
cliVersion?: string;
runtimeInstallPolicy?: KtxManagedPythonInstallPolicy;
@@ -1626,7 +1627,12 @@ function hasServiceAccountsBlock(connection: KtxProjectConnectionConfig | undefi
return 'serviceAccounts' in filters;
}
-function printQueryHistoryFilterProposal(io: KtxCliIo, proposal: QueryHistoryFilterProposal): void {
+function printQueryHistoryFilterProposal(io: KtxCliIo, proposal: QueryHistoryFilterProposal, debug = false): void {
+ if (debug && proposal.parseFailedTemplateIds.length > 0) {
+ io.stderr.write(
+ `[debug] query-history filter picker could not parse ${proposal.parseFailedTemplateIds.length} template(s): ${proposal.parseFailedTemplateIds.join(', ')}\n`,
+ );
+ }
if (proposal.excludedRoles.length === 0) {
if (proposal.skipped?.reason === 'no-llm') {
io.stdout.write('│ Query-history filter picker skipped: no LLM is configured.\n');
@@ -1635,6 +1641,12 @@ function printQueryHistoryFilterProposal(io: KtxCliIo, proposal: QueryHistoryFil
} else if (proposal.skipped?.reason === 'no-in-scope-history') {
io.stdout.write('│ Query-history filter picker found no in-scope service-account exclusions.\n');
}
+ if (proposal.parseFailedTemplateIds.length > 0) {
+ const count = proposal.parseFailedTemplateIds.length;
+ io.stdout.write(
+ `│ Skipped ${count} query template${count === 1 ? '' : 's'} ktx could not parse (run with --debug to list them).\n`,
+ );
+ }
for (const warning of proposal.warnings) {
io.stdout.write(`│ ! ${warning}\n`);
}
@@ -1727,12 +1739,17 @@ async function maybeProposeQueryHistoryFilters(input: {
deps: input.deps,
});
if (!llmRuntime && !input.deps.queryHistoryFilterPicker) {
- printQueryHistoryFilterProposal(input.io, {
- excludedRoles: [],
- consideredRoleCount: 0,
- skipped: { reason: 'no-llm' },
- warnings: [],
- });
+ printQueryHistoryFilterProposal(
+ input.io,
+ {
+ excludedRoles: [],
+ consideredRoleCount: 0,
+ skipped: { reason: 'no-llm' },
+ warnings: [],
+ parseFailedTemplateIds: [],
+ },
+ input.args.debug === true,
+ );
return;
}
@@ -1773,7 +1790,19 @@ async function maybeProposeQueryHistoryFilters(input: {
userServiceAccountsPresent,
});
- printQueryHistoryFilterProposal(input.io, proposal);
+ printQueryHistoryFilterProposal(input.io, proposal, input.args.debug === true);
+ await emitTelemetryEvent({
+ name: 'query_history_filter_completed',
+ projectDir: input.projectDir,
+ io: input.io,
+ fields: {
+ dialect,
+ consideredRoleCount: proposal.consideredRoleCount,
+ excludedRoleCount: proposal.excludedRoles.length,
+ parseFailedCount: proposal.parseFailedTemplateIds.length,
+ outcome: 'ok',
+ },
+ });
if (proposal.skipped?.reason === 'user-block-present') {
input.io.stdout.write('│ Existing query-history service-account filters left unchanged.\n');
return;
diff --git a/packages/cli/src/setup-demo-tour.ts b/packages/cli/src/setup-demo-tour.ts
index da80b988..ca891167 100644
--- a/packages/cli/src/setup-demo-tour.ts
+++ b/packages/cli/src/setup-demo-tour.ts
@@ -259,6 +259,7 @@ async function runDemoContextReplay(
frame: 0,
startedAt: Date.now(),
totalElapsedMs: 0,
+ starCount: null,
};
const allTargets = [...allPrimary, ...allContext];
diff --git a/packages/cli/src/setup-embeddings.ts b/packages/cli/src/setup-embeddings.ts
index 8f49bcf1..5d02e3e4 100644
--- a/packages/cli/src/setup-embeddings.ts
+++ b/packages/cli/src/setup-embeddings.ts
@@ -222,8 +222,8 @@ async function chooseCredentialRef(
const choice = await prompts.select({
message: `How should KTX find your ${embeddingBackendDisplayName(backend)} embedding API key?`,
options: [
- { value: 'env', label: `Use ${defaultEnv} from the environment` },
{ value: 'paste', label: 'Paste a key and save it as a local secret file' },
+ { value: 'env', label: `Use ${defaultEnv} from the environment` },
{ value: 'back', label: 'Back' },
],
});
diff --git a/packages/cli/src/setup-models.ts b/packages/cli/src/setup-models.ts
index 8e8cf30b..fbbabbdb 100644
--- a/packages/cli/src/setup-models.ts
+++ b/packages/cli/src/setup-models.ts
@@ -10,7 +10,7 @@ import { resolveKtxConfigReference } from './context/core/config-reference.js';
import { type KtxProjectConfig, type KtxProjectLlmConfig, serializeKtxProjectConfig } from './context/project/config.js';
import { loadKtxProject } from './context/project/project.js';
import { markKtxSetupStateStepComplete } from './context/project/setup-config.js';
-import type { KtxLlmConfig } from './llm/types.js';
+import { type KtxModelRole, KTX_MODEL_ROLES, type KtxLlmConfig } from './llm/types.js';
import { type KtxLlmHealthCheckResult, runKtxLlmHealthCheck } from './llm/model-health.js';
import {
formatClaudeCodePromptCachingWarning,
@@ -37,7 +37,6 @@ export interface KtxSetupModelArgs {
llmBackend?: KtxSetupLlmBackend;
anthropicApiKeyEnv?: string;
anthropicApiKeyFile?: string;
- llmModel?: string;
vertexProject?: string;
vertexLocation?: string;
forcePrompt?: boolean;
@@ -52,14 +51,27 @@ export type KtxSetupModelResult =
| { status: 'missing-input'; projectDir: string }
| { status: 'failed'; projectDir: string };
-/** @internal */
-export interface AnthropicModelChoice {
- id: string;
- label: string;
- recommended: boolean;
+// Single source of truth for the LLM backends a user can pick during setup.
+// The CLI arg parser, the interactive prompt, and the missing-backend error all
+// derive from this list, so adding a backend is one edit. Order is the prompt's
+// preference order (subscription backends first).
+const KTX_SETUP_LLM_BACKENDS = ['claude-code', 'codex', 'anthropic', 'vertex'] as const;
+export type KtxSetupLlmBackend = (typeof KTX_SETUP_LLM_BACKENDS)[number];
+
+/** Validates a raw CLI or prompt value against the setup-selectable LLM backends. */
+export function isKtxSetupLlmBackend(value: string): value is KtxSetupLlmBackend {
+ return KTX_SETUP_LLM_BACKENDS.some((backend) => backend === value);
}
-export type KtxSetupLlmBackend = 'anthropic' | 'vertex' | 'claude-code' | 'codex';
+// Display labels for the interactive provider prompt. The Record key type forces
+// every backend to carry a label, so adding one to KTX_SETUP_LLM_BACKENDS fails
+// to compile until its prompt option exists here.
+const KTX_SETUP_LLM_BACKEND_LABELS: Record = {
+ 'claude-code': 'Claude subscription (Pro/Max)',
+ codex: 'Codex subscription',
+ anthropic: 'Anthropic API key',
+ vertex: 'Google Vertex AI for Anthropic Claude',
+};
/** @internal */
export interface KtxSetupModelPromptAdapter {
@@ -76,9 +88,7 @@ export interface KtxSetupModelPromptAdapter {
export interface KtxSetupModelDeps {
env?: NodeJS.ProcessEnv;
- fetch?: typeof fetch;
prompts?: KtxSetupModelPromptAdapter;
- listModels?: (apiKey: string) => Promise;
healthCheck?: (config: KtxLlmConfig) => Promise;
claudeCodeAuthProbe?: (input: {
projectDir: string;
@@ -91,94 +101,72 @@ export interface KtxSetupModelDeps {
spinner?: () => KtxCliSpinner;
}
-/** @internal */
-export const BUNDLED_ANTHROPIC_MODELS: AnthropicModelChoice[] = [
- { id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6', recommended: true },
- { id: 'claude-opus-4-6', label: 'Claude Opus 4.6', recommended: false },
- { id: 'claude-haiku-4-5', label: 'Claude Haiku 4.5', recommended: false },
-];
-
-const VERTEX_ANTHROPIC_MODELS: AnthropicModelChoice[] = [
- { id: 'claude-opus-4-7', label: 'Claude Opus 4.7', recommended: false },
- { id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6', recommended: false },
- { id: 'claude-opus-4-6', label: 'Claude Opus 4.6', recommended: false },
- { id: 'claude-opus-4-5', label: 'Claude Opus 4.5', recommended: false },
- { id: 'claude-haiku-4-5', label: 'Claude Haiku 4.5', recommended: false },
- { id: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5', recommended: false },
- { id: 'claude-opus-4-1', label: 'Claude Opus 4.1', recommended: false },
-];
-
-const CLAUDE_CODE_MODELS: AnthropicModelChoice[] = [
- { id: 'sonnet', label: 'Claude Sonnet', recommended: true },
- { id: 'opus', label: 'Claude Opus', recommended: false },
- { id: 'haiku', label: 'Claude Haiku', recommended: false },
-];
-
-// Curated Codex models from OpenAI's current lineup that work under both
-// ChatGPT-account (subscription) and API-key auth. Intentionally omitted:
-// the `*-codex` ids (e.g. gpt-5.3-codex, gpt-5.2-codex) are API-key-only and
-// fail on ChatGPT-account auth, and gpt-5.3-codex-spark is a ChatGPT-Pro-only
-// research preview. Codex resolves real availability per account at runtime
-// (its binary remote-fetches the model list), so this is a convenience
-// shortlist only — the manual-entry option accepts any id your account's
-// `codex` picker exposes, and the auth probe reports an unsupported choice.
-const CODEX_MODELS: AnthropicModelChoice[] = [
- { id: 'gpt-5.5', label: 'GPT-5.5', recommended: true },
- { id: 'gpt-5.4', label: 'GPT-5.4', recommended: false },
- { id: 'gpt-5.4-mini', label: 'GPT-5.4 mini', recommended: false },
-];
-
-const HIDDEN_ANTHROPIC_MODEL_PATTERNS = [
- /^claude-sonnet-4$/i,
- /^claude-opus-4$/i,
- /^Claude Sonnet 4$/i,
- /^Claude Opus 4$/i,
-];
-
const ANTHROPIC_CREDENTIAL_PROMPT_CONTEXT =
'KTX uses the key to verify Anthropic model access now and to run ingest agents that turn schemas, SQL, ' +
'BI metadata, and docs into semantic-layer sources and wiki context. ktx.yaml stores an env: or file: ' +
'reference, not the raw key.';
-const ANTHROPIC_MODEL_PROMPT_CONTEXT =
- 'KTX uses this as the default model for ingest agents that turn schemas, SQL, BI metadata, and docs ' +
- 'into semantic-layer sources and wiki context.';
-
const VERTEX_PROJECT_PROMPT_CONTEXT =
'KTX stores the selected Google Cloud project ID in ktx.yaml and uses Application Default Credentials for ' +
'access. Project visibility depends on the signed-in Google account and organization permissions.';
const DEFAULT_VERTEX_LOCATION = 'us-east5';
+type KtxSetupModelPreset = Record;
+
+const ANTHROPIC_PRESET = {
+ default: 'claude-sonnet-4-6',
+ triage: 'claude-haiku-4-5',
+ candidateExtraction: 'claude-sonnet-4-6',
+ curator: 'claude-opus-4-7',
+ reconcile: 'claude-opus-4-7',
+ repair: 'claude-haiku-4-5',
+} satisfies KtxSetupModelPreset;
+
+const CLAUDE_CODE_PRESET = {
+ default: 'sonnet',
+ triage: 'haiku',
+ candidateExtraction: 'sonnet',
+ curator: 'opus',
+ reconcile: 'opus',
+ repair: 'haiku',
+} satisfies KtxSetupModelPreset;
+
+const CODEX_PRESET = {
+ default: DEFAULT_CODEX_MODEL,
+ triage: DEFAULT_CODEX_MODEL,
+ candidateExtraction: DEFAULT_CODEX_MODEL,
+ curator: DEFAULT_CODEX_MODEL,
+ reconcile: DEFAULT_CODEX_MODEL,
+ repair: DEFAULT_CODEX_MODEL,
+} satisfies KtxSetupModelPreset;
+
+const MODEL_PRESETS = {
+ anthropic: ANTHROPIC_PRESET,
+ vertex: ANTHROPIC_PRESET,
+ 'claude-code': CLAUDE_CODE_PRESET,
+ codex: CODEX_PRESET,
+} satisfies Record;
+
+function presetForBackend(backend: KtxSetupLlmBackend): KtxSetupModelPreset {
+ return MODEL_PRESETS[backend];
+}
+
const execFileAsync = promisify(execFile);
-type AnthropicModelDiscoveryErrorReason = 'authentication' | 'http' | 'empty-response';
-
-class AnthropicModelDiscoveryError extends Error {
- constructor(
- message: string,
- public readonly reason: AnthropicModelDiscoveryErrorReason,
- public readonly status?: number,
- ) {
- super(message);
- this.name = 'AnthropicModelDiscoveryError';
- }
-}
-
-function isAnthropicModelAuthenticationError(error: unknown): error is AnthropicModelDiscoveryError {
- return error instanceof AnthropicModelDiscoveryError && error.reason === 'authentication';
-}
-
-function isSelectableAnthropicModel(model: AnthropicModelChoice): boolean {
- return !HIDDEN_ANTHROPIC_MODEL_PATTERNS.some((pattern) => pattern.test(model.id) || pattern.test(model.label));
-}
-
-type ChooseModelResult =
- | { status: 'ready'; model: string }
- | { status: 'back' | 'missing-input' | 'invalid-credential' };
-
type ChooseBackendResult =
| { status: 'ready'; backend: KtxSetupLlmBackend; prompted: boolean }
- | { status: 'back' };
+ | { status: 'back' }
+ | { status: 'missing-input' };
+
+// Non-interactive setup cannot pick a provider safely: every backend needs
+// something the user must supply (an API key, gcloud ADC, or a logged-in local
+// CLI), so there is no credential-free default to fall back to. Name the hidden
+// --llm-backend flag and its choices here instead, mirroring how the other
+// automation errors guide users to the flag they need.
+const MISSING_LLM_BACKEND_MESSAGE =
+ `Missing LLM backend: pass --llm-backend with one of ${KTX_SETUP_LLM_BACKENDS.join(', ')}. ` +
+ 'claude-code and codex use local CLI authentication; anthropic also needs --anthropic-api-key-env or ' +
+ '--anthropic-api-key-file, and vertex also needs --vertex-project.';
type VertexConfigChoice =
| {
@@ -234,47 +222,6 @@ async function defaultListGcloudProjects(): Promise {
.filter((project): project is GcloudProjectChoice => Boolean(project));
}
-/** @internal */
-export async function fetchAnthropicModels(
- apiKey: string,
- fetchFn: typeof fetch = fetch,
-): Promise {
- const response = await fetchFn('https://api.anthropic.com/v1/models?limit=1000', {
- headers: {
- 'anthropic-version': '2023-06-01',
- 'x-api-key': apiKey,
- },
- });
- if (!response.ok) {
- if (response.status === 401 || response.status === 403) {
- throw new AnthropicModelDiscoveryError(
- `Anthropic model discovery failed with HTTP ${response.status}`,
- 'authentication',
- response.status,
- );
- }
- throw new AnthropicModelDiscoveryError(
- `Anthropic model discovery failed with HTTP ${response.status}`,
- 'http',
- response.status,
- );
- }
- const body = (await response.json()) as { data?: Array<{ id?: unknown; display_name?: unknown; type?: unknown }> };
- const models = (body.data ?? [])
- .map((item) => ({
- id: typeof item.id === 'string' ? item.id : '',
- label: typeof item.display_name === 'string' ? item.display_name : typeof item.id === 'string' ? item.id : '',
- recommended: false,
- }))
- .filter((item) => item.id.startsWith('claude-'))
- .filter(isSelectableAnthropicModel);
- if (models.length === 0) {
- throw new AnthropicModelDiscoveryError('Anthropic model discovery returned no Claude models', 'empty-response');
- }
- const recommendedIndex = models.findIndex((item) => item.id.includes('sonnet'));
- return models.map((item, index) => ({ ...item, recommended: index === Math.max(recommendedIndex, 0) }));
-}
-
export function isKtxSetupLlmConfigReady(config: KtxProjectLlmConfig): boolean {
let resolved: KtxLlmConfig | null;
try {
@@ -309,12 +256,12 @@ function buildProjectLlmConfig(
| { backend: 'vertex'; vertex: { project?: string; location: string } }
| { backend: 'claude-code' }
| { backend: 'codex' },
- model: string,
+ models: KtxSetupModelPreset,
): KtxProjectLlmConfig {
if (provider.backend === 'claude-code') {
return {
provider: { backend: 'claude-code' },
- models: { ...existing.models, default: model },
+ models,
promptCaching: existing.promptCaching,
};
}
@@ -322,7 +269,7 @@ function buildProjectLlmConfig(
if (provider.backend === 'codex') {
return {
provider: { backend: 'codex' },
- models: { ...existing.models, default: model },
+ models,
promptCaching: existing.promptCaching,
};
}
@@ -333,7 +280,7 @@ function buildProjectLlmConfig(
backend: 'vertex',
vertex: provider.vertex,
},
- models: { ...existing.models, default: model },
+ models,
promptCaching: { ...(existing.promptCaching ?? {}), enabled: true, vertexFallbackTo5m: true },
};
}
@@ -343,7 +290,7 @@ function buildProjectLlmConfig(
backend: 'anthropic',
anthropic: { api_key: provider.credentialRef },
},
- models: { ...existing.models, default: model },
+ models,
promptCaching: { ...(existing.promptCaching ?? {}), enabled: true },
};
}
@@ -470,8 +417,8 @@ async function chooseCredentialRef(
const choice = await prompts.select({
message: `How should KTX find your Anthropic API key?\n\n${ANTHROPIC_CREDENTIAL_PROMPT_CONTEXT}`,
options: [
- { value: 'env', label: 'Use ANTHROPIC_API_KEY from the environment' },
{ value: 'paste', label: 'Paste a key and save it as a local secret file' },
+ { value: 'env', label: 'Use ANTHROPIC_API_KEY from the environment' },
{ value: 'back', label: 'Back' },
],
});
@@ -514,16 +461,12 @@ function requestedBackend(args: KtxSetupModelArgs): KtxSetupLlmBackend | undefin
if (args.vertexProject || args.vertexLocation) {
return 'vertex';
}
- if (args.anthropicApiKeyEnv || args.anthropicApiKeyFile || args.llmModel) {
+ if (args.anthropicApiKeyEnv || args.anthropicApiKeyFile) {
return 'anthropic';
}
return undefined;
}
-function requestedModel(args: KtxSetupModelArgs): string | undefined {
- return args.llmModel;
-}
-
async function chooseBackend(
args: KtxSetupModelArgs,
io: KtxCliIo,
@@ -534,7 +477,8 @@ async function chooseBackend(
return { status: 'ready', backend: explicit, prompted: false };
}
if (args.inputMode === 'disabled') {
- return { status: 'ready', backend: 'anthropic', prompted: false };
+ io.stderr.write(`${MISSING_LLM_BACKEND_MESSAGE}\n`);
+ return { status: 'missing-input' };
}
const prompts = deps.prompts ?? createPromptAdapter();
@@ -546,21 +490,20 @@ async function chooseBackend(
const choice = await prompts.select({
message: 'Which LLM provider should KTX use?',
options: [
- { value: 'claude-code', label: 'Claude subscription (Pro/Max)' },
- { value: 'codex', label: 'Codex subscription' },
- { value: 'anthropic', label: 'Anthropic API key' },
- { value: 'vertex', label: 'Google Vertex AI for Anthropic Claude' },
+ ...KTX_SETUP_LLM_BACKENDS.map((backend) => ({ value: backend, label: KTX_SETUP_LLM_BACKEND_LABELS[backend] })),
{ value: 'back', label: 'Back' },
],
});
if (choice === 'back') {
return { status: 'back' };
}
- return {
- status: 'ready',
- backend: choice === 'vertex' || choice === 'claude-code' || choice === 'codex' ? choice : 'anthropic',
- prompted: true,
- };
+ if (isKtxSetupLlmBackend(choice)) {
+ return { status: 'ready', backend: choice, prompted: true };
+ }
+ // Options are derived from KTX_SETUP_LLM_BACKENDS, so the only other value is
+ // 'back' (handled above). Treat any unexpected value as a cancel rather than
+ // silently assuming a provider.
+ return { status: 'back' };
}
function resolveProvidedVertexRef(
@@ -774,187 +717,6 @@ async function chooseVertexConfig(
};
}
-async function chooseModel(
- args: KtxSetupModelArgs,
- credentialValue: string,
- io: KtxCliIo,
- deps: KtxSetupModelDeps,
-): Promise {
- const providedModel = requestedModel(args);
- if (providedModel) {
- return { status: 'ready', model: providedModel };
- }
- if (args.inputMode === 'disabled') {
- io.stderr.write('Missing LLM model: pass --llm-model.\n');
- return { status: 'missing-input' };
- }
-
- let models: AnthropicModelChoice[];
- try {
- models = deps.listModels
- ? await deps.listModels(credentialValue)
- : await fetchAnthropicModels(credentialValue, deps.fetch);
- } catch (error) {
- if (isAnthropicModelAuthenticationError(error)) {
- const statusSuffix = error.status ? ` (HTTP ${error.status})` : '';
- io.stderr.write(`Anthropic API key is invalid or unauthorized${statusSuffix}. Check the key and try again.\n`);
- return { status: 'invalid-credential' };
- }
- io.stderr.write(
- 'Could not fetch live Anthropic models. Showing bundled defaults. Setup will still test the selected model before saving it.\n',
- );
- models = BUNDLED_ANTHROPIC_MODELS;
- }
-
- const selectableModels = models.filter(isSelectableAnthropicModel);
- const prompts = deps.prompts ?? createPromptAdapter();
- const modelOptions = [
- ...selectableModels.map((model) => ({
- value: model.id,
- label: model.label || model.id,
- ...(model.recommended ? { hint: 'recommended' } : {}),
- })),
- { value: 'manual', label: 'Enter a model ID manually' },
- { value: 'back', label: 'Back' },
- ];
- const choice = await prompts.autocomplete({
- message: `Which Anthropic model should KTX use?\n\n${ANTHROPIC_MODEL_PROMPT_CONTEXT}`,
- placeholder: 'Type to search models',
- options: modelOptions,
- });
- if (choice === 'back') {
- return { status: 'back' };
- }
- if (choice === 'manual') {
- const manual = await prompts.text({
- message: withTextInputNavigation('Anthropic model ID'),
- placeholder: selectableModels.find((model) => model.recommended)?.id ?? selectableModels[0]?.id,
- });
- if (manual === undefined) {
- return { status: 'back' };
- }
- return manual.trim() ? { status: 'ready', model: manual.trim() } : { status: 'missing-input' };
- }
- return { status: 'ready', model: choice };
-}
-
-async function chooseVertexModel(args: KtxSetupModelArgs, io: KtxCliIo, deps: KtxSetupModelDeps): Promise {
- const providedModel = requestedModel(args);
- if (providedModel) {
- return { status: 'ready', model: providedModel };
- }
- if (args.inputMode === 'disabled') {
- io.stderr.write('Missing LLM model: pass --llm-model.\n');
- return { status: 'missing-input' };
- }
-
- const selectableModels = VERTEX_ANTHROPIC_MODELS.filter(isSelectableAnthropicModel);
- const prompts = deps.prompts ?? createPromptAdapter();
- const choice = await prompts.autocomplete({
- message: `Which Anthropic model should KTX use?\n\n${ANTHROPIC_MODEL_PROMPT_CONTEXT}`,
- placeholder: 'Type to search models',
- options: [
- ...selectableModels.map((model) => ({
- value: model.id,
- label: model.label || model.id,
- ...(model.recommended ? { hint: 'recommended' } : {}),
- })),
- { value: 'manual', label: 'Enter a model ID manually' },
- { value: 'back', label: 'Back' },
- ],
- });
- if (choice === 'back') {
- return { status: 'back' };
- }
- if (choice === 'manual') {
- const manual = await prompts.text({
- message: withTextInputNavigation('Anthropic model ID'),
- placeholder: selectableModels.find((model) => model.recommended)?.id ?? selectableModels[0]?.id,
- });
- if (manual === undefined) {
- return { status: 'back' };
- }
- return manual.trim() ? { status: 'ready', model: manual.trim() } : { status: 'missing-input' };
- }
- return { status: 'ready', model: choice };
-}
-
-async function chooseClaudeCodeModel(args: KtxSetupModelArgs, deps: KtxSetupModelDeps): Promise {
- const providedModel = requestedModel(args);
- if (providedModel) {
- return { status: 'ready', model: providedModel };
- }
- if (args.inputMode === 'disabled') {
- return { status: 'ready', model: 'sonnet' };
- }
-
- const prompts = deps.prompts ?? createPromptAdapter();
- const choice = await prompts.select({
- message: `Which Claude Code model should KTX use?\n\n${ANTHROPIC_MODEL_PROMPT_CONTEXT}`,
- options: [
- ...CLAUDE_CODE_MODELS.map((model) => ({
- value: model.id,
- label: model.label,
- ...(model.recommended ? { hint: 'recommended' } : {}),
- })),
- { value: 'manual', label: 'Enter a Claude Code model ID manually' },
- { value: 'back', label: 'Back' },
- ],
- });
- if (choice === 'back') {
- return { status: 'back' };
- }
- if (choice === 'manual') {
- const manual = await prompts.text({
- message: withTextInputNavigation('Claude Code model ID'),
- placeholder: CLAUDE_CODE_MODELS.find((model) => model.recommended)?.id ?? CLAUDE_CODE_MODELS[0]?.id,
- });
- if (manual === undefined) {
- return { status: 'back' };
- }
- return manual.trim() ? { status: 'ready', model: manual.trim() } : { status: 'missing-input' };
- }
- return { status: 'ready', model: choice };
-}
-
-async function chooseCodexModel(args: KtxSetupModelArgs, deps: KtxSetupModelDeps): Promise {
- const providedModel = requestedModel(args);
- if (providedModel) {
- return { status: 'ready', model: providedModel };
- }
- if (args.inputMode === 'disabled') {
- return { status: 'ready', model: DEFAULT_CODEX_MODEL };
- }
-
- const prompts = deps.prompts ?? createPromptAdapter();
- const choice = await prompts.select({
- message: `Which Codex model should KTX use?\n\n${ANTHROPIC_MODEL_PROMPT_CONTEXT}`,
- options: [
- ...CODEX_MODELS.map((model) => ({
- value: model.id,
- label: model.label,
- ...(model.recommended ? { hint: 'recommended' } : {}),
- })),
- { value: 'manual', label: 'Enter a Codex model ID manually' },
- { value: 'back', label: 'Back' },
- ],
- });
- if (choice === 'back') {
- return { status: 'back' };
- }
- if (choice === 'manual') {
- const manual = await prompts.text({
- message: withTextInputNavigation('Codex model ID'),
- placeholder: CODEX_MODELS.find((model) => model.recommended)?.id ?? CODEX_MODELS[0]?.id,
- });
- if (manual === undefined) {
- return { status: 'back' };
- }
- return manual.trim() ? { status: 'ready', model: manual.trim() } : { status: 'missing-input' };
- }
- return { status: 'ready', model: choice };
-}
-
async function persistLlmConfig(
projectDir: string,
provider:
@@ -962,12 +724,12 @@ async function persistLlmConfig(
| { backend: 'vertex'; vertex: { project?: string; location: string } }
| { backend: 'claude-code' }
| { backend: 'codex' },
- model: string,
+ models: KtxSetupModelPreset,
): Promise {
const project = await loadKtxProject({ projectDir });
const config = {
...project.config,
- llm: buildProjectLlmConfig(project.config.llm, provider, model),
+ llm: buildProjectLlmConfig(project.config.llm, provider, models),
scan: {
...project.config.scan,
enrichment: {
@@ -990,6 +752,61 @@ function buildInteractiveRetryArgs(args: KtxSetupModelArgs, backend?: KtxSetupLl
};
}
+type PresetModelValidationResult = { ok: true } | { ok: false; message: string };
+
+function distinctPresetModels(preset: KtxSetupModelPreset): string[] {
+ const models: string[] = [];
+ const seen = new Set();
+ for (const role of KTX_MODEL_ROLES) {
+ const model = preset[role];
+ if (!seen.has(model)) {
+ seen.add(model);
+ models.push(model);
+ }
+ }
+ return models;
+}
+
+function rolesUsingModel(preset: KtxSetupModelPreset, model: string): KtxModelRole[] {
+ return KTX_MODEL_ROLES.filter((role) => preset[role] === model);
+}
+
+function formatPresetFallbackWarning(roles: KtxModelRole[], unavailableModel: string, anchorModel: string): string {
+ return `LLM model ${unavailableModel} is unavailable for ${roles.join(', ')}; using ${anchorModel} for those roles.`;
+}
+
+async function validatePresetModels(
+ preset: KtxSetupModelPreset,
+ validateModel: (model: string) => Promise,
+ io: KtxCliIo,
+): Promise<{ status: 'ready'; models: KtxSetupModelPreset } | { status: 'failed'; message: string }> {
+ const anchorModel = preset.default;
+ const degraded = { ...preset };
+ const models = distinctPresetModels(preset);
+
+ const anchorResult = await validateModel(anchorModel);
+ if (!anchorResult.ok) {
+ return { status: 'failed', message: anchorResult.message };
+ }
+
+ for (const model of models) {
+ if (model === anchorModel) {
+ continue;
+ }
+ const result = await validateModel(model);
+ if (result.ok) {
+ continue;
+ }
+ const affectedRoles = rolesUsingModel(degraded, model);
+ for (const role of affectedRoles) {
+ degraded[role] = anchorModel;
+ }
+ io.stderr.write(`${formatPresetFallbackWarning(affectedRoles, model, anchorModel)}\n`);
+ }
+
+ return { status: 'ready', models: degraded };
+}
+
export async function runKtxSetupAnthropicModelStep(
args: KtxSetupModelArgs,
io: KtxCliIo,
@@ -1007,7 +824,6 @@ export async function runKtxSetupAnthropicModelStep(
!args.llmBackend &&
!args.anthropicApiKeyEnv &&
!args.anthropicApiKeyFile &&
- !args.llmModel &&
!args.vertexProject &&
!args.vertexLocation
) {
@@ -1038,94 +854,74 @@ export async function runKtxSetupAnthropicModelStep(
return { status: vertex.status, projectDir: args.projectDir };
}
- const model = await chooseVertexModel(backendArgs, io, deps);
- if (model.status === 'back' && !backendArgs.vertexLocation) {
+ const preset = presetForBackend('vertex');
+ const validation = await validatePresetModels(
+ preset,
+ async (model) =>
+ runLlmHealthCheckWithProgress(
+ buildVertexHealthConfig(vertex.values, model),
+ 'Vertex AI',
+ model,
+ healthCheck,
+ deps,
+ ),
+ io,
+ );
+ if (validation.status !== 'ready') {
+ io.stderr.write(
+ `Vertex AI Anthropic model health check failed: ${formatVertexHealthFailure(validation.message, vertex.values)}\n`,
+ );
+ if (args.inputMode === 'disabled') {
+ return { status: 'failed', projectDir: args.projectDir };
+ }
+ io.stderr.write('Choose a different Vertex AI project or location, or Back.\n');
attemptArgs = buildInteractiveRetryArgs(args, backendChoice.backend);
continue;
}
- if (model.status === 'invalid-credential') {
- return { status: 'failed', projectDir: args.projectDir };
- }
- if (model.status !== 'ready') {
- return { status: model.status, projectDir: args.projectDir };
- }
- const health = await runLlmHealthCheckWithProgress(
- buildVertexHealthConfig(vertex.values, model.model),
- 'Vertex AI',
- model.model,
- healthCheck,
- deps,
- );
- if (health.ok) {
- await persistLlmConfig(args.projectDir, { backend: 'vertex', vertex: vertex.refs }, model.model);
- io.stdout.write(`│ LLM ready: yes (${model.model})\n`);
- return { status: 'ready', projectDir: args.projectDir };
- }
-
- io.stderr.write(`Vertex AI Anthropic model health check failed: ${formatVertexHealthFailure(health.message, vertex.values)}\n`);
- if (args.inputMode === 'disabled') {
- return { status: 'failed', projectDir: args.projectDir };
- }
- io.stderr.write('Choose a different Vertex AI project, location, or model, or Back.\n');
- attemptArgs = buildInteractiveRetryArgs(args, backendChoice.backend);
- continue;
+ await persistLlmConfig(args.projectDir, { backend: 'vertex', vertex: vertex.refs }, validation.models);
+ io.stdout.write(`│ LLM ready: yes (${validation.models.default})\n`);
+ return { status: 'ready', projectDir: args.projectDir };
}
if (backendChoice.backend === 'claude-code') {
- const model = await chooseClaudeCodeModel(backendArgs, deps);
- if (model.status === 'back' && backendChoice.prompted) {
- attemptArgs = buildInteractiveRetryArgs(args);
- continue;
- }
- if (model.status === 'invalid-credential') {
- return { status: 'failed', projectDir: args.projectDir };
- }
- if (model.status !== 'ready') {
- return { status: model.status, projectDir: args.projectDir };
- }
+ const preset = presetForBackend('claude-code');
const probe = deps.claudeCodeAuthProbe ?? runClaudeCodeAuthProbe;
- const health = await probe({ projectDir: args.projectDir, model: model.model, env: deps.env ?? process.env });
- if (!health.ok) {
- io.stderr.write(`${health.message}\n`);
+ const validation = await validatePresetModels(
+ preset,
+ async (model) => probe({ projectDir: args.projectDir, model, env: deps.env ?? process.env }),
+ io,
+ );
+ if (validation.status !== 'ready') {
+ io.stderr.write(`${validation.message}\n`);
return { status: 'failed', projectDir: args.projectDir };
}
const warning = formatClaudeCodePromptCachingWarning(
ignoredClaudeCodePromptCachingFields(
- buildProjectLlmConfig(project.config.llm, { backend: 'claude-code' }, model.model),
+ buildProjectLlmConfig(project.config.llm, { backend: 'claude-code' }, validation.models),
),
);
if (warning) {
io.stderr.write(`${warning}\n`);
}
- await persistLlmConfig(args.projectDir, { backend: 'claude-code' }, model.model);
- io.stdout.write(`│ LLM ready: yes (${model.model})\n`);
+ await persistLlmConfig(args.projectDir, { backend: 'claude-code' }, validation.models);
+ io.stdout.write(`│ LLM ready: yes (${validation.models.default})\n`);
return { status: 'ready', projectDir: args.projectDir };
}
if (backendChoice.backend === 'codex') {
- const model = await chooseCodexModel(backendArgs, deps);
- if (model.status === 'back' && backendChoice.prompted) {
- attemptArgs = buildInteractiveRetryArgs(args);
- continue;
- }
- if (model.status === 'invalid-credential') {
- return { status: 'failed', projectDir: args.projectDir };
- }
- if (model.status !== 'ready') {
- return { status: model.status, projectDir: args.projectDir };
- }
+ const preset = presetForBackend('codex');
const probe = deps.codexAuthProbe ?? runCodexAuthProbe;
- const health = await probe({ projectDir: args.projectDir, model: model.model });
- if (!health.ok) {
- io.stderr.write(`${health.message}\n`);
+ const validation = await validatePresetModels(preset, async (model) => probe({ projectDir: args.projectDir, model }), io);
+ if (validation.status !== 'ready') {
+ io.stderr.write(`${validation.message}\n`);
return { status: 'failed', projectDir: args.projectDir };
}
// Prefix the clack gutter so the warning sits inside the setup frame
// instead of breaking out of it; kept on stderr for scripted runs.
io.stderr.write(`│ ${formatCodexIsolationWarning()}\n`);
- await persistLlmConfig(args.projectDir, { backend: 'codex' }, model.model);
- io.stdout.write(`│ LLM ready: yes (codex, ${model.model})\n`);
+ await persistLlmConfig(args.projectDir, { backend: 'codex' }, validation.models);
+ io.stdout.write(`│ LLM ready: yes (codex, ${validation.models.default})\n`);
return { status: 'ready', projectDir: args.projectDir };
}
@@ -1138,8 +934,21 @@ export async function runKtxSetupAnthropicModelStep(
return { status: credential.status, projectDir: args.projectDir };
}
- const model = await chooseModel(backendArgs, credential.value, io, deps);
- if (model.status === 'invalid-credential') {
+ const preset = presetForBackend('anthropic');
+ const validation = await validatePresetModels(
+ preset,
+ async (model) =>
+ runLlmHealthCheckWithProgress(
+ buildAnthropicHealthConfig(credential.value, model),
+ 'Anthropic API',
+ model,
+ healthCheck,
+ deps,
+ ),
+ io,
+ );
+ if (validation.status !== 'ready') {
+ io.stderr.write(`Anthropic model health check failed: ${validation.message}\n`);
if (args.inputMode === 'disabled') {
return { status: 'failed', projectDir: args.projectDir };
}
@@ -1147,32 +956,9 @@ export async function runKtxSetupAnthropicModelStep(
attemptArgs = buildInteractiveRetryArgs(args, backendChoice.backend);
continue;
}
- if (model.status === 'back' && !backendArgs.anthropicApiKeyEnv && !backendArgs.anthropicApiKeyFile) {
- attemptArgs = buildInteractiveRetryArgs(args, backendChoice.backend);
- continue;
- }
- if (model.status !== 'ready') {
- return { status: model.status, projectDir: args.projectDir };
- }
- const health = await runLlmHealthCheckWithProgress(
- buildAnthropicHealthConfig(credential.value, model.model),
- 'Anthropic API',
- model.model,
- healthCheck,
- deps,
- );
- if (health.ok) {
- await persistLlmConfig(args.projectDir, { backend: 'anthropic', credentialRef: credential.ref }, model.model);
- io.stdout.write(`│ LLM ready: yes (${model.model})\n`);
- return { status: 'ready', projectDir: args.projectDir };
- }
-
- io.stderr.write(`Anthropic model health check failed: ${health.message}\n`);
- if (args.inputMode === 'disabled') {
- return { status: 'failed', projectDir: args.projectDir };
- }
- io.stderr.write('Choose a different credential source or model, or Back.\n');
- attemptArgs = buildInteractiveRetryArgs(args, backendChoice.backend);
+ await persistLlmConfig(args.projectDir, { backend: 'anthropic', credentialRef: credential.ref }, validation.models);
+ io.stdout.write(`│ LLM ready: yes (${validation.models.default})\n`);
+ return { status: 'ready', projectDir: args.projectDir };
}
}
diff --git a/packages/cli/src/setup-prompts.ts b/packages/cli/src/setup-prompts.ts
index 1609bd76..e135004f 100644
--- a/packages/cli/src/setup-prompts.ts
+++ b/packages/cli/src/setup-prompts.ts
@@ -1,4 +1,3 @@
-import type { Writable } from 'node:stream';
import {
autocomplete,
autocompleteMultiselect,
@@ -9,12 +8,13 @@ import {
log,
multiselect,
note,
- password,
select,
text,
} from '@clack/prompts';
import type { KtxCliIo } from './cli-runtime.js';
+import { isWritableTtyOutput } from './io/tty.js';
import { withMenuOptionsSpacing, withTextInputNavigation } from './prompt-navigation.js';
+import { revealPassword } from './reveal-password-prompt.js';
import { withSetupInterruptConfirmation } from './setup-interrupt.js';
export interface KtxSetupPromptOption {
@@ -189,7 +189,7 @@ export function createKtxSetupPromptAdapter(options: KtxSetupPromptAdapterOption
},
async password(promptOptions) {
const value = await withSetupInterruptConfirmation(() =>
- password({ ...promptOptions, message: withTextInputNavigation(promptOptions.message) }),
+ revealPassword({ ...promptOptions, message: withTextInputNavigation(promptOptions.message) }),
);
return isCancel(value) ? undefined : String(value);
},
@@ -211,14 +211,6 @@ export interface KtxSetupUiAdapter {
note(message: string, title: string, io: KtxCliIo, options?: KtxSetupNoteOptions): void;
}
-function isWritableTtyOutput(output: KtxCliIo['stdout']): output is KtxCliIo['stdout'] & Writable {
- return (
- output.isTTY === true &&
- typeof (output as { on?: unknown }).on === 'function' &&
- typeof (output as { columns?: unknown }).columns !== 'undefined'
- );
-}
-
export function createKtxSetupUiAdapter(): KtxSetupUiAdapter {
return {
intro(title, io) {
diff --git a/packages/cli/src/setup-sources.ts b/packages/cli/src/setup-sources.ts
index 0a66c3a7..25552fbf 100644
--- a/packages/cli/src/setup-sources.ts
+++ b/packages/cli/src/setup-sources.ts
@@ -119,11 +119,11 @@ export interface KtxSetupSourcesDeps {
const SOURCE_OPTIONS: Array<{ value: KtxSetupSourceType; label: string }> = [
{ value: 'dbt', label: 'dbt' },
- { value: 'metricflow', label: 'MetricFlow' },
{ value: 'metabase', label: 'Metabase' },
+ { value: 'notion', label: 'Notion' },
+ { value: 'metricflow', label: 'MetricFlow' },
{ value: 'looker', label: 'Looker' },
{ value: 'lookml', label: 'LookML' },
- { value: 'notion', label: 'Notion' },
];
const SOURCE_LABELS = Object.fromEntries(SOURCE_OPTIONS.map((option) => [option.value, option.label])) as Record<
@@ -269,8 +269,8 @@ async function chooseSourceCredentialRef(input: {
message: `How should KTX find your ${input.label}?`,
options: [
...(input.existingRef ? [{ value: 'keep', label: 'Keep existing credential' }] : []),
- { value: 'env', label: `Use ${input.envName} from the environment` },
{ value: 'paste', label: 'Paste a key and save it as a local secret file' },
+ { value: 'env', label: `Use ${input.envName} from the environment` },
{ value: 'back', label: 'Back' },
],
});
@@ -307,8 +307,8 @@ async function chooseGitAuthCredentialRef(input: {
message: `${label} repo requires authentication.`,
options: [
...(input.existingRef ? [{ value: 'keep', label: 'Keep existing credential' }] : []),
- { value: 'env', label: 'Use GITHUB_TOKEN from the environment' },
{ value: 'paste', label: 'Paste a token and save it as a local secret file' },
+ { value: 'env', label: 'Use GITHUB_TOKEN from the environment' },
{ value: 'skip', label: 'Skip — try without authentication' },
{ value: 'back', label: 'Back' },
],
@@ -1063,8 +1063,8 @@ async function promptForInteractiveSource(
const selectedLocation = await prompts.select({
message: `${source} source location`,
options: [
- { value: 'path', label: 'Local path' },
{ value: 'git', label: 'Git URL' },
+ { value: 'path', label: 'Local path' },
{ value: 'back', label: 'Back' },
],
});
@@ -1343,8 +1343,8 @@ async function promptForInteractiveSource(
const crawlMode = await prompts.select({
message: 'Which Notion pages should KTX ingest?',
options: [
- { value: 'selected_roots', label: 'Specific pages and their subpages (choose them in a picker)' },
{ value: 'all_accessible', label: 'All pages the integration can access' },
+ { value: 'selected_roots', label: 'Specific pages and their subpages (choose them in a picker)' },
{ value: 'back', label: 'Back' },
],
});
@@ -2064,7 +2064,7 @@ export async function runKtxSetupSourcesStep(
const addMore = await prompts.select({
message: `${readyConnectionIds.length} context source${readyConnectionIds.length > 1 ? 's' : ''} configured (${readyConnectionIds.join(', ')}). Add another?`,
options: [
- { value: 'done', label: 'Done — continue to context build' },
+ { value: 'done', label: 'Done adding context sources' },
{ value: 'edit', label: 'Edit an existing context source' },
{ value: 'add', label: 'Add another context source' },
],
diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts
index a991367e..2208d2b4 100644
--- a/packages/cli/src/setup.ts
+++ b/packages/cli/src/setup.ts
@@ -6,11 +6,13 @@ import { ktxLocalStateDbPath } from './context/project/local-state-db.js';
import { loadKtxProject, type KtxLocalProject } from './context/project/project.js';
import { readKtxSetupState } from './context/project/setup-config.js';
import { getKtxCliPackageInfo, type KtxCliIo } from './cli-runtime.js';
+import { SLACK_SETUP_NOTE } from './community-cta.js';
import { formatNextStepLines, formatSetupNextStepLines } from './next-steps.js';
import { runtimeInstallPolicyFromFlags } from './managed-python-command.js';
import { readManagedPythonRuntimeStatus } from './managed-python-runtime.js';
import { resolveProjectRuntimeRequirements } from './runtime-requirements.js';
import { isKtxSetupExitError } from './setup-interrupt.js';
+import type { CommandOutcome } from './telemetry/index.js';
import {
type KtxAgentScope,
type KtxAgentTarget,
@@ -80,12 +82,12 @@ export type KtxSetupArgs =
agentScope?: KtxAgentScope;
skipAgents?: boolean;
inputMode: 'auto' | 'disabled';
+ debug?: boolean;
yes: boolean;
cliVersion: string;
llmBackend?: KtxSetupLlmBackend;
anthropicApiKeyEnv?: string;
anthropicApiKeyFile?: string;
- llmModel?: string;
vertexProject?: string;
vertexLocation?: string;
skipLlm: boolean;
@@ -210,6 +212,80 @@ function setupTelemetryOutcome(
return 'abandoned';
}
+interface SetupCommandAnnotation {
+ outcome: CommandOutcome;
+ errorClass?: string;
+ errorDetail?: string;
+}
+
+/**
+ * Classify a terminal non-ready setup status into the `command` telemetry
+ * outcome. The setup flow is the decision-maker and knows the difference:
+ * - `failed` is a genuine error; attach a step-scoped reason so the dashboard
+ * shows an actionable signature instead of a blank.
+ * - `missing-input` from a *non-interactive* run is an automation error
+ * (required flags absent and no prompt was possible); attach a reason too.
+ * - `missing-input` from an interactive prompt, or a project `cancelled`, is the
+ * user backing out of the wizard — an abort, not a failure. Keep it out of
+ * error telemetry so it stops inflating the error count.
+ *
+ * `interactive` must reflect whether a prompt could actually be shown — input
+ * is enabled AND a TTY is attached. `inputMode: 'auto'` alone is not enough: a
+ * piped/CI run without `--no-input` is still non-interactive, and steps such as
+ * the project step return `missing-input` ("pass --yes …") there without ever
+ * prompting. Treating that as an abort would make a broken automation run exit
+ * 0, so it must classify as an error.
+ *
+ * Reasons are synthetic, step-scoped strings (no user input), so they satisfy
+ * the telemetry privacy rules. The step's own `errorDetail`, when present, has
+ * already been vetted for the `setup_step` event and is safe to reuse.
+ */
+function setupCommandOutcomeAnnotation(input: {
+ status: 'failed' | 'missing-input' | 'cancelled';
+ step: TelemetrySetupStep;
+ interactive: boolean;
+ errorDetail?: string;
+}): SetupCommandAnnotation {
+ if (input.status === 'failed') {
+ return {
+ outcome: 'error',
+ errorClass: 'KtxSetupStepFailed',
+ errorDetail: input.errorDetail ?? `${input.step} setup step failed`,
+ };
+ }
+ if (input.status === 'missing-input' && !input.interactive) {
+ return {
+ outcome: 'error',
+ errorClass: 'KtxSetupMissingInput',
+ errorDetail: `${input.step} setup step requires input not provided in a non-interactive run`,
+ };
+ }
+ return { outcome: 'aborted' };
+}
+
+/**
+ * Single source of truth for how a non-ready setup step ends: the process exit
+ * code and the telemetry annotation are both derived from one classification,
+ * so they can never disagree. A genuine failure (`error`) exits non-zero; an
+ * abort — the user leaving an interactive wizard — exits 0, matching the entry
+ * menu's "Exit", a project cancellation, and a confirmed Ctrl+C.
+ */
+/** @internal */
+export function setupTerminalOutcome(input: {
+ status: 'failed' | 'missing-input' | 'cancelled';
+ step: TelemetrySetupStep;
+ interactive: boolean;
+ errorDetail?: string;
+}): { exitCode: number; annotation: SetupCommandAnnotation } {
+ const annotation = setupCommandOutcomeAnnotation(input);
+ return { exitCode: annotation.outcome === 'error' ? 1 : 0, annotation };
+}
+
+async function annotateSetupCommandOutcome(annotation: SetupCommandAnnotation): Promise {
+ const { annotateCommandOutcome } = await import('./telemetry/index.js');
+ annotateCommandOutcome(annotation);
+}
+
async function recordSetupStep(input: {
projectDir: string;
step: TelemetrySetupStep;
@@ -572,6 +648,10 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
args.inputMode !== 'disabled' &&
!args.agents &&
(io.stdout.isTTY === true || deps.entryMenuDeps?.prompts !== undefined);
+ // A prompt is only possible when input is enabled AND a TTY is attached. A
+ // piped/CI `ktx setup` without `--no-input` is still `inputMode: 'auto'` but
+ // cannot prompt, so its `missing-input` is an automation error, not an abort.
+ const interactive = args.inputMode !== 'disabled' && io.stdout.isTTY === true;
setupLoop: while (true) {
entryAction = undefined;
@@ -618,7 +698,13 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
}
if (projectResult.status !== 'ready') {
- return projectResult.status === 'cancelled' ? 0 : 1;
+ const terminal = setupTerminalOutcome({
+ status: projectResult.status,
+ step: 'project',
+ interactive,
+ });
+ await annotateSetupCommandOutcome(terminal.annotation);
+ return terminal.exitCode;
}
const agentsRequested = args.agents || entryAction === 'agents';
@@ -699,7 +785,6 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
...(args.llmBackend ? { llmBackend: args.llmBackend } : {}),
...(args.anthropicApiKeyEnv ? { anthropicApiKeyEnv: args.anthropicApiKeyEnv } : {}),
...(args.anthropicApiKeyFile ? { anthropicApiKeyFile: args.anthropicApiKeyFile } : {}),
- ...(args.llmModel ? { llmModel: args.llmModel } : {}),
...(args.vertexProject ? { vertexProject: args.vertexProject } : {}),
...(args.vertexLocation ? { vertexLocation: args.vertexLocation } : {}),
forcePrompt: forcePromptSteps.has('models') || runOnly === 'models',
@@ -735,6 +820,7 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
{
projectDir: projectResult.projectDir,
inputMode: args.inputMode,
+ ...(args.debug !== undefined ? { debug: args.debug } : {}),
yes: args.yes,
cliVersion: args.cliVersion,
runtimeInstallPolicy: setupRuntimeInstallPolicy(args),
@@ -855,11 +941,15 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
...(stepResult.errorDetail ? { errorDetail: stepResult.errorDetail } : {}),
});
- if (stepResult.status === 'failed') {
- return 1;
- }
- if (stepResult.status === 'missing-input') {
- return 1;
+ if (stepResult.status === 'failed' || stepResult.status === 'missing-input') {
+ const terminal = setupTerminalOutcome({
+ status: stepResult.status,
+ step,
+ interactive,
+ ...(stepResult.errorDetail ? { errorDetail: stepResult.errorDetail } : {}),
+ });
+ await annotateSetupCommandOutcome(terminal.annotation);
+ return terminal.exitCode;
}
if (stepResult.status === 'back') {
const previousIndex = previousNavigableStepIndex(stepIndex);
@@ -921,5 +1011,6 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
);
}
}
+ setupUi.note(SLACK_SETUP_NOTE.body, SLACK_SETUP_NOTE.title, io);
return 0;
}
diff --git a/packages/cli/src/sl.ts b/packages/cli/src/sl.ts
index f3eeb33e..6c849265 100644
--- a/packages/cli/src/sl.ts
+++ b/packages/cli/src/sl.ts
@@ -1,6 +1,5 @@
import { readFile } from 'node:fs/promises';
import type { KtxCliIo } from './cli-runtime.js';
-import { createDefaultLocalQueryExecutor } from './context/connections/local-query-executor.js';
import type { KtxSqlQueryExecutorPort } from './context/connections/query-executor.js';
import { KtxIngestEmbeddingPortAdapter } from './context/llm/embedding-port.js';
import type { KtxEmbeddingPort } from './context/core/embedding.js';
@@ -20,13 +19,15 @@ import {
resolveProjectEmbeddingProvider,
type EmbeddingProviderResolution,
} from './embedding-resolution.js';
+import { createKtxCliIngestQueryExecutor } from './ingest-query-executor.js';
import type { PrintListColumn } from './io/print-list.js';
import {
createManagedPythonSemanticLayerComputePort,
type KtxManagedPythonInstallPolicy,
} from './managed-python-command.js';
import { profileMark } from './startup-profile.js';
-import { emitTelemetryEvent } from './telemetry/index.js';
+import { emitTelemetryEvent, reportException } from './telemetry/index.js';
+import { collectTelemetryRedactionSecrets } from './telemetry/redaction-secrets.js';
import { scrubErrorClass } from './telemetry/scrubber.js';
profileMark('module:sl');
@@ -80,7 +81,7 @@ interface KtxSlDeps {
io: KtxSlIo;
projectDir?: string;
}) => Promise;
- createQueryExecutor?: () => KtxSqlQueryExecutorPort;
+ createQueryExecutor?: (project: KtxLocalProject) => KtxSqlQueryExecutorPort;
}
function resolutionToEmbeddingPort(resolution: EmbeddingProviderResolution): KtxEmbeddingPort | null {
@@ -202,8 +203,9 @@ function ambiguousSourceMessage(sourceName: string, connectionIds: readonly stri
export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: KtxSlDeps = {}): Promise {
const startedAt = performance.now();
let queryForTelemetry: SemanticLayerQueryInput | undefined;
+ let project: KtxLocalProject | undefined;
try {
- const project = await (deps.loadProject ?? loadKtxProject)({ projectDir: args.projectDir });
+ project = await (deps.loadProject ?? loadKtxProject)({ projectDir: args.projectDir });
if (args.command === 'list') {
const sources = await listLocalSlSources(project, { connectionId: args.connectionId });
await printSlSources({
@@ -319,8 +321,8 @@ export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: Ktx
io,
projectDir: args.projectDir,
});
- const queryExecutor = args.execute ? (deps.createQueryExecutor ?? createDefaultLocalQueryExecutor)() : undefined;
- const result = await compileLocalSlQuery(project as KtxLocalProject, {
+ const queryExecutor = args.execute ? (deps.createQueryExecutor ?? createKtxCliIngestQueryExecutor)(project) : undefined;
+ const result = await compileLocalSlQuery(project, {
connectionId: args.connectionId,
query,
compute,
@@ -351,6 +353,20 @@ export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: Ktx
const _exhaustive: never = args;
throw new Error(`Unsupported sl command: ${JSON.stringify(_exhaustive)}`);
} catch (error) {
+ await reportException({
+ error,
+ context: { source: `sl ${args.command}`, handled: true, fatal: false },
+ projectDir: args.projectDir,
+ io,
+ redactionSecrets: await collectTelemetryRedactionSecrets({
+ project,
+ projectDir: args.projectDir,
+ connectionId: args.connectionId,
+ includeLlm: args.command === 'query',
+ includeEmbeddings: args.command === 'search' || args.command === 'query',
+ env: process.env,
+ }),
+ });
if (args.command === 'validate') {
const errorClass = scrubErrorClass(error);
await emitTelemetryEvent({
diff --git a/packages/cli/src/sql.ts b/packages/cli/src/sql.ts
index bfae0608..d3eb6a81 100644
--- a/packages/cli/src/sql.ts
+++ b/packages/cli/src/sql.ts
@@ -7,7 +7,8 @@ import { createKtxCliScanConnector } from './local-scan-connectors.js';
import { createManagedDaemonSqlAnalysisPort } from './managed-python-http.js';
import { profileMark } from './startup-profile.js';
import { isDemoConnection } from './telemetry/demo-detect.js';
-import { emitTelemetryEvent } from './telemetry/index.js';
+import { emitTelemetryEvent, reportException } from './telemetry/index.js';
+import { collectTelemetryRedactionSecrets } from './telemetry/redaction-secrets.js';
import { scrubErrorClass } from './telemetry/scrubber.js';
profileMark('module:sql');
@@ -142,8 +143,9 @@ export async function runKtxSql(args: KtxSqlArgs, io: KtxCliIo = process, deps:
const startedAt = performance.now();
let driver = 'unknown';
let demoConnection = false;
+ let project: KtxLocalProject | undefined;
try {
- const project = await (deps.loadProject ?? loadKtxProject)({ projectDir: args.projectDir });
+ project = await (deps.loadProject ?? loadKtxProject)({ projectDir: args.projectDir });
const connection = project.config.connections[args.connectionId];
if (!connection) {
throw new Error(`Connection "${args.connectionId}" is not configured in ktx.yaml`);
@@ -171,7 +173,7 @@ export async function runKtxSql(args: KtxSqlArgs, io: KtxCliIo = process, deps:
const createScanConnector = deps.createScanConnector ?? createKtxCliScanConnector;
let connector: KtxScanConnector | null = null;
try {
- connector = await createScanConnector(project as KtxLocalProject, args.connectionId);
+ connector = await createScanConnector(project, args.connectionId);
if (!connector.capabilities.readOnlySql || !connector.executeReadOnly) {
throw new Error(`Connection "${args.connectionId}" does not support read-only SQL execution.`);
}
@@ -218,6 +220,20 @@ export async function runKtxSql(args: KtxSqlArgs, io: KtxCliIo = process, deps:
...(errorClass ? { errorClass } : {}),
},
});
+ await reportException({
+ error,
+ context: { source: 'sql run', handled: true, fatal: false },
+ projectDir: args.projectDir,
+ io,
+ redactionSecrets: await collectTelemetryRedactionSecrets({
+ project,
+ projectDir: args.projectDir,
+ connectionId: args.connectionId,
+ includeLlm: false,
+ includeEmbeddings: false,
+ env: process.env,
+ }),
+ });
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 1;
}
diff --git a/packages/cli/src/star-prompt/cache.ts b/packages/cli/src/star-prompt/cache.ts
new file mode 100644
index 00000000..8bb6faf5
--- /dev/null
+++ b/packages/cli/src/star-prompt/cache.ts
@@ -0,0 +1,50 @@
+import { readFileSync, renameSync, writeFileSync } from 'node:fs';
+import { mkdir } from 'node:fs/promises';
+import { homedir } from 'node:os';
+import { dirname, join } from 'node:path';
+import { z } from 'zod';
+
+const starCountCacheSchema = z
+ .object({
+ count: z.number().int().nonnegative(),
+ fetchedAt: z.string(),
+ })
+ .strict();
+
+export type StarCountCache = z.infer;
+
+/** @internal */
+export function starCountCachePath(homeDir = homedir()): string {
+ return join(homeDir, '.ktx', 'star-count.json');
+}
+
+export function readStarCountCache(options: { homeDir?: string } = {}): StarCountCache | null {
+ try {
+ return starCountCacheSchema.parse(JSON.parse(readFileSync(starCountCachePath(options.homeDir), 'utf-8')));
+ } catch {
+ return null;
+ }
+}
+
+export async function writeStarCountCache(value: StarCountCache, options: { homeDir?: string } = {}): Promise {
+ try {
+ const path = starCountCachePath(options.homeDir);
+ await mkdir(dirname(path), { recursive: true });
+ const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
+ writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf-8');
+ renameSync(tempPath, path);
+ } catch {
+ return;
+ }
+}
+
+export function isFreshStarCountCache(cache: StarCountCache | null, now: Date, ttlMs: number): boolean {
+ if (!cache) {
+ return false;
+ }
+ const fetchedAtMs = Date.parse(cache.fetchedAt);
+ if (Number.isNaN(fetchedAtMs)) {
+ return false;
+ }
+ return now.getTime() - fetchedAtMs < ttlMs;
+}
diff --git a/packages/cli/src/star-prompt/star-count.ts b/packages/cli/src/star-prompt/star-count.ts
new file mode 100644
index 00000000..9292a0b0
--- /dev/null
+++ b/packages/cli/src/star-prompt/star-count.ts
@@ -0,0 +1,76 @@
+import { request as httpsRequest } from 'node:https';
+import { URL } from 'node:url';
+import { z } from 'zod';
+
+const GITHUB_REPO_URL = new URL('https://api.github.com/repos/Kaelio/ktx');
+const DEFAULT_TIMEOUT_MS = 5000;
+const githubRepoSchema = z.object({
+ stargazers_count: z.number().int().nonnegative(),
+});
+
+type HttpsRequest = typeof httpsRequest;
+
+function parseStarCount(raw: string): number {
+ return githubRepoSchema.parse(JSON.parse(raw)).stargazers_count;
+}
+
+export function fetchGitHubStarCount(options: { request?: HttpsRequest; timeoutMs?: number } = {}): Promise {
+ const requestImpl = options.request ?? httpsRequest;
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
+
+ return new Promise((resolve) => {
+ let settled = false;
+ const finish = (count: number | null): void => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ resolve(count);
+ };
+
+ try {
+ const request = requestImpl(
+ GITHUB_REPO_URL,
+ {
+ method: 'GET',
+ headers: {
+ accept: 'application/vnd.github+json',
+ 'user-agent': 'ktx-star-prompt',
+ },
+ },
+ (response) => {
+ const chunks: Buffer[] = [];
+ response.on('data', (chunk: Buffer | string) => {
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
+ });
+ response.on('end', () => {
+ const statusCode = response.statusCode ?? 0;
+ if (statusCode < 200 || statusCode >= 300) {
+ finish(null);
+ return;
+ }
+ try {
+ finish(parseStarCount(Buffer.concat(chunks).toString('utf8')));
+ } catch {
+ finish(null);
+ }
+ });
+ },
+ );
+
+ request.on('socket', (socket) => {
+ socket.unref();
+ });
+ request.on('error', () => {
+ finish(null);
+ });
+ request.setTimeout(timeoutMs, () => {
+ request.destroy(new Error('GitHub star count request timed out'));
+ finish(null);
+ });
+ request.end();
+ } catch {
+ finish(null);
+ }
+ });
+}
diff --git a/packages/cli/src/star-prompt/star-line.ts b/packages/cli/src/star-prompt/star-line.ts
new file mode 100644
index 00000000..ef1070a2
--- /dev/null
+++ b/packages/cli/src/star-prompt/star-line.ts
@@ -0,0 +1,42 @@
+import { SYMBOLS } from '../io/symbols.js';
+
+const STAR_PROMPT_URL = 'github.com/Kaelio/ktx';
+const STAR_PROMPT_TEXT = 'This takes a few minutes - mind giving ktx a star while you wait?';
+
+interface StarPromptSymbols {
+ star: string;
+ middot: string;
+ rightArrow: string;
+}
+
+export interface RenderStarPromptLineOptions {
+ columns: number;
+ count?: number | null;
+ symbols?: StarPromptSymbols;
+}
+
+function usableColumns(columns: number): number {
+ return Number.isFinite(columns) && columns > 0 ? Math.floor(columns) : 80;
+}
+
+function starCountSegment(count: number | null | undefined, symbols: StarPromptSymbols): string {
+ if (typeof count !== 'number' || !Number.isFinite(count)) {
+ return '';
+ }
+ const formatted = new Intl.NumberFormat('en-US').format(count);
+ return ` ${symbols.middot} ${formatted} ${symbols.star}`;
+}
+
+export function renderStarPromptLine(options: RenderStarPromptLineOptions): string {
+ const symbols = options.symbols ?? SYMBOLS;
+ const columns = usableColumns(options.columns);
+ const base = ` ${symbols.star} ${STAR_PROMPT_TEXT} ${STAR_PROMPT_URL}`;
+ const withCount = `${base}${starCountSegment(options.count, symbols)}`;
+ if (withCount.length <= columns) {
+ return withCount;
+ }
+ if (base.length <= columns) {
+ return base;
+ }
+ return ` ${symbols.star} Star ktx ${symbols.rightArrow} ${STAR_PROMPT_URL}`;
+}
diff --git a/packages/cli/src/telemetry/command-hook.ts b/packages/cli/src/telemetry/command-hook.ts
index 99f8723e..5113c7fa 100644
--- a/packages/cli/src/telemetry/command-hook.ts
+++ b/packages/cli/src/telemetry/command-hook.ts
@@ -9,6 +9,9 @@ interface CommandSpan {
hasProject: boolean;
attachProjectGroup: boolean;
startedAt: number;
+ annotatedOutcome?: CommandOutcome;
+ annotatedErrorClass?: string;
+ annotatedErrorDetail?: string;
}
export interface CompletedCommandSpan {
@@ -29,6 +32,41 @@ export function beginCommandSpan(input: CommandSpan): void {
activeCommandSpan = input;
}
+/**
+ * Let a command action record the true outcome and reason on the active span.
+ *
+ * The Commander wrapper can only derive an outcome from a thrown error or the
+ * process exit code, so a command that exits non-zero *without throwing* (e.g.
+ * `ktx setup` when the user abandons the wizard) lands as `outcome: 'error'`
+ * with no `errorClass`/`errorDetail` — an unactionable blank in the dashboard.
+ * The action is the decision-maker: it can mark the run `aborted`, or attach a
+ * scrubbed reason so the next occurrence is self-diagnosing. A later thrown
+ * error still wins (see {@link completeCommandSpan}), since that is the most
+ * authoritative signal and also feeds the `$exception` stream. No-ops when no
+ * span is active so call sites stay safe in tests and bare-help paths.
+ *
+ * Values are emitted verbatim and must already satisfy the telemetry privacy
+ * rules — pass synthetic or already-scrubbed strings, never raw user input.
+ */
+export function annotateCommandOutcome(input: {
+ outcome?: CommandOutcome;
+ errorClass?: string;
+ errorDetail?: string;
+}): void {
+ if (!activeCommandSpan) {
+ return;
+ }
+ if (input.outcome !== undefined) {
+ activeCommandSpan.annotatedOutcome = input.outcome;
+ }
+ if (input.errorClass !== undefined) {
+ activeCommandSpan.annotatedErrorClass = input.errorClass;
+ }
+ if (input.errorDetail !== undefined) {
+ activeCommandSpan.annotatedErrorDetail = input.errorDetail;
+ }
+}
+
export function completeCommandSpan(input: {
completedAt: number;
outcome: CommandOutcome;
@@ -40,13 +78,17 @@ export function completeCommandSpan(input: {
return undefined;
}
- const errorClass = input.error ? scrubErrorClass(input.error) : undefined;
- const errorDetail = input.error ? formatErrorDetail(input.error) : undefined;
+ // Precedence: a thrown error is authoritative; otherwise an action's own
+ // annotation; otherwise the wrapper's exit-code-derived outcome.
+ const thrown = Boolean(input.error);
+ const outcome = thrown ? input.outcome : (span.annotatedOutcome ?? input.outcome);
+ const errorClass = thrown ? scrubErrorClass(input.error) : span.annotatedErrorClass;
+ const errorDetail = thrown ? formatErrorDetail(input.error) : span.annotatedErrorDetail;
return {
commandPath: span.commandPath,
durationMs: Math.max(0, input.completedAt - span.startedAt),
- outcome: input.outcome,
+ outcome,
...(errorClass ? { errorClass } : {}),
...(errorDetail ? { errorDetail } : {}),
flagsPresent: span.flagsPresent,
diff --git a/packages/cli/src/telemetry/emitter.ts b/packages/cli/src/telemetry/emitter.ts
index 3344e00b..12453262 100644
--- a/packages/cli/src/telemetry/emitter.ts
+++ b/packages/cli/src/telemetry/emitter.ts
@@ -16,6 +16,16 @@ type PostHogClient = {
properties: Record;
groups?: Record;
}): void;
+ captureException(
+ error: unknown,
+ distinctId?: string,
+ additionalProperties?: Record,
+ ): void;
+ captureExceptionImmediate(
+ error: unknown,
+ distinctId?: string,
+ additionalProperties?: Record,
+ ): Promise;
shutdown(): Promise | void;
};
@@ -105,6 +115,57 @@ export async function trackTelemetryEvent(input: {
}
}
+function writeDebugExceptionPayload(input: {
+ error: Error;
+ distinctId: string;
+ properties: Record;
+ stderr: TelemetrySink;
+}): void {
+ input.stderr.write(
+ `[telemetry-exception] ${JSON.stringify({
+ distinctId: input.distinctId,
+ message: input.error.message,
+ name: input.error.name,
+ properties: input.properties,
+ })}\n`,
+ );
+}
+
+export async function trackTelemetryException(input: {
+ error: Error;
+ distinctId: string;
+ properties: Record;
+ env?: TelemetryEmitterEnv;
+ stderr: TelemetrySink;
+ projectApiKey?: string;
+ host?: string;
+ immediate?: boolean;
+}): Promise {
+ const env = input.env ?? process.env;
+
+ if (debugEnabled(env)) {
+ writeDebugExceptionPayload(input);
+ return;
+ }
+
+ const projectApiKey = telemetryProjectApiKey(input.projectApiKey);
+ const host = telemetryHost(env, input.host);
+ const client = await getPostHogClient(projectApiKey, host);
+ if (!client) {
+ return;
+ }
+
+ try {
+ if (input.immediate) {
+ await client.captureExceptionImmediate(input.error, input.distinctId, input.properties);
+ return;
+ }
+ client.captureException(input.error, input.distinctId, input.properties);
+ } catch {
+ return;
+ }
+}
+
export async function shutdownTelemetryEmitter(): Promise {
const client = await clientPromise;
if (!client) {
diff --git a/packages/cli/src/telemetry/events.schema.json b/packages/cli/src/telemetry/events.schema.json
index a75f92f1..c6c3d6f8 100644
--- a/packages/cli/src/telemetry/events.schema.json
+++ b/packages/cli/src/telemetry/events.schema.json
@@ -206,6 +206,17 @@
"errorClass",
"durationMs"
]
+ },
+ {
+ "name": "query_history_filter_completed",
+ "description": "Emitted after the setup query-history service-account filter picker runs.",
+ "fields": [
+ "dialect",
+ "consideredRoleCount",
+ "excludedRoleCount",
+ "parseFailedCount",
+ "outcome"
+ ]
}
],
"$defs": {
@@ -1434,6 +1445,77 @@
"durationMs"
],
"additionalProperties": false
+ },
+ "query_history_filter_completed": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "cliVersion": {
+ "type": "string"
+ },
+ "nodeVersion": {
+ "type": "string"
+ },
+ "osPlatform": {
+ "type": "string"
+ },
+ "osRelease": {
+ "type": "string"
+ },
+ "arch": {
+ "type": "string"
+ },
+ "runtime": {
+ "type": "string",
+ "enum": [
+ "node",
+ "daemon-py"
+ ]
+ },
+ "isCi": {
+ "type": "boolean"
+ },
+ "dialect": {
+ "type": "string"
+ },
+ "consideredRoleCount": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 9007199254740991
+ },
+ "excludedRoleCount": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 9007199254740991
+ },
+ "parseFailedCount": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 9007199254740991
+ },
+ "outcome": {
+ "type": "string",
+ "enum": [
+ "ok",
+ "error"
+ ]
+ }
+ },
+ "required": [
+ "cliVersion",
+ "nodeVersion",
+ "osPlatform",
+ "osRelease",
+ "arch",
+ "runtime",
+ "isCi",
+ "dialect",
+ "consideredRoleCount",
+ "excludedRoleCount",
+ "parseFailedCount",
+ "outcome"
+ ],
+ "additionalProperties": false
}
}
}
diff --git a/packages/cli/src/telemetry/events.ts b/packages/cli/src/telemetry/events.ts
index c4fc2e6f..cf650492 100644
--- a/packages/cli/src/telemetry/events.ts
+++ b/packages/cli/src/telemetry/events.ts
@@ -206,6 +206,16 @@ const sqlGenCompletedSchema = telemetryCommonEnvelopeSchema
})
.strict();
+const queryHistoryFilterCompletedSchema = telemetryCommonEnvelopeSchema
+ .extend({
+ dialect: z.string(),
+ consideredRoleCount: z.number().int().nonnegative(),
+ excludedRoleCount: z.number().int().nonnegative(),
+ parseFailedCount: z.number().int().nonnegative(),
+ outcome: outcomeSchema,
+ })
+ .strict();
+
/** @internal */
export const telemetryEventSchemas = {
install_first_run: installFirstRunSchema,
@@ -225,6 +235,7 @@ export const telemetryEventSchemas = {
daemon_stopped: daemonStoppedSchema,
sl_plan_completed: slPlanCompletedSchema,
sql_gen_completed: sqlGenCompletedSchema,
+ query_history_filter_completed: queryHistoryFilterCompletedSchema,
} as const;
/** @internal */
@@ -360,6 +371,11 @@ export const telemetryEventCatalog = [
description: 'Emitted after daemon SQL generation completes.',
fields: ['outcome', 'dialect', 'errorClass', 'durationMs'],
},
+ {
+ name: 'query_history_filter_completed',
+ description: 'Emitted after the setup query-history service-account filter picker runs.',
+ fields: ['dialect', 'consideredRoleCount', 'excludedRoleCount', 'parseFailedCount', 'outcome'],
+ },
] as const;
export type TelemetryEventName = keyof typeof telemetryEventSchemas;
diff --git a/packages/cli/src/telemetry/exception.ts b/packages/cli/src/telemetry/exception.ts
new file mode 100644
index 00000000..0ce81244
--- /dev/null
+++ b/packages/cli/src/telemetry/exception.ts
@@ -0,0 +1,201 @@
+import { inspect } from 'node:util';
+
+import { getKtxCliPackageInfo, type KtxCliIo, type KtxCliPackageInfo } from '../cli-runtime.js';
+import { buildCommonEnvelope } from './events.js';
+import { trackTelemetryException } from './emitter.js';
+import { computeTelemetryProjectId, loadTelemetryIdentity } from './identity.js';
+
+export interface ExceptionContext {
+ source: string;
+ handled: boolean;
+ fatal: boolean;
+ extra?: Record;
+}
+
+type AnyObject = object;
+
+const reportedObjects = new WeakSet();
+const recentHandledPrimitives: string[] = [];
+const RECENT_PRIMITIVE_LIMIT = 128;
+
+function primitiveKey(value: unknown): string {
+ return `${typeof value}:${String(value)}`;
+}
+
+function rememberHandledPrimitive(value: unknown): void {
+ recentHandledPrimitives.push(primitiveKey(value));
+ if (recentHandledPrimitives.length > RECENT_PRIMITIVE_LIMIT) {
+ recentHandledPrimitives.splice(0, recentHandledPrimitives.length - RECENT_PRIMITIVE_LIMIT);
+ }
+}
+
+function consumeHandledPrimitive(value: unknown): boolean {
+ const key = primitiveKey(value);
+ const index = recentHandledPrimitives.indexOf(key);
+ if (index < 0) {
+ return false;
+ }
+ recentHandledPrimitives.splice(index, 1);
+ return true;
+}
+
+function shouldSkipAsAlreadyReported(error: unknown, handled: boolean): boolean {
+ if ((typeof error === 'object' || typeof error === 'function') && error !== null) {
+ if (reportedObjects.has(error)) {
+ return true;
+ }
+ reportedObjects.add(error);
+ return false;
+ }
+
+ if (handled) {
+ rememberHandledPrimitive(error);
+ return false;
+ }
+
+ return consumeHandledPrimitive(error);
+}
+
+function escapeRegExp(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+function redactStaticPatterns(value: string): string {
+ return value
+ .replace(/([a-z][a-z0-9+.-]*:\/\/[^:\s/@]+:)([^@\s/]+)(@)/gi, '$1[redacted]$3')
+ .replace(/\b(password|pwd)=([^;&\s]+)/gi, '$1=[redacted]')
+ .replace(/\bAuthorization\s*:\s*[^\r\n,;]+/gi, 'Authorization: [redacted]')
+ .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [redacted]')
+ .replace(/\b(api[_-]?key)\s*[:=]\s*([^\s,;]+)/gi, '$1=[redacted]')
+ .replace(/\b(KTX_[A-Z0-9_]*|[A-Z0-9_]*(?:TOKEN|SECRET))\s*[:=]\s*([^\s,;]+)/g, '$1=[redacted]')
+ .replace(/([?&](?:X-Amz-Signature|X-Goog-Signature|sig)=)[^&\s]+/gi, '$1[redacted]');
+}
+
+function redactText(value: string, secrets: ReadonlyArray): string {
+ let redacted = value;
+ for (const secret of secrets) {
+ if (secret) {
+ redacted = redacted.replace(new RegExp(escapeRegExp(secret), 'g'), '[redacted]');
+ }
+ }
+ return redactStaticPatterns(redacted);
+}
+
+const FORBIDDEN_EXTRA_PROPERTY_KEYS = new Set([
+ 'argv',
+ 'args',
+ 'env',
+ 'environment',
+ 'sql',
+ 'query',
+ 'prompt',
+ 'mcparguments',
+ 'mcpargs',
+ 'tablename',
+ 'schemaname',
+ 'columnname',
+ 'databaseurl',
+ 'connectionstring',
+ 'url',
+ 'password',
+ 'token',
+ 'apikey',
+ 'api_key',
+ 'authorization',
+]);
+
+function safeExtraProperties(
+ extra: Record | undefined,
+): Record {
+ const safe: Record = {};
+ for (const [key, value] of Object.entries(extra ?? {})) {
+ if (!FORBIDDEN_EXTRA_PROPERTY_KEYS.has(key.replace(/[^a-z0-9_]/gi, '').toLowerCase())) {
+ safe[key] = value;
+ }
+ }
+ return safe;
+}
+
+function toMessage(error: unknown): string {
+ if (error instanceof Error) {
+ return error.message;
+ }
+ if (typeof error === 'string') {
+ return error;
+ }
+ return inspect(error, { depth: 4, breakLength: 120 });
+}
+
+function sanitizedError(error: unknown, secrets: ReadonlyArray): Error {
+ if (error instanceof Error) {
+ const cause = 'cause' in error ? (error as Error & { cause?: unknown }).cause : undefined;
+ const clone = new Error(redactText(error.message, secrets), {
+ ...(cause !== undefined ? { cause: sanitizedError(cause, secrets) } : {}),
+ });
+ clone.name = error.name;
+ if (error.stack) {
+ clone.stack = redactText(error.stack, secrets);
+ }
+ return clone;
+ }
+ return new Error(redactText(toMessage(error), secrets));
+}
+
+export async function reportException(input: {
+ error: unknown;
+ context: ExceptionContext;
+ io: KtxCliIo;
+ packageInfo?: KtxCliPackageInfo;
+ projectDir?: string;
+ immediate?: boolean;
+ redactionSecrets?: ReadonlyArray;
+}): Promise {
+ try {
+ if (shouldSkipAsAlreadyReported(input.error, input.context.handled)) {
+ return;
+ }
+
+ const debug = process.env.KTX_TELEMETRY_DEBUG === '1';
+ const identity = await loadTelemetryIdentity({
+ stderr: input.io.stderr,
+ env: process.env,
+ });
+
+ if ((!identity.enabled || !identity.installId) && !debug) {
+ return;
+ }
+
+ const packageInfo = input.packageInfo ?? getKtxCliPackageInfo();
+ const installId = identity.installId ?? 'debug';
+ const projectId = input.projectDir ? computeTelemetryProjectId(installId, input.projectDir) : undefined;
+ const safeError = sanitizedError(input.error, input.redactionSecrets ?? []);
+ const properties: Record = {
+ ...buildCommonEnvelope({
+ cliVersion: packageInfo.version,
+ isCi: Boolean(process.env.CI),
+ }),
+ source: input.context.source,
+ handled: input.context.handled,
+ fatal: input.context.fatal,
+ ...(projectId ? { projectId } : {}),
+ ...safeExtraProperties(input.context.extra),
+ };
+
+ delete properties.$groups;
+ await trackTelemetryException({
+ error: safeError,
+ distinctId: installId,
+ properties,
+ env: process.env,
+ stderr: input.io.stderr,
+ immediate: input.immediate,
+ });
+ } catch {
+ return;
+ }
+}
+
+/** @internal */
+export function __resetTelemetryExceptionStateForTests(): void {
+ recentHandledPrimitives.length = 0;
+}
diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts
index b02e0224..24a62d11 100644
--- a/packages/cli/src/telemetry/index.ts
+++ b/packages/cli/src/telemetry/index.ts
@@ -1,12 +1,14 @@
import { getKtxCliPackageInfo, type KtxCliIo, type KtxCliPackageInfo } from '../cli-runtime.js';
import { loadKtxProject } from '../context/project/project.js';
import {
+ annotateCommandOutcome,
beginCommandSpan,
completeCommandSpan,
type CommandOutcome,
type CompletedCommandSpan,
} from './command-hook.js';
import { shutdownTelemetryEmitter, trackTelemetryEvent } from './emitter.js';
+import { reportException, type ExceptionContext } from './exception.js';
import {
buildCommonEnvelope,
buildTelemetryEvent,
@@ -17,8 +19,8 @@ import {
import { computeTelemetryProjectId, loadTelemetryIdentity } from './identity.js';
import { buildProjectStackSnapshotFields } from './project-snapshot.js';
-export { beginCommandSpan, completeCommandSpan, shutdownTelemetryEmitter };
-export type { CommandOutcome, CompletedCommandSpan };
+export { annotateCommandOutcome, beginCommandSpan, completeCommandSpan, reportException, shutdownTelemetryEmitter };
+export type { CommandOutcome, CompletedCommandSpan, ExceptionContext };
export async function showTelemetryNoticeIfNeeded(io: KtxCliIo, packageInfo: KtxCliPackageInfo): Promise {
const identity = await loadTelemetryIdentity({
diff --git a/packages/cli/src/telemetry/redaction-secrets.ts b/packages/cli/src/telemetry/redaction-secrets.ts
new file mode 100644
index 00000000..2bf7a863
--- /dev/null
+++ b/packages/cli/src/telemetry/redaction-secrets.ts
@@ -0,0 +1,117 @@
+import { resolveKtxConfigReference } from '../context/core/config-reference.js';
+import { loadKtxProject, type KtxLocalProject } from '../context/project/project.js';
+
+const SENSITIVE_KEY =
+ /(password|secret|token|api[_-]?key|auth[_-]?token|auth_token_ref|private[_-]?key|passphrase|credential|authorization|url)$/i;
+
+type TelemetryRedactionProject = Pick;
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function addSecret(values: string[], value: string | undefined): void {
+ const trimmed = value?.trim();
+ if (trimmed && !values.includes(trimmed)) {
+ values.push(trimmed);
+ }
+}
+
+function tryResolve(value: string, env: NodeJS.ProcessEnv): string | undefined {
+ try {
+ return resolveKtxConfigReference(value, env);
+ } catch {
+ return undefined;
+ }
+}
+
+function addUrlCredentials(values: string[], value: string): void {
+ try {
+ const parsed = new URL(value);
+ addSecret(values, parsed.password ? decodeURIComponent(parsed.password) : undefined);
+ addSecret(values, parsed.username ? decodeURIComponent(parsed.username) : undefined);
+ } catch {
+ return;
+ }
+}
+
+function collectFromRecord(input: unknown, env: NodeJS.ProcessEnv, values: string[]): void {
+ if (Array.isArray(input)) {
+ for (const item of input) {
+ collectFromRecord(item, env, values);
+ }
+ return;
+ }
+
+ if (!isRecord(input)) {
+ return;
+ }
+
+ for (const [key, raw] of Object.entries(input)) {
+ if (isRecord(raw) || Array.isArray(raw)) {
+ collectFromRecord(raw, env, values);
+ continue;
+ }
+ if (typeof raw !== 'string' || !SENSITIVE_KEY.test(key)) {
+ continue;
+ }
+ const resolved = tryResolve(raw, env);
+ addSecret(values, resolved);
+ if (resolved) {
+ addUrlCredentials(values, resolved);
+ }
+ }
+}
+
+function collectLlmSecrets(project: TelemetryRedactionProject, env: NodeJS.ProcessEnv, values: string[]): void {
+ collectFromRecord(project.config.llm.provider, env, values);
+}
+
+function collectEmbeddingSecrets(project: TelemetryRedactionProject, env: NodeJS.ProcessEnv, values: string[]): void {
+ collectFromRecord(project.config.ingest.embeddings, env, values);
+ collectFromRecord(project.config.scan.enrichment.embeddings, env, values);
+}
+
+function collectConnectionSecrets(
+ project: TelemetryRedactionProject,
+ connectionId: string | undefined,
+ env: NodeJS.ProcessEnv,
+ values: string[],
+): void {
+ if (!connectionId) {
+ return;
+ }
+ collectFromRecord(project.config.connections[connectionId], env, values);
+}
+
+export async function collectTelemetryRedactionSecrets(input: {
+ project?: TelemetryRedactionProject;
+ projectDir?: string;
+ connectionId?: string;
+ includeLlm?: boolean;
+ includeEmbeddings?: boolean;
+ env?: NodeJS.ProcessEnv;
+}): Promise {
+ const env = input.env ?? process.env;
+ let project = input.project;
+ if (!project && input.projectDir) {
+ try {
+ project = await loadKtxProject({ projectDir: input.projectDir });
+ } catch {
+ project = undefined;
+ }
+ }
+ if (!project) {
+ return [];
+ }
+
+ const values: string[] = [];
+ if (input.includeLlm) {
+ collectLlmSecrets(project, env, values);
+ }
+ if (input.includeEmbeddings) {
+ collectEmbeddingSecrets(project, env, values);
+ }
+ collectConnectionSecrets(project, input.connectionId, env, values);
+ return values;
+}
diff --git a/packages/cli/src/update-check/cache.ts b/packages/cli/src/update-check/cache.ts
new file mode 100644
index 00000000..19ebf07a
--- /dev/null
+++ b/packages/cli/src/update-check/cache.ts
@@ -0,0 +1,45 @@
+import { renameSync, writeFileSync } from 'node:fs';
+import { mkdir, readFile } from 'node:fs/promises';
+import { homedir } from 'node:os';
+import { dirname, join } from 'node:path';
+import { z } from 'zod';
+
+const updateCheckCacheSchema = z
+ .object({
+ checkedAt: z.string(),
+ channel: z.enum(['latest', 'next']),
+ installedVersion: z.string(),
+ latestForChannel: z.string(),
+ lastNoticeAt: z.string().optional(),
+ })
+ .strict();
+
+export type UpdateCheckCache = z.infer;
+
+/** @internal */
+export function updateCheckCachePath(homeDir = homedir()): string {
+ return join(homeDir, '.ktx', 'update-check.json');
+}
+
+export async function readUpdateCheckCache(options: { homeDir?: string } = {}): Promise {
+ try {
+ return updateCheckCacheSchema.parse(JSON.parse(await readFile(updateCheckCachePath(options.homeDir), 'utf-8')));
+ } catch {
+ return null;
+ }
+}
+
+export async function writeUpdateCheckCache(
+ value: UpdateCheckCache,
+ options: { homeDir?: string } = {},
+): Promise {
+ try {
+ const path = updateCheckCachePath(options.homeDir);
+ await mkdir(dirname(path), { recursive: true });
+ const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
+ writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf-8');
+ renameSync(tempPath, path);
+ } catch {
+ return;
+ }
+}
diff --git a/packages/cli/src/update-check/channel.ts b/packages/cli/src/update-check/channel.ts
new file mode 100644
index 00000000..d8251021
--- /dev/null
+++ b/packages/cli/src/update-check/channel.ts
@@ -0,0 +1,43 @@
+import semver from 'semver';
+
+export type UpdateChannel = 'latest' | 'next';
+
+export type UpdateDecision =
+ | { status: 'skip' }
+ | { status: 'upToDate'; channel: UpdateChannel; target: string }
+ | { status: 'available'; channel: UpdateChannel; target: string };
+
+/** @internal */
+export function inferUpdateChannel(installed: string): UpdateChannel | null {
+ const parsed = semver.parse(installed);
+ if (!parsed || installed === '0.0.0') {
+ return null;
+ }
+
+ const [prereleaseId] = parsed.prerelease;
+ if (prereleaseId === undefined) {
+ return 'latest';
+ }
+ if (prereleaseId === 'rc') {
+ return 'next';
+ }
+ return null;
+}
+
+export function decideUpdate(installed: string, distTags: Record): UpdateDecision {
+ const channel = inferUpdateChannel(installed);
+ if (!channel || !semver.valid(installed)) {
+ return { status: 'skip' };
+ }
+
+ const target = distTags[channel];
+ if (!target || !semver.valid(target)) {
+ return { status: 'skip' };
+ }
+
+ if (semver.gt(target, installed)) {
+ return { status: 'available', channel, target };
+ }
+
+ return { status: 'upToDate', channel, target };
+}
diff --git a/packages/cli/src/update-check/registry.ts b/packages/cli/src/update-check/registry.ts
new file mode 100644
index 00000000..f0934933
--- /dev/null
+++ b/packages/cli/src/update-check/registry.ts
@@ -0,0 +1,52 @@
+import { request as httpsRequest } from 'node:https';
+import { URL } from 'node:url';
+import { z } from 'zod';
+
+const DIST_TAGS_URL = new URL('https://registry.npmjs.org/-/package/@kaelio/ktx/dist-tags');
+const distTagsSchema = z.record(z.string(), z.string());
+
+function parseDistTags(raw: string): Record {
+ return distTagsSchema.parse(JSON.parse(raw));
+}
+
+export function fetchDistTags(): Promise> {
+ return new Promise((resolve, reject) => {
+ const request = httpsRequest(
+ DIST_TAGS_URL,
+ {
+ method: 'GET',
+ headers: {
+ accept: 'application/json',
+ },
+ },
+ (response) => {
+ const chunks: Buffer[] = [];
+ response.on('data', (chunk: Buffer | string) => {
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
+ });
+ response.on('end', () => {
+ const text = Buffer.concat(chunks).toString('utf8');
+ const statusCode = response.statusCode ?? 0;
+ if (statusCode < 200 || statusCode >= 300) {
+ reject(new Error(`npm dist-tags request failed with ${statusCode}: ${text}`));
+ return;
+ }
+ try {
+ resolve(parseDistTags(text));
+ } catch (error) {
+ reject(error);
+ }
+ });
+ },
+ );
+
+ request.on('socket', (socket) => {
+ socket.unref();
+ });
+ request.on('error', reject);
+ request.setTimeout(5000, () => {
+ request.destroy(new Error('npm dist-tags request timed out'));
+ });
+ request.end();
+ });
+}
diff --git a/packages/cli/src/update-check/update-check.ts b/packages/cli/src/update-check/update-check.ts
new file mode 100644
index 00000000..611a43a3
--- /dev/null
+++ b/packages/cli/src/update-check/update-check.ts
@@ -0,0 +1,187 @@
+import type { KtxCliIo } from '../cli-runtime.js';
+import { cyan, dim, type CliStyleEnv } from '../clack.js';
+import { resolveOutputMode } from '../io/mode.js';
+import { type UpdateCheckCache, readUpdateCheckCache, writeUpdateCheckCache } from './cache.js';
+import { decideUpdate, inferUpdateChannel, type UpdateChannel } from './channel.js';
+import { fetchDistTags as defaultFetchDistTags } from './registry.js';
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+/** @internal */
+export interface UpdateCheckEnv extends NodeJS.ProcessEnv, CliStyleEnv {
+ CI?: string;
+ DO_NOT_TRACK?: string;
+ KTX_NO_UPDATE_CHECK?: string;
+ KTX_OUTPUT?: string;
+ NO_UPDATE_NOTIFIER?: string;
+}
+
+/** @internal */
+export interface UpdateCheckCommandOptions {
+ format?: unknown;
+ json?: unknown;
+ output?: unknown;
+}
+
+export interface PrepareUpdateCheckNoticeOptions {
+ commandOptions?: UpdateCheckCommandOptions;
+ env?: UpdateCheckEnv;
+ fetchDistTags?: () => Promise>;
+ homeDir?: string;
+ installedVersion: string;
+ io: KtxCliIo;
+ now?: () => Date;
+}
+
+export interface PreparedUpdateCheckNotice {
+ notice: string | null;
+}
+
+function truthy(value: string | undefined): boolean {
+ return value !== undefined && value !== '' && value !== '0' && value !== 'false';
+}
+
+function commandRequestsJson(options: UpdateCheckCommandOptions | undefined): boolean {
+ return options?.json === true || options?.output === 'json' || options?.format === 'json';
+}
+
+/** @internal */
+export function shouldSuppressUpdateCheck(args: {
+ commandOptions?: UpdateCheckCommandOptions;
+ env?: UpdateCheckEnv;
+ io: KtxCliIo;
+}): boolean {
+ const env = args.env ?? process.env;
+ if (truthy(env.KTX_NO_UPDATE_CHECK) || truthy(env.NO_UPDATE_NOTIFIER) || truthy(env.DO_NOT_TRACK)) {
+ return true;
+ }
+
+ if (commandRequestsJson(args.commandOptions) || truthy(env.CI) || args.io.stdout.isTTY !== true) {
+ return true;
+ }
+
+ try {
+ const mode = resolveOutputMode({
+ json: false,
+ io: args.io,
+ env,
+ });
+ return mode !== 'pretty';
+ } catch {
+ return true;
+ }
+}
+
+/** @internal */
+export function renderUpdateNotice(args: {
+ channel: UpdateChannel;
+ env?: CliStyleEnv;
+ installedVersion: string;
+ targetVersion: string;
+}): string {
+ const command = args.channel === 'next' ? 'npm i -g @kaelio/ktx@next' : 'npm i -g @kaelio/ktx';
+ return `${cyan('↑', args.env)} Update available: ktx ${args.installedVersion} → ${args.targetVersion}\n ${dim(command, args.env)}\n`;
+}
+
+function timestampMs(value: string | undefined): number | null {
+ if (!value) {
+ return null;
+ }
+ const parsed = Date.parse(value);
+ return Number.isNaN(parsed) ? null : parsed;
+}
+
+function elapsedAtLeast(value: string | undefined, now: Date, intervalMs: number): boolean {
+ const previous = timestampMs(value);
+ if (previous === null) {
+ return true;
+ }
+ return now.getTime() - previous >= intervalMs;
+}
+
+function shouldRefreshCache(cache: UpdateCheckCache | null, installedVersion: string, now: Date): boolean {
+ if (!cache || cache.installedVersion !== installedVersion) {
+ return true;
+ }
+ return elapsedAtLeast(cache.checkedAt, now, DAY_MS);
+}
+
+async function refreshUpdateCache(args: {
+ cache: UpdateCheckCache | null;
+ fetchDistTags: () => Promise>;
+ homeDir?: string;
+ installedVersion: string;
+ now: Date;
+}): Promise {
+ const distTags = await args.fetchDistTags();
+ const decision = decideUpdate(args.installedVersion, distTags);
+ if (decision.status === 'skip') {
+ return;
+ }
+
+ await writeUpdateCheckCache(
+ {
+ checkedAt: args.now.toISOString(),
+ channel: decision.channel,
+ installedVersion: args.installedVersion,
+ latestForChannel: decision.target,
+ ...(args.cache?.installedVersion === args.installedVersion && args.cache.channel === decision.channel
+ ? { lastNoticeAt: args.cache.lastNoticeAt }
+ : {}),
+ },
+ { homeDir: args.homeDir },
+ );
+}
+
+export async function prepareUpdateCheckNotice(
+ options: PrepareUpdateCheckNoticeOptions,
+): Promise {
+ const env = options.env ?? process.env;
+ const now = (options.now ?? (() => new Date()))();
+ const fetchDistTags = options.fetchDistTags ?? defaultFetchDistTags;
+
+ if (
+ shouldSuppressUpdateCheck({
+ commandOptions: options.commandOptions,
+ env,
+ io: options.io,
+ })
+ ) {
+ return { notice: null };
+ }
+
+ if (!inferUpdateChannel(options.installedVersion)) {
+ return { notice: null };
+ }
+
+ let cache = await readUpdateCheckCache({ homeDir: options.homeDir });
+ let notice: string | null = null;
+
+ if (cache?.installedVersion === options.installedVersion) {
+ const decision = decideUpdate(options.installedVersion, {
+ [cache.channel]: cache.latestForChannel,
+ });
+ if (decision.status === 'available' && elapsedAtLeast(cache.lastNoticeAt, now, DAY_MS)) {
+ notice = renderUpdateNotice({
+ channel: decision.channel,
+ env,
+ installedVersion: options.installedVersion,
+ targetVersion: decision.target,
+ });
+ cache = { ...cache, lastNoticeAt: now.toISOString() };
+ await writeUpdateCheckCache(cache, { homeDir: options.homeDir });
+ }
+ }
+
+ if (shouldRefreshCache(cache, options.installedVersion, now)) {
+ void refreshUpdateCache({
+ cache,
+ fetchDistTags,
+ homeDir: options.homeDir,
+ installedVersion: options.installedVersion,
+ now,
+ }).catch(() => {});
+ }
+
+ return { notice };
+}
diff --git a/packages/cli/test/cli-program-telemetry.test.ts b/packages/cli/test/cli-program-telemetry.test.ts
index 4e7130b3..4b8a07e5 100644
--- a/packages/cli/test/cli-program-telemetry.test.ts
+++ b/packages/cli/test/cli-program-telemetry.test.ts
@@ -7,9 +7,33 @@ import { runCommanderKtxCli } from '../src/cli-program.js';
import type { KtxCliDeps, KtxCliIo, KtxCliPackageInfo } from '../src/cli-runtime.js';
import { TELEMETRY_NOTICE } from '../src/telemetry/identity.js';
-function makeIo(stdoutIsTTY = true): { io: KtxCliIo; stdout: () => string; stderr: () => string } {
+const reportExceptionMock = vi.hoisted(() => vi.fn(async () => {}));
+
+vi.mock('../src/telemetry/exception.js', () => ({
+ reportException: reportExceptionMock,
+}));
+
+function makeIo(
+ stdoutIsTTY = true,
+ stderrIsTTY = false,
+): { io: KtxCliIo; stdout: () => string; stderr: () => string } {
let stdout = '';
let stderr = '';
+ const stderrStream = stderrIsTTY
+ ? {
+ isTTY: true,
+ columns: 80,
+ on: () => undefined,
+ write: (chunk: string) => {
+ stderr += chunk;
+ },
+ }
+ : {
+ write: (chunk: string) => {
+ stderr += chunk;
+ },
+ };
+
return {
io: {
stdout: {
@@ -18,11 +42,7 @@ function makeIo(stdoutIsTTY = true): { io: KtxCliIo; stdout: () => string; stder
stdout += chunk;
},
},
- stderr: {
- write: (chunk) => {
- stderr += chunk;
- },
- },
+ stderr: stderrStream,
},
stdout: () => stdout,
stderr: () => stderr,
@@ -43,6 +63,7 @@ describe('runCommanderKtxCli telemetry', () => {
vi.stubEnv('CI', '');
vi.stubEnv('KTX_TELEMETRY_DISABLED', '');
vi.stubEnv('DO_NOT_TRACK', '');
+ reportExceptionMock.mockClear();
});
afterEach(async () => {
@@ -114,6 +135,57 @@ describe('runCommanderKtxCli telemetry', () => {
expect(io.stderr()).not.toContain(missingProjectDir);
});
+ it('emits aborted (not error) when setup exits non-zero after the user abandons the wizard', async () => {
+ const io = makeIo(true);
+ const deps: KtxCliDeps = {
+ setup: async () => {
+ // What runKtxSetup does when an interactive step is abandoned: it
+ // annotates the span and returns a non-zero exit code without throwing.
+ const { annotateCommandOutcome } = await import('../src/telemetry/index.js');
+ annotateCommandOutcome({ outcome: 'aborted' });
+ return 1;
+ },
+ };
+
+ await expect(
+ runCommanderKtxCli(['--project-dir', tempDir, 'setup'], io.io, deps, info, { runInit: async () => 0 }),
+ ).resolves.toBe(1);
+
+ expect(io.stderr()).toContain('"event":"command"');
+ expect(io.stderr()).toContain('"commandPath":["ktx","setup"]');
+ // The non-zero exit alone would have produced a blank "error"; the
+ // annotation reclassifies it as a user abort that leaves the error view.
+ expect(io.stderr()).toContain('"outcome":"aborted"');
+ expect(io.stderr()).not.toContain('"outcome":"error"');
+ expect(reportExceptionMock).not.toHaveBeenCalled();
+ });
+
+ it('emits a self-diagnosing error reason when a setup step genuinely fails without throwing', async () => {
+ const io = makeIo(true);
+ const deps: KtxCliDeps = {
+ setup: async () => {
+ const { annotateCommandOutcome } = await import('../src/telemetry/index.js');
+ annotateCommandOutcome({
+ outcome: 'error',
+ errorClass: 'KtxSetupStepFailed',
+ errorDetail: 'runtime setup step failed',
+ });
+ return 1;
+ },
+ };
+
+ await expect(
+ runCommanderKtxCli(['--project-dir', tempDir, 'setup'], io.io, deps, info, { runInit: async () => 0 }),
+ ).resolves.toBe(1);
+
+ expect(io.stderr()).toContain('"outcome":"error"');
+ expect(io.stderr()).toContain('"errorClass":"KtxSetupStepFailed"');
+ expect(io.stderr()).toContain('"errorDetail":"runtime setup step failed"');
+ // Non-throwing failures have no exception twin; the command event carries
+ // the reason on its own.
+ expect(reportExceptionMock).not.toHaveBeenCalled();
+ });
+
it('does not import or emit telemetry for help, version, bare non-TTY, or unknown top-level command', async () => {
const helpIo = makeIo(true);
await expect(runCommanderKtxCli(['--help'], helpIo.io, {}, info, { runInit: async () => 0 })).resolves.toBe(0);
@@ -131,4 +203,101 @@ describe('runCommanderKtxCli telemetry', () => {
await expect(runCommanderKtxCli(['unknown'], unknownIo.io, {}, info, { runInit: async () => 0 })).resolves.toBe(1);
expect(unknownIo.stderr()).not.toContain('[telemetry]');
});
+
+ it('reports genuine top-level command catches as handled exceptions', async () => {
+ const io = makeIo(true);
+ const deps: KtxCliDeps = {
+ doctor: async () => {
+ throw new Error('status failed');
+ },
+ };
+
+ await expect(
+ runCommanderKtxCli(
+ ['--project-dir', tempDir, 'status', '--json'],
+ io.io,
+ deps,
+ info,
+ { runInit: async () => 0 },
+ ),
+ ).resolves.toBe(1);
+
+ expect(reportExceptionMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ context: expect.objectContaining({ source: 'ktx status', handled: true, fatal: false }),
+ projectDir: tempDir,
+ }),
+ );
+ });
+
+ it('prints the Slack hint for unexpected command errors on TTY stderr only', async () => {
+ const ttyIo = makeIo(true, true);
+ const deps: KtxCliDeps = {
+ doctor: async () => {
+ throw new Error('status failed');
+ },
+ };
+
+ await expect(
+ runCommanderKtxCli(
+ ['--project-dir', tempDir, 'status', '--json'],
+ ttyIo.io,
+ deps,
+ info,
+ { runInit: async () => 0 },
+ ),
+ ).resolves.toBe(1);
+
+ expect(ttyIo.stderr()).toContain('status failed');
+ expect(ttyIo.stderr()).toContain('Stuck? The ktx community can help');
+ expect(ttyIo.stderr()).toContain('https://ktx.sh/slack');
+
+ const pipeIo = makeIo(true, false);
+ await expect(
+ runCommanderKtxCli(
+ ['--project-dir', tempDir, 'status', '--json'],
+ pipeIo.io,
+ deps,
+ info,
+ { runInit: async () => 0 },
+ ),
+ ).resolves.toBe(1);
+
+ expect(pipeIo.stderr()).toContain('status failed');
+ expect(pipeIo.stderr()).not.toContain('https://ktx.sh/slack');
+ });
+
+ it('does not print the Slack hint for Commander usage errors', async () => {
+ const io = makeIo(true, true);
+
+ await expect(
+ runCommanderKtxCli(['--not-a-real-option'], io.io, {}, info, { runInit: async () => 0 }),
+ ).resolves.toBe(1);
+
+ expect(io.stderr()).toContain("unknown option '--not-a-real-option'");
+ expect(io.stderr()).not.toContain('Stuck? The ktx community can help');
+ });
+
+ it('prints the Slack hint for bare interactive setup failures on TTY stderr', async () => {
+ const originalCwd = process.cwd();
+ const noProjectDir = await mkdtemp(join(tmpdir(), 'ktx-cli-bare-'));
+ const io = makeIo(true, true);
+ const deps: KtxCliDeps = {
+ setup: async () => {
+ throw new Error('setup failed');
+ },
+ };
+
+ try {
+ process.chdir(noProjectDir);
+ await expect(runCommanderKtxCli([], io.io, deps, info, { runInit: async () => 0 })).resolves.toBe(1);
+ } finally {
+ process.chdir(originalCwd);
+ await rm(noProjectDir, { recursive: true, force: true });
+ }
+
+ expect(io.stderr()).toContain('setup failed');
+ expect(io.stderr()).toContain('Stuck? The ktx community can help');
+ expect(io.stderr()).toContain('https://ktx.sh/slack');
+ });
});
diff --git a/packages/cli/test/cli-program.test.ts b/packages/cli/test/cli-program.test.ts
index 332645aa..39c9955c 100644
--- a/packages/cli/test/cli-program.test.ts
+++ b/packages/cli/test/cli-program.test.ts
@@ -54,6 +54,32 @@ describe('buildKtxProgram', () => {
expect(wrote).toBe('');
});
+
+ it('adds the Slack community footer to root help', () => {
+ let stdout = '';
+ const io: KtxCliIo = {
+ stdout: {
+ isTTY: false,
+ columns: 80,
+ write: (chunk) => {
+ stdout += chunk;
+ },
+ },
+ stderr: {
+ write: () => undefined,
+ },
+ };
+ const program: Command = buildKtxProgram({
+ io,
+ deps: {},
+ packageInfo: stubPackageInfo(),
+ runInit: async () => 0,
+ });
+
+ program.outputHelp();
+
+ expect(stdout).toContain('Community & support: https://ktx.sh/slack');
+ });
});
describe('collectCommandFlagsPresent', () => {
diff --git a/packages/cli/test/cli-runtime.test.ts b/packages/cli/test/cli-runtime.test.ts
new file mode 100644
index 00000000..96eb23ff
--- /dev/null
+++ b/packages/cli/test/cli-runtime.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from 'vitest';
+
+import type { KtxCliIo } from '../src/cli-runtime.js';
+import { writeGlobalExceptionToStderr } from '../src/cli-runtime.js';
+
+function makeIo(stderrIsTty: boolean): { io: KtxCliIo; stderr: () => string } {
+ let stderr = '';
+ const stderrStream = stderrIsTty
+ ? {
+ isTTY: true,
+ columns: 80,
+ on: () => undefined,
+ write: (chunk: string) => {
+ stderr += chunk;
+ },
+ }
+ : {
+ write: (chunk: string) => {
+ stderr += chunk;
+ },
+ };
+
+ return {
+ io: {
+ stdout: {
+ write: () => undefined,
+ },
+ stderr: stderrStream,
+ },
+ stderr: () => stderr,
+ };
+}
+
+describe('writeGlobalExceptionToStderr', () => {
+ it('prints the crash Slack hint after a stack on TTY stderr', () => {
+ const testIo = makeIo(true);
+
+ writeGlobalExceptionToStderr(testIo.io, new Error('global boom'));
+
+ expect(testIo.stderr()).toContain('Error: global boom');
+ expect(testIo.stderr()).toContain('This may be a bug');
+ expect(testIo.stderr()).toContain('https://ktx.sh/slack');
+ });
+
+ it('prints crash details without the Slack hint on non-TTY stderr', () => {
+ const testIo = makeIo(false);
+
+ writeGlobalExceptionToStderr(testIo.io, 'global boom');
+
+ expect(testIo.stderr()).toContain('global boom');
+ expect(testIo.stderr()).not.toContain('https://ktx.sh/slack');
+ });
+});
diff --git a/packages/cli/test/community-cta.test.ts b/packages/cli/test/community-cta.test.ts
new file mode 100644
index 00000000..7d21e77e
--- /dev/null
+++ b/packages/cli/test/community-cta.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ SLACK_HELP_FOOTER,
+ SLACK_SETUP_NOTE,
+ writeErrorCommunityHint,
+} from '../src/community-cta.js';
+import type { KtxCliIo } from '../src/cli-runtime.js';
+
+function makeIo(stderrIsTty: boolean): { io: KtxCliIo; stderr: () => string } {
+ let stderr = '';
+ const stderrStream = stderrIsTty
+ ? {
+ isTTY: true,
+ columns: 80,
+ on: () => undefined,
+ write: (chunk: string) => {
+ stderr += chunk;
+ },
+ }
+ : {
+ write: (chunk: string) => {
+ stderr += chunk;
+ },
+ };
+
+ return {
+ io: {
+ stdout: {
+ write: () => undefined,
+ },
+ stderr: stderrStream,
+ },
+ stderr: () => stderr,
+ };
+}
+
+describe('community CTA', () => {
+ it('writes the error hint to TTY stderr', () => {
+ const testIo = makeIo(true);
+
+ writeErrorCommunityHint(testIo.io, 'error');
+
+ expect(testIo.stderr()).toContain('Stuck? The ktx community can help');
+ expect(testIo.stderr()).toContain('https://ktx.sh/slack');
+ });
+
+ it('suppresses the error hint for non-TTY stderr', () => {
+ const testIo = makeIo(false);
+
+ writeErrorCommunityHint(testIo.io, 'error');
+
+ expect(testIo.stderr()).toBe('');
+ });
+
+ it('uses stronger crash copy for crash hints', () => {
+ const testIo = makeIo(true);
+
+ writeErrorCommunityHint(testIo.io, 'crash');
+
+ expect(testIo.stderr()).toContain('This may be a bug');
+ expect(testIo.stderr()).toContain('https://ktx.sh/slack');
+ });
+
+ it('exports setup and help copy with the stable Slack URL', () => {
+ expect(SLACK_HELP_FOOTER).toBe('Community & support: https://ktx.sh/slack');
+ expect(SLACK_SETUP_NOTE).toEqual({
+ title: 'Community',
+ body: 'Questions or feedback? Join the ktx Slack: https://ktx.sh/slack',
+ });
+ });
+});
diff --git a/packages/cli/test/connection.test.ts b/packages/cli/test/connection.test.ts
index 67e55af8..22c8bbe9 100644
--- a/packages/cli/test/connection.test.ts
+++ b/packages/cli/test/connection.test.ts
@@ -10,6 +10,12 @@ import type { KtxConnectionDriver, KtxScanConnector } from '../src/context/scan/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { runKtxConnection } from '../src/connection.js';
+const reportExceptionMock = vi.hoisted(() => vi.fn(async () => {}));
+
+vi.mock('../src/telemetry/exception.js', () => ({
+ reportException: reportExceptionMock,
+}));
+
function stripAnsi(s: string): string {
return s.replace(/\[[0-9;]*m/g, '');
}
@@ -38,7 +44,7 @@ function makeIo() {
function nativeConnector(
driver: KtxConnectionDriver,
- testResult: { success: true } | { success: false; error: string } = { success: true },
+ testResult: { success: true } | { success: false; error: string; cause?: unknown } = { success: true },
) {
const testConnection = vi.fn(async () => testResult);
const cleanup = vi.fn(async () => undefined);
@@ -72,6 +78,7 @@ describe('runKtxConnection', () => {
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-cli-connection-'));
+ reportExceptionMock.mockClear();
});
afterEach(async () => {
@@ -165,12 +172,13 @@ describe('runKtxConnection', () => {
it('records the raw errorDetail in connection_test telemetry when a native test fails', async () => {
vi.stubEnv('KTX_TELEMETRY_DEBUG', '1');
vi.stubEnv('CI', '');
+ vi.stubEnv('DATABASE_URL', 'postgres://svc:db-url-password@db.example.test/analytics'); // pragma: allowlist secret
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
- warehouse: { driver: 'sqlite' },
+ warehouse: { driver: 'postgres', url: 'env:DATABASE_URL' },
});
- const { connector } = nativeConnector('sqlite', { success: false, error: 'database file is unreadable' });
+ const { connector } = nativeConnector('postgres', { success: false, error: 'database file is unreadable' });
const io = makeIo();
const code = await runKtxConnection({ command: 'test', projectDir, connectionId: 'warehouse' }, io.io, {
@@ -181,6 +189,44 @@ describe('runKtxConnection', () => {
expect(io.stderr()).toContain('"event":"connection_test"');
expect(io.stderr()).toContain('"outcome":"error"');
expect(io.stderr()).toContain('"errorDetail":"database file is unreadable"');
+ expect(reportExceptionMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ context: expect.objectContaining({ source: 'connection test', handled: true, fatal: false }),
+ projectDir,
+ redactionSecrets: expect.arrayContaining([
+ 'postgres://svc:db-url-password@db.example.test/analytics', // pragma: allowlist secret
+ 'db-url-password',
+ ]),
+ }),
+ );
+ });
+
+ it('preserves the driver error class and code in connection_test telemetry', async () => {
+ vi.stubEnv('KTX_TELEMETRY_DEBUG', '1');
+ vi.stubEnv('CI', '');
+ const projectDir = join(tempDir, 'project');
+ await initKtxProject({ projectDir });
+ await writeConnections(projectDir, {
+ warehouse: { driver: 'sqlserver', host: 'db.example.test', database: 'analytics', username: 'svc_ro' },
+ });
+ class ConnectionError extends Error {
+ readonly code = 'ELOGIN';
+ }
+ const driverError = new ConnectionError("Login failed for user 'svc_ro'.");
+ const { connector } = nativeConnector('sqlserver', {
+ success: false,
+ error: driverError.message,
+ cause: driverError,
+ });
+ const io = makeIo();
+
+ const code = await runKtxConnection({ command: 'test', projectDir, connectionId: 'warehouse' }, io.io, {
+ createScanConnector: vi.fn(async () => connector),
+ });
+
+ expect(code).toBe(1);
+ expect(io.stderr()).toContain('"errorClass":"ConnectionError"');
+ expect(io.stderr()).toContain('"errorDetail":"ELOGIN: Login failed for user \'svc_ro\'."');
});
it('reports the connector error and still cleans up when native testConnection fails', async () => {
diff --git a/packages/cli/test/connectors/mysql/connector.test.ts b/packages/cli/test/connectors/mysql/connector.test.ts
index c8334164..829d2b0e 100644
--- a/packages/cli/test/connectors/mysql/connector.test.ts
+++ b/packages/cli/test/connectors/mysql/connector.test.ts
@@ -74,6 +74,16 @@ function fakePoolFactory(): KtxMysqlPoolFactory {
if (sql.trim() === 'SELECT 1') {
return mysqlResult([{ '1': 1 }], [{ name: '1', type: 8 }]);
}
+ if (sql.includes('INFORMATION_SCHEMA.STATISTICS')) {
+ expect(sql).toContain('SEQ_IN_INDEX = 1');
+ return mysqlResult(
+ [
+ { column_name: 'id', estimated_cardinality: 2 },
+ { column_name: 'customer_id', estimated_cardinality: 2 },
+ ],
+ [{ name: 'column_name' }, { name: 'estimated_cardinality' }],
+ );
+ }
throw new Error(`Unexpected SQL: ${sql} params=${JSON.stringify(params)}`);
});
const release = vi.fn();
@@ -515,10 +525,25 @@ describe('KtxMysqlScanConnector', () => {
{ catalog: null, schema: 'analytics', name: 'orders', kind: 'table' },
{ catalog: null, schema: 'analytics', name: 'order_summary', kind: 'view' },
]);
- await expect(connector.columnStats(
- { connectionId: 'warehouse', table: { catalog: null, db: 'analytics', name: 'orders' }, column: 'status' },
- { runId: 'scan-run-1' },
- )).resolves.toBeNull();
+ await expect(
+ connector.columnStats(
+ { connectionId: 'warehouse', table: { catalog: null, db: 'analytics', name: 'orders' }, column: 'id' },
+ { runId: 'scan-run-1' },
+ ),
+ ).resolves.toEqual({ min: null, max: null, average: null, nullCount: null, distinctCount: 2 });
+
+ await expect(
+ connector.columnStats(
+ { connectionId: 'warehouse', table: { catalog: null, db: 'analytics', name: 'orders' }, column: 'status' },
+ { runId: 'scan-run-1' },
+ ),
+ ).resolves.toBeNull();
+
+ await expect(
+ connector.getColumnStatistics({ catalog: null, db: 'analytics', name: 'orders' }),
+ ).resolves.toMatchObject({
+ cardinalityByColumn: new Map([['id', 2], ['customer_id', 2]]),
+ });
await connector.cleanup();
});
diff --git a/packages/cli/test/connectors/mysql/dialect.test.ts b/packages/cli/test/connectors/mysql/dialect.test.ts
index a00d6188..26fade92 100644
--- a/packages/cli/test/connectors/mysql/dialect.test.ts
+++ b/packages/cli/test/connectors/mysql/dialect.test.ts
@@ -36,4 +36,26 @@ describe('KtxMysqlDialect', () => {
expect(dialect.getLimitOffsetClause(10, 20)).toBe('LIMIT 10 OFFSET 20');
});
+
+ it('generates column statistics query using INFORMATION_SCHEMA.STATISTICS', () => {
+ const sql = dialect.generateColumnStatisticsQuery('analytics', 'orders');
+ expect(sql).not.toBeNull();
+ expect(sql).toContain('INFORMATION_SCHEMA.STATISTICS');
+ expect(sql).toContain("TABLE_SCHEMA = 'analytics'");
+ expect(sql).toContain("TABLE_NAME = 'orders'");
+ expect(sql).toContain('CARDINALITY IS NOT NULL');
+ expect(sql).toContain('column_name');
+ expect(sql).toContain('estimated_cardinality');
+ });
+
+ it('filters to leading index columns only (SEQ_IN_INDEX = 1) to avoid inflated cardinality from composite indexes', () => {
+ const sql = dialect.generateColumnStatisticsQuery('analytics', 'orders');
+ expect(sql).toContain('SEQ_IN_INDEX = 1');
+ });
+
+ it('escapes single quotes in schema and table names for statistics query', () => {
+ const sql = dialect.generateColumnStatisticsQuery("andy's_db", "o'rders");
+ expect(sql).toContain("TABLE_SCHEMA = 'andy''s_db'");
+ expect(sql).toContain("TABLE_NAME = 'o''rders'");
+ });
});
diff --git a/packages/cli/test/context-build-view.test.ts b/packages/cli/test/context-build-view.test.ts
index d8692eb5..ad3a54cc 100644
--- a/packages/cli/test/context-build-view.test.ts
+++ b/packages/cli/test/context-build-view.test.ts
@@ -1,5 +1,8 @@
+import { mkdtemp, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
import { buildDefaultKtxProjectConfig, type KtxProjectConfig } from '../src/context/project/config.js';
-import { describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { KtxPublicIngestProject, KtxPublicIngestTargetResult } from '../src/public-ingest.js';
import {
type ContextBuildTargetState,
@@ -12,6 +15,7 @@ import {
runContextBuild,
viewStateFromSourceProgress,
} from '../src/context-build-view.js';
+import { writeStarCountCache } from '../src/star-prompt/cache.js';
function makeIo(options: { isTTY?: boolean; columns?: number } = {}) {
let stdout = '';
@@ -426,6 +430,64 @@ describe('renderContextBuildView', () => {
expect(rendered).not.toContain('resume');
});
+ it('renders the star prompt directly above the stop hint while active', () => {
+ const state = initViewState([
+ { connectionId: 'warehouse', driver: 'postgres', operation: 'database-ingest', debugCommand: '', steps: ['database-schema'] },
+ ]);
+ state.primarySources[0].status = 'running';
+ state.starCount = 1234;
+
+ const rendered = renderContextBuildView(state, {
+ styled: false,
+ showHint: true,
+ showStarPrompt: true,
+ columns: 120,
+ });
+
+ expect(rendered).toContain(
+ ' ★ This takes a few minutes - mind giving ktx a star while you wait? github.com/Kaelio/ktx · 1,234 ★',
+ );
+ expect(rendered.indexOf('mind giving ktx a star')).toBeLessThan(rendered.indexOf('Ctrl+C to stop'));
+ });
+
+ it('renders the star prompt without a count while active', () => {
+ const state = initViewState([
+ { connectionId: 'warehouse', driver: 'postgres', operation: 'database-ingest', debugCommand: '', steps: ['database-schema'] },
+ ]);
+ state.primarySources[0].status = 'running';
+
+ const rendered = renderContextBuildView(state, {
+ styled: false,
+ showHint: true,
+ showStarPrompt: true,
+ columns: 120,
+ });
+
+ expect(rendered).toContain(
+ ' ★ This takes a few minutes - mind giving ktx a star while you wait? github.com/Kaelio/ktx',
+ );
+ expect(rendered).not.toContain('1,234');
+ });
+
+ it('omits the star prompt after the build finishes', () => {
+ const state = initViewState([
+ { connectionId: 'warehouse', driver: 'postgres', operation: 'database-ingest', debugCommand: '', steps: ['database-schema'] },
+ ]);
+ state.primarySources[0].status = 'done';
+ state.totalElapsedMs = 5000;
+ state.starCount = 1234;
+
+ const rendered = renderContextBuildView(state, {
+ styled: false,
+ showHint: true,
+ showStarPrompt: true,
+ columns: 120,
+ });
+
+ expect(rendered).not.toContain('mind giving ktx a star');
+ expect(rendered).not.toContain('Ctrl+C to stop');
+ });
+
it('omits detach hint when all targets are done', () => {
const state = initViewState([
{ connectionId: 'warehouse', driver: 'postgres', operation: 'database-ingest', debugCommand: '', steps: ['database-schema'] },
@@ -608,9 +670,140 @@ describe('createRepainter', () => {
const cursorMoves = [...io.stdout().matchAll(/\[(\d+)A/g)].map((m) => Number(m[1]));
expect(cursorMoves).toEqual([2]);
});
+
+ it('exposes the same terminal columns used for visual row calculations', () => {
+ const io = makeIo({ isTTY: true, columns: 44 });
+ const repainter = createRepainter(io.io);
+
+ expect(repainter.columns()).toBe(44);
+ });
});
describe('runContextBuild', () => {
+ let starPromptHomeDir: string;
+
+ beforeEach(async () => {
+ starPromptHomeDir = await mkdtemp(join(tmpdir(), 'ktx-star-prompt-run-'));
+ });
+
+ afterEach(async () => {
+ await rm(starPromptHomeDir, { recursive: true, force: true });
+ });
+
+ it('seeds the interactive star prompt from a fresh cached count without fetching', async () => {
+ await writeStarCountCache(
+ { count: 1234, fetchedAt: '2026-06-08T10:00:00.000Z' },
+ { homeDir: starPromptHomeDir },
+ );
+ const io = makeIo({ isTTY: true, columns: 120 });
+ const project = projectWithConnections({ warehouse: { driver: 'postgres' } });
+ const fetchStarCount = vi.fn(async () => 9999);
+ const executeTarget = vi.fn(async (target) => successResult(target.connectionId, target.driver, target.operation));
+
+ await runContextBuild(
+ project,
+ { projectDir: '/tmp/project', inputMode: 'disabled' },
+ io.io,
+ {
+ executeTarget,
+ fetchStarCount,
+ now: () => Date.parse('2026-06-08T11:00:00.000Z'),
+ starPromptEnv: {},
+ starPromptHomeDir,
+ },
+ );
+
+ expect(fetchStarCount).not.toHaveBeenCalled();
+ expect(io.stdout()).toContain('mind giving ktx a star');
+ expect(io.stdout()).toContain('1,234 ★');
+ });
+
+ it('refreshes a stale cached count while the interactive build is active', async () => {
+ await writeStarCountCache(
+ { count: 1234, fetchedAt: '2026-06-06T10:00:00.000Z' },
+ { homeDir: starPromptHomeDir },
+ );
+ const io = makeIo({ isTTY: true, columns: 120 });
+ const project = projectWithConnections({ warehouse: { driver: 'postgres' } });
+ const fetchStarCount = vi.fn(async () => 5678);
+ let finishTarget!: () => void;
+ const targetFinished = new Promise((resolve) => {
+ finishTarget = () => {
+ resolve(successResult('warehouse', 'postgres', 'database-ingest'));
+ };
+ });
+ const executeTarget = vi.fn(async () => targetFinished);
+
+ const run = runContextBuild(
+ project,
+ { projectDir: '/tmp/project', inputMode: 'disabled' },
+ io.io,
+ {
+ executeTarget,
+ fetchStarCount,
+ now: () => Date.parse('2026-06-08T11:00:00.000Z'),
+ starPromptEnv: {},
+ starPromptHomeDir,
+ },
+ );
+
+ await vi.waitFor(() => {
+ expect(fetchStarCount).toHaveBeenCalledTimes(1);
+ expect(io.stdout()).toContain('5,678 ★');
+ });
+ finishTarget();
+ await expect(run).resolves.toMatchObject({ exitCode: 0 });
+ });
+
+ it.each([
+ ['DO_NOT_TRACK', { DO_NOT_TRACK: '1' }],
+ ['KTX_NO_STAR', { KTX_NO_STAR: '1' }],
+ ['CI', { CI: '1' }],
+ ])('suppresses the star prompt and fetch for %s', async (_name, starPromptEnv) => {
+ const io = makeIo({ isTTY: true, columns: 120 });
+ const project = projectWithConnections({ warehouse: { driver: 'postgres' } });
+ const fetchStarCount = vi.fn(async () => 1234);
+ const executeTarget = vi.fn(async (target) => successResult(target.connectionId, target.driver, target.operation));
+
+ await runContextBuild(
+ project,
+ { projectDir: '/tmp/project', inputMode: 'disabled' },
+ io.io,
+ {
+ executeTarget,
+ fetchStarCount,
+ now: () => Date.parse('2026-06-08T11:00:00.000Z'),
+ starPromptEnv,
+ starPromptHomeDir,
+ },
+ );
+
+ expect(fetchStarCount).not.toHaveBeenCalled();
+ expect(io.stdout()).not.toContain('mind giving ktx a star');
+ });
+
+ it('suppresses the star prompt and fetch for non-TTY output', async () => {
+ const io = makeIo({ isTTY: false, columns: 120 });
+ const project = projectWithConnections({ warehouse: { driver: 'postgres' } });
+ const fetchStarCount = vi.fn(async () => 1234);
+ const executeTarget = vi.fn(async (target) => successResult(target.connectionId, target.driver, target.operation));
+
+ await runContextBuild(
+ project,
+ { projectDir: '/tmp/project', inputMode: 'disabled' },
+ io.io,
+ {
+ executeTarget,
+ fetchStarCount,
+ now: () => Date.parse('2026-06-08T11:00:00.000Z'),
+ starPromptHomeDir,
+ },
+ );
+
+ expect(fetchStarCount).not.toHaveBeenCalled();
+ expect(io.stdout()).not.toContain('mind giving ktx a star');
+ });
+
it('executes scan targets before source-ingest targets', async () => {
const io = makeIo();
const project = projectWithConnections({
diff --git a/packages/cli/test/context/connections/dialects.test.ts b/packages/cli/test/context/connections/dialects.test.ts
index 0b72566e..217be1eb 100644
--- a/packages/cli/test/context/connections/dialects.test.ts
+++ b/packages/cli/test/context/connections/dialects.test.ts
@@ -89,7 +89,7 @@ const fixtures: DialectFixture[] = [
cardinalityContains: 'SELECT COUNT(DISTINCT val) AS cardinality',
randomizedCardinalityContains: 'ORDER BY RAND()',
distinctValuesContains: 'SELECT DISTINCT CAST(`status` AS CHAR) AS val',
- statisticsContains: null,
+ statisticsContains: 'INFORMATION_SCHEMA.STATISTICS',
dimensionInput: 'tinyint(1)',
dimensionType: 'boolean',
nativeTypeInput: 'varchar(255)',
diff --git a/packages/cli/test/context/connections/drivers.test.ts b/packages/cli/test/context/connections/drivers.test.ts
index 380b2265..5d59db4e 100644
--- a/packages/cli/test/context/connections/drivers.test.ts
+++ b/packages/cli/test/context/connections/drivers.test.ts
@@ -68,7 +68,6 @@ const connectionFixtures: Record = {
const allowedScopeKeys = new Set(['dataset_ids', 'databases', 'schemas', 'schema_names']);
const historicSqlReaderDrivers = new Set(['postgres', 'bigquery', 'snowflake']);
-const localExecutorDrivers = new Set(['postgres', 'sqlite']);
function assertExportedRegistryBoundaryTypes(input: {
scopeConfigKey: KtxScopeConfigKey;
@@ -140,6 +139,5 @@ describe('driverRegistrations', () => {
expect(allowedScopeKeys.has(registration.scopeConfigKey ?? '')).toBe(true);
}
expect(registration.hasHistoricSqlReader).toBe(historicSqlReaderDrivers.has(registration.driver));
- expect(registration.hasLocalQueryExecutor).toBe(localExecutorDrivers.has(registration.driver));
});
});
diff --git a/packages/cli/test/context/connections/local-query-executor.test.ts b/packages/cli/test/context/connections/local-query-executor.test.ts
deleted file mode 100644
index ca700b04..00000000
--- a/packages/cli/test/context/connections/local-query-executor.test.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-import { describe, expect, it, vi } from 'vitest';
-import { createDefaultLocalQueryExecutor } from '../../../src/context/connections/local-query-executor.js';
-
-describe('createDefaultLocalQueryExecutor', () => {
- it('dispatches postgres and sqlite drivers to their executors', async () => {
- const postgres = {
- execute: vi.fn(async () => ({
- headers: ['pg'],
- rows: [[1]],
- totalRows: 1,
- command: 'SELECT',
- rowCount: 1,
- })),
- };
- const sqlite = {
- execute: vi.fn(async () => ({
- headers: ['sqlite'],
- rows: [[2]],
- totalRows: 1,
- command: 'SELECT',
- rowCount: 1,
- })),
- };
- const executor = createDefaultLocalQueryExecutor({ postgres, sqlite });
-
- await expect(
- executor.execute({
- connectionId: 'pg',
- connection: { driver: 'postgres' },
- sql: 'select 1',
- }),
- ).resolves.toMatchObject({ headers: ['pg'] });
- await expect(
- executor.execute({
- connectionId: 'local',
- connection: { driver: 'sqlite' },
- sql: 'select 1',
- }),
- ).resolves.toMatchObject({ headers: ['sqlite'] });
-
- expect(postgres.execute).toHaveBeenCalledTimes(1);
- expect(sqlite.execute).toHaveBeenCalledTimes(1);
- });
-
- it('rejects unsupported local execution drivers', async () => {
- const executor = createDefaultLocalQueryExecutor({
- postgres: { execute: vi.fn() },
- sqlite: { execute: vi.fn() },
- });
-
- await expect(
- executor.execute({
- connectionId: 'warehouse',
- connection: { driver: 'snowflake' },
- sql: 'select 1',
- }),
- ).rejects.toThrow('No local query executor is configured for driver "snowflake".');
- });
-});
diff --git a/packages/cli/test/context/connections/postgres-query-executor.test.ts b/packages/cli/test/context/connections/postgres-query-executor.test.ts
deleted file mode 100644
index fe4ab15c..00000000
--- a/packages/cli/test/context/connections/postgres-query-executor.test.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-import { describe, expect, it, vi } from 'vitest';
-import { createPostgresQueryExecutor } from '../../../src/context/connections/postgres-query-executor.js';
-
-function makeClient() {
- const calls: unknown[] = [];
- const client = {
- connect: vi.fn(async () => undefined),
- query: vi.fn(async (input: unknown) => {
- calls.push(input);
- if (input === 'BEGIN READ ONLY') {
- return { rows: [], fields: [], rowCount: null, command: 'BEGIN' };
- }
- if (input === 'COMMIT') {
- return { rows: [], fields: [], rowCount: null, command: 'COMMIT' };
- }
- return {
- rows: [
- ['paid', 2],
- ['open', 1],
- ],
- fields: [{ name: 'status' }, { name: 'order_count' }],
- rowCount: 2,
- command: 'SELECT',
- };
- }),
- end: vi.fn(async () => undefined),
- };
- return { client, calls };
-}
-
-describe('createPostgresQueryExecutor', () => {
- it('runs a read-only transaction in array row mode and closes the client', async () => {
- const { client, calls } = makeClient();
- const executor = createPostgresQueryExecutor({
- clientFactory: vi.fn(() => client),
- });
-
- const result = await executor.execute({
- connectionId: 'warehouse',
- connection: { driver: 'postgres', url: 'postgres://example/db' },
- sql: 'select status, count(*) as order_count from public.orders group by status',
- maxRows: 50,
- });
-
- expect(client.connect).toHaveBeenCalledTimes(1);
- expect(calls[0]).toBe('BEGIN READ ONLY');
- expect(calls[1]).toEqual({
- text: 'select * from (select status, count(*) as order_count from public.orders group by status) as ktx_query_result limit 50',
- rowMode: 'array',
- });
- expect(calls[2]).toBe('COMMIT');
- expect(client.end).toHaveBeenCalledTimes(1);
- expect(result).toEqual({
- headers: ['status', 'order_count'],
- rows: [
- ['paid', 2],
- ['open', 1],
- ],
- totalRows: 2,
- command: 'SELECT',
- rowCount: 2,
- });
- });
-
- it('rolls back and closes the client when query execution fails', async () => {
- const client = {
- connect: vi.fn(async () => undefined),
- query: vi.fn(async (input: unknown) => {
- if (input === 'BEGIN READ ONLY' || input === 'ROLLBACK') {
- return { rows: [], fields: [], rowCount: null, command: String(input) };
- }
- throw new Error('syntax error');
- }),
- end: vi.fn(async () => undefined),
- };
- const executor = createPostgresQueryExecutor({
- clientFactory: vi.fn(() => client),
- });
-
- await expect(
- executor.execute({
- connectionId: 'warehouse',
- connection: { driver: 'postgres', url: 'postgres://example/db' },
- sql: 'select * from broken',
- maxRows: 10,
- }),
- ).rejects.toThrow('syntax error');
- expect(client.query).toHaveBeenCalledWith('ROLLBACK');
- expect(client.end).toHaveBeenCalledTimes(1);
- });
-
- it('requires a Postgres url', async () => {
- const executor = createPostgresQueryExecutor({ clientFactory: vi.fn() });
-
- await expect(
- executor.execute({
- connectionId: 'warehouse',
- connection: { driver: 'postgres' },
- sql: 'select 1',
- }),
- ).rejects.toThrow('Local Postgres execution requires connections.warehouse.url');
- });
-});
diff --git a/packages/cli/test/context/connections/sqlite-query-executor.test.ts b/packages/cli/test/context/connections/sqlite-query-executor.test.ts
deleted file mode 100644
index a9e61ba5..00000000
--- a/packages/cli/test/context/connections/sqlite-query-executor.test.ts
+++ /dev/null
@@ -1,139 +0,0 @@
-import { mkdtemp, rm } from 'node:fs/promises';
-import { writeFileSync } from 'node:fs';
-import { tmpdir } from 'node:os';
-import { join } from 'node:path';
-import Database from 'better-sqlite3';
-import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { createSqliteQueryExecutor, sqliteDatabasePathFromConnection } from '../../../src/context/connections/sqlite-query-executor.js';
-
-describe('createSqliteQueryExecutor', () => {
- let tempDir: string;
- let dbPath: string;
-
- beforeEach(async () => {
- tempDir = await mkdtemp(join(tmpdir(), 'ktx-sqlite-query-'));
- dbPath = join(tempDir, 'warehouse.db');
- const db = new Database(dbPath);
- db.exec(`
- CREATE TABLE orders (
- id INTEGER PRIMARY KEY,
- status TEXT NOT NULL,
- amount INTEGER NOT NULL
- );
- INSERT INTO orders (status, amount) VALUES
- ('paid', 20),
- ('paid', 30),
- ('open', 10);
- `);
- db.close();
- });
-
- afterEach(async () => {
- await rm(tempDir, { recursive: true, force: true });
- });
-
- it('executes read-only SELECT SQL against a relative SQLite path', async () => {
- const executor = createSqliteQueryExecutor();
-
- const result = await executor.execute({
- connectionId: 'warehouse',
- projectDir: tempDir,
- connection: { driver: 'sqlite', path: 'warehouse.db' },
- sql: 'select status, count(*) as order_count from orders group by status order by status',
- maxRows: 10,
- });
-
- expect(result).toEqual({
- headers: ['status', 'order_count'],
- rows: [
- ['open', 1],
- ['paid', 2],
- ],
- totalRows: 2,
- command: 'SELECT',
- rowCount: 2,
- });
- });
-
- it('supports file urls for SQLite database paths', async () => {
- expect(
- sqliteDatabasePathFromConnection({
- connectionId: 'warehouse',
- projectDir: tempDir,
- connection: { driver: 'sqlite', url: `file://${dbPath}` },
- sql: 'select 1',
- }),
- ).toBe(dbPath);
- });
-
- it('resolves file references for SQLite path fields', async () => {
- const pointerPath = join(tempDir, 'sqlite-path.txt');
- writeFileSync(pointerPath, dbPath, 'utf-8');
-
- expect(
- sqliteDatabasePathFromConnection({
- connectionId: 'warehouse',
- projectDir: tempDir,
- connection: { driver: 'sqlite', path: `file:${pointerPath}` },
- sql: 'select 1',
- }),
- ).toBe(dbPath);
- });
-
- it('resolves env references for SQLite database urls', async () => {
- const originalDatabaseUrl = process.env.KTX_SQLITE_TEST_URL;
- process.env.KTX_SQLITE_TEST_URL = `sqlite:${dbPath}`;
-
- try {
- expect(
- sqliteDatabasePathFromConnection({
- connectionId: 'warehouse',
- projectDir: tempDir,
- connection: { driver: 'sqlite', url: 'env:KTX_SQLITE_TEST_URL' },
- sql: 'select 1',
- }),
- ).toBe(dbPath);
- } finally {
- if (originalDatabaseUrl === undefined) {
- delete process.env.KTX_SQLITE_TEST_URL;
- } else {
- process.env.KTX_SQLITE_TEST_URL = originalDatabaseUrl;
- }
- }
- });
-
- it('rejects mutating SQL before opening the database', async () => {
- const executor = createSqliteQueryExecutor();
-
- await expect(
- executor.execute({
- connectionId: 'warehouse',
- projectDir: tempDir,
- connection: { driver: 'sqlite', path: 'warehouse.db' },
- sql: 'delete from orders',
- }),
- ).rejects.toThrow('Only read-only SELECT/WITH queries can be executed locally');
- });
-
- it('requires a SQLite driver and a database path', async () => {
- const executor = createSqliteQueryExecutor();
-
- await expect(
- executor.execute({
- connectionId: 'warehouse',
- projectDir: tempDir,
- connection: { driver: 'postgres', path: 'warehouse.db' },
- sql: 'select 1',
- }),
- ).rejects.toThrow('Local SQLite execution cannot run driver "postgres"');
-
- await expect(
- executor.execute({
- connectionId: 'warehouse',
- projectDir: tempDir,
- connection: { driver: 'sqlite' },
- sql: 'select 1',
- }),
- ).rejects.toThrow('Local SQLite execution requires connections.warehouse.path or connections.warehouse.url');
- });
-});
diff --git a/packages/cli/test/context/core/abort.test.ts b/packages/cli/test/context/core/abort.test.ts
new file mode 100644
index 00000000..aed46c1e
--- /dev/null
+++ b/packages/cli/test/context/core/abort.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it, vi } from 'vitest';
+import { createAbortError, isAbortError, linkAbortSignal, throwIfAborted } from '../../../src/context/core/abort.js';
+
+describe('abort helpers', () => {
+ it('recognizes DOMException abort errors and common abort-shaped errors', () => {
+ expect(isAbortError(createAbortError())).toBe(true);
+ expect(isAbortError(Object.assign(new Error('cancelled'), { name: 'AbortError' }))).toBe(true);
+ expect(isAbortError(Object.assign(new Error('operation aborted'), { code: 'ABORT_ERR' }))).toBe(true);
+ expect(isAbortError(new Error('ordinary failure'))).toBe(false);
+ });
+
+ it('throws when the provided signal is already aborted', () => {
+ const controller = new AbortController();
+ controller.abort();
+
+ expect(() => throwIfAborted(controller.signal)).toThrow(/Aborted/);
+ });
+
+ it('links a child controller to a parent signal and removes the listener on dispose', () => {
+ const parent = new AbortController();
+ const child = linkAbortSignal(parent.signal);
+
+ expect(child.controller.signal.aborted).toBe(false);
+ parent.abort();
+ expect(child.controller.signal.aborted).toBe(true);
+
+ const removeSpy = vi.spyOn(parent.signal, 'removeEventListener');
+ child.dispose();
+ expect(removeSpy).toHaveBeenCalledWith('abort', expect.any(Function));
+ });
+});
diff --git a/packages/cli/test/context/core/git.service.init-identity.test.ts b/packages/cli/test/context/core/git.service.init-identity.test.ts
new file mode 100644
index 00000000..8589d1ed
--- /dev/null
+++ b/packages/cli/test/context/core/git.service.init-identity.test.ts
@@ -0,0 +1,101 @@
+import { execFileSync } from 'node:child_process';
+import { mkdtemp, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import type { KtxCoreConfig } from '../../../src/context/core/config.js';
+import { GitService } from '../../../src/context/core/git.service.js';
+
+// Regression for the production exception "Failed to initialize git repository"
+// (PostHog issue 019ea9df-96d6-7882-98e2-6b892bf9c1ab, ktx 0.10.0, darwin).
+//
+// Repro: the project directory is ALREADY a git repo with no commits (the user ran
+// `git init` first, or ktx is pointed at an empty repo), AND the machine has no configured
+// git identity (a fresh Mac with no ~/.gitconfig). GitService only set the committer identity
+// on the path where it created the repo itself, so the bootstrap commit failed with
+// "Committer identity unknown" and was rethrown opaquely.
+describe('GitService.initialize without a configured git identity', () => {
+ let repoDir: string;
+ let homeDir: string;
+ let savedEnv: Record;
+
+ const IDENTITY_ENV_KEYS = [
+ 'HOME',
+ 'USERPROFILE',
+ 'XDG_CONFIG_HOME',
+ 'GIT_CONFIG_NOSYSTEM',
+ 'GIT_AUTHOR_NAME',
+ 'GIT_AUTHOR_EMAIL',
+ 'GIT_COMMITTER_NAME',
+ 'GIT_COMMITTER_EMAIL',
+ 'EMAIL',
+ ];
+
+ const coreConfig = (configDir: string): KtxCoreConfig => ({
+ storage: { configDir, homeDir: configDir },
+ git: {
+ userName: 'Test User',
+ userEmail: 'test@example.com',
+ bootstrapMessage: 'Initialize test config repo',
+ bootstrapAuthor: 'test-system',
+ bootstrapAuthorEmail: 'system@example.com',
+ },
+ });
+
+ beforeEach(async () => {
+ repoDir = await mkdtemp(join(tmpdir(), 'git-service-identity-'));
+ homeDir = await mkdtemp(join(tmpdir(), 'git-service-home-'));
+
+ // Model a machine with no configured git identity, deterministically and independent of
+ // the host's ~/.gitconfig. `useConfigOnly` disables git's username@hostname email guess,
+ // so a missing identity is a hard failure rather than a hostname-dependent one. Note we
+ // cannot use GIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEM here: simple-git rejects those env vars.
+ await writeFile(join(homeDir, '.gitconfig'), '[user]\n\tuseConfigOnly = true\n', 'utf-8');
+
+ savedEnv = Object.fromEntries(IDENTITY_ENV_KEYS.map((key) => [key, process.env[key]]));
+ process.env.HOME = homeDir;
+ process.env.USERPROFILE = homeDir;
+ process.env.XDG_CONFIG_HOME = join(homeDir, 'xdg-empty');
+ process.env.GIT_CONFIG_NOSYSTEM = '1';
+ for (const key of ['GIT_AUTHOR_NAME', 'GIT_AUTHOR_EMAIL', 'GIT_COMMITTER_NAME', 'GIT_COMMITTER_EMAIL', 'EMAIL']) {
+ delete process.env[key];
+ }
+
+ // Pre-create an empty repo: checkIsRepo() will be true, but there is no HEAD yet.
+ execFileSync('git', ['init'], { cwd: repoDir, env: process.env, stdio: 'ignore' });
+ });
+
+ afterEach(async () => {
+ for (const [key, value] of Object.entries(savedEnv)) {
+ if (value === undefined) {
+ delete process.env[key];
+ } else {
+ process.env[key] = value;
+ }
+ }
+ await rm(repoDir, { recursive: true, force: true });
+ await rm(homeDir, { recursive: true, force: true });
+ });
+
+ it('bootstraps a commit in a pre-existing empty repo so HEAD resolves', async () => {
+ const service = new GitService(coreConfig(repoDir));
+
+ await expect(service.onModuleInit()).resolves.toBeUndefined();
+
+ const head = await service.revParseHead();
+ expect(head).toMatch(/^[0-9a-f]{40}$/);
+ });
+
+ it("does not write its identity into the user's repo config", async () => {
+ const service = new GitService(coreConfig(repoDir));
+ await service.onModuleInit();
+
+ // ktx must not hijack the identity the user would use for their own commits in this repo.
+ const localName = execFileSync('git', ['config', '--local', '--default', '', 'user.name'], {
+ cwd: repoDir,
+ env: process.env,
+ encoding: 'utf-8',
+ }).trim();
+ expect(localName).toBe('');
+ });
+});
diff --git a/packages/cli/test/context/index-sync/reindex.nested-git-root.test.ts b/packages/cli/test/context/index-sync/reindex.nested-git-root.test.ts
new file mode 100644
index 00000000..97efa993
--- /dev/null
+++ b/packages/cli/test/context/index-sync/reindex.nested-git-root.test.ts
@@ -0,0 +1,66 @@
+import { execFileSync } from 'node:child_process';
+import { mkdir, mkdtemp, rm, stat } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { reindexLocalIndexes } from '../../../src/context/index-sync/reindex.js';
+import { initKtxProject, type KtxLocalProject } from '../../../src/context/project/project.js';
+
+const AUTHOR = 'Agent';
+const EMAIL = 'agent@example.com';
+
+const WIKI_PAGE = '---\nsummary: Revenue\nusage_mode: auto\n---\n\nPaid orders.\n';
+
+/**
+ * Regression for the "wiki silently unsearchable when the project dir is not the git root"
+ * bug: a ktx project initialized below an existing git working tree. ingest writes wiki
+ * pages through a session worktree and squash-merges into main, so the page must land
+ * inside the project dir (where reindex scans), not at the enclosing git root.
+ */
+describe('reindex with a ktx project nested inside an enclosing git repo', () => {
+ let tempDir: string;
+ let enclosing: string;
+ let projectDir: string;
+
+ beforeEach(async () => {
+ tempDir = await mkdtemp(join(tmpdir(), 'ktx-nested-git-root-'));
+ enclosing = join(tempDir, 'enclosing');
+ await mkdir(enclosing, { recursive: true });
+ execFileSync('git', ['init', '-q'], { cwd: enclosing });
+ projectDir = join(enclosing, 'analytics');
+ });
+
+ afterEach(async () => {
+ await rm(tempDir, { recursive: true, force: true });
+ });
+
+ it('indexes a wiki page written through a session worktree and squash-merged into main', async () => {
+ const project: KtxLocalProject = await initKtxProject({
+ projectDir,
+ authorName: AUTHOR,
+ authorEmail: EMAIL,
+ });
+
+ // Mirror the ingest write path: create a session worktree, write the page on its
+ // branch through the worktree-scoped file store, then squash-merge into main.
+ const mainHead = await project.git.revParseHead();
+ const workdir = join(projectDir, '.ktx/worktrees/session-test');
+ const branch = 'session/test';
+ await project.git.addWorktree(workdir, branch, mainHead);
+ const worktreeStore = project.fileStore.forWorktree(workdir);
+ await worktreeStore.writeFile('wiki/global/revenue.md', WIKI_PAGE, AUTHOR, EMAIL, 'Add revenue page');
+ const merge = await project.git.squashMergeIntoMain(branch, AUTHOR, EMAIL, 'Merge session');
+ expect(merge.ok).toBe(true);
+ await project.git.removeWorktree(workdir);
+ await project.git.deleteBranch(branch, true);
+
+ // The page must land inside the project dir, not the enclosing git root.
+ await expect(stat(join(projectDir, 'wiki/global/revenue.md'))).resolves.toBeDefined();
+ await expect(stat(join(enclosing, 'wiki/global/revenue.md'))).rejects.toMatchObject({ code: 'ENOENT' });
+
+ // ...and reindex must discover and index it.
+ const summary = await reindexLocalIndexes(project, { force: false, embeddingService: null });
+ const global = summary.scopes.find((scope) => scope.label === 'global');
+ expect(global).toMatchObject({ scanned: 1, updated: 1 });
+ });
+});
diff --git a/packages/cli/test/context/ingest/adapters/historic-sql/query-history-filter-picker.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/query-history-filter-picker.test.ts
index 4c295092..5c9e2e60 100644
--- a/packages/cli/test/context/ingest/adapters/historic-sql/query-history-filter-picker.test.ts
+++ b/packages/cli/test/context/ingest/adapters/historic-sql/query-history-filter-picker.test.ts
@@ -64,6 +64,27 @@ function sqlAnalysis(tablesById: Record>,
+ errorIds: string[],
+): SqlAnalysisPort {
+ const errors = new Set(errorIds);
+ return {
+ analyzeForFingerprint: vi.fn(),
+ analyzeBatch: vi.fn(async (items: SqlAnalysisBatchItem[]): Promise