From 994bc2314782d03e6f45db37b2b7d8c0f6d0c9f0 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Mon, 11 May 2026 11:09:55 +0200 Subject: [PATCH] docs: add plan for managed local embeddings runtime --- ...-05-11-managed-local-embeddings-runtime.md | 1122 +++++++++++++++++ 1 file changed, 1122 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-11-managed-local-embeddings-runtime.md diff --git a/docs/superpowers/plans/2026-05-11-managed-local-embeddings-runtime.md b/docs/superpowers/plans/2026-05-11-managed-local-embeddings-runtime.md new file mode 100644 index 00000000..c2023c96 --- /dev/null +++ b/docs/superpowers/plans/2026-05-11-managed-local-embeddings-runtime.md @@ -0,0 +1,1122 @@ +# Managed Local Embeddings Runtime Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make local `sentence-transformers` embedding setup use the +KTX-managed Python runtime and daemon instead of requiring users to start a +manual `ktx-daemon` process. + +**Architecture:** Add one managed local-embedding helper in the CLI that +prompts or fails according to the existing runtime install policy, starts the +managed daemon with the `local-embeddings` feature, and returns the daemon URL +for health checks. Store a stable managed-runtime marker in `ktx.yaml`, and +teach context embedding config resolution to turn that marker into a daemon URL +only when the CLI has provided one through the environment. + +**Tech Stack:** TypeScript, Vitest, Commander, `@clack/prompts`, KTX managed +Python runtime commands, `@ktx/llm` embedding health checks. + +--- + +## Existing status + +This plan is based on +`docs/superpowers/specs/2026-05-11-npm-managed-python-runtime-design.md`. + +Existing plans based on the spec: + +- `docs/superpowers/plans/2026-05-11-bundled-python-runtime-wheel.md` is + implemented. The worktree contains the runtime wheel builder, runtime wheel + packaging, the `kaelio-ktx` Python artifact policy entry, and matching + artifact tests. +- `docs/superpowers/plans/2026-05-11-managed-python-runtime-installer.md` is + implemented. The worktree contains `managed-python-runtime.ts`, the runtime + command runner, `runtime install`, `status`, `doctor`, and `prune` command + registration, and matching CLI tests. +- `docs/superpowers/plans/2026-05-11-managed-python-runtime-command-integration.md` + is implemented. The worktree contains `managed-python-command.ts`, `ktx sl + query` runtime policy flags, schema validation, and matching `sl` tests. +- `docs/superpowers/plans/2026-05-11-managed-python-runtime-daemon-lifecycle.md` + is implemented. The worktree contains `managed-python-daemon.ts`, daemon + state paths in the runtime layout, `runtime start`, `runtime stop`, Python + `/health` version metadata, and matching TypeScript and Python tests. + +Spec requirements still outside this plan: + +- Public npm package surface rename from `@ktx/cli` to `@kaelio/ktx`. +- Managed runtime usage for non-embedding Python-backed command paths beyond + `ktx sl query`. +- Release smoke coverage for `npx @kaelio/ktx ...` invocation modes. + +This plan implements the next local-embedding runtime slice: + +- Selecting local embeddings installs only the `local-embeddings` runtime + feature. +- Local embedding setup starts or reuses the managed HTTP daemon. +- `--yes` installs and starts without prompting. +- `--no-input` fails with an exact preparation command when the managed local + embedding runtime is missing. +- Project config records a managed local embedding marker instead of a random + daemon port. +- Context embedding resolution only resolves the marker when the CLI provides + the active daemon URL. + +## File structure + +- Modify `packages/context/src/llm/local-config.ts`: define the managed local + embeddings marker and environment variable, and resolve that marker to a + runtime daemon URL. +- Modify `packages/context/src/llm/local-config.test.ts`: cover marker + resolution, missing daemon URL behavior, and provider construction. +- Modify `packages/context/src/llm/index.ts`: export the marker constants. +- Modify `packages/context/src/package-exports.test.ts`: assert root exports + expose the marker constants. +- Create `packages/cli/src/managed-local-embeddings.ts`: start or reuse the + managed daemon with `local-embeddings` and build health/project configs. +- Create `packages/cli/src/managed-local-embeddings.test.ts`: cover ready, + `--yes`, prompt, and `--no-input` behavior. +- Modify `packages/cli/src/setup-embeddings.ts`: use the managed helper for + local embeddings and persist the managed marker. +- Modify `packages/cli/src/setup-embeddings.test.ts`: update local embedding + setup expectations and no-input failure behavior. +- Modify `packages/cli/src/setup.ts`: pass CLI version and runtime install + policy into the embeddings step. +- Modify `packages/cli/src/commands/setup-commands.ts`: attach package version + to setup runs. +- Modify `packages/cli/src/cli-program.ts`: attach package version to the bare + interactive setup path. +- Modify `packages/cli/src/index.ts`: export the managed local embedding helper + for tests and programmatic use. +- Modify `packages/cli/src/index.test.ts` and `packages/cli/src/setup.test.ts`: + update setup argument expectations for `cliVersion`. + +### Task 1: Add managed embedding marker resolution in context + +**Files:** + +- Modify: `packages/context/src/llm/local-config.test.ts` +- Modify: `packages/context/src/llm/local-config.ts` +- Modify: `packages/context/src/llm/index.ts` +- Modify: `packages/context/src/package-exports.test.ts` + +- [ ] **Step 1: Write failing marker resolution tests** + +In `packages/context/src/llm/local-config.test.ts`, extend the import from +`./local-config.js` so it includes the new constants: + +```typescript +import { + MANAGED_SENTENCE_TRANSFORMERS_BASE_URL, + MANAGED_SENTENCE_TRANSFORMERS_BASE_URL_ENV, + createLocalKtxEmbeddingProviderFromConfig, + createLocalKtxLlmProviderFromConfig, + resolveLocalKtxEmbeddingConfig, + resolveLocalKtxLlmConfig, +} from './local-config.js'; +``` + +Add these tests inside `describe('local KTX embedding config', () => { ... })` +after the existing `resolves sentence-transformers config` test: + +```typescript + it('resolves managed sentence-transformers config from the CLI-provided daemon URL', () => { + const config: KtxProjectEmbeddingConfig = { + backend: 'sentence-transformers', + model: 'all-MiniLM-L6-v2', + dimensions: 384, + sentenceTransformers: { + base_url: MANAGED_SENTENCE_TRANSFORMERS_BASE_URL, + pathPrefix: '', + }, + batchSize: 32, + }; + + expect( + resolveLocalKtxEmbeddingConfig(config, { + [MANAGED_SENTENCE_TRANSFORMERS_BASE_URL_ENV]: 'http://127.0.0.1:61234', + }), + ).toEqual({ + backend: 'sentence-transformers', + model: 'all-MiniLM-L6-v2', + dimensions: 384, + sentenceTransformers: { baseURL: 'http://127.0.0.1:61234', pathPrefix: '' }, + batchSize: 32, + }); + }); + + it('returns null for managed sentence-transformers when no daemon URL is available', () => { + const config: KtxProjectEmbeddingConfig = { + backend: 'sentence-transformers', + model: 'all-MiniLM-L6-v2', + dimensions: 384, + sentenceTransformers: { + base_url: MANAGED_SENTENCE_TRANSFORMERS_BASE_URL, + pathPrefix: '', + }, + }; + + expect(resolveLocalKtxEmbeddingConfig(config, {})).toBeNull(); + }); +``` + +In `packages/context/src/package-exports.test.ts`, add these assertions after +the existing `root.createLocalKtxEmbeddingProviderFromConfig` assertion: + +```typescript + expect(root.MANAGED_SENTENCE_TRANSFORMERS_BASE_URL).toBe('managed:local-embeddings'); + expect(root.MANAGED_SENTENCE_TRANSFORMERS_BASE_URL_ENV).toBe( + 'KTX_MANAGED_SENTENCE_TRANSFORMERS_BASE_URL', + ); +``` + +- [ ] **Step 2: Run the failing context tests** + +Run: + +```bash +pnpm --filter @ktx/context run test -- src/llm/local-config.test.ts src/package-exports.test.ts +``` + +Expected: FAIL with missing exports for +`MANAGED_SENTENCE_TRANSFORMERS_BASE_URL` and +`MANAGED_SENTENCE_TRANSFORMERS_BASE_URL_ENV`. + +- [ ] **Step 3: Implement marker resolution** + +In `packages/context/src/llm/local-config.ts`, add these exports after the +`LocalConfigDeps` interface: + +```typescript +export const MANAGED_SENTENCE_TRANSFORMERS_BASE_URL = 'managed:local-embeddings'; +export const MANAGED_SENTENCE_TRANSFORMERS_BASE_URL_ENV = 'KTX_MANAGED_SENTENCE_TRANSFORMERS_BASE_URL'; +``` + +Add this helper before `resolveLocalKtxEmbeddingConfig`: + +```typescript +function resolveSentenceTransformersBaseUrl(value: string | undefined, env: NodeJS.ProcessEnv): string | undefined { + if (!value) { + return undefined; + } + if (value === MANAGED_SENTENCE_TRANSFORMERS_BASE_URL) { + return resolveOptional(`env:${MANAGED_SENTENCE_TRANSFORMERS_BASE_URL_ENV}`, env); + } + return value; +} +``` + +Replace `resolveLocalKtxEmbeddingConfig` with this implementation: + +```typescript +export function resolveLocalKtxEmbeddingConfig( + config: KtxProjectEmbeddingConfig, + env: NodeJS.ProcessEnv, +): KtxEmbeddingConfig | null { + if (config.backend === 'none') { + return null; + } + if (config.backend === 'sentence-transformers') { + const baseURL = resolveSentenceTransformersBaseUrl(config.sentenceTransformers?.base_url, env); + if (!baseURL) { + return null; + } + return { + backend: config.backend, + model: config.model ?? 'all-MiniLM-L6-v2', + dimensions: config.dimensions, + sentenceTransformers: { + baseURL, + pathPrefix: config.sentenceTransformers?.pathPrefix, + }, + batchSize: config.batchSize, + }; + } + return { + backend: config.backend, + model: config.model ?? 'deterministic', + dimensions: config.dimensions, + ...(resolvedProviderConfig(config.openai, env) ? { openai: resolvedProviderConfig(config.openai, env) } : {}), + batchSize: config.batchSize, + }; +} +``` + +In `packages/context/src/llm/index.ts`, add the new constants to the existing +export from `./local-config.js`: + +```typescript +export { + MANAGED_SENTENCE_TRANSFORMERS_BASE_URL, + MANAGED_SENTENCE_TRANSFORMERS_BASE_URL_ENV, + createLocalKtxEmbeddingProviderFromConfig, + createLocalKtxLlmProviderFromConfig, + resolveLocalKtxEmbeddingConfig, + resolveLocalKtxLlmConfig, +} from './local-config.js'; +``` + +- [ ] **Step 4: Verify the context marker tests pass** + +Run: + +```bash +pnpm --filter @ktx/context run test -- src/llm/local-config.test.ts src/package-exports.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +Run: + +```bash +git add packages/context/src/llm/local-config.ts packages/context/src/llm/local-config.test.ts packages/context/src/llm/index.ts packages/context/src/package-exports.test.ts +git commit -m "feat: add managed local embeddings config marker" +``` + +### Task 2: Add the managed local embeddings CLI helper + +**Files:** + +- Create: `packages/cli/src/managed-local-embeddings.test.ts` +- Create: `packages/cli/src/managed-local-embeddings.ts` +- Modify: `packages/cli/src/index.ts` + +- [ ] **Step 1: Write the failing helper tests** + +Create `packages/cli/src/managed-local-embeddings.test.ts` with this content: + +```typescript +import { describe, expect, it, vi } from 'vitest'; +import { + MANAGED_SENTENCE_TRANSFORMERS_BASE_URL, + MANAGED_SENTENCE_TRANSFORMERS_BASE_URL_ENV, +} from '@ktx/context'; +import { + ensureManagedLocalEmbeddingsDaemon, + managedLocalEmbeddingHealthConfig, + managedLocalEmbeddingProjectConfig, +} from './managed-local-embeddings.js'; +import type { ManagedPythonCommandRuntime } from './managed-python-command.js'; +import type { ManagedPythonDaemonStartResult } from './managed-python-daemon.js'; + +function makeIo() { + let stdout = ''; + let stderr = ''; + return { + io: { + stdout: { + write: (chunk: string) => { + stdout += chunk; + }, + }, + stderr: { + write: (chunk: string) => { + stderr += chunk; + }, + }, + }, + stdout: () => stdout, + stderr: () => stderr, + }; +} + +function runtime(): ManagedPythonCommandRuntime { + return { + layout: { + cliVersion: '0.2.0', + runtimeRoot: '/runtime', + versionDir: '/runtime/0.2.0', + venvDir: '/runtime/0.2.0/.venv', + manifestPath: '/runtime/0.2.0/manifest.json', + installLogPath: '/runtime/0.2.0/install.log', + assetDir: '/assets/python', + assetManifestPath: '/assets/python/manifest.json', + pythonPath: '/runtime/0.2.0/.venv/bin/python', + daemonPath: '/runtime/0.2.0/.venv/bin/ktx-daemon', + daemonStatePath: '/runtime/0.2.0/daemon.json', + daemonStdoutPath: '/runtime/0.2.0/daemon.stdout.log', + daemonStderrPath: '/runtime/0.2.0/daemon.stderr.log', + }, + manifest: { + schemaVersion: 1, + cliVersion: '0.2.0', + installedAt: '2026-05-11T00:00:00.000Z', + asset: { + schemaVersion: 1, + distributionName: 'kaelio-ktx', + normalizedName: 'kaelio_ktx', + version: '0.2.0', + wheel: { + file: 'kaelio_ktx-0.2.0-py3-none-any.whl', + sha256: 'a'.repeat(64), + bytes: 123, + }, + }, + features: ['core', 'local-embeddings'], + python: { + executable: '/runtime/0.2.0/.venv/bin/python', + daemonExecutable: '/runtime/0.2.0/.venv/bin/ktx-daemon', + }, + installLog: '/runtime/0.2.0/install.log', + }, + }; +} + +function daemonResult(status: 'started' | 'reused' = 'reused'): ManagedPythonDaemonStartResult { + return { + status, + layout: runtime().layout, + baseUrl: 'http://127.0.0.1:61234', + state: { + schemaVersion: 1, + pid: 12345, + host: '127.0.0.1', + port: 61234, + version: '0.2.0', + features: ['core', 'local-embeddings'], + startedAt: '2026-05-11T00:00:00.000Z', + stdoutLog: '/runtime/0.2.0/daemon.stdout.log', + stderrLog: '/runtime/0.2.0/daemon.stderr.log', + }, + }; +} + +describe('managedLocalEmbeddingProjectConfig', () => { + it('uses a stable managed runtime marker instead of a random daemon port', () => { + expect( + managedLocalEmbeddingProjectConfig({ + model: 'all-MiniLM-L6-v2', + dimensions: 384, + }), + ).toEqual({ + backend: 'sentence-transformers', + model: 'all-MiniLM-L6-v2', + dimensions: 384, + sentenceTransformers: { + base_url: MANAGED_SENTENCE_TRANSFORMERS_BASE_URL, + pathPrefix: '', + }, + }); + }); +}); + +describe('managedLocalEmbeddingHealthConfig', () => { + it('uses the active managed daemon URL for the immediate health check', () => { + expect( + managedLocalEmbeddingHealthConfig({ + baseUrl: 'http://127.0.0.1:61234', + model: 'all-MiniLM-L6-v2', + dimensions: 384, + }), + ).toEqual({ + backend: 'sentence-transformers', + model: 'all-MiniLM-L6-v2', + dimensions: 384, + sentenceTransformers: { baseURL: 'http://127.0.0.1:61234', pathPrefix: '' }, + }); + }); +}); + +describe('ensureManagedLocalEmbeddingsDaemon', () => { + it('ensures the local-embeddings feature and starts the managed daemon', async () => { + const io = makeIo(); + const ensureRuntime = vi.fn(async () => runtime()); + const startDaemon = vi.fn(async () => daemonResult('started')); + + await expect( + ensureManagedLocalEmbeddingsDaemon({ + cliVersion: '0.2.0', + installPolicy: 'auto', + io: io.io, + ensureRuntime, + startDaemon, + }), + ).resolves.toEqual({ + baseUrl: 'http://127.0.0.1:61234', + env: { + [MANAGED_SENTENCE_TRANSFORMERS_BASE_URL_ENV]: 'http://127.0.0.1:61234', + }, + }); + + expect(ensureRuntime).toHaveBeenCalledWith({ + cliVersion: '0.2.0', + installPolicy: 'auto', + io: io.io, + feature: 'local-embeddings', + }); + expect(startDaemon).toHaveBeenCalledWith({ + cliVersion: '0.2.0', + features: ['local-embeddings'], + force: false, + }); + expect(io.stderr()).toContain('Started KTX local embeddings daemon: http://127.0.0.1:61234'); + }); + + it('reuses an already running daemon without reporting a new start', async () => { + const io = makeIo(); + + await ensureManagedLocalEmbeddingsDaemon({ + cliVersion: '0.2.0', + installPolicy: 'prompt', + io: io.io, + ensureRuntime: vi.fn(async () => runtime()), + startDaemon: vi.fn(async () => daemonResult('reused')), + }); + + expect(io.stderr()).toContain('Using KTX local embeddings daemon: http://127.0.0.1:61234'); + }); +}); +``` + +- [ ] **Step 2: Run the failing helper tests** + +Run: + +```bash +pnpm --filter @ktx/cli run test -- src/managed-local-embeddings.test.ts +``` + +Expected: FAIL with an import error for +`./managed-local-embeddings.js`. + +- [ ] **Step 3: Implement the helper** + +Create `packages/cli/src/managed-local-embeddings.ts` with this content: + +```typescript +import { + MANAGED_SENTENCE_TRANSFORMERS_BASE_URL, + MANAGED_SENTENCE_TRANSFORMERS_BASE_URL_ENV, +} from '@ktx/context'; +import type { KtxProjectEmbeddingConfig } from '@ktx/context/project'; +import type { KtxEmbeddingConfig } from '@ktx/llm'; +import type { KtxCliIo } from './cli-runtime.js'; +import { + ensureManagedPythonCommandRuntime, + type KtxManagedPythonInstallPolicy, + type ManagedPythonCommandRuntime, +} from './managed-python-command.js'; +import { startManagedPythonDaemon, type ManagedPythonDaemonStartResult } from './managed-python-daemon.js'; + +export interface ManagedLocalEmbeddingsDaemon { + baseUrl: string; + env: Record; +} + +export interface ManagedLocalEmbeddingsOptions { + cliVersion: string; + installPolicy: KtxManagedPythonInstallPolicy; + io: KtxCliIo; + ensureRuntime?: (options: { + cliVersion: string; + installPolicy: KtxManagedPythonInstallPolicy; + io: KtxCliIo; + feature: 'local-embeddings'; + }) => Promise; + startDaemon?: (options: { + cliVersion: string; + features: ['local-embeddings']; + force: boolean; + }) => Promise; +} + +export function managedLocalEmbeddingProjectConfig(input: { + model: string; + dimensions: number; +}): KtxProjectEmbeddingConfig { + return { + backend: 'sentence-transformers', + model: input.model, + dimensions: input.dimensions, + sentenceTransformers: { + base_url: MANAGED_SENTENCE_TRANSFORMERS_BASE_URL, + pathPrefix: '', + }, + }; +} + +export function managedLocalEmbeddingHealthConfig(input: { + baseUrl: string; + model: string; + dimensions: number; +}): KtxEmbeddingConfig { + return { + backend: 'sentence-transformers', + model: input.model, + dimensions: input.dimensions, + sentenceTransformers: { + baseURL: input.baseUrl, + pathPrefix: '', + }, + }; +} + +export async function ensureManagedLocalEmbeddingsDaemon( + options: ManagedLocalEmbeddingsOptions, +): Promise { + const ensureRuntime = options.ensureRuntime ?? ensureManagedPythonCommandRuntime; + const startDaemon = options.startDaemon ?? startManagedPythonDaemon; + + await ensureRuntime({ + cliVersion: options.cliVersion, + installPolicy: options.installPolicy, + io: options.io, + feature: 'local-embeddings', + }); + const daemon = await startDaemon({ + cliVersion: options.cliVersion, + features: ['local-embeddings'], + force: false, + }); + + const verb = daemon.status === 'started' ? 'Started' : 'Using'; + options.io.stderr.write(`${verb} KTX local embeddings daemon: ${daemon.baseUrl}\n`); + + return { + baseUrl: daemon.baseUrl, + env: { + [MANAGED_SENTENCE_TRANSFORMERS_BASE_URL_ENV]: daemon.baseUrl, + }, + }; +} +``` + +In `packages/cli/src/index.ts`, add this export after the existing +`managed-python-daemon.js` exports: + +```typescript +export { + ensureManagedLocalEmbeddingsDaemon, + managedLocalEmbeddingHealthConfig, + managedLocalEmbeddingProjectConfig, + type ManagedLocalEmbeddingsDaemon, + type ManagedLocalEmbeddingsOptions, +} from './managed-local-embeddings.js'; +``` + +- [ ] **Step 4: Verify helper tests pass** + +Run: + +```bash +pnpm --filter @ktx/cli run test -- src/managed-local-embeddings.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +Run: + +```bash +git add packages/cli/src/managed-local-embeddings.ts packages/cli/src/managed-local-embeddings.test.ts packages/cli/src/index.ts +git commit -m "feat: add managed local embeddings daemon helper" +``` + +### Task 3: Wire setup embeddings to the managed runtime + +**Files:** + +- Modify: `packages/cli/src/setup-embeddings.ts` +- Modify: `packages/cli/src/setup-embeddings.test.ts` + +- [ ] **Step 1: Write failing setup tests for managed local embeddings** + +In `packages/cli/src/setup-embeddings.test.ts`, update the import from +`./setup-embeddings.js` so it also imports the managed install policy type: + +```typescript +import { + type KtxSetupEmbeddingsPromptAdapter, + runKtxSetupEmbeddingsStep, +} from './setup-embeddings.js'; +``` + +Add this helper near `makePromptAdapter`: + +```typescript +function managedDaemon(baseUrl = 'http://127.0.0.1:61234') { + return { + baseUrl, + env: { + KTX_MANAGED_SENTENCE_TRANSFORMERS_BASE_URL: baseUrl, + }, + }; +} +``` + +In every `runKtxSetupEmbeddingsStep` call that does not inject an `embeddingBackend: +'openai'`, add these arguments: + +```typescript + cliVersion: '0.2.0', + runtimeInstallPolicy: 'auto', +``` + +In the test named `configures local sentence-transformers embeddings after +interactive selection`, add this dependency: + +```typescript + const ensureLocalEmbeddings = vi.fn(async () => managedDaemon()); +``` + +Pass it in the deps object: + +```typescript + { prompts, env: {}, healthCheck, ensureLocalEmbeddings }, +``` + +Replace the expected health check config in that test with: + +```typescript + expect(ensureLocalEmbeddings).toHaveBeenCalledWith({ + cliVersion: '0.2.0', + installPolicy: 'auto', + io: io.io, + }); + expect(healthCheck).toHaveBeenCalledWith({ + backend: 'sentence-transformers', + model: 'all-MiniLM-L6-v2', + dimensions: 384, + sentenceTransformers: { baseURL: 'http://127.0.0.1:61234', pathPrefix: '' }, + }); +``` + +Replace the persisted local embedding expectation in that test with: + +```typescript + expect(config.ingest.embeddings).toMatchObject({ + backend: 'sentence-transformers', + model: 'all-MiniLM-L6-v2', + dimensions: 384, + sentenceTransformers: { base_url: 'managed:local-embeddings', pathPrefix: '' }, + }); +``` + +Add this new test after the existing non-interactive local embeddings test: + +```typescript + it('fails non-interactive local setup when the managed local embeddings runtime is missing', async () => { + const io = makeIo(); + const ensureLocalEmbeddings = vi.fn(async () => { + throw new Error( + 'KTX Python runtime is required for this command. Run: ktx runtime install --feature local-embeddings --yes', + ); + }); + + const result = await runKtxSetupEmbeddingsStep( + { + projectDir: tempDir, + inputMode: 'disabled', + cliVersion: '0.2.0', + runtimeInstallPolicy: 'never', + skipEmbeddings: false, + }, + io.io, + { env: {}, ensureLocalEmbeddings }, + ); + + expect(result.status).toBe('failed'); + expect(io.stderr()).toContain( + 'KTX Python runtime is required for this command. Run: ktx runtime install --feature local-embeddings --yes', + ); + }); +``` + +- [ ] **Step 2: Run the failing setup tests** + +Run: + +```bash +pnpm --filter @ktx/cli run test -- src/setup-embeddings.test.ts +``` + +Expected: FAIL because `KtxSetupEmbeddingsArgs` has no `cliVersion` or +`runtimeInstallPolicy`, and `KtxSetupEmbeddingsDeps` has no +`ensureLocalEmbeddings`. + +- [ ] **Step 3: Update setup embeddings types and imports** + +In `packages/cli/src/setup-embeddings.ts`, add these imports: + +```typescript +import type { KtxManagedPythonInstallPolicy } from './managed-python-command.js'; +import { + ensureManagedLocalEmbeddingsDaemon, + managedLocalEmbeddingHealthConfig, + managedLocalEmbeddingProjectConfig, + type ManagedLocalEmbeddingsDaemon, +} from './managed-local-embeddings.js'; +``` + +Add these fields to `KtxSetupEmbeddingsArgs` after `inputMode`: + +```typescript + cliVersion: string; + runtimeInstallPolicy: KtxManagedPythonInstallPolicy; +``` + +Add this dependency to `KtxSetupEmbeddingsDeps`: + +```typescript + ensureLocalEmbeddings?: (options: { + cliVersion: string; + installPolicy: KtxManagedPythonInstallPolicy; + io: KtxCliIo; + }) => Promise; +``` + +- [ ] **Step 4: Replace manual local daemon messaging and config** + +In `packages/cli/src/setup-embeddings.ts`, remove these constants: + +```typescript +const LOCAL_EMBEDDING_DAEMON_COMMAND = 'ktx-daemon serve-http --host 127.0.0.1 --port 8765'; +const LOCAL_EMBEDDING_DAEMON_DEV_COMMAND = + 'cd ktx && source .venv/bin/activate && uv run ktx-daemon serve-http --host 127.0.0.1 --port 8765'; +``` + +Replace `localEmbeddingSetupMessage` with: + +```typescript +function localEmbeddingSetupMessage(message: string): string { + return [ + `Local embedding health check failed: ${message}`, + 'Local embeddings use the KTX-managed Python runtime.', + 'Prepare the runtime with: ktx runtime start --feature local-embeddings', + 'Use --yes with setup to install and start the runtime without prompting.', + 'The first run may download Python packages and the all-MiniLM-L6-v2 model.', + ].join('\n'); +} +``` + +Inside `runKtxSetupEmbeddingsStep`, before building `healthConfig`, add this +block after the OpenAI credential block: + +```typescript + let managedLocalEmbeddings: ManagedLocalEmbeddingsDaemon | undefined; + if (selectedBackend === LOCAL_EMBEDDING_BACKEND) { + const ensureLocalEmbeddings = deps.ensureLocalEmbeddings ?? ensureManagedLocalEmbeddingsDaemon; + try { + managedLocalEmbeddings = await ensureLocalEmbeddings({ + cliVersion: args.cliVersion, + installPolicy: args.runtimeInstallPolicy, + io, + }); + } catch (error) { + io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + return { status: 'failed', projectDir: args.projectDir }; + } + } +``` + +Replace the `healthConfig` assignment with: + +```typescript + const healthConfig = + selectedBackend === LOCAL_EMBEDDING_BACKEND && managedLocalEmbeddings + ? managedLocalEmbeddingHealthConfig({ + baseUrl: managedLocalEmbeddings.baseUrl, + model, + dimensions, + }) + : buildHealthConfig({ + backend: selectedBackend, + model, + dimensions, + credentialValue, + }); +``` + +Replace the successful local persistence call inside `if (health.ok) { ... }` +with: + +```typescript + await persistEmbeddingConfig( + args.projectDir, + selectedBackend === LOCAL_EMBEDDING_BACKEND + ? managedLocalEmbeddingProjectConfig({ model, dimensions }) + : buildProjectEmbeddingConfig({ + backend: selectedBackend, + model, + dimensions, + credentialRef, + }), + ); +``` + +- [ ] **Step 5: Verify setup embeddings tests pass** + +Run: + +```bash +pnpm --filter @ktx/cli run test -- src/setup-embeddings.test.ts src/managed-local-embeddings.test.ts +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +Run: + +```bash +git add packages/cli/src/setup-embeddings.ts packages/cli/src/setup-embeddings.test.ts +git commit -m "feat: use managed runtime for local embedding setup" +``` + +### Task 4: Pass runtime policy and CLI version through setup commands + +**Files:** + +- Modify: `packages/cli/src/setup.ts` +- Modify: `packages/cli/src/commands/setup-commands.ts` +- Modify: `packages/cli/src/cli-program.ts` +- Modify: `packages/cli/src/setup.test.ts` +- Modify: `packages/cli/src/index.test.ts` + +- [ ] **Step 1: Write failing setup argument expectations** + +In `packages/cli/src/index.test.ts`, find the test that routes the main setup +command and add `cliVersion: '0.0.0-private'` to the expected setup run +argument object. + +Add this assertion to the same test when `--yes` is present: + +```typescript + yes: true, + cliVersion: '0.0.0-private', +``` + +In `packages/cli/src/setup.test.ts`, find the setup test that asserts the +embeddings runner arguments. Add these expected fields to the embeddings step +argument object: + +```typescript + cliVersion: '0.2.0', + runtimeInstallPolicy: 'auto', +``` + +Add one focused unit test near the other setup flow tests: + +```typescript + it('passes no-input runtime policy to the embeddings step', async () => { + const io = makeIo(); + const embeddings = vi.fn(async () => ({ status: 'failed' as const, projectDir: tempDir })); + + await expect( + runKtxSetup( + { + command: 'run', + projectDir: tempDir, + mode: 'existing', + agents: false, + agentScope: 'project', + agentInstallMode: 'cli', + skipAgents: true, + inputMode: 'disabled', + yes: false, + cliVersion: '0.2.0', + skipLlm: true, + skipEmbeddings: false, + databaseSchemas: [], + skipDatabases: true, + skipSources: true, + }, + io.io, + { + project: { + run: vi.fn(async () => ({ status: 'ready' as const, projectDir: tempDir })), + }, + embeddings, + }, + ), + ).resolves.toBe(1); + + expect(embeddings).toHaveBeenCalledWith( + expect.objectContaining({ + cliVersion: '0.2.0', + runtimeInstallPolicy: 'never', + }), + io.io, + ); + }); +``` + +- [ ] **Step 2: Run the failing setup routing tests** + +Run: + +```bash +pnpm --filter @ktx/cli run test -- src/setup.test.ts src/index.test.ts +``` + +Expected: FAIL because setup args do not carry `cliVersion` yet and embeddings +args do not derive `runtimeInstallPolicy`. + +- [ ] **Step 3: Add `cliVersion` to setup run args** + +In `packages/cli/src/setup.ts`, add this field to the run variant of +`KtxSetupArgs` immediately after `yes`: + +```typescript + cliVersion: string; +``` + +Add this helper near the other setup helpers: + +```typescript +function setupRuntimeInstallPolicy(args: Extract): 'prompt' | 'auto' | 'never' { + if (args.yes) { + return 'auto'; + } + return args.inputMode === 'disabled' ? 'never' : 'prompt'; +} +``` + +In the embeddings step call inside `runKtxSetupInner`, add: + +```typescript + cliVersion: args.cliVersion, + runtimeInstallPolicy: setupRuntimeInstallPolicy(args), +``` + +- [ ] **Step 4: Pass package version from Commander and bare setup** + +In `packages/cli/src/commands/setup-commands.ts`, add this field to the setup +run argument object: + +```typescript + cliVersion: context.packageInfo.version, +``` + +Place it immediately after `yes: options.yes === true`. + +In `packages/cli/src/cli-program.ts`, add this field to the bare interactive +setup argument object inside `runBareInteractiveCommand`: + +```typescript + cliVersion: context.packageInfo.version, +``` + +Place it immediately after `yes: false`. + +- [ ] **Step 5: Verify setup routing tests pass** + +Run: + +```bash +pnpm --filter @ktx/cli run test -- src/setup.test.ts src/index.test.ts src/setup-embeddings.test.ts +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +Run: + +```bash +git add packages/cli/src/setup.ts packages/cli/src/commands/setup-commands.ts packages/cli/src/cli-program.ts packages/cli/src/setup.test.ts packages/cli/src/index.test.ts +git commit -m "feat: pass managed runtime policy through setup" +``` + +### Task 5: Final verification + +**Files:** + +- Verify: `packages/context/src/llm/local-config.ts` +- Verify: `packages/cli/src/managed-local-embeddings.ts` +- Verify: `packages/cli/src/setup-embeddings.ts` +- Verify: `packages/cli/src/setup.ts` + +- [ ] **Step 1: Run focused context tests** + +Run: + +```bash +pnpm --filter @ktx/context run test -- src/llm/local-config.test.ts src/package-exports.test.ts +``` + +Expected: PASS. + +- [ ] **Step 2: Run focused CLI tests** + +Run: + +```bash +pnpm --filter @ktx/cli run test -- src/managed-local-embeddings.test.ts src/setup-embeddings.test.ts src/setup.test.ts src/index.test.ts +``` + +Expected: PASS. + +- [ ] **Step 3: Run TypeScript checks for changed packages** + +Run: + +```bash +pnpm --filter @ktx/context run type-check +pnpm --filter @ktx/cli run type-check +``` + +Expected: PASS. + +- [ ] **Step 4: Run package-level tests if type-check changed public exports** + +Run: + +```bash +pnpm --filter @ktx/context run test +pnpm --filter @ktx/cli run test +``` + +Expected: PASS. + +- [ ] **Step 5: Run pre-commit for changed files** + +Run: + +```bash +uv run pre-commit run --files packages/context/src/llm/local-config.ts packages/context/src/llm/local-config.test.ts packages/context/src/llm/index.ts packages/context/src/package-exports.test.ts packages/cli/src/managed-local-embeddings.ts packages/cli/src/managed-local-embeddings.test.ts packages/cli/src/setup-embeddings.ts packages/cli/src/setup-embeddings.test.ts packages/cli/src/setup.ts packages/cli/src/commands/setup-commands.ts packages/cli/src/cli-program.ts packages/cli/src/setup.test.ts packages/cli/src/index.test.ts packages/cli/src/index.ts +``` + +Expected: PASS. If pre-commit is unavailable because local hook versions are +missing, run the focused tests and type-check commands from steps 1 through 3 +and record the pre-commit error. + +- [ ] **Step 6: Commit final verification adjustments** + +Run this only if final verification required small fixes: + +```bash +git add packages/context/src/llm/local-config.ts packages/context/src/llm/local-config.test.ts packages/context/src/llm/index.ts packages/context/src/package-exports.test.ts packages/cli/src/managed-local-embeddings.ts packages/cli/src/managed-local-embeddings.test.ts packages/cli/src/setup-embeddings.ts packages/cli/src/setup-embeddings.test.ts packages/cli/src/setup.ts packages/cli/src/commands/setup-commands.ts packages/cli/src/cli-program.ts packages/cli/src/setup.test.ts packages/cli/src/index.test.ts packages/cli/src/index.ts +git commit -m "test: verify managed local embeddings runtime setup" +``` + +## Acceptance criteria + +- `ktx setup --embedding-backend sentence-transformers --yes` installs the + `local-embeddings` runtime feature when needed, starts or reuses the managed + daemon, probes the active daemon URL, and writes `managed:local-embeddings` + to `ktx.yaml`. +- `ktx setup --embedding-backend sentence-transformers --no-input` fails with + the exact runtime preparation command when the runtime is missing. +- Existing OpenAI embedding setup behavior is unchanged. +- The project config no longer stores the daemon's random port. +- `resolveLocalKtxEmbeddingConfig` returns a usable `KtxEmbeddingConfig` for + managed local embeddings only when + `KTX_MANAGED_SENTENCE_TRANSFORMERS_BASE_URL` is present. +- Focused CLI and context tests pass. + +## Self-review + +- Spec coverage: This plan covers lazy `local-embeddings` installation after + local embeddings are selected, separate prompt/no-input behavior, and managed + daemon reuse for local embedding setup health checks. +- Placeholder scan: This plan contains concrete file paths, code snippets, + commands, expected outcomes, and commit commands. +- Type consistency: The new `ManagedLocalEmbeddingsDaemon` type, managed marker + constants, setup argument fields, and helper function names are used + consistently across tasks.