fix(cli): resolve embedding provider explicitly and surface lane status in sl search (#192)

* feat(cli): add tryUseManagedLocalEmbeddingsDaemon for read-only callers

* feat(cli): add resolveProjectEmbeddingProvider helper

* fix(cli): wire sl search through resolveProjectEmbeddingProvider so semantic lane works

* fix(cli): wire wiki/knowledge search through resolveProjectEmbeddingProvider

* feat(cli): surface embeddings-unavailable status when sl search returns empty

* refactor(cli): route admin reindex through resolveProjectEmbeddingProvider

* refactor: pass embeddingProvider into ingest/scan instead of resolving inside @ktx/context

* refactor(mcp): resolve embedding provider in CLI factory, pass into context ports

* refactor(context): delete MANAGED_SENTENCE_TRANSFORMERS_BASE_URL sentinel

* refactor(cli): delete sentinel-based managed-embeddings indirection

* chore: scrub stale managed-embeddings sentinel references from tests and smoke script

* chore: unexport unused EmbeddingResolutionMode alias

* fix(cli): force pathPrefix="" when targeting the managed embeddings daemon

The managed daemon serves /embeddings/compute directly. The default
pathPrefix in @ktx/llm is /api, so omitting sentenceTransformers from
ktx.yaml produced /api/embeddings/compute -> 404. The resolver now
sets pathPrefix='' explicitly when wiring the managed daemon URL,
matching what the daemon actually exposes.
This commit is contained in:
Andrey Avtomonov 2026-05-21 02:21:22 +02:00 committed by GitHub
parent 56a967278a
commit 9d92c79988
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 750 additions and 442 deletions

View file

@ -1,15 +1,11 @@
import { import { KtxIngestEmbeddingPortAdapter, type KtxEmbeddingPort } from '@ktx/context';
createLocalKtxEmbeddingProviderFromConfig,
KtxIngestEmbeddingPortAdapter,
type KtxEmbeddingPort,
} from '@ktx/context';
import { reindexLocalIndexes, type ReindexScopeResult, type ReindexSummary } from '@ktx/context/index-sync'; import { reindexLocalIndexes, type ReindexScopeResult, type ReindexSummary } from '@ktx/context/index-sync';
import { type KtxLocalProject } from '@ktx/context/project'; import { loadKtxProject } from '@ktx/context/project';
import { Option, type Command } from '@commander-js/extra-typings'; import { Option, type Command } from '@commander-js/extra-typings';
import { cancel, intro, log, note, outro } from '@clack/prompts'; import { cancel, intro, log, note, outro } from '@clack/prompts';
import type { KtxCliCommandContext } from './cli-program.js'; import type { KtxCliCommandContext } from './cli-program.js';
import { loadKtxCliProject } from './cli-project.js';
import type { KtxCliIo } from './cli-runtime.js'; import type { KtxCliIo } from './cli-runtime.js';
import { resolveProjectEmbeddingProvider } from './embedding-resolution.js';
import { resolveOutputMode } from './io/mode.js'; import { resolveOutputMode } from './io/mode.js';
import { green, red, SYMBOLS } from './io/symbols.js'; import { green, red, SYMBOLS } from './io/symbols.js';
@ -48,15 +44,6 @@ export function registerAdminReindexCommand(admin: Command, context: KtxCliComma
}); });
} }
function resolveReindexEmbeddingService(project: KtxLocalProject): KtxEmbeddingPort | null {
const config = project.config.ingest.embeddings;
if (config.backend === 'none') {
return null;
}
const provider = createLocalKtxEmbeddingProviderFromConfig(config);
return provider ? new KtxIngestEmbeddingPortAdapter(provider) : null;
}
function scopeKey(scope: ReindexScopeResult): string { function scopeKey(scope: ReindexScopeResult): string {
if (scope.kind === 'wiki') { if (scope.kind === 'wiki') {
return scope.scope === 'user' ? `wiki/user/${scope.scopeId ?? 'local'}` : 'wiki/global'; return scope.scope === 'user' ? `wiki/user/${scope.scopeId ?? 'local'}` : 'wiki/global';
@ -166,13 +153,16 @@ function renderReindexPretty(summary: ReindexSummary, io: KtxCliIo): void {
async function runKtxAdminReindex(args: KtxAdminReindexArgs, io: KtxCliIo = process): Promise<number> { async function runKtxAdminReindex(args: KtxAdminReindexArgs, io: KtxCliIo = process): Promise<number> {
try { try {
const project = await loadKtxCliProject({ const project = await loadKtxProject({ projectDir: args.projectDir });
projectDir: args.projectDir, const resolution = await resolveProjectEmbeddingProvider(project, {
mode: 'use-if-running',
cliVersion: args.cliVersion, cliVersion: args.cliVersion,
installPolicy: 'never',
io, io,
}); });
const embeddingService = resolveReindexEmbeddingService(project); const embeddingService: KtxEmbeddingPort | null =
resolution.kind === 'configured' || resolution.kind === 'managed-running' || resolution.kind === 'managed-started'
? new KtxIngestEmbeddingPortAdapter(resolution.provider)
: null;
const summary = await reindexLocalIndexes(project, { force: args.force, embeddingService }); const summary = await reindexLocalIndexes(project, { force: args.force, embeddingService });
const mode = resolveOutputMode({ explicit: args.output, json: args.json, io }); const mode = resolveOutputMode({ explicit: args.output, json: args.json, io });

View file

@ -1,29 +1,6 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { MANAGED_SENTENCE_TRANSFORMERS_BASE_URL } from '@ktx/context';
import { buildDefaultKtxProjectConfig, type KtxLocalProject, type KtxProjectConfig } from '@ktx/context/project'; import { buildDefaultKtxProjectConfig, type KtxLocalProject, type KtxProjectConfig } from '@ktx/context/project';
import { import { loadKtxCliProject } from './cli-project.js';
loadKtxCliProject,
projectNeedsManagedLocalEmbeddings,
substituteManagedLocalEmbeddingsUrl,
} from './cli-project.js';
import type { ManagedLocalEmbeddingsDaemon } from './managed-local-embeddings.js';
const RESOLVED_BASE_URL = 'http://127.0.0.1:51234';
function makeIo() {
let stderr = '';
return {
io: {
stdout: { write: (_chunk: string) => {} },
stderr: {
write: (chunk: string) => {
stderr += chunk;
},
},
},
stderr: () => stderr,
};
}
function projectWithConfig(config: KtxProjectConfig): KtxLocalProject { function projectWithConfig(config: KtxProjectConfig): KtxLocalProject {
return { return {
@ -36,147 +13,14 @@ function projectWithConfig(config: KtxProjectConfig): KtxLocalProject {
}; };
} }
function withManagedIngestEmbedding(config: KtxProjectConfig): KtxProjectConfig {
return {
...config,
ingest: {
...config.ingest,
embeddings: {
backend: 'sentence-transformers',
model: 'all-MiniLM-L6-v2',
dimensions: 384,
sentenceTransformers: { base_url: MANAGED_SENTENCE_TRANSFORMERS_BASE_URL, pathPrefix: '' },
},
},
};
}
function withManagedScanEnrichmentEmbedding(config: KtxProjectConfig): KtxProjectConfig {
return {
...config,
scan: {
...config.scan,
enrichment: {
...config.scan.enrichment,
embeddings: {
backend: 'sentence-transformers',
model: 'all-MiniLM-L6-v2',
dimensions: 384,
sentenceTransformers: { base_url: MANAGED_SENTENCE_TRANSFORMERS_BASE_URL, pathPrefix: '' },
},
},
},
};
}
const fakeDaemon: ManagedLocalEmbeddingsDaemon = {
baseUrl: RESOLVED_BASE_URL,
stdoutLog: '/work/proj/.ktx/runtime/daemon.stdout.log',
stderrLog: '/work/proj/.ktx/runtime/daemon.stderr.log',
};
describe('projectNeedsManagedLocalEmbeddings', () => {
it('returns false when neither ingest nor scan embeddings reference the managed sentinel', () => {
expect(projectNeedsManagedLocalEmbeddings(buildDefaultKtxProjectConfig())).toBe(false);
});
it('returns true when ingest.embeddings uses the managed sentinel', () => {
expect(projectNeedsManagedLocalEmbeddings(withManagedIngestEmbedding(buildDefaultKtxProjectConfig()))).toBe(true);
});
it('returns true when scan.enrichment.embeddings uses the managed sentinel', () => {
expect(
projectNeedsManagedLocalEmbeddings(withManagedScanEnrichmentEmbedding(buildDefaultKtxProjectConfig())),
).toBe(true);
});
});
describe('substituteManagedLocalEmbeddingsUrl', () => {
it('rewrites the managed sentinel in both ingest.embeddings and scan.enrichment.embeddings', () => {
const config = withManagedScanEnrichmentEmbedding(withManagedIngestEmbedding(buildDefaultKtxProjectConfig()));
const resolved = substituteManagedLocalEmbeddingsUrl(config, RESOLVED_BASE_URL);
expect(resolved.ingest.embeddings.sentenceTransformers?.base_url).toBe(RESOLVED_BASE_URL);
expect(resolved.scan.enrichment.embeddings?.sentenceTransformers?.base_url).toBe(RESOLVED_BASE_URL);
});
it('returns the input unchanged when no sentinel is present', () => {
const config = buildDefaultKtxProjectConfig();
const resolved = substituteManagedLocalEmbeddingsUrl(config, RESOLVED_BASE_URL);
expect(resolved.ingest.embeddings).toEqual(config.ingest.embeddings);
expect(resolved.scan.enrichment.embeddings).toEqual(config.scan.enrichment.embeddings);
});
it('does not touch non-sentinel sentence-transformers URLs', () => {
const config: KtxProjectConfig = {
...buildDefaultKtxProjectConfig(),
ingest: {
...buildDefaultKtxProjectConfig().ingest,
embeddings: {
backend: 'sentence-transformers',
model: 'all-MiniLM-L6-v2',
dimensions: 384,
sentenceTransformers: { base_url: 'http://localhost:9999', pathPrefix: '' },
},
},
};
const resolved = substituteManagedLocalEmbeddingsUrl(config, RESOLVED_BASE_URL);
expect(resolved.ingest.embeddings.sentenceTransformers?.base_url).toBe('http://localhost:9999');
});
});
describe('loadKtxCliProject', () => { describe('loadKtxCliProject', () => {
it('returns the project unchanged and does not start the daemon when no sentinel is present', async () => { it('delegates to loadKtxProject and returns the project unchanged', async () => {
const io = makeIo();
const project = projectWithConfig(buildDefaultKtxProjectConfig()); const project = projectWithConfig(buildDefaultKtxProjectConfig());
const loadProject = vi.fn(async () => project); const loadProject = vi.fn(async () => project);
const ensureLocalEmbeddings = vi.fn(async () => fakeDaemon);
const result = await loadKtxCliProject( const result = await loadKtxCliProject({ projectDir: '/work/proj' }, { loadProject });
{ projectDir: '/work/proj', cliVersion: '0.2.0', installPolicy: 'never', io: io.io },
{ loadProject, ensureLocalEmbeddings },
);
expect(result).toBe(project); expect(result).toBe(project);
expect(ensureLocalEmbeddings).not.toHaveBeenCalled(); expect(loadProject).toHaveBeenCalledWith({ projectDir: '/work/proj' });
});
it('starts the daemon and substitutes the resolved URL when ingest.embeddings uses the sentinel', async () => {
const io = makeIo();
const project = projectWithConfig(withManagedIngestEmbedding(buildDefaultKtxProjectConfig()));
const loadProject = vi.fn(async () => project);
const ensureLocalEmbeddings = vi.fn(async () => fakeDaemon);
const result = await loadKtxCliProject(
{ projectDir: '/work/proj', cliVersion: '0.2.0', installPolicy: 'never', io: io.io },
{ loadProject, ensureLocalEmbeddings },
);
expect(ensureLocalEmbeddings).toHaveBeenCalledWith({
cliVersion: '0.2.0',
projectDir: '/work/proj',
installPolicy: 'never',
io: io.io,
});
expect(result.config.ingest.embeddings.sentenceTransformers?.base_url).toBe(RESOLVED_BASE_URL);
});
it('does not mutate process.env', async () => {
const io = makeIo();
const before = process.env.KTX_MANAGED_SENTENCE_TRANSFORMERS_BASE_URL;
delete process.env.KTX_MANAGED_SENTENCE_TRANSFORMERS_BASE_URL;
try {
const project = projectWithConfig(withManagedIngestEmbedding(buildDefaultKtxProjectConfig()));
await loadKtxCliProject(
{ projectDir: '/work/proj', cliVersion: '0.2.0', installPolicy: 'never', io: io.io },
{ loadProject: vi.fn(async () => project), ensureLocalEmbeddings: vi.fn(async () => fakeDaemon) },
);
expect(process.env.KTX_MANAGED_SENTENCE_TRANSFORMERS_BASE_URL).toBeUndefined();
} finally {
if (before === undefined) {
delete process.env.KTX_MANAGED_SENTENCE_TRANSFORMERS_BASE_URL;
} else {
process.env.KTX_MANAGED_SENTENCE_TRANSFORMERS_BASE_URL = before;
}
}
}); });
}); });

View file

@ -1,91 +1,20 @@
import { MANAGED_SENTENCE_TRANSFORMERS_BASE_URL } from '@ktx/context';
import { loadKtxProject, type KtxLocalProject } from '@ktx/context/project'; import { loadKtxProject, type KtxLocalProject } from '@ktx/context/project';
import type { KtxProjectConfig, KtxProjectEmbeddingConfig } from '@ktx/context/project';
import type { KtxCliIo } from './cli-runtime.js';
import {
ensureManagedLocalEmbeddingsDaemon,
type ManagedLocalEmbeddingsDaemon,
} from './managed-local-embeddings.js';
import type { KtxManagedPythonInstallPolicy } from './managed-python-command.js';
export interface LoadKtxCliProjectOptions { export interface LoadKtxCliProjectOptions {
projectDir: string; projectDir: string;
cliVersion: string;
installPolicy: KtxManagedPythonInstallPolicy;
io: KtxCliIo;
} }
export interface LoadKtxCliProjectDeps { export interface LoadKtxCliProjectDeps {
loadProject?: typeof loadKtxProject; loadProject?: typeof loadKtxProject;
ensureLocalEmbeddings?: (
options: Parameters<typeof ensureManagedLocalEmbeddingsDaemon>[0],
) => Promise<ManagedLocalEmbeddingsDaemon>;
} }
/**
* Thin wrapper around `loadKtxProject`. Kept as a single entrypoint so the CLI can grow shared
* pre-load behavior later (telemetry, project lock, etc.). Today it does no extra work.
*/
export async function loadKtxCliProject( export async function loadKtxCliProject(
options: LoadKtxCliProjectOptions, options: LoadKtxCliProjectOptions,
deps: LoadKtxCliProjectDeps = {}, deps: LoadKtxCliProjectDeps = {},
): Promise<KtxLocalProject> { ): Promise<KtxLocalProject> {
const loadProject = deps.loadProject ?? loadKtxProject; return (deps.loadProject ?? loadKtxProject)({ projectDir: options.projectDir });
const ensureLocalEmbeddings = deps.ensureLocalEmbeddings ?? ensureManagedLocalEmbeddingsDaemon;
const project = await loadProject({ projectDir: options.projectDir });
if (!projectNeedsManagedLocalEmbeddings(project.config)) {
return project;
}
const daemon = await ensureLocalEmbeddings({
cliVersion: options.cliVersion,
projectDir: options.projectDir,
installPolicy: options.installPolicy,
io: options.io,
});
return {
...project,
config: substituteManagedLocalEmbeddingsUrl(project.config, daemon.baseUrl),
};
}
export function projectNeedsManagedLocalEmbeddings(config: KtxProjectConfig): boolean {
return (
embeddingUsesManagedSentinel(config.ingest.embeddings) ||
embeddingUsesManagedSentinel(config.scan.enrichment.embeddings)
);
}
export function substituteManagedLocalEmbeddingsUrl(
config: KtxProjectConfig,
baseUrl: string,
): KtxProjectConfig {
const ingestEmbeddings = rewriteManagedEmbeddingConfig(config.ingest.embeddings, baseUrl);
const scanEnrichmentEmbeddings = rewriteManagedEmbeddingConfig(config.scan.enrichment.embeddings, baseUrl);
return {
...config,
ingest: { ...config.ingest, embeddings: ingestEmbeddings },
scan: {
...config.scan,
enrichment: { ...config.scan.enrichment, embeddings: scanEnrichmentEmbeddings },
},
};
}
function embeddingUsesManagedSentinel(embedding: KtxProjectEmbeddingConfig | undefined): boolean {
return embedding?.sentenceTransformers?.base_url === MANAGED_SENTENCE_TRANSFORMERS_BASE_URL;
}
function rewriteManagedEmbeddingConfig<T extends KtxProjectEmbeddingConfig | undefined>(
embedding: T,
baseUrl: string,
): T {
if (!embedding || !embeddingUsesManagedSentinel(embedding)) {
return embedding;
}
return {
...embedding,
sentenceTransformers: {
...embedding.sentenceTransformers,
base_url: baseUrl,
},
} as T;
} }

View file

@ -59,6 +59,7 @@ export function registerWikiCommands(program: Command, context: KtxCliCommandCon
userId: options.userId, userId: options.userId,
output: options.output, output: options.output,
json: options.json, json: options.json,
cliVersion: context.packageInfo.version,
}); });
return; return;
} }
@ -71,6 +72,7 @@ export function registerWikiCommands(program: Command, context: KtxCliCommandCon
json: options.json, json: options.json,
...(isDebugEnabled(command) ? { debug: true } : {}), ...(isDebugEnabled(command) ? { debug: true } : {}),
...(options.limit !== undefined ? { limit: options.limit } : {}), ...(options.limit !== undefined ? { limit: options.limit } : {}),
cliVersion: context.packageInfo.version,
}); });
}, },
); );

