import { KtxIngestEmbeddingPortAdapter } from './context/llm/embedding-port.js'; import type { KtxEmbeddingPort } from './context/core/embedding.js'; import { loadKtxProject } from './context/project/project.js'; import { type LocalKnowledgeSearchResult, type LocalKnowledgeSummary, listLocalKnowledgePages, searchLocalKnowledgePages as defaultSearchLocalKnowledgePages } from './context/wiki/local-knowledge.js'; import { resolveProjectEmbeddingProvider, type EmbeddingProviderResolution, } from './embedding-resolution.js'; import { resolveOutputMode } from './io/mode.js'; import { createRankBadgeFormatter, printList, type PrintListColumn } from './io/print-list.js'; import { emitTelemetryEvent } from './telemetry/index.js'; export type KtxKnowledgeArgs = | { command: 'list'; projectDir: string; userId: string; output?: string; json?: boolean; cliVersion: string } | { command: 'search'; projectDir: string; query: string; userId: string; output?: string; json?: boolean; limit?: number; debug?: boolean; cliVersion: string; }; type KtxKnowledgeIo = import('./cli-runtime.js').KtxCliIo; const WIKI_LIST_COLUMNS: ReadonlyArray> = [ { key: 'scope', label: 'SCOPE', plain: '' }, { key: 'key', label: 'KEY', plain: '' }, { key: 'summary', label: 'SUMMARY', plain: '', optional: true, dim: true }, ]; function wikiSearchColumns( rows: ReadonlyArray, ): ReadonlyArray> { return [ { key: 'score', label: 'SCORE', plain: 'score=', role: 'badge', prettyFormat: createRankBadgeFormatter(rows), dim: true, }, { key: 'scope', label: 'SCOPE', plain: '' }, { key: 'key', label: 'KEY', plain: '' }, { key: 'summary', label: 'SUMMARY', plain: '', optional: true, dim: true }, ]; } interface KtxKnowledgeDeps { embeddingService?: KtxEmbeddingPort | null; resolveEmbeddingProvider?: typeof resolveProjectEmbeddingProvider; searchLocalKnowledgePages?: typeof defaultSearchLocalKnowledgePages; } 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>, deps: KtxKnowledgeDeps, args: { cliVersion: string }, io: KtxKnowledgeIo, ): Promise { if ('embeddingService' in deps) { return deps.embeddingService ?? null; } const resolution = await (deps.resolveEmbeddingProvider ?? resolveProjectEmbeddingProvider)(project, { mode: 'use-if-running', cliVersion: args.cliVersion, io, }); return resolutionToEmbeddingPort(resolution); } function writeWikiSearchDebug( io: KtxKnowledgeIo, input: { mode: string; embeddingConfigured: boolean; results: LocalKnowledgeSearchResult[]; }, ): void { io.stderr.write( `[debug] wiki search mode=${input.mode} embedding=${input.embeddingConfigured ? 'configured' : 'unconfigured'} results=${input.results.length}\n`, ); const lanes = input.results[0]?.lanes ?? []; for (const lane of lanes) { const reason = lane.reason ? ` reason=${lane.reason}` : ''; io.stderr.write( `[debug] wiki search lane=${lane.lane} status=${lane.status} returned=${lane.returnedCandidateCount} weight=${lane.weight}${reason}\n`, ); } } export async function runKtxKnowledge( args: KtxKnowledgeArgs, io: KtxKnowledgeIo = process, deps: KtxKnowledgeDeps = {}, ): Promise { const startedAt = performance.now(); try { const project = await loadKtxProject({ projectDir: args.projectDir }); if (args.command === 'list') { const pages = await listLocalKnowledgePages(project, { userId: args.userId }); const mode = resolveOutputMode({ explicit: args.output, json: args.json, io }); printList({ rows: pages, columns: WIKI_LIST_COLUMNS, groupBy: 'scope', emptyMessage: `No local wiki pages found in ${project.projectDir}`, emptyHint: 'Add Markdown files under wiki/ or run `ktx ingest `.', unit: 'page', command: 'wiki list', mode, io, }); return 0; } if (args.command === 'search') { const embeddingService = await wikiSearchEmbeddingService(project, deps, { cliVersion: args.cliVersion }, io); const search = deps.searchLocalKnowledgePages ?? defaultSearchLocalKnowledgePages; const results = await search(project, { query: args.query, userId: args.userId, embeddingService, limit: args.limit, }); await emitTelemetryEvent({ name: 'wiki_query_completed', projectDir: args.projectDir, io, fields: { queryLength: args.query.length, resultCount: results.length, durationMs: Math.max(0, performance.now() - startedAt), outcome: 'ok', }, }); if (args.debug) { writeWikiSearchDebug(io, { mode: project.config.storage.search, embeddingConfigured: embeddingService !== null, results, }); } const mode = resolveOutputMode({ explicit: args.output, json: args.json, io }); let emptyMessage = `No local wiki pages matched "${args.query}"`; let emptyHint = 'Run `ktx wiki` to inspect available pages.'; if (results.length === 0 && mode !== 'json') { const pages = await listLocalKnowledgePages(project, { userId: args.userId }); if (pages.length === 0) { emptyMessage = `No local wiki pages found in ${project.projectDir}`; emptyHint = 'Add Markdown files under wiki/ or run `ktx ingest `.'; } } printList({ rows: results, columns: wikiSearchColumns(results), groupBy: 'scope', emptyMessage, emptyHint, unit: 'page', command: 'wiki search', mode, io, }); return 0; } return 0; } catch (error) { if (args.command === 'search') { await emitTelemetryEvent({ name: 'wiki_query_completed', projectDir: args.projectDir, io, fields: { queryLength: args.query.length, resultCount: 0, durationMs: Math.max(0, performance.now() - startedAt), outcome: 'error', }, }); } io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); return 1; } }