mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-07 07:55:13 +02:00
* feat(completion): complete known argument values
* fix(completion): hide Commander-hidden subcommands from completions
Replace the `__`-prefix name heuristic with Commander's `_hidden` flag so
internal subcommands registered with { hidden: true } (e.g. `mcp serve-internal`)
are excluded from completions, mirroring `ktx --help`.
* test: cover wiki and sl read command routing
* test: cover raw wiki and sl reads
* feat: add wiki read command
* feat: add sl read command
* feat: complete read command entity names
* docs: document wiki and sl read commands
* test: include read commands in command tree
* feat(sl): read and validate unique sources by name
* feat(sl): make read and validate connection id optional
* fix(completion): dedupe semantic source names
* docs(sl): document connection-optional read and validate
* fix(sl): require connection id for query command
* docs(sl): clarify query connection requirement
* fix(completion): don't resolve option values as subcommands
resolveCommand skipped flag tokens but not the value consumed by a
value-taking option in the `--flag value` form, so a connection id like
`query` was matched as the `sl query` subcommand and yielded no `sl`
completions. Track value-taking options and skip their consumed value
before matching subcommands.
* test(telemetry): assert first-run notice via TELEMETRY_NOTICE constant
CI (which tests this branch merged with main) failed because #243 changed
the first-run notice wording in identity.ts (dropped "anonymous") but left
this test grepping for the old literal 'ktx collects anonymous usage data',
so indexOf returned -1. Assert against the exported TELEMETRY_NOTICE
constant instead so the test tracks the source of truth and cannot drift
when the notice text changes again.
215 lines
7.2 KiB
TypeScript
215 lines
7.2 KiB
TypeScript
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,
|
|
readLocalKnowledgePage,
|
|
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;
|
|
}
|
|
| { command: 'read'; projectDir: string; key: string; userId: string };
|
|
|
|
type KtxKnowledgeIo = import('./cli-runtime.js').KtxCliIo;
|
|
|
|
const WIKI_LIST_COLUMNS: ReadonlyArray<PrintListColumn<LocalKnowledgeSummary>> = [
|
|
{ key: 'scope', label: 'SCOPE', plain: '' },
|
|
{ key: 'key', label: 'KEY', plain: '' },
|
|
{ key: 'summary', label: 'SUMMARY', plain: '', optional: true, dim: true },
|
|
];
|
|
|
|
function wikiSearchColumns(
|
|
rows: ReadonlyArray<LocalKnowledgeSearchResult>,
|
|
): ReadonlyArray<PrintListColumn<LocalKnowledgeSearchResult>> {
|
|
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<ReturnType<typeof loadKtxProject>>,
|
|
deps: KtxKnowledgeDeps,
|
|
args: { cliVersion: string },
|
|
io: KtxKnowledgeIo,
|
|
): Promise<KtxEmbeddingPort | null> {
|
|
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<number> {
|
|
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<LocalKnowledgeSummary>({
|
|
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 <connectionId>`.',
|
|
unit: 'page',
|
|
command: 'wiki list',
|
|
mode,
|
|
io,
|
|
});
|
|
return 0;
|
|
}
|
|
if (args.command === 'read') {
|
|
const page = await readLocalKnowledgePage(project, { key: args.key, userId: args.userId });
|
|
if (!page) {
|
|
throw new Error(`No wiki page found for key '${args.key}'`);
|
|
}
|
|
const raw = await project.fileStore.readFile(page.path);
|
|
io.stdout.write(raw.content);
|
|
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 <connectionId>`.';
|
|
}
|
|
}
|
|
printList<LocalKnowledgeSearchResult>({
|
|
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;
|
|
}
|
|
}
|