mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-19 08:28:06 +02:00
Merge origin/main into copy-claude-code-backend-spec
This commit is contained in:
commit
7287e4907b
59 changed files with 8221 additions and 3159 deletions
|
|
@ -48,6 +48,7 @@
|
|||
"@ktx/llm": "workspace:*",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"commander": "14.0.3",
|
||||
"fflate": "^0.8.2",
|
||||
"ink": "^7.0.2",
|
||||
"react": "^19.2.6",
|
||||
"zod": "^4.4.3"
|
||||
|
|
|
|||
|
|
@ -186,6 +186,10 @@ function shouldSuppressProjectDirLine(path: string[], options: Record<string, un
|
|||
return true;
|
||||
}
|
||||
|
||||
if (commandPathKey === 'ktx mcp stdio') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
commandPathKey === 'ktx status' &&
|
||||
typeof options.projectDir !== 'string' &&
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ export interface KtxCliDeps {
|
|||
stopDaemon?: typeof import('./managed-mcp-daemon.js').stopKtxMcpDaemon;
|
||||
readStatus?: typeof import('./managed-mcp-daemon.js').readKtxMcpDaemonStatus;
|
||||
runServer?: typeof import('./mcp-http-server.js').runKtxMcpHttpServer;
|
||||
runStdioServer?: typeof import('./mcp-stdio-server.js').runKtxMcpStdioServer;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ describe('registerMcpCommands', () => {
|
|||
'serve-internal',
|
||||
'start',
|
||||
'status',
|
||||
'stdio',
|
||||
'stop',
|
||||
]);
|
||||
expect(
|
||||
|
|
@ -54,4 +55,48 @@ describe('registerMcpCommands', () => {
|
|||
);
|
||||
expect(startDaemon).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prints "already running" when startDaemon reports already-running', async () => {
|
||||
const program = new Command().exitOverride().option('--project-dir <path>');
|
||||
const startDaemon = vi.fn().mockResolvedValue({
|
||||
status: 'already-running',
|
||||
url: 'http://127.0.0.1:7878/mcp',
|
||||
state: {
|
||||
schemaVersion: 1,
|
||||
pid: 4242,
|
||||
host: '127.0.0.1',
|
||||
port: 7878,
|
||||
tokenAuth: false,
|
||||
projectDir: '/tmp/ktx-already',
|
||||
startedAt: '2026-05-14T00:00:00.000Z',
|
||||
logPath: '/tmp/ktx-already/.ktx/logs/mcp.log',
|
||||
},
|
||||
});
|
||||
const context = makeContext({ deps: { mcp: { startDaemon } } });
|
||||
registerMcpCommands(program, context);
|
||||
|
||||
await program.parseAsync(['--project-dir', '/tmp/ktx-already', 'mcp', 'start'], { from: 'user' });
|
||||
|
||||
expect(startDaemon).toHaveBeenCalledTimes(1);
|
||||
expect(context.io.stdout.write).toHaveBeenCalledWith(
|
||||
'KTX MCP daemon already running: http://127.0.0.1:7878/mcp\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('runs the stdio server with the resolved project directory', async () => {
|
||||
const program = new Command().exitOverride().option('--project-dir <path>');
|
||||
const runStdioServer = vi.fn().mockResolvedValue(undefined);
|
||||
const context = makeContext({ deps: { mcp: { runStdioServer } } });
|
||||
registerMcpCommands(program, context);
|
||||
|
||||
await expect(program.parseAsync(['--project-dir', '/tmp/ktx6', 'mcp', 'stdio'], { from: 'user' })).resolves.toBe(
|
||||
program,
|
||||
);
|
||||
|
||||
expect(runStdioServer).toHaveBeenCalledWith({
|
||||
projectDir: '/tmp/ktx6',
|
||||
cliVersion: '0.0.0-test',
|
||||
io: context.io,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
stopKtxMcpDaemon,
|
||||
} from '../managed-mcp-daemon.js';
|
||||
import { buildMcpSecurityConfig, runKtxMcpHttpServer } from '../mcp-http-server.js';
|
||||
import { runKtxMcpStdioServer } from '../mcp-stdio-server.js';
|
||||
|
||||
function tokenFromOption(value: string | undefined): string | undefined {
|
||||
return value ?? process.env.KTX_MCP_TOKEN;
|
||||
|
|
@ -27,6 +28,17 @@ function binPath(): string {
|
|||
export function registerMcpCommands(program: Command, context: KtxCliCommandContext): void {
|
||||
const mcp = program.command('mcp').description('Run the KTX MCP HTTP server');
|
||||
|
||||
mcp
|
||||
.command('stdio')
|
||||
.description('Run the KTX MCP server over stdio')
|
||||
.action(async (_options, command) => {
|
||||
await (context.deps.mcp?.runStdioServer ?? runKtxMcpStdioServer)({
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
cliVersion: context.packageInfo.version,
|
||||
io: context.io,
|
||||
});
|
||||
});
|
||||
|
||||
mcp
|
||||
.command('start')
|
||||
.description('Start the KTX MCP HTTP server')
|
||||
|
|
@ -70,7 +82,11 @@ export function registerMcpCommands(program: Command, context: KtxCliCommandCont
|
|||
allowedOrigins: options.allowedOrigin,
|
||||
binPath: binPath(),
|
||||
});
|
||||
context.io.stdout.write(`KTX MCP daemon started: ${result.url}\n`);
|
||||
context.io.stdout.write(
|
||||
result.status === 'started'
|
||||
? `KTX MCP daemon started: ${result.url}\n`
|
||||
: `KTX MCP daemon already running: ${result.url}\n`,
|
||||
);
|
||||
});
|
||||
|
||||
mcp
|
||||
|
|
|
|||
|
|
@ -220,6 +220,7 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
|
|||
.addOption(
|
||||
new Option('--target <target>', 'Agent target').choices([
|
||||
'claude-code',
|
||||
'claude-desktop',
|
||||
'codex',
|
||||
'cursor',
|
||||
'opencode',
|
||||
|
|
|
|||
|
|
@ -994,6 +994,37 @@ describe('runContextBuild', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('threads the original runtime IO into captured target execution', async () => {
|
||||
const io = makeIo({ isTTY: true });
|
||||
const project = projectWithConnections({
|
||||
warehouse: { driver: 'postgres', context: { queryHistory: { enabled: true } } },
|
||||
});
|
||||
const executeTarget = vi.fn(async (target) => successResult(target.connectionId, target.driver, target.operation));
|
||||
|
||||
await runContextBuild(
|
||||
project,
|
||||
{
|
||||
projectDir: '/tmp/project',
|
||||
inputMode: 'auto',
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
},
|
||||
io.io,
|
||||
{ executeTarget, now: () => 1000 },
|
||||
);
|
||||
|
||||
expect(executeTarget).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ connectionId: 'warehouse' }),
|
||||
expect.objectContaining({ runtimeInstallPolicy: 'auto' }),
|
||||
expect.objectContaining({
|
||||
stdout: expect.objectContaining({ isTTY: false }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
runtimeIo: io.io,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('calls onSourceProgress when sources start and finish', async () => {
|
||||
const io = makeIo();
|
||||
const project = projectWithConnections({
|
||||
|
|
|
|||
|
|
@ -1022,6 +1022,7 @@ export async function runContextBuild(
|
|||
const progressDeps: KtxPublicIngestDeps = {
|
||||
scanProgress: createContextBuildProgressPort(updateSchemaPhase),
|
||||
ingestProgress: updateIngestPhase,
|
||||
runtimeIo: io,
|
||||
onPhaseStart,
|
||||
onPhaseEnd,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
} from '@ktx/context/ingest';
|
||||
import { initKtxProject, ktxLocalStateDbPath, loadKtxProject } from '@ktx/context/project';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { type KtxIngestArgs, runKtxIngest } from './ingest.js';
|
||||
import { type KtxIngestArgs, type KtxIngestDeps, runKtxIngest } from './ingest.js';
|
||||
import type { KtxCliLocalIngestAdaptersOptions } from './local-adapters.js';
|
||||
import {
|
||||
CliLookerSlWritingAgentRunner,
|
||||
|
|
@ -1110,6 +1110,7 @@ describe('runKtxIngest', () => {
|
|||
completedLocalBundleRun(input, input.jobId ?? 'local-job-1'),
|
||||
);
|
||||
const io = makeIo();
|
||||
const runtimeIo = makeIo({ isTTY: true });
|
||||
|
||||
await expect(
|
||||
runKtxIngest(
|
||||
|
|
@ -1127,6 +1128,9 @@ describe('runKtxIngest', () => {
|
|||
createAdapters,
|
||||
runLocalIngest: runLocal,
|
||||
jobIdFactory: () => 'local-job-1',
|
||||
runtimeIo: runtimeIo.io,
|
||||
} as KtxIngestDeps & {
|
||||
runtimeIo: typeof runtimeIo.io;
|
||||
},
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
|
@ -1135,7 +1139,7 @@ describe('runKtxIngest', () => {
|
|||
cliVersion: '0.2.0',
|
||||
projectDir,
|
||||
installPolicy: 'auto',
|
||||
io: io.io,
|
||||
io: runtimeIo.io,
|
||||
};
|
||||
expect(createAdapters).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ projectDir }),
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ export interface KtxIngestDeps {
|
|||
| 'pullConfigOptions'
|
||||
>;
|
||||
progress?: (update: KtxIngestProgressUpdate) => void;
|
||||
runtimeIo?: KtxIngestIo;
|
||||
}
|
||||
|
||||
function reportStatus(report: IngestReportSnapshot): 'done' | 'error' {
|
||||
|
|
@ -615,7 +616,7 @@ export async function runKtxIngest(
|
|||
(deps.runLocalIngest || deps.runLocalMetabaseIngest ? () => [] : createKtxCliLocalIngestAdapters);
|
||||
const executeLocalIngest = deps.runLocalIngest ?? runLocalIngest;
|
||||
const localIngestOptions = deps.localIngestOptions ?? {};
|
||||
const managedDaemon = managedDaemonOptionsForIngestRun(args, io);
|
||||
const managedDaemon = managedDaemonOptionsForIngestRun(args, deps.runtimeIo ?? io);
|
||||
const operationalLogger = createCliOperationalLogger(io, args.outputMode);
|
||||
const adapterOptions = {
|
||||
...(localIngestOptions.pullConfigOptions ?? {}),
|
||||
|
|
|
|||
|
|
@ -94,6 +94,78 @@ describe('managed MCP daemon lifecycle', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('returns already-running without spawning when the daemon is alive at the same host/port', async () => {
|
||||
await mkdir(join(projectDir, '.ktx'), { recursive: true });
|
||||
await writeFile(join(projectDir, '.ktx/mcp.json'), `${JSON.stringify(state(projectDir), null, 2)}\n`);
|
||||
const spawnDaemon = vi.fn(() => child(9999));
|
||||
|
||||
const result = await startKtxMcpDaemon({
|
||||
projectDir,
|
||||
cliVersion: '0.0.0-test',
|
||||
host: '127.0.0.1',
|
||||
port: 7878,
|
||||
allowedHosts: [],
|
||||
allowedOrigins: [],
|
||||
binPath: '/repo/packages/cli/dist/bin.js',
|
||||
spawnDaemon,
|
||||
processAlive: vi.fn(() => true),
|
||||
portAvailable: vi.fn(async () => true),
|
||||
});
|
||||
|
||||
expect(result.status).toBe('already-running');
|
||||
expect(result.url).toBe('http://127.0.0.1:7878/mcp');
|
||||
expect(result.state.pid).toBe(4242);
|
||||
expect(spawnDaemon).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the recorded daemon uses a different host or port', async () => {
|
||||
await mkdir(join(projectDir, '.ktx'), { recursive: true });
|
||||
await writeFile(join(projectDir, '.ktx/mcp.json'), `${JSON.stringify(state(projectDir), null, 2)}\n`);
|
||||
const spawnDaemon = vi.fn(() => child(9999));
|
||||
|
||||
await expect(
|
||||
startKtxMcpDaemon({
|
||||
projectDir,
|
||||
cliVersion: '0.0.0-test',
|
||||
host: '127.0.0.1',
|
||||
port: 9000,
|
||||
allowedHosts: [],
|
||||
allowedOrigins: [],
|
||||
binPath: '/repo/packages/cli/dist/bin.js',
|
||||
spawnDaemon,
|
||||
processAlive: vi.fn(() => true),
|
||||
portAvailable: vi.fn(async () => true),
|
||||
}),
|
||||
).rejects.toThrow(/different configuration[\s\S]*ktx mcp stop/);
|
||||
expect(spawnDaemon).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when token-auth presence differs from the recorded daemon', async () => {
|
||||
await mkdir(join(projectDir, '.ktx'), { recursive: true });
|
||||
await writeFile(
|
||||
join(projectDir, '.ktx/mcp.json'),
|
||||
`${JSON.stringify(state(projectDir, { tokenAuth: false }), null, 2)}\n`,
|
||||
);
|
||||
const spawnDaemon = vi.fn(() => child(9999));
|
||||
|
||||
await expect(
|
||||
startKtxMcpDaemon({
|
||||
projectDir,
|
||||
cliVersion: '0.0.0-test',
|
||||
host: '127.0.0.1',
|
||||
port: 7878,
|
||||
token: 'secret-token',
|
||||
allowedHosts: [],
|
||||
allowedOrigins: [],
|
||||
binPath: '/repo/packages/cli/dist/bin.js',
|
||||
spawnDaemon,
|
||||
processAlive: vi.fn(() => true),
|
||||
portAvailable: vi.fn(async () => true),
|
||||
}),
|
||||
).rejects.toThrow(/different configuration/);
|
||||
expect(spawnDaemon).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports running when the process is alive and health passes', async () => {
|
||||
await mkdir(join(projectDir, '.ktx'), { recursive: true });
|
||||
await writeFile(join(projectDir, '.ktx/mcp.json'), `${JSON.stringify(state(projectDir), null, 2)}\n`);
|
||||
|
|
|
|||
|
|
@ -121,11 +121,25 @@ export async function startKtxMcpDaemon(options: {
|
|||
portAvailable?: (host: string, port: number) => Promise<boolean>;
|
||||
spawnDaemon?: typeof defaultSpawnDaemon;
|
||||
now?: () => Date;
|
||||
}): Promise<{ status: 'started'; state: KtxMcpDaemonState; url: string }> {
|
||||
}): Promise<{ status: 'started' | 'already-running'; state: KtxMcpDaemonState; url: string }> {
|
||||
const existing = await readState(options.projectDir).catch(() => undefined);
|
||||
const processAlive = options.processAlive ?? defaultProcessAlive;
|
||||
if (existing && processAlive(existing.pid)) {
|
||||
throw new Error(`KTX MCP daemon is already recorded at http://${existing.host}:${existing.port}/mcp`);
|
||||
const sameConfig =
|
||||
existing.host === options.host &&
|
||||
existing.port === options.port &&
|
||||
existing.tokenAuth === Boolean(options.token);
|
||||
if (sameConfig) {
|
||||
return {
|
||||
status: 'already-running',
|
||||
state: existing,
|
||||
url: `http://${existing.host}:${existing.port}/mcp`,
|
||||
};
|
||||
}
|
||||
throw new Error(
|
||||
`KTX MCP daemon is already running at http://${existing.host}:${existing.port}/mcp ` +
|
||||
'with a different configuration. Run `ktx mcp stop` first, then start again.',
|
||||
);
|
||||
}
|
||||
const portAvailable = options.portAvailable ?? defaultPortAvailable;
|
||||
if (!(await portAvailable(options.host, options.port))) {
|
||||
|
|
|
|||
|
|
@ -1,16 +1,11 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { createServer, type IncomingHttpHeaders, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
||||
import { createDefaultKtxMcpServer, createLocalProjectMcpContextPorts } from '@ktx/context/mcp';
|
||||
import { createLocalProjectMemoryCapture } from '@ktx/context/memory';
|
||||
import { loadKtxProject, type KtxLocalProject } from '@ktx/context/project';
|
||||
import { loadKtxProject } from '@ktx/context/project';
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import { createKtxCliIngestQueryExecutor } from './ingest-query-executor.js';
|
||||
import { createKtxCliScanConnector } from './local-scan-connectors.js';
|
||||
import { createManagedPythonSemanticLayerComputePort } from './managed-python-command.js';
|
||||
import { createManagedDaemonSqlAnalysisPort } from './managed-python-http.js';
|
||||
import { createKtxMcpServerFactory } from './mcp-server-factory.js';
|
||||
|
||||
const DEFAULT_ALLOWED_HOSTS = ['localhost', '127.0.0.1', '::1'] as const;
|
||||
|
||||
|
|
@ -124,13 +119,6 @@ export interface RunKtxMcpHttpServerOptions extends McpSecurityConfigInput {
|
|||
loadProject?: typeof loadKtxProject;
|
||||
}
|
||||
|
||||
function noopIo(): KtxCliIo {
|
||||
return {
|
||||
stdout: { write() {} },
|
||||
stderr: { write() {} },
|
||||
};
|
||||
}
|
||||
|
||||
function writeJson(res: ServerResponse, status: number, body: object): void {
|
||||
const payload = `${JSON.stringify(body)}\n`;
|
||||
res.writeHead(status, {
|
||||
|
|
@ -159,55 +147,6 @@ async function readJsonBody(req: IncomingMessage): Promise<unknown> {
|
|||
return raw.trim().length === 0 ? undefined : (JSON.parse(raw) as unknown);
|
||||
}
|
||||
|
||||
async function defaultMcpServerFactory(input: {
|
||||
project: KtxLocalProject;
|
||||
projectDir: string;
|
||||
cliVersion: string;
|
||||
io?: KtxCliIo;
|
||||
}): Promise<() => McpServer> {
|
||||
const io = input.io ?? noopIo();
|
||||
const queryExecutor = createKtxCliIngestQueryExecutor(input.project);
|
||||
const semanticLayerCompute = await createManagedPythonSemanticLayerComputePort({
|
||||
cliVersion: input.cliVersion,
|
||||
installPolicy: 'auto',
|
||||
io,
|
||||
});
|
||||
const sqlAnalysis = createManagedDaemonSqlAnalysisPort({
|
||||
cliVersion: input.cliVersion,
|
||||
projectDir: input.projectDir,
|
||||
installPolicy: 'auto',
|
||||
io,
|
||||
});
|
||||
const contextTools = createLocalProjectMcpContextPorts(input.project, {
|
||||
semanticLayerCompute,
|
||||
queryExecutor,
|
||||
sqlAnalysis,
|
||||
localScan: {
|
||||
createConnector: async (connectionId) => createKtxCliScanConnector(input.project, connectionId),
|
||||
},
|
||||
localIngest: {
|
||||
semanticLayerCompute,
|
||||
queryExecutor,
|
||||
},
|
||||
});
|
||||
|
||||
let memoryCapture: ReturnType<typeof createLocalProjectMemoryCapture> | undefined;
|
||||
try {
|
||||
memoryCapture = createLocalProjectMemoryCapture(input.project, { semanticLayerCompute, queryExecutor });
|
||||
} catch (error) {
|
||||
input.io?.stderr.write(`KTX MCP memory_capture disabled: ${error instanceof Error ? error.message : String(error)}\n`);
|
||||
}
|
||||
|
||||
return () =>
|
||||
createDefaultKtxMcpServer({
|
||||
name: 'ktx',
|
||||
version: input.cliVersion,
|
||||
userContext: { userId: 'local' },
|
||||
contextTools,
|
||||
memoryCapture,
|
||||
});
|
||||
}
|
||||
|
||||
function listenerPort(server: Server, fallback: number): number {
|
||||
const address = server.address();
|
||||
return typeof address === 'object' && address ? address.port : fallback;
|
||||
|
|
@ -233,7 +172,7 @@ export async function runKtxMcpHttpServer(options: RunKtxMcpHttpServerOptions):
|
|||
: undefined;
|
||||
const createMcpServer =
|
||||
options.createMcpServer ??
|
||||
(await defaultMcpServerFactory({
|
||||
(await createKtxMcpServerFactory({
|
||||
project: project!,
|
||||
projectDir: options.projectDir,
|
||||
cliVersion: options.cliVersion ?? '0.0.0-private',
|
||||
|
|
|
|||
63
packages/cli/src/mcp-server-factory.ts
Normal file
63
packages/cli/src/mcp-server-factory.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { createDefaultKtxMcpServer, createLocalProjectMcpContextPorts } from '@ktx/context/mcp';
|
||||
import { createLocalProjectMemoryIngest } from '@ktx/context/memory';
|
||||
import type { KtxLocalProject } from '@ktx/context/project';
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import { createKtxCliIngestQueryExecutor } from './ingest-query-executor.js';
|
||||
import { createKtxCliScanConnector } from './local-scan-connectors.js';
|
||||
import { createManagedPythonSemanticLayerComputePort } from './managed-python-command.js';
|
||||
import { createManagedDaemonSqlAnalysisPort } from './managed-python-http.js';
|
||||
|
||||
function noopMcpIo(): KtxCliIo {
|
||||
return {
|
||||
stdout: { write() {} },
|
||||
stderr: { write() {} },
|
||||
};
|
||||
}
|
||||
|
||||
export async function createKtxMcpServerFactory(input: {
|
||||
project: KtxLocalProject;
|
||||
projectDir: string;
|
||||
cliVersion: string;
|
||||
io?: KtxCliIo;
|
||||
}): Promise<() => McpServer> {
|
||||
const io = input.io ?? noopMcpIo();
|
||||
const queryExecutor = createKtxCliIngestQueryExecutor(input.project);
|
||||
const semanticLayerCompute = await createManagedPythonSemanticLayerComputePort({
|
||||
cliVersion: input.cliVersion,
|
||||
installPolicy: 'auto',
|
||||
io,
|
||||
});
|
||||
const sqlAnalysis = createManagedDaemonSqlAnalysisPort({
|
||||
cliVersion: input.cliVersion,
|
||||
projectDir: input.projectDir,
|
||||
installPolicy: 'auto',
|
||||
io,
|
||||
});
|
||||
const contextTools = createLocalProjectMcpContextPorts(input.project, {
|
||||
semanticLayerCompute,
|
||||
queryExecutor,
|
||||
sqlAnalysis,
|
||||
localScan: {
|
||||
createConnector: async (connectionId) => createKtxCliScanConnector(input.project, connectionId),
|
||||
},
|
||||
});
|
||||
|
||||
let memoryIngest: ReturnType<typeof createLocalProjectMemoryIngest> | undefined;
|
||||
try {
|
||||
memoryIngest = createLocalProjectMemoryIngest(input.project, { semanticLayerCompute, queryExecutor });
|
||||
} catch (error) {
|
||||
input.io?.stderr.write(`KTX MCP memory_ingest disabled: ${error instanceof Error ? error.message : String(error)}\n`);
|
||||
}
|
||||
|
||||
return () =>
|
||||
createDefaultKtxMcpServer({
|
||||
name: 'ktx',
|
||||
version: input.cliVersion,
|
||||
userContext: { userId: 'local' },
|
||||
contextTools: {
|
||||
...contextTools,
|
||||
...(memoryIngest ? { memoryIngest } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
64
packages/cli/src/mcp-stdio-server.ts
Normal file
64
packages/cli/src/mcp-stdio-server.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import process from 'node:process';
|
||||
import type { Readable, Writable } from 'node:stream';
|
||||
import { loadKtxProject } from '@ktx/context/project';
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import { createKtxMcpServerFactory } from './mcp-server-factory.js';
|
||||
|
||||
export interface RunKtxMcpStdioServerOptions {
|
||||
projectDir: string;
|
||||
cliVersion?: string;
|
||||
io?: KtxCliIo;
|
||||
createMcpServer?: () => McpServer;
|
||||
loadProject?: typeof loadKtxProject;
|
||||
stdin?: Readable;
|
||||
stdout?: Writable;
|
||||
}
|
||||
|
||||
export async function runKtxMcpStdioServer(options: RunKtxMcpStdioServerOptions): Promise<void> {
|
||||
const project =
|
||||
options.createMcpServer === undefined
|
||||
? await (options.loadProject ?? loadKtxProject)({ projectDir: options.projectDir })
|
||||
: undefined;
|
||||
const protocolIo: KtxCliIo = {
|
||||
stdout: { write() {} },
|
||||
stderr: options.io?.stderr ?? { write() {} },
|
||||
};
|
||||
const createMcpServer =
|
||||
options.createMcpServer ??
|
||||
(await createKtxMcpServerFactory({
|
||||
project: project!,
|
||||
projectDir: options.projectDir,
|
||||
cliVersion: options.cliVersion ?? '0.0.0-private',
|
||||
io: protocolIo,
|
||||
}));
|
||||
const stdin = options.stdin ?? process.stdin;
|
||||
const transport = new StdioServerTransport(stdin, options.stdout);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const settle = (callback: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
stdin.off('end', closeTransport);
|
||||
stdin.off('close', closeTransport);
|
||||
callback();
|
||||
};
|
||||
const closeTransport = () => {
|
||||
transport.close().catch((error: unknown) => {
|
||||
settle(() => reject(error instanceof Error ? error : new Error(String(error))));
|
||||
});
|
||||
};
|
||||
transport.onclose = () => settle(resolve);
|
||||
transport.onerror = (error) => {
|
||||
options.io?.stderr.write(`KTX MCP stdio transport error: ${error.message}\n`);
|
||||
settle(() => reject(error));
|
||||
};
|
||||
stdin.once('end', closeTransport);
|
||||
stdin.once('close', closeTransport);
|
||||
createMcpServer().connect(transport).catch((error: unknown) => {
|
||||
settle(() => reject(error instanceof Error ? error : new Error(String(error))));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -27,7 +27,7 @@ function replayInput(): MemoryFlowReplayInput {
|
|||
{ unitKey: 'customers', target: 'sl', action: 'updated', key: 'orbit_demo.customers', summary: 'customer metrics', rawFiles: ['customers'], status: 'success' },
|
||||
],
|
||||
provenance: [{ rawPath: 'orders', artifactKind: 'wiki', artifactKey: 'wiki/orders.md', actionType: 'wiki_written' }],
|
||||
transcripts: [{ unitKey: 'orders', path: '/tmp/t.jsonl', toolCallCount: 2, errorCount: 0, toolNames: ['read_raw_span', 'wiki_write'] }],
|
||||
transcripts: [{ unitKey: 'orders', path: '/tmp/t.jsonl', toolCallCount: 2, errorCount: 0, toolNames: ['read_raw_span', 'memory_ingest'] }],
|
||||
},
|
||||
events: [
|
||||
{ type: 'source_acquired', adapter: 'live-database', trigger: 'manual_resync', fileCount: 2 },
|
||||
|
|
|
|||
|
|
@ -421,11 +421,18 @@ describe('runKtxPublicIngest', () => {
|
|||
|
||||
it('runs query history after schema ingest with current-run window override', async () => {
|
||||
const io = makeIo();
|
||||
const runtimeIo = makeIo({ isTTY: true });
|
||||
const project = deepReadyProject({
|
||||
warehouse: { driver: 'postgres', context: { queryHistory: { enabled: true, windowDays: 90 } } },
|
||||
});
|
||||
const runScan = vi.fn(async () => 0);
|
||||
const runIngest = vi.fn<NonNullable<KtxPublicIngestDeps['runIngest']>>(async () => 0);
|
||||
const deps = {
|
||||
loadProject: vi.fn(async () => project),
|
||||
runScan,
|
||||
runIngest,
|
||||
runtimeIo: runtimeIo.io,
|
||||
} as KtxPublicIngestDeps & { runtimeIo: typeof runtimeIo.io };
|
||||
|
||||
await expect(
|
||||
runKtxPublicIngest(
|
||||
|
|
@ -442,13 +449,14 @@ describe('runKtxPublicIngest', () => {
|
|||
queryHistoryWindowDays: 30,
|
||||
},
|
||||
io.io,
|
||||
{ loadProject: vi.fn(async () => project), runScan, runIngest },
|
||||
deps,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(runScan).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ connectionId: 'warehouse', mode: 'enriched' }),
|
||||
expect.anything(),
|
||||
expect.objectContaining({ runtimeIo: runtimeIo.io }),
|
||||
);
|
||||
expect(runIngest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
|
@ -461,6 +469,7 @@ describe('runKtxPublicIngest', () => {
|
|||
historicSqlPullConfigOverride: expect.objectContaining({ dialect: 'postgres', windowDays: 30 }),
|
||||
}),
|
||||
expect.anything(),
|
||||
expect.objectContaining({ runtimeIo: runtimeIo.io }),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ export interface KtxPublicIngestDeps {
|
|||
) => Promise<{ exitCode: number }>;
|
||||
scanProgress?: KtxProgressPort;
|
||||
ingestProgress?: (update: KtxIngestProgressUpdate) => void;
|
||||
runtimeIo?: KtxCliIo;
|
||||
onPhaseStart?: (phaseKey: KtxPublicIngestPhaseKey) => void;
|
||||
onPhaseEnd?: (phaseKey: KtxPublicIngestPhaseKey, status: 'done' | 'failed' | 'skipped', summary?: string) => void;
|
||||
}
|
||||
|
|
@ -719,10 +720,13 @@ export async function executePublicIngestTarget(
|
|||
const runScan = deps.runScan ?? runKtxScan;
|
||||
const capturedScanIo = deps.scanProgress ? null : createCapturedPublicIngestIo();
|
||||
const scanIo = capturedScanIo ?? io;
|
||||
const scanDeps = {
|
||||
...(deps.scanProgress ? { progress: deps.scanProgress } : {}),
|
||||
...(deps.runtimeIo ? { runtimeIo: deps.runtimeIo } : {}),
|
||||
};
|
||||
deps.onPhaseStart?.('database-schema');
|
||||
const scanExitCode = deps.scanProgress
|
||||
? await runScan(scanArgs, scanIo, { progress: deps.scanProgress })
|
||||
: await runScan(scanArgs, scanIo);
|
||||
const scanExitCode =
|
||||
Object.keys(scanDeps).length > 0 ? await runScan(scanArgs, scanIo, scanDeps) : await runScan(scanArgs, scanIo);
|
||||
if (scanExitCode !== 0) {
|
||||
deps.onPhaseEnd?.('database-schema', 'failed');
|
||||
if (target.queryHistory?.enabled === true) {
|
||||
|
|
@ -759,10 +763,15 @@ export async function executePublicIngestTarget(
|
|||
};
|
||||
const capturedIngestIo = deps.ingestProgress ? null : createCapturedPublicIngestIo();
|
||||
const ingestIo = capturedIngestIo ?? io;
|
||||
const ingestDeps = {
|
||||
...(deps.ingestProgress ? { progress: deps.ingestProgress } : {}),
|
||||
...(deps.runtimeIo ? { runtimeIo: deps.runtimeIo } : {}),
|
||||
};
|
||||
deps.onPhaseStart?.('query-history');
|
||||
const qhExitCode = deps.ingestProgress
|
||||
? await runIngest(ingestArgs, ingestIo, { progress: deps.ingestProgress })
|
||||
: await runIngest(ingestArgs, ingestIo);
|
||||
const qhExitCode =
|
||||
Object.keys(ingestDeps).length > 0
|
||||
? await runIngest(ingestArgs, ingestIo, ingestDeps)
|
||||
: await runIngest(ingestArgs, ingestIo);
|
||||
if (qhExitCode !== 0) {
|
||||
deps.onPhaseEnd?.('query-history', 'failed');
|
||||
return markTargetResult(
|
||||
|
|
@ -795,10 +804,15 @@ export async function executePublicIngestTarget(
|
|||
const runIngest = deps.runIngest ?? runKtxIngest;
|
||||
const capturedIngestIo = deps.ingestProgress ? null : createCapturedPublicIngestIo();
|
||||
const ingestIo = capturedIngestIo ?? io;
|
||||
const ingestDeps = {
|
||||
...(deps.ingestProgress ? { progress: deps.ingestProgress } : {}),
|
||||
...(deps.runtimeIo ? { runtimeIo: deps.runtimeIo } : {}),
|
||||
};
|
||||
deps.onPhaseStart?.('source-ingest');
|
||||
const exitCode = deps.ingestProgress
|
||||
? await runIngest(ingestArgs, ingestIo, { progress: deps.ingestProgress })
|
||||
: await runIngest(ingestArgs, ingestIo);
|
||||
const exitCode =
|
||||
Object.keys(ingestDeps).length > 0
|
||||
? await runIngest(ingestArgs, ingestIo, ingestDeps)
|
||||
: await runIngest(ingestArgs, ingestIo);
|
||||
deps.onPhaseEnd?.('source-ingest', exitCode === 0 ? 'done' : 'failed');
|
||||
return markTargetResult(
|
||||
target,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import type {
|
|||
RunLocalScanOptions,
|
||||
} from '@ktx/context/scan';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createCliScanProgress, runKtxScan } from './scan.js';
|
||||
import { createCliScanProgress, runKtxScan, type KtxScanDeps } from './scan.js';
|
||||
|
||||
const sqlServerExtractSchema = vi.hoisted(() =>
|
||||
vi.fn(async (connectionId: string) => ({
|
||||
|
|
@ -392,6 +392,7 @@ describe('runKtxScan', () => {
|
|||
}),
|
||||
);
|
||||
const io = makeIo();
|
||||
const runtimeIo = makeIo({ isTTY: true });
|
||||
|
||||
await expect(
|
||||
runKtxScan(
|
||||
|
|
@ -406,7 +407,9 @@ describe('runKtxScan', () => {
|
|||
runtimeInstallPolicy: 'auto',
|
||||
},
|
||||
io.io,
|
||||
{ runLocalScan, createLocalIngestAdapters },
|
||||
{ runLocalScan, createLocalIngestAdapters, runtimeIo: runtimeIo.io } as KtxScanDeps & {
|
||||
runtimeIo: typeof runtimeIo.io;
|
||||
},
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
|
|
@ -415,7 +418,7 @@ describe('runKtxScan', () => {
|
|||
cliVersion: '0.2.0',
|
||||
projectDir: tempDir,
|
||||
installPolicy: 'auto',
|
||||
io: io.io,
|
||||
io: runtimeIo.io,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export interface KtxScanDeps {
|
|||
runLocalScan?: typeof runLocalScan;
|
||||
createLocalIngestAdapters?: typeof createKtxCliLocalIngestAdapters;
|
||||
progress?: KtxProgressPort;
|
||||
runtimeIo?: KtxCliIo;
|
||||
}
|
||||
|
||||
function shouldUseStyledOutput(io: KtxCliIo): boolean {
|
||||
|
|
@ -313,7 +314,7 @@ export function createCliScanProgress(
|
|||
export async function runKtxScan(args: KtxScanArgs, io: KtxCliIo = process, deps: KtxScanDeps = {}): Promise<number> {
|
||||
try {
|
||||
const project = await loadKtxProject({ projectDir: args.projectDir });
|
||||
const managedDaemon = managedDaemonOptionsForScanRun(args, io);
|
||||
const managedDaemon = managedDaemonOptionsForScanRun(args, deps.runtimeIo ?? io);
|
||||
const connector =
|
||||
args.mode !== 'structural' || args.detectRelationships
|
||||
? await createKtxCliScanConnector(project, args.connectionId)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
|||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { readKtxSetupState } from '@ktx/context/project';
|
||||
import { strFromU8, unzipSync } from 'fflate';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
formatInstallSummary,
|
||||
|
|
@ -24,6 +25,44 @@ function makeIo() {
|
|||
};
|
||||
}
|
||||
|
||||
async function readZipText(path: string, entry: string): Promise<string> {
|
||||
const archive = unzipSync(new Uint8Array(await readFile(path)));
|
||||
const content = archive[entry];
|
||||
if (!content) throw new Error(`Missing zip entry: ${entry}`);
|
||||
return strFromU8(content);
|
||||
}
|
||||
|
||||
function captureEnvKeys(env: NodeJS.ProcessEnv, keys: readonly string[]): Record<string, string | undefined> {
|
||||
const snapshot: Record<string, string | undefined> = {};
|
||||
for (const key of keys) snapshot[key] = env[key];
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function clearEnvKeys(env: NodeJS.ProcessEnv, keys: readonly string[]): void {
|
||||
for (const key of keys) delete env[key];
|
||||
}
|
||||
|
||||
function captureKtxEnv(env: NodeJS.ProcessEnv): Record<string, string | undefined> {
|
||||
const snapshot: Record<string, string | undefined> = {};
|
||||
for (const key of Object.keys(env)) {
|
||||
if (key.startsWith('KTX_')) snapshot[key] = env[key];
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function clearKtxEnv(env: NodeJS.ProcessEnv): void {
|
||||
for (const key of Object.keys(env)) {
|
||||
if (key.startsWith('KTX_')) delete env[key];
|
||||
}
|
||||
}
|
||||
|
||||
function restoreEnvKeys(env: NodeJS.ProcessEnv, snapshot: Record<string, string | undefined>): void {
|
||||
for (const [key, value] of Object.entries(snapshot)) {
|
||||
if (value === undefined) delete env[key];
|
||||
else env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
describe('setup agents', () => {
|
||||
let tempDir: string;
|
||||
|
||||
|
|
@ -37,28 +76,54 @@ describe('setup agents', () => {
|
|||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('plans project-scoped CLI and research files for every target', () => {
|
||||
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'claude-code', scope: 'project', mode: 'cli' })).toEqual([
|
||||
it('plans project-scoped MCP analytics files for every target', () => {
|
||||
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'claude-code', scope: 'project', mode: 'mcp' })).toEqual([
|
||||
{ kind: 'file', path: join(tempDir, '.claude/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' },
|
||||
]);
|
||||
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'claude-desktop', scope: 'global', mode: 'mcp' })).toEqual([
|
||||
{ kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh'), role: 'launcher' },
|
||||
{ kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin.zip'), role: 'claude-plugin' },
|
||||
]);
|
||||
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'codex', scope: 'project', mode: 'mcp' })).toEqual([
|
||||
{ kind: 'file', path: join(tempDir, '.agents/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' },
|
||||
]);
|
||||
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'cursor', scope: 'project', mode: 'mcp' })).toEqual([
|
||||
{ kind: 'file', path: join(tempDir, '.cursor/rules/ktx-analytics.mdc'), role: 'analytics-skill' },
|
||||
]);
|
||||
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'opencode', scope: 'project', mode: 'mcp' })).toEqual([
|
||||
{ kind: 'file', path: join(tempDir, '.opencode/commands/ktx-analytics.md'), role: 'analytics-skill' },
|
||||
]);
|
||||
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'universal', scope: 'project', mode: 'mcp' })).toEqual([
|
||||
{ kind: 'file', path: join(tempDir, '.agents/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('plans project-scoped admin CLI files for every target when requested', () => {
|
||||
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'claude-code', scope: 'project', mode: 'mcp-cli' })).toEqual([
|
||||
{ kind: 'file', path: join(tempDir, '.claude/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' },
|
||||
{ kind: 'file', path: join(tempDir, '.claude/skills/ktx/SKILL.md'), role: 'skill' },
|
||||
{ kind: 'file', path: join(tempDir, '.claude/skills/ktx-research/SKILL.md'), role: 'research-skill' },
|
||||
{ kind: 'file', path: join(tempDir, '.claude/rules/ktx.md'), role: 'rule' },
|
||||
]);
|
||||
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'codex', scope: 'project', mode: 'cli' })).toEqual([
|
||||
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'codex', scope: 'project', mode: 'mcp-cli' })).toEqual([
|
||||
{ kind: 'file', path: join(tempDir, '.agents/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' },
|
||||
{ kind: 'file', path: join(tempDir, '.agents/skills/ktx/SKILL.md'), role: 'skill' },
|
||||
{ kind: 'file', path: join(tempDir, '.agents/skills/ktx-research/SKILL.md'), role: 'research-skill' },
|
||||
{ kind: 'file', path: join(tempDir, '.codex/instructions/ktx.md'), role: 'rule' },
|
||||
]);
|
||||
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'cursor', scope: 'project', mode: 'cli' })).toEqual([
|
||||
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'cursor', scope: 'project', mode: 'mcp-cli' })).toEqual([
|
||||
{ kind: 'file', path: join(tempDir, '.cursor/rules/ktx-analytics.mdc'), role: 'analytics-skill' },
|
||||
{ kind: 'file', path: join(tempDir, '.cursor/rules/ktx.mdc') },
|
||||
{ kind: 'file', path: join(tempDir, '.cursor/rules/ktx-research.mdc'), role: 'research-skill' },
|
||||
]);
|
||||
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'opencode', scope: 'project', mode: 'cli' })).toEqual([
|
||||
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'opencode', scope: 'project', mode: 'mcp-cli' })).toEqual([
|
||||
{ kind: 'file', path: join(tempDir, '.opencode/commands/ktx-analytics.md'), role: 'analytics-skill' },
|
||||
{ kind: 'file', path: join(tempDir, '.opencode/commands/ktx.md') },
|
||||
{ kind: 'file', path: join(tempDir, '.opencode/commands/ktx-research.md'), role: 'research-skill' },
|
||||
]);
|
||||
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'universal', scope: 'project', mode: 'cli' })).toEqual([
|
||||
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'universal', scope: 'project', mode: 'mcp-cli' })).toEqual([
|
||||
{ kind: 'file', path: join(tempDir, '.agents/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' },
|
||||
{ kind: 'file', path: join(tempDir, '.agents/skills/ktx/SKILL.md') },
|
||||
{ kind: 'file', path: join(tempDir, '.agents/skills/ktx-research/SKILL.md'), role: 'research-skill' },
|
||||
]);
|
||||
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'claude-desktop', scope: 'global', mode: 'mcp-cli' })).toEqual([
|
||||
{ kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh'), role: 'launcher' },
|
||||
{ kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin.zip'), role: 'claude-plugin' },
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -74,7 +139,7 @@ describe('setup agents', () => {
|
|||
agents: true,
|
||||
target: 'universal',
|
||||
scope: 'project',
|
||||
mode: 'cli',
|
||||
mode: 'mcp-cli',
|
||||
skipAgents: false,
|
||||
},
|
||||
io.io,
|
||||
|
|
@ -82,7 +147,7 @@ describe('setup agents', () => {
|
|||
).resolves.toEqual({
|
||||
status: 'ready',
|
||||
projectDir: tempDir,
|
||||
installs: [{ target: 'universal', scope: 'project', mode: 'cli' }],
|
||||
installs: [{ target: 'universal', scope: 'project', mode: 'mcp-cli' }],
|
||||
});
|
||||
|
||||
await expect(stat(join(tempDir, '.agents/skills/ktx/SKILL.md'))).resolves.toBeDefined();
|
||||
|
|
@ -99,13 +164,13 @@ describe('setup agents', () => {
|
|||
expect(await readKtxAgentInstallManifest(tempDir)).toMatchObject({
|
||||
version: 1,
|
||||
projectDir: tempDir,
|
||||
installs: [{ target: 'universal', scope: 'project', mode: 'cli' }],
|
||||
installs: [{ target: 'universal', scope: 'project', mode: 'mcp-cli' }],
|
||||
});
|
||||
expect(await readKtxSetupState(tempDir)).toEqual({ completed_steps: ['agents'] });
|
||||
expect(io.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('installs the research skill from the runtime asset', async () => {
|
||||
it('installs the analytics skill from the runtime asset', async () => {
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
|
|
@ -117,17 +182,20 @@ describe('setup agents', () => {
|
|||
agents: true,
|
||||
target: 'universal',
|
||||
scope: 'project',
|
||||
mode: 'cli',
|
||||
mode: 'mcp-cli',
|
||||
skipAgents: false,
|
||||
},
|
||||
io.io,
|
||||
),
|
||||
).resolves.toMatchObject({ status: 'ready' });
|
||||
|
||||
const researchSkill = await readFile(join(tempDir, '.agents/skills/ktx-research/SKILL.md'), 'utf-8');
|
||||
expect(researchSkill).toContain('name: ktx-research');
|
||||
expect(researchSkill).toContain('Always run `discover_data` before writing SQL.');
|
||||
expect(researchSkill).toContain('Treat a `dictionary_search` miss as non-authoritative.');
|
||||
const analyticsSkill = await readFile(join(tempDir, '.agents/skills/ktx-analytics/SKILL.md'), 'utf-8');
|
||||
expect(analyticsSkill).toContain('name: ktx-analytics');
|
||||
expect(analyticsSkill).toContain('Always run `discover_data` before writing SQL.');
|
||||
expect(analyticsSkill).toContain('Treat a `dictionary_search` miss as non-authoritative.');
|
||||
expect(analyticsSkill).toContain('memory_ingest');
|
||||
expect(analyticsSkill).toContain('ARR is reported in cents');
|
||||
expect(analyticsSkill).not.toContain(`memory_${'capture'}`);
|
||||
});
|
||||
|
||||
it('writes PATH-independent launcher commands for skills', async () => {
|
||||
|
|
@ -142,7 +210,7 @@ describe('setup agents', () => {
|
|||
agents: true,
|
||||
target: 'universal',
|
||||
scope: 'project',
|
||||
mode: 'cli',
|
||||
mode: 'mcp-cli',
|
||||
skipAgents: false,
|
||||
},
|
||||
io.io,
|
||||
|
|
@ -170,7 +238,7 @@ describe('setup agents', () => {
|
|||
agents: true,
|
||||
target: 'claude-code',
|
||||
scope: 'project',
|
||||
mode: 'cli',
|
||||
mode: 'mcp-cli',
|
||||
skipAgents: false,
|
||||
},
|
||||
io.io,
|
||||
|
|
@ -187,6 +255,279 @@ describe('setup agents', () => {
|
|||
expect(io.stdout()).toContain('Run `ktx mcp start` to enable the configured KTX MCP server.');
|
||||
});
|
||||
|
||||
it('prompts for MCP-first client agent connection mode in interactive setup', async () => {
|
||||
const io = makeIo();
|
||||
const prompts = {
|
||||
select: vi.fn(async ({ message }: { message: string }) => (message.startsWith('Where') ? 'project' : 'mcp')),
|
||||
multiselect: vi.fn(async () => ['claude-code']),
|
||||
cancel: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(
|
||||
runKtxSetupAgentsStep(
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'auto',
|
||||
yes: false,
|
||||
agents: true,
|
||||
scope: 'project',
|
||||
mode: 'mcp',
|
||||
skipAgents: false,
|
||||
},
|
||||
io.io,
|
||||
{ prompts },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: 'ready',
|
||||
installs: [{ target: 'claude-code', scope: 'project', mode: 'mcp' }],
|
||||
});
|
||||
|
||||
expect(prompts.select).toHaveBeenCalledWith({
|
||||
message: 'How should client agents connect to this KTX project?',
|
||||
options: [
|
||||
{ value: 'mcp', label: 'MCP tools + analytics skill' },
|
||||
{ value: 'mcp-cli', label: 'MCP tools + analytics skill + admin CLI skill' },
|
||||
],
|
||||
});
|
||||
expect(prompts.multiselect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: expect.arrayContaining([{ value: 'claude-desktop', label: 'Claude Desktop' }]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('prompts for global scope when every selected target supports it', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-home-'));
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = home;
|
||||
try {
|
||||
const io = makeIo();
|
||||
const prompts = {
|
||||
select: vi.fn(async ({ message }: { message: string }) =>
|
||||
message.startsWith('Where should') ? 'global' : 'mcp',
|
||||
),
|
||||
multiselect: vi.fn(async () => ['claude-code']),
|
||||
cancel: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(
|
||||
runKtxSetupAgentsStep(
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'auto',
|
||||
yes: false,
|
||||
agents: true,
|
||||
scope: 'project',
|
||||
mode: 'mcp',
|
||||
skipAgents: false,
|
||||
},
|
||||
io.io,
|
||||
{ prompts },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: 'ready',
|
||||
installs: [{ target: 'claude-code', scope: 'global', mode: 'mcp' }],
|
||||
});
|
||||
|
||||
expect(prompts.select).toHaveBeenCalledWith({
|
||||
message: 'Where should KTX install supported agent config?',
|
||||
options: [
|
||||
{ value: 'project', label: 'Project' },
|
||||
{ value: 'global', label: 'Global' },
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
process.env.HOME = previousHome;
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('registers Claude Desktop MCP via claude_desktop_config.json and ships a skills-only plugin', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-home-'));
|
||||
const previousHome = process.env.HOME;
|
||||
const envSnapshot = captureEnvKeys(process.env, ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY']);
|
||||
const ktxEnvSnapshot = captureKtxEnv(process.env);
|
||||
process.env.HOME = home;
|
||||
clearEnvKeys(process.env, ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY']);
|
||||
clearKtxEnv(process.env);
|
||||
try {
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxSetupAgentsStep(
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
yes: true,
|
||||
agents: true,
|
||||
target: 'claude-desktop',
|
||||
scope: 'project',
|
||||
mode: 'mcp',
|
||||
skipAgents: false,
|
||||
},
|
||||
io.io,
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: 'ready',
|
||||
installs: [{ target: 'claude-desktop', scope: 'global', mode: 'mcp' }],
|
||||
});
|
||||
|
||||
const pluginPath = join(tempDir, '.ktx/agents/claude/ktx-plugin.zip');
|
||||
const launcherPath = join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh');
|
||||
await expect(stat(pluginPath)).resolves.toBeDefined();
|
||||
const launcherStat = await stat(launcherPath);
|
||||
expect(launcherStat.mode & 0o111).not.toBe(0);
|
||||
const launcher = await readFile(launcherPath, 'utf-8');
|
||||
expect(launcher).toContain('KTX_CLI_BIN=');
|
||||
expect(launcher).toContain('.nvm/versions/node');
|
||||
|
||||
const configPath = join(home, 'Library/Application Support/Claude/claude_desktop_config.json');
|
||||
const config = JSON.parse(await readFile(configPath, 'utf-8')) as {
|
||||
mcpServers: { ktx: { command: string; args: string[]; env?: Record<string, string> } };
|
||||
};
|
||||
expect(config.mcpServers.ktx).toEqual({
|
||||
command: launcherPath,
|
||||
args: ['--project-dir', tempDir, 'mcp', 'stdio'],
|
||||
});
|
||||
|
||||
expect(await readZipText(pluginPath, '.claude-plugin/plugin.json')).toContain('"name": "ktx"');
|
||||
await expect(readZipText(pluginPath, '.mcp.json')).rejects.toThrow('Missing zip entry');
|
||||
expect(await readZipText(pluginPath, 'skills/ktx-analytics/SKILL.md')).toContain('KTX Analytics Workflow');
|
||||
const setupMd = await readZipText(pluginPath, 'SETUP.md');
|
||||
expect(setupMd).not.toContain('ktx mcp start');
|
||||
expect(setupMd).toContain('claude_desktop_config.json');
|
||||
await expect(readZipText(pluginPath, 'skills/ktx/SKILL.md')).rejects.toThrow('Missing zip entry');
|
||||
|
||||
expect(io.stdout()).toContain('Claude plugin generated');
|
||||
expect(io.stdout()).toContain('.ktx/agents/claude/ktx-plugin.zip');
|
||||
expect(io.stdout()).toContain('KTX MCP server registered');
|
||||
expect(io.stdout()).toContain('claude_desktop_config.json');
|
||||
expect(io.stdout()).toContain('Restart Claude Desktop');
|
||||
expect(io.stdout()).not.toContain('Run `ktx mcp start`');
|
||||
} finally {
|
||||
process.env.HOME = previousHome;
|
||||
restoreEnvKeys(process.env, envSnapshot);
|
||||
restoreEnvKeys(process.env, ktxEnvSnapshot);
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('captures KTX_*, OPENAI_API_KEY, and ANTHROPIC_API_KEY into the Claude Desktop MCP env block', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-home-'));
|
||||
const previousHome = process.env.HOME;
|
||||
const envSnapshot = captureEnvKeys(process.env, [
|
||||
'OPENAI_API_KEY',
|
||||
'ANTHROPIC_API_KEY',
|
||||
'KTX_LOG_LEVEL',
|
||||
]);
|
||||
const ktxEnvSnapshot = captureKtxEnv(process.env);
|
||||
process.env.HOME = home;
|
||||
clearKtxEnv(process.env);
|
||||
process.env.OPENAI_API_KEY = 'sk-test-openai'; // pragma: allowlist secret
|
||||
process.env.ANTHROPIC_API_KEY = 'sk-ant-test'; // pragma: allowlist secret
|
||||
process.env.KTX_LOG_LEVEL = 'debug';
|
||||
try {
|
||||
const io = makeIo();
|
||||
await runKtxSetupAgentsStep(
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
yes: true,
|
||||
agents: true,
|
||||
target: 'claude-desktop',
|
||||
scope: 'project',
|
||||
mode: 'mcp',
|
||||
skipAgents: false,
|
||||
},
|
||||
io.io,
|
||||
);
|
||||
|
||||
const configPath = join(home, 'Library/Application Support/Claude/claude_desktop_config.json');
|
||||
const config = JSON.parse(await readFile(configPath, 'utf-8')) as {
|
||||
mcpServers: { ktx: { env?: Record<string, string> } };
|
||||
};
|
||||
expect(config.mcpServers.ktx.env).toEqual({
|
||||
OPENAI_API_KEY: 'sk-test-openai', // pragma: allowlist secret
|
||||
ANTHROPIC_API_KEY: 'sk-ant-test', // pragma: allowlist secret
|
||||
KTX_LOG_LEVEL: 'debug',
|
||||
});
|
||||
} finally {
|
||||
process.env.HOME = previousHome;
|
||||
restoreEnvKeys(process.env, envSnapshot);
|
||||
restoreEnvKeys(process.env, ktxEnvSnapshot);
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('includes the admin CLI skill in the Claude Desktop plugin zip when requested', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-home-'));
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = home;
|
||||
try {
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxSetupAgentsStep(
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
yes: true,
|
||||
agents: true,
|
||||
target: 'claude-desktop',
|
||||
scope: 'project',
|
||||
mode: 'mcp-cli',
|
||||
skipAgents: false,
|
||||
},
|
||||
io.io,
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: 'ready',
|
||||
installs: [{ target: 'claude-desktop', scope: 'global', mode: 'mcp-cli' }],
|
||||
});
|
||||
|
||||
const pluginPath = join(tempDir, '.ktx/agents/claude/ktx-plugin.zip');
|
||||
const adminSkill = await readZipText(pluginPath, 'skills/ktx/SKILL.md');
|
||||
expect(adminSkill).toContain(`--project-dir ${tempDir}`);
|
||||
expect(adminSkill).toContain('status --json');
|
||||
expect(await readZipText(pluginPath, 'SETUP.md')).toContain('admin CLI skill');
|
||||
await expect(readZipText(pluginPath, '.mcp.json')).rejects.toThrow('Missing zip entry');
|
||||
} finally {
|
||||
process.env.HOME = previousHome;
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('installs MCP client config and analytics skill without admin CLI files', async () => {
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxSetupAgentsStep(
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
yes: true,
|
||||
agents: true,
|
||||
target: 'claude-code',
|
||||
scope: 'project',
|
||||
mode: 'mcp',
|
||||
skipAgents: false,
|
||||
},
|
||||
io.io,
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: 'ready',
|
||||
installs: [{ target: 'claude-code', scope: 'project', mode: 'mcp' }],
|
||||
});
|
||||
|
||||
const mcpJson = JSON.parse(await readFile(join(tempDir, '.mcp.json'), 'utf-8')) as {
|
||||
mcpServers: { ktx: { type: string; url: string } };
|
||||
};
|
||||
expect(mcpJson.mcpServers.ktx).toEqual({ type: 'http', url: 'http://localhost:7878/mcp' });
|
||||
await expect(stat(join(tempDir, '.claude/skills/ktx-analytics/SKILL.md'))).resolves.toBeDefined();
|
||||
await expect(stat(join(tempDir, '.claude/skills/ktx/SKILL.md'))).rejects.toThrow();
|
||||
await expect(stat(join(tempDir, '.claude/rules/ktx.md'))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('writes Cursor project MCP config', async () => {
|
||||
const io = makeIo();
|
||||
|
||||
|
|
@ -198,7 +539,7 @@ describe('setup agents', () => {
|
|||
agents: true,
|
||||
target: 'cursor',
|
||||
scope: 'project',
|
||||
mode: 'cli',
|
||||
mode: 'mcp-cli',
|
||||
skipAgents: false,
|
||||
},
|
||||
io.io,
|
||||
|
|
@ -210,7 +551,7 @@ describe('setup agents', () => {
|
|||
expect(cursorJson.mcpServers.ktx).toEqual({ url: 'http://localhost:7878/mcp' });
|
||||
});
|
||||
|
||||
it('prints Codex and opencode snippets without mutating printed-only config files', async () => {
|
||||
it('prints Codex, opencode, and universal snippets without mutating printed-only config files', async () => {
|
||||
const codexIo = makeIo();
|
||||
await runKtxSetupAgentsStep(
|
||||
{
|
||||
|
|
@ -220,7 +561,7 @@ describe('setup agents', () => {
|
|||
agents: true,
|
||||
target: 'codex',
|
||||
scope: 'project',
|
||||
mode: 'cli',
|
||||
mode: 'mcp-cli',
|
||||
skipAgents: false,
|
||||
},
|
||||
codexIo.io,
|
||||
|
|
@ -237,7 +578,7 @@ describe('setup agents', () => {
|
|||
agents: true,
|
||||
target: 'opencode',
|
||||
scope: 'project',
|
||||
mode: 'cli',
|
||||
mode: 'mcp-cli',
|
||||
skipAgents: false,
|
||||
},
|
||||
opencodeIo.io,
|
||||
|
|
@ -245,6 +586,23 @@ describe('setup agents', () => {
|
|||
expect(opencodeIo.stdout()).toContain('"mcp"');
|
||||
expect(opencodeIo.stdout()).toContain('"type": "remote"');
|
||||
await expect(readFile(join(tempDir, 'opencode.json'), 'utf-8')).rejects.toThrow();
|
||||
|
||||
const universalIo = makeIo();
|
||||
await runKtxSetupAgentsStep(
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
yes: true,
|
||||
agents: true,
|
||||
target: 'universal',
|
||||
scope: 'project',
|
||||
mode: 'mcp',
|
||||
skipAgents: false,
|
||||
},
|
||||
universalIo.io,
|
||||
);
|
||||
expect(universalIo.stdout()).toContain('Universal MCP endpoint:');
|
||||
expect(universalIo.stdout()).toContain('http://localhost:7878/mcp');
|
||||
});
|
||||
|
||||
it('uses MCP daemon state for port and token metadata without rendering literal tokens', async () => {
|
||||
|
|
@ -280,7 +638,7 @@ describe('setup agents', () => {
|
|||
agents: true,
|
||||
target: 'claude-code',
|
||||
scope: 'project',
|
||||
mode: 'cli',
|
||||
mode: 'mcp-cli',
|
||||
skipAgents: false,
|
||||
},
|
||||
io.io,
|
||||
|
|
@ -314,7 +672,7 @@ describe('setup agents', () => {
|
|||
agents: true,
|
||||
target: 'claude-code',
|
||||
scope: 'local',
|
||||
mode: 'cli',
|
||||
mode: 'mcp-cli',
|
||||
skipAgents: false,
|
||||
},
|
||||
io.io,
|
||||
|
|
@ -340,7 +698,7 @@ describe('setup agents', () => {
|
|||
agents: true,
|
||||
target: 'claude-code',
|
||||
scope: 'project',
|
||||
mode: 'cli',
|
||||
mode: 'mcp-cli',
|
||||
skipAgents: false,
|
||||
},
|
||||
io.io,
|
||||
|
|
@ -355,6 +713,50 @@ describe('setup agents', () => {
|
|||
await expect(readKtxAgentInstallManifest(tempDir)).resolves.toEqual(null);
|
||||
});
|
||||
|
||||
it('removes generated Claude Desktop plugin from the manifest', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-home-'));
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = home;
|
||||
try {
|
||||
const io = makeIo();
|
||||
await runKtxSetupAgentsStep(
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
yes: true,
|
||||
agents: true,
|
||||
target: 'claude-desktop',
|
||||
scope: 'project',
|
||||
mode: 'mcp-cli',
|
||||
skipAgents: false,
|
||||
},
|
||||
io.io,
|
||||
);
|
||||
const pluginPath = join(tempDir, '.ktx/agents/claude/ktx-plugin.zip');
|
||||
const launcherPath = join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh');
|
||||
const configPath = join(home, 'Library/Application Support/Claude/claude_desktop_config.json');
|
||||
await expect(stat(pluginPath)).resolves.toBeDefined();
|
||||
await expect(stat(launcherPath)).resolves.toBeDefined();
|
||||
const beforeConfig = JSON.parse(await readFile(configPath, 'utf-8')) as {
|
||||
mcpServers: Record<string, unknown>;
|
||||
};
|
||||
expect(beforeConfig.mcpServers.ktx).toBeDefined();
|
||||
|
||||
await expect(removeKtxAgentInstall(tempDir, io.io)).resolves.toBe(0);
|
||||
|
||||
await expect(stat(pluginPath)).rejects.toThrow();
|
||||
await expect(stat(launcherPath)).rejects.toThrow();
|
||||
const afterConfig = JSON.parse(await readFile(configPath, 'utf-8')) as {
|
||||
mcpServers: Record<string, unknown>;
|
||||
};
|
||||
expect(afterConfig.mcpServers.ktx).toBeUndefined();
|
||||
await expect(readKtxAgentInstallManifest(tempDir)).resolves.toEqual(null);
|
||||
} finally {
|
||||
process.env.HOME = previousHome;
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('treats cancel as skip in interactive mode', async () => {
|
||||
const io = makeIo();
|
||||
const prompts = {
|
||||
|
|
@ -371,7 +773,7 @@ describe('setup agents', () => {
|
|||
yes: false,
|
||||
agents: true,
|
||||
scope: 'project',
|
||||
mode: 'cli',
|
||||
mode: 'mcp-cli',
|
||||
skipAgents: false,
|
||||
},
|
||||
io.io,
|
||||
|
|
@ -383,7 +785,7 @@ describe('setup agents', () => {
|
|||
it('explains how to select multiple agent targets in interactive mode', async () => {
|
||||
const io = makeIo();
|
||||
const prompts = {
|
||||
select: vi.fn(async () => 'cli'),
|
||||
select: vi.fn(async () => 'mcp-cli'),
|
||||
multiselect: vi.fn(async () => ['back']),
|
||||
cancel: vi.fn(),
|
||||
};
|
||||
|
|
@ -396,7 +798,7 @@ describe('setup agents', () => {
|
|||
yes: false,
|
||||
agents: true,
|
||||
scope: 'project',
|
||||
mode: 'cli',
|
||||
mode: 'mcp-cli',
|
||||
skipAgents: false,
|
||||
},
|
||||
io.io,
|
||||
|
|
@ -423,7 +825,7 @@ describe('setup agents', () => {
|
|||
agents: true,
|
||||
target: 'claude-code',
|
||||
scope: 'project',
|
||||
mode: 'cli',
|
||||
mode: 'mcp-cli',
|
||||
skipAgents: false,
|
||||
},
|
||||
io.io,
|
||||
|
|
@ -432,21 +834,28 @@ describe('setup agents', () => {
|
|||
const output = io.stdout();
|
||||
expect(output).toContain('Agent integration complete');
|
||||
expect(output).toContain('Claude Code');
|
||||
expect(output).toContain('+ Skill installed — teaches your agent which KTX commands to run');
|
||||
expect(output).toContain('+ Analytics skill installed — teaches your agent the KTX MCP analytics workflow');
|
||||
expect(output).toContain('.claude/skills/ktx-analytics/SKILL.md');
|
||||
expect(output).toContain('+ Skill installed — teaches admin agents which KTX CLI commands to run');
|
||||
expect(output).toContain('.claude/skills/ktx/SKILL.md');
|
||||
expect(output).toContain('+ Rule installed — tells your agent when to use KTX');
|
||||
expect(output).toContain('+ Rule installed — tells admin agents when to use KTX CLI');
|
||||
expect(output).toContain('.claude/rules/ktx.md');
|
||||
});
|
||||
|
||||
it('formats summary with relative paths for project scope', () => {
|
||||
const summary = formatInstallSummary(
|
||||
[{ target: 'cursor', scope: 'project', mode: 'cli' }],
|
||||
[{ kind: 'file', path: join(tempDir, '.cursor/rules/ktx.mdc') }],
|
||||
[{ target: 'cursor', scope: 'project', mode: 'mcp-cli' }],
|
||||
[
|
||||
{ kind: 'file', path: join(tempDir, '.cursor/rules/ktx-analytics.mdc'), role: 'analytics-skill' },
|
||||
{ kind: 'file', path: join(tempDir, '.cursor/rules/ktx.mdc') },
|
||||
],
|
||||
tempDir,
|
||||
);
|
||||
|
||||
expect(summary).toContain('Cursor');
|
||||
expect(summary).toContain('+ Rule installed — tells your agent when to use KTX');
|
||||
expect(summary).toContain('+ Analytics skill installed — teaches your agent the KTX MCP analytics workflow');
|
||||
expect(summary).toContain('.cursor/rules/ktx-analytics.mdc');
|
||||
expect(summary).toContain('+ Rule installed — tells admin agents when to use KTX CLI');
|
||||
expect(summary).toContain('.cursor/rules/ktx.mdc');
|
||||
expect(summary).not.toContain(tempDir);
|
||||
});
|
||||
|
|
@ -454,12 +863,14 @@ describe('setup agents', () => {
|
|||
it('formats summary with multiple agent targets', () => {
|
||||
const summary = formatInstallSummary(
|
||||
[
|
||||
{ target: 'claude-code', scope: 'project', mode: 'cli' },
|
||||
{ target: 'codex', scope: 'project', mode: 'cli' },
|
||||
{ target: 'claude-code', scope: 'project', mode: 'mcp-cli' },
|
||||
{ target: 'codex', scope: 'project', mode: 'mcp-cli' },
|
||||
],
|
||||
[
|
||||
{ kind: 'file', path: join(tempDir, '.claude/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' },
|
||||
{ kind: 'file', path: join(tempDir, '.claude/skills/ktx/SKILL.md'), role: 'skill' },
|
||||
{ kind: 'file', path: join(tempDir, '.claude/rules/ktx.md'), role: 'rule' },
|
||||
{ kind: 'file', path: join(tempDir, '.agents/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' },
|
||||
{ kind: 'file', path: join(tempDir, '.agents/skills/ktx/SKILL.md'), role: 'skill' },
|
||||
{ kind: 'file', path: join(tempDir, '.codex/instructions/ktx.md'), role: 'rule' },
|
||||
],
|
||||
|
|
@ -467,9 +878,11 @@ describe('setup agents', () => {
|
|||
);
|
||||
|
||||
expect(summary).toContain('Claude Code');
|
||||
expect(summary).toContain('+ Skill installed — teaches your agent which KTX commands to run');
|
||||
expect(summary).toContain('+ Rule installed — tells your agent when to use KTX');
|
||||
expect(summary).toContain('+ Analytics skill installed — teaches your agent the KTX MCP analytics workflow');
|
||||
expect(summary).toContain('+ Skill installed — teaches admin agents which KTX CLI commands to run');
|
||||
expect(summary).toContain('+ Rule installed — tells admin agents when to use KTX CLI');
|
||||
expect(summary).toContain('Codex');
|
||||
expect(summary).toContain('.agents/skills/ktx-analytics/SKILL.md');
|
||||
expect(summary).toContain('.agents/skills/ktx/SKILL.md');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
|
|
@ -7,17 +7,20 @@ import {
|
|||
markKtxSetupStateStepComplete,
|
||||
serializeKtxProjectConfig,
|
||||
} from '@ktx/context/project';
|
||||
import { strToU8, zipSync } from 'fflate';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import { bold, dim, green } from './io/symbols.js';
|
||||
import { withMultiselectNavigation } from './prompt-navigation.js';
|
||||
import {
|
||||
createKtxSetupPromptAdapter,
|
||||
createKtxSetupUiAdapter,
|
||||
type KtxSetupPromptOption,
|
||||
} from './setup-prompts.js';
|
||||
import { readKtxMcpDaemonStatus } from './managed-mcp-daemon.js';
|
||||
|
||||
export type KtxAgentTarget = 'claude-code' | 'codex' | 'cursor' | 'opencode' | 'universal';
|
||||
export type KtxAgentTarget = 'claude-code' | 'claude-desktop' | 'codex' | 'cursor' | 'opencode' | 'universal';
|
||||
export type KtxAgentScope = 'project' | 'global' | 'local';
|
||||
export type KtxAgentInstallMode = 'cli';
|
||||
export type KtxAgentInstallMode = 'mcp' | 'mcp-cli';
|
||||
|
||||
export interface KtxSetupAgentsArgs {
|
||||
projectDir: string;
|
||||
|
|
@ -47,7 +50,7 @@ export interface KtxAgentInstallManifest {
|
|||
installedAt: string;
|
||||
installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>;
|
||||
entries: Array<
|
||||
| { kind: 'file'; path: string; role?: 'skill' | 'rule' | 'research-skill' }
|
||||
| { kind: 'file'; path: string; role?: 'skill' | 'rule' | 'analytics-skill' | 'claude-plugin' | 'launcher' }
|
||||
| { kind: 'json-key'; path: string; jsonPath: string[] }
|
||||
>;
|
||||
}
|
||||
|
|
@ -169,6 +172,14 @@ function opencodeSnippet(endpoint: KtxMcpEndpointInfo): string {
|
|||
);
|
||||
}
|
||||
|
||||
function universalMcpSnippet(endpoint: KtxMcpEndpointInfo): string {
|
||||
return [
|
||||
'Universal MCP endpoint:',
|
||||
endpoint.url,
|
||||
...(endpoint.tokenAuth ? ['Header: Authorization: Bearer ${KTX_MCP_TOKEN}'] : []),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function claudeConfigPath(projectDir: string, scope: KtxAgentScope): { path: string; jsonPath: string[] } {
|
||||
const home = process.env.HOME ?? '';
|
||||
if (scope === 'global') {
|
||||
|
|
@ -188,16 +199,63 @@ function cursorConfigPath(projectDir: string, scope: KtxAgentScope): { path: str
|
|||
};
|
||||
}
|
||||
|
||||
function claudeDesktopConfigPath(): { path: string; jsonPath: string[] } {
|
||||
const home = process.env.HOME ?? '';
|
||||
const path =
|
||||
process.platform === 'win32'
|
||||
? join(process.env.APPDATA ?? join(home, 'AppData/Roaming'), 'Claude/claude_desktop_config.json')
|
||||
: join(home, 'Library/Application Support/Claude/claude_desktop_config.json');
|
||||
return { path, jsonPath: ['mcpServers', 'ktx'] };
|
||||
}
|
||||
|
||||
const CLAUDE_DESKTOP_FORWARDED_ENV_KEYS = ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY'] as const;
|
||||
|
||||
export function collectClaudeDesktopForwardedEnv(source: NodeJS.ProcessEnv): Record<string, string> {
|
||||
const captured: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (value === undefined || value === '') continue;
|
||||
if (key.startsWith('KTX_') || (CLAUDE_DESKTOP_FORWARDED_ENV_KEYS as readonly string[]).includes(key)) {
|
||||
captured[key] = value;
|
||||
}
|
||||
}
|
||||
return captured;
|
||||
}
|
||||
|
||||
function claudeDesktopMcpEntry(input: {
|
||||
launcherPath: string;
|
||||
projectDir: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Record<string, unknown> {
|
||||
const captured = collectClaudeDesktopForwardedEnv(input.env ?? process.env);
|
||||
return {
|
||||
command: input.launcherPath,
|
||||
args: ['--project-dir', input.projectDir, 'mcp', 'stdio'],
|
||||
...(Object.keys(captured).length > 0 ? { env: captured } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function installMcpClientConfig(input: {
|
||||
projectDir: string;
|
||||
target: KtxAgentTarget;
|
||||
scope: KtxAgentScope;
|
||||
}): Promise<KtxMcpClientInstallResult> {
|
||||
const endpoint = await resolveMcpEndpoint(input.projectDir);
|
||||
const entries: InstallEntry[] = [];
|
||||
const snippets: string[] = [];
|
||||
const notices: string[] = [];
|
||||
|
||||
if (input.target === 'claude-desktop') {
|
||||
const config = claudeDesktopConfigPath();
|
||||
const launcherPath = claudeDesktopLauncherPath(input.projectDir);
|
||||
await writeJsonKey(
|
||||
config.path,
|
||||
config.jsonPath,
|
||||
claudeDesktopMcpEntry({ launcherPath, projectDir: input.projectDir }),
|
||||
);
|
||||
entries.push({ kind: 'json-key', path: config.path, jsonPath: config.jsonPath });
|
||||
return { entries, snippets, notices };
|
||||
}
|
||||
|
||||
const endpoint = await resolveMcpEndpoint(input.projectDir);
|
||||
if (!endpoint.running) {
|
||||
notices.push('Run `ktx mcp start` to enable the configured KTX MCP server.');
|
||||
}
|
||||
|
|
@ -213,74 +271,141 @@ async function installMcpClientConfig(input: {
|
|||
} else if (input.target === 'codex') {
|
||||
snippets.push(`Codex MCP snippet for ~/.codex/config.toml:\n${codexSnippet(endpoint)}`);
|
||||
} else if (input.target === 'opencode') {
|
||||
const path = input.scope === 'global' ? '~/.config/opencode/opencode.json' : relative(input.projectDir, join(input.projectDir, 'opencode.json'));
|
||||
const path =
|
||||
input.scope === 'global'
|
||||
? '~/.config/opencode/opencode.json'
|
||||
: relative(input.projectDir, join(input.projectDir, 'opencode.json'));
|
||||
snippets.push(`opencode MCP snippet for ${path}:\n${opencodeSnippet(endpoint)}`);
|
||||
} else if (input.target === 'universal') {
|
||||
snippets.push(universalMcpSnippet(endpoint));
|
||||
}
|
||||
|
||||
return { entries, snippets, notices };
|
||||
}
|
||||
|
||||
function plannedMcpJsonEntries(input: {
|
||||
projectDir: string;
|
||||
target: KtxAgentTarget;
|
||||
scope: KtxAgentScope;
|
||||
}): InstallEntry[] {
|
||||
if (input.target === 'claude-code') {
|
||||
const config = claudeConfigPath(input.projectDir, input.scope);
|
||||
return [{ kind: 'json-key', path: config.path, jsonPath: config.jsonPath }];
|
||||
}
|
||||
if (input.target === 'claude-desktop') {
|
||||
const config = claudeDesktopConfigPath();
|
||||
return [{ kind: 'json-key', path: config.path, jsonPath: config.jsonPath }];
|
||||
}
|
||||
if (input.target === 'cursor') {
|
||||
const config = cursorConfigPath(input.projectDir, input.scope);
|
||||
return [{ kind: 'json-key', path: config.path, jsonPath: config.jsonPath }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function agentInstallManifestPath(projectDir: string): string {
|
||||
return join(resolve(projectDir), '.ktx/agents/install-manifest.json');
|
||||
}
|
||||
|
||||
function claudeDesktopPluginPath(projectDir: string): string {
|
||||
return join(resolve(projectDir), '.ktx/agents/claude/ktx-plugin.zip');
|
||||
}
|
||||
|
||||
function claudeDesktopLauncherPath(projectDir: string): string {
|
||||
return join(resolve(projectDir), '.ktx/agents/claude/ktx-plugin-runner.sh');
|
||||
}
|
||||
|
||||
export function plannedKtxAgentFiles(input: {
|
||||
projectDir: string;
|
||||
target: KtxAgentTarget;
|
||||
scope: KtxAgentScope;
|
||||
mode: KtxAgentInstallMode;
|
||||
}): InstallEntry[] {
|
||||
const withAdminCli = input.mode === 'mcp-cli';
|
||||
|
||||
if (input.scope === 'global') {
|
||||
if (input.target === 'claude-code') {
|
||||
const home = process.env.HOME ?? '';
|
||||
return [
|
||||
{ kind: 'file', path: join(home, '.claude/skills/ktx/SKILL.md'), role: 'skill' as const },
|
||||
{ kind: 'file', path: join(home, '.claude/skills/ktx-research/SKILL.md'), role: 'research-skill' as const },
|
||||
{ kind: 'file', path: join(home, '.claude/rules/ktx.md'), role: 'rule' as const },
|
||||
{ kind: 'file', path: join(home, '.claude/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' as const },
|
||||
...(withAdminCli
|
||||
? [
|
||||
{ kind: 'file' as const, path: join(home, '.claude/skills/ktx/SKILL.md'), role: 'skill' as const },
|
||||
{ kind: 'file' as const, path: join(home, '.claude/rules/ktx.md'), role: 'rule' as const },
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
if (input.target === 'codex') {
|
||||
const codexHome = process.env.CODEX_HOME ?? join(process.env.HOME ?? '', '.codex');
|
||||
return [
|
||||
{ kind: 'file', path: join(codexHome, 'skills/ktx/SKILL.md'), role: 'skill' as const },
|
||||
{ kind: 'file', path: join(codexHome, 'skills/ktx-research/SKILL.md'), role: 'research-skill' as const },
|
||||
{ kind: 'file', path: join(codexHome, 'instructions/ktx.md'), role: 'rule' as const },
|
||||
{ kind: 'file', path: join(codexHome, 'skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' as const },
|
||||
...(withAdminCli
|
||||
? [
|
||||
{ kind: 'file' as const, path: join(codexHome, 'skills/ktx/SKILL.md'), role: 'skill' as const },
|
||||
{ kind: 'file' as const, path: join(codexHome, 'instructions/ktx.md'), role: 'rule' as const },
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
if (input.target === 'cursor' || input.target === 'opencode') {
|
||||
return [];
|
||||
}
|
||||
if (input.target === 'claude-desktop') {
|
||||
return [
|
||||
{ kind: 'file', path: claudeDesktopLauncherPath(input.projectDir), role: 'launcher' as const },
|
||||
{ kind: 'file', path: claudeDesktopPluginPath(input.projectDir), role: 'claude-plugin' as const },
|
||||
];
|
||||
}
|
||||
throw new Error(`Global ${input.target} installation is not supported; omit --global.`);
|
||||
}
|
||||
|
||||
const root = resolve(input.projectDir);
|
||||
const analyticsEntries: Partial<Record<KtxAgentTarget, InstallEntry[]>> = {
|
||||
'claude-code': [
|
||||
{ kind: 'file', path: join(root, '.claude/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' },
|
||||
],
|
||||
codex: [
|
||||
{ kind: 'file', path: join(root, '.agents/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' },
|
||||
],
|
||||
cursor: [
|
||||
{ kind: 'file', path: join(root, '.cursor/rules/ktx-analytics.mdc'), role: 'analytics-skill' },
|
||||
],
|
||||
opencode: [
|
||||
{ kind: 'file', path: join(root, '.opencode/commands/ktx-analytics.md'), role: 'analytics-skill' },
|
||||
],
|
||||
universal: [
|
||||
{ kind: 'file', path: join(root, '.agents/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' },
|
||||
],
|
||||
'claude-desktop': [],
|
||||
};
|
||||
const cliEntries: Partial<Record<KtxAgentTarget, InstallEntry[]>> = {
|
||||
'claude-code': [
|
||||
{ kind: 'file', path: join(root, '.claude/skills/ktx/SKILL.md'), role: 'skill' },
|
||||
{ kind: 'file', path: join(root, '.claude/skills/ktx-research/SKILL.md'), role: 'research-skill' },
|
||||
],
|
||||
codex: [
|
||||
{ kind: 'file', path: join(root, '.agents/skills/ktx/SKILL.md'), role: 'skill' },
|
||||
{ kind: 'file', path: join(root, '.agents/skills/ktx-research/SKILL.md'), role: 'research-skill' },
|
||||
],
|
||||
cursor: [
|
||||
{ kind: 'file', path: join(root, '.cursor/rules/ktx.mdc') },
|
||||
{ kind: 'file', path: join(root, '.cursor/rules/ktx-research.mdc'), role: 'research-skill' },
|
||||
],
|
||||
opencode: [
|
||||
{ kind: 'file', path: join(root, '.opencode/commands/ktx.md') },
|
||||
{ kind: 'file', path: join(root, '.opencode/commands/ktx-research.md'), role: 'research-skill' },
|
||||
],
|
||||
universal: [
|
||||
{ kind: 'file', path: join(root, '.agents/skills/ktx/SKILL.md') },
|
||||
{ kind: 'file', path: join(root, '.agents/skills/ktx-research/SKILL.md'), role: 'research-skill' },
|
||||
],
|
||||
'claude-desktop': [],
|
||||
};
|
||||
const ruleEntries: Partial<Record<KtxAgentTarget, InstallEntry>> = {
|
||||
'claude-code': { kind: 'file', path: join(root, '.claude/rules/ktx.md'), role: 'rule' },
|
||||
codex: { kind: 'file', path: join(root, '.codex/instructions/ktx.md'), role: 'rule' },
|
||||
};
|
||||
return [...(cliEntries[input.target] ?? []), ruleEntries[input.target]].filter(
|
||||
return [
|
||||
...(analyticsEntries[input.target] ?? []),
|
||||
...(withAdminCli ? (cliEntries[input.target] ?? []) : []),
|
||||
...(withAdminCli ? [ruleEntries[input.target]] : []),
|
||||
].filter(
|
||||
(entry): entry is InstallEntry => entry !== undefined,
|
||||
);
|
||||
}
|
||||
|
|
@ -292,8 +417,8 @@ function ktxCliLauncher(): KtxCliLauncher {
|
|||
};
|
||||
}
|
||||
|
||||
async function readResearchSkillContent(): Promise<string> {
|
||||
const path = fileURLToPath(new URL('./skills/research/SKILL.md', import.meta.url));
|
||||
async function readAnalyticsSkillContent(): Promise<string> {
|
||||
const path = fileURLToPath(new URL('./skills/analytics/SKILL.md', import.meta.url));
|
||||
const content = await readFile(path, 'utf-8');
|
||||
return content.endsWith('\n') ? content : `${content}\n`;
|
||||
}
|
||||
|
|
@ -305,6 +430,10 @@ function shellQuote(value: string): string {
|
|||
return `'${value.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
function shellScriptQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
function ktxCommandLine(launcher: KtxCliLauncher, args: string[]): string {
|
||||
return [launcher.command, ...launcher.args, ...args].map(shellQuote).join(' ');
|
||||
}
|
||||
|
|
@ -320,11 +449,14 @@ function cliInstructionContent(input: { projectDir: string; launcher: KtxCliLaun
|
|||
'',
|
||||
'# KTX Local Context',
|
||||
'',
|
||||
'This is an admin/developer CLI helper. End-user data agents should use the KTX MCP tools when available.',
|
||||
'',
|
||||
`Use this project with \`--project-dir ${input.projectDir}\`.`,
|
||||
'Commands are pinned to the local KTX CLI path that created this file, so agents do not need `ktx` in PATH.',
|
||||
'If the CLI path no longer exists after moving this checkout or reinstalling KTX, rerun `ktx setup --agents`.',
|
||||
'',
|
||||
'Agents must not print secrets, credential references, environment variable values, or file contents from `.ktx/secrets`.',
|
||||
'Agents must not print secrets, credential references, environment variable values, or file contents from ' +
|
||||
'`.ktx/secrets`.',
|
||||
'',
|
||||
'Available commands:',
|
||||
'',
|
||||
|
|
@ -352,9 +484,132 @@ function cliInstructionContent(input: { projectDir: string; launcher: KtxCliLaun
|
|||
].join('\n');
|
||||
}
|
||||
|
||||
function claudePluginJsonContent(): string {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
name: 'ktx',
|
||||
version: '0.0.0-local',
|
||||
description: 'KTX analytics workflow guidance and local MCP tools.',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
function claudePluginVersionContent(): string {
|
||||
return `${JSON.stringify({ version: '0.0.0-local' }, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function claudePluginSetupContent(input: { projectDir: string; withAdminCli: boolean }): string {
|
||||
return [
|
||||
'# KTX Claude Plugin',
|
||||
'',
|
||||
'Install this plugin ZIP from Claude Desktop to load the KTX analytics skill.',
|
||||
'',
|
||||
`KTX project: \`${input.projectDir}\``,
|
||||
'',
|
||||
'Included:',
|
||||
'',
|
||||
'- `ktx-analytics` skill for the MCP analytics workflow',
|
||||
...(input.withAdminCli ? ['- `ktx` admin CLI skill for KTX maintenance commands'] : []),
|
||||
'',
|
||||
'The KTX MCP server is registered separately in `claude_desktop_config.json` by `ktx setup` and runs as a local stdio child of Claude Desktop — no daemon to start.',
|
||||
'',
|
||||
'If this checkout or project directory moves, rerun `ktx setup --agents` and reinstall the regenerated plugin.',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function claudePluginLauncherContent(input: { launcher: KtxCliLauncher }): string {
|
||||
const binPath = input.launcher.args[0];
|
||||
if (!binPath) {
|
||||
throw new Error('Expected KTX CLI launcher to include a bin path.');
|
||||
}
|
||||
const candidates = [
|
||||
input.launcher.command,
|
||||
'/opt/homebrew/bin/node',
|
||||
'/usr/local/bin/node',
|
||||
'/usr/bin/node',
|
||||
];
|
||||
return [
|
||||
'#!/bin/sh',
|
||||
'set -eu',
|
||||
'',
|
||||
`KTX_CLI_BIN=${shellScriptQuote(binPath)}`,
|
||||
'',
|
||||
'run_with_node() {',
|
||||
' node_bin=$1',
|
||||
' shift',
|
||||
' exec "$node_bin" "$KTX_CLI_BIN" "$@"',
|
||||
'}',
|
||||
'',
|
||||
'if [ -n "${KTX_NODE:-}" ] && [ -x "${KTX_NODE:-}" ]; then',
|
||||
' run_with_node "$KTX_NODE" "$@"',
|
||||
'fi',
|
||||
'',
|
||||
'if [ -x "$HOME/.volta/bin/node" ]; then',
|
||||
' run_with_node "$HOME/.volta/bin/node" "$@"',
|
||||
'fi',
|
||||
'',
|
||||
...candidates.map((candidate) =>
|
||||
[
|
||||
`if [ -x ${shellScriptQuote(candidate)} ]; then`,
|
||||
` run_with_node ${shellScriptQuote(candidate)} "$@"`,
|
||||
'fi',
|
||||
].join('\n'),
|
||||
),
|
||||
'',
|
||||
'for candidate in "$HOME"/.nvm/versions/node/*/bin/node; do',
|
||||
' if [ -x "$candidate" ]; then',
|
||||
' run_with_node "$candidate" "$@"',
|
||||
' fi',
|
||||
'done',
|
||||
'',
|
||||
'if command -v node >/dev/null 2>&1; then',
|
||||
' run_with_node "$(command -v node)" "$@"',
|
||||
'fi',
|
||||
'',
|
||||
'echo "KTX plugin could not find Node.js. Set KTX_NODE to a Node executable and reinstall the plugin." >&2',
|
||||
'exit 127',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function writeClaudeDesktopPlugin(input: {
|
||||
projectDir: string;
|
||||
path: string;
|
||||
mode: KtxAgentInstallMode;
|
||||
launcher: KtxCliLauncher;
|
||||
}): Promise<void> {
|
||||
const withAdminCli = input.mode === 'mcp-cli';
|
||||
const files: Record<string, Uint8Array> = {
|
||||
'.claude-plugin/plugin.json': strToU8(claudePluginJsonContent()),
|
||||
'version.json': strToU8(claudePluginVersionContent()),
|
||||
'skills/ktx-analytics/SKILL.md': strToU8(await readAnalyticsSkillContent()),
|
||||
'SETUP.md': strToU8(claudePluginSetupContent({ projectDir: input.projectDir, withAdminCli })),
|
||||
};
|
||||
if (withAdminCli) {
|
||||
files['skills/ktx/SKILL.md'] = strToU8(
|
||||
cliInstructionContent({ projectDir: input.projectDir, launcher: input.launcher }),
|
||||
);
|
||||
}
|
||||
await mkdir(dirname(input.path), { recursive: true });
|
||||
await writeFile(input.path, Buffer.from(zipSync(files)));
|
||||
}
|
||||
|
||||
async function writeClaudeDesktopLauncher(input: {
|
||||
path: string;
|
||||
launcher: KtxCliLauncher;
|
||||
}): Promise<void> {
|
||||
await mkdir(dirname(input.path), { recursive: true });
|
||||
await writeFile(input.path, claudePluginLauncherContent({ launcher: input.launcher }), 'utf-8');
|
||||
await chmod(input.path, 0o755);
|
||||
}
|
||||
|
||||
function ruleInstructionContent(input: { projectDir: string }): string {
|
||||
return [
|
||||
`Use the \`ktx\` CLI to query local semantic context and wiki knowledge for this project (\`--project-dir ${input.projectDir}\`).`,
|
||||
`Use the \`ktx\` CLI to query local semantic context and wiki knowledge for this project ` +
|
||||
`(\`--project-dir ${input.projectDir}\`).`,
|
||||
'',
|
||||
'Use when the user asks about data schemas, metrics, dimensions, database structure, or wants to run SQL queries.',
|
||||
'',
|
||||
|
|
@ -390,7 +645,9 @@ async function writeManifest(projectDir: string, manifest: KtxAgentInstallManife
|
|||
}
|
||||
|
||||
function entryKey(entry: InstallEntry): string {
|
||||
return entry.kind === 'json-key' ? `${entry.kind}:${entry.path}:${entry.jsonPath.join('.')}` : `${entry.kind}:${entry.path}`;
|
||||
return entry.kind === 'json-key'
|
||||
? `${entry.kind}:${entry.path}:${entry.jsonPath.join('.')}`
|
||||
: `${entry.kind}:${entry.path}`;
|
||||
}
|
||||
|
||||
function mergeManifest(
|
||||
|
|
@ -455,6 +712,7 @@ function createPromptAdapter(): KtxSetupAgentsPromptAdapter {
|
|||
|
||||
const targetDisplayNames: Record<KtxAgentTarget, string> = {
|
||||
'claude-code': 'Claude Code',
|
||||
'claude-desktop': 'Claude Desktop',
|
||||
codex: 'Codex',
|
||||
cursor: 'Cursor',
|
||||
opencode: 'OpenCode',
|
||||
|
|
@ -463,12 +721,25 @@ const targetDisplayNames: Record<KtxAgentTarget, string> = {
|
|||
|
||||
const fileEntryLabels: Record<KtxAgentTarget, string> = {
|
||||
'claude-code': 'Skill installed',
|
||||
'claude-desktop': 'Skill installed',
|
||||
codex: 'Skill installed',
|
||||
cursor: 'Rule installed',
|
||||
opencode: 'Command installed',
|
||||
universal: 'Skill installed',
|
||||
};
|
||||
|
||||
function mcpEntryLabel(entry: Extract<InstallEntry, { kind: 'json-key' }>): string {
|
||||
return `MCP config installed — connects client agents to KTX MCP tools (${entry.jsonPath.join('.')})`;
|
||||
}
|
||||
|
||||
function targetSupportsGlobalScope(target: KtxAgentTarget): boolean {
|
||||
return target === 'claude-code' || target === 'codex';
|
||||
}
|
||||
|
||||
function effectiveInstallScope(target: KtxAgentTarget, requestedScope: KtxAgentScope): KtxAgentScope {
|
||||
return target === 'claude-desktop' ? 'global' : requestedScope;
|
||||
}
|
||||
|
||||
export function formatInstallSummary(
|
||||
installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>,
|
||||
entries: InstallEntry[],
|
||||
|
|
@ -486,11 +757,21 @@ export function formatInstallSummary(
|
|||
entries.filter((entry) => entry.kind === 'file' && plannedFilePaths.has(entry.path)),
|
||||
);
|
||||
}
|
||||
const mcpEntriesByTarget = new Map<KtxAgentTarget, InstallEntry[]>();
|
||||
for (const install of installs) {
|
||||
const plannedMcpKeys = new Set(plannedMcpJsonEntries({ projectDir, ...install }).map(entryKey));
|
||||
mcpEntriesByTarget.set(
|
||||
install.target,
|
||||
entries.filter((entry) => entry.kind === 'json-key' && plannedMcpKeys.has(entryKey(entry))),
|
||||
);
|
||||
}
|
||||
|
||||
const fileHints: Record<string, string> = {
|
||||
skill: 'teaches your agent which KTX commands to run',
|
||||
rule: 'tells your agent when to use KTX',
|
||||
'research-skill': 'teaches your agent the KTX MCP research workflow',
|
||||
skill: 'teaches admin agents which KTX CLI commands to run',
|
||||
rule: 'tells admin agents when to use KTX CLI',
|
||||
'analytics-skill': 'teaches your agent the KTX MCP analytics workflow',
|
||||
'claude-plugin': 'bundles KTX skills for Claude Desktop (MCP server is registered in claude_desktop_config.json)',
|
||||
launcher: 'runs the local KTX CLI with an available Node.js for Claude Desktop',
|
||||
};
|
||||
|
||||
const lines: string[] = [];
|
||||
|
|
@ -498,16 +779,34 @@ export function formatInstallSummary(
|
|||
const targetEntries = entriesByTarget.get(install.target) ?? [];
|
||||
lines.push(` ${targetDisplayNames[install.target]}`);
|
||||
for (const entry of targetEntries) {
|
||||
const displayPath =
|
||||
install.scope === 'global' ? entry.path : relative(projectDir, entry.path);
|
||||
if (entry.kind === 'file') {
|
||||
const isRule = entry.role === 'rule' || fileEntryLabels[install.target] === 'Rule installed';
|
||||
const label = entry.role === 'research-skill' ? 'Research skill installed' : isRule ? 'Rule installed' : fileEntryLabels[install.target];
|
||||
const isRule = entry.role === 'rule' || (!entry.role && fileEntryLabels[install.target] === 'Rule installed');
|
||||
const label =
|
||||
entry.role === 'analytics-skill'
|
||||
? 'Analytics skill installed'
|
||||
: entry.role === 'claude-plugin'
|
||||
? 'Claude plugin generated'
|
||||
: entry.role === 'launcher'
|
||||
? 'Launcher installed'
|
||||
: isRule
|
||||
? 'Rule installed'
|
||||
: fileEntryLabels[install.target];
|
||||
const hint = fileHints[isRule ? 'rule' : (entry.role ?? 'skill')] ?? '';
|
||||
lines.push(` + ${label} — ${hint}`);
|
||||
lines.push(` ${displayPath}`);
|
||||
if (entry.role !== 'claude-plugin') {
|
||||
const displayPath =
|
||||
install.scope === 'global' ? entry.path : relative(projectDir, entry.path);
|
||||
lines.push(` ${displayPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const entry of mcpEntriesByTarget
|
||||
.get(install.target)
|
||||
?.filter((entry): entry is Extract<InstallEntry, { kind: 'json-key' }> => entry.kind === 'json-key') ?? []) {
|
||||
const displayPath = install.scope === 'global' ? entry.path : relative(projectDir, entry.path);
|
||||
lines.push(` + ${mcpEntryLabel(entry)}`);
|
||||
lines.push(` ${displayPath}`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
|
@ -522,11 +821,24 @@ async function installTarget(input: {
|
|||
const launcher = ktxCliLauncher();
|
||||
for (const entry of entries) {
|
||||
if (entry.kind !== 'file') continue;
|
||||
if (entry.role === 'launcher') {
|
||||
await writeClaudeDesktopLauncher({ path: entry.path, launcher });
|
||||
continue;
|
||||
}
|
||||
if (entry.role === 'claude-plugin') {
|
||||
await writeClaudeDesktopPlugin({
|
||||
projectDir: input.projectDir,
|
||||
path: entry.path,
|
||||
mode: input.mode,
|
||||
launcher,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const content =
|
||||
entry.role === 'rule'
|
||||
? ruleInstructionContent({ projectDir: input.projectDir })
|
||||
: entry.role === 'research-skill'
|
||||
? await readResearchSkillContent()
|
||||
: entry.role === 'analytics-skill'
|
||||
? await readAnalyticsSkillContent()
|
||||
: cliInstructionContent({ projectDir: input.projectDir, launcher });
|
||||
await mkdir(dirname(entry.path), { recursive: true });
|
||||
await writeFile(entry.path, content, 'utf-8');
|
||||
|
|
@ -558,14 +870,13 @@ export async function runKtxSetupAgentsStep(
|
|||
args.inputMode === 'disabled'
|
||||
? args.mode
|
||||
: ((await prompts.select({
|
||||
message: 'How should agents use this KTX project?',
|
||||
message: 'How should client agents connect to this KTX project?',
|
||||
options: [
|
||||
{ value: 'cli', label: 'CLI tools and skills' },
|
||||
{ value: 'skip', label: 'Skip' },
|
||||
{ value: 'mcp', label: 'MCP tools + analytics skill' },
|
||||
{ value: 'mcp-cli', label: 'MCP tools + analytics skill + admin CLI skill' },
|
||||
],
|
||||
})) as KtxAgentInstallMode | 'skip' | 'back');
|
||||
})) as KtxAgentInstallMode | 'back');
|
||||
if (mode === 'back') return { status: 'skipped', projectDir: args.projectDir };
|
||||
if (mode === 'skip') return { status: 'skipped', projectDir: args.projectDir };
|
||||
|
||||
const targets =
|
||||
args.target !== undefined
|
||||
|
|
@ -576,6 +887,7 @@ export async function runKtxSetupAgentsStep(
|
|||
message: withMultiselectNavigation('Which agent targets should KTX install?'),
|
||||
options: [
|
||||
{ value: 'claude-code', label: 'Claude Code' },
|
||||
{ value: 'claude-desktop', label: 'Claude Desktop' },
|
||||
{ value: 'codex', label: 'Codex' },
|
||||
{ value: 'cursor', label: 'Cursor' },
|
||||
{ value: 'opencode', label: 'OpenCode' },
|
||||
|
|
@ -589,26 +901,80 @@ export async function runKtxSetupAgentsStep(
|
|||
return { status: 'missing-input', projectDir: args.projectDir };
|
||||
}
|
||||
|
||||
const installs = targets.map((target) => ({ target, scope: args.scope, mode }));
|
||||
const scopeTargets = targets.filter((target) => target !== 'claude-desktop');
|
||||
const selectedScope =
|
||||
args.inputMode !== 'disabled' &&
|
||||
args.scope === 'project' &&
|
||||
scopeTargets.length > 0 &&
|
||||
scopeTargets.every(targetSupportsGlobalScope)
|
||||
? ((await prompts.select({
|
||||
message: 'Where should KTX install supported agent config?',
|
||||
options: [
|
||||
{ value: 'project', label: 'Project' },
|
||||
{ value: 'global', label: 'Global' },
|
||||
],
|
||||
})) as KtxAgentScope | 'back')
|
||||
: args.scope;
|
||||
if (selectedScope === 'back') return { status: 'back', projectDir: args.projectDir };
|
||||
|
||||
const installs = targets.map((target) => ({ target, scope: effectiveInstallScope(target, selectedScope), mode }));
|
||||
const entries: InstallEntry[] = [];
|
||||
const snippets: string[] = [];
|
||||
const notices = new Set<string>();
|
||||
let claudeDesktopTutorial: string | undefined;
|
||||
try {
|
||||
for (const install of installs) {
|
||||
entries.push(...(await installTarget({ projectDir: args.projectDir, ...install })));
|
||||
const mcpResult = await installMcpClientConfig({ projectDir: args.projectDir, target: install.target, scope: install.scope });
|
||||
const targetEntries = await installTarget({ projectDir: args.projectDir, ...install });
|
||||
entries.push(...targetEntries);
|
||||
const mcpResult = await installMcpClientConfig({
|
||||
projectDir: args.projectDir,
|
||||
target: install.target,
|
||||
scope: install.scope,
|
||||
});
|
||||
entries.push(...mcpResult.entries);
|
||||
for (const snippet of mcpResult.snippets) snippets.push(snippet);
|
||||
for (const notice of mcpResult.notices) notices.add(notice);
|
||||
if (install.target === 'claude-desktop') {
|
||||
const pluginEntry = targetEntries.find(
|
||||
(entry): entry is Extract<InstallEntry, { kind: 'file' }> =>
|
||||
entry.kind === 'file' && entry.role === 'claude-plugin',
|
||||
);
|
||||
const pluginPath = pluginEntry?.path ?? '';
|
||||
const configPath = claudeDesktopConfigPath().path;
|
||||
claudeDesktopTutorial = [
|
||||
`${green('✓')} ${bold('KTX MCP server registered')}`,
|
||||
` ${dim(configPath)}`,
|
||||
'',
|
||||
bold('1. Restart Claude Desktop'),
|
||||
' Quit and reopen so it picks up the new MCP server.',
|
||||
'',
|
||||
bold('2. Install the KTX plugin'),
|
||||
' Open Claude Desktop → Settings → Plugins and install from file:',
|
||||
` 📦 ${dim(pluginPath)}`,
|
||||
].join('\n');
|
||||
}
|
||||
}
|
||||
await writeManifest(args.projectDir, mergeManifest(args.projectDir, await readKtxAgentInstallManifest(args.projectDir), installs, entries));
|
||||
await writeManifest(
|
||||
args.projectDir,
|
||||
mergeManifest(args.projectDir, await readKtxAgentInstallManifest(args.projectDir), installs, entries),
|
||||
);
|
||||
await markAgentsComplete(args.projectDir);
|
||||
io.stdout.write(`\nAgent integration complete\n\n${formatInstallSummary(installs, entries, args.projectDir)}\n`);
|
||||
for (const snippet of snippets) {
|
||||
io.stdout.write(`\n${snippet}\n`);
|
||||
const setupUi = createKtxSetupUiAdapter();
|
||||
setupUi.note(
|
||||
formatInstallSummary(installs, entries, args.projectDir),
|
||||
'Agent integration complete',
|
||||
io,
|
||||
);
|
||||
if (claudeDesktopTutorial) {
|
||||
setupUi.note(claudeDesktopTutorial, 'Finish Claude Desktop setup', io, {
|
||||
format: (line) => line,
|
||||
});
|
||||
}
|
||||
for (const notice of notices) {
|
||||
io.stdout.write(`\n${notice}\n`);
|
||||
const nextStepBlocks: string[] = [];
|
||||
for (const notice of notices) nextStepBlocks.push(notice);
|
||||
for (const snippet of snippets) nextStepBlocks.push(snippet);
|
||||
if (nextStepBlocks.length > 0) {
|
||||
setupUi.note(nextStepBlocks.join('\n\n'), 'Next steps', io, { format: bold });
|
||||
}
|
||||
return { status: 'ready', projectDir: args.projectDir, installs };
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -184,7 +184,7 @@ describe('runDemoTour', () => {
|
|||
const mockAgents = vi.fn().mockResolvedValue({
|
||||
status: 'ready',
|
||||
projectDir: '/tmp/test',
|
||||
installs: [{ target: 'claude-code', scope: 'project', mode: 'cli' }],
|
||||
installs: [{ target: 'claude-code', scope: 'project', mode: 'mcp-cli' }],
|
||||
} satisfies KtxSetupAgentsResult);
|
||||
|
||||
const navigation = vi.fn().mockResolvedValue('forward');
|
||||
|
|
|
|||
|
|
@ -375,7 +375,7 @@ export async function runDemoTour(
|
|||
yes: false,
|
||||
agents: true,
|
||||
scope: 'project',
|
||||
mode: 'cli',
|
||||
mode: 'mcp-cli',
|
||||
skipAgents: false,
|
||||
},
|
||||
io,
|
||||
|
|
|
|||
|
|
@ -138,9 +138,13 @@ export function createKtxSetupPromptAdapter(options: KtxSetupPromptAdapterOption
|
|||
};
|
||||
}
|
||||
|
||||
interface KtxSetupNoteOptions {
|
||||
format?: (line: string) => string;
|
||||
}
|
||||
|
||||
export interface KtxSetupUiAdapter {
|
||||
intro(title: string, io: KtxCliIo): void;
|
||||
note(message: string, title: string, io: KtxCliIo): void;
|
||||
note(message: string, title: string, io: KtxCliIo, options?: KtxSetupNoteOptions): void;
|
||||
}
|
||||
|
||||
function isWritableTtyOutput(output: KtxCliIo['stdout']): output is KtxCliIo['stdout'] & Writable {
|
||||
|
|
@ -160,9 +164,12 @@ export function createKtxSetupUiAdapter(): KtxSetupUiAdapter {
|
|||
}
|
||||
io.stdout.write(`${title}\n`);
|
||||
},
|
||||
note(message, title, io) {
|
||||
note(message, title, io, options) {
|
||||
if (isWritableTtyOutput(io.stdout)) {
|
||||
note(message, title, { output: io.stdout });
|
||||
note(message, title, {
|
||||
output: io.stdout,
|
||||
...(options?.format ? { format: options.format } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
io.stdout.write(`\n${title}:\n`);
|
||||
|
|
|
|||
|
|
@ -232,7 +232,10 @@ describe('setup status', () => {
|
|||
version: 1,
|
||||
projectDir: tempDir,
|
||||
installedAt: '2026-05-07T00:00:00.000Z',
|
||||
installs: [{ target: 'codex', scope: 'project', mode: 'cli' }],
|
||||
installs: [
|
||||
{ target: 'codex', scope: 'project', mode: 'mcp' },
|
||||
{ target: 'codex', scope: 'project', mode: 'mcp-cli' },
|
||||
],
|
||||
entries: [],
|
||||
},
|
||||
null,
|
||||
|
|
@ -1051,6 +1054,53 @@ describe('setup status', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('auto-installs the managed runtime by default during setup', async () => {
|
||||
const io = makeIo();
|
||||
const embeddings = vi.fn(async () => ({ status: 'ready' as const, projectDir: tempDir }));
|
||||
const context = vi.fn(async () => ({ status: 'failed' as const, projectDir: tempDir }));
|
||||
|
||||
await expect(
|
||||
runKtxSetup(
|
||||
{
|
||||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
mode: 'new',
|
||||
agents: false,
|
||||
agentScope: 'project',
|
||||
skipAgents: true,
|
||||
inputMode: 'auto',
|
||||
yes: false,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: true,
|
||||
skipEmbeddings: false,
|
||||
databaseSchemas: [],
|
||||
skipDatabases: true,
|
||||
skipSources: true,
|
||||
},
|
||||
io.io,
|
||||
{
|
||||
embeddings,
|
||||
context,
|
||||
},
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(embeddings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
}),
|
||||
io.io,
|
||||
);
|
||||
expect(context).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
}),
|
||||
io.io,
|
||||
);
|
||||
});
|
||||
|
||||
it('lets Back from embedding setup return to the model step instead of exiting', async () => {
|
||||
const testIo = makeIo();
|
||||
const modelResults = [
|
||||
|
|
@ -1467,7 +1517,7 @@ describe('setup status', () => {
|
|||
return {
|
||||
status: 'ready',
|
||||
projectDir: tempDir,
|
||||
installs: [{ target: 'codex', scope: 'project', mode: 'cli' }],
|
||||
installs: [{ target: 'codex', scope: 'project', mode: 'mcp-cli' }],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
|
@ -1519,7 +1569,7 @@ describe('setup status', () => {
|
|||
agents: async () => ({
|
||||
status: 'ready',
|
||||
projectDir: tempDir,
|
||||
installs: [{ target: 'codex', scope: 'project', mode: 'cli' }],
|
||||
installs: [{ target: 'codex', scope: 'project', mode: 'mcp-cli' }],
|
||||
}),
|
||||
},
|
||||
),
|
||||
|
|
@ -1570,7 +1620,7 @@ describe('setup status', () => {
|
|||
return {
|
||||
status: 'ready',
|
||||
projectDir: tempDir,
|
||||
installs: [{ target: 'codex', scope: 'project', mode: 'cli' }],
|
||||
installs: [{ target: 'codex', scope: 'project', mode: 'mcp-cli' }],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
|
@ -1585,7 +1635,7 @@ describe('setup status', () => {
|
|||
const agents = vi.fn(async () => ({
|
||||
status: 'ready' as const,
|
||||
projectDir: tempDir,
|
||||
installs: [{ target: 'codex' as const, scope: 'project' as const, mode: 'cli' as const }],
|
||||
installs: [{ target: 'codex' as const, scope: 'project' as const, mode: 'mcp-cli' as const }],
|
||||
}));
|
||||
await writeFile(join(tempDir, 'ktx.yaml'), ['connections: {}', ''].join('\n'), 'utf-8');
|
||||
|
||||
|
|
@ -1654,7 +1704,7 @@ describe('setup status', () => {
|
|||
version: 1,
|
||||
projectDir: tempDir,
|
||||
installedAt: '2026-05-07T00:00:00.000Z',
|
||||
installs: [{ target: 'codex', scope: 'project', mode: 'cli' }],
|
||||
installs: [{ target: 'codex', scope: 'project', mode: 'mcp-cli' }],
|
||||
entries: [],
|
||||
},
|
||||
null,
|
||||
|
|
@ -1717,7 +1767,7 @@ describe('setup status', () => {
|
|||
return {
|
||||
status: 'ready',
|
||||
projectDir: tempDir,
|
||||
installs: [{ target: 'codex', scope: 'project', mode: 'cli' }],
|
||||
installs: [{ target: 'codex', scope: 'project', mode: 'mcp-cli' }],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
|
@ -1809,7 +1859,7 @@ describe('setup status', () => {
|
|||
return {
|
||||
status: 'ready',
|
||||
projectDir: tempDir,
|
||||
installs: [{ target: 'codex', scope: 'project', mode: 'cli' }],
|
||||
installs: [{ target: 'codex', scope: 'project', mode: 'mcp-cli' }],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
|
@ -1826,7 +1876,7 @@ describe('setup status', () => {
|
|||
const agents = vi.fn(async () => ({
|
||||
status: 'ready' as const,
|
||||
projectDir: tempDir,
|
||||
installs: [{ target: 'universal' as const, scope: 'project' as const, mode: 'cli' as const }],
|
||||
installs: [{ target: 'universal' as const, scope: 'project' as const, mode: 'mcp-cli' as const }],
|
||||
}));
|
||||
|
||||
await expect(
|
||||
|
|
|
|||
|
|
@ -307,12 +307,15 @@ export async function readKtxSetupStatus(projectDir: string): Promise<KtxSetupSt
|
|||
const databaseIds = project.config.setup?.database_connection_ids ?? Object.keys(project.config.connections);
|
||||
const databasesComplete = completedSteps.includes('databases');
|
||||
const manifest = await readKtxAgentInstallManifest(resolvedProjectDir);
|
||||
const agents =
|
||||
manifest?.installs.map((install) => ({
|
||||
const agentMap = new Map<string, { target: string; scope: string; ready: boolean }>();
|
||||
for (const install of manifest?.installs ?? []) {
|
||||
agentMap.set(`${install.target}:${install.scope}`, {
|
||||
target: install.target,
|
||||
scope: install.scope,
|
||||
ready: true,
|
||||
})) ?? [];
|
||||
});
|
||||
}
|
||||
const agents = [...agentMap.values()];
|
||||
|
||||
return {
|
||||
project: { path: resolvedProjectDir, ready: true, name: basename(project.projectDir) || project.projectDir },
|
||||
|
|
@ -413,10 +416,7 @@ function writeContextNotReadyForAgents(projectDir: string, io: KtxCliIo): void {
|
|||
}
|
||||
|
||||
function setupRuntimeInstallPolicy(args: Extract<KtxSetupArgs, { command: 'run' }>): 'prompt' | 'auto' | 'never' {
|
||||
if (args.yes) {
|
||||
return 'auto';
|
||||
}
|
||||
return args.inputMode === 'disabled' ? 'never' : 'prompt';
|
||||
return args.inputMode === 'disabled' && !args.yes ? 'never' : 'auto';
|
||||
}
|
||||
|
||||
async function commitSetupConfigChanges(projectDir: string): Promise<void> {
|
||||
|
|
@ -662,7 +662,7 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
|
|||
agents: true,
|
||||
...(args.target ? { target: args.target } : {}),
|
||||
scope: args.agentScope ?? 'project',
|
||||
mode: 'cli',
|
||||
mode: 'mcp',
|
||||
skipAgents: false,
|
||||
},
|
||||
io,
|
||||
|
|
@ -707,16 +707,21 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
|
|||
await commitSetupConfigChanges(projectResult.projectDir);
|
||||
|
||||
const status = await readKtxSetupStatus(projectResult.projectDir);
|
||||
io.stdout.write(formatKtxSetupStatus(status));
|
||||
setupUi.note(
|
||||
formatSetupNextStepLines({
|
||||
setupReady: setupStatusReady(status),
|
||||
hasContextTargets: setupHasContextTargets(status),
|
||||
contextReady: setupContextReady(status),
|
||||
agentIntegrationReady: status.agents.some((agent) => agent.ready),
|
||||
}).join('\n'),
|
||||
'What you can do next',
|
||||
io,
|
||||
);
|
||||
const focusedOnAgents = args.agents || entryAction === 'agents';
|
||||
if (!focusedOnAgents) {
|
||||
setupUi.note(formatKtxSetupStatus(status).trimEnd(), 'Project status', io, {
|
||||
format: (line) => line,
|
||||
});
|
||||
setupUi.note(
|
||||
formatSetupNextStepLines({
|
||||
setupReady: setupStatusReady(status),
|
||||
hasContextTargets: setupHasContextTargets(status),
|
||||
contextReady: setupContextReady(status),
|
||||
agentIntegrationReady: status.agents.some((agent) => agent.ready),
|
||||
}).join('\n'),
|
||||
'What you can do next',
|
||||
io,
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
62
packages/cli/src/skills/analytics/SKILL.md
Normal file
62
packages/cli/src/skills/analytics/SKILL.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
---
|
||||
name: ktx-analytics
|
||||
description: Use when answering a question that needs data from a KTX-connected database - investigating, analyzing, "how many", "show me", "what's the breakdown of", finding records by value, exploring tables, comparing periods, explaining metrics, or any data-analysis request. Triggers even when the user does not say "analytics"; if the answer requires querying a configured KTX connection, this skill applies.
|
||||
---
|
||||
|
||||
# KTX Analytics Workflow
|
||||
|
||||
You have access to KTX MCP tools for data discovery, semantic-layer analysis, raw read-only SQL, wiki context, and memory ingest. Follow this workflow.
|
||||
|
||||
<workflow>
|
||||
1. **Discover** - call `discover_data` first to see what exists across wiki pages, semantic-layer sources, metrics, dimensions, raw tables, and columns. Returns refs only.
|
||||
2. **Inspect top hits in parallel** - for each promising ref:
|
||||
- `kind: 'wiki'` -> `wiki_read`
|
||||
- `kind: 'sl_source'`, `kind: 'sl_measure'`, or `kind: 'sl_dimension'` -> `sl_read_source`
|
||||
- `kind: 'table'` or `kind: 'column'` -> `entity_details`
|
||||
3. **Resolve business values** - if the user named a value such as "Acme Corp", "enterprise", or "status=shipped", call `dictionary_search` to find which column holds it.
|
||||
4. **Plan the analysis** - identify the grain, metrics, dimensions, filters, time window, and expected row limits before querying.
|
||||
5. **Query** -
|
||||
- Prefer `sl_query` when the semantic layer covers the question.
|
||||
- Use `sql_execution` only for questions the semantic layer does not cover.
|
||||
6. **Validate and explain** - sanity-check totals, filters, null handling, and time zones. State the source tables or semantic-layer objects used.
|
||||
7. **Capture durable learnings** - call `memory_ingest` whenever a turn produces something worth remembering (business rules, metric definitions, schema gotchas, recurring findings) **or** whenever the user asks you to remember something. Pass markdown in `content` including any source context the memory agent should weigh. Each call is a feedback loop; better notes today mean smarter `discover_data` and `wiki_search` results tomorrow.
|
||||
</workflow>
|
||||
|
||||
<rules>
|
||||
- Always run `discover_data` before writing SQL. Do not guess table names.
|
||||
- Prefer the semantic layer over raw SQL when both can answer the question; measures are the source of truth.
|
||||
- Read entity details before writing SQL against an unfamiliar table. Do not assume column names.
|
||||
- Treat `sql_execution` as read-only. Writes are rejected by the server.
|
||||
- Validate value mentions with `dictionary_search` instead of guessing case or spelling. Treat a `dictionary_search` miss as non-authoritative. The index is built from profile-sampled values, so a missing value may simply have been outside the sample. Follow up with `sql_execution` against the most plausible columns before concluding the value is absent.
|
||||
- When `connection_list` shows multiple connections, pass an explicit `connectionId` to every tool that takes one and where user intent pins a specific warehouse. Required: `entity_details`, `sl_read_source`, and `sql_execution`. Required when user intent is warehouse-specific, including wording like "in our warehouse" or "this warehouse": `memory_ingest`; without `connectionId`, the memory agent cannot update the semantic layer and the knowledge lands as wiki-only. Pass `connectionId` when intent pins a warehouse, otherwise omit for unscoped discovery: `sl_query`, `discover_data`, and `dictionary_search`. Never pass `connectionId` to `connection_list`, `wiki_search`, `wiki_read`, or `memory_ingest_status`. If intent is ambiguous for a required-or-scoped tool, ask the user which warehouse before calling.
|
||||
- Show compact result tables for small outputs. For broad results, summarize the top findings and mention the applied limit.
|
||||
- Ask a concise clarification only when the metric, date range, entity, or grain is genuinely ambiguous and cannot be inferred from context.
|
||||
</rules>
|
||||
|
||||
<examples>
|
||||
**Input:** "How many orders did Acme Corp place last month?"
|
||||
|
||||
**Workflow:**
|
||||
1. `dictionary_search({ values: ["Acme Corp"] })` finds `customers.name`.
|
||||
2. `discover_data({ query: "orders customer monthly" })` finds an orders semantic-layer source.
|
||||
3. `sl_read_source({ connectionId: "warehouse", sourceName: "orders_facts" })` confirms the source grain, measures, and dimensions.
|
||||
4. `sl_query({ connectionId: "warehouse", measures: ["order_count"], filters: ["customer_name = 'Acme Corp'"] })` answers through the semantic layer.
|
||||
5. `memory_ingest({ connectionId: "warehouse", content: "Acme Corp order analysis used orders_facts.order_count filtered by customers.name = 'Acme Corp'. Source: current analysis turn." })` captures the durable finding.
|
||||
|
||||
---
|
||||
|
||||
**Input:** "What columns does the events table have?"
|
||||
|
||||
**Workflow:**
|
||||
1. `discover_data({ query: "events table" })` returns a `table` ref.
|
||||
2. `entity_details({ connectionId: "warehouse", entities: [{ table: "analytics.events" }] })` returns columns, types, and foreign keys.
|
||||
3. Answer directly. No query is needed.
|
||||
|
||||
---
|
||||
|
||||
**Input:** "Heads up: ARR is always reported in cents in our warehouse."
|
||||
|
||||
**Workflow:**
|
||||
1. If multiple connections exist, call `connection_list` and identify the warehouse the user means. Ask if ambiguous.
|
||||
2. `memory_ingest({ connectionId: "warehouse", content: "ARR is reported in cents (not dollars) in this warehouse. Multiply by 0.01 for dollar amounts. Source: user clarification." })` remembers the warehouse-specific rule without running an analysis turn.
|
||||
</examples>
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
---
|
||||
name: ktx-research
|
||||
description: Use when answering a question that needs data from a KTX-connected database - investigating, analyzing, "how many", "show me", "what's the breakdown of", finding records by value, exploring tables, comparing periods, or any data-investigation request. Triggers even when the user does not say "research"; if the answer requires querying a configured KTX connection, this skill applies.
|
||||
---
|
||||
|
||||
# KTX Research Workflow
|
||||
|
||||
You have access to KTX MCP tools for investigating data. Follow this workflow.
|
||||
|
||||
<workflow>
|
||||
1. **Discover** - call `discover_data` first to see what exists across wiki, semantic-layer sources, and raw tables. Returns refs only.
|
||||
2. **Inspect top hits in parallel** - for each promising ref:
|
||||
- `kind: 'wiki'` -> `wiki_read`
|
||||
- `kind: 'sl_source'`, `kind: 'sl_measure'`, or `kind: 'sl_dimension'` -> `sl_read_source`
|
||||
- `kind: 'table'` or `kind: 'column'` -> `entity_details`
|
||||
3. **Resolve literals** - if the user named a value such as "Acme Corp" or "status=shipped", call `dictionary_search` to find which column holds it.
|
||||
4. **Query** -
|
||||
- Prefer `sl_query` when the semantic layer covers the question.
|
||||
- Use `sql_execution` only for questions the semantic layer does not cover.
|
||||
5. **Capture learnings** - at the end of the turn, call `memory_capture` so future turns benefit. Skip when the answer carries no durable knowledge.
|
||||
</workflow>
|
||||
|
||||
<rules>
|
||||
- Always run `discover_data` before writing SQL. Do not guess table names.
|
||||
- Prefer the semantic layer over raw SQL when both can answer the question; measures are the source of truth.
|
||||
- Read entity details before writing SQL against an unfamiliar table. Do not assume column names.
|
||||
- Treat `sql_execution` as read-only. Writes are rejected by the server.
|
||||
- Validate value mentions with `dictionary_search` instead of guessing case or spelling. Treat a `dictionary_search` miss as non-authoritative. The index is built from profile-sampled values, so a missing value may simply have been outside the sample. Follow up with `sql_execution` against the most plausible columns before concluding the value is absent.
|
||||
</rules>
|
||||
|
||||
<examples>
|
||||
**Input:** "How many orders did Acme Corp place last month?"
|
||||
|
||||
**Workflow:**
|
||||
1. `dictionary_search({ values: ["Acme Corp"] })` finds `customers.name`.
|
||||
2. `discover_data({ query: "orders customer monthly" })` finds an orders semantic-layer source.
|
||||
3. `sl_read_source({ connectionId: "warehouse", sourceName: "orders_facts" })` confirms the source grain, measures, and dimensions.
|
||||
4. `sl_query({ connectionId: "warehouse", measures: ["order_count"], filters: ["customer_name = 'Acme Corp'"] })` answers through the semantic layer.
|
||||
5. `memory_capture({ userMessage, assistantMessage })` captures the durable finding.
|
||||
|
||||
---
|
||||
|
||||
**Input:** "What columns does the events table have?"
|
||||
|
||||
**Workflow:**
|
||||
1. `discover_data({ query: "events table" })` returns a `table` ref.
|
||||
2. `entity_details({ connectionId: "warehouse", entities: [{ table: "analytics.events" }] })` returns columns, types, and foreign keys.
|
||||
3. Answer directly. No query is needed.
|
||||
</examples>
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { MemoryCaptureStatus } from '@ktx/context/memory';
|
||||
import type { MemoryIngestStatus } from '@ktx/context/memory';
|
||||
import type { KtxLocalProject } from '@ktx/context/project';
|
||||
import { runKtxTextIngest, type TextMemoryCapturePort } from './text-ingest.js';
|
||||
import { runKtxTextIngest, type TextMemoryIngestPort } from './text-ingest.js';
|
||||
|
||||
function makeIo(options: { isTTY?: boolean } = {}) {
|
||||
let stdout = '';
|
||||
|
|
@ -25,18 +25,18 @@ function makeIo(options: { isTTY?: boolean } = {}) {
|
|||
};
|
||||
}
|
||||
|
||||
function fakeCapture(
|
||||
function fakeIngest(
|
||||
options: {
|
||||
failRunIds?: Set<string>;
|
||||
missingStatusRunIds?: Set<string>;
|
||||
events?: string[];
|
||||
} = {},
|
||||
): TextMemoryCapturePort {
|
||||
): TextMemoryIngestPort {
|
||||
let next = 1;
|
||||
return {
|
||||
capture: vi.fn(async () => {
|
||||
ingest: vi.fn(async () => {
|
||||
const runId = `run-${next++}`;
|
||||
options.events?.push(`capture:${runId}`);
|
||||
options.events?.push(`ingest:${runId}`);
|
||||
return { runId };
|
||||
}),
|
||||
waitForRun: vi.fn(async (runId: string) => {
|
||||
|
|
@ -51,26 +51,26 @@ function fakeCapture(
|
|||
return {
|
||||
runId,
|
||||
status: 'error',
|
||||
stage: 'capturing',
|
||||
stage: 'ingesting',
|
||||
done: true,
|
||||
captured: { wiki: [], sl: [], xrefs: [] },
|
||||
error: `${runId} failed`,
|
||||
commitHash: null,
|
||||
skillsLoaded: [],
|
||||
signalDetected: false,
|
||||
} satisfies MemoryCaptureStatus;
|
||||
} satisfies MemoryIngestStatus;
|
||||
}
|
||||
return {
|
||||
runId,
|
||||
status: 'done',
|
||||
stage: 'capturing',
|
||||
stage: 'ingesting',
|
||||
done: true,
|
||||
captured: { wiki: [`wiki-${runId}`], sl: [`sl-${runId}`], xrefs: [] },
|
||||
error: null,
|
||||
commitHash: `commit-${runId}`,
|
||||
skillsLoaded: ['wiki_capture', 'sl'],
|
||||
signalDetected: true,
|
||||
} satisfies MemoryCaptureStatus;
|
||||
} satisfies MemoryIngestStatus;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
@ -80,11 +80,11 @@ function fakeProject(projectDir = '/tmp/project'): KtxLocalProject {
|
|||
}
|
||||
|
||||
describe('runKtxTextIngest', () => {
|
||||
it('captures repeated inline text sequentially with generated internal chat ids', async () => {
|
||||
it('ingests repeated inline text sequentially with generated internal chat ids', async () => {
|
||||
const io = makeIo();
|
||||
const events: string[] = [];
|
||||
const capture = fakeCapture({ events });
|
||||
const createMemoryCapture = vi.fn(() => capture);
|
||||
const ingest = fakeIngest({ events });
|
||||
const createMemoryIngest = vi.fn(() => ingest);
|
||||
|
||||
await expect(
|
||||
runKtxTextIngest(
|
||||
|
|
@ -99,14 +99,14 @@ describe('runKtxTextIngest', () => {
|
|||
io.io,
|
||||
{
|
||||
loadProject: vi.fn(async () => fakeProject()),
|
||||
createMemoryCapture,
|
||||
createMemoryIngest,
|
||||
now: () => 1_700_000_000_000,
|
||||
},
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(createMemoryCapture).toHaveBeenCalledWith({ projectDir: '/tmp/project' });
|
||||
expect(capture.capture).toHaveBeenNthCalledWith(
|
||||
expect(createMemoryIngest).toHaveBeenCalledWith({ projectDir: '/tmp/project' });
|
||||
expect(ingest.ingest).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
userId: 'local-cli',
|
||||
|
|
@ -116,7 +116,7 @@ describe('runKtxTextIngest', () => {
|
|||
sourceType: 'external_ingest',
|
||||
}),
|
||||
);
|
||||
expect(capture.capture).toHaveBeenNthCalledWith(
|
||||
expect(ingest.ingest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
chatId: 'cli-text-ingest-1700000000000-2',
|
||||
|
|
@ -124,8 +124,8 @@ describe('runKtxTextIngest', () => {
|
|||
assistantMessage: 'Orders are completed purchases.',
|
||||
}),
|
||||
);
|
||||
expect(capture.capture).not.toHaveBeenCalledWith(expect.objectContaining({ connectionId: expect.anything() }));
|
||||
expect(events).toEqual(['capture:run-1', 'wait:run-1', 'status:run-1', 'capture:run-2', 'wait:run-2', 'status:run-2']);
|
||||
expect(ingest.ingest).not.toHaveBeenCalledWith(expect.objectContaining({ connectionId: expect.anything() }));
|
||||
expect(events).toEqual(['ingest:run-1', 'wait:run-1', 'status:run-1', 'ingest:run-2', 'wait:run-2', 'status:run-2']);
|
||||
expect(JSON.parse(io.stdout())).toMatchObject({
|
||||
status: 'done',
|
||||
results: [
|
||||
|
|
@ -147,7 +147,7 @@ describe('runKtxTextIngest', () => {
|
|||
|
||||
it('loads files and stdin as batch items and passes a global connection id', async () => {
|
||||
const io = makeIo();
|
||||
const capture = fakeCapture();
|
||||
const ingest = fakeIngest();
|
||||
|
||||
await expect(
|
||||
runKtxTextIngest(
|
||||
|
|
@ -163,7 +163,7 @@ describe('runKtxTextIngest', () => {
|
|||
io.io,
|
||||
{
|
||||
loadProject: vi.fn(async () => fakeProject()),
|
||||
createMemoryCapture: vi.fn(() => capture),
|
||||
createMemoryIngest: vi.fn(() => ingest),
|
||||
readFile: vi.fn(async (path) => `file:${path}`),
|
||||
readStdin: vi.fn(async () => 'stdin content'),
|
||||
now: () => 10,
|
||||
|
|
@ -171,7 +171,7 @@ describe('runKtxTextIngest', () => {
|
|||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(capture.capture).toHaveBeenNthCalledWith(
|
||||
expect(ingest.ingest).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
connectionId: 'warehouse',
|
||||
|
|
@ -180,7 +180,7 @@ describe('runKtxTextIngest', () => {
|
|||
assistantMessage: 'file:/tmp/docs/revenue.md',
|
||||
}),
|
||||
);
|
||||
expect(capture.capture).toHaveBeenNthCalledWith(
|
||||
expect(ingest.ingest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
connectionId: 'warehouse',
|
||||
|
|
@ -194,9 +194,9 @@ describe('runKtxTextIngest', () => {
|
|||
expect(io.stdout()).toContain('stdin');
|
||||
});
|
||||
|
||||
it('uses bounded inline text previews as labels in plain output and capture metadata', async () => {
|
||||
it('uses bounded inline text previews as labels in plain output and ingest metadata', async () => {
|
||||
const io = makeIo();
|
||||
const capture = fakeCapture();
|
||||
const ingest = fakeIngest();
|
||||
const longText = `This inline note is intentionally long ${'x'.repeat(120)}`;
|
||||
|
||||
await expect(
|
||||
|
|
@ -212,7 +212,7 @@ describe('runKtxTextIngest', () => {
|
|||
io.io,
|
||||
{
|
||||
loadProject: vi.fn(async () => fakeProject()),
|
||||
createMemoryCapture: vi.fn(() => capture),
|
||||
createMemoryIngest: vi.fn(() => ingest),
|
||||
now: () => 10,
|
||||
},
|
||||
),
|
||||
|
|
@ -225,19 +225,19 @@ describe('runKtxTextIngest', () => {
|
|||
expect(output).not.toContain('text-1');
|
||||
expect(output).not.toContain(longText);
|
||||
|
||||
expect(capture.capture).toHaveBeenNthCalledWith(
|
||||
expect(ingest.ingest).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
userMessage: 'Ingest external text artifact "remember to call me Andrey" into KTX memory.',
|
||||
}),
|
||||
);
|
||||
expect(capture.capture).toHaveBeenNthCalledWith(
|
||||
expect(ingest.ingest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
userMessage: 'Ingest external text artifact "first line second line" into KTX memory.',
|
||||
}),
|
||||
);
|
||||
expect(capture.capture).toHaveBeenNthCalledWith(
|
||||
expect(ingest.ingest).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
userMessage: 'Ingest external text artifact "This inline note is intentionally long xxxxxxxx..." into KTX memory.',
|
||||
|
|
@ -247,7 +247,7 @@ describe('runKtxTextIngest', () => {
|
|||
|
||||
it('continues after an item failure by default and stops when failFast is set', async () => {
|
||||
const continueIo = makeIo();
|
||||
const continueCapture = fakeCapture({ failRunIds: new Set(['run-1']) });
|
||||
const continueIngest = fakeIngest({ failRunIds: new Set(['run-1']) });
|
||||
|
||||
await expect(
|
||||
runKtxTextIngest(
|
||||
|
|
@ -262,12 +262,12 @@ describe('runKtxTextIngest', () => {
|
|||
continueIo.io,
|
||||
{
|
||||
loadProject: vi.fn(async () => fakeProject()),
|
||||
createMemoryCapture: vi.fn(() => continueCapture),
|
||||
createMemoryIngest: vi.fn(() => continueIngest),
|
||||
},
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(continueCapture.capture).toHaveBeenCalledTimes(2);
|
||||
expect(continueIngest.ingest).toHaveBeenCalledTimes(2);
|
||||
expect(JSON.parse(continueIo.stdout())).toMatchObject({
|
||||
status: 'failed',
|
||||
results: [
|
||||
|
|
@ -277,7 +277,7 @@ describe('runKtxTextIngest', () => {
|
|||
});
|
||||
|
||||
const failFastIo = makeIo();
|
||||
const failFastCapture = fakeCapture({ failRunIds: new Set(['run-1']) });
|
||||
const failFastIngest = fakeIngest({ failRunIds: new Set(['run-1']) });
|
||||
|
||||
await expect(
|
||||
runKtxTextIngest(
|
||||
|
|
@ -292,12 +292,12 @@ describe('runKtxTextIngest', () => {
|
|||
failFastIo.io,
|
||||
{
|
||||
loadProject: vi.fn(async () => fakeProject()),
|
||||
createMemoryCapture: vi.fn(() => failFastCapture),
|
||||
createMemoryIngest: vi.fn(() => failFastIngest),
|
||||
},
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(failFastCapture.capture).toHaveBeenCalledTimes(1);
|
||||
expect(failFastIngest.ingest).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(failFastIo.stdout()).results).toHaveLength(1);
|
||||
});
|
||||
|
||||
|
|
@ -314,7 +314,7 @@ describe('runKtxTextIngest', () => {
|
|||
failFast: false,
|
||||
},
|
||||
noInputIo.io,
|
||||
{ loadProject: vi.fn(), createMemoryCapture: vi.fn() },
|
||||
{ loadProject: vi.fn(), createMemoryIngest: vi.fn() },
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
expect(noInputIo.stderr()).toContain('Provide at least one text item');
|
||||
|
|
@ -331,7 +331,7 @@ describe('runKtxTextIngest', () => {
|
|||
failFast: false,
|
||||
},
|
||||
emptyIo.io,
|
||||
{ loadProject: vi.fn(), createMemoryCapture: vi.fn() },
|
||||
{ loadProject: vi.fn(), createMemoryIngest: vi.fn() },
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
expect(emptyIo.stderr()).toContain('Text item "text-1" is empty');
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { readFile as fsReadFile } from 'node:fs/promises';
|
||||
import { basename, resolve } from 'node:path';
|
||||
import { createLocalProjectMemoryCapture, type MemoryAgentInput, type MemoryCaptureStatus } from '@ktx/context/memory';
|
||||
import { createLocalProjectMemoryIngest, type MemoryAgentInput, type MemoryIngestStatus } from '@ktx/context/memory';
|
||||
import { loadKtxProject, type KtxLocalProject } from '@ktx/context/project';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import { createRepainter, initViewState, renderContextBuildView, type ContextBuildTargetState } from './context-build-view.js';
|
||||
|
|
@ -17,10 +17,10 @@ export interface KtxTextIngestArgs {
|
|||
failFast: boolean;
|
||||
}
|
||||
|
||||
export interface TextMemoryCapturePort {
|
||||
capture(input: MemoryAgentInput): Promise<{ runId: string }>;
|
||||
export interface TextMemoryIngestPort {
|
||||
ingest(input: MemoryAgentInput): Promise<{ runId: string }>;
|
||||
waitForRun(runId: string): Promise<void>;
|
||||
status(runId: string): Promise<MemoryCaptureStatus | null>;
|
||||
status(runId: string): Promise<MemoryIngestStatus | null>;
|
||||
}
|
||||
|
||||
interface TextIngestItem {
|
||||
|
|
@ -32,14 +32,14 @@ interface TextIngestResult {
|
|||
label: string;
|
||||
runId: string | null;
|
||||
status: 'done' | 'error';
|
||||
captured: MemoryCaptureStatus['captured'];
|
||||
captured: MemoryIngestStatus['captured'];
|
||||
commitHash: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface KtxTextIngestDeps {
|
||||
loadProject?: (options: { projectDir: string }) => Promise<KtxLocalProject>;
|
||||
createMemoryCapture?: (project: KtxLocalProject) => TextMemoryCapturePort;
|
||||
createMemoryIngest?: (project: KtxLocalProject) => TextMemoryIngestPort;
|
||||
readFile?: (path: string) => Promise<string>;
|
||||
readStdin?: () => Promise<string>;
|
||||
now?: () => number;
|
||||
|
|
@ -48,8 +48,8 @@ export interface KtxTextIngestDeps {
|
|||
const INLINE_TEXT_LABEL_MAX_LENGTH = 50;
|
||||
const ANSI_ESCAPE_PATTERN = /\x1B\[[0-?]*[ -/]*[@-~]/g;
|
||||
|
||||
function defaultCreateMemoryCapture(project: KtxLocalProject): TextMemoryCapturePort {
|
||||
return createLocalProjectMemoryCapture(project);
|
||||
function defaultCreateMemoryIngest(project: KtxLocalProject): TextMemoryIngestPort {
|
||||
return createLocalProjectMemoryIngest(project);
|
||||
}
|
||||
|
||||
async function defaultReadStdin(): Promise<string> {
|
||||
|
|
@ -65,7 +65,7 @@ async function defaultReadFile(path: string): Promise<string> {
|
|||
return await fsReadFile(path, 'utf-8');
|
||||
}
|
||||
|
||||
function emptyCaptured(): MemoryCaptureStatus['captured'] {
|
||||
function emptyCaptured(): MemoryIngestStatus['captured'] {
|
||||
return { wiki: [], sl: [], xrefs: [] };
|
||||
}
|
||||
|
||||
|
|
@ -182,7 +182,7 @@ function renderTextIngestView(state: ReturnType<typeof initViewState>, styled: b
|
|||
});
|
||||
}
|
||||
|
||||
function summarizeCaptured(captured: MemoryCaptureStatus['captured']): string {
|
||||
function summarizeCaptured(captured: MemoryIngestStatus['captured']): string {
|
||||
const parts = [
|
||||
`wiki=${captured.wiki.length}`,
|
||||
`sl=${captured.sl.length}`,
|
||||
|
|
@ -191,7 +191,7 @@ function summarizeCaptured(captured: MemoryCaptureStatus['captured']): string {
|
|||
return parts.join(', ');
|
||||
}
|
||||
|
||||
function resultFromStatus(label: string, status: MemoryCaptureStatus): TextIngestResult {
|
||||
function resultFromStatus(label: string, status: MemoryIngestStatus): TextIngestResult {
|
||||
return {
|
||||
label,
|
||||
runId: status.runId,
|
||||
|
|
@ -251,7 +251,7 @@ export async function runKtxTextIngest(
|
|||
}
|
||||
|
||||
const project = await (deps.loadProject ?? loadKtxProject)({ projectDir: args.projectDir });
|
||||
const memoryCapture = (deps.createMemoryCapture ?? defaultCreateMemoryCapture)(project);
|
||||
const memoryIngest = (deps.createMemoryIngest ?? defaultCreateMemoryIngest)(project);
|
||||
const now = deps.now ?? (() => Date.now());
|
||||
const batchId = now();
|
||||
const state = initViewState(items.map((item) => makeTarget(item.label)));
|
||||
|
|
@ -292,7 +292,7 @@ export async function runKtxTextIngest(
|
|||
let runId: string | null = null;
|
||||
let result: TextIngestResult;
|
||||
try {
|
||||
const captureInput: MemoryAgentInput = {
|
||||
const ingestInput: MemoryAgentInput = {
|
||||
userId: args.userId,
|
||||
chatId: `cli-text-ingest-${batchId}-${index + 1}`,
|
||||
userMessage: `Ingest external text artifact ${artifactReference(item.label)} into KTX memory.`,
|
||||
|
|
@ -300,12 +300,12 @@ export async function runKtxTextIngest(
|
|||
...(args.connectionId ? { connectionId: args.connectionId } : {}),
|
||||
sourceType: 'external_ingest',
|
||||
};
|
||||
const capture = await memoryCapture.capture(captureInput);
|
||||
runId = capture.runId;
|
||||
await memoryCapture.waitForRun(runId);
|
||||
const status = await memoryCapture.status(runId);
|
||||
const ingest = await memoryIngest.ingest(ingestInput);
|
||||
runId = ingest.runId;
|
||||
await memoryIngest.waitForRun(runId);
|
||||
const status = await memoryIngest.status(runId);
|
||||
if (!status) {
|
||||
throw new Error(`Memory capture run "${runId}" was not found.`);
|
||||
throw new Error(`Memory ingest run "${runId}" was not found.`);
|
||||
}
|
||||
result = resultFromStatus(item.label, status);
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { request as httpRequest } from 'node:http';
|
|||
import { request as httpsRequest } from 'node:https';
|
||||
import { URL } from 'node:url';
|
||||
import { spawn } from 'node:child_process';
|
||||
import type { SemanticLayerQueryInput, SemanticLayerSource } from '../sl/index.js';
|
||||
import type { ResolvedSemanticLayerSource, SemanticLayerQueryInput } from '../sl/types.js';
|
||||
|
||||
export interface KtxSemanticLayerComputeQueryResult {
|
||||
sql: string;
|
||||
|
|
@ -54,13 +54,21 @@ export interface KtxSemanticLayerSourceGenerationResult {
|
|||
}
|
||||
|
||||
export interface KtxSemanticLayerComputePort {
|
||||
/**
|
||||
* Callers must pass sources sanitized through toResolvedWire. The Python
|
||||
* daemon rejects authoring-only fields such as usage and inherits_columns_from.
|
||||
*/
|
||||
query(input: {
|
||||
sources: Array<Record<string, unknown> | SemanticLayerSource>;
|
||||
sources: ResolvedSemanticLayerSource[];
|
||||
query: SemanticLayerQueryInput;
|
||||
dialect: string;
|
||||
}): Promise<KtxSemanticLayerComputeQueryResult>;
|
||||
/**
|
||||
* Callers must pass sources sanitized through toResolvedWire. The Python
|
||||
* daemon rejects authoring-only fields such as usage and inherits_columns_from.
|
||||
*/
|
||||
validateSources(input: {
|
||||
sources: Array<Record<string, unknown> | SemanticLayerSource>;
|
||||
sources: ResolvedSemanticLayerSource[];
|
||||
dialect: string;
|
||||
recentlyTouched?: string[];
|
||||
}): Promise<KtxSemanticLayerComputeValidationResult>;
|
||||
|
|
|
|||
|
|
@ -191,6 +191,36 @@ describe('local KTX embedding config', () => {
|
|||
expect(resolveLocalKtxEmbeddingConfig(config, {})).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when backend is openai but no apiKey is resolvable from env', () => {
|
||||
const config: KtxProjectEmbeddingConfig = {
|
||||
backend: 'openai',
|
||||
model: 'text-embedding-3-small',
|
||||
dimensions: 1536,
|
||||
openai: { api_key: 'env:OPENAI_API_KEY' }, // pragma: allowlist secret
|
||||
};
|
||||
|
||||
expect(resolveLocalKtxEmbeddingConfig(config, {})).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves openai embedding config from env', () => {
|
||||
const config: KtxProjectEmbeddingConfig = {
|
||||
backend: 'openai',
|
||||
model: 'text-embedding-3-small',
|
||||
dimensions: 1536,
|
||||
openai: { api_key: 'env:OPENAI_API_KEY' }, // pragma: allowlist secret
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveLocalKtxEmbeddingConfig(config, { OPENAI_API_KEY: 'sk-test' }), // pragma: allowlist secret
|
||||
).toEqual({
|
||||
backend: 'openai',
|
||||
model: 'text-embedding-3-small',
|
||||
dimensions: 1536,
|
||||
openai: { apiKey: 'sk-test' }, // pragma: allowlist secret
|
||||
batchSize: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('constructs deterministic embeddings from the default project config', () => {
|
||||
const createKtxEmbeddingProvider = vi.fn(() => ({}) as never);
|
||||
const provider = createLocalKtxEmbeddingProviderFromConfig(
|
||||
|
|
|
|||
|
|
@ -177,11 +177,23 @@ export function resolveLocalKtxEmbeddingConfig(
|
|||
batchSize: config.batchSize,
|
||||
};
|
||||
}
|
||||
if (config.backend === 'openai') {
|
||||
const openai = resolvedProviderConfig(config.openai, env);
|
||||
if (!openai?.apiKey) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
backend: config.backend,
|
||||
model: config.model ?? 'deterministic',
|
||||
dimensions: config.dimensions,
|
||||
openai,
|
||||
batchSize: config.batchSize,
|
||||
};
|
||||
}
|
||||
return {
|
||||
backend: config.backend,
|
||||
model: config.model ?? 'deterministic',
|
||||
dimensions: config.dimensions,
|
||||
...(resolvedProviderConfig(config.openai, env) ? { openai: resolvedProviderConfig(config.openai, env) } : {}),
|
||||
...(config.sentenceTransformers
|
||||
? {
|
||||
sentenceTransformers: {
|
||||
|
|
|
|||
1615
packages/context/src/mcp/__snapshots__/mcp-tools-list.json
Normal file
1615
packages/context/src/mcp/__snapshots__/mcp-tools-list.json
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -8,29 +8,18 @@ export type {
|
|||
KtxDiscoverDataMcpPort,
|
||||
KtxDictionarySearchMcpPort,
|
||||
KtxEntityDetailsMcpPort,
|
||||
KtxIngestDiffSummary,
|
||||
KtxIngestMcpPort,
|
||||
KtxIngestStatusResponse,
|
||||
KtxIngestTriggerKind,
|
||||
KtxIngestTriggerResponse,
|
||||
KtxIngestWorkUnitSummary,
|
||||
KtxKnowledgeMcpPort,
|
||||
KtxKnowledgePage,
|
||||
KtxKnowledgeSearchResponse,
|
||||
KtxKnowledgeSearchResult,
|
||||
KtxKnowledgeWriteResponse,
|
||||
KtxMcpContextPorts,
|
||||
KtxMcpServerDeps,
|
||||
KtxMcpServerLike,
|
||||
KtxMcpTextContent,
|
||||
KtxMcpToolResult,
|
||||
KtxMcpUserContext,
|
||||
KtxSemanticLayerListResponse,
|
||||
KtxSemanticLayerMcpPort,
|
||||
KtxSemanticLayerQueryResponse,
|
||||
KtxSemanticLayerReadResponse,
|
||||
KtxSemanticLayerSourceSummary,
|
||||
KtxSemanticLayerValidationResponse,
|
||||
KtxSemanticLayerWriteResponse,
|
||||
MemoryCapturePort,
|
||||
MemoryIngestPort,
|
||||
} from './types.js';
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,65 +1,19 @@
|
|||
import YAML from 'yaml';
|
||||
import {
|
||||
type KtxSqlQueryExecutorPort,
|
||||
localConnectionInfoFromConfig,
|
||||
localConnectionTypeForConfig,
|
||||
} from '../connections/index.js';
|
||||
import { type KtxSqlQueryExecutorPort, localConnectionInfoFromConfig } from '../connections/index.js';
|
||||
import type { KtxEmbeddingPort } from '../core/index.js';
|
||||
import type { KtxSemanticLayerComputePort } from '../daemon/index.js';
|
||||
import {
|
||||
createDefaultLocalIngestAdapters,
|
||||
getLocalIngestStatus,
|
||||
type IngestReportSnapshot,
|
||||
ingestReportToMemoryFlowReplay,
|
||||
type LocalIngestMcpOptions,
|
||||
runLocalIngest,
|
||||
runLocalMetabaseIngest,
|
||||
} from '../ingest/index.js';
|
||||
import { createLocalKtxEmbeddingProviderFromConfig, KtxIngestEmbeddingPortAdapter } from '../llm/index.js';
|
||||
import type { KtxLocalProject } from '../project/index.js';
|
||||
import {
|
||||
createKtxEntityDetailsService,
|
||||
getLocalScanReport,
|
||||
getLocalScanStatus,
|
||||
type KtxConnectionDriver,
|
||||
type KtxScanConnector,
|
||||
type KtxScanReport,
|
||||
type LocalScanMcpOptions,
|
||||
runLocalScan,
|
||||
} from '../scan/index.js';
|
||||
import { createKtxEntityDetailsService, type KtxScanConnector, type LocalScanMcpOptions } from '../scan/index.js';
|
||||
import { createKtxDiscoverDataService } from '../search/index.js';
|
||||
import type { SqlAnalysisDialect, SqlAnalysisPort } from '../sql-analysis/index.js';
|
||||
import {
|
||||
compileLocalSlQuery,
|
||||
createKtxDictionarySearchService,
|
||||
type LocalSlSourceSearchResult,
|
||||
type LocalSlSourceSummary,
|
||||
listLocalSlSources,
|
||||
searchLocalSlSources,
|
||||
sourceDefinitionSchema,
|
||||
sourceOverlaySchema,
|
||||
} from '../sl/index.js';
|
||||
import { readLocalKnowledgePage, searchLocalKnowledgePages, writeLocalKnowledgePage } from '../wiki/local-knowledge.js';
|
||||
import type {
|
||||
KtxConnectionTestResponse,
|
||||
KtxIngestStatusResponse,
|
||||
KtxMcpContextPorts,
|
||||
KtxScanArtifactListResponse,
|
||||
KtxScanArtifactReadResponse,
|
||||
KtxScanArtifactSummary,
|
||||
KtxScanArtifactType,
|
||||
KtxSqlExecutionResponse,
|
||||
} from './types.js';
|
||||
|
||||
const LOCAL_AUTHOR = 'ktx';
|
||||
const LOCAL_AUTHOR_EMAIL = 'ktx@example.com';
|
||||
const SL_SHAPE_WARNING = 'Local stdio validation checks YAML shape only; Python semantic validation is not configured.';
|
||||
import { compileLocalSlQuery, createKtxDictionarySearchService } from '../sl/index.js';
|
||||
import { readLocalKnowledgePage, searchLocalKnowledgePages } from '../wiki/local-knowledge.js';
|
||||
import type { KtxMcpContextPorts, KtxMcpProgressCallback, KtxSqlExecutionResponse } from './types.js';
|
||||
|
||||
interface CreateLocalProjectMcpContextPortsOptions {
|
||||
semanticLayerCompute?: KtxSemanticLayerComputePort;
|
||||
queryExecutor?: KtxSqlQueryExecutorPort;
|
||||
sqlAnalysis?: SqlAnalysisPort;
|
||||
localIngest?: LocalIngestMcpOptions;
|
||||
localScan?: LocalScanMcpOptions;
|
||||
embeddingService?: KtxEmbeddingPort | null;
|
||||
}
|
||||
|
|
@ -115,284 +69,23 @@ function assertSafeSourceName(sourceName: string): string {
|
|||
return assertSafePathToken('semantic-layer source name', sourceName);
|
||||
}
|
||||
|
||||
function normalizeScanDriver(driver: string | undefined): KtxConnectionDriver {
|
||||
const normalized = (driver ?? '').toLowerCase();
|
||||
if (
|
||||
normalized === 'postgres' ||
|
||||
normalized === 'postgresql' ||
|
||||
normalized === 'sqlite' ||
|
||||
normalized === 'sqlite3' ||
|
||||
normalized === 'mysql' ||
|
||||
normalized === 'clickhouse' ||
|
||||
normalized === 'sqlserver' ||
|
||||
normalized === 'bigquery' ||
|
||||
normalized === 'snowflake'
|
||||
) {
|
||||
return normalized === 'sqlite3' ? 'sqlite' : normalized;
|
||||
}
|
||||
return 'postgres';
|
||||
}
|
||||
|
||||
async function cleanupConnector(connector: KtxScanConnector | null): Promise<void> {
|
||||
if (connector?.cleanup) {
|
||||
await connector.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function testLocalConnection(
|
||||
project: KtxLocalProject,
|
||||
options: CreateLocalProjectMcpContextPortsOptions,
|
||||
connectionId: string,
|
||||
): Promise<KtxConnectionTestResponse | null> {
|
||||
const safeConnectionId = assertSafeConnectionId(connectionId);
|
||||
const connection = project.config.connections[safeConnectionId];
|
||||
if (!connection) {
|
||||
return null;
|
||||
}
|
||||
const connectionType = localConnectionTypeForConfig(safeConnectionId, connection);
|
||||
const createConnector = options.localScan?.createConnector;
|
||||
if (!createConnector) {
|
||||
return {
|
||||
id: safeConnectionId,
|
||||
connectionType,
|
||||
ok: true,
|
||||
tableCount: null,
|
||||
message: 'Connection is configured; no native scan connector is available for live testing.',
|
||||
warnings: ['ktx serve was not configured with a local scan connector factory.'],
|
||||
};
|
||||
}
|
||||
|
||||
let connector: KtxScanConnector | null = null;
|
||||
try {
|
||||
connector = await createConnector(safeConnectionId);
|
||||
const snapshot = await connector.introspect(
|
||||
{
|
||||
connectionId: safeConnectionId,
|
||||
driver: normalizeScanDriver(connection.driver),
|
||||
mode: 'structural',
|
||||
dryRun: true,
|
||||
detectRelationships: false,
|
||||
},
|
||||
{ runId: `connection-test-${safeConnectionId}` },
|
||||
);
|
||||
return {
|
||||
id: safeConnectionId,
|
||||
connectionType,
|
||||
ok: true,
|
||||
tableCount: snapshot.tables.length,
|
||||
message: 'Connection test passed.',
|
||||
warnings: [],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
id: safeConnectionId,
|
||||
connectionType,
|
||||
ok: false,
|
||||
tableCount: null,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
warnings: [],
|
||||
};
|
||||
} finally {
|
||||
await cleanupConnector(connector);
|
||||
}
|
||||
}
|
||||
|
||||
function scanArtifactType(path: string, report: KtxScanReport): KtxScanArtifactType {
|
||||
if (path === report.artifactPaths.reportPath) {
|
||||
return 'report';
|
||||
}
|
||||
if (report.artifactPaths.manifestShards.includes(path)) {
|
||||
return 'manifest_shard';
|
||||
}
|
||||
if (report.artifactPaths.enrichmentArtifacts.includes(path)) {
|
||||
return 'enrichment_artifact';
|
||||
}
|
||||
return 'raw_source';
|
||||
}
|
||||
|
||||
async function artifactSize(project: KtxLocalProject, path: string): Promise<number | undefined> {
|
||||
try {
|
||||
const result = await project.fileStore.readFile(path);
|
||||
return typeof result.size === 'number' ? result.size : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function listArtifactsForReport(
|
||||
project: KtxLocalProject,
|
||||
runId: string,
|
||||
report: KtxScanReport,
|
||||
): Promise<KtxScanArtifactListResponse> {
|
||||
const paths = new Set<string>();
|
||||
if (report.artifactPaths.rawSourcesDir) {
|
||||
const listed = await project.fileStore.listFiles(report.artifactPaths.rawSourcesDir);
|
||||
for (const file of listed.files) {
|
||||
paths.add(file);
|
||||
}
|
||||
}
|
||||
if (report.artifactPaths.reportPath) {
|
||||
paths.add(report.artifactPaths.reportPath);
|
||||
}
|
||||
for (const path of report.artifactPaths.manifestShards) {
|
||||
paths.add(path);
|
||||
}
|
||||
for (const path of report.artifactPaths.enrichmentArtifacts) {
|
||||
paths.add(path);
|
||||
}
|
||||
|
||||
const artifacts: KtxScanArtifactSummary[] = [];
|
||||
for (const path of [...paths].sort()) {
|
||||
const size = await artifactSize(project, path);
|
||||
artifacts.push({
|
||||
path,
|
||||
type: scanArtifactType(path, report),
|
||||
...(size === undefined ? {} : { size }),
|
||||
});
|
||||
}
|
||||
return { runId, artifacts };
|
||||
}
|
||||
|
||||
async function readScanArtifact(
|
||||
project: KtxLocalProject,
|
||||
runId: string,
|
||||
path: string,
|
||||
): Promise<KtxScanArtifactReadResponse | null> {
|
||||
const report = await getLocalScanReport(project, runId);
|
||||
if (!report) {
|
||||
return null;
|
||||
}
|
||||
const listed = await listArtifactsForReport(project, runId, report);
|
||||
const artifact = listed.artifacts.find((candidate) => candidate.path === path);
|
||||
if (!artifact) {
|
||||
return null;
|
||||
}
|
||||
const result = await project.fileStore.readFile(path);
|
||||
return {
|
||||
runId,
|
||||
path,
|
||||
type: artifact.type,
|
||||
...(typeof result.size === 'number' ? { size: result.size } : {}),
|
||||
content: result.content,
|
||||
};
|
||||
}
|
||||
|
||||
function slPath(connectionId: string, sourceName: string): string {
|
||||
return `semantic-layer/${assertSafeConnectionId(connectionId)}/${assertSafeSourceName(sourceName)}.yaml`;
|
||||
}
|
||||
|
||||
function sourceNameFromPath(path: string): string {
|
||||
return (
|
||||
path
|
||||
.split('/')
|
||||
.at(-1)
|
||||
?.replace(/\.ya?ml$/, '') ?? path
|
||||
);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseYamlRecord(raw: string): Record<string, unknown> {
|
||||
const parsed = YAML.parse(raw) as unknown;
|
||||
if (!isRecord(parsed)) {
|
||||
throw new Error('Semantic-layer source YAML must contain an object');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function listSlPaths(project: KtxLocalProject, connectionId?: string): Promise<string[]> {
|
||||
const root = connectionId ? `semantic-layer/${assertSafeConnectionId(connectionId)}` : 'semantic-layer';
|
||||
const listed = await project.fileStore.listFiles(root);
|
||||
return listed.files.filter((file) => file.endsWith('.yaml') || file.endsWith('.yml')).sort();
|
||||
}
|
||||
|
||||
async function loadComputableSources(
|
||||
project: KtxLocalProject,
|
||||
connectionId: string,
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const paths = await listSlPaths(project, connectionId);
|
||||
const sources: Record<string, unknown>[] = [];
|
||||
for (const path of paths) {
|
||||
const raw = await project.fileStore.readFile(path);
|
||||
const source = parseYamlRecord(raw.content);
|
||||
if (source.table || source.sql) {
|
||||
sources.push(source);
|
||||
}
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
function validateSourceRecord(sourceName: string, source: Record<string, unknown>): string[] {
|
||||
const namedSource = { ...source, name: typeof source.name === 'string' ? source.name : sourceName };
|
||||
const definition = sourceDefinitionSchema.safeParse(namedSource);
|
||||
if (definition.success) {
|
||||
return [];
|
||||
}
|
||||
const overlay = sourceOverlaySchema.safeParse(namedSource);
|
||||
if (overlay.success) {
|
||||
return [];
|
||||
}
|
||||
return definition.error.issues.map((issue) => `${sourceName}: ${issue.path.join('.') || 'source'} ${issue.message}`);
|
||||
}
|
||||
|
||||
function localIngestSourceDir(config: unknown): string | undefined {
|
||||
if (!isRecord(config) || config.sourceDir === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof config.sourceDir !== 'string' || config.sourceDir.trim().length === 0) {
|
||||
throw new Error('Local ingest config sourceDir must be a non-empty string when provided');
|
||||
}
|
||||
return config.sourceDir;
|
||||
}
|
||||
|
||||
function rawFileCountFromIngestReport(report: IngestReportSnapshot): number {
|
||||
return new Set(report.body.workUnits.flatMap((workUnit) => workUnit.rawFiles)).size;
|
||||
}
|
||||
|
||||
function hasSlSearchMetadata(
|
||||
source: LocalSlSourceSummary | LocalSlSourceSearchResult,
|
||||
): source is LocalSlSourceSearchResult {
|
||||
return 'score' in source;
|
||||
}
|
||||
|
||||
function statusFromIngestReport(report: IngestReportSnapshot): KtxIngestStatusResponse {
|
||||
const failedWorkUnits = report.body.failedWorkUnits;
|
||||
return {
|
||||
runId: report.runId,
|
||||
jobId: report.jobId,
|
||||
reportId: report.id,
|
||||
status: failedWorkUnits.length > 0 ? 'error' : 'done',
|
||||
stage: 'done',
|
||||
progress: 1,
|
||||
errors: failedWorkUnits,
|
||||
done: true,
|
||||
adapter: report.sourceKey,
|
||||
connectionId: report.connectionId,
|
||||
sourceDir: null,
|
||||
syncId: report.body.syncId,
|
||||
startedAt: report.createdAt,
|
||||
completedAt: report.createdAt,
|
||||
previousRunId: null,
|
||||
diffSummary: report.body.diffSummary,
|
||||
workUnitCount: report.body.workUnits.length,
|
||||
rawFileCount: rawFileCountFromIngestReport(report),
|
||||
workUnits: report.body.workUnits.map((workUnit) => ({
|
||||
unitKey: workUnit.unitKey,
|
||||
rawFiles: [...workUnit.rawFiles],
|
||||
peerFileIndex: [],
|
||||
dependencyPaths: [],
|
||||
})),
|
||||
evictionDeletedRawPaths: [...report.body.evictionInputs],
|
||||
};
|
||||
}
|
||||
|
||||
async function executeValidatedReadOnlySql(
|
||||
project: KtxLocalProject,
|
||||
options: CreateLocalProjectMcpContextPortsOptions,
|
||||
input: { connectionId: string; sql: string; maxRows: number },
|
||||
onProgress?: KtxMcpProgressCallback,
|
||||
): Promise<KtxSqlExecutionResponse> {
|
||||
await onProgress?.({ progress: 0, message: 'Validating SQL' });
|
||||
const connectionId = assertSafeConnectionId(input.connectionId);
|
||||
const connection = project.config.connections[connectionId];
|
||||
if (!connection) {
|
||||
|
|
@ -416,6 +109,7 @@ async function executeValidatedReadOnlySql(
|
|||
if (!connector.capabilities.readOnlySql || !connector.executeReadOnly) {
|
||||
throw new Error(`Connection "${connectionId}" does not support read-only SQL execution.`);
|
||||
}
|
||||
await onProgress?.({ progress: 0.3, message: 'Executing' });
|
||||
const result = await connector.executeReadOnly(
|
||||
{
|
||||
connectionId,
|
||||
|
|
@ -424,12 +118,14 @@ async function executeValidatedReadOnlySql(
|
|||
},
|
||||
{ runId: 'mcp-sql-execution' },
|
||||
);
|
||||
return {
|
||||
const response = {
|
||||
headers: result.headers,
|
||||
...(result.headerTypes ? { headerTypes: result.headerTypes } : {}),
|
||||
rows: result.rows,
|
||||
rowCount: result.rowCount ?? result.rows.length,
|
||||
};
|
||||
await onProgress?.({ progress: 1, message: `Fetched ${response.rowCount} rows` });
|
||||
return response;
|
||||
} finally {
|
||||
await cleanupConnector(connector);
|
||||
}
|
||||
|
|
@ -453,9 +149,6 @@ export function createLocalProjectMcpContextPorts(
|
|||
)
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
},
|
||||
async test(input) {
|
||||
return testLocalConnection(project, options, input.connectionId);
|
||||
},
|
||||
},
|
||||
knowledge: {
|
||||
async search(input) {
|
||||
|
|
@ -495,58 +188,8 @@ export function createLocalProjectMcpContextPorts(
|
|||
}
|
||||
: null;
|
||||
},
|
||||
async write(input) {
|
||||
const existing = await readLocalKnowledgePage(project, {
|
||||
key: input.key,
|
||||
userId: input.userId,
|
||||
});
|
||||
await writeLocalKnowledgePage(project, {
|
||||
key: input.key,
|
||||
scope: 'GLOBAL',
|
||||
userId: input.userId,
|
||||
summary: input.summary,
|
||||
content: input.content,
|
||||
tags: input.tags,
|
||||
refs: input.refs,
|
||||
slRefs: input.slRefs,
|
||||
source: input.source,
|
||||
intent: input.intent,
|
||||
tables: input.tables,
|
||||
representativeSql: input.representativeSql,
|
||||
usage: input.usage,
|
||||
fingerprints: input.fingerprints,
|
||||
});
|
||||
return { success: true, key: input.key, action: existing ? 'updated' : 'created' };
|
||||
},
|
||||
},
|
||||
semanticLayer: {
|
||||
async listSources(input) {
|
||||
const listed: Array<LocalSlSourceSummary | LocalSlSourceSearchResult> = input.query
|
||||
? await searchLocalSlSources(project, {
|
||||
connectionId: input.connectionId,
|
||||
query: input.query,
|
||||
embeddingService,
|
||||
})
|
||||
: await listLocalSlSources(project, { connectionId: input.connectionId });
|
||||
const sources = listed.map((source) => ({
|
||||
connectionId: source.connectionId,
|
||||
connectionName: source.connectionId,
|
||||
name: source.name,
|
||||
description: source.description,
|
||||
columnCount: source.columnCount,
|
||||
measureCount: source.measureCount,
|
||||
joinCount: source.joinCount,
|
||||
...(hasSlSearchMetadata(source) && source.frequencyTier ? { frequencyTier: source.frequencyTier } : {}),
|
||||
...(hasSlSearchMetadata(source) && source.snippet ? { snippet: source.snippet } : {}),
|
||||
...(hasSlSearchMetadata(source) ? { score: source.score } : {}),
|
||||
...(hasSlSearchMetadata(source) && source.matchReasons ? { matchReasons: source.matchReasons } : {}),
|
||||
...(hasSlSearchMetadata(source) && source.dictionaryMatches
|
||||
? { dictionaryMatches: source.dictionaryMatches }
|
||||
: {}),
|
||||
...(hasSlSearchMetadata(source) && source.lanes ? { lanes: source.lanes } : {}),
|
||||
}));
|
||||
return { sources, totalSources: sources.length };
|
||||
},
|
||||
async readSource(input) {
|
||||
const path = slPath(input.connectionId, input.sourceName);
|
||||
try {
|
||||
|
|
@ -556,71 +199,9 @@ export function createLocalProjectMcpContextPorts(
|
|||
return null;
|
||||
}
|
||||
},
|
||||
async writeSource(input) {
|
||||
const path = slPath(input.connectionId, input.sourceName);
|
||||
if (input.delete) {
|
||||
const deleted = await project.fileStore.deleteFile(
|
||||
path,
|
||||
LOCAL_AUTHOR,
|
||||
LOCAL_AUTHOR_EMAIL,
|
||||
`Remove semantic-layer source: ${input.sourceName}`,
|
||||
);
|
||||
return { success: Boolean(deleted), sourceName: input.sourceName };
|
||||
}
|
||||
|
||||
const yaml =
|
||||
input.yaml ?? YAML.stringify({ ...input.source, name: input.sourceName }, { indent: 2, lineWidth: 0, version: '1.1' });
|
||||
parseYamlRecord(yaml);
|
||||
await project.fileStore.writeFile(
|
||||
path,
|
||||
`${yaml.trimEnd()}\n`,
|
||||
LOCAL_AUTHOR,
|
||||
LOCAL_AUTHOR_EMAIL,
|
||||
`Update semantic-layer source: ${input.sourceName}`,
|
||||
);
|
||||
return { success: true, sourceName: input.sourceName, yaml: `${yaml.trimEnd()}\n` };
|
||||
},
|
||||
async validate(input) {
|
||||
if (options.semanticLayerCompute) {
|
||||
const connectionId = assertSafeConnectionId(input.connectionId);
|
||||
const result = await options.semanticLayerCompute.validateSources({
|
||||
sources: await loadComputableSources(project, connectionId),
|
||||
dialect: dialectForDriver(project.config.connections[connectionId]?.driver),
|
||||
recentlyTouched: input.names,
|
||||
});
|
||||
return {
|
||||
success: result.valid,
|
||||
errors: result.errors,
|
||||
warnings: result.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
const names = new Set(input.names ?? []);
|
||||
const paths = await listSlPaths(project, input.connectionId);
|
||||
const errors: string[] = [];
|
||||
for (const path of paths) {
|
||||
const sourceName = sourceNameFromPath(path);
|
||||
if (names.size > 0 && !names.has(sourceName)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const raw = await project.fileStore.readFile(path);
|
||||
errors.push(...validateSourceRecord(sourceName, parseYamlRecord(raw.content)));
|
||||
} catch (error) {
|
||||
errors.push(`${sourceName}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
errors,
|
||||
warnings: [SL_SHAPE_WARNING],
|
||||
};
|
||||
},
|
||||
async query(input) {
|
||||
async query(input, executionOptions) {
|
||||
if (!options.semanticLayerCompute) {
|
||||
throw new Error(
|
||||
'sl_query requires a semantic-layer query adapter. Local stdio MCP exposes file-backed SL CRUD only.',
|
||||
);
|
||||
throw new Error('sl_query requires a semantic-layer query adapter.');
|
||||
}
|
||||
return compileLocalSlQuery(project, {
|
||||
connectionId: input.connectionId,
|
||||
|
|
@ -629,6 +210,7 @@ export function createLocalProjectMcpContextPorts(
|
|||
execute: Boolean(options.queryExecutor),
|
||||
maxRows: input.query.limit,
|
||||
queryExecutor: options.queryExecutor,
|
||||
onProgress: executionOptions?.onProgress,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
|
@ -651,114 +233,8 @@ export function createLocalProjectMcpContextPorts(
|
|||
|
||||
if (options.sqlAnalysis && options.localScan?.createConnector) {
|
||||
ports.sqlExecution = {
|
||||
async execute(input) {
|
||||
return executeValidatedReadOnlySql(project, options, input);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (options.localIngest) {
|
||||
ports.ingest = {
|
||||
async trigger(input) {
|
||||
const sourceDir = localIngestSourceDir(input.config);
|
||||
if (input.adapter === 'metabase' && !sourceDir) {
|
||||
const result = await (options.localIngest?.runLocalMetabaseIngest ?? runLocalMetabaseIngest)({
|
||||
project,
|
||||
adapters: options.localIngest?.adapters ?? createDefaultLocalIngestAdapters(project),
|
||||
metabaseConnectionId: input.connectionId,
|
||||
trigger: input.trigger,
|
||||
jobIdFactory: options.localIngest?.jobIdFactory,
|
||||
pullConfigOptions: options.localIngest?.pullConfigOptions,
|
||||
agentRunner: options.localIngest?.agentRunner,
|
||||
llmRuntime: options.localIngest?.llmRuntime,
|
||||
memoryModel: options.localIngest?.memoryModel,
|
||||
semanticLayerCompute: options.localIngest?.semanticLayerCompute ?? options.semanticLayerCompute,
|
||||
queryExecutor: options.localIngest?.queryExecutor ?? options.queryExecutor,
|
||||
logger: options.localIngest?.logger,
|
||||
});
|
||||
return {
|
||||
runId: `metabase-fanout:${result.metabaseConnectionId}`,
|
||||
jobId: undefined,
|
||||
reportId: undefined,
|
||||
fanout: {
|
||||
status: result.status,
|
||||
children: result.children.map((child) => ({
|
||||
runId: child.report.runId,
|
||||
jobId: child.report.jobId,
|
||||
reportId: child.report.id,
|
||||
targetConnectionId: child.targetConnectionId,
|
||||
metabaseDatabaseId: child.metabaseDatabaseId,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const executeLocalIngest = options.localIngest?.runLocalIngest ?? runLocalIngest;
|
||||
const result = await executeLocalIngest({
|
||||
project,
|
||||
adapters: options.localIngest?.adapters ?? createDefaultLocalIngestAdapters(project),
|
||||
adapter: input.adapter,
|
||||
connectionId: input.connectionId,
|
||||
sourceDir,
|
||||
pullConfigOptions: options.localIngest?.pullConfigOptions,
|
||||
trigger: input.trigger,
|
||||
jobId: options.localIngest?.jobIdFactory?.(),
|
||||
agentRunner: options.localIngest?.agentRunner,
|
||||
llmRuntime: options.localIngest?.llmRuntime,
|
||||
memoryModel: options.localIngest?.memoryModel,
|
||||
semanticLayerCompute: options.localIngest?.semanticLayerCompute ?? options.semanticLayerCompute,
|
||||
queryExecutor: options.localIngest?.queryExecutor ?? options.queryExecutor,
|
||||
logger: options.localIngest?.logger,
|
||||
});
|
||||
return {
|
||||
runId: result.report.runId,
|
||||
jobId: result.report.jobId,
|
||||
reportId: result.report.id,
|
||||
};
|
||||
},
|
||||
async status(input) {
|
||||
const report = await getLocalIngestStatus(project, input.runId);
|
||||
return report ? statusFromIngestReport(report) : null;
|
||||
},
|
||||
async report(input) {
|
||||
return getLocalIngestStatus(project, input.runId);
|
||||
},
|
||||
async replay(input) {
|
||||
const report = await getLocalIngestStatus(project, input.runId);
|
||||
return report ? ingestReportToMemoryFlowReplay(report) : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (options.localScan) {
|
||||
ports.scan = {
|
||||
async trigger(input) {
|
||||
return runLocalScan({
|
||||
project,
|
||||
connectionId: input.connectionId,
|
||||
mode: input.mode,
|
||||
detectRelationships: input.detectRelationships,
|
||||
dryRun: input.dryRun,
|
||||
trigger: 'mcp',
|
||||
adapters: options.localScan?.adapters,
|
||||
databaseIntrospectionUrl: options.localScan?.databaseIntrospectionUrl,
|
||||
createConnector: options.localScan?.createConnector,
|
||||
jobId: options.localScan?.jobIdFactory?.(),
|
||||
now: options.localScan?.now,
|
||||
});
|
||||
},
|
||||
async status(input) {
|
||||
return getLocalScanStatus(project, input.runId);
|
||||
},
|
||||
async report(input) {
|
||||
return getLocalScanReport(project, input.runId);
|
||||
},
|
||||
async listArtifacts(input) {
|
||||
const report = await getLocalScanReport(project, input.runId);
|
||||
return report ? listArtifactsForReport(project, input.runId, report) : null;
|
||||
},
|
||||
async readArtifact(input) {
|
||||
return readScanArtifact(project, input.runId, input.path);
|
||||
async execute(input, executionOptions) {
|
||||
return executeValidatedReadOnlySql(project, options, input, executionOptions?.onProgress);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,71 +1,8 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
import type { MemoryAgentInput } from '../memory/index.js';
|
||||
import { jsonErrorToolResult, jsonToolResult, registerKtxContextTools } from './context-tools.js';
|
||||
import type { KtxMcpServerDeps, KtxMcpServerLike, MemoryCapturePort } from './types.js';
|
||||
|
||||
const memoryCaptureInputSchema = {
|
||||
userMessage: z.string().min(1).describe('The user message that may contain durable knowledge.'),
|
||||
assistantMessage: z.string().optional().describe('The assistant response that concluded the exchange.'),
|
||||
connectionId: z.string().min(1).optional().describe('Optional connection id for semantic-layer capture.'),
|
||||
};
|
||||
|
||||
const memoryCaptureStatusInputSchema = {
|
||||
runId: z.string().min(1).describe('The memory capture run id returned by memory_capture.'),
|
||||
};
|
||||
|
||||
function registerMemoryCaptureTools(deps: {
|
||||
server: KtxMcpServerLike;
|
||||
memoryCapture: MemoryCapturePort;
|
||||
userContext: KtxMcpServerDeps['userContext'];
|
||||
}): void {
|
||||
deps.server.registerTool(
|
||||
'memory_capture',
|
||||
{
|
||||
title: 'Memory Capture',
|
||||
description:
|
||||
'Capture durable knowledge and semantic-layer updates from the final user/assistant exchange. Returns a run id for polling.',
|
||||
inputSchema: memoryCaptureInputSchema,
|
||||
},
|
||||
async (input) => {
|
||||
const captureInput: MemoryAgentInput = {
|
||||
userId: deps.userContext.userId,
|
||||
chatId: `mcp-${randomUUID()}`,
|
||||
userMessage: String(input.userMessage),
|
||||
assistantMessage: typeof input.assistantMessage === 'string' ? input.assistantMessage : undefined,
|
||||
connectionId: typeof input.connectionId === 'string' ? input.connectionId : undefined,
|
||||
sourceType: 'external_ingest',
|
||||
};
|
||||
const result = await deps.memoryCapture.capture(captureInput);
|
||||
return jsonToolResult(result);
|
||||
},
|
||||
);
|
||||
|
||||
deps.server.registerTool(
|
||||
'memory_capture_status',
|
||||
{
|
||||
title: 'Memory Capture Status',
|
||||
description: 'Read the current or final status for a memory capture run.',
|
||||
inputSchema: memoryCaptureStatusInputSchema,
|
||||
},
|
||||
async (input) => {
|
||||
const runId = String(input.runId);
|
||||
const status = await deps.memoryCapture.status(runId);
|
||||
return status ? jsonToolResult(status) : jsonErrorToolResult(`Memory capture run "${runId}" was not found.`);
|
||||
},
|
||||
);
|
||||
}
|
||||
import { registerKtxContextTools } from './context-tools.js';
|
||||
import type { KtxMcpServerDeps, KtxMcpServerLike } from './types.js';
|
||||
|
||||
export function createKtxMcpServer(deps: KtxMcpServerDeps): KtxMcpServerDeps['server'] {
|
||||
if (deps.memoryCapture) {
|
||||
registerMemoryCaptureTools({
|
||||
server: deps.server,
|
||||
memoryCapture: deps.memoryCapture,
|
||||
userContext: deps.userContext,
|
||||
});
|
||||
}
|
||||
|
||||
if (deps.contextTools) {
|
||||
registerKtxContextTools({
|
||||
server: deps.server,
|
||||
|
|
@ -86,7 +23,6 @@ export function createDefaultKtxMcpServer(
|
|||
});
|
||||
createKtxMcpServer({
|
||||
server: server as KtxMcpServerLike,
|
||||
memoryCapture: deps.memoryCapture,
|
||||
userContext: deps.userContext,
|
||||
contextTools: deps.contextTools,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,16 +1,7 @@
|
|||
import type { IngestReportSnapshot, MemoryFlowReplayInput, TableUsageOutput } from '../ingest/index.js';
|
||||
import type { MemoryCaptureService } from '../memory/index.js';
|
||||
import type { MemoryIngestService } from '../memory/index.js';
|
||||
import type { KtxEntityDetailsInput, KtxEntityDetailsResponse } from '../scan/entity-details.js';
|
||||
import type { KtxScanMode, KtxScanReport } from '../scan/index.js';
|
||||
import type { KtxDiscoverDataInput, KtxDiscoverDataResponse } from '../search/index.js';
|
||||
import type {
|
||||
KtxDictionarySearchInput,
|
||||
KtxDictionarySearchResponse,
|
||||
SemanticLayerQueryInput,
|
||||
SlDictionaryMatch,
|
||||
SlSearchLaneSummary,
|
||||
SlSearchMatchReason,
|
||||
} from '../sl/index.js';
|
||||
import type { KtxDictionarySearchInput, KtxDictionarySearchResponse, SemanticLayerQueryInput } from '../sl/index.js';
|
||||
import type { WikiSearchLaneSummary, WikiSearchMatchReason } from '../wiki/index.js';
|
||||
|
||||
export interface KtxMcpTextContent {
|
||||
|
|
@ -18,15 +9,38 @@ export interface KtxMcpTextContent {
|
|||
text: string;
|
||||
}
|
||||
|
||||
export interface KtxMcpToolResult<T extends object = object> {
|
||||
export type NonArrayObject = object & { length?: never };
|
||||
|
||||
export interface KtxMcpToolResult<T extends NonArrayObject = NonArrayObject> {
|
||||
content: KtxMcpTextContent[];
|
||||
structuredContent?: T;
|
||||
isError?: true;
|
||||
}
|
||||
|
||||
export interface MemoryCapturePort {
|
||||
capture: MemoryCaptureService['capture'];
|
||||
status: MemoryCaptureService['status'];
|
||||
interface KtxMcpProgressEvent {
|
||||
progress: number;
|
||||
total?: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type KtxMcpProgressCallback = (event: KtxMcpProgressEvent) => void | Promise<void>;
|
||||
|
||||
export interface KtxMcpToolHandlerContext {
|
||||
_meta?: { progressToken?: string | number; [key: string]: unknown };
|
||||
sendNotification?: (notification: {
|
||||
method: 'notifications/progress';
|
||||
params: {
|
||||
progressToken: string | number;
|
||||
progress: number;
|
||||
total?: number;
|
||||
message?: string;
|
||||
};
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface MemoryIngestPort {
|
||||
ingest: MemoryIngestService['ingest'];
|
||||
status: MemoryIngestService['status'];
|
||||
}
|
||||
|
||||
export interface KtxMcpUserContext {
|
||||
|
|
@ -40,8 +54,10 @@ export interface KtxMcpServerLike {
|
|||
title?: string;
|
||||
description?: string;
|
||||
inputSchema: unknown;
|
||||
outputSchema?: unknown;
|
||||
annotations?: Record<string, unknown>;
|
||||
},
|
||||
handler: (input: Record<string, unknown>) => Promise<unknown>,
|
||||
handler: (input: Record<string, unknown>, context?: KtxMcpToolHandlerContext) => Promise<unknown>,
|
||||
): void;
|
||||
}
|
||||
|
||||
|
|
@ -51,18 +67,8 @@ export interface KtxConnectionSummary {
|
|||
connectionType: string;
|
||||
}
|
||||
|
||||
export interface KtxConnectionTestResponse {
|
||||
id: string;
|
||||
connectionType: string;
|
||||
ok: boolean;
|
||||
tableCount: number | null;
|
||||
message: string;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface KtxConnectionsMcpPort {
|
||||
list(): Promise<KtxConnectionSummary[]>;
|
||||
test?(input: { connectionId: string }): Promise<KtxConnectionTestResponse | null>;
|
||||
}
|
||||
|
||||
export interface KtxKnowledgeSearchResult {
|
||||
|
|
@ -90,62 +96,9 @@ export interface KtxKnowledgePage {
|
|||
slRefs?: string[];
|
||||
}
|
||||
|
||||
interface KtxHistoricSqlKnowledgeUsage {
|
||||
executions: number;
|
||||
distinct_users: number;
|
||||
first_seen: string;
|
||||
last_seen: string;
|
||||
p50_runtime_ms: number | null;
|
||||
p95_runtime_ms: number | null;
|
||||
error_rate: number;
|
||||
rows_produced?: number;
|
||||
}
|
||||
|
||||
export interface KtxKnowledgeWriteResponse {
|
||||
success: boolean;
|
||||
key: string;
|
||||
action: 'created' | 'updated';
|
||||
}
|
||||
|
||||
export interface KtxKnowledgeMcpPort {
|
||||
search(input: { userId: string; query: string; limit: number }): Promise<KtxKnowledgeSearchResponse>;
|
||||
read(input: { userId: string; key: string }): Promise<KtxKnowledgePage | null>;
|
||||
write(input: {
|
||||
userId: string;
|
||||
key: string;
|
||||
summary: string;
|
||||
content: string;
|
||||
tags?: string[];
|
||||
refs?: string[];
|
||||
slRefs?: string[];
|
||||
source?: string;
|
||||
intent?: string;
|
||||
tables?: string[];
|
||||
representativeSql?: string;
|
||||
usage?: KtxHistoricSqlKnowledgeUsage;
|
||||
fingerprints?: string[];
|
||||
}): Promise<KtxKnowledgeWriteResponse>;
|
||||
}
|
||||
|
||||
export interface KtxSemanticLayerSourceSummary {
|
||||
connectionId: string;
|
||||
connectionName: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
columnCount: number;
|
||||
measureCount: number;
|
||||
joinCount: number;
|
||||
frequencyTier?: TableUsageOutput['frequencyTier'];
|
||||
snippet?: string;
|
||||
score?: number;
|
||||
matchReasons?: SlSearchMatchReason[];
|
||||
dictionaryMatches?: SlDictionaryMatch[];
|
||||
lanes?: SlSearchLaneSummary[];
|
||||
}
|
||||
|
||||
export interface KtxSemanticLayerListResponse {
|
||||
sources: KtxSemanticLayerSourceSummary[];
|
||||
totalSources: number;
|
||||
}
|
||||
|
||||
export interface KtxSemanticLayerReadResponse {
|
||||
|
|
@ -153,21 +106,6 @@ export interface KtxSemanticLayerReadResponse {
|
|||
yaml: string;
|
||||
}
|
||||
|
||||
export interface KtxSemanticLayerWriteResponse {
|
||||
success: boolean;
|
||||
sourceName: string;
|
||||
yaml?: string;
|
||||
errors?: string[];
|
||||
warnings?: string[];
|
||||
commitHash?: string;
|
||||
}
|
||||
|
||||
export interface KtxSemanticLayerValidationResponse {
|
||||
success: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface KtxSemanticLayerQueryResponse {
|
||||
sql: string;
|
||||
headers: string[];
|
||||
|
|
@ -177,143 +115,11 @@ export interface KtxSemanticLayerQueryResponse {
|
|||
}
|
||||
|
||||
export interface KtxSemanticLayerMcpPort {
|
||||
listSources(input: { connectionId?: string; query?: string }): Promise<KtxSemanticLayerListResponse>;
|
||||
readSource(input: { connectionId: string; sourceName: string }): Promise<KtxSemanticLayerReadResponse | null>;
|
||||
writeSource(input: {
|
||||
connectionId: string;
|
||||
sourceName: string;
|
||||
yaml?: string;
|
||||
source?: Record<string, unknown>;
|
||||
delete?: boolean;
|
||||
}): Promise<KtxSemanticLayerWriteResponse>;
|
||||
validate(input: { connectionId: string; names?: string[] }): Promise<KtxSemanticLayerValidationResponse>;
|
||||
query(input: { connectionId?: string; query: SemanticLayerQueryInput }): Promise<KtxSemanticLayerQueryResponse>;
|
||||
}
|
||||
|
||||
export type KtxIngestTriggerKind = 'upload' | 'scheduled_pull' | 'manual_resync';
|
||||
|
||||
interface KtxIngestTriggerFanoutChild {
|
||||
runId: string;
|
||||
jobId: string;
|
||||
reportId: string;
|
||||
targetConnectionId: string;
|
||||
metabaseDatabaseId: number;
|
||||
}
|
||||
|
||||
export interface KtxIngestTriggerResponse {
|
||||
runId: string;
|
||||
jobId?: string;
|
||||
reportId?: string;
|
||||
fanout?: {
|
||||
status: 'all_succeeded' | 'partial_failure' | 'all_failed';
|
||||
children: KtxIngestTriggerFanoutChild[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface KtxIngestDiffSummary {
|
||||
added: number;
|
||||
modified: number;
|
||||
deleted: number;
|
||||
unchanged: number;
|
||||
}
|
||||
|
||||
export interface KtxIngestWorkUnitSummary {
|
||||
unitKey: string;
|
||||
rawFiles: string[];
|
||||
peerFileIndex: string[];
|
||||
dependencyPaths: string[];
|
||||
}
|
||||
|
||||
export interface KtxIngestStatusResponse {
|
||||
runId: string;
|
||||
jobId?: string;
|
||||
reportId?: string;
|
||||
status: string;
|
||||
stage?: string;
|
||||
progress?: number;
|
||||
errors?: string[];
|
||||
done: boolean;
|
||||
adapter?: string;
|
||||
connectionId?: string;
|
||||
sourceDir?: string | null;
|
||||
syncId?: string;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
previousRunId?: string | null;
|
||||
diffSummary?: KtxIngestDiffSummary;
|
||||
workUnitCount?: number;
|
||||
rawFileCount?: number;
|
||||
workUnits?: KtxIngestWorkUnitSummary[];
|
||||
evictionDeletedRawPaths?: string[];
|
||||
}
|
||||
|
||||
export interface KtxIngestMcpPort {
|
||||
trigger(input: {
|
||||
adapter: string;
|
||||
connectionId: string;
|
||||
config?: unknown;
|
||||
trigger: KtxIngestTriggerKind;
|
||||
}): Promise<KtxIngestTriggerResponse>;
|
||||
status(input: { runId: string }): Promise<KtxIngestStatusResponse | null>;
|
||||
report?(input: { runId: string }): Promise<IngestReportSnapshot | null>;
|
||||
replay?(input: { runId: string }): Promise<MemoryFlowReplayInput | null>;
|
||||
}
|
||||
|
||||
interface KtxScanTriggerResponse {
|
||||
runId: string;
|
||||
status: 'done';
|
||||
done: true;
|
||||
connectionId: string;
|
||||
mode: KtxScanMode;
|
||||
dryRun: boolean;
|
||||
syncId: string;
|
||||
report: KtxScanReport;
|
||||
}
|
||||
|
||||
interface KtxScanStatusResponse {
|
||||
runId: string;
|
||||
status: string;
|
||||
done: boolean;
|
||||
connectionId: string;
|
||||
mode: KtxScanMode;
|
||||
dryRun: boolean;
|
||||
syncId: string;
|
||||
progress: number;
|
||||
startedAt: string;
|
||||
completedAt: string;
|
||||
reportPath: string | null;
|
||||
warnings: KtxScanReport['warnings'];
|
||||
}
|
||||
|
||||
export type KtxScanArtifactType = 'report' | 'raw_source' | 'manifest_shard' | 'enrichment_artifact';
|
||||
|
||||
export interface KtxScanArtifactSummary {
|
||||
path: string;
|
||||
type: KtxScanArtifactType;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface KtxScanArtifactListResponse {
|
||||
runId: string;
|
||||
artifacts: KtxScanArtifactSummary[];
|
||||
}
|
||||
|
||||
export interface KtxScanArtifactReadResponse extends KtxScanArtifactSummary {
|
||||
runId: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface KtxScanMcpPort {
|
||||
trigger(input: {
|
||||
connectionId: string;
|
||||
mode?: KtxScanMode;
|
||||
detectRelationships: boolean;
|
||||
dryRun: boolean;
|
||||
}): Promise<KtxScanTriggerResponse>;
|
||||
status(input: { runId: string }): Promise<KtxScanStatusResponse | null>;
|
||||
report(input: { runId: string }): Promise<KtxScanReport | null>;
|
||||
listArtifacts?(input: { runId: string }): Promise<KtxScanArtifactListResponse | null>;
|
||||
readArtifact?(input: { runId: string; path: string }): Promise<KtxScanArtifactReadResponse | null>;
|
||||
query(
|
||||
input: { connectionId?: string; query: SemanticLayerQueryInput },
|
||||
options?: { onProgress?: KtxMcpProgressCallback },
|
||||
): Promise<KtxSemanticLayerQueryResponse>;
|
||||
}
|
||||
|
||||
export interface KtxEntityDetailsMcpPort {
|
||||
|
|
@ -336,7 +142,10 @@ export interface KtxSqlExecutionResponse {
|
|||
}
|
||||
|
||||
export interface KtxSqlExecutionMcpPort {
|
||||
execute(input: { connectionId: string; sql: string; maxRows: number }): Promise<KtxSqlExecutionResponse>;
|
||||
execute(
|
||||
input: { connectionId: string; sql: string; maxRows: number },
|
||||
options?: { onProgress?: KtxMcpProgressCallback },
|
||||
): Promise<KtxSqlExecutionResponse>;
|
||||
}
|
||||
|
||||
export interface KtxMcpContextPorts {
|
||||
|
|
@ -347,13 +156,11 @@ export interface KtxMcpContextPorts {
|
|||
dictionarySearch?: KtxDictionarySearchMcpPort;
|
||||
discover?: KtxDiscoverDataMcpPort;
|
||||
sqlExecution?: KtxSqlExecutionMcpPort;
|
||||
ingest?: KtxIngestMcpPort;
|
||||
scan?: KtxScanMcpPort;
|
||||
memoryIngest?: MemoryIngestPort;
|
||||
}
|
||||
|
||||
export interface KtxMcpServerDeps {
|
||||
server: KtxMcpServerLike;
|
||||
memoryCapture?: MemoryCapturePort;
|
||||
userContext: KtxMcpUserContext;
|
||||
contextTools?: KtxMcpContextPorts;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,13 +8,13 @@ export {
|
|||
stepBudgetFor,
|
||||
} from './capture-signals.js';
|
||||
export { MemoryAgentService } from './memory-agent.service.js';
|
||||
export { createLocalProjectMemoryCapture, type CreateLocalProjectMemoryCaptureOptions } from './local-memory.js';
|
||||
export { createLocalProjectMemoryIngest, type CreateLocalProjectMemoryIngestOptions } from './local-memory.js';
|
||||
export { LocalMemoryRunStore, type LocalMemoryRunStoreOptions } from './local-memory-runs.js';
|
||||
export {
|
||||
MemoryCaptureService,
|
||||
type MemoryCaptureServiceDeps,
|
||||
type MemoryCaptureStartResult,
|
||||
type MemoryCaptureStatus,
|
||||
MemoryIngestService,
|
||||
type MemoryIngestServiceDeps,
|
||||
type MemoryIngestStartResult,
|
||||
type MemoryIngestStatus,
|
||||
type MemoryRunRecord,
|
||||
type MemoryRunStatus,
|
||||
type MemoryRunStorePort,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { tmpdir } from 'node:os';
|
|||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { initKtxProject } from '../project/index.js';
|
||||
import { createLocalProjectMemoryCapture } from './local-memory.js';
|
||||
import { createLocalProjectMemoryIngest } from './local-memory.js';
|
||||
import { LocalMemoryRunStore } from './local-memory-runs.js';
|
||||
|
||||
vi.mock('ai', () => ({
|
||||
|
|
@ -77,7 +77,7 @@ describe('LocalMemoryRunStore', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('createLocalProjectMemoryCapture', () => {
|
||||
describe('createLocalProjectMemoryIngest', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
|
|
@ -110,13 +110,13 @@ describe('createLocalProjectMemoryCapture', () => {
|
|||
},
|
||||
};
|
||||
|
||||
const capture = createLocalProjectMemoryCapture(project, {
|
||||
const ingest = createLocalProjectMemoryIngest(project, {
|
||||
agentRunner: agentRunner as never,
|
||||
runIdFactory: () => 'memory-run-1',
|
||||
});
|
||||
|
||||
await expect(
|
||||
capture.capture({
|
||||
ingest.ingest({
|
||||
userId: 'local-user',
|
||||
chatId: 'chat-1',
|
||||
userMessage: 'define revenue as paid order value net of refunds',
|
||||
|
|
@ -124,12 +124,12 @@ describe('createLocalProjectMemoryCapture', () => {
|
|||
sourceType: 'external_ingest',
|
||||
}),
|
||||
).resolves.toEqual({ runId: 'memory-run-1' });
|
||||
await capture.waitForRun('memory-run-1');
|
||||
await ingest.waitForRun('memory-run-1');
|
||||
|
||||
await expect(access(join(project.projectDir, '.ktx/db.sqlite'))).resolves.toBeUndefined();
|
||||
await expectPathMissing(join(project.projectDir, '.ktx/memory-runs/memory-run-1.json'));
|
||||
|
||||
await expect(capture.status('memory-run-1')).resolves.toMatchObject({
|
||||
await expect(ingest.status('memory-run-1')).resolves.toMatchObject({
|
||||
runId: 'memory-run-1',
|
||||
status: 'done',
|
||||
done: true,
|
||||
|
|
@ -172,12 +172,12 @@ describe('createLocalProjectMemoryCapture', () => {
|
|||
},
|
||||
};
|
||||
|
||||
const capture = createLocalProjectMemoryCapture(project, {
|
||||
const ingest = createLocalProjectMemoryIngest(project, {
|
||||
agentRunner: agentRunner as never,
|
||||
runIdFactory: () => 'memory-run-2',
|
||||
});
|
||||
|
||||
await capture.capture({
|
||||
await ingest.ingest({
|
||||
userId: 'local-user',
|
||||
chatId: 'chat-2',
|
||||
userMessage: 'going forward define orders count as count of public orders',
|
||||
|
|
@ -185,12 +185,12 @@ describe('createLocalProjectMemoryCapture', () => {
|
|||
connectionId: 'warehouse',
|
||||
sourceType: 'external_ingest',
|
||||
});
|
||||
await capture.waitForRun('memory-run-2');
|
||||
await ingest.waitForRun('memory-run-2');
|
||||
|
||||
await expect(access(join(project.projectDir, '.ktx/db.sqlite'))).resolves.toBeUndefined();
|
||||
await expectPathMissing(join(project.projectDir, '.ktx/memory-runs/memory-run-2.json'));
|
||||
|
||||
await expect(capture.status('memory-run-2')).resolves.toMatchObject({
|
||||
await expect(ingest.status('memory-run-2')).resolves.toMatchObject({
|
||||
runId: 'memory-run-2',
|
||||
status: 'done',
|
||||
captured: { wiki: [], sl: ['orders'], xrefs: [] },
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ import {
|
|||
} from '../wiki/index.js';
|
||||
import { LocalMemoryRunStore } from './local-memory-runs.js';
|
||||
import { MemoryAgentService } from './memory-agent.service.js';
|
||||
import { MemoryCaptureService } from './memory-runs.js';
|
||||
import { MemoryIngestService } from './memory-runs.js';
|
||||
import type {
|
||||
MemoryConnectionPort,
|
||||
MemoryFileStorePort,
|
||||
|
|
@ -64,9 +64,9 @@ import type {
|
|||
const promptsDir = fileURLToPath(new URL('../../prompts', import.meta.url));
|
||||
const skillsDir = fileURLToPath(new URL('../../skills', import.meta.url));
|
||||
const LOCAL_AUTHOR = { name: 'KTX Local', email: 'local@ktx.local' };
|
||||
const LOCAL_SHAPE_WARNING = 'Local memory capture validates semantic-layer YAML shape only.';
|
||||
const LOCAL_SHAPE_WARNING = 'Local memory ingest validates semantic-layer YAML shape only.';
|
||||
|
||||
export interface CreateLocalProjectMemoryCaptureOptions {
|
||||
export interface CreateLocalProjectMemoryIngestOptions {
|
||||
llmRuntime?: KtxLlmRuntimePort;
|
||||
agentRunner?: AgentRunnerPort;
|
||||
memoryModel?: string;
|
||||
|
|
@ -76,10 +76,10 @@ export interface CreateLocalProjectMemoryCaptureOptions {
|
|||
logger?: KtxLogger;
|
||||
}
|
||||
|
||||
export function createLocalProjectMemoryCapture(
|
||||
export function createLocalProjectMemoryIngest(
|
||||
project: KtxLocalProject,
|
||||
options: CreateLocalProjectMemoryCaptureOptions = {},
|
||||
): MemoryCaptureService {
|
||||
options: CreateLocalProjectMemoryIngestOptions = {},
|
||||
): MemoryIngestService {
|
||||
const logger = options.logger ?? noopLogger;
|
||||
const rootFileStore = new LocalMemoryFileStore(project.fileStore);
|
||||
const embedding = new NoopEmbeddingPort();
|
||||
|
|
@ -139,7 +139,7 @@ export function createLocalProjectMemoryCapture(
|
|||
toolsetFactory,
|
||||
logger,
|
||||
});
|
||||
return new MemoryCaptureService({
|
||||
return new MemoryIngestService({
|
||||
memoryAgent,
|
||||
runs: new LocalMemoryRunStore({ projectDir: project.projectDir, idFactory: options.runIdFactory }),
|
||||
});
|
||||
|
|
@ -147,7 +147,7 @@ export function createLocalProjectMemoryCapture(
|
|||
|
||||
function requireLlmRuntime(runtime: KtxLlmRuntimePort | null | undefined): KtxLlmRuntimePort {
|
||||
if (!runtime) {
|
||||
throw new Error('createLocalProjectMemoryCapture requires llm.provider.backend or an injected agentRunner');
|
||||
throw new Error('createLocalProjectMemoryIngest requires llm.provider.backend or an injected agentRunner');
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { MemoryAgentInput, MemoryAgentResult, MemoryAgentService } from './index.js';
|
||||
import { MemoryCaptureService, type MemoryRunStorePort } from './memory-runs.js';
|
||||
import { MemoryIngestService, type MemoryRunStorePort } from './memory-runs.js';
|
||||
|
||||
class InMemoryRunStore implements MemoryRunStorePort {
|
||||
readonly rows = new Map<
|
||||
|
|
@ -74,32 +74,32 @@ function deferred<T>() {
|
|||
}
|
||||
|
||||
function buildService(): {
|
||||
capture: MemoryCaptureService;
|
||||
ingest: MemoryIngestService;
|
||||
store: InMemoryRunStore;
|
||||
ingest: ReturnType<typeof vi.fn>;
|
||||
memoryAgentIngest: ReturnType<typeof vi.fn>;
|
||||
run: ReturnType<typeof deferred<MemoryAgentResult>>;
|
||||
} {
|
||||
const store = new InMemoryRunStore();
|
||||
const run = deferred<MemoryAgentResult>();
|
||||
const ingest = vi.fn<MemoryAgentService['ingest']>().mockReturnValue(run.promise);
|
||||
const memoryAgent = { ingest };
|
||||
const memoryAgentIngest = vi.fn<MemoryAgentService['ingest']>().mockReturnValue(run.promise);
|
||||
const memoryAgent = { ingest: memoryAgentIngest };
|
||||
return {
|
||||
capture: new MemoryCaptureService({ memoryAgent, runs: store }),
|
||||
ingest: new MemoryIngestService({ memoryAgent, runs: store }),
|
||||
store,
|
||||
ingest,
|
||||
memoryAgentIngest,
|
||||
run,
|
||||
};
|
||||
}
|
||||
|
||||
describe('MemoryCaptureService', () => {
|
||||
it('creates a run, executes memory capture, and stores a done summary', async () => {
|
||||
describe('MemoryIngestService', () => {
|
||||
it('creates a run, executes memory ingest, and stores a done summary', async () => {
|
||||
const result: MemoryAgentResult = {
|
||||
signalDetected: true,
|
||||
actions: [{ target: 'wiki', type: 'created', key: 'revenue', detail: 'captured revenue definition' }],
|
||||
skillsLoaded: ['wiki_capture'],
|
||||
commitHash: 'abc123',
|
||||
};
|
||||
const { capture, store, ingest, run } = buildService();
|
||||
const { ingest, store, memoryAgentIngest, run } = buildService();
|
||||
|
||||
const input: MemoryAgentInput = {
|
||||
userId: 'user-1',
|
||||
|
|
@ -109,21 +109,21 @@ describe('MemoryCaptureService', () => {
|
|||
connectionId: '00000000-0000-0000-0000-000000000001',
|
||||
};
|
||||
|
||||
const started = await capture.capture(input);
|
||||
const started = await ingest.ingest(input);
|
||||
|
||||
expect(started.runId).toBe('run-1');
|
||||
expect(ingest).toHaveBeenCalledWith(input);
|
||||
await expect(capture.status(started.runId)).resolves.toMatchObject({
|
||||
expect(memoryAgentIngest).toHaveBeenCalledWith(input);
|
||||
await expect(ingest.status(started.runId)).resolves.toMatchObject({
|
||||
runId: 'run-1',
|
||||
status: 'running',
|
||||
stage: 'capturing',
|
||||
stage: 'ingesting',
|
||||
done: false,
|
||||
});
|
||||
|
||||
run.resolve(result);
|
||||
await capture.waitForRun(started.runId);
|
||||
await ingest.waitForRun(started.runId);
|
||||
|
||||
const status = await capture.status(started.runId);
|
||||
const status = await ingest.status(started.runId);
|
||||
expect(status).toEqual({
|
||||
runId: 'run-1',
|
||||
stage: 'done',
|
||||
|
|
@ -142,10 +142,10 @@ describe('MemoryCaptureService', () => {
|
|||
expect(store.rows.get('run-1')?.inputHash).toHaveLength(64);
|
||||
});
|
||||
|
||||
it('stores no-signal captures as done with empty captured arrays', async () => {
|
||||
const { capture, run } = buildService();
|
||||
it('stores no-signal ingests as done with empty captured arrays', async () => {
|
||||
const { ingest, run } = buildService();
|
||||
|
||||
const started = await capture.capture({
|
||||
const started = await ingest.ingest({
|
||||
userId: 'user-1',
|
||||
chatId: 'chat-2',
|
||||
userMessage: 'Thanks.',
|
||||
|
|
@ -157,9 +157,9 @@ describe('MemoryCaptureService', () => {
|
|||
skillsLoaded: [],
|
||||
commitHash: null,
|
||||
});
|
||||
await capture.waitForRun(started.runId);
|
||||
await ingest.waitForRun(started.runId);
|
||||
|
||||
await expect(capture.status(started.runId)).resolves.toMatchObject({
|
||||
await expect(ingest.status(started.runId)).resolves.toMatchObject({
|
||||
done: true,
|
||||
status: 'done',
|
||||
captured: { wiki: [], sl: [], xrefs: [] },
|
||||
|
|
@ -172,16 +172,16 @@ describe('MemoryCaptureService', () => {
|
|||
const memoryAgent = {
|
||||
ingest: vi.fn<MemoryAgentService['ingest']>().mockRejectedValue(new Error('LLM provider missing')),
|
||||
};
|
||||
const capture = new MemoryCaptureService({ memoryAgent, runs: store });
|
||||
const ingest = new MemoryIngestService({ memoryAgent, runs: store });
|
||||
|
||||
const started = await capture.capture({
|
||||
const started = await ingest.ingest({
|
||||
userId: 'user-1',
|
||||
chatId: 'chat-3',
|
||||
userMessage: 'Remember this.',
|
||||
});
|
||||
await capture.waitForRun(started.runId);
|
||||
await ingest.waitForRun(started.runId);
|
||||
|
||||
await expect(capture.status(started.runId)).resolves.toMatchObject({
|
||||
await expect(ingest.status(started.runId)).resolves.toMatchObject({
|
||||
done: true,
|
||||
status: 'error',
|
||||
stage: 'error',
|
||||
|
|
@ -191,8 +191,8 @@ describe('MemoryCaptureService', () => {
|
|||
});
|
||||
|
||||
it('returns null for an unknown run id', async () => {
|
||||
const { capture } = buildService();
|
||||
const { ingest } = buildService();
|
||||
|
||||
await expect(capture.status('missing')).resolves.toBeNull();
|
||||
await expect(ingest.status('missing')).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,16 +21,16 @@ export interface MemoryRunStorePort {
|
|||
findById(id: string): Promise<MemoryRunRecord | null>;
|
||||
}
|
||||
|
||||
export interface MemoryCaptureServiceDeps {
|
||||
export interface MemoryIngestServiceDeps {
|
||||
memoryAgent: Pick<MemoryAgentService, 'ingest'>;
|
||||
runs: MemoryRunStorePort;
|
||||
}
|
||||
|
||||
export interface MemoryCaptureStartResult {
|
||||
export interface MemoryIngestStartResult {
|
||||
runId: string;
|
||||
}
|
||||
|
||||
export interface MemoryCaptureStatus {
|
||||
export interface MemoryIngestStatus {
|
||||
runId: string;
|
||||
status: MemoryRunStatus;
|
||||
stage: string;
|
||||
|
|
@ -55,7 +55,7 @@ function inputHash(input: MemoryAgentInput): string {
|
|||
return createHash('sha256').update(stableInput).digest('hex');
|
||||
}
|
||||
|
||||
function capturedKeys(actions: MemoryAction[]): MemoryCaptureStatus['captured'] {
|
||||
function capturedKeys(actions: MemoryAction[]): MemoryIngestStatus['captured'] {
|
||||
const wiki = new Set<string>();
|
||||
const sl = new Set<string>();
|
||||
const xrefs = new Set<string>();
|
||||
|
|
@ -78,20 +78,20 @@ function capturedKeys(actions: MemoryAction[]): MemoryCaptureStatus['captured']
|
|||
};
|
||||
}
|
||||
|
||||
export class MemoryCaptureService {
|
||||
export class MemoryIngestService {
|
||||
private readonly inFlight = new Map<string, Promise<void>>();
|
||||
|
||||
constructor(private readonly deps: MemoryCaptureServiceDeps) {}
|
||||
constructor(private readonly deps: MemoryIngestServiceDeps) {}
|
||||
|
||||
async capture(input: MemoryAgentInput): Promise<MemoryCaptureStartResult> {
|
||||
async ingest(input: MemoryAgentInput): Promise<MemoryIngestStartResult> {
|
||||
const row = await this.deps.runs.createRunning({
|
||||
inputHash: inputHash(input),
|
||||
chatId: input.chatId,
|
||||
});
|
||||
|
||||
await this.deps.runs.markRunning(row.id, 'capturing');
|
||||
await this.deps.runs.markRunning(row.id, 'ingesting');
|
||||
|
||||
const run = this.runCapture(row.id, input);
|
||||
const run = this.runIngest(row.id, input);
|
||||
this.inFlight.set(row.id, run);
|
||||
run.finally(() => this.inFlight.delete(row.id)).catch(() => undefined);
|
||||
|
||||
|
|
@ -102,7 +102,7 @@ export class MemoryCaptureService {
|
|||
await this.inFlight.get(runId);
|
||||
}
|
||||
|
||||
private async runCapture(runId: string, input: MemoryAgentInput): Promise<void> {
|
||||
private async runIngest(runId: string, input: MemoryAgentInput): Promise<void> {
|
||||
try {
|
||||
const outputSummary = await this.deps.memoryAgent.ingest(input);
|
||||
await this.deps.runs.markDone(runId, outputSummary);
|
||||
|
|
@ -111,7 +111,7 @@ export class MemoryCaptureService {
|
|||
}
|
||||
}
|
||||
|
||||
async status(runId: string): Promise<MemoryCaptureStatus | null> {
|
||||
async status(runId: string): Promise<MemoryIngestStatus | null> {
|
||||
const row = await this.deps.runs.findById(runId);
|
||||
if (!row) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -182,6 +182,46 @@ grain: []
|
|||
});
|
||||
});
|
||||
|
||||
it('strips authoring-only fields (usage, inherits_columns_from) before sending sources to the daemon', async () => {
|
||||
await project.fileStore.writeFile(
|
||||
'semantic-layer/warehouse/_schema/public.yaml',
|
||||
`tables:
|
||||
invoices:
|
||||
table: public.invoices
|
||||
columns:
|
||||
- name: invoice_id
|
||||
type: number
|
||||
pk: true
|
||||
- name: amount
|
||||
type: number
|
||||
usage:
|
||||
narrative: Activation policy windows table for invoice analytics.
|
||||
frequencyTier: mid
|
||||
commonFilters:
|
||||
- amount
|
||||
commonGroupBys: []
|
||||
commonJoins: []
|
||||
staleSince: null
|
||||
`,
|
||||
'ktx',
|
||||
'ktx@example.com',
|
||||
'Add manifest shard with usage',
|
||||
);
|
||||
|
||||
await compileLocalSlQuery(project, {
|
||||
connectionId: 'warehouse',
|
||||
query: { measures: ['sum(invoices.amount)'], dimensions: [] },
|
||||
compute,
|
||||
});
|
||||
|
||||
const lastCall = (compute.query as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[0];
|
||||
const invoices = lastCall?.sources.find((s: Record<string, unknown>) => s.name === 'invoices');
|
||||
expect(invoices).toBeDefined();
|
||||
expect(invoices).not.toHaveProperty('usage');
|
||||
expect(invoices).not.toHaveProperty('inherits_columns_from');
|
||||
expect(invoices).not.toHaveProperty('source_type');
|
||||
});
|
||||
|
||||
it('resolves the only configured connection when connectionId is omitted', async () => {
|
||||
await compileLocalSlQuery(project, {
|
||||
query: { measures: ['orders.order_count'], dimensions: [] },
|
||||
|
|
@ -236,6 +276,43 @@ grain: []
|
|||
});
|
||||
});
|
||||
|
||||
it('emits progress while compiling and executing a local semantic-layer query', async () => {
|
||||
const progress: Array<{ progress: number; message: string }> = [];
|
||||
const queryExecutor = {
|
||||
execute: vi.fn(async () => ({
|
||||
headers: ['status', 'order_count'],
|
||||
rows: [['paid', 2]],
|
||||
totalRows: 1,
|
||||
command: 'SELECT',
|
||||
rowCount: 1,
|
||||
})),
|
||||
};
|
||||
|
||||
const result = await compileLocalSlQuery(project, {
|
||||
connectionId: 'warehouse',
|
||||
query: {
|
||||
measures: ['orders.order_count'],
|
||||
dimensions: ['orders.status'],
|
||||
limit: 25,
|
||||
},
|
||||
compute,
|
||||
execute: true,
|
||||
maxRows: 10,
|
||||
queryExecutor,
|
||||
onProgress: (event) => {
|
||||
progress.push({ progress: event.progress, message: event.message });
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.totalRows).toBe(1);
|
||||
expect(progress).toEqual([
|
||||
{ progress: 0, message: 'Compiling query' },
|
||||
{ progress: 0.3, message: 'Generating SQL' },
|
||||
{ progress: 0.6, message: 'Executing' },
|
||||
{ progress: 1, message: 'Fetched 1 rows' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('requires a query executor for executed mode', async () => {
|
||||
await expect(
|
||||
compileLocalSlQuery(project, {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import type { KtxSqlQueryExecutorPort } from '../connections/index.js';
|
||||
import type { KtxSemanticLayerComputePort } from '../daemon/index.js';
|
||||
import type { KtxMcpProgressCallback } from '../mcp/types.js';
|
||||
import type { KtxLocalProject } from '../project/index.js';
|
||||
import { loadLocalSlSourceRecords } from './local-sl.js';
|
||||
import { toResolvedWire } from './semantic-layer.service.js';
|
||||
import type { SemanticLayerQueryExecutionResult, SemanticLayerQueryInput } from './types.js';
|
||||
|
||||
const COMPILE_ONLY_REASON =
|
||||
|
|
@ -14,6 +16,7 @@ export interface CompileLocalSlQueryOptions {
|
|||
execute?: boolean;
|
||||
maxRows?: number;
|
||||
queryExecutor?: KtxSqlQueryExecutorPort;
|
||||
onProgress?: KtxMcpProgressCallback;
|
||||
}
|
||||
|
||||
export interface CompileLocalSlQueryResult extends SemanticLayerQueryExecutionResult {
|
||||
|
|
@ -75,10 +78,10 @@ function resolveLocalConnectionId(project: KtxLocalProject, requested: string |
|
|||
async function loadComputableSources(
|
||||
project: KtxLocalProject,
|
||||
connectionId: string,
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
): Promise<ReturnType<typeof toResolvedWire>[]> {
|
||||
return (await loadLocalSlSourceRecords(project, { connectionId: assertSafeConnectionId(connectionId) }))
|
||||
.map((record) => ({ ...record.source }))
|
||||
.filter((source) => source.table || source.sql);
|
||||
.filter((record) => record.source.table || record.source.sql)
|
||||
.map((record) => toResolvedWire(record.source));
|
||||
}
|
||||
|
||||
function headersFromColumns(columns: Array<Record<string, unknown>>): string[] {
|
||||
|
|
@ -91,15 +94,20 @@ export async function compileLocalSlQuery(
|
|||
project: KtxLocalProject,
|
||||
options: CompileLocalSlQueryOptions,
|
||||
): Promise<CompileLocalSlQueryResult> {
|
||||
await options.onProgress?.({ progress: 0, message: 'Compiling query' });
|
||||
const connectionId = resolveLocalConnectionId(project, options.connectionId);
|
||||
const dialect = dialectForDriver(project.config.connections[connectionId]?.driver);
|
||||
const sources = await loadComputableSources(project, connectionId);
|
||||
|
||||
await options.onProgress?.({ progress: 0.3, message: 'Generating SQL' });
|
||||
const response = await options.compute.query({
|
||||
sources: await loadComputableSources(project, connectionId),
|
||||
sources,
|
||||
dialect,
|
||||
query: options.query,
|
||||
});
|
||||
|
||||
if (!options.execute) {
|
||||
await options.onProgress?.({ progress: 1, message: 'Fetched 0 rows' });
|
||||
return {
|
||||
connectionId,
|
||||
dialect: response.dialect,
|
||||
|
|
@ -122,6 +130,7 @@ export async function compileLocalSlQuery(
|
|||
}
|
||||
|
||||
const maxRows = options.maxRows ?? options.query.limit;
|
||||
await options.onProgress?.({ progress: 0.6, message: 'Executing' });
|
||||
const execution = await options.queryExecutor.execute({
|
||||
connectionId,
|
||||
projectDir: project.projectDir,
|
||||
|
|
@ -129,6 +138,7 @@ export async function compileLocalSlQuery(
|
|||
sql: response.sql,
|
||||
maxRows,
|
||||
});
|
||||
await options.onProgress?.({ progress: 1, message: `Fetched ${execution.totalRows} rows` });
|
||||
|
||||
return {
|
||||
connectionId,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue