ktx/packages/cli/src/setup-embeddings.ts

486 lines
16 KiB
TypeScript
Raw Normal View History

2026-05-10 23:12:26 +02:00
import { writeFile } from 'node:fs/promises';
import { cancel, isCancel, password, select } from '@clack/prompts';
2026-05-10 23:51:24 +02:00
import { resolveKtxConfigReference } from '@ktx/context/core';
2026-05-10 23:12:26 +02:00
import {
2026-05-10 23:51:24 +02:00
type KtxProjectConfig,
type KtxProjectEmbeddingConfig,
loadKtxProject,
markKtxSetupStepComplete,
serializeKtxProjectConfig,
} from '@ktx/context/project';
import { type KtxEmbeddingConfig, type KtxEmbeddingHealthCheckResult, runKtxEmbeddingHealthCheck } from '@ktx/llm';
import type { KtxCliIo } from './cli-runtime.js';
2026-05-10 23:12:26 +02:00
import { withMenuOptionsSpacing, withTextInputNavigation } from './prompt-navigation.js';
import { withSetupInterruptConfirmation } from './setup-interrupt.js';
import { envCredentialReference, writeProjectLocalSecretReference } from './setup-secrets.js';
2026-05-10 23:51:24 +02:00
export type KtxSetupEmbeddingBackend = 'openai' | 'sentence-transformers';
2026-05-10 23:12:26 +02:00
2026-05-10 23:51:24 +02:00
export interface KtxSetupEmbeddingsArgs {
2026-05-10 23:12:26 +02:00
projectDir: string;
inputMode: 'auto' | 'disabled';
2026-05-10 23:51:24 +02:00
embeddingBackend?: KtxSetupEmbeddingBackend;
2026-05-10 23:12:26 +02:00
embeddingApiKeyEnv?: string;
embeddingApiKeyFile?: string;
forcePrompt?: boolean;
showPromptInstructions?: boolean;
skipEmbeddings: boolean;
}
2026-05-10 23:51:24 +02:00
export type KtxSetupEmbeddingsResult =
2026-05-10 23:12:26 +02:00
| { status: 'ready'; projectDir: string }
| { status: 'skipped'; projectDir: string }
| { status: 'back'; projectDir: string }
| { status: 'missing-input'; projectDir: string }
| { status: 'failed'; projectDir: string };
2026-05-10 23:51:24 +02:00
export interface KtxSetupEmbeddingsPromptAdapter {
2026-05-10 23:12:26 +02:00
select(options: { message: string; options: Array<{ value: string; label: string }> }): Promise<string>;
password(options: { message: string }): Promise<string | undefined>;
cancel(message: string): void;
}
2026-05-10 23:51:24 +02:00
export interface KtxSetupEmbeddingsDeps {
2026-05-10 23:12:26 +02:00
env?: NodeJS.ProcessEnv;
2026-05-10 23:51:24 +02:00
prompts?: KtxSetupEmbeddingsPromptAdapter;
healthCheck?: (config: KtxEmbeddingConfig) => Promise<KtxEmbeddingHealthCheckResult>;
2026-05-10 23:12:26 +02:00
}
2026-05-10 23:51:24 +02:00
type BackendChoice = KtxSetupEmbeddingBackend | 'back';
2026-05-10 23:12:26 +02:00
const DEFAULTS: Record<
2026-05-10 23:51:24 +02:00
KtxSetupEmbeddingBackend,
2026-05-10 23:12:26 +02:00
{ model: string; dimensions: number; envName?: string; baseUrl?: string; pathPrefix?: string }
> = {
openai: { model: 'text-embedding-3-small', dimensions: 1536, envName: 'OPENAI_API_KEY' },
'sentence-transformers': {
model: 'all-MiniLM-L6-v2',
dimensions: 384,
baseUrl: 'http://127.0.0.1:8765',
pathPrefix: '',
},
};
2026-05-10 23:51:24 +02:00
const LOCAL_EMBEDDING_BACKEND: KtxSetupEmbeddingBackend = 'sentence-transformers';
const LOCAL_EMBEDDING_DAEMON_COMMAND = 'ktx-daemon serve-http --host 127.0.0.1 --port 8765';
2026-05-10 23:12:26 +02:00
const LOCAL_EMBEDDING_DAEMON_DEV_COMMAND =
2026-05-10 23:51:24 +02:00
'cd ktx && source .venv/bin/activate && uv run ktx-daemon serve-http --host 127.0.0.1 --port 8765';
2026-05-10 23:12:26 +02:00
const EMBEDDING_OPTION_PROMPT_CONTEXT =
2026-05-10 23:51:24 +02:00
'KTX uses embeddings for semantic search over semantic-layer sources, wiki context, schema metadata, ' +
2026-05-10 23:12:26 +02:00
'and relationship evidence.';
const LOCAL_EMBEDDING_HEALTH_TIMEOUT_MS = 120_000;
const HEALTH_CHECK_SPINNER_FRAMES = ['-', '\\', '|', '/'] as const;
const HEALTH_CHECK_SPINNER_INTERVAL_MS = 120;
const CLEAR_CURRENT_LINE = '\x1b[2K\r';
interface HealthCheckProgress {
succeed(message: string): void;
fail(message: string): void;
}
2026-05-10 23:51:24 +02:00
function createPromptAdapter(): KtxSetupEmbeddingsPromptAdapter {
2026-05-10 23:12:26 +02:00
return {
async select(options) {
const value = await withSetupInterruptConfirmation(() => select(withMenuOptionsSpacing(options)));
if (isCancel(value)) {
cancel('Setup cancelled.');
return 'back';
}
return value;
},
async password(options) {
const value = await withSetupInterruptConfirmation(() =>
password({ ...options, message: withTextInputNavigation(options.message) }),
);
return isCancel(value) ? undefined : value;
},
cancel(message) {
cancel(message);
},
};
}
2026-05-10 23:51:24 +02:00
function hasCompletedEmbeddings(config: KtxProjectConfig): boolean {
2026-05-10 23:12:26 +02:00
return (
config.setup?.completed_steps.includes('embeddings') === true &&
config.ingest.embeddings.backend !== 'none' &&
config.ingest.embeddings.backend !== 'deterministic' &&
typeof config.ingest.embeddings.model === 'string' &&
config.ingest.embeddings.model.length > 0 &&
config.ingest.embeddings.dimensions > 0
);
}
function buildProjectEmbeddingConfig(input: {
2026-05-10 23:51:24 +02:00
backend: KtxSetupEmbeddingBackend;
2026-05-10 23:12:26 +02:00
model: string;
dimensions: number;
credentialRef?: string;
2026-05-10 23:51:24 +02:00
}): KtxProjectEmbeddingConfig {
2026-05-10 23:12:26 +02:00
if (input.backend === 'openai') {
return {
backend: 'openai',
model: input.model,
dimensions: input.dimensions,
openai: {
...(input.credentialRef ? { api_key: input.credentialRef } : {}),
},
};
}
const defaults = DEFAULTS[input.backend];
return {
backend: input.backend,
model: input.model,
dimensions: input.dimensions,
sentenceTransformers: {
base_url: defaults.baseUrl ?? '',
pathPrefix: defaults.pathPrefix ?? '',
},
};
}
function buildHealthConfig(input: {
2026-05-10 23:51:24 +02:00
backend: KtxSetupEmbeddingBackend;
2026-05-10 23:12:26 +02:00
model: string;
dimensions: number;
credentialValue?: string;
2026-05-10 23:51:24 +02:00
}): KtxEmbeddingConfig {
2026-05-10 23:12:26 +02:00
if (input.backend === 'openai') {
return {
backend: 'openai',
model: input.model,
dimensions: input.dimensions,
openai: {
...(input.credentialValue ? { apiKey: input.credentialValue } : {}),
},
};
}
const defaults = DEFAULTS[input.backend];
return {
backend: input.backend,
model: input.model,
dimensions: input.dimensions,
sentenceTransformers: {
baseURL: defaults.baseUrl ?? '',
pathPrefix: defaults.pathPrefix ?? '',
},
};
}
2026-05-10 23:51:24 +02:00
function embeddingBackendDisplayName(backend: KtxSetupEmbeddingBackend): string {
2026-05-10 23:12:26 +02:00
if (backend === 'openai') {
return 'OpenAI';
}
return 'sentence-transformers';
}
2026-05-10 23:51:24 +02:00
async function persistEmbeddingConfig(projectDir: string, embeddings: KtxProjectEmbeddingConfig): Promise<void> {
const project = await loadKtxProject({ projectDir });
const config = markKtxSetupStepComplete(
2026-05-10 23:12:26 +02:00
{
...project.config,
ingest: {
...project.config.ingest,
embeddings,
},
scan: {
...project.config.scan,
enrichment: {
...project.config.scan.enrichment,
embeddings,
},
},
},
'embeddings',
);
2026-05-10 23:51:24 +02:00
await writeFile(project.configPath, serializeKtxProjectConfig(config), 'utf-8');
2026-05-10 23:12:26 +02:00
}
async function chooseCredentialRef(
2026-05-10 23:51:24 +02:00
backend: Extract<KtxSetupEmbeddingBackend, 'openai'>,
args: KtxSetupEmbeddingsArgs,
io: KtxCliIo,
deps: KtxSetupEmbeddingsDeps,
2026-05-10 23:12:26 +02:00
): Promise<{ status: 'ready'; ref: string; value: string } | { status: 'back' | 'missing-input' }> {
const env = deps.env ?? process.env;
if (args.embeddingApiKeyEnv) {
const ref = envCredentialReference(args.embeddingApiKeyEnv);
2026-05-10 23:51:24 +02:00
const value = resolveKtxConfigReference(ref, env);
2026-05-10 23:12:26 +02:00
if (!value) {
io.stderr.write(`Missing embedding API key: ${args.embeddingApiKeyEnv} is not set.\n`);
return { status: 'missing-input' };
}
return { status: 'ready', ref, value };
}
if (args.embeddingApiKeyFile) {
const ref = `file:${args.embeddingApiKeyFile}`;
let value: string | undefined;
try {
2026-05-10 23:51:24 +02:00
value = resolveKtxConfigReference(ref, env);
2026-05-10 23:12:26 +02:00
} catch {
value = undefined;
}
if (!value) {
io.stderr.write(`Missing embedding API key file: ${args.embeddingApiKeyFile}\n`);
return { status: 'missing-input' };
}
return { status: 'ready', ref, value };
}
if (args.inputMode === 'disabled') {
io.stderr.write('Missing embedding API key: pass --embedding-api-key-env or --embedding-api-key-file.\n');
return { status: 'missing-input' };
}
const defaultEnv = DEFAULTS[backend].envName ?? 'EMBEDDING_API_KEY';
const prompts = deps.prompts ?? createPromptAdapter();
const choice = await prompts.select({
2026-05-10 23:51:24 +02:00
message: `How should KTX find your ${embeddingBackendDisplayName(backend)} embedding API key?`,
2026-05-10 23:12:26 +02:00
options: [
{ value: 'env', label: `Use ${defaultEnv} from the environment` },
{ value: 'paste', label: 'Paste a key and save it as a local secret file' },
{ value: 'back', label: 'Back' },
],
});
if (choice === 'back') {
return { status: 'back' };
}
if (choice === 'paste') {
io.stdout.write(
`${[
2026-05-10 23:51:24 +02:00
`KTX will save the key in .ktx/secrets/${backend}-api-key with local file permissions,`,
'then write a file: reference in ktx.yaml.',
2026-05-10 23:12:26 +02:00
].join(' ')}\n`,
);
const value = await prompts.password({ message: withTextInputNavigation(`${backend} embedding API key`) });
if (value === undefined) {
return { status: 'back' };
}
if (!value.trim()) {
return { status: 'missing-input' };
}
const ref = await writeProjectLocalSecretReference({
projectDir: args.projectDir,
fileName: `${backend}-api-key`,
value,
});
return { status: 'ready', ref, value: value.trim() };
}
const ref = envCredentialReference(defaultEnv);
2026-05-10 23:51:24 +02:00
const value = resolveKtxConfigReference(ref, env);
2026-05-10 23:12:26 +02:00
if (!value) {
io.stderr.write(`Missing embedding API key: ${defaultEnv} is not set.\n`);
return { status: 'missing-input' };
}
return { status: 'ready', ref, value };
}
async function chooseEmbeddingBackend(
2026-05-10 23:51:24 +02:00
args: KtxSetupEmbeddingsArgs,
deps: KtxSetupEmbeddingsDeps,
2026-05-10 23:12:26 +02:00
): Promise<BackendChoice> {
if (args.embeddingBackend) {
return args.embeddingBackend;
}
if (args.inputMode === 'disabled') {
return LOCAL_EMBEDDING_BACKEND;
}
const choice = await (deps.prompts ?? createPromptAdapter()).select({
2026-05-10 23:51:24 +02:00
message: `Which embedding option should KTX use?\n\n${EMBEDDING_OPTION_PROMPT_CONTEXT}`,
2026-05-10 23:12:26 +02:00
options: [
{ value: 'sentence-transformers', label: 'Local sentence-transformers embeddings' },
{ value: 'openai', label: 'OpenAI embeddings (recommended)' },
{ value: 'back', label: 'Back' },
],
});
if (choice === 'openai' || choice === 'sentence-transformers' || choice === 'back') {
return choice;
}
return 'back';
}
function localEmbeddingSetupMessage(message: string): string {
return [
`Local embedding health check failed: ${message}`,
2026-05-10 23:51:24 +02:00
'Local embeddings use the KTX Python daemon. KTX can call ktx-daemon automatically when it is on PATH.',
2026-05-10 23:12:26 +02:00
`For repeated inference, start the HTTP daemon in another terminal with: ${LOCAL_EMBEDDING_DAEMON_COMMAND}`,
2026-05-10 23:51:24 +02:00
`From the KTX repo, use: ${LOCAL_EMBEDDING_DAEMON_DEV_COMMAND}`,
2026-05-10 23:12:26 +02:00
'The first run may download the all-MiniLM-L6-v2 model, so it can take a minute.',
].join('\n');
}
async function promptAfterLocalEmbeddingFailure(
2026-05-10 23:51:24 +02:00
deps: KtxSetupEmbeddingsDeps,
): Promise<'retry' | Extract<KtxSetupEmbeddingBackend, 'openai'> | 'back'> {
2026-05-10 23:12:26 +02:00
const choice = await (deps.prompts ?? createPromptAdapter()).select({
2026-05-10 23:51:24 +02:00
message: 'Local embeddings are not reachable. Start the local KTX daemon, then retry.',
2026-05-10 23:12:26 +02:00
options: [
{ value: 'retry', label: 'Retry' },
{ value: 'openai', label: 'Use OpenAI embeddings' },
{ value: 'back', label: 'Back' },
],
});
if (choice === 'openai' || choice === 'back') {
return choice;
}
return 'retry';
}
2026-05-10 23:51:24 +02:00
function healthCheckStartText(backend: KtxSetupEmbeddingBackend, model: string, dimensions: number): string {
2026-05-10 23:12:26 +02:00
if (backend === LOCAL_EMBEDDING_BACKEND) {
return [
`Testing local sentence-transformers embeddings (${model}, ${dimensions} dimensions).`,
'First run may take up to 60 seconds.',
].join(' ');
}
return `Checking ${backend} embeddings (${model}, ${dimensions} dimensions).`;
}
2026-05-10 23:51:24 +02:00
function startHealthCheckProgress(io: KtxCliIo, message: string): HealthCheckProgress {
2026-05-10 23:12:26 +02:00
if (io.stdout.isTTY !== true) {
io.stdout.write(`${message}\n`);
const noop = () => undefined;
return {
succeed: noop,
fail: noop,
};
}
let frameIndex = 0;
let stopped = false;
const writeFrame = () => {
io.stdout.write(`${CLEAR_CURRENT_LINE}${HEALTH_CHECK_SPINNER_FRAMES[frameIndex]} ${message}`);
};
writeFrame();
const interval = setInterval(() => {
frameIndex = (frameIndex + 1) % HEALTH_CHECK_SPINNER_FRAMES.length;
writeFrame();
}, HEALTH_CHECK_SPINNER_INTERVAL_MS);
const stop = (finalMessage: string) => {
if (stopped) {
return;
}
stopped = true;
clearInterval(interval);
io.stdout.write(`${CLEAR_CURRENT_LINE}${finalMessage}\n`);
};
return {
succeed(message) {
stop(message);
},
fail(message) {
stop(message);
},
};
}
2026-05-10 23:51:24 +02:00
export async function runKtxSetupEmbeddingsStep(
args: KtxSetupEmbeddingsArgs,
io: KtxCliIo,
deps: KtxSetupEmbeddingsDeps = {},
): Promise<KtxSetupEmbeddingsResult> {
2026-05-10 23:12:26 +02:00
if (args.skipEmbeddings) {
io.stdout.write('Embeddings setup skipped.\n');
return { status: 'skipped', projectDir: args.projectDir };
}
2026-05-10 23:51:24 +02:00
const project = await loadKtxProject({ projectDir: args.projectDir });
2026-05-10 23:12:26 +02:00
if (
args.forcePrompt !== true &&
hasCompletedEmbeddings(project.config) &&
!args.embeddingBackend &&
!args.embeddingApiKeyEnv &&
!args.embeddingApiKeyFile
) {
io.stdout.write(`Embeddings ready: yes (${project.config.ingest.embeddings.model})\n`);
return { status: 'ready', projectDir: args.projectDir };
}
const healthCheck =
deps.healthCheck ??
2026-05-10 23:51:24 +02:00
((config: KtxEmbeddingConfig) =>
runKtxEmbeddingHealthCheck(config, { timeoutMs: LOCAL_EMBEDDING_HEALTH_TIMEOUT_MS }));
let selectedBackend: KtxSetupEmbeddingBackend | undefined;
2026-05-10 23:12:26 +02:00
while (true) {
if (!selectedBackend) {
const backend = await chooseEmbeddingBackend(args, deps);
if (backend === 'back') {
return { status: 'back', projectDir: args.projectDir };
}
selectedBackend = backend;
}
const defaults = DEFAULTS[selectedBackend];
const model = defaults.model;
const dimensions = defaults.dimensions;
let credentialRef: string | undefined;
let credentialValue: string | undefined;
if (selectedBackend === 'openai') {
const credential = await chooseCredentialRef(selectedBackend, args, io, deps);
if (credential.status === 'back' && !args.embeddingBackend && args.inputMode !== 'disabled') {
selectedBackend = undefined;
continue;
}
if (credential.status !== 'ready') {
return { status: credential.status, projectDir: args.projectDir };
}
credentialRef = credential.ref;
credentialValue = credential.value;
}
const healthConfig = buildHealthConfig({
backend: selectedBackend,
model,
dimensions,
credentialValue,
});
const progress = startHealthCheckProgress(io, healthCheckStartText(selectedBackend, model, dimensions));
2026-05-10 23:51:24 +02:00
let health: KtxEmbeddingHealthCheckResult;
2026-05-10 23:12:26 +02:00
try {
health = await healthCheck(healthConfig);
} catch (error) {
progress.fail('Embedding test failed');
throw error;
}
if (health.ok) {
progress.succeed(`Embedding test passed (${model}, ${dimensions} dimensions)`);
await persistEmbeddingConfig(
args.projectDir,
buildProjectEmbeddingConfig({
backend: selectedBackend,
model,
dimensions,
credentialRef,
}),
);
io.stdout.write(`Embeddings ready: yes (${model}, ${dimensions} dimensions)\n`);
return { status: 'ready', projectDir: args.projectDir };
}
progress.fail('Embedding test failed');
io.stderr.write(
selectedBackend === 'sentence-transformers'
? `${localEmbeddingSetupMessage(health.message)}\n`
: `Embedding health check failed: ${health.message}\n`,
);
if (args.inputMode === 'disabled') {
return { status: 'failed', projectDir: args.projectDir };
}
if (selectedBackend !== 'sentence-transformers' && (args.embeddingApiKeyEnv || args.embeddingApiKeyFile)) {
return { status: 'failed', projectDir: args.projectDir };
}
const nextAction =
selectedBackend === 'sentence-transformers' ? await promptAfterLocalEmbeddingFailure(deps) : 'retry';
if (nextAction === 'back') {
return { status: 'back', projectDir: args.projectDir };
}
if (nextAction === 'openai') {
selectedBackend = nextAction;
}
}
}