chore(workspace): gate dead-code with knip production mode

Turn on production-mode knip plus an autofix run in pre-commit and the
`pnpm dead-code` script, document the `/** @internal */` convention for
test-only exports in AGENTS.md, annotate test-only exports across the
CLI with that JSDoc, and drop dead exports/wrappers the new gate
surfaced (e.g. `cli-project.ts`, `lookerRuntimeSourceToFileAdapterSource`,
`createLocalScanEnrichmentProvidersFromConfig`,
`PGLITE_OWNER_PROCESS_BACKEND_CAPABILITIES`, stale type re-exports).
Replace the loose `ignoreIssues` allowlist in `knip.json` with explicit
production entries so cross-package barrel leaks are caught.
This commit is contained in:
Andrey Avtomonov 2026-05-21 11:35:41 +02:00
parent ac3885b652
commit b690e6988b
71 changed files with 211 additions and 279 deletions

View file

@ -41,8 +41,13 @@ repos:
language: system
pass_filenames: false
- id: knip-dead-code
name: knip dead-code check
entry: pnpm exec knip --reporter compact
name: knip dead-code (auto-fix)
entry: pnpm exec knip --fix --reporter compact
language: system
pass_filenames: false
- id: knip-dead-code-production
name: knip dead-code (production mode)
entry: pnpm exec knip --production --reporter compact
language: system
pass_filenames: false

View file

@ -166,7 +166,16 @@ pnpm run test 2>&1 | tee /tmp/ktx-test-output.log
analysis. These checks are intentionally part of CI and pre-commit because the
normal development workflow is agent-based.
- Run `pnpm run dead-code` after TypeScript changes.
- `pnpm run dead-code` runs three checks: Biome (`dead-code:biome`), Knip
default-mode (`dead-code:knip`), and Knip production-mode
(`dead-code:knip:production`). All three must pass.
- Default-mode Knip catches dead code reachable from no entry at all (broken
graph). Production-mode Knip catches code reachable only via tests —
i.e. code that's tested but doesn't ship.
- Pre-commit runs `knip --fix` (auto-removes the `export` keyword from
symbols that are exported but unused) plus `knip --production` (alerts on
test-only paths). CI runs the same checks without `--fix` and fails on any
finding.
- Treat Knip findings as investigation prompts, not automatic deletion orders.
- Remove private dead code when you confirm there are no imports, dynamic
references, generated references, or tests that still need it.
@ -177,6 +186,26 @@ normal development workflow is agent-based.
- Update `knip.json` when adding dynamic entrypoints, generated files, package
exports, CLI bins, or framework files that Knip cannot infer.
#### Internal exports for testability
When a function, type, or constant must be exported solely so a unit test can
import it (i.e. it has no production cross-file consumer), annotate the
declaration with `/** @internal */` JSDoc. Knip's production-mode check
ignores `@internal` exports, so the convention keeps the gate clean without
silencing the rest of the file.
```typescript
/** @internal */
export function reindexHasErrors(result: ReindexResult): boolean { ... }
```
Do NOT use Vitest in-source testing (`if (import.meta.vitest)` blocks). Keep
tests in separate `*.test.ts(x)` files.
If the only consumer of an export is its own test and the underlying behavior
isn't used in production, delete both the export AND the test — testing dead
code is still dead code.
### CLI Standards
- Use Commander for CLI command trees, arguments, options, help text, custom

View file

@ -14,33 +14,25 @@
},
"packages/cli": {
"entry": [
"src/index.ts",
"src/bin.ts",
"src/llm/index.ts",
"src/context/index.ts",
"src/context/**/index.ts",
"src/connectors/*/index.ts",
"src/**/*.test.ts",
"src/**/*.test.tsx",
"scripts/**/*.mjs"
"src/llm/index.ts!",
"src/context/**/index.ts!",
"src/connectors/*/index.ts!",
"src/print-command-tree.ts!",
"src/**/*.test-utils.ts",
"src/**/acceptance-fixtures.ts"
],
"project": ["src/**/*.{ts,tsx}", "scripts/**/*.mjs", "vitest.config.ts"]
"project": ["src/**/*.{ts,tsx}!", "scripts/**/*.mjs", "vitest.config.ts"]
},
"docs-site": {
"entry": [
"app/**/*.{ts,tsx}",
"components/**/*.{ts,tsx}",
"lib/**/*.{ts,tsx}",
"middleware.ts",
"next.config.mjs",
"source.config.ts",
"tests/**/*.mjs"
"components/**/*.{ts,tsx}!",
"source.config.ts!"
],
"project": [
"app/**/*.{ts,tsx}",
"components/**/*.{ts,tsx}",
"lib/**/*.{ts,tsx}",
"*.ts",
"app/**/*.{ts,tsx}!",
"components/**/*.{ts,tsx}!",
"lib/**/*.{ts,tsx}!",
"*.ts!",
"*.mjs",
"tests/**/*.mjs"
],
@ -51,41 +43,7 @@
"**/dist/**",
"**/coverage/**",
"**/.next/**",
"**/node_modules/**",
"**/*.gen.ts",
"**/*.generated.ts"
"**/node_modules/**"
],
"ignoreBinaries": ["uv", "lsof"],
"ignoreIssues": {
"packages/cli/src/clack.ts": ["exports"],
"packages/cli/src/commands/connection-metabase-setup.ts": ["exports", "types"],
"packages/cli/src/ingest.test-utils.ts": ["exports"],
"packages/cli/src/io/symbols.ts": ["exports"],
"packages/cli/src/managed-python-command.ts": ["types"],
"packages/cli/src/managed-python-daemon.ts": ["types"],
"packages/cli/src/managed-python-http.ts": ["exports", "types"],
"packages/cli/src/managed-python-runtime.ts": ["types"],
"packages/cli/src/memory-flow-tui.tsx": ["types"],
"packages/cli/src/next-steps.ts": ["exports"],
"packages/cli/src/print-command-tree.ts": ["exports"],
"packages/cli/src/setup-agents.ts": ["exports", "types"],
"packages/cli/src/setup-context.ts": ["types"],
"packages/cli/src/setup-demo-tour.ts": ["exports"],
"packages/cli/src/setup-models.ts": ["exports"],
"packages/cli/src/setup-project.ts": ["types"],
"packages/cli/src/setup-ready-menu.ts": ["types"],
"packages/cli/src/setup-sources.ts": ["types"],
"packages/cli/src/context/ingest/adapters/historic-sql/pattern-inputs.ts": ["exports", "types"],
"packages/cli/src/context/ingest/adapters/lookml/pull-config.ts": ["exports"],
"packages/cli/src/context/ingest/adapters/metabase/serialize-card.ts": ["types"],
"packages/cli/src/context/ingest/adapters/metabase/types.ts": ["exports"],
"packages/cli/src/context/ingest/adapters/metricflow/parse.ts": ["types"],
"packages/cli/src/context/ingest/ports.ts": ["types"],
"packages/cli/src/context/ingest/stages/stage-3-work-units.ts": ["types"],
"packages/cli/src/context/ingest/stages/stage-index.types.ts": ["types"],
"packages/cli/src/context/project/config.ts": ["types"],
"packages/cli/src/context/scan/relationship-candidates.ts": ["types"],
"packages/cli/src/context/scan/relationship-diagnostics.ts": ["types"],
"packages/cli/src/context/tools/context-evidence-tool-store.ts": ["types"]
}
"ignoreBinaries": ["uv", "lsof"]
}

View file

@ -19,10 +19,11 @@
"artifacts:verify-manifest": "node scripts/package-artifacts.mjs verify-manifest",
"build": "pnpm --filter './packages/*' run build",
"check": "node scripts/check-boundaries.mjs && node --test scripts/*.test.mjs && pnpm --filter './packages/*' run build && pnpm --filter './packages/*' run test",
"dead-code": "pnpm run dead-code:biome && pnpm run dead-code:knip",
"dead-code": "pnpm run dead-code:biome && pnpm run dead-code:knip && pnpm run dead-code:knip:production",
"dead-code:biome": "biome ci . --formatter-enabled=false --assist-enabled=false",
"dead-code:fix": "biome check . --formatter-enabled=false --assist-enabled=false --write && knip --fix --format",
"dead-code:knip": "knip --reporter compact",
"dead-code:knip:production": "knip --production --reporter compact",
"docs": "kill $(lsof -ti:3000) 2>/dev/null; pnpm --filter ktx-docs run dev",
"ktx": "node scripts/run-ktx.mjs",
"link:dev": "node scripts/link-dev-cli.mjs",