View file

@ -77,6 +77,7 @@ export function registerSlCommands(program: Command, context: KtxCliCommandConte
connectionId: options.connectionId, connectionId: options.connectionId,
output: options.output, output: options.output,
json: options.json, json: options.json,
cliVersion: context.packageInfo.version,
}); });
return; return;
} }
@ -88,6 +89,7 @@ export function registerSlCommands(program: Command, context: KtxCliCommandConte
...(options.limit !== undefined ? { limit: options.limit } : {}), ...(options.limit !== undefined ? { limit: options.limit } : {}),
output: options.output, output: options.output,
json: options.json, json: options.json,
cliVersion: context.packageInfo.version,
}); });
}, },
); );

View file

@ -0,0 +1,145 @@
import { describe, expect, it, vi } from 'vitest';
import { buildDefaultKtxProjectConfig, type KtxLocalProject, type KtxProjectConfig } from '@ktx/context/project';
import { resolveProjectEmbeddingProvider } from './embedding-resolution.js';
import type { ManagedLocalEmbeddingsDaemon } from './managed-local-embeddings.js';
function projectWithConfig(config: KtxProjectConfig): KtxLocalProject {
return {
projectDir: '/work/proj',
configPath: '/work/proj/ktx.yaml',
config,
coreConfig: {} as KtxLocalProject['coreConfig'],
git: {} as KtxLocalProject['git'],
fileStore: {} as KtxLocalProject['fileStore'],
};
}
function withManagedEmbedding(config: KtxProjectConfig, base_url?: string): KtxProjectConfig {
return {
...config,
ingest: {
...config.ingest,
embeddings: {
backend: 'sentence-transformers',
model: 'all-MiniLM-L6-v2',
dimensions: 384,
...(base_url === undefined
? {}
: { sentenceTransformers: { base_url, pathPrefix: '' } }),
},
},
};
}
const noopIo = {
stdout: { write: (_chunk: string) => {} },
stderr: { write: (_chunk: string) => {} },
} as const;
const fakeDaemon: ManagedLocalEmbeddingsDaemon = {
baseUrl: 'http://127.0.0.1:51234',
stdoutLog: '/tmp/o',
stderrLog: '/tmp/e',
};
describe('resolveProjectEmbeddingProvider', () => {
it('returns disabled when backend is none', async () => {
const project = projectWithConfig(buildDefaultKtxProjectConfig());
const result = await resolveProjectEmbeddingProvider(project, {
mode: 'use-if-running',
cliVersion: '0.5.0',
io: noopIo,
});
expect(result.kind).toBe('disabled');
});
it('returns a configured provider when base_url is explicit', async () => {
const project = projectWithConfig(withManagedEmbedding(buildDefaultKtxProjectConfig(), 'http://my-st:8080'));
const createKtxEmbeddingProvider = vi.fn(() => ({ id: 'fake' }) as never);
const result = await resolveProjectEmbeddingProvider(project, {
mode: 'use-if-running',
cliVersion: '0.5.0',
io: noopIo,
createKtxEmbeddingProvider,
});
expect(result.kind).toBe('configured');
expect(createKtxEmbeddingProvider).toHaveBeenCalledOnce();
});
it('connects to the running managed daemon when base_url is omitted', async () => {
const project = projectWithConfig(withManagedEmbedding(buildDefaultKtxProjectConfig(), undefined));
const tryUseManaged = vi.fn(async () => fakeDaemon);
const createKtxEmbeddingProvider = vi.fn(() => ({ id: 'fake' }) as never);
const ensureManaged = vi.fn(async () => fakeDaemon);
const result = await resolveProjectEmbeddingProvider(project, {
mode: 'use-if-running',
cliVersion: '0.5.0',
io: noopIo,
createKtxEmbeddingProvider,
tryUseManagedDaemon: tryUseManaged,
ensureManagedDaemon: ensureManaged,
});
expect(result.kind).toBe('managed-running');
expect(tryUseManaged).toHaveBeenCalledOnce();
expect(ensureManaged).not.toHaveBeenCalled();
});
it('passes pathPrefix="" to the embedding provider when targeting the managed daemon', async () => {
const project = projectWithConfig(withManagedEmbedding(buildDefaultKtxProjectConfig(), undefined));
const tryUseManaged = vi.fn(async () => fakeDaemon);
const createKtxEmbeddingProvider = vi.fn(() => ({ id: 'fake' }) as never);
await resolveProjectEmbeddingProvider(project, {
mode: 'use-if-running',
cliVersion: '0.5.0',
io: noopIo,
createKtxEmbeddingProvider,
tryUseManagedDaemon: tryUseManaged,
});
expect(createKtxEmbeddingProvider).toHaveBeenCalledWith(
expect.objectContaining({
sentenceTransformers: expect.objectContaining({
baseURL: fakeDaemon.baseUrl,
pathPrefix: '',
}),
}),
);
});
it('returns managed-unavailable when no daemon is running and mode is use-if-running', async () => {
const project = projectWithConfig(withManagedEmbedding(buildDefaultKtxProjectConfig(), ''));
const tryUseManaged = vi.fn(async () => null);
const ensureManaged = vi.fn(async () => fakeDaemon);
const result = await resolveProjectEmbeddingProvider(project, {
mode: 'use-if-running',
cliVersion: '0.5.0',
io: noopIo,
tryUseManagedDaemon: tryUseManaged,
ensureManagedDaemon: ensureManaged,
});
expect(result.kind).toBe('managed-unavailable');
expect(ensureManaged).not.toHaveBeenCalled();
});
it('starts the managed daemon when mode is ensure', async () => {
const project = projectWithConfig(withManagedEmbedding(buildDefaultKtxProjectConfig(), undefined));
const tryUseManaged = vi.fn(async () => null);
const ensureManaged = vi.fn(async () => fakeDaemon);
const createKtxEmbeddingProvider = vi.fn(() => ({ id: 'fake' }) as never);
const result = await resolveProjectEmbeddingProvider(project, {
mode: 'ensure',
installPolicy: 'auto',
cliVersion: '0.5.0',
io: noopIo,
createKtxEmbeddingProvider,
tryUseManagedDaemon: tryUseManaged,
ensureManagedDaemon: ensureManaged,
});
expect(result.kind).toBe('managed-started');
expect(ensureManaged).toHaveBeenCalledWith({
cliVersion: '0.5.0',
projectDir: '/work/proj',
installPolicy: 'auto',
io: noopIo,
});
});
});

View file

@ -0,0 +1,107 @@
import {
type KtxEmbeddingProvider,
createKtxEmbeddingProvider as defaultCreateKtxEmbeddingProvider,
} from '@ktx/llm';
import type { KtxLocalProject, KtxProjectEmbeddingConfig } from '@ktx/context/project';
import { resolveLocalKtxEmbeddingConfig } from '@ktx/context';
import type { KtxCliIo } from './cli-runtime.js';
import {
ensureManagedLocalEmbeddingsDaemon as defaultEnsureManagedDaemon,
tryUseManagedLocalEmbeddingsDaemon as defaultTryUseManagedDaemon,
} from './managed-local-embeddings.js';
import type { KtxManagedPythonInstallPolicy } from './managed-python-command.js';
type EmbeddingResolutionMode = 'ensure' | 'use-if-running';
export type EmbeddingProviderResolution =
| { kind: 'disabled' }
| { kind: 'configured'; provider: KtxEmbeddingProvider; baseUrl: string }
| { kind: 'managed-running'; provider: KtxEmbeddingProvider; baseUrl: string }
| { kind: 'managed-started'; provider: KtxEmbeddingProvider; baseUrl: string }
| { kind: 'managed-unavailable'; reason: string };
export interface ResolveProjectEmbeddingProviderOptions {
mode: EmbeddingResolutionMode;
cliVersion: string;
io: KtxCliIo;
/** Required when mode === 'ensure'. */
installPolicy?: KtxManagedPythonInstallPolicy;
tryUseManagedDaemon?: typeof defaultTryUseManagedDaemon;
ensureManagedDaemon?: typeof defaultEnsureManagedDaemon;
createKtxEmbeddingProvider?: typeof defaultCreateKtxEmbeddingProvider;
}
function usesManagedDaemon(embeddings: KtxProjectEmbeddingConfig): boolean {
if (embeddings.backend !== 'sentence-transformers') {
return false;
}
const baseUrl = embeddings.sentenceTransformers?.base_url;
return baseUrl === undefined || baseUrl === '';
}
export async function resolveProjectEmbeddingProvider(
project: KtxLocalProject,
options: ResolveProjectEmbeddingProviderOptions,
): Promise<EmbeddingProviderResolution> {
const embeddings = project.config.ingest.embeddings;
if (embeddings.backend === 'none') {
return { kind: 'disabled' };
}
const createProvider = options.createKtxEmbeddingProvider ?? defaultCreateKtxEmbeddingProvider;
if (!usesManagedDaemon(embeddings)) {
const resolved = resolveLocalKtxEmbeddingConfig(embeddings, process.env);
if (!resolved) {
return { kind: 'managed-unavailable', reason: 'embedding config missing required fields' };
}
const provider = createProvider(resolved);
const baseUrl = embeddings.sentenceTransformers?.base_url ?? '';
return { kind: 'configured', provider, baseUrl };
}
const tryUse = options.tryUseManagedDaemon ?? defaultTryUseManagedDaemon;
const running = await tryUse({ cliVersion: options.cliVersion, projectDir: project.projectDir });
if (running) {
const provider = buildManagedProvider(embeddings, running.baseUrl, createProvider);
return provider
? { kind: 'managed-running', provider, baseUrl: running.baseUrl }
: { kind: 'managed-unavailable', reason: 'failed to build embedding provider from running daemon' };
}
if (options.mode === 'use-if-running') {
return { kind: 'managed-unavailable', reason: 'managed embeddings daemon is not running' };
}
const ensure = options.ensureManagedDaemon ?? defaultEnsureManagedDaemon;
if (!options.installPolicy) {
throw new Error("installPolicy is required when mode === 'ensure'");
}
const daemon = await ensure({
cliVersion: options.cliVersion,
projectDir: project.projectDir,
installPolicy: options.installPolicy,
io: options.io,
});
const provider = buildManagedProvider(embeddings, daemon.baseUrl, createProvider);
return provider
? { kind: 'managed-started', provider, baseUrl: daemon.baseUrl }
: { kind: 'managed-unavailable', reason: 'failed to build embedding provider after starting daemon' };
}
function buildManagedProvider(
embeddings: KtxProjectEmbeddingConfig,
baseUrl: string,
createProvider: typeof defaultCreateKtxEmbeddingProvider,
): KtxEmbeddingProvider | null {
const merged: KtxProjectEmbeddingConfig = {
...embeddings,
sentenceTransformers: {
...embeddings.sentenceTransformers,
base_url: baseUrl,
pathPrefix: '',
},
};
const resolved = resolveLocalKtxEmbeddingConfig(merged, process.env);
return resolved ? createProvider(resolved) : null;
}