View file

@ -55,10 +55,12 @@ function quotePlainValue(value: string): string {
return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
}
/** @internal */
export function reindexHasErrors(summary: ReindexSummary): boolean {
return summary.scopes.some((scope) => scope.error);
}
/** @internal */
export function renderReindexPlain(summary: ReindexSummary, io: KtxCliIo): void {
const updateKey = summary.force ? 'rebuilt' : 'updated';
for (const scope of summary.scopes) {
@ -88,6 +90,7 @@ export function renderReindexPlain(summary: ReindexSummary, io: KtxCliIo): void
);
}
/** @internal */
export function renderReindexJson(summary: ReindexSummary, io: KtxCliIo): void {
io.stdout.write(`${JSON.stringify({ kind: 'reindex', data: summary, meta: { command: 'admin reindex' } }, null, 2)}\n`);
}

View file

@ -26,7 +26,7 @@ export interface KtxCliPromptAdapter {
spinner(): KtxCliSpinner;
}
export class KtxCliPromptCancelledError extends Error {
class KtxCliPromptCancelledError extends Error {
constructor(message = 'Operation cancelled.') {
super(message);
this.name = 'KtxCliPromptCancelledError';

View file

@ -1,26 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { buildDefaultKtxProjectConfig, type KtxLocalProject, type KtxProjectConfig } from './context/project/index.js';
import { loadKtxCliProject } from './cli-project.js';
function projectWithConfig(config: KtxProjectConfig): KtxLocalProject {
return {
projectDir: '/work/proj',
configPath: '/work/proj/ktx.yaml',
config,
coreConfig: {} as KtxLocalProject['coreConfig'],
git: {} as KtxLocalProject['git'],
fileStore: {} as KtxLocalProject['fileStore'],
};
}
describe('loadKtxCliProject', () => {
it('delegates to loadKtxProject and returns the project unchanged', async () => {
const project = projectWithConfig(buildDefaultKtxProjectConfig());
const loadProject = vi.fn(async () => project);
const result = await loadKtxCliProject({ projectDir: '/work/proj' }, { loadProject });
expect(result).toBe(project);
expect(loadProject).toHaveBeenCalledWith({ projectDir: '/work/proj' });
});
});

View file

@ -1,20 +0,0 @@
import { loadKtxProject, type KtxLocalProject } from './context/project/index.js';
export interface LoadKtxCliProjectOptions {
projectDir: string;
}
export interface LoadKtxCliProjectDeps {
loadProject?: typeof loadKtxProject;
}
/**
* Thin wrapper around `loadKtxProject`. Kept as a single entrypoint so the CLI can grow shared
* pre-load behavior later (telemetry, project lock, etc.). Today it does no extra work.
*/
export async function loadKtxCliProject(
options: LoadKtxCliProjectOptions,
deps: LoadKtxCliProjectDeps = {},
): Promise<KtxLocalProject> {
return (deps.loadProject ?? loadKtxProject)({ projectDir: options.projectDir });
}

View file

@ -444,17 +444,20 @@ export function renderContextBuildView(
const ESC_K_RE = new RegExp(`${ESC.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\[K`, 'g');
const ANSI_RE = /\x1b\[[0-9;]*m/g;
/** @internal */
export function extractProgressMessage(chunk: string): string | null {
const cleaned = chunk.replace(/^\r/, '').replace(ESC_K_RE, '').replace(/\n$/, '').trim();
const match = cleaned.match(/^\[(\d+)%\]\s*(.+)$/);
return match ? `[${match[1]}%] ${match[2]}` : null;
}
/** @internal */
export function parseScanSummary(output: string): string | null {
const match = output.match(/(\d+) changes? across (\d+) tables?/);
return match ? `${match[2]} tables` : null;
}
/** @internal */
export function parseIngestSummary(output: string): string | null {
const savedMemory = output.match(/Saved memory: (.+)/);
if (savedMemory) return savedMemory[1];
@ -560,6 +563,7 @@ function collectSourceProgress(targets: ContextBuildTargetState[]): ContextBuild
});
}
/** @internal */
export function viewStateFromSourceProgress(
sources: ContextBuildSourceProgressUpdate[],
now: number,

View file

@ -3,6 +3,7 @@ import { join, relative } from 'node:path';
const YAML_EXT_RE = /\.(ya?ml)$/i;
/** @internal */
export function normalizeDbtPath(path: string): string {
return path.replaceAll('\\', '/');
}

View file

@ -1,13 +1,14 @@
import { Buffer } from 'node:buffer';
import type { StagedPatternsInput } from './types.js';
export const HISTORIC_SQL_PATTERN_WORKUNIT_DIR = 'patterns-input';
const HISTORIC_SQL_PATTERN_WORKUNIT_DIR = 'patterns-input';
/** @internal */
export const HISTORIC_SQL_PATTERN_WORKUNIT_MAX_BYTES = 110_000;
export const HISTORIC_SQL_PATTERN_WORKUNIT_PATH_RE = /^patterns-input\/part-\d{4}\.json$/;
const HISTORIC_SQL_PATTERN_WORKUNIT_PATH_RE = /^patterns-input\/part-\d{4}\.json$/;
type PatternTemplate = StagedPatternsInput['templates'][number];
export interface HistoricSqlPatternInputShard {
interface HistoricSqlPatternInputShard {
path: string;
input: StagedPatternsInput;
byteLength: number;
@ -27,10 +28,11 @@ export function isHistoricSqlPatternInputShardPath(path: string): boolean {
return HISTORIC_SQL_PATTERN_WORKUNIT_PATH_RE.test(path);
}
export function serializeStagedPatternsInput(input: StagedPatternsInput): string {
function serializeStagedPatternsInput(input: StagedPatternsInput): string {
return `${JSON.stringify(input, null, 2)}\n`;
}
/** @internal */
export function serializedStagedPatternsInputByteLength(input: StagedPatternsInput): number {
return Buffer.byteLength(serializeStagedPatternsInput(input), 'utf-8');
}

View file

@ -33,6 +33,7 @@ function tableSortKey(table: KtxTableRef): string {
return `${table.catalog ?? ''}\u0000${table.db ?? ''}\u0000${table.name}`;
}
/** @internal */
export function liveDatabaseTablePath(table: KtxTableRef): string {
return `${LIVE_DATABASE_TABLES_DIR}/${encodePathPart(table.catalog)}.${encodePathPart(table.db)}.${encodePathPart(
table.name,

View file

@ -1,11 +1,11 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import type { ParsedTargetTable } from '../../parsed-target-table.js';
import type { FetchContext } from '../../types.js';
import { writeLookerEvidenceDocuments } from './evidence-documents.js';
import { writeLookerFetchReport } from './fetch-report.js';
import {
type LookerPullConfig,
type ParsedTargetTable,
parseLookerPullConfig,
STAGED_FILES,
type StagedDashboardFile,

View file

@ -1,11 +1,6 @@
import type { ParsedTargetTable } from '../../parsed-target-table.js';
import type { LookerWarehouseConnectionInfo } from './client.js';
import type {
LookerPullConfig,
LookerRuntimeCursors,
ParsedTargetTable,
StagedExploreFile,
StagedLookmlModelsFile,
} from './types.js';
import type { LookerPullConfig, LookerRuntimeCursors, StagedExploreFile, StagedLookmlModelsFile } from './types.js';
export const LOOKER_DIALECT_TO_CONNECTION_TYPE = {
bigquery: 'BIGQUERY',

View file

@ -1,17 +1,5 @@
import { describe, expect, it } from 'vitest';
import { buildLookerReconcileNotes, lookerRuntimeSourceToFileAdapterSource } from './reconcile.js';
describe('lookerRuntimeSourceToFileAdapterSource', () => {
it('maps API-derived Looker source names to file-adapter source names', () => {
expect(lookerRuntimeSourceToFileAdapterSource('looker__b2b__sales_pipeline')).toBe('b2b__sales_pipeline');
expect(lookerRuntimeSourceToFileAdapterSource('looker__finance__orders')).toBe('finance__orders');
});
it('ignores non-Looker and malformed source names', () => {
expect(lookerRuntimeSourceToFileAdapterSource('b2b__sales_pipeline')).toBeNull();
expect(lookerRuntimeSourceToFileAdapterSource('looker__missing_explore')).toBeNull();
});
});
import { buildLookerReconcileNotes } from './reconcile.js';
describe('buildLookerReconcileNotes', () => {
it('instructs reconciliation to record subsumed provenance', () => {

View file

@ -1,16 +1,3 @@
export function lookerRuntimeSourceToFileAdapterSource(sourceName: string): string | null {
if (!sourceName.startsWith('looker__')) {
return null;
}
const stripped = sourceName.slice('looker__'.length);
const parts = stripped.split('__');
if (parts.length < 2 || parts.some((part) => part.length === 0)) {
return null;
}
const [model, ...exploreParts] = parts;
return `${model}__${exploreParts.join('__')}`;
}
export function buildLookerReconcileNotes(): string[] {
return [
[

View file

@ -1,7 +1,8 @@
import { tool } from 'ai';
import { z } from 'zod';
import type { ToolOutput } from '../../../../tools/index.js';
import { type ParsedTargetTable, stagedLookerQuerySchema } from '../types.js';
import type { ParsedTargetTable } from '../../../parsed-target-table.js';
import { stagedLookerQuerySchema } from '../types.js';
const lookerUsageInputSchema = z.object({
queryCount30d: z.number().int().nonnegative().default(0),

View file

@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest';
import { parsedTargetTableSchema } from '../../parsed-target-table.js';
import {
lookerPullConfigSchema,
parseLookerPullConfig,
parsedTargetTableSchema,
stagedDashboardFileSchema,
stagedExploreFileSchema,
stagedLookerFetchIssueSchema,

View file

@ -7,8 +7,6 @@ const nullableLookerIdSchema = z.union([lookerIdSchema, z.null()]).default(null)
export const lookerConnectionIdSchema = z.string().min(1).regex(/^[A-Za-z0-9_-]+$/);
export { parsedTargetTableSchema, type ParsedTargetTable } from '../../parsed-target-table.js';
export const lookerRuntimeCursorsSchema = z.object({
dashboardsLastSyncedAt: z.iso.datetime().nullable().default(null),
looksLastSyncedAt: z.iso.datetime().nullable().default(null),
@ -16,6 +14,7 @@ export const lookerRuntimeCursorsSchema = z.object({
export type LookerRuntimeCursors = z.infer<typeof lookerRuntimeCursorsSchema>;
/** @internal */
export const lookerPullConfigSchema = z.object({
lookerConnectionId: lookerConnectionIdSchema.optional(),
instanceBaseUrl: z.url().optional(),

View file

@ -4,7 +4,9 @@ import * as z from 'zod';
import type { SourceFetchReport } from '../../types.js';
import type { ParsedLookmlProject } from './parse.js';
/** @internal */
export const LOOKML_FETCH_REPORT_FILE = 'lookml-fetch-report.json';
/** @internal */
export const LOOKML_MISMATCHED_MODELS_FILE = 'lookml-mismatched-models.json';
const fetchIssueKindSchema = z.enum([

View file

@ -1,7 +1,7 @@
import * as z from 'zod';
import { parsedTargetTableSchema } from '../../parsed-target-table.js';
export const lookmlPullConfigSchema = z.object({
const lookmlPullConfigSchema = z.object({
repoUrl: z.string().url(),
branch: z.string().default('main'),
path: z.string().nullable().default(null),

View file

@ -6,6 +6,7 @@ const CARD_REF_RE = /\{\{#(\d+)\}\}/g;
* Input TemplateTag shape mirrors `MetabaseClient.getTemplateTags` output. We keep the
* shape loose only `name`, `type`, and optional `cardReference`/`default` are needed here.
*/
/** @internal */
export interface InputTemplateTag {
name: string;
type: string;
@ -13,6 +14,7 @@ export interface InputTemplateTag {
defaultValue?: string | null;
}
/** @internal */
export function extractReferencedCardIds(templateTags: InputTemplateTag[], sql: string): number[] {
const ids = new Set<number>();
for (const tag of templateTags) {
@ -34,7 +36,7 @@ export function extractReferencedCardIds(templateTags: InputTemplateTag[], sql:
* care about. The adapter reads whatever the client returns; this helper stays
* duck-typed so the client's type can evolve without churn here.
*/
export interface InputCard {
interface InputCard {
id: number;
name: string;
description?: string | null;

View file

@ -1,6 +1,6 @@
import { z } from 'zod';
export const metabaseSyncModeSchema = z.enum(['ALL', 'ONLY', 'EXCEPT']);
const metabaseSyncModeSchema = z.enum(['ALL', 'ONLY', 'EXCEPT']);
export type MetabaseSyncMode = z.infer<typeof metabaseSyncModeSchema>;
export const metabaseLocalConnectionIdSchema = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/);
@ -25,7 +25,7 @@ export function parseMetabasePullConfig(raw: unknown): MetabasePullConfig {
}
/** A Metabase column from `card.result_metadata`. Mirrors what the LLM consumes today. */
export const stagedResultColumnSchema = z.object({
const stagedResultColumnSchema = z.object({
name: z.string(),
display_name: z.string().optional().nullable(),
base_type: z.string(),
@ -37,7 +37,7 @@ export const stagedResultColumnSchema = z.object({
export type StagedResultColumn = z.infer<typeof stagedResultColumnSchema>;
export const stagedParameterSchema = z.object({
const stagedParameterSchema = z.object({
id: z.string(),
name: z.string(),
type: z.string(),
@ -49,7 +49,7 @@ export const stagedParameterSchema = z.object({
export type StagedParameter = z.infer<typeof stagedParameterSchema>;
/** A template tag pulled from an MBQL card's `dataset_query.stages[0].template-tags`. */
export const stagedTemplateTagSchema = z.object({
const stagedTemplateTagSchema = z.object({
name: z.string(),
type: z.string(),
defaultValue: z.string().optional().nullable(),
@ -87,7 +87,7 @@ export const stagedCardFileSchema = z.object({
export type StagedCardFile = z.infer<typeof stagedCardFileSchema>;
/** A serialized collection file, `collections/<id>.json`. Minimal — path lives on the card. */
export const stagedCollectionFileSchema = z.object({
const stagedCollectionFileSchema = z.object({
metabaseId: z.union([z.number().int(), z.literal('root')]),
name: z.string(),
parentId: z.union([z.number().int(), z.literal('root')]).nullable(),
@ -96,7 +96,7 @@ export const stagedCollectionFileSchema = z.object({
export type StagedCollectionFile = z.infer<typeof stagedCollectionFileSchema>;
/** A serialized database-mapping snapshot, `databases/<id>.json`. */
export const stagedDatabaseFileSchema = z.object({
const stagedDatabaseFileSchema = z.object({
metabaseDatabaseId: z.number().int().positive(),
metabaseDatabaseName: z.string(),
metabaseEngine: z.string().nullable(),

View file

@ -2,7 +2,7 @@ import { readdir, readFile } from 'node:fs/promises';
import { join, relative } from 'node:path';
import { parse as parseYaml } from 'yaml';
export interface ParsedMetricFlowSemanticModel {
interface ParsedMetricFlowSemanticModel {
/** Path relative to stagedDir, e.g. "models/orders.yml". */
path: string;
/** `name:` on the semantic_model. */
@ -24,9 +24,9 @@ export interface ParsedMetricFlowSemanticModel {
defaultTimeDimension: string | null;
}
export type MetricFlowMetricType = 'simple' | 'derived' | 'cumulative' | 'ratio' | 'conversion';
type MetricFlowMetricType = 'simple' | 'derived' | 'cumulative' | 'ratio' | 'conversion';
export interface ParsedMetricFlowMetric {
interface ParsedMetricFlowMetric {
path: string;
name: string;
type: MetricFlowMetricType;

View file

@ -5,6 +5,7 @@ import { kmeans, pickK } from '../../clustering/kmeans.js';
import type { WorkUnit } from '../../types.js';
import { notionMetadataSchema } from './types.js';
/** @internal */
export const MIN_PAGES_TO_CLUSTER = 5;
const CLUSTER_TEXT_BODY_CHARS = 1024;
const CLUSTER_SEED = 42;

View file

@ -14,6 +14,7 @@ function richTextToMarkdown(value: unknown): string {
.trim();
}
/** @internal */
export function propertyValueToText(value: unknown): string {
if (!value || typeof value !== 'object' || !('type' in value)) {
return '';

View file

@ -89,6 +89,7 @@ function shouldRetryNotionError(error: unknown): boolean {
return code === 'rate_limited' || transientErrorCodes.has(code ?? '') || transientStatusCodes.has(status ?? 0);
}
/** @internal */
export async function retryNotionRequest<T>(operation: () => Promise<T>, options: RetryOptions = {}): Promise<T> {
const maxAttempts = options.maxAttempts ?? 4;
const sleep = options.sleep ?? defaultSleep;

View file

@ -2,7 +2,7 @@ import type { KtxModelRole } from '../../llm/index.js';
import type { KtxEmbeddingPort } from '../core/embedding.js';
import type { GitService, KtxFileStorePort, KtxLogger, SessionOutcome } from '../core/index.js';
import type { AgentRunnerPort, KtxLlmRuntimePort, KtxRuntimeToolSet } from '../llm/index.js';
import type { CaptureSession, MemoryAction, MemoryKnowledgeSlRefsPort } from '../memory/index.js';
import type { MemoryAction, MemoryKnowledgeSlRefsPort } from '../memory/index.js';
import type { PromptService } from '../prompts/index.js';
import type { SkillsRegistryService } from '../skills/index.js';
import type {
@ -34,7 +34,7 @@ import type {
SourceAdapter,
} from './types.js';
export type JsonPrimitive = string | number | boolean | null;
type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue | undefined };
export interface IngestRunRecord {
@ -359,9 +359,4 @@ export interface IngestBundleRunnerDeps {
logger?: KtxLogger;
}
export interface IngestCaptureState {
session: CaptureSession;
actions: MemoryAction[];
}
export type IngestRunnerJob = IngestBundleJob;

View file

@ -3,6 +3,7 @@ export interface SemanticLayerTargetPolicyInput {
allowedConnectionIds: ReadonlySet<string>;
}
/** @internal */
export interface SemanticLayerTargetPolicyViolation {
path: string;
connectionId: string;

View file

@ -6,7 +6,7 @@ import type { WorkUnit } from '../types.js';
const MAX_WORK_UNIT_PROMPT_CHARS = 240_000;
export interface TouchedValidationResult {
interface TouchedValidationResult {
invalidSources: string[];
validSources: string[];
}

View file

@ -1,7 +1,7 @@
import type { MemoryAction } from '../../memory/index.js';
import type { TouchedSlSource } from '../../tools/index.js';
export interface StageIndexWorkUnit {
interface StageIndexWorkUnit {
unitKey: string;
rawFiles: string[];
status: 'success' | 'failed';

View file

@ -141,6 +141,7 @@ export interface KtxSqlExecutionResponse {
rowCount: number;
}
/** @internal */
export interface KtxSqlExecutionMcpPort {
execute(
input: { connectionId: string; sql: string; maxRows: number },

View file

@ -256,14 +256,10 @@ const ktxProjectConfigSchema = z
export type KtxProjectConfig = z.infer<typeof ktxProjectConfigSchema>;
export type KtxProjectLlmConfig = z.infer<typeof llmSchema>;
export type KtxProjectLlmProviderConfig = z.infer<typeof llmProviderSchema>;
export type KtxProjectEmbeddingConfig = z.infer<typeof embeddingSchema>;
export type KtxScanEnrichmentConfig = z.infer<typeof scanEnrichmentSchema>;
export type KtxIngestWorkUnitsConfig = z.infer<typeof workUnitsSchema>;
export type KtxScanRelationshipConfig = z.infer<typeof scanRelationshipsSchema>;
export type KtxProjectScanConfig = z.infer<typeof scanSchema>;
export type KtxProjectConnectionConfig = z.infer<typeof connectionSchema>;
export type KtxProjectSetupConfig = z.infer<typeof setupSchema>;
export type KtxStorageState = z.infer<typeof storageSchema>['state'];
export type KtxSearchBackend = z.infer<typeof storageSchema>['search'];

View file

@ -12,7 +12,6 @@ import {
runLocalScanEnrichment,
snapshotToKtxEnrichedSchema,
} from './local-enrichment.js';
import { createLocalScanEnrichmentProvidersFromConfig } from './local-scan.js';
import {
createKtxConnectorCapabilities,
type KtxQueryResult,
@ -813,46 +812,4 @@ describe('local scan enrichment', () => {
}
});
it('resolves gateway LLM providers and passes injected embedding provider through to scan enrichment', () => {
const createKtxLlmProvider = vi.fn(() => ({
getModel: vi.fn().mockReturnValue({ modelId: 'provider/language-model', provider: 'gateway' }),
}));
const embeddingProvider = {
dimensions: 1536,
maxBatchSize: 8,
embed: vi.fn(),
[['embed', 'Many'].join('')]: vi.fn(),
};
const providers = createLocalScanEnrichmentProvidersFromConfig(
{
mode: 'llm',
embeddings: {
backend: 'openai',
model: 'provider/embedding-model',
dimensions: 1536,
batchSize: 8,
openai: { api_key: 'env:OPENAI_API_KEY' }, // pragma: allowlist secret
},
},
{
provider: {
backend: 'gateway',
gateway: {},
},
models: { default: 'provider/language-model' },
},
{
createKtxLlmProvider: createKtxLlmProvider as any,
env: { OPENAI_API_KEY: 'openai-key' }, // pragma: allowlist secret
embeddingProvider: embeddingProvider as any,
},
);
expect(providers?.embedding?.dimensions).toBe(1536);
expect(providers?.embedding?.maxBatchSize).toBe(8);
expect(createKtxLlmProvider).toHaveBeenCalledWith(
expect.objectContaining({ backend: 'gateway', modelSlots: { default: 'provider/language-model' } }),
);
});
});

View file

@ -226,15 +226,6 @@ function resolveLocalScanEnrichmentProviders(
};
}
export function createLocalScanEnrichmentProvidersFromConfig(
config: KtxScanEnrichmentConfig,
llmConfig: KtxProjectLlmConfig,
deps: LocalScanEnrichmentProviderDeps = {},
): KtxLocalScanEnrichmentProviders | null {
const resolved = resolveLocalScanEnrichmentProviders(config, llmConfig, deps);
return resolved.status === 'ready' ? resolved.providers : null;
}
function createLocalScanEnrichmentStateStore(options: RunLocalScanOptions): SqliteLocalScanEnrichmentStateStore | null {
if (options.dryRun) {
return null;

View file

@ -12,7 +12,7 @@ import {
pluralizeKtxRelationshipToken,
singularizeKtxRelationshipToken,
} from './relationship-name-similarity.js';
export type { KtxRelationshipNormalizedName } from './relationship-name-similarity.js';
;
export { normalizeKtxRelationshipName } from './relationship-name-similarity.js';
import type { KtxRelationshipProfileArtifact } from './relationship-profiling.js';
import {

View file

@ -66,7 +66,7 @@ export interface KtxRelationshipDiagnosticsThresholds {
reviewThreshold: number;
}
export interface KtxRelationshipDiagnosticsPolicy {
interface KtxRelationshipDiagnosticsPolicy {
validationRequiredForManifest: boolean;
maxCandidatesPerColumn: number;
profileSampleRows: number;

View file

@ -4,8 +4,8 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Client } from 'pg';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { assertSearchBackendCapabilities, assertSearchBackendConformanceCase } from './index.js';
import { KtxPGliteOwnerProcess, PGLITE_OWNER_PROCESS_BACKEND_CAPABILITIES } from './pglite-owner-process.js';
import { assertSearchBackendConformanceCase } from './index.js';
import { KtxPGliteOwnerProcess } from './pglite-owner-process.js';
async function allocatePort(): Promise<number> {
const server = createServer();
@ -107,20 +107,6 @@ describe('KtxPGliteOwnerProcess', () => {
await rm(tempDir, { recursive: true, force: true });
});
it('declares the advanced PGlite search capabilities observed by the spike', () => {
assertSearchBackendCapabilities({
backendName: 'pglite-owner-process',
capabilities: PGLITE_OWNER_PROCESS_BACKEND_CAPABILITIES,
expected: {
fts: true,
vector: true,
fuzzy: true,
jsonSearch: true,
arraySearch: false,
},
});
});
it('starts a socket owner process and serves PostgreSQL clients', async () => {
const owner = await KtxPGliteOwnerProcess.start({
dataDir,

View file

@ -3,15 +3,6 @@ import { pg_trgm } from '@electric-sql/pglite/contrib/pg_trgm';
import { vector } from '@electric-sql/pglite/vector';
import { PGLiteSocketServer } from '@electric-sql/pglite-socket';
import { Client, type ClientConfig, type QueryResult, type QueryResultRow } from 'pg';
import type { SearchBackendCapabilities } from './types.js';
export const PGLITE_OWNER_PROCESS_BACKEND_CAPABILITIES = {
fts: true,
vector: true,
fuzzy: true,
jsonSearch: true,
arraySearch: false,
} satisfies SearchBackendCapabilities;
export interface KtxPGliteOwnerProcessOptions {
dataDir: string;

View file

@ -25,8 +25,11 @@ export interface LoadAllSourcesResult {
loadErrors: string[];
}
/** @internal */
export class UnknownColumnOverrideError extends Error {}
/** @internal */
export class ColumnNameCollisionError extends Error {}
/** @internal */
export class ConflictingExcludeAndOverrideError extends Error {}
class ComposeContractError extends Error {}

View file

@ -13,7 +13,7 @@ export interface ContextEvidenceSearchArgs {
export type ContextEvidenceSearchMatchReason = 'lexical' | 'semantic' | 'token' | (string & {});
export interface ContextEvidenceSearchLaneSummary {
interface ContextEvidenceSearchLaneSummary {
lane: string;
status: 'available' | 'skipped' | 'failed';
requestedCandidatePoolLimit: number;

View file

@ -23,6 +23,7 @@ interface WikiSearchStructured {
totalFound: number;
}
/** @internal */
export interface WikiSearchAdapterPort {
search(input: { userId: string; query: string; limit: number }): Promise<{
results: Array<{

View file

@ -17,8 +17,11 @@ interface EnsureDemoProjectOptions {
force: boolean;
}
/** @internal */
export const DEMO_CONNECTION_ID = 'orbit_demo';
/** @internal */
export const DEMO_ADAPTER = 'live-database';
/** @internal */
export const DEMO_REPLAY_FILE = 'replay.memory-flow.v1.json';
const REQUIRED_PACKAGED_BASE_ASSET_PATHS = ['demo.db', 'manifest.json', DEMO_REPLAY_FILE] as const;
@ -115,6 +118,7 @@ async function assertPackagedSeededAssetsPresent(): Promise<void> {
}
}
/** @internal */
export async function ensureDemoProject(options: EnsureDemoProjectOptions): Promise<DemoProjectResult> {
const projectDir = resolve(options.projectDir);
const configPath = join(projectDir, 'ktx.yaml');

View file

@ -140,6 +140,7 @@ export function formatDuration(ms: number): string {
return `${hr}h${(min % 60).toString().padStart(2, '0')}m`;
}
/** @internal */
export function formatEta(ms: number | null, status: MemoryFlowReplayInput['status']): string {
if (status !== 'running') return 'done';
if (ms === null) return 'estimating...';
@ -153,6 +154,7 @@ export function formatCost(usd: number): string {
return `$${usd.toFixed(2)}`;
}
/** @internal */
export function formatTokens(n: number): string {
if (!Number.isFinite(n) || n <= 0) return '0';
if (n < 1000) return `${Math.round(n)}`;
@ -160,6 +162,7 @@ export function formatTokens(n: number): string {
return `${(n / 1_000_000).toFixed(2)}M`;
}
/** @internal */
export function formatTokensPerSec(n: number): string {
if (!Number.isFinite(n) || n <= 0) return '0/s';
if (n < 1000) return `${Math.round(n)}/s`;
@ -167,6 +170,7 @@ export function formatTokensPerSec(n: number): string {
}
const PROGRESS_BAR_WIDTH = 12;
/** @internal */
export function progressBar(ratio: number, width: number = PROGRESS_BAR_WIDTH): string {
const clamped = Math.max(0, Math.min(1, ratio));
const filled = Math.round(clamped * width);

View file

@ -14,6 +14,7 @@ function writeLine(io: KtxCliIo, message: string): void {
io.stderr.write(message.endsWith('\n') ? message : `${message}\n`);
}
/** @internal */
export function createNoopOperationalLogger(): KtxOperationalLogger {
return {
log: () => undefined,

View file

@ -17,6 +17,7 @@ export interface KtxMcpDaemonState {
logPath: string;
}
/** @internal */
export interface KtxMcpDaemonChild {
pid?: number;
unref(): void;

View file

@ -33,7 +33,7 @@ export interface ManagedPythonCommandRuntime {
manifest: InstalledKtxRuntimeManifest;
}
export interface ManagedPythonCommandDeps {
interface ManagedPythonCommandDeps {
readStatus?: (options: ManagedPythonRuntimeLayoutOptions) => Promise<ManagedPythonRuntimeStatus>;
installRuntime?: (options: ManagedPythonRuntimeInstallOptions) => Promise<ManagedPythonRuntimeInstallResult>;
confirmInstall?: (message: string, io: KtxCliIo) => Promise<boolean>;
@ -51,6 +51,7 @@ export interface ManagedPythonSemanticLayerComputeOptions extends ManagedPythonC
createPythonCompute?: typeof createPythonSemanticLayerComputePort;
}
/** @internal */
export function managedRuntimeInstallCommand(feature: KtxRuntimeFeature): string {
return feature === 'local-embeddings'
? 'ktx admin runtime install --feature local-embeddings --yes'

View file

@ -51,7 +51,7 @@ export interface ManagedPythonDaemonProcessInfo {
command: string;
}
export type ManagedPythonDaemonStopAllSource = 'state' | 'process';
type ManagedPythonDaemonStopAllSource = 'state' | 'process';
export interface ManagedPythonDaemonStopAllEntry {
pid: number;
@ -74,11 +74,13 @@ export interface ManagedPythonDaemonStopAllResult {
scanErrors: string[];
}
/** @internal */
export interface ManagedPythonDaemonChild {
pid?: number;
unref(): void;
}
/** @internal */
export type ManagedPythonDaemonSpawn = (
command: string,
args: string[],
@ -89,6 +91,7 @@ export type ManagedPythonDaemonSpawn = (
},
) => ManagedPythonDaemonChild;
/** @internal */
export type ManagedPythonDaemonFetch = (
url: string,
) => Promise<{
@ -98,7 +101,7 @@ export type ManagedPythonDaemonFetch = (
text(): Promise<string>;
}>;
export type ManagedPythonDaemonKillProcess = (pid: number, signal?: NodeJS.Signals) => void;
type ManagedPythonDaemonKillProcess = (pid: number, signal?: NodeJS.Signals) => void;
export interface ManagedPythonDaemonStartOptions extends ManagedPythonDaemonLayoutOptions {
features: KtxRuntimeFeature[];

View file

@ -21,12 +21,13 @@ import {
} from './managed-python-command.js';
import { startManagedPythonDaemon, type ManagedPythonDaemonStartResult } from './managed-python-daemon.js';
/** @internal */
export type ManagedPythonHttpJsonRunner = (
path: string,
payload: Record<string, unknown>,
) => Promise<Record<string, unknown>>;
export type ManagedPythonHttpPostJson = (
type ManagedPythonHttpPostJson = (
baseUrl: string,
path: string,
payload: Record<string, unknown>,
@ -75,7 +76,7 @@ function parseJsonObject(raw: string, path: string): Record<string, unknown> {
return parsed as Record<string, unknown>;
}
export async function postManagedDaemonJson(
async function postManagedDaemonJson(
baseUrl: string,
path: string,
payload: Record<string, unknown>,
@ -117,6 +118,7 @@ export async function postManagedDaemonJson(
});
}
/** @internal */
export function createManagedPythonDaemonBaseUrlResolver(
options: ManagedPythonCoreDaemonOptions,
): () => Promise<string> {
@ -158,6 +160,7 @@ function isResolveBaseUrlOnly(
return 'resolveBaseUrl' in options;
}
/** @internal */
export function createManagedDaemonHttpJsonRunner(
options: ManagedPythonDaemonHttpOptions,
): ManagedPythonHttpJsonRunner {

View file

@ -25,7 +25,7 @@ const runtimeAssetManifestSchema = z.object({
}),
});
export type KtxRuntimeAssetManifest = z.infer<typeof runtimeAssetManifestSchema>;
type KtxRuntimeAssetManifest = z.infer<typeof runtimeAssetManifestSchema>;
const installedRuntimeManifestSchema = z.object({
schemaVersion: z.literal(1),
@ -76,6 +76,7 @@ export interface ManagedPythonDaemonLayout extends ManagedPythonRuntimeLayout {
daemonStderrPath: string;
}
/** @internal */
export interface ManagedRuntimeAsset {
manifest: KtxRuntimeAssetManifest;
wheelPath: string;
@ -104,7 +105,7 @@ export interface ManagedPythonRuntimeInstallResult {
manifest: InstalledKtxRuntimeManifest;
}
export type ManagedPythonRuntimeStatusKind = 'missing' | 'ready' | 'mismatched' | 'broken';
type ManagedPythonRuntimeStatusKind = 'missing' | 'ready' | 'mismatched' | 'broken';
export interface ManagedPythonRuntimeStatus {
kind: ManagedPythonRuntimeStatusKind;
@ -121,6 +122,7 @@ export interface ManagedPythonRuntimeDoctorCheck {
fix?: string;
}
/** @internal */
export const MISSING_UV_RUNTIME_INSTALL_MESSAGE =
'uv is required to install the KTX Python runtime. KTX does not download uv automatically. Install uv, make sure it is on PATH, and retry: ktx admin runtime install --yes';
@ -142,6 +144,7 @@ function executablePath(venvDir: string, platform: NodeJS.Platform, name: string
return join(venvDir, 'bin', name);
}
/** @internal */
export function managedPythonRuntimeLayout(options: ManagedPythonRuntimeLayoutOptions): ManagedPythonRuntimeLayout {
const platform = options.platform ?? process.platform;
const env = options.env ?? process.env;
@ -235,6 +238,7 @@ function parseRequiresPythonFromWheel(input: { wheelPath: string; contents: Buff
};
}
/** @internal */
export async function verifyRuntimeAsset(input: { assetDir: string }): Promise<ManagedRuntimeAsset> {
const manifestPath = join(input.assetDir, 'manifest.json');
let manifestData: unknown;

View file

@ -25,6 +25,7 @@ export interface McpSecurityConfig {
allowedOrigins: string[];
}
/** @internal */
export type McpAuthorizationResult =
| { ok: true }
| { ok: false; status: 401 | 403; message: string };
@ -34,6 +35,7 @@ function isLoopbackHost(host: string): boolean {
return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1';
}
/** @internal */
export function normalizeHostHeader(value: string): string {
const trimmed = value.trim().toLowerCase();
if (trimmed.startsWith('[')) {
@ -85,6 +87,7 @@ function headerValue(headers: IncomingHttpHeaders | Record<string, string | unde
return Array.isArray(value) ? value[0] : value;
}
/** @internal */
export function isMcpRequestAuthorized(
request: { path: string; headers: IncomingHttpHeaders | Record<string, string | undefined> },
config: McpSecurityConfig,

View file

@ -42,6 +42,7 @@ function defaultPrepareKeypressEvents(stdin: KtxMemoryFlowStdin): void {
emitKeypressEvents(stdin as Parameters<typeof emitKeypressEvents>[0]);
}
/** @internal */
export function memoryFlowCommandForKey(
chunk: string,
search: MemoryFlowInteractionState['search'],

View file

@ -66,6 +66,7 @@ export interface MemoryFlowTuiLiveSession {
isClosed(): boolean;
}
/** @internal */
export interface MemoryFlowInkInstance {
rerender(tree: ReactNode): void;
unmount(): void;
@ -73,7 +74,7 @@ export interface MemoryFlowInkInstance {
clear?(): void;
}
export interface MemoryFlowInkRenderOptions {
interface MemoryFlowInkRenderOptions {
stdin?: KtxMemoryFlowTuiIo['stdin'];
stdout: KtxMemoryFlowTuiIo['stdout'];
stderr: KtxMemoryFlowTuiIo['stderr'];
@ -157,6 +158,7 @@ export function sanitizeMemoryFlowTuiError(error: unknown): string {
.replace(/\b(api[_-]?key|password|token|secret)=\S+/gi, '[redacted]');
}
/** @internal */
export function memoryFlowCommandForInkInput(
input: string,
key: InkKey,
@ -285,6 +287,7 @@ function TrustIssues(props: { view: MemoryFlowViewModel; theme: MemoryFlowTuiThe
);
}
/** @internal */
export function MemoryFlowTuiApp(props: MemoryFlowTuiAppProps): ReactNode {
const app = useApp();
const totalEvents = props.input.events.length;

View file

@ -1,3 +1,4 @@
/** @internal */
export const KTX_CONTEXT_BUILD_COMMANDS = [
{
command: 'ktx ingest',
@ -24,9 +25,10 @@ export const KTX_NEXT_STEP_DIRECT_COMMANDS = [
},
] as const;
/** @internal */
export const KTX_NEXT_STEP_COMMANDS = [...KTX_NEXT_STEP_DIRECT_COMMANDS] as const;
export const KTX_NEXT_STEP_COMMAND_WIDTH = Math.max(
const KTX_NEXT_STEP_COMMAND_WIDTH = Math.max(
...[...KTX_CONTEXT_BUILD_COMMANDS, ...KTX_NEXT_STEP_COMMANDS].map((step) => step.command.length),
);

View file

@ -24,6 +24,7 @@ export interface PickNotionRootPagesArgs {
connection: KtxProjectConnectionConfig;
}
/** @internal */
export type NotionPickerApi = Pick<NotionApi, 'search' | 'retrieveBotUser'>;
export type NotionRootPagePickResult =
| { kind: 'selected'; rootPageIds: string[] }
@ -50,6 +51,7 @@ function assertSafeNotionPickerConnectionId(connectionId: string): void {
}
}
/** @internal */
export function normalizeNotionPageId(value: string): string {
const trimmed = value.trim();
const compact = trimmed.includes('-') ? trimmed.replace(/-/g, '') : trimmed;
@ -106,6 +108,7 @@ function extractParentPageId(page: Record<string, unknown>): string | null {
return normalizeNotionPageId(parent.page_id);
}
/** @internal */
export function notionPickerPageFromSearchResult(result: Record<string, unknown>): TreePickerNodeInput {
const id = typeof result.id === 'string' ? normalizeNotionPageId(result.id) : '';
if (!id) {
@ -119,6 +122,7 @@ export function notionPickerPageFromSearchResult(result: Record<string, unknown>
};
}
/** @internal */
export async function discoverNotionPickerPages(
api: NotionPickerApi,
options: { cap?: number } = {},
@ -161,6 +165,7 @@ export async function discoverNotionPickerPages(
return { pages, cappedAtCount: cap, warnings };
}
/** @internal */
export async function resolveNotionWorkspaceLabel(api: NotionPickerApi, connectionId: string): Promise<string> {
try {
const bot = (await api.retrieveBotUser()) as NotionBotInfo;

View file

@ -38,6 +38,7 @@ function withTextInputBodySpacing(message: string): string {
return `${title}\n\n${bodyLines.join('\n')}`;
}
/** @internal */
export function withMenuOptionSpacing(message: string): string {
if (!message.includes('\n') || message.endsWith('\n')) {
return message;

View file

@ -274,6 +274,7 @@ interface KtxCliScanProgress extends Omit<KtxProgressPort, 'update'> {
flush(): void;
}
/** @internal */
export function createCliScanProgress(
io: KtxCliIo,
state: KtxCliScanProgressState = { progress: 0, hasPendingTransient: false },

View file

@ -21,6 +21,7 @@ import { readKtxMcpDaemonStatus } from './managed-mcp-daemon.js';
export type KtxAgentTarget = 'claude-code' | 'claude-desktop' | 'codex' | 'cursor' | 'opencode' | 'universal';
export type KtxAgentScope = 'project' | 'global' | 'local';
/** @internal */
export type KtxAgentInstallMode = 'mcp' | 'mcp-cli';
export interface KtxSetupAgentsArgs {
@ -118,6 +119,7 @@ function writeSetupOutro(io: KtxCliIo, message: string): void {
const STEP_HEADING_RE = /^(\d+)\. (.+)$/;
const ACTION_MARKER_RE = /^(RUN|PASTE|USE|OPEN):$/;
/** @internal */
export function createAgentNextActionsLineFormatter(
stdout: KtxCliIo['stdout'],
): (line: string) => string {
@ -301,7 +303,7 @@ function claudeDesktopConfigPath(): { path: string; jsonPath: string[] } {
const CLAUDE_DESKTOP_FORWARDED_ENV_KEYS = ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY'] as const;
export function collectClaudeDesktopForwardedEnv(source: NodeJS.ProcessEnv): Record<string, string> {
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;
@ -394,7 +396,7 @@ function plannedMcpJsonEntries(input: {
return [];
}
export function agentInstallManifestPath(projectDir: string): string {
function agentInstallManifestPath(projectDir: string): string {
return join(resolve(projectDir), '.ktx/agents/install-manifest.json');
}
@ -410,6 +412,7 @@ function claudeDesktopLauncherPath(projectDir: string): string {
return join(resolve(projectDir), '.ktx/agents/claude/ktx-plugin-runner.sh');
}
/** @internal */
export function plannedKtxAgentFiles(input: {
projectDir: string;
target: KtxAgentTarget;
@ -750,6 +753,7 @@ function mergeManifest(
};
}
/** @internal */
export async function removeKtxAgentInstall(projectDir: string, io: KtxCliIo): Promise<number> {
const manifest = await readKtxAgentInstallManifest(projectDir);
if (!manifest) {
@ -765,7 +769,7 @@ export async function removeKtxAgentInstall(projectDir: string, io: KtxCliIo): P
return 0;
}
export interface KtxSetupAgentsPromptAdapter {
interface KtxSetupAgentsPromptAdapter {
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
multiselect(options: {
message: string;
@ -853,6 +857,7 @@ function hasAdminCliEntries(entries: InstallEntry[]): boolean {
);
}
/** @internal */
export interface InstallSummaryEntry {
title: string;
lines: string[];
@ -869,6 +874,7 @@ function formatInlinePath(path: string): string {
return path;
}
/** @internal */
export function formatInstallSummaryLines(
installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>,
entries: InstallEntry[],

View file

@ -25,12 +25,13 @@ import {
type KtxSetupPromptOption,
} from './setup-prompts.js';
export type KtxSetupContextBuildStatus =
type KtxSetupContextBuildStatus =
| 'not_started'
| 'completed'
| 'failed'
| 'stale';
/** @internal */
export interface KtxSetupContextCommands {
build: string;
status: string;
@ -61,7 +62,7 @@ export interface KtxSetupContextStatusSummary {
detail?: string;
}
export interface KtxSetupContextReadiness {
interface KtxSetupContextReadiness {
ready: boolean;
agentContextReady: boolean;
semanticSearchReady: boolean;
@ -86,7 +87,7 @@ export interface KtxSetupContextStepArgs {
runtimeInstallPolicy?: KtxManagedPythonInstallPolicy;
}
export interface KtxSetupContextPromptAdapter {
interface KtxSetupContextPromptAdapter {
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
cancel(message: string): void;
}
@ -125,6 +126,7 @@ async function pathExists(path: string): Promise<boolean> {
}
}
/** @internal */
export function contextBuildCommands(projectDir: string): KtxSetupContextCommands {
const resolvedProjectDir = resolve(projectDir);
return {
@ -236,6 +238,7 @@ export async function readKtxSetupContextState(projectDir: string): Promise<KtxS
return normalizeState(projectDir, JSON.parse(await readFile(filePath, 'utf-8')) as unknown);
}
/** @internal */
export async function writeKtxSetupContextState(projectDir: string, state: KtxSetupContextState): Promise<void> {
const resolvedProjectDir = resolve(projectDir);
await mkdir(join(resolvedProjectDir, '.ktx', 'setup'), { recursive: true });

View file

@ -64,6 +64,7 @@ export type KtxSetupDatabasesResult =
| { status: 'missing-input'; projectDir: string }
| { status: 'failed'; projectDir: string };
/** @internal */
export interface KtxSetupDatabasesPromptAdapter {
multiselect(options: {
message: string;

View file

@ -64,6 +64,7 @@ function createTargetState(target: KtxPublicIngestPlanTarget): ContextBuildTarge
// Pure rendering functions
// ---------------------------------------------------------------------------
/** @internal */
export function renderDemoBanner(projectDir?: string): string {
const lines = [
'',
@ -76,6 +77,7 @@ export function renderDemoBanner(projectDir?: string): string {
return lines.join('\n');
}
/** @internal */
export function renderDemoCardContent(title: string, selections: string[]): string {
const lines = [
`${title}`,
@ -88,6 +90,7 @@ export function renderDemoCardContent(title: string, selections: string[]): stri
return lines.join('\n');
}
/** @internal */
export function renderDemoAgentTransition(): string {
const lines = [
'┌ Demo project is ready — let\'s connect your agent',
@ -99,6 +102,7 @@ export function renderDemoAgentTransition(): string {
return lines.join('\n');
}
/** @internal */
export function renderDemoCompletionSummary(projectDir: string, agentInstalled: boolean): string {
const lines: string[] = [
'',
@ -129,7 +133,7 @@ export function renderDemoCompletionSummary(projectDir: string, agentInstalled:
// Keypress navigation
// ---------------------------------------------------------------------------
export async function waitForDemoNavigation(
async function waitForDemoNavigation(
stdin?: NodeJS.ReadStream,
): Promise<'forward' | 'back'> {
const input = stdin ?? process.stdin;
@ -169,7 +173,7 @@ export async function waitForDemoNavigation(
// Interactive card
// ---------------------------------------------------------------------------
export async function renderDemoCard(
async function renderDemoCard(
title: string,
selections: string[],
io: KtxCliIo,
@ -186,6 +190,7 @@ export async function renderDemoCard(
// Context build replay
// ---------------------------------------------------------------------------
/** @internal */
export interface DemoReplayEvent {
delayMs: number;
connectionId: string;
@ -194,6 +199,7 @@ export interface DemoReplayEvent {
summaryText: string | null;
}
/** @internal */
export const DEMO_REPLAY_TARGETS = {
primarySources: [
createDemoTarget('postgres-warehouse', 'database-ingest', 'postgres'),
@ -205,6 +211,7 @@ export const DEMO_REPLAY_TARGETS = {
],
} as const;
/** @internal */
export function buildDemoReplayTimeline(): DemoReplayEvent[] {
return [
// postgres-warehouse: database schema context
@ -239,7 +246,7 @@ function renderDemoContextCompletionSummary(): string {
return lines.join('\n');
}
export async function runDemoContextReplay(
async function runDemoContextReplay(
io: KtxCliIo,
stdin?: NodeJS.ReadStream,
): Promise<'forward' | 'back'> {

View file

@ -46,6 +46,7 @@ export type KtxSetupEmbeddingsResult =
| { status: 'missing-input'; projectDir: string }
| { status: 'failed'; projectDir: string };
/** @internal */
export interface KtxSetupEmbeddingsPromptAdapter {
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
password(options: { message: string }): Promise<string | undefined>;

View file

@ -9,6 +9,7 @@ export class KtxSetupExitError extends Error {
}
}
/** @internal */
export interface SetupInterruptTracker {
track<T>(run: () => Promise<T>): Promise<T>;
wasCtrlC(): boolean;

View file

@ -51,6 +51,7 @@ export type KtxSetupModelResult =
| { status: 'missing-input'; projectDir: string }
| { status: 'failed'; projectDir: string };
/** @internal */
export interface AnthropicModelChoice {
id: string;
label: string;
@ -59,6 +60,7 @@ export interface AnthropicModelChoice {
export type KtxSetupLlmBackend = 'anthropic' | 'vertex' | 'claude-code';
/** @internal */
export interface KtxSetupModelPromptAdapter {
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
text(options: { message: string; placeholder?: string }): Promise<string | undefined>;
@ -82,8 +84,7 @@ export interface KtxSetupModelDeps {
spinner?: () => KtxCliSpinner;
}
export const BUNDLED_ANTHROPIC_MODEL_REGISTRY_VERSION = '2026-05-07';
/** @internal */
export const BUNDLED_ANTHROPIC_MODELS: AnthropicModelChoice[] = [
{ id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6', recommended: true },
{ id: 'claude-opus-4-6', label: 'Claude Opus 4.6', recommended: false },
@ -131,7 +132,7 @@ const execFileAsync = promisify(execFile);
type AnthropicModelDiscoveryErrorReason = 'authentication' | 'http' | 'empty-response';
export class AnthropicModelDiscoveryError extends Error {
class AnthropicModelDiscoveryError extends Error {
constructor(
message: string,
public readonly reason: AnthropicModelDiscoveryErrorReason,
@ -212,6 +213,7 @@ async function defaultListGcloudProjects(): Promise<GcloudProjectChoice[]> {
.filter((project): project is GcloudProjectChoice => Boolean(project));
}
/** @internal */
export async function fetchAnthropicModels(
apiKey: string,
fetchFn: typeof fetch = fetch,

View file

@ -18,8 +18,8 @@ import {
type KtxSetupPromptOption,
} from './setup-prompts.js';
export type KtxSetupProjectMode = 'auto' | 'prompt-new';
export type KtxSetupInputMode = 'auto' | 'disabled';
type KtxSetupProjectMode = 'auto' | 'prompt-new';
type KtxSetupInputMode = 'auto' | 'disabled';
export interface KtxSetupProjectArgs {
projectDir: string;
@ -45,6 +45,7 @@ export type KtxSetupProjectResult =
| { status: 'cancelled'; projectDir: string }
| { status: 'missing-input'; projectDir: string };
/** @internal */
export interface KtxSetupProjectPromptAdapter {
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
text(options: { message: string; placeholder?: string }): Promise<string | undefined>;

View file

@ -14,7 +14,7 @@ export type KtxSetupReadyAction =
| 'agents'
| 'exit';
export interface KtxSetupReadyMenuPromptAdapter {
interface KtxSetupReadyMenuPromptAdapter {
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
cancel(message: string): void;
}

View file

@ -89,7 +89,7 @@ export interface KtxSetupSourcesPromptAdapter {
log?(message: string): void;
}
export type SourceValidationResult = { ok: true; detail?: string } | { ok: false; message: string };
type SourceValidationResult = { ok: true; detail?: string } | { ok: false; message: string };
export interface KtxSetupSourcesDeps {
prompts?: KtxSetupSourcesPromptAdapter;

View file

@ -17,6 +17,7 @@ export interface KtxTextIngestArgs {
failFast: boolean;
}
/** @internal */
export interface TextMemoryIngestPort {
ingest(input: MemoryAgentInput): Promise<{ runId: string }>;
waitForRun(runId: string): Promise<void>;

View file

@ -93,6 +93,7 @@ function transientHint(text: string, now: number): PickerState['transientHint']
return { text, expiresAt: now + TRANSIENT_HINT_DURATION_MS };
}
/** @internal */
export function clearExpiredTransientHint(state: PickerState, now = Date.now()): PickerState {
if (!state.transientHint || state.transientHint.expiresAt > now) {
return state;
@ -249,6 +250,7 @@ function checkedAncestor(nodeId: string, state: PickerState): TreePickerNode | n
return null;
}
/** @internal */
export function canToggle(nodeId: string, state: PickerState): { ok: true } | { ok: false; reason: string } {
if (!state.byId.has(nodeId)) {
return { ok: false, reason: 'Node not found' };
@ -260,6 +262,7 @@ export function canToggle(nodeId: string, state: PickerState): { ok: true } | {
return { ok: true };
}
/** @internal */
export function toggleChecked(state: PickerState, nodeId: string, now = Date.now()): PickerState {
const toggle = canToggle(nodeId, state);
if (!toggle.ok) {
@ -335,6 +338,7 @@ export function visibleNodeIds(state: PickerState): string[] {
return result;
}
/** @internal */
export function selectAllVisible(state: PickerState): PickerState {
const candidates = state.search.query.trim().length > 0 ? matchingIds(state) : new Set(visibleNodeIds(state));
const checked = new Set(state.checked);
@ -358,6 +362,7 @@ export function selectAllVisible(state: PickerState): PickerState {
});
}
/** @internal */
export function selectNone(state: PickerState): PickerState {
return cloneState(state, { checked: new Set(), transientHint: null });
}
@ -383,6 +388,7 @@ function setExpanded(state: PickerState, nodeId: string, value: boolean | 'toggl
return cloneState(state, { expanded });
}
/** @internal */
export function moveCursor(state: PickerState, dir: 'up' | 'down' | 'left' | 'right'): PickerState {
const node = state.byId.get(state.cursorId);
if (!node) {

View file

@ -77,12 +77,14 @@ interface TreePickerAppProps extends TreePickerRenderInput {
onExit(result: TreePickerResult): void;
}
/** @internal */
export interface TreePickerInkInstance {
rerender(tree: ReactNode): void;
unmount(): void;
waitUntilExit(): Promise<void>;
}
/** @internal */
export interface TreePickerInkRenderOptions {
stdin?: TreePickerTuiIo['stdin'];
stdout: TreePickerTuiIo['stdout'];
@ -97,6 +99,7 @@ function resolveTheme(env: NodeJS.ProcessEnv = process.env): TreePickerTheme {
return env.NO_COLOR || env.TERM === 'dumb' ? NO_COLOR_THEME : COLOR_THEME;
}
/** @internal */
export function resolveTreePickerWidth(columns: number | undefined): number {
const resolvedColumns = columns ?? 100;
return Math.max(60, Math.min(120, resolvedColumns - 4));
@ -114,6 +117,7 @@ function rowMatchesSearch(state: PickerState, nodeId: string): boolean {
return node.title.toLocaleLowerCase().includes(query) || node.path.toLocaleLowerCase().includes(query);
}
/** @internal */
export function sanitizeTreePickerTuiError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
return message
@ -121,11 +125,13 @@ export function sanitizeTreePickerTuiError(error: unknown): string {
.replace(/\b(api[_-]?key|password|token|secret)=\S+/gi, '[redacted]');
}
/** @internal */
export function windowOffset(count: number, selected: number, visible: number): number {
if (count <= visible) return 0;
return Math.max(0, Math.min(count - visible, selected - Math.floor(visible / 2)));
}
/** @internal */
export function windowItems<T>(items: T[], selected: number, visible: number): { items: T[]; offset: number } {
const offset = windowOffset(items.length, selected, visible);
return { items: items.slice(offset, offset + visible), offset };
@ -137,6 +143,7 @@ function truncateText(value: string, width: number): string {
return `${value.slice(0, width - 3)}...`;
}
/** @internal */
export function treePickerCommandForInkInput(
input: string,
key: InkKey,
@ -205,6 +212,7 @@ function PickerRow(props: { state: PickerState; nodeId: string; width: number; t
);
}
/** @internal */
export function TreePickerApp(props: TreePickerAppProps): ReactNode {
const app = useApp();
const [state, setState] = useState(props.initialState);

View file

@ -88,6 +88,7 @@ export function warnVizFallbackOnce(io: KtxVizFallbackIo, decision: KtxVizFallba
io.stderr.write(`Visualization requested but ${decision.message}; printing plain output.\n`);
}
/** @internal */
export function resetVizFallbackWarningsForTest(): void {
warnedFallbackReasons.clear();
}