View file

@ -152,12 +152,12 @@ describe('runKtxCli', () => {
await expect(runKtxCli(['--project-dir', tempDir, 'wiki', '--json'], listIo.io, { knowledge })) await expect(runKtxCli(['--project-dir', tempDir, 'wiki', '--json'], listIo.io, { knowledge }))
.resolves.toBe(0); .resolves.toBe(0);
expect(knowledge).toHaveBeenCalledWith( expect(knowledge).toHaveBeenCalledWith(
{ expect.objectContaining({
command: 'list', command: 'list',
projectDir: tempDir, projectDir: tempDir,
userId: 'local', userId: 'local',
json: true, json: true,
}, }),
listIo.io, listIo.io,
); );
@ -166,14 +166,14 @@ describe('runKtxCli', () => {
runKtxCli(['--project-dir', tempDir, 'wiki', 'revenue', '--limit', '5'], searchIo.io, { knowledge }), runKtxCli(['--project-dir', tempDir, 'wiki', 'revenue', '--limit', '5'], searchIo.io, { knowledge }),
).resolves.toBe(0); ).resolves.toBe(0);
expect(knowledge).toHaveBeenLastCalledWith( expect(knowledge).toHaveBeenLastCalledWith(
{ expect.objectContaining({
command: 'search', command: 'search',
projectDir: tempDir, projectDir: tempDir,
query: 'revenue', query: 'revenue',
userId: 'local', userId: 'local',
json: false, json: false,
limit: 5, limit: 5,
}, }),
searchIo.io, searchIo.io,
); );
@ -182,14 +182,14 @@ describe('runKtxCli', () => {
runKtxCli(['--project-dir', tempDir, '--debug', 'wiki', 'revenue'], debugSearchIo.io, { knowledge }), runKtxCli(['--project-dir', tempDir, '--debug', 'wiki', 'revenue'], debugSearchIo.io, { knowledge }),
).resolves.toBe(0); ).resolves.toBe(0);
expect(knowledge).toHaveBeenLastCalledWith( expect(knowledge).toHaveBeenLastCalledWith(
{ expect.objectContaining({
command: 'search', command: 'search',
projectDir: tempDir, projectDir: tempDir,
query: 'revenue', query: 'revenue',
userId: 'local', userId: 'local',
json: false, json: false,
debug: true, debug: true,
}, }),
debugSearchIo.io, debugSearchIo.io,
); );
@ -198,13 +198,13 @@ describe('runKtxCli', () => {
runKtxCli(['--project-dir', tempDir, 'wiki', 'revenue', 'policy'], multiWordIo.io, { knowledge }), runKtxCli(['--project-dir', tempDir, 'wiki', 'revenue', 'policy'], multiWordIo.io, { knowledge }),
).resolves.toBe(0); ).resolves.toBe(0);
expect(knowledge).toHaveBeenLastCalledWith( expect(knowledge).toHaveBeenLastCalledWith(
{ expect.objectContaining({
command: 'search', command: 'search',
projectDir: tempDir, projectDir: tempDir,
query: 'revenue policy', query: 'revenue policy',
userId: 'local', userId: 'local',
json: false, json: false,
}, }),
multiWordIo.io, multiWordIo.io,
); );
}); });
@ -248,7 +248,7 @@ describe('runKtxCli', () => {
), ),
).resolves.toBe(0); ).resolves.toBe(0);
expect(sl).toHaveBeenCalledWith( expect(sl).toHaveBeenCalledWith(
{ expect.objectContaining({
command: 'search', command: 'search',
projectDir: tempDir, projectDir: tempDir,
connectionId: 'warehouse', connectionId: 'warehouse',
@ -256,7 +256,7 @@ describe('runKtxCli', () => {
limit: 5, limit: 5,
json: true, json: true,
output: undefined, output: undefined,
}, }),
searchIo.io, searchIo.io,
); );
@ -265,13 +265,13 @@ describe('runKtxCli', () => {
runKtxCli(['--project-dir', tempDir, 'sl', '--connection-id', 'warehouse', '--json'], bareIo.io, { sl }), runKtxCli(['--project-dir', tempDir, 'sl', '--connection-id', 'warehouse', '--json'], bareIo.io, { sl }),
).resolves.toBe(0); ).resolves.toBe(0);
expect(sl).toHaveBeenLastCalledWith( expect(sl).toHaveBeenLastCalledWith(
{ expect.objectContaining({
command: 'list', command: 'list',
projectDir: tempDir, projectDir: tempDir,
connectionId: 'warehouse', connectionId: 'warehouse',
json: true, json: true,
output: undefined, output: undefined,
}, }),
bareIo.io, bareIo.io,
); );

View file

@ -54,7 +54,6 @@ export type {
export { export {
ensureManagedLocalEmbeddingsDaemon, ensureManagedLocalEmbeddingsDaemon,
managedLocalEmbeddingHealthConfig, managedLocalEmbeddingHealthConfig,
managedLocalEmbeddingProjectConfig,
type ManagedLocalEmbeddingsDaemon, type ManagedLocalEmbeddingsDaemon,
type ManagedLocalEmbeddingsOptions, type ManagedLocalEmbeddingsOptions,
} from './managed-local-embeddings.js'; } from './managed-local-embeddings.js';

View file

@ -18,8 +18,8 @@ import {
sanitizeMemoryFlowError, sanitizeMemoryFlowError,
} from '@ktx/context/ingest'; } from '@ktx/context/ingest';
import type { KtxSqlQueryExecutorPort } from '@ktx/context/connections'; import type { KtxSqlQueryExecutorPort } from '@ktx/context/connections';
import { type KtxLocalProject } from '@ktx/context/project'; import { loadKtxProject, type KtxLocalProject } from '@ktx/context/project';
import { loadKtxCliProject } from './cli-project.js'; import { resolveProjectEmbeddingProvider } from './embedding-resolution.js';
import { createKtxCliIngestQueryExecutor } from './ingest-query-executor.js'; import { createKtxCliIngestQueryExecutor } from './ingest-query-executor.js';
import { readIngestReportSnapshotFile } from './ingest-report-file.js'; import { readIngestReportSnapshotFile } from './ingest-report-file.js';
import { createCliOperationalLogger } from './io/logger.js'; import { createCliOperationalLogger } from './io/logger.js';
@ -682,16 +682,17 @@ export async function runKtxIngest(
deps: KtxIngestDeps = {}, deps: KtxIngestDeps = {},
): Promise<number> { ): Promise<number> {
try { try {
const cliVersion = args.command === 'run' ? args.cliVersion : undefined; const project = await loadKtxProject({ projectDir: args.projectDir });
const runtimeInstallPolicy = args.command === 'run' ? args.runtimeInstallPolicy : undefined;
const project = await loadKtxCliProject({
projectDir: args.projectDir,
cliVersion: cliVersion ?? '0.0.0-private',
installPolicy: runtimeInstallPolicy ?? 'never',
io,
});
const env = deps.env ?? process.env; const env = deps.env ?? process.env;
if (args.command === 'run') { if (args.command === 'run') {
const resolution = await resolveProjectEmbeddingProvider(project, {
mode: 'ensure',
installPolicy: args.runtimeInstallPolicy ?? 'never',
cliVersion: args.cliVersion ?? '0.0.0-private',
io,
});
const embeddingProvider =
resolution.kind === 'disabled' || resolution.kind === 'managed-unavailable' ? null : resolution.provider;
const ingestProject = const ingestProject =
args.allowImplicitAdapter && !project.config.ingest.adapters.includes(args.adapter) args.allowImplicitAdapter && !project.config.ingest.adapters.includes(args.adapter)
? { ? {
@ -771,6 +772,7 @@ export async function runKtxIngest(
queryExecutor, queryExecutor,
trigger: 'manual_resync', trigger: 'manual_resync',
jobIdFactory: deps.jobIdFactory, jobIdFactory: deps.jobIdFactory,
embeddingProvider,
...(memoryFlow ? { memoryFlow } : {}), ...(memoryFlow ? { memoryFlow } : {}),
...(progress ? { progress } : {}), ...(progress ? { progress } : {}),
}); });
@ -843,6 +845,7 @@ export async function runKtxIngest(
...localIngestOptions, ...localIngestOptions,
queryExecutor, queryExecutor,
pullConfigOptions: adapterOptions, pullConfigOptions: adapterOptions,
embeddingProvider,
...(args.debugLlmRequestFile ? { llmDebugRequestFile: args.debugLlmRequestFile } : {}), ...(args.debugLlmRequestFile ? { llmDebugRequestFile: args.debugLlmRequestFile } : {}),
...(memoryFlow ? { memoryFlow } : {}), ...(memoryFlow ? { memoryFlow } : {}),
}); });

View file

@ -5,7 +5,7 @@ import { stripVTControlCharacters } from 'node:util';
import { initKtxProject, loadKtxProject } from '@ktx/context/project'; import { initKtxProject, loadKtxProject } from '@ktx/context/project';
import type { KtxEmbeddingPort } from '@ktx/context'; import type { KtxEmbeddingPort } from '@ktx/context';
import { writeLocalKnowledgePage } from '@ktx/context/wiki'; import { writeLocalKnowledgePage } from '@ktx/context/wiki';
import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { runKtxKnowledge } from './knowledge.js'; import { runKtxKnowledge } from './knowledge.js';
function makeIo() { function makeIo() {
@ -81,12 +81,17 @@ describe('runKtxKnowledge', () => {
await seedWikiPage(projectDir); await seedWikiPage(projectDir);
const listIo = makeIo(); const listIo = makeIo();
await expect(runKtxKnowledge({ command: 'list', projectDir, userId: 'local' }, listIo.io)).resolves.toBe(0); await expect(
runKtxKnowledge({ command: 'list', projectDir, userId: 'local', cliVersion: '0.0.0-test' }, listIo.io),
).resolves.toBe(0);
expect(listIo.stdout()).toContain('GLOBAL\tmetrics-revenue\tRevenue'); expect(listIo.stdout()).toContain('GLOBAL\tmetrics-revenue\tRevenue');
const searchIo = makeIo(); const searchIo = makeIo();
await expect( await expect(
runKtxKnowledge({ command: 'search', projectDir, query: 'paid order', userId: 'local' }, searchIo.io), runKtxKnowledge(
{ command: 'search', projectDir, query: 'paid order', userId: 'local', cliVersion: '0.0.0-test' },
searchIo.io,
),
).resolves.toBe(0); ).resolves.toBe(0);
expect(searchIo.stdout()).toContain('metrics-revenue'); expect(searchIo.stdout()).toContain('metrics-revenue');
}); });
@ -99,7 +104,14 @@ describe('runKtxKnowledge', () => {
const searchIo = makeIo(); const searchIo = makeIo();
await expect( await expect(
runKtxKnowledge( runKtxKnowledge(
{ command: 'search', projectDir, query: 'paid order', userId: 'local', output: 'pretty' }, {
command: 'search',
projectDir,
query: 'paid order',
userId: 'local',
output: 'pretty',
cliVersion: '0.0.0-test',
},
searchIo.io, searchIo.io,
), ),
).resolves.toBe(0); ).resolves.toBe(0);
@ -115,9 +127,12 @@ describe('runKtxKnowledge', () => {
await seedWikiPage(projectDir); await seedWikiPage(projectDir);
const listIo = makeIo(); const listIo = makeIo();
await expect(runKtxKnowledge({ command: 'list', projectDir, userId: 'local', json: true }, listIo.io)).resolves.toBe( await expect(
0, runKtxKnowledge(
); { command: 'list', projectDir, userId: 'local', json: true, cliVersion: '0.0.0-test' },
listIo.io,
),
).resolves.toBe(0);
expect(JSON.parse(listIo.stdout())).toMatchObject({ expect(JSON.parse(listIo.stdout())).toMatchObject({
kind: 'list', kind: 'list',
data: { items: [expect.objectContaining({ key: 'metrics-revenue', summary: 'Revenue' })] }, data: { items: [expect.objectContaining({ key: 'metrics-revenue', summary: 'Revenue' })] },
@ -127,7 +142,15 @@ describe('runKtxKnowledge', () => {
const searchIo = makeIo(); const searchIo = makeIo();
await expect( await expect(
runKtxKnowledge( runKtxKnowledge(
{ command: 'search', projectDir, query: 'paid order', userId: 'local', json: true, limit: 5 }, {
command: 'search',
projectDir,
query: 'paid order',
userId: 'local',
json: true,
limit: 5,
cliVersion: '0.0.0-test',
},
searchIo.io, searchIo.io,
), ),
).resolves.toBe(0); ).resolves.toBe(0);
@ -144,7 +167,10 @@ describe('runKtxKnowledge', () => {
const searchIo = makeIo(); const searchIo = makeIo();
await expect( await expect(
runKtxKnowledge({ command: 'search', projectDir, query: 'revenue', userId: 'local' }, searchIo.io), runKtxKnowledge(
{ command: 'search', projectDir, query: 'revenue', userId: 'local', cliVersion: '0.0.0-test' },
searchIo.io,
),
).resolves.toBe(0); ).resolves.toBe(0);
expect(searchIo.stdout()).toBe(''); expect(searchIo.stdout()).toBe('');
@ -166,7 +192,7 @@ describe('runKtxKnowledge', () => {
const searchIo = makeIo(); const searchIo = makeIo();
await expect( await expect(
runKtxKnowledge( runKtxKnowledge(
{ command: 'search', projectDir, query: 'revenue', userId: 'local' }, { command: 'search', projectDir, query: 'revenue', userId: 'local', cliVersion: '0.0.0-test' },
searchIo.io, searchIo.io,
{ embeddingService: new FakeEmbeddingPort() }, { embeddingService: new FakeEmbeddingPort() },
), ),
@ -176,6 +202,37 @@ describe('runKtxKnowledge', () => {
expect(searchIo.stderr()).toBe(''); expect(searchIo.stderr()).toBe('');
}); });
it('routes wiki search through resolveEmbeddingProvider when no embeddingService is injected', async () => {
const projectDir = join(tempDir, 'resolver-project');
await initKtxProject({ projectDir });
const search = vi.fn(async () => []);
const searchIo = makeIo();
await expect(
runKtxKnowledge(
{
command: 'search',
projectDir,
query: 'income',
userId: 'local',
cliVersion: '0.5.0',
},
searchIo.io,
{
resolveEmbeddingProvider: async () => ({
kind: 'managed-running',
provider: { id: 'fake' } as never,
baseUrl: 'http://127.0.0.1:51234',
}),
searchLocalKnowledgePages: search,
},
),
).resolves.toBe(0);
expect(search).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ embeddingService: expect.any(Object) }),
);
});
it('writes wiki search lane diagnostics to stderr when debug is enabled', async () => { it('writes wiki search lane diagnostics to stderr when debug is enabled', async () => {
const projectDir = join(tempDir, 'debug-project'); const projectDir = join(tempDir, 'debug-project');
await initKtxProject({ projectDir }); await initKtxProject({ projectDir });
@ -184,7 +241,15 @@ describe('runKtxKnowledge', () => {
const searchIo = makeIo(); const searchIo = makeIo();
await expect( await expect(
runKtxKnowledge( runKtxKnowledge(
{ command: 'search', projectDir, query: 'paid order', userId: 'local', json: true, debug: true }, {
command: 'search',
projectDir,
query: 'paid order',
userId: 'local',
json: true,
debug: true,
cliVersion: '0.0.0-test',
},
searchIo.io, searchIo.io,
{ embeddingService: new FakeEmbeddingPort() }, { embeddingService: new FakeEmbeddingPort() },
), ),

View file

@ -1,20 +1,20 @@
import { import { KtxIngestEmbeddingPortAdapter, type KtxEmbeddingPort } from '@ktx/context';
createLocalKtxEmbeddingProviderFromConfig,
KtxIngestEmbeddingPortAdapter,
type KtxEmbeddingPort,
} from '@ktx/context';
import { loadKtxProject } from '@ktx/context/project'; import { loadKtxProject } from '@ktx/context/project';
import { import {
type LocalKnowledgeSearchResult, type LocalKnowledgeSearchResult,
type LocalKnowledgeSummary, type LocalKnowledgeSummary,
listLocalKnowledgePages, listLocalKnowledgePages,
searchLocalKnowledgePages, searchLocalKnowledgePages as defaultSearchLocalKnowledgePages,
} from '@ktx/context/wiki'; } from '@ktx/context/wiki';
import {
resolveProjectEmbeddingProvider,
type EmbeddingProviderResolution,
} from './embedding-resolution.js';
import { resolveOutputMode } from './io/mode.js'; import { resolveOutputMode } from './io/mode.js';
import { createRankBadgeFormatter, printList, type PrintListColumn } from './io/print-list.js'; import { createRankBadgeFormatter, printList, type PrintListColumn } from './io/print-list.js';
export type KtxKnowledgeArgs = export type KtxKnowledgeArgs =
| { command: 'list'; projectDir: string; userId: string; output?: string; json?: boolean } | { command: 'list'; projectDir: string; userId: string; output?: string; json?: boolean; cliVersion: string }
| { | {
command: 'search'; command: 'search';
projectDir: string; projectDir: string;
@ -24,6 +24,7 @@ export type KtxKnowledgeArgs =
json?: boolean; json?: boolean;
limit?: number; limit?: number;
debug?: boolean; debug?: boolean;
cliVersion: string;
}; };
type KtxKnowledgeIo = import('./cli-runtime.js').KtxCliIo; type KtxKnowledgeIo = import('./cli-runtime.js').KtxCliIo;
@ -54,20 +55,36 @@ function wikiSearchColumns(
interface KtxKnowledgeDeps { interface KtxKnowledgeDeps {
embeddingService?: KtxEmbeddingPort | null; embeddingService?: KtxEmbeddingPort | null;
createEmbeddingProvider?: typeof createLocalKtxEmbeddingProviderFromConfig; resolveEmbeddingProvider?: typeof resolveProjectEmbeddingProvider;
searchLocalKnowledgePages?: typeof defaultSearchLocalKnowledgePages;
} }
function wikiSearchEmbeddingService( function resolutionToEmbeddingPort(resolution: EmbeddingProviderResolution): KtxEmbeddingPort | null {
if (
resolution.kind === 'configured' ||
resolution.kind === 'managed-running' ||
resolution.kind === 'managed-started'
) {
return new KtxIngestEmbeddingPortAdapter(resolution.provider);
}
return null;
}
async function wikiSearchEmbeddingService(
project: Awaited<ReturnType<typeof loadKtxProject>>, project: Awaited<ReturnType<typeof loadKtxProject>>,
deps: KtxKnowledgeDeps, deps: KtxKnowledgeDeps,
): KtxEmbeddingPort | null { args: { cliVersion: string },
io: KtxKnowledgeIo,
): Promise<KtxEmbeddingPort | null> {
if ('embeddingService' in deps) { if ('embeddingService' in deps) {
return deps.embeddingService ?? null; return deps.embeddingService ?? null;
} }
const provider = (deps.createEmbeddingProvider ?? createLocalKtxEmbeddingProviderFromConfig)( const resolution = await (deps.resolveEmbeddingProvider ?? resolveProjectEmbeddingProvider)(project, {
project.config.ingest.embeddings, mode: 'use-if-running',
); cliVersion: args.cliVersion,
return provider ? new KtxIngestEmbeddingPortAdapter(provider) : null; io,
});
return resolutionToEmbeddingPort(resolution);
} }
function writeWikiSearchDebug( function writeWikiSearchDebug(
@ -114,8 +131,9 @@ export async function runKtxKnowledge(
return 0; return 0;
} }
if (args.command === 'search') { if (args.command === 'search') {
const embeddingService = wikiSearchEmbeddingService(project, deps); const embeddingService = await wikiSearchEmbeddingService(project, deps, { cliVersion: args.cliVersion }, io);
const results = await searchLocalKnowledgePages(project, { const search = deps.searchLocalKnowledgePages ?? defaultSearchLocalKnowledgePages;
const results = await search(project, {
query: args.query, query: args.query,
userId: args.userId, userId: args.userId,
embeddingService, embeddingService,

View file

@ -1,12 +1,12 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { MANAGED_SENTENCE_TRANSFORMERS_BASE_URL } from '@ktx/context';
import { import {
ensureManagedLocalEmbeddingsDaemon, ensureManagedLocalEmbeddingsDaemon,
managedLocalEmbeddingHealthConfig, managedLocalEmbeddingHealthConfig,
managedLocalEmbeddingProjectConfig, tryUseManagedLocalEmbeddingsDaemon,
} from './managed-local-embeddings.js'; } from './managed-local-embeddings.js';
import type { ManagedPythonCommandRuntime } from './managed-python-command.js'; import type { ManagedPythonCommandRuntime } from './managed-python-command.js';
import type { ManagedPythonDaemonStartResult } from './managed-python-daemon.js'; import type { ManagedPythonDaemonStartResult } from './managed-python-daemon.js';
import type { ManagedPythonDaemonLayout } from './managed-python-runtime.js';
function makeIo() { function makeIo() {
let stdout = ''; let stdout = '';
@ -94,25 +94,6 @@ function daemonResult(status: 'started' | 'reused' = 'reused'): ManagedPythonDae
}; };
} }
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', () => { describe('managedLocalEmbeddingHealthConfig', () => {
it('uses the active KTX daemon URL for the immediate health check', () => { it('uses the active KTX daemon URL for the immediate health check', () => {
expect( expect(
@ -181,3 +162,93 @@ describe('ensureManagedLocalEmbeddingsDaemon', () => {
expect(io.stderr()).toContain('Using KTX daemon: http://127.0.0.1:61234'); expect(io.stderr()).toContain('Using KTX daemon: http://127.0.0.1:61234');
}); });
}); });
describe('tryUseManagedLocalEmbeddingsDaemon', () => {
it('returns the daemon when one is running and healthy', async () => {
const readStatus = vi.fn(async () => ({
kind: 'running' as const,
detail: 'ok',
layout: {} as ManagedPythonDaemonLayout,
state: {
schemaVersion: 1 as const,
pid: 123,
host: '127.0.0.1' as const,
port: 4321,
version: '0.5.0',
features: ['local-embeddings' as const],
startedAt: '2026-05-21T00:00:00Z',
stdoutLog: '/tmp/stdout.log',
stderrLog: '/tmp/stderr.log',
},
baseUrl: 'http://127.0.0.1:4321',
}));
const result = await tryUseManagedLocalEmbeddingsDaemon({
cliVersion: '0.5.0',
projectDir: '/work/proj',
readStatus,
});
expect(result).toEqual({
baseUrl: 'http://127.0.0.1:4321',
stdoutLog: '/tmp/stdout.log',
stderrLog: '/tmp/stderr.log',
});
expect(readStatus).toHaveBeenCalledWith({
cliVersion: '0.5.0',
projectDir: '/work/proj',
});
});
it('returns null when no daemon state exists', async () => {
const readStatus = vi.fn(async () => ({
kind: 'stopped' as const,
detail: 'no state',
layout: {} as ManagedPythonDaemonLayout,
}));
const result = await tryUseManagedLocalEmbeddingsDaemon({
cliVersion: '0.5.0',
projectDir: '/work/proj',
readStatus,
});
expect(result).toBeNull();
});
it('returns null when daemon is stale', async () => {
const readStatus = vi.fn(async () => ({
kind: 'stale' as const,
detail: 'process gone',
layout: {} as ManagedPythonDaemonLayout,
}));
const result = await tryUseManagedLocalEmbeddingsDaemon({
cliVersion: '0.5.0',
projectDir: '/work/proj',
readStatus,
});
expect(result).toBeNull();
});
it('rejects daemons that do not advertise local-embeddings', async () => {
const readStatus = vi.fn(async () => ({
kind: 'running' as const,
detail: 'ok',
layout: {} as ManagedPythonDaemonLayout,
state: {
schemaVersion: 1 as const,
pid: 123,
host: '127.0.0.1' as const,
port: 4321,
version: '0.5.0',
features: ['core' as const],
startedAt: '2026-05-21T00:00:00Z',
stdoutLog: '/tmp/stdout.log',
stderrLog: '/tmp/stderr.log',
},
baseUrl: 'http://127.0.0.1:4321',
}));
const result = await tryUseManagedLocalEmbeddingsDaemon({
cliVersion: '0.5.0',
projectDir: '/work/proj',
readStatus,
});
expect(result).toBeNull();
});
});

View file

@ -1,5 +1,3 @@
import { MANAGED_SENTENCE_TRANSFORMERS_BASE_URL } from '@ktx/context';
import type { KtxProjectEmbeddingConfig } from '@ktx/context/project';
import type { KtxEmbeddingConfig } from '@ktx/llm'; import type { KtxEmbeddingConfig } from '@ktx/llm';
import type { KtxCliIo } from './cli-runtime.js'; import type { KtxCliIo } from './cli-runtime.js';
import { import {
@ -7,7 +5,12 @@ import {
type KtxManagedPythonInstallPolicy, type KtxManagedPythonInstallPolicy,
type ManagedPythonCommandRuntime, type ManagedPythonCommandRuntime,
} from './managed-python-command.js'; } from './managed-python-command.js';
import { startManagedPythonDaemon, type ManagedPythonDaemonStartResult } from './managed-python-daemon.js'; import {
readManagedPythonDaemonStatus,
startManagedPythonDaemon,
type ManagedPythonDaemonStartResult,
type ManagedPythonDaemonStatus,
} from './managed-python-daemon.js';
export interface ManagedLocalEmbeddingsDaemon { export interface ManagedLocalEmbeddingsDaemon {
baseUrl: string; baseUrl: string;
@ -34,21 +37,6 @@ export interface ManagedLocalEmbeddingsOptions {
}) => Promise<ManagedPythonDaemonStartResult>; }) => Promise<ManagedPythonDaemonStartResult>;
} }
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: { export function managedLocalEmbeddingHealthConfig(input: {
baseUrl: string; baseUrl: string;
model: string; model: string;
@ -93,3 +81,30 @@ export async function ensureManagedLocalEmbeddingsDaemon(
stderrLog: daemon.state.stderrLog, stderrLog: daemon.state.stderrLog,
}; };
} }
export interface TryUseManagedLocalEmbeddingsOptions {
cliVersion: string;
projectDir: string;
readStatus?: typeof readManagedPythonDaemonStatus;
}
export async function tryUseManagedLocalEmbeddingsDaemon(
options: TryUseManagedLocalEmbeddingsOptions,
): Promise<ManagedLocalEmbeddingsDaemon | null> {
const readStatus = options.readStatus ?? readManagedPythonDaemonStatus;
const status: ManagedPythonDaemonStatus = await readStatus({
cliVersion: options.cliVersion,
projectDir: options.projectDir,
});
if (status.kind !== 'running') {
return null;
}
if (!status.state.features.includes('local-embeddings')) {
return null;
}
return {
baseUrl: status.baseUrl,
stdoutLog: status.state.stdoutLog,
stderrLog: status.state.stderrLog,
};
}

View file

@ -1,8 +1,10 @@
import { KtxIngestEmbeddingPortAdapter } from '@ktx/context';
import { createDefaultKtxMcpServer, createLocalProjectMcpContextPorts } from '@ktx/context/mcp'; import { createDefaultKtxMcpServer, createLocalProjectMcpContextPorts } from '@ktx/context/mcp';
import { createLocalProjectMemoryIngest } from '@ktx/context/memory'; import { createLocalProjectMemoryIngest } from '@ktx/context/memory';
import type { KtxLocalProject } from '@ktx/context/project'; import type { KtxLocalProject } from '@ktx/context/project';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { KtxCliIo } from './cli-runtime.js'; import type { KtxCliIo } from './cli-runtime.js';
import { resolveProjectEmbeddingProvider } from './embedding-resolution.js';
import { createKtxCliIngestQueryExecutor } from './ingest-query-executor.js'; import { createKtxCliIngestQueryExecutor } from './ingest-query-executor.js';
import { createKtxCliScanConnector } from './local-scan-connectors.js'; import { createKtxCliScanConnector } from './local-scan-connectors.js';
import { createManagedPythonSemanticLayerComputePort } from './managed-python-command.js'; import { createManagedPythonSemanticLayerComputePort } from './managed-python-command.js';
@ -34,10 +36,20 @@ export async function createKtxMcpServerFactory(input: {
installPolicy: 'auto', installPolicy: 'auto',
io, io,
}); });
const resolution = await resolveProjectEmbeddingProvider(input.project, {
mode: 'use-if-running',
cliVersion: input.cliVersion,
io,
});
const embeddingService =
resolution.kind === 'configured' || resolution.kind === 'managed-running' || resolution.kind === 'managed-started'
? new KtxIngestEmbeddingPortAdapter(resolution.provider)
: null;
const contextTools = createLocalProjectMcpContextPorts(input.project, { const contextTools = createLocalProjectMcpContextPorts(input.project, {
semanticLayerCompute, semanticLayerCompute,
queryExecutor, queryExecutor,
sqlAnalysis, sqlAnalysis,
embeddingService,
localScan: { localScan: {
createConnector: async (connectionId) => createKtxCliScanConnector(input.project, connectionId), createConnector: async (connectionId) => createKtxCliScanConnector(input.project, connectionId),
}, },

View file

@ -1,5 +1,4 @@
import { type KtxLocalProject, type KtxProjectConnectionConfig } from '@ktx/context/project'; import { loadKtxProject, type KtxLocalProject, type KtxProjectConnectionConfig } from '@ktx/context/project';
import { loadKtxCliProject } from './cli-project.js';
import type { KtxProgressPort } from '@ktx/context/scan'; import type { KtxProgressPort } from '@ktx/context/scan';
import type { KtxCliIo } from './index.js'; import type { KtxCliIo } from './index.js';
import type { KtxIngestArgs, KtxIngestDeps, KtxIngestProgressUpdate } from './ingest.js'; import type { KtxIngestArgs, KtxIngestDeps, KtxIngestProgressUpdate } from './ingest.js';
@ -869,14 +868,7 @@ export async function runKtxPublicIngest(
deps: KtxPublicIngestDeps = {}, deps: KtxPublicIngestDeps = {},
): Promise<number> { ): Promise<number> {
const loadProject = const loadProject =
deps.loadProject ?? deps.loadProject ?? ((options: { projectDir: string }) => loadKtxProject({ projectDir: options.projectDir }));
((options: { projectDir: string }) =>
loadKtxCliProject({
projectDir: options.projectDir,
cliVersion: args.cliVersion ?? '0.0.0-private',
installPolicy: args.runtimeInstallPolicy ?? 'never',
io,
}));
const project = await loadProject({ projectDir: args.projectDir }); const project = await loadProject({ projectDir: args.projectDir });
if (shouldUseForegroundContextBuildView(args, io)) { if (shouldUseForegroundContextBuildView(args, io)) {
const plan = buildPublicIngestPlan(project, args); const plan = buildPublicIngestPlan(project, args);

View file

@ -1,4 +1,3 @@
import { MANAGED_SENTENCE_TRANSFORMERS_BASE_URL } from '@ktx/context';
import { buildDefaultKtxProjectConfig, type KtxProjectConfig } from '@ktx/context/project'; import { buildDefaultKtxProjectConfig, type KtxProjectConfig } from '@ktx/context/project';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
@ -51,7 +50,7 @@ describe('runtime requirement detection', () => {
model: 'all-MiniLM-L6-v2', model: 'all-MiniLM-L6-v2',
dimensions: 384, dimensions: 384,
sentenceTransformers: { sentenceTransformers: {
base_url: MANAGED_SENTENCE_TRANSFORMERS_BASE_URL, base_url: '',
}, },
}, },
}, },

View file

@ -1,4 +1,3 @@
import { MANAGED_SENTENCE_TRANSFORMERS_BASE_URL } from '@ktx/context';
import type { import type {
KtxProjectConfig, KtxProjectConfig,
KtxProjectConnectionConfig, KtxProjectConnectionConfig,
@ -63,7 +62,7 @@ function requiresManagedLocalEmbeddings(embeddings: KtxProjectEmbeddingConfig):
return false; return false;
} }
const baseUrl = embeddings.sentenceTransformers?.base_url; const baseUrl = embeddings.sentenceTransformers?.base_url;
return baseUrl === undefined || baseUrl === '' || baseUrl === MANAGED_SENTENCE_TRANSFORMERS_BASE_URL; return baseUrl === undefined || baseUrl === '';
} }
function uniqueRequirements(requirements: KtxRuntimeRequirement[]): KtxRuntimeRequirements { function uniqueRequirements(requirements: KtxRuntimeRequirement[]): KtxRuntimeRequirements {

View file

@ -5,7 +5,8 @@ import {
type KtxScanWarning, type KtxScanWarning,
runLocalScan, runLocalScan,
} from '@ktx/context/scan'; } from '@ktx/context/scan';
import { loadKtxCliProject } from './cli-project.js'; import { loadKtxProject } from '@ktx/context/project';
import { resolveProjectEmbeddingProvider } from './embedding-resolution.js';
import type { KtxCliIo } from './index.js'; import type { KtxCliIo } from './index.js';
import { createKtxCliLocalIngestAdapters } from './local-adapters.js'; import { createKtxCliLocalIngestAdapters } from './local-adapters.js';
import { createKtxCliScanConnector } from './local-scan-connectors.js'; import { createKtxCliScanConnector } from './local-scan-connectors.js';
@ -313,12 +314,15 @@ export function createCliScanProgress(
export async function runKtxScan(args: KtxScanArgs, io: KtxCliIo = process, deps: KtxScanDeps = {}): Promise<number> { export async function runKtxScan(args: KtxScanArgs, io: KtxCliIo = process, deps: KtxScanDeps = {}): Promise<number> {
try { try {
const project = await loadKtxCliProject({ const project = await loadKtxProject({ projectDir: args.projectDir });
projectDir: args.projectDir, const resolution = await resolveProjectEmbeddingProvider(project, {
cliVersion: args.cliVersion ?? '0.0.0-private', mode: 'ensure',
installPolicy: args.runtimeInstallPolicy ?? 'never', installPolicy: args.runtimeInstallPolicy ?? 'never',
cliVersion: args.cliVersion ?? '0.0.0-private',
io, io,
}); });
const embeddingProvider =
resolution.kind === 'disabled' || resolution.kind === 'managed-unavailable' ? null : resolution.provider;
const managedDaemon = managedDaemonOptionsForScanRun(args, deps.runtimeIo ?? io); const managedDaemon = managedDaemonOptionsForScanRun(args, deps.runtimeIo ?? io);
const connector = const connector =
args.mode !== 'structural' || args.detectRelationships args.mode !== 'structural' || args.detectRelationships
@ -336,6 +340,7 @@ export async function runKtxScan(args: KtxScanArgs, io: KtxCliIo = process, deps
trigger: 'cli', trigger: 'cli',
databaseIntrospectionUrl: args.databaseIntrospectionUrl, databaseIntrospectionUrl: args.databaseIntrospectionUrl,
connector, connector,
embeddingProvider,
adapters: (deps.createLocalIngestAdapters ?? createKtxCliLocalIngestAdapters)(project, { adapters: (deps.createLocalIngestAdapters ?? createKtxCliLocalIngestAdapters)(project, {
...(args.databaseIntrospectionUrl ? { databaseIntrospectionUrl: args.databaseIntrospectionUrl } : {}), ...(args.databaseIntrospectionUrl ? { databaseIntrospectionUrl: args.databaseIntrospectionUrl } : {}),
...(managedDaemon ? { managedDaemon } : {}), ...(managedDaemon ? { managedDaemon } : {}),

View file

@ -54,9 +54,6 @@ function managedDaemon(
baseUrl, baseUrl,
stdoutLog: logs.stdoutLog ?? '/tmp/ktx-daemon.stdout.log', stdoutLog: logs.stdoutLog ?? '/tmp/ktx-daemon.stdout.log',
stderrLog: logs.stderrLog ?? '/tmp/ktx-daemon.stderr.log', stderrLog: logs.stderrLog ?? '/tmp/ktx-daemon.stderr.log',
env: {
KTX_MANAGED_SENTENCE_TRANSFORMERS_BASE_URL: baseUrl,
},
}; };
} }
@ -176,8 +173,8 @@ describe('setup embeddings step', () => {
backend: 'sentence-transformers', backend: 'sentence-transformers',
model: 'all-MiniLM-L6-v2', model: 'all-MiniLM-L6-v2',
dimensions: 384, dimensions: 384,
sentenceTransformers: { base_url: 'managed:local-embeddings', pathPrefix: '' },
}); });
expect(config.ingest.embeddings.sentenceTransformers).toBeUndefined();
expect(config.scan.enrichment.embeddings).toMatchObject(config.ingest.embeddings); expect(config.scan.enrichment.embeddings).toMatchObject(config.ingest.embeddings);
expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:'); expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
expect((await readKtxSetupState(tempDir)).completed_steps).toContain('embeddings'); expect((await readKtxSetupState(tempDir)).completed_steps).toContain('embeddings');
@ -275,8 +272,8 @@ describe('setup embeddings step', () => {
backend: 'sentence-transformers', backend: 'sentence-transformers',
model: 'all-MiniLM-L6-v2', model: 'all-MiniLM-L6-v2',
dimensions: 384, dimensions: 384,
sentenceTransformers: { base_url: 'managed:local-embeddings', pathPrefix: '' },
}); });
expect(config.ingest.embeddings.sentenceTransformers).toBeUndefined();
expect(config.scan.enrichment.embeddings).toMatchObject(config.ingest.embeddings); expect(config.scan.enrichment.embeddings).toMatchObject(config.ingest.embeddings);
expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:'); expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
expect((await readKtxSetupState(tempDir)).completed_steps).toContain('embeddings'); expect((await readKtxSetupState(tempDir)).completed_steps).toContain('embeddings');

View file

@ -14,7 +14,6 @@ import { createStaticCliSpinner, type KtxCliSpinner } from './clack.js';
import { import {
ensureManagedLocalEmbeddingsDaemon, ensureManagedLocalEmbeddingsDaemon,
managedLocalEmbeddingHealthConfig, managedLocalEmbeddingHealthConfig,
managedLocalEmbeddingProjectConfig,
type ManagedLocalEmbeddingsDaemon, type ManagedLocalEmbeddingsDaemon,
} from './managed-local-embeddings.js'; } from './managed-local-embeddings.js';
import type { KtxManagedPythonInstallPolicy } from './managed-python-command.js'; import type { KtxManagedPythonInstallPolicy } from './managed-python-command.js';
@ -455,7 +454,11 @@ export async function runKtxSetupEmbeddingsStep(
await persistEmbeddingConfig( await persistEmbeddingConfig(
args.projectDir, args.projectDir,
selectedBackend === LOCAL_EMBEDDING_BACKEND selectedBackend === LOCAL_EMBEDDING_BACKEND
? managedLocalEmbeddingProjectConfig({ model, dimensions }) ? {
backend: 'sentence-transformers' as const,
model,
dimensions,
}
: buildProjectEmbeddingConfig({ : buildProjectEmbeddingConfig({
backend: selectedBackend, backend: selectedBackend,
model, model,

View file

@ -1,7 +1,6 @@
import { mkdtemp, rm } from 'node:fs/promises'; import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { MANAGED_SENTENCE_TRANSFORMERS_BASE_URL } from '@ktx/context';
import { buildDefaultKtxProjectConfig, readKtxSetupState, type KtxProjectConfig } from '@ktx/context/project'; import { buildDefaultKtxProjectConfig, readKtxSetupState, type KtxProjectConfig } from '@ktx/context/project';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { runKtxSetupRuntimeStep } from './setup-runtime.js'; import { runKtxSetupRuntimeStep } from './setup-runtime.js';
@ -103,7 +102,6 @@ describe('runKtxSetupRuntimeStep', () => {
baseUrl: 'http://127.0.0.1:61234', baseUrl: 'http://127.0.0.1:61234',
stdoutLog: join(tempDir, '.ktx', 'runtime', 'daemon.stdout.log'), stdoutLog: join(tempDir, '.ktx', 'runtime', 'daemon.stdout.log'),
stderrLog: join(tempDir, '.ktx', 'runtime', 'daemon.stderr.log'), stderrLog: join(tempDir, '.ktx', 'runtime', 'daemon.stderr.log'),
env: { KTX_MANAGED_SENTENCE_TRANSFORMERS_BASE_URL: 'http://127.0.0.1:61234' },
})); }));
const config: KtxProjectConfig = { const config: KtxProjectConfig = {
...buildDefaultKtxProjectConfig(), ...buildDefaultKtxProjectConfig(),
@ -113,7 +111,7 @@ describe('runKtxSetupRuntimeStep', () => {
backend: 'sentence-transformers', backend: 'sentence-transformers',
model: 'all-MiniLM-L6-v2', model: 'all-MiniLM-L6-v2',
dimensions: 384, dimensions: 384,
sentenceTransformers: { base_url: MANAGED_SENTENCE_TRANSFORMERS_BASE_URL }, sentenceTransformers: { base_url: '' },
}, },
}, },
}; };

View file

@ -77,12 +77,24 @@ describe('runKtxSl', () => {
expect(validateIo.stdout()).toContain('Valid semantic-layer source: warehouse/orders'); expect(validateIo.stdout()).toContain('Valid semantic-layer source: warehouse/orders');
const listIo = makeIo(); const listIo = makeIo();
await expect(runKtxSl({ command: 'list', projectDir, connectionId: 'warehouse' }, listIo.io)).resolves.toBe(0); await expect(
runKtxSl({ command: 'list', projectDir, connectionId: 'warehouse', cliVersion: '0.0.0-test' }, listIo.io),
).resolves.toBe(0);
expect(listIo.stdout()).toContain('warehouse\torders\tcolumns=1\tmeasures=0\tjoins=0'); expect(listIo.stdout()).toContain('warehouse\torders\tcolumns=1\tmeasures=0\tjoins=0');
const searchIo = makeIo(); const searchIo = makeIo();
await expect( await expect(
runKtxSl({ command: 'search', projectDir, connectionId: 'warehouse', query: 'order', json: true }, searchIo.io), runKtxSl(
{
command: 'search',
projectDir,
connectionId: 'warehouse',
query: 'order',
json: true,
cliVersion: '0.0.0-test',
},
searchIo.io,
),
).resolves.toBe(0); ).resolves.toBe(0);
expect(JSON.parse(searchIo.stdout())).toMatchObject({ expect(JSON.parse(searchIo.stdout())).toMatchObject({
kind: 'list', kind: 'list',
@ -106,7 +118,14 @@ describe('runKtxSl', () => {
const searchIo = makeIo(); const searchIo = makeIo();
await expect( await expect(
runKtxSl( runKtxSl(
{ command: 'search', projectDir, connectionId: 'warehouse', query: 'order', output: 'pretty' }, {
command: 'search',
projectDir,
connectionId: 'warehouse',
query: 'order',
output: 'pretty',
cliVersion: '0.0.0-test',
},
searchIo.io, searchIo.io,
), ),
).resolves.toBe(0); ).resolves.toBe(0);
@ -136,7 +155,14 @@ describe('runKtxSl', () => {
const listIo = makeIo(); const listIo = makeIo();
await expect( await expect(
runKtxSl( runKtxSl(
{ command: 'search', projectDir, connectionId: 'warehouse', query: 'paid', json: true }, {
command: 'search',
projectDir,
connectionId: 'warehouse',
query: 'paid',
json: true,
cliVersion: '0.0.0-test',
},
listIo.io, listIo.io,
), ),
).resolves.toBe(0); ).resolves.toBe(0);
@ -575,7 +601,7 @@ joins: []
const listIo = makeIo(); const listIo = makeIo();
const code = await runKtxSl( const code = await runKtxSl(
{ command: 'list', projectDir, connectionId: 'warehouse', output: 'json' }, { command: 'list', projectDir, connectionId: 'warehouse', output: 'json', cliVersion: '0.0.0-test' },
listIo.io, listIo.io,
); );
expect(code).toBe(0); expect(code).toBe(0);
@ -601,13 +627,80 @@ joins: []
}); });
}); });
it('search prints embeddings status when results are empty', async () => {
const stderr: string[] = [];
const io = {
stdout: { write: (_chunk: string) => {} },
stderr: {
write: (chunk: string) => {
stderr.push(chunk);
},
},
};
const projectDir = join(tempDir, 'empty-status');
const project = await initKtxProject({ projectDir });
await expect(
runKtxSl(
{
command: 'search',
projectDir: project.projectDir,
query: 'nope',
cliVersion: '0.5.0',
},
io,
{
loadProject: async () => project,
resolveEmbeddingProvider: async () => ({
kind: 'managed-unavailable',
reason: 'managed embeddings daemon is not running',
}),
searchLocalSlSources: async () => [],
},
),
).resolves.toBe(0);
expect(stderr.join('')).toMatch(/embeddings: unavailable/);
expect(stderr.join('')).toMatch(/managed embeddings daemon is not running/);
});
it('passes a managed-daemon-backed embedding service into the search', async () => {
const projectDir = join(tempDir, 'resolver-project');
const project = await initKtxProject({ projectDir });
const search = vi.fn(async () => []);
const searchIo = makeIo();
await expect(
runKtxSl(
{
command: 'search',
projectDir: project.projectDir,
query: 'income',
cliVersion: '0.5.0',
json: true,
},
searchIo.io,
{
loadProject: async () => project,
resolveEmbeddingProvider: async () => ({
kind: 'managed-running',
provider: { id: 'fake' } as never,
baseUrl: 'http://127.0.0.1:51234',
}),
searchLocalSlSources: search,
},
),
).resolves.toBe(0);
expect(search).toHaveBeenCalledWith(
project,
expect.objectContaining({ embeddingService: expect.any(Object) }),
);
});
it('emits sl list with grouping and Clack-style framing when output=pretty', async () => { it('emits sl list with grouping and Clack-style framing when output=pretty', async () => {
const projectDir = join(tempDir, 'project'); const projectDir = join(tempDir, 'project');
await seedSlSource({ projectDir }); await seedSlSource({ projectDir });
const listIo = makeIo(); const listIo = makeIo();
const code = await runKtxSl( const code = await runKtxSl(
{ command: 'list', projectDir, connectionId: 'warehouse', output: 'pretty' }, { command: 'list', projectDir, connectionId: 'warehouse', output: 'pretty', cliVersion: '0.0.0-test' },
listIo.io, listIo.io,
); );
expect(code).toBe(0); expect(code).toBe(0);

View file

@ -1,22 +1,22 @@
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { createDefaultLocalQueryExecutor, type KtxSqlQueryExecutorPort } from '@ktx/context/connections'; import { createDefaultLocalQueryExecutor, type KtxSqlQueryExecutorPort } from '@ktx/context/connections';
import { import { KtxIngestEmbeddingPortAdapter, type KtxEmbeddingPort } from '@ktx/context';
createLocalKtxEmbeddingProviderFromConfig,
KtxIngestEmbeddingPortAdapter,
type KtxEmbeddingPort,
} from '@ktx/context';
import type { KtxSemanticLayerComputePort } from '@ktx/context/daemon'; import type { KtxSemanticLayerComputePort } from '@ktx/context/daemon';
import { loadKtxProject, type KtxLocalProject } from '@ktx/context/project'; import { loadKtxProject, type KtxLocalProject } from '@ktx/context/project';
import { import {
compileLocalSlQuery, compileLocalSlQuery,
listLocalSlSources, listLocalSlSources,
readLocalSlSource, readLocalSlSource,
searchLocalSlSources, searchLocalSlSources as defaultSearchLocalSlSources,
validateLocalSlSource, validateLocalSlSource,
type LocalSlSourceSearchResult, type LocalSlSourceSearchResult,
type LocalSlSourceSummary, type LocalSlSourceSummary,
type SemanticLayerQueryInput, type SemanticLayerQueryInput,
} from '@ktx/context/sl'; } from '@ktx/context/sl';
import {
resolveProjectEmbeddingProvider,
type EmbeddingProviderResolution,
} from './embedding-resolution.js';
import type { PrintListColumn } from './io/print-list.js'; import type { PrintListColumn } from './io/print-list.js';
import { import {
createManagedPythonSemanticLayerComputePort, createManagedPythonSemanticLayerComputePort,
@ -29,7 +29,14 @@ profileMark('module:sl');
type SlQueryFormat = 'json' | 'sql'; type SlQueryFormat = 'json' | 'sql';
export type KtxSlArgs = export type KtxSlArgs =
| { command: 'list'; projectDir: string; connectionId?: string; output?: string; json?: boolean } | {
command: 'list';
projectDir: string;
connectionId?: string;
output?: string;
json?: boolean;
cliVersion: string;
}
| { | {
command: 'search'; command: 'search';
projectDir: string; projectDir: string;
@ -38,6 +45,7 @@ export type KtxSlArgs =
limit?: number; limit?: number;
output?: string; output?: string;
json?: boolean; json?: boolean;
cliVersion: string;
} }
| { command: 'validate'; projectDir: string; connectionId: string; sourceName: string } | { command: 'validate'; projectDir: string; connectionId: string; sourceName: string }
| { | {
@ -60,8 +68,8 @@ interface KtxSlIo {
interface KtxSlDeps { interface KtxSlDeps {
loadProject?: typeof loadKtxProject; loadProject?: typeof loadKtxProject;
embeddingService?: KtxEmbeddingPort | null; resolveEmbeddingProvider?: typeof resolveProjectEmbeddingProvider;
createEmbeddingProvider?: typeof createLocalKtxEmbeddingProviderFromConfig; searchLocalSlSources?: typeof defaultSearchLocalSlSources;
createSemanticLayerCompute?: () => KtxSemanticLayerComputePort; createSemanticLayerCompute?: () => KtxSemanticLayerComputePort;
createManagedSemanticLayerCompute?: (options: { createManagedSemanticLayerCompute?: (options: {
cliVersion: string; cliVersion: string;
@ -71,14 +79,15 @@ interface KtxSlDeps {
createQueryExecutor?: () => KtxSqlQueryExecutorPort; createQueryExecutor?: () => KtxSqlQueryExecutorPort;
} }
function slSearchEmbeddingService(project: KtxLocalProject, deps: KtxSlDeps): KtxEmbeddingPort | null { function resolutionToEmbeddingPort(resolution: EmbeddingProviderResolution): KtxEmbeddingPort | null {
if ('embeddingService' in deps) { if (
return deps.embeddingService ?? null; resolution.kind === 'configured' ||
resolution.kind === 'managed-running' ||
resolution.kind === 'managed-started'
) {
return new KtxIngestEmbeddingPortAdapter(resolution.provider);
} }
const provider = (deps.createEmbeddingProvider ?? createLocalKtxEmbeddingProviderFromConfig)( return null;
project.config.ingest.embeddings,
);
return provider ? new KtxIngestEmbeddingPortAdapter(provider) : null;
} }
async function printSlSources(input: { async function printSlSources(input: {
@ -188,12 +197,24 @@ export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: Ktx
return 0; return 0;
} }
if (args.command === 'search') { if (args.command === 'search') {
const sources = await searchLocalSlSources(project, { const resolver = deps.resolveEmbeddingProvider ?? resolveProjectEmbeddingProvider;
const resolution = await resolver(project, {
mode: 'use-if-running',
cliVersion: args.cliVersion,
io,
});
const embeddingService = resolutionToEmbeddingPort(resolution);
const search = deps.searchLocalSlSources ?? defaultSearchLocalSlSources;
const sources = await search(project, {
connectionId: args.connectionId, connectionId: args.connectionId,
query: args.query, query: args.query,
embeddingService: slSearchEmbeddingService(project, deps), embeddingService,
limit: args.limit, limit: args.limit,
}); });
if (sources.length === 0 && resolution.kind === 'managed-unavailable' && !args.json) {
const { SYMBOLS } = await import('./io/symbols.js');
io.stderr.write(`embeddings: unavailable ${SYMBOLS.emDash} ${resolution.reason}\n`);
}
await printSlSources({ await printSlSources({
rows: sources, rows: sources,
emptyMessage: `No semantic-layer sources matched "${args.query}" in ${project.projectDir}`, emptyMessage: `No semantic-layer sources matched "${args.query}" in ${project.projectDir}`,

View file

@ -8,7 +8,6 @@ import { noopLogger, SessionWorktreeService } from '../core/index.js';
import type { KtxSemanticLayerComputePort } from '../daemon/index.js'; import type { KtxSemanticLayerComputePort } from '../daemon/index.js';
import { import {
createRuntimeToolDescriptorFromAiTool, createRuntimeToolDescriptorFromAiTool,
createLocalKtxEmbeddingProviderFromConfig,
createLocalKtxLlmRuntimeFromConfig, createLocalKtxLlmRuntimeFromConfig,
KtxIngestEmbeddingPortAdapter, KtxIngestEmbeddingPortAdapter,
RuntimeAgentRunner, RuntimeAgentRunner,
@ -16,6 +15,7 @@ import {
type KtxLlmRuntimePort, type KtxLlmRuntimePort,
type KtxRuntimeToolSet, type KtxRuntimeToolSet,
} from '../llm/index.js'; } from '../llm/index.js';
import type { KtxEmbeddingProvider } from '@ktx/llm';
import type { KtxLocalProject } from '../project/index.js'; import type { KtxLocalProject } from '../project/index.js';
import { ktxLocalStateDbPath } from '../project/index.js'; import { ktxLocalStateDbPath } from '../project/index.js';
import { PromptService } from '../prompts/index.js'; import { PromptService } from '../prompts/index.js';
@ -114,6 +114,7 @@ export interface CreateLocalBundleIngestRuntimeOptions {
queryExecutor?: KtxSqlQueryExecutorPort; queryExecutor?: KtxSqlQueryExecutorPort;
jobIdFactory?: () => string; jobIdFactory?: () => string;
logger?: KtxLogger; logger?: KtxLogger;
embeddingProvider?: KtxEmbeddingProvider | null;
} }
export interface LocalBundleIngestRuntime { export interface LocalBundleIngestRuntime {
@ -669,7 +670,7 @@ export function createLocalBundleIngestRuntime(
mkdirSync(join(options.project.projectDir, '.ktx/cache/local-ingest'), { recursive: true }); mkdirSync(join(options.project.projectDir, '.ktx/cache/local-ingest'), { recursive: true });
const store = new SqliteBundleIngestStore({ dbPath }); const store = new SqliteBundleIngestStore({ dbPath });
const contextStore = new SqliteContextEvidenceStore({ dbPath }); const contextStore = new SqliteContextEvidenceStore({ dbPath });
const embeddingProvider = createLocalKtxEmbeddingProviderFromConfig(options.project.config.ingest.embeddings); const embeddingProvider = options.embeddingProvider ?? null;
const embedding = embeddingProvider ? new KtxIngestEmbeddingPortAdapter(embeddingProvider) : new NoopEmbeddingPort(); const embedding = embeddingProvider ? new KtxIngestEmbeddingPortAdapter(embeddingProvider) : new NoopEmbeddingPort();
const connections = new LocalConnectionCatalog(options.project, options.queryExecutor); const connections = new LocalConnectionCatalog(options.project, options.queryExecutor);
const rootFileStore = options.project.fileStore; const rootFileStore = options.project.fileStore;

View file

@ -34,6 +34,7 @@ export interface RunLocalIngestOptions {
semanticLayerCompute?: KtxSemanticLayerComputePort; semanticLayerCompute?: KtxSemanticLayerComputePort;
queryExecutor?: KtxSqlQueryExecutorPort; queryExecutor?: KtxSqlQueryExecutorPort;
logger?: KtxLogger; logger?: KtxLogger;
embeddingProvider?: import('@ktx/llm').KtxEmbeddingProvider | null;
} }
export interface LocalIngestMcpOptions export interface LocalIngestMcpOptions
@ -172,6 +173,7 @@ async function runScheduledPullJob(options: {
semanticLayerCompute?: KtxSemanticLayerComputePort; semanticLayerCompute?: KtxSemanticLayerComputePort;
queryExecutor?: KtxSqlQueryExecutorPort; queryExecutor?: KtxSqlQueryExecutorPort;
logger?: KtxLogger; logger?: KtxLogger;
embeddingProvider?: import('@ktx/llm').KtxEmbeddingProvider | null;
}): Promise<LocalIngestResult> { }): Promise<LocalIngestResult> {
const runtime = createLocalBundleIngestRuntime(options); const runtime = createLocalBundleIngestRuntime(options);
const jobId = options.jobId ?? runtime.nextJobId(); const jobId = options.jobId ?? runtime.nextJobId();
@ -225,6 +227,7 @@ export async function runLocalIngest(options: RunLocalIngestOptions): Promise<Lo
semanticLayerCompute: options.semanticLayerCompute, semanticLayerCompute: options.semanticLayerCompute,
queryExecutor: options.queryExecutor, queryExecutor: options.queryExecutor,
logger: options.logger, logger: options.logger,
embeddingProvider: options.embeddingProvider,
}); });
} }
@ -403,6 +406,7 @@ export async function runLocalMetabaseIngest(
semanticLayerCompute: options.semanticLayerCompute, semanticLayerCompute: options.semanticLayerCompute,
queryExecutor: options.queryExecutor, queryExecutor: options.queryExecutor,
logger: options.logger, logger: options.logger,
embeddingProvider: options.embeddingProvider,
}); });
} catch (error) { } catch (error) {
child = await recordLocalMetabaseChildFailure({ child = await recordLocalMetabaseChildFailure({

View file

@ -37,7 +37,6 @@ export {
summarizeKtxLlmDebugRequest, summarizeKtxLlmDebugRequest,
} from './debug-request-recorder.js'; } from './debug-request-recorder.js';
export { export {
MANAGED_SENTENCE_TRANSFORMERS_BASE_URL,
createLocalKtxEmbeddingProviderFromConfig, createLocalKtxEmbeddingProviderFromConfig,
createLocalKtxLlmProviderFromConfig, createLocalKtxLlmProviderFromConfig,
createLocalKtxLlmRuntimeFromConfig, createLocalKtxLlmRuntimeFromConfig,

View file

@ -5,7 +5,6 @@ import {
type KtxProjectLlmConfig, type KtxProjectLlmConfig,
} from '../project/config.js'; } from '../project/config.js';
import { import {
MANAGED_SENTENCE_TRANSFORMERS_BASE_URL,
createLocalKtxEmbeddingProviderFromConfig, createLocalKtxEmbeddingProviderFromConfig,
createLocalKtxLlmProviderFromConfig, createLocalKtxLlmProviderFromConfig,
resolveLocalKtxEmbeddingConfig, resolveLocalKtxEmbeddingConfig,
@ -151,13 +150,13 @@ describe('local KTX embedding config', () => {
}); });
}); });
it('returns null when sentence-transformers base_url is still the unresolved managed sentinel', () => { it('returns null when sentence-transformers has no base_url (managed daemon delegation)', () => {
const config: KtxProjectEmbeddingConfig = { const config: KtxProjectEmbeddingConfig = {
backend: 'sentence-transformers', backend: 'sentence-transformers',
model: 'all-MiniLM-L6-v2', model: 'all-MiniLM-L6-v2',
dimensions: 384, dimensions: 384,
sentenceTransformers: { sentenceTransformers: {
base_url: MANAGED_SENTENCE_TRANSFORMERS_BASE_URL, base_url: '',
pathPrefix: '', pathPrefix: '',
}, },
}; };

View file

@ -22,8 +22,6 @@ interface LocalConfigDeps {
createAiSdkRuntime?: (deps: { llmProvider: KtxLlmProvider }) => KtxLlmRuntimePort; createAiSdkRuntime?: (deps: { llmProvider: KtxLlmProvider }) => KtxLlmRuntimePort;
} }
export const MANAGED_SENTENCE_TRANSFORMERS_BASE_URL = 'managed:local-embeddings';
function resolveOptional(value: string | undefined, env: NodeJS.ProcessEnv): string | undefined { function resolveOptional(value: string | undefined, env: NodeJS.ProcessEnv): string | undefined {
return resolveKtxConfigReference(value, env) || undefined; return resolveKtxConfigReference(value, env) || undefined;
} }
@ -149,7 +147,7 @@ export function resolveLocalKtxEmbeddingConfig(
} }
if (config.backend === 'sentence-transformers') { if (config.backend === 'sentence-transformers') {
const baseURL = config.sentenceTransformers?.base_url; const baseURL = config.sentenceTransformers?.base_url;
if (!baseURL || baseURL === MANAGED_SENTENCE_TRANSFORMERS_BASE_URL) { if (!baseURL) {
return null; return null;
} }
return { return {

View file

@ -174,7 +174,7 @@ describe('createLocalProjectMcpContextPorts', () => {
driver: 'postgres', driver: 'postgres',
url: 'env:DATABASE_URL', url: 'env:DATABASE_URL',
}; };
const ports = createLocalProjectMcpContextPorts(project); const ports = createLocalProjectMcpContextPorts(project, { embeddingService: null });
expect(Object.keys(ports).sort()).toEqual([ expect(Object.keys(ports).sort()).toEqual([
'connections', 'connections',
@ -216,6 +216,7 @@ describe('createLocalProjectMcpContextPorts', () => {
localScan: { localScan: {
createConnector, createConnector,
}, },
embeddingService: null,
}); });
expect(Object.keys(ports).sort()).toContain('sqlExecution'); expect(Object.keys(ports).sort()).toContain('sqlExecution');
@ -269,6 +270,7 @@ describe('createLocalProjectMcpContextPorts', () => {
localScan: { localScan: {
createConnector, createConnector,
}, },
embeddingService: null,
}); });
const result = await ports.sqlExecution?.execute( const result = await ports.sqlExecution?.execute(
@ -313,6 +315,7 @@ describe('createLocalProjectMcpContextPorts', () => {
localScan: { localScan: {
createConnector: vi.fn(async () => connector), createConnector: vi.fn(async () => connector),
}, },
embeddingService: null,
}); });
await expect( await expect(
@ -332,7 +335,7 @@ describe('createLocalProjectMcpContextPorts', () => {
url: 'env:DATABASE_URL', url: 'env:DATABASE_URL',
}; };
await seedScanReport(project.projectDir); await seedScanReport(project.projectDir);
const ports = createLocalProjectMcpContextPorts(project); const ports = createLocalProjectMcpContextPorts(project, { embeddingService: null });
await expect( await expect(
ports.entityDetails?.read({ ports.entityDetails?.read({
@ -358,7 +361,7 @@ describe('createLocalProjectMcpContextPorts', () => {
driver: 'postgres', driver: 'postgres',
url: 'env:DATABASE_URL', url: 'env:DATABASE_URL',
}; };
const ports = createLocalProjectMcpContextPorts(project); const ports = createLocalProjectMcpContextPorts(project, { embeddingService: null });
await expect( await expect(
ports.entityDetails?.read({ ports.entityDetails?.read({
@ -411,7 +414,7 @@ describe('createLocalProjectMcpContextPorts', () => {
'Seed dictionary profile', 'Seed dictionary profile',
); );
const ports = createLocalProjectMcpContextPorts(project); const ports = createLocalProjectMcpContextPorts(project, { embeddingService: null });
await expect(ports.dictionarySearch?.search({ values: ['paid'] })).resolves.toMatchObject({ await expect(ports.dictionarySearch?.search({ values: ['paid'] })).resolves.toMatchObject({
searched: [{ connectionId: 'warehouse', status: 'ready' }], searched: [{ connectionId: 'warehouse', status: 'ready' }],
@ -432,7 +435,7 @@ describe('createLocalProjectMcpContextPorts', () => {
url: 'env:DATABASE_URL', url: 'env:DATABASE_URL',
}; };
const ports = createLocalProjectMcpContextPorts(project); const ports = createLocalProjectMcpContextPorts(project, { embeddingService: null });
await expect(ports.dictionarySearch?.search({ values: ['paid'] })).resolves.toEqual({ await expect(ports.dictionarySearch?.search({ values: ['paid'] })).resolves.toEqual({
searched: [ searched: [
@ -601,7 +604,7 @@ describe('createLocalProjectMcpContextPorts', () => {
'seed scan report', 'seed scan report',
); );
const ports = createLocalProjectMcpContextPorts(project); const ports = createLocalProjectMcpContextPorts(project, { embeddingService: null });
const results = await ports.discover?.search({ query: 'paid orders', connectionId: 'warehouse', limit: 10 }); const results = await ports.discover?.search({ query: 'paid orders', connectionId: 'warehouse', limit: 10 });
expect(results).toEqual( expect(results).toEqual(
@ -635,7 +638,7 @@ describe('createLocalProjectMcpContextPorts', () => {
'ktx@example.com', 'ktx@example.com',
'Seed wiki', 'Seed wiki',
); );
const ports = createLocalProjectMcpContextPorts(project); const ports = createLocalProjectMcpContextPorts(project, { embeddingService: null });
await expect(ports.knowledge?.read({ userId: 'local-user', key: 'revenue' })).resolves.toMatchObject({ await expect(ports.knowledge?.read({ userId: 'local-user', key: 'revenue' })).resolves.toMatchObject({
key: 'revenue', key: 'revenue',
@ -680,7 +683,7 @@ describe('createLocalProjectMcpContextPorts', () => {
'', '',
].join('\n'), ].join('\n'),
}); });
const ports = createLocalProjectMcpContextPorts(project); const ports = createLocalProjectMcpContextPorts(project, { embeddingService: null });
await expect( await expect(
ports.semanticLayer?.readSource({ connectionId: 'warehouse', sourceName: 'orders' }), ports.semanticLayer?.readSource({ connectionId: 'warehouse', sourceName: 'orders' }),
@ -692,7 +695,7 @@ describe('createLocalProjectMcpContextPorts', () => {
it('rejects path traversal keys before touching the project directory', async () => { it('rejects path traversal keys before touching the project directory', async () => {
const project = await initKtxProject({ projectDir: tempDir }); const project = await initKtxProject({ projectDir: tempDir });
const ports = createLocalProjectMcpContextPorts(project); const ports = createLocalProjectMcpContextPorts(project, { embeddingService: null });
await expect( await expect(
ports.knowledge?.read({ ports.knowledge?.read({
@ -746,7 +749,7 @@ describe('createLocalProjectMcpContextPorts', () => {
})), })),
generateSources: vi.fn(), generateSources: vi.fn(),
}; };
const ports = createLocalProjectMcpContextPorts(project, { semanticLayerCompute }); const ports = createLocalProjectMcpContextPorts(project, { semanticLayerCompute, embeddingService: null });
await expect( await expect(
ports.semanticLayer?.query({ ports.semanticLayer?.query({
@ -817,6 +820,7 @@ describe('createLocalProjectMcpContextPorts', () => {
const ports = createLocalProjectMcpContextPorts(project, { const ports = createLocalProjectMcpContextPorts(project, {
semanticLayerCompute: compute, semanticLayerCompute: compute,
queryExecutor, queryExecutor,
embeddingService: null,
}); });
const result = await ports.semanticLayer?.query({ const result = await ports.semanticLayer?.query({

View file

@ -1,7 +1,6 @@
import { type KtxSqlQueryExecutorPort, localConnectionInfoFromConfig } from '../connections/index.js'; import { type KtxSqlQueryExecutorPort, localConnectionInfoFromConfig } from '../connections/index.js';
import type { KtxEmbeddingPort } from '../core/index.js'; import type { KtxEmbeddingPort } from '../core/index.js';
import type { KtxSemanticLayerComputePort } from '../daemon/index.js'; import type { KtxSemanticLayerComputePort } from '../daemon/index.js';
import { createLocalKtxEmbeddingProviderFromConfig, KtxIngestEmbeddingPortAdapter } from '../llm/index.js';
import type { KtxLocalProject } from '../project/index.js'; import type { KtxLocalProject } from '../project/index.js';
import { createKtxEntityDetailsService, type KtxScanConnector, type LocalScanMcpOptions } from '../scan/index.js'; import { createKtxEntityDetailsService, type KtxScanConnector, type LocalScanMcpOptions } from '../scan/index.js';
import { createKtxDiscoverDataService } from '../search/index.js'; import { createKtxDiscoverDataService } from '../search/index.js';
@ -15,7 +14,7 @@ interface CreateLocalProjectMcpContextPortsOptions {
queryExecutor?: KtxSqlQueryExecutorPort; queryExecutor?: KtxSqlQueryExecutorPort;
sqlAnalysis?: SqlAnalysisPort; sqlAnalysis?: SqlAnalysisPort;
localScan?: LocalScanMcpOptions; localScan?: LocalScanMcpOptions;
embeddingService?: KtxEmbeddingPort | null; embeddingService: KtxEmbeddingPort | null;
} }
function dialectForDriver(driver: string | undefined): string { function dialectForDriver(driver: string | undefined): string {
@ -133,12 +132,9 @@ async function executeValidatedReadOnlySql(
export function createLocalProjectMcpContextPorts( export function createLocalProjectMcpContextPorts(
project: KtxLocalProject, project: KtxLocalProject,
options: CreateLocalProjectMcpContextPortsOptions = {}, options: CreateLocalProjectMcpContextPortsOptions,
): KtxMcpContextPorts { ): KtxMcpContextPorts {
const configuredEmbeddingProvider = createLocalKtxEmbeddingProviderFromConfig(project.config.ingest.embeddings); const embeddingService = options.embeddingService;
const embeddingService =
options.embeddingService ??
(configuredEmbeddingProvider ? new KtxIngestEmbeddingPortAdapter(configuredEmbeddingProvider) : null);
const ports: KtxMcpContextPorts = { const ports: KtxMcpContextPorts = {
connections: { connections: {
async list() { async list() {

View file

@ -143,7 +143,6 @@ describe('@ktx/context package exports', () => {
expect(root.assertSearchBackendConformanceCase).toBeTypeOf('function'); expect(root.assertSearchBackendConformanceCase).toBeTypeOf('function');
expect(root.assertSearchBackendCapabilities).toBeTypeOf('function'); expect(root.assertSearchBackendCapabilities).toBeTypeOf('function');
expect(root.createLocalKtxEmbeddingProviderFromConfig).toBeTypeOf('function'); expect(root.createLocalKtxEmbeddingProviderFromConfig).toBeTypeOf('function');
expect(root.MANAGED_SENTENCE_TRANSFORMERS_BASE_URL).toBe('managed:local-embeddings');
expect(agent).toBeDefined(); expect(agent).toBeDefined();
expect(agent.AgentRunnerService).toBeTypeOf('function'); expect(agent.AgentRunnerService).toBeTypeOf('function');
expect(root.AgentRunnerService).toBeTypeOf('function'); expect(root.AgentRunnerService).toBeTypeOf('function');

View file

@ -36,7 +36,7 @@ const vertexProviderSchema = z
const sentenceTransformersSchema = z const sentenceTransformersSchema = z
.strictObject({ .strictObject({
base_url: z.string().default('').describe('Base URL of the sentence-transformers HTTP server. Empty string uses the managed local runtime.'), base_url: z.string().default('').describe('Base URL of the sentence-transformers HTTP server. Leave empty (or omit) to use the project-managed local daemon.'),
pathPrefix: z.string().optional().describe('Optional URL path prefix prepended to embedding requests.'), pathPrefix: z.string().optional().describe('Optional URL path prefix prepended to embedding requests.'),
}) })
.describe('Sentence-transformers embedding server configuration.'); .describe('Sentence-transformers embedding server configuration.');

View file

@ -813,16 +813,16 @@ describe('local scan enrichment', () => {
} }
}); });
it('resolves gateway LLM providers and OpenAI embeddings from local scan config', () => { it('resolves gateway LLM providers and passes injected embedding provider through to scan enrichment', () => {
const createKtxLlmProvider = vi.fn(() => ({ const createKtxLlmProvider = vi.fn(() => ({
getModel: vi.fn().mockReturnValue({ modelId: 'provider/language-model', provider: 'gateway' }), getModel: vi.fn().mockReturnValue({ modelId: 'provider/language-model', provider: 'gateway' }),
})); }));
const createKtxEmbeddingProvider = vi.fn(() => ({ const embeddingProvider = {
dimensions: 1536, dimensions: 1536,
maxBatchSize: 8, maxBatchSize: 8,
embed: vi.fn(), embed: vi.fn(),
[['embed', 'Many'].join('')]: vi.fn(), [['embed', 'Many'].join('')]: vi.fn(),
})); };
const providers = createLocalScanEnrichmentProvidersFromConfig( const providers = createLocalScanEnrichmentProvidersFromConfig(
{ {
@ -844,8 +844,8 @@ describe('local scan enrichment', () => {
}, },
{ {
createKtxLlmProvider: createKtxLlmProvider as any, createKtxLlmProvider: createKtxLlmProvider as any,
createKtxEmbeddingProvider: createKtxEmbeddingProvider as any,
env: { OPENAI_API_KEY: 'openai-key' }, // pragma: allowlist secret env: { OPENAI_API_KEY: 'openai-key' }, // pragma: allowlist secret
embeddingProvider: embeddingProvider as any,
}, },
); );
@ -854,8 +854,5 @@ describe('local scan enrichment', () => {
expect(createKtxLlmProvider).toHaveBeenCalledWith( expect(createKtxLlmProvider).toHaveBeenCalledWith(
expect.objectContaining({ backend: 'gateway', modelSlots: { default: 'provider/language-model' } }), expect.objectContaining({ backend: 'gateway', modelSlots: { default: 'provider/language-model' } }),
); );
expect(createKtxEmbeddingProvider).toHaveBeenCalledWith(
expect.objectContaining({ backend: 'openai', model: 'provider/embedding-model' }),
);
}); });
}); });

View file

@ -1,4 +1,4 @@
import type { createKtxEmbeddingProvider, createKtxLlmProvider } from '@ktx/llm'; import type { createKtxEmbeddingProvider, createKtxLlmProvider, KtxEmbeddingProvider } from '@ktx/llm';
import { import {
createDefaultLocalIngestAdapters, createDefaultLocalIngestAdapters,
getLocalStageOnlyIngestStatus, getLocalStageOnlyIngestStatus,
@ -6,11 +6,7 @@ import {
runLocalStageOnlyIngest, runLocalStageOnlyIngest,
type SourceAdapter, type SourceAdapter,
} from '../ingest/index.js'; } from '../ingest/index.js';
import { import { createLocalKtxLlmRuntimeFromConfig, KtxScanEmbeddingPortAdapter } from '../llm/index.js';
createLocalKtxEmbeddingProviderFromConfig,
createLocalKtxLlmRuntimeFromConfig,
KtxScanEmbeddingPortAdapter,
} from '../llm/index.js';
import type { KtxProjectLlmConfig, KtxScanEnrichmentConfig, KtxScanRelationshipConfig } from '../project/config.js'; import type { KtxProjectLlmConfig, KtxScanEnrichmentConfig, KtxScanRelationshipConfig } from '../project/config.js';
import type { KtxLocalProject } from '../project/index.js'; import type { KtxLocalProject } from '../project/index.js';
import { ktxLocalStateDbPath } from '../project/local-state-db.js'; import { ktxLocalStateDbPath } from '../project/local-state-db.js';
@ -55,6 +51,7 @@ export interface RunLocalScanOptions {
enrichmentProviders?: KtxLocalScanEnrichmentProviders | null; enrichmentProviders?: KtxLocalScanEnrichmentProviders | null;
enrichmentStateStore?: SqliteLocalScanEnrichmentStateStore | null; enrichmentStateStore?: SqliteLocalScanEnrichmentStateStore | null;
progress?: KtxProgressPort; progress?: KtxProgressPort;
embeddingProvider?: KtxEmbeddingProvider | null;
} }
export interface LocalScanRunResult { export interface LocalScanRunResult {
@ -152,6 +149,7 @@ interface LocalScanEnrichmentProviderDeps {
createKtxEmbeddingProvider?: typeof createKtxEmbeddingProvider; createKtxEmbeddingProvider?: typeof createKtxEmbeddingProvider;
env?: NodeJS.ProcessEnv; env?: NodeJS.ProcessEnv;
projectDir?: string; projectDir?: string;
embeddingProvider?: KtxEmbeddingProvider | null;
} }
export function createLocalScanEnrichmentProvidersFromConfig( export function createLocalScanEnrichmentProvidersFromConfig(
@ -171,7 +169,7 @@ export function createLocalScanEnrichmentProvidersFromConfig(
...deps, ...deps,
projectDir: deps.projectDir, projectDir: deps.projectDir,
}); });
const embeddingProvider = createLocalKtxEmbeddingProviderFromConfig(config.embeddings, deps); const embeddingProvider = deps.embeddingProvider ?? null;
if (!llmRuntime || !embeddingProvider) { if (!llmRuntime || !embeddingProvider) {
return null; return null;
} }
@ -371,6 +369,7 @@ export async function runLocalScan(options: RunLocalScanOptions): Promise<LocalS
? options.enrichmentProviders ? options.enrichmentProviders
: createLocalScanEnrichmentProvidersFromConfig(options.project.config.scan.enrichment, options.project.config.llm, { : createLocalScanEnrichmentProvidersFromConfig(options.project.config.scan.enrichment, options.project.config.llm, {
projectDir: options.project.projectDir, projectDir: options.project.projectDir,
embeddingProvider: options.embeddingProvider ?? null,
}) })
: null; : null;

View file

@ -357,10 +357,13 @@ export async function runLocalEmbeddingsRuntimeSmoke(options = {}) {
requireOutput(commands[5].label, setup, /Embeddings ready: yes \(all-MiniLM-L6-v2\)/); requireOutput(commands[5].label, setup, /Embeddings ready: yes \(all-MiniLM-L6-v2\)/);
const config = await readFile(join(projectDir, 'ktx.yaml'), 'utf8'); const config = await readFile(join(projectDir, 'ktx.yaml'), 'utf8');
if (!config.includes('base_url: managed:local-embeddings')) { if (!/backend:\s*sentence-transformers/.test(config)) {
throw new Error(`ktx.yaml did not contain managed local embeddings marker:\n${config}`); throw new Error(`ktx.yaml did not declare sentence-transformers embedding backend:\n${config}`);
} }
process.stdout.write('KTX setup persisted managed local embeddings marker\n'); if (/base_url:/.test(config)) {
throw new Error(`ktx.yaml should omit base_url for managed local embeddings:\n${config}`);
}
process.stdout.write('KTX setup persisted managed local embeddings (no base_url)\n');
const stop = await run(commands[6].command, commands[6].args, { const stop = await run(commands[6].command, commands[6].args, {
cwd: installDir, cwd: installDir,