mirror of
https://github.com/Kaelio/ktx.git
synced 2026-07-10 11:12:10 +02:00
feat: rename project wiki directory (#66)
* feat: rename project wiki directory * test: fix wiki skill ordering expectations * Show configured context sources in setup
This commit is contained in:
parent
97da9919e9
commit
d7147f9ca1
116 changed files with 839 additions and 484 deletions
|
|
@ -3,6 +3,19 @@ import { z } from 'zod';
|
|||
const projectDirSchema = z.string().min(1);
|
||||
const stringArraySchema = z.array(z.string());
|
||||
|
||||
export const wikiWriteCommandSchema = z.object({
|
||||
command: z.literal('write'),
|
||||
projectDir: projectDirSchema,
|
||||
key: z.string().min(1),
|
||||
scope: z.enum(['GLOBAL', 'USER']),
|
||||
userId: z.string().min(1),
|
||||
summary: z.string().min(1),
|
||||
content: z.string().min(1),
|
||||
tags: stringArraySchema,
|
||||
refs: stringArraySchema,
|
||||
slRefs: stringArraySchema,
|
||||
});
|
||||
|
||||
const orderBySchema = z.union([
|
||||
z.string().min(1),
|
||||
z.object({
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { type Command } from '@commander-js/extra-typings';
|
||||
import { type Command, Option } from '@commander-js/extra-typings';
|
||||
import {
|
||||
collectOption,
|
||||
type KtxCliCommandContext,
|
||||
parsePositiveIntegerOption,
|
||||
resolveCommandProjectDir,
|
||||
} from '../cli-program.js';
|
||||
import { wikiWriteCommandSchema } from '../command-schemas.js';
|
||||
import type { KtxKnowledgeArgs } from '../knowledge.js';
|
||||
import { profileMark } from '../startup-profile.js';
|
||||
|
||||
|
|
@ -17,7 +19,7 @@ async function runKnowledgeArgs(context: KtxCliCommandContext, args: KtxKnowledg
|
|||
export function registerWikiCommands(program: Command, context: KtxCliCommandContext): void {
|
||||
const wiki = program
|
||||
.command('wiki')
|
||||
.description('List or search local wiki pages')
|
||||
.description('List, read, search, or write local wiki pages')
|
||||
.showHelpAfterError()
|
||||
.addHelpText(
|
||||
'after',
|
||||
|
|
@ -38,6 +40,22 @@ export function registerWikiCommands(program: Command, context: KtxCliCommandCon
|
|||
});
|
||||
});
|
||||
|
||||
wiki
|
||||
.command('read')
|
||||
.description('Read one local wiki page')
|
||||
.argument('<key>', 'Wiki page key')
|
||||
.option('--json', 'Print JSON output', false)
|
||||
.option('--user-id <id>', 'Local user id', 'local')
|
||||
.action(async (key: string, options: { userId: string; json?: boolean }, command) => {
|
||||
await runKnowledgeArgs(context, {
|
||||
command: 'read',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
key,
|
||||
userId: options.userId,
|
||||
json: options.json,
|
||||
});
|
||||
});
|
||||
|
||||
wiki
|
||||
.command('search')
|
||||
.description('Search local wiki pages')
|
||||
|
|
@ -55,4 +73,31 @@ export function registerWikiCommands(program: Command, context: KtxCliCommandCon
|
|||
...(options.limit !== undefined ? { limit: options.limit } : {}),
|
||||
});
|
||||
});
|
||||
|
||||
wiki
|
||||
.command('write')
|
||||
.description('Write one local wiki page')
|
||||
.argument('<key>', 'Wiki page key')
|
||||
.option('--user-id <id>', 'Local user id', 'local')
|
||||
.addOption(new Option('--scope <scope>', 'global or user').choices(['global', 'user']).default('global'))
|
||||
.requiredOption('--summary <summary>', 'Wiki summary')
|
||||
.requiredOption('--content <content>', 'Wiki content')
|
||||
.option('--tag <tag>', 'Wiki tag; repeatable', collectOption, [])
|
||||
.option('--ref <ref>', 'Wiki ref; repeatable', collectOption, [])
|
||||
.option('--sl-ref <ref>', 'Semantic-layer ref; repeatable', collectOption, [])
|
||||
.action(async (key: string, options, command) => {
|
||||
const args = wikiWriteCommandSchema.parse({
|
||||
command: 'write',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
key,
|
||||
scope: options.scope === 'user' ? 'USER' : 'GLOBAL',
|
||||
userId: options.userId,
|
||||
summary: options.summary,
|
||||
content: options.content,
|
||||
tags: options.tag,
|
||||
refs: options.ref,
|
||||
slRefs: options.slRef,
|
||||
});
|
||||
await runKnowledgeArgs(context, args);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ describe('demo assets', () => {
|
|||
|
||||
await expect(access(packagedDemoAssetPath('semantic-layer/dbt-main/mart_arr_daily.yaml'))).resolves.toBeUndefined();
|
||||
await expect(access(packagedDemoAssetPath('semantic-layer/postgres-warehouse/mart_account_activity.yaml'))).resolves.toBeUndefined();
|
||||
await expect(access(packagedDemoAssetPath('knowledge/global/orbit-company-overview.md'))).resolves.toBeUndefined();
|
||||
await expect(access(packagedDemoAssetPath('wiki/global/orbit-company-overview.md'))).resolves.toBeUndefined();
|
||||
await expect(access(packagedDemoAssetPath('links/provenance.json'))).resolves.toBeUndefined();
|
||||
await expect(access(packagedDemoAssetPath('reports/seeded-demo-report.json'))).resolves.toBeUndefined();
|
||||
});
|
||||
|
|
@ -108,7 +108,7 @@ describe('demo assets', () => {
|
|||
await expect(access(join(projectDir, 'state.sqlite'))).resolves.toBeUndefined();
|
||||
await expect(access(join(projectDir, 'reports'))).resolves.toBeUndefined();
|
||||
await expect(access(join(projectDir, 'semantic-layer'))).resolves.toBeUndefined();
|
||||
await expect(access(join(projectDir, 'knowledge'))).resolves.toBeUndefined();
|
||||
await expect(access(join(projectDir, 'wiki'))).resolves.toBeUndefined();
|
||||
await expect(access(join(projectDir, 'replays', 'replay.memory-flow.v1.json'))).resolves.toBeUndefined();
|
||||
await expect(access(join(projectDir, 'raw-sources'))).resolves.toBeUndefined();
|
||||
await expect(access(join(projectDir, '_schema'))).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
|
|
@ -129,7 +129,7 @@ describe('demo assets', () => {
|
|||
await ensureSeededDemoProject({ projectDir, force: false });
|
||||
|
||||
await expect(access(join(projectDir, 'semantic-layer', 'dbt-main', 'mart_arr_daily.yaml'))).resolves.toBeUndefined();
|
||||
await expect(access(join(projectDir, 'knowledge', 'global', 'orbit-company-overview.md'))).resolves.toBeUndefined();
|
||||
await expect(access(join(projectDir, 'wiki', 'global', 'orbit-company-overview.md'))).resolves.toBeUndefined();
|
||||
await expect(access(join(projectDir, 'links', 'provenance.json'))).resolves.toBeUndefined();
|
||||
await expect(access(join(projectDir, 'reports', 'seeded-demo-report.json'))).resolves.toBeUndefined();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ const REQUIRED_SEEDED_ASSET_PATHS = [
|
|||
DEMO_REPLAY_FILE,
|
||||
join('semantic-layer', 'dbt-main', 'mart_arr_daily.yaml'),
|
||||
join('semantic-layer', 'postgres-warehouse', 'mart_account_activity.yaml'),
|
||||
join('knowledge', 'global', 'orbit-company-overview.md'),
|
||||
join('wiki', 'global', 'orbit-company-overview.md'),
|
||||
] as const;
|
||||
|
||||
function assetDir(): string {
|
||||
|
|
@ -131,7 +131,7 @@ export async function ensureDemoProject(options: EnsureDemoProjectOptions): Prom
|
|||
}
|
||||
|
||||
await mkdir(projectDir, { recursive: true });
|
||||
for (const relativeDir of ['reports', 'semantic-layer', 'knowledge', 'replays', 'raw-sources', 'links']) {
|
||||
for (const relativeDir of ['reports', 'semantic-layer', 'wiki', 'replays', 'raw-sources', 'links']) {
|
||||
await mkdir(join(projectDir, relativeDir), { recursive: true });
|
||||
}
|
||||
|
||||
|
|
@ -157,7 +157,7 @@ async function copySeededAssetDirectories(projectDir: string): Promise<void> {
|
|||
|
||||
await Promise.all([
|
||||
copyDirIfExists(join(src, 'semantic-layer'), join(dest, 'semantic-layer')),
|
||||
copyDirIfExists(join(src, 'knowledge'), join(dest, 'knowledge')),
|
||||
copyDirIfExists(join(src, 'wiki'), join(dest, 'wiki')),
|
||||
copyDirIfExists(join(src, 'raw-sources'), join(dest, 'raw-sources')),
|
||||
copyDirIfExists(join(src, 'links'), join(dest, 'links')),
|
||||
copyDirIfExists(join(src, 'reports'), join(dest, 'reports')),
|
||||
|
|
|
|||
|
|
@ -139,22 +139,78 @@ describe('runKtxCli', () => {
|
|||
expect(testIo.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('rejects removed public wiki and sl read/write commands', async () => {
|
||||
const sl = vi.fn(async () => 0);
|
||||
it('routes public wiki read and write commands', async () => {
|
||||
const knowledge = vi.fn(async () => 0);
|
||||
|
||||
const readIo = makeIo();
|
||||
await expect(runKtxCli(['--project-dir', tempDir, 'wiki', 'read', 'revenue', '--json'], readIo.io, { knowledge }))
|
||||
.resolves.toBe(0);
|
||||
expect(knowledge).toHaveBeenCalledWith(
|
||||
{
|
||||
command: 'read',
|
||||
projectDir: tempDir,
|
||||
key: 'revenue',
|
||||
userId: 'local',
|
||||
json: true,
|
||||
},
|
||||
readIo.io,
|
||||
);
|
||||
|
||||
const writeIo = makeIo();
|
||||
await expect(
|
||||
runKtxCli(
|
||||
[
|
||||
'--project-dir',
|
||||
tempDir,
|
||||
'wiki',
|
||||
'write',
|
||||
'revenue',
|
||||
'--scope',
|
||||
'user',
|
||||
'--summary',
|
||||
'Revenue',
|
||||
'--content',
|
||||
'Revenue.',
|
||||
'--tag',
|
||||
'finance',
|
||||
'--ref',
|
||||
'https://example.com/revenue',
|
||||
'--sl-ref',
|
||||
'orders',
|
||||
],
|
||||
writeIo.io,
|
||||
{ knowledge },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
expect(knowledge).toHaveBeenLastCalledWith(
|
||||
{
|
||||
command: 'write',
|
||||
projectDir: tempDir,
|
||||
key: 'revenue',
|
||||
scope: 'USER',
|
||||
userId: 'local',
|
||||
summary: 'Revenue',
|
||||
content: 'Revenue.',
|
||||
tags: ['finance'],
|
||||
refs: ['https://example.com/revenue'],
|
||||
slRefs: ['orders'],
|
||||
},
|
||||
writeIo.io,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects removed public sl read/write commands', async () => {
|
||||
const sl = vi.fn(async () => 0);
|
||||
|
||||
for (const argv of [
|
||||
['--project-dir', tempDir, 'wiki', 'read', 'revenue'],
|
||||
['--project-dir', tempDir, 'wiki', 'write', 'revenue', '--summary', 'Revenue', '--content', 'Revenue.'],
|
||||
['--project-dir', tempDir, 'sl', 'read', 'orders', '--connection-id', 'warehouse'],
|
||||
['--project-dir', tempDir, 'sl', 'write', 'orders', '--connection-id', 'warehouse', '--yaml', 'name: orders'],
|
||||
]) {
|
||||
const io = makeIo();
|
||||
await expect(runKtxCli(argv, io.io, { knowledge, sl })).resolves.toBe(1);
|
||||
await expect(runKtxCli(argv, io.io, { sl })).resolves.toBe(1);
|
||||
expect(io.stderr()).toMatch(/unknown command|error:/);
|
||||
}
|
||||
|
||||
expect(knowledge).not.toHaveBeenCalled();
|
||||
expect(sl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ export function bundleReportSnapshot(): IngestReportSnapshot {
|
|||
rawFiles: ['cards/1.json', 'cards/2.json'],
|
||||
status: 'success',
|
||||
actions: [
|
||||
{ target: 'wiki', type: 'created', key: 'knowledge/global/revenue.md', detail: 'Revenue overview' },
|
||||
{ target: 'wiki', type: 'created', key: 'wiki/global/revenue.md', detail: 'Revenue overview' },
|
||||
{ target: 'sl', type: 'updated', key: 'warehouse.orders', detail: 'Added order amount measure' },
|
||||
],
|
||||
touchedSlSources: [{ connectionId: 'warehouse', sourceName: 'warehouse.orders' }],
|
||||
|
|
@ -178,7 +178,7 @@ export function bundleReportSnapshot(): IngestReportSnapshot {
|
|||
{
|
||||
rawPath: 'cards/1.json',
|
||||
artifactKind: 'wiki',
|
||||
artifactKey: 'knowledge/global/revenue.md',
|
||||
artifactKey: 'wiki/global/revenue.md',
|
||||
actionType: 'wiki_written',
|
||||
},
|
||||
{
|
||||
|
|
@ -194,7 +194,7 @@ export function bundleReportSnapshot(): IngestReportSnapshot {
|
|||
path: 'tool-transcripts/cards.jsonl',
|
||||
toolCallCount: 4,
|
||||
errorCount: 0,
|
||||
toolNames: ['ingest_triage', 'knowledge_capture', 'sl_capture'],
|
||||
toolNames: ['ingest_triage', 'wiki_capture', 'sl_capture'],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { tmpdir } from 'node:os';
|
|||
import { join } from 'node:path';
|
||||
import { initKtxProject } from '@ktx/context/project';
|
||||
import type { KtxEmbeddingPort } from '@ktx/context';
|
||||
import { type LocalKnowledgeScope, writeLocalKnowledgePage } from '@ktx/context/wiki';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { runKtxKnowledge } from './knowledge.js';
|
||||
|
||||
|
|
@ -41,29 +40,6 @@ class FakeEmbeddingPort implements KtxEmbeddingPort {
|
|||
}
|
||||
}
|
||||
|
||||
async function seedKnowledgePage(input: {
|
||||
projectDir: string;
|
||||
key: string;
|
||||
summary: string;
|
||||
content: string;
|
||||
scope?: LocalKnowledgeScope;
|
||||
tags?: string[];
|
||||
refs?: string[];
|
||||
slRefs?: string[];
|
||||
}): Promise<void> {
|
||||
const project = await initKtxProject({ projectDir: input.projectDir, projectName: 'warehouse' });
|
||||
await writeLocalKnowledgePage(project, {
|
||||
key: input.key,
|
||||
scope: input.scope ?? 'GLOBAL',
|
||||
userId: 'local',
|
||||
summary: input.summary,
|
||||
content: input.content,
|
||||
tags: input.tags ?? [],
|
||||
refs: input.refs ?? [],
|
||||
slRefs: input.slRefs ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
describe('runKtxKnowledge', () => {
|
||||
let tempDir: string;
|
||||
|
||||
|
|
@ -75,16 +51,36 @@ describe('runKtxKnowledge', () => {
|
|||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('lists and searches knowledge pages', async () => {
|
||||
it('writes, reads, lists, and searches wiki pages', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await seedKnowledgePage({
|
||||
projectDir,
|
||||
key: 'metrics-revenue',
|
||||
summary: 'Revenue',
|
||||
content: 'Revenue is paid order value.',
|
||||
tags: ['finance'],
|
||||
slRefs: ['orders'],
|
||||
});
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
|
||||
const writeIo = makeIo();
|
||||
await expect(
|
||||
runKtxKnowledge(
|
||||
{
|
||||
command: 'write',
|
||||
projectDir,
|
||||
key: 'metrics-revenue',
|
||||
scope: 'GLOBAL',
|
||||
userId: 'local',
|
||||
summary: 'Revenue',
|
||||
content: 'Revenue is paid order value.',
|
||||
tags: ['finance'],
|
||||
refs: [],
|
||||
slRefs: ['orders'],
|
||||
},
|
||||
writeIo.io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
expect(writeIo.stdout()).toContain('Wrote wiki/global/metrics-revenue.md');
|
||||
|
||||
const readIo = makeIo();
|
||||
await expect(
|
||||
runKtxKnowledge({ command: 'read', projectDir, key: 'metrics-revenue', userId: 'local' }, readIo.io),
|
||||
).resolves.toBe(0);
|
||||
expect(readIo.stdout()).toContain('# metrics-revenue');
|
||||
expect(readIo.stdout()).toContain('Revenue is paid order value.');
|
||||
|
||||
const listIo = makeIo();
|
||||
await expect(runKtxKnowledge({ command: 'list', projectDir, userId: 'local' }, listIo.io)).resolves.toBe(0);
|
||||
|
|
@ -97,16 +93,27 @@ describe('runKtxKnowledge', () => {
|
|||
expect(searchIo.stdout()).toContain('metrics-revenue');
|
||||
});
|
||||
|
||||
it('prints wiki list and search as public JSON envelopes', async () => {
|
||||
it('prints wiki list, search, and read as public JSON envelopes', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await seedKnowledgePage({
|
||||
projectDir,
|
||||
key: 'metrics-revenue',
|
||||
summary: 'Revenue',
|
||||
content: 'Revenue is paid order value.',
|
||||
tags: ['finance'],
|
||||
slRefs: ['orders'],
|
||||
});
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
|
||||
await expect(
|
||||
runKtxKnowledge(
|
||||
{
|
||||
command: 'write',
|
||||
projectDir,
|
||||
key: 'metrics-revenue',
|
||||
scope: 'GLOBAL',
|
||||
userId: 'local',
|
||||
summary: 'Revenue',
|
||||
content: 'Revenue is paid order value.',
|
||||
tags: ['finance'],
|
||||
refs: [],
|
||||
slRefs: ['orders'],
|
||||
},
|
||||
makeIo().io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
const listIo = makeIo();
|
||||
await expect(runKtxKnowledge({ command: 'list', projectDir, userId: 'local', json: true }, listIo.io)).resolves.toBe(
|
||||
|
|
@ -130,6 +137,48 @@ describe('runKtxKnowledge', () => {
|
|||
data: { items: [expect.objectContaining({ key: 'metrics-revenue', summary: 'Revenue' })] },
|
||||
meta: { command: 'wiki search' },
|
||||
});
|
||||
|
||||
const readIo = makeIo();
|
||||
await expect(
|
||||
runKtxKnowledge({ command: 'read', projectDir, key: 'metrics-revenue', userId: 'local', json: true }, readIo.io),
|
||||
).resolves.toBe(0);
|
||||
expect(JSON.parse(readIo.stdout())).toMatchObject({
|
||||
kind: 'wiki.page',
|
||||
data: {
|
||||
key: 'metrics-revenue',
|
||||
summary: 'Revenue',
|
||||
content: 'Revenue is paid order value.',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects slash-delimited write keys with a flat-key suggestion', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
|
||||
const writeIo = makeIo();
|
||||
await expect(
|
||||
runKtxKnowledge(
|
||||
{
|
||||
command: 'write',
|
||||
projectDir,
|
||||
key: 'orbit/company-overview',
|
||||
scope: 'GLOBAL',
|
||||
userId: 'local',
|
||||
summary: 'Orbit',
|
||||
content: 'Orbit overview.',
|
||||
tags: [],
|
||||
refs: [],
|
||||
slRefs: [],
|
||||
},
|
||||
writeIo.io,
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(writeIo.stderr()).toContain(
|
||||
'Invalid wiki key "orbit/company-overview". Wiki keys must be flat; use "orbit-company-overview".',
|
||||
);
|
||||
expect(writeIo.stdout()).toBe('');
|
||||
});
|
||||
|
||||
it('explains empty search results for a project without wiki pages', async () => {
|
||||
|
|
@ -143,19 +192,30 @@ describe('runKtxKnowledge', () => {
|
|||
|
||||
expect(searchIo.stdout()).toBe('');
|
||||
expect(searchIo.stderr()).toContain('No local wiki pages found');
|
||||
expect(searchIo.stderr()).toContain('Run ingest');
|
||||
expect(searchIo.stderr()).not.toContain('ktx wiki write');
|
||||
expect(searchIo.stderr()).toContain('ktx wiki write');
|
||||
});
|
||||
|
||||
it('uses configured embeddings for semantic wiki search', async () => {
|
||||
const projectDir = join(tempDir, 'semantic-project');
|
||||
await seedKnowledgePage({
|
||||
projectDir,
|
||||
key: 'active-contract-arr-open-tickets',
|
||||
summary: 'Active Contract ARR Ranked by Open Support Ticket Count',
|
||||
content: 'Accounts ranked by annual recurring contract value and support ticket load.',
|
||||
tags: ['historic-sql'],
|
||||
});
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
|
||||
await expect(
|
||||
runKtxKnowledge(
|
||||
{
|
||||
command: 'write',
|
||||
projectDir,
|
||||
key: 'active-contract-arr-open-tickets',
|
||||
scope: 'GLOBAL',
|
||||
userId: 'local',
|
||||
summary: 'Active Contract ARR Ranked by Open Support Ticket Count',
|
||||
content: 'Accounts ranked by annual recurring contract value and support ticket load.',
|
||||
tags: ['historic-sql'],
|
||||
refs: [],
|
||||
slRefs: [],
|
||||
},
|
||||
makeIo().io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
const searchIo = makeIo();
|
||||
await expect(
|
||||
|
|
|
|||
|
|
@ -4,12 +4,31 @@ import {
|
|||
type KtxEmbeddingPort,
|
||||
} from '@ktx/context';
|
||||
import { loadKtxProject } from '@ktx/context/project';
|
||||
import { listLocalKnowledgePages, searchLocalKnowledgePages } from '@ktx/context/wiki';
|
||||
import {
|
||||
type LocalKnowledgeScope,
|
||||
listLocalKnowledgePages,
|
||||
readLocalKnowledgePage,
|
||||
searchLocalKnowledgePages,
|
||||
writeLocalKnowledgePage,
|
||||
} from '@ktx/context/wiki';
|
||||
import { writeJsonResult } from './io/print-list.js';
|
||||
|
||||
export type KtxKnowledgeArgs =
|
||||
| { command: 'list'; projectDir: string; userId: string; json?: boolean }
|
||||
| { command: 'search'; projectDir: string; query: string; userId: string; json?: boolean; limit?: number };
|
||||
| { command: 'read'; projectDir: string; key: string; userId: string; json?: boolean }
|
||||
| { command: 'search'; projectDir: string; query: string; userId: string; json?: boolean; limit?: number }
|
||||
| {
|
||||
command: 'write';
|
||||
projectDir: string;
|
||||
key: string;
|
||||
scope: LocalKnowledgeScope;
|
||||
userId: string;
|
||||
summary: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
refs: string[];
|
||||
slRefs: string[];
|
||||
};
|
||||
|
||||
interface KtxKnowledgeIo {
|
||||
stdout: { write(chunk: string): void };
|
||||
|
|
@ -56,6 +75,25 @@ export async function runKtxKnowledge(
|
|||
}
|
||||
return 0;
|
||||
}
|
||||
if (args.command === 'read') {
|
||||
const page = await readLocalKnowledgePage(project, { key: args.key, userId: args.userId });
|
||||
if (!page) {
|
||||
throw new Error(`Wiki page "${args.key}" was not found`);
|
||||
}
|
||||
if (args.json) {
|
||||
writeJsonResult(io, {
|
||||
kind: 'wiki.page',
|
||||
data: page,
|
||||
meta: { command: 'wiki read' },
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
io.stdout.write(`# ${page.key}\n\n`);
|
||||
io.stdout.write(`Scope: ${page.scope}\n`);
|
||||
io.stdout.write(`Summary: ${page.summary}\n\n`);
|
||||
io.stdout.write(`${page.content}\n`);
|
||||
return 0;
|
||||
}
|
||||
if (args.command === 'search') {
|
||||
const results = await searchLocalKnowledgePages(project, {
|
||||
query: args.query,
|
||||
|
|
@ -75,7 +113,7 @@ export async function runKtxKnowledge(
|
|||
const pages = await listLocalKnowledgePages(project, { userId: args.userId });
|
||||
if (pages.length === 0) {
|
||||
io.stderr.write(
|
||||
`No local wiki pages found in ${project.projectDir}. Run ingest to capture wiki context, then retry the search.\n`,
|
||||
`No local wiki pages found in ${project.projectDir}. Create one with \`ktx wiki write <key> --summary <summary> --content <content>\` or run ingest.\n`,
|
||||
);
|
||||
} else {
|
||||
io.stderr.write(
|
||||
|
|
@ -89,8 +127,19 @@ export async function runKtxKnowledge(
|
|||
}
|
||||
return 0;
|
||||
}
|
||||
const _exhaustive: never = args;
|
||||
throw new Error(`Unsupported wiki command: ${JSON.stringify(_exhaustive)}`);
|
||||
|
||||
const write = await writeLocalKnowledgePage(project, {
|
||||
key: args.key,
|
||||
scope: args.scope,
|
||||
userId: args.userId,
|
||||
summary: args.summary,
|
||||
content: args.content,
|
||||
tags: args.tags,
|
||||
refs: args.refs,
|
||||
slRefs: args.slRefs,
|
||||
});
|
||||
io.stdout.write(`Wrote ${write.path}\n`);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
return 1;
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ function tableName(key: string): string {
|
|||
function humanizeInsight(key: string, target: 'sl' | 'wiki', summary: string | undefined): string {
|
||||
if (summary) return summary;
|
||||
const name = target === 'sl' ? tableName(key) : topicName(key);
|
||||
return target === 'sl' ? `Query definition: ${name}` : `Knowledge page: ${name}`;
|
||||
return target === 'sl' ? `Query definition: ${name}` : `Wiki page: ${name}`;
|
||||
}
|
||||
|
||||
const INTERNAL_DEMO_CONNECTION_ID = 'orbit_demo';
|
||||
|
|
@ -453,7 +453,7 @@ function CompletionSummary(props: {
|
|||
)}
|
||||
{wiki > 0 && (
|
||||
<Text color={props.theme.complete}>
|
||||
{' '}📝 {wiki} knowledge page{wiki === 1 ? '' : 's'} — so agents understand your business context
|
||||
{' '}📝 {wiki} wiki page{wiki === 1 ? '' : 's'} — so agents understand your business context
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -46,9 +46,9 @@ function replay(): MemoryFlowReplayInput {
|
|||
{ type: 'raw_snapshot_written', syncId: 'sync-1', rawFileCount: 2 },
|
||||
{ type: 'diff_computed', added: 1, modified: 1, deleted: 0, unchanged: 0 },
|
||||
{ type: 'chunks_planned', chunkCount: 2, workUnitCount: 2, evictionCount: 0 },
|
||||
{ type: 'work_unit_started', unitKey: 'orders', skills: ['knowledge_capture'], stepBudget: 4 },
|
||||
{ type: 'work_unit_started', unitKey: 'orders', skills: ['wiki_capture'], stepBudget: 4 },
|
||||
{ type: 'work_unit_finished', unitKey: 'orders', status: 'success' },
|
||||
{ type: 'work_unit_started', unitKey: 'customers', skills: ['knowledge_capture'], stepBudget: 4 },
|
||||
{ type: 'work_unit_started', unitKey: 'customers', skills: ['wiki_capture'], stepBudget: 4 },
|
||||
{ type: 'work_unit_finished', unitKey: 'customers', status: 'failed', reason: 'validation reset' },
|
||||
{ type: 'reconciliation_finished', conflictCount: 0, fallbackCount: 1 },
|
||||
{ type: 'saved', commitSha: 'abc12345', wikiCount: 1, slCount: 1 },
|
||||
|
|
|
|||
|
|
@ -23,10 +23,10 @@ function replayInput(): MemoryFlowReplayInput {
|
|||
],
|
||||
details: {
|
||||
actions: [
|
||||
{ unitKey: 'orders', target: 'wiki', action: 'created', key: 'knowledge/orders.md', summary: 'order lifecycle', rawFiles: ['orders'], status: 'success' },
|
||||
{ unitKey: 'orders', target: 'wiki', action: 'created', key: 'wiki/orders.md', summary: 'order lifecycle', rawFiles: ['orders'], status: 'success' },
|
||||
{ unitKey: 'customers', target: 'sl', action: 'updated', key: 'orbit_demo.customers', summary: 'customer metrics', rawFiles: ['customers'], status: 'success' },
|
||||
],
|
||||
provenance: [{ rawPath: 'orders', artifactKind: 'wiki', artifactKey: 'knowledge/orders.md', actionType: 'wiki_written' }],
|
||||
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'] }],
|
||||
},
|
||||
events: [
|
||||
|
|
@ -35,8 +35,8 @@ function replayInput(): MemoryFlowReplayInput {
|
|||
{ type: 'raw_snapshot_written', syncId: 'sync-1', rawFileCount: 2 },
|
||||
{ type: 'diff_computed', added: 1, modified: 1, deleted: 0, unchanged: 0 },
|
||||
{ type: 'chunks_planned', chunkCount: 2, workUnitCount: 2, evictionCount: 0 },
|
||||
{ type: 'work_unit_started', unitKey: 'orders', skills: ['knowledge_capture'], stepBudget: 40 },
|
||||
{ type: 'candidate_action', unitKey: 'orders', target: 'wiki', action: 'created', key: 'knowledge/orders.md' },
|
||||
{ type: 'work_unit_started', unitKey: 'orders', skills: ['wiki_capture'], stepBudget: 40 },
|
||||
{ type: 'candidate_action', unitKey: 'orders', target: 'wiki', action: 'created', key: 'wiki/orders.md' },
|
||||
{ type: 'work_unit_finished', unitKey: 'orders', status: 'success' },
|
||||
{ type: 'work_unit_started', unitKey: 'customers', skills: ['sl_capture'], stepBudget: 40 },
|
||||
{ type: 'candidate_action', unitKey: 'customers', target: 'sl', action: 'updated', key: 'orbit_demo.customers' },
|
||||
|
|
@ -220,7 +220,7 @@ describe('MemoryFlowTuiApp', () => {
|
|||
{ type: 'source_acquired', adapter: 'live-database', trigger: 'manual_resync', fileCount: 1 },
|
||||
{ type: 'diff_computed', added: 1, modified: 0, deleted: 0, unchanged: 0 },
|
||||
{ type: 'chunks_planned', chunkCount: 1, workUnitCount: 1, evictionCount: 0 },
|
||||
{ type: 'work_unit_started', unitKey: 'orders', skills: ['knowledge_capture'], stepBudget: 40 },
|
||||
{ type: 'work_unit_started', unitKey: 'orders', skills: ['wiki_capture'], stepBudget: 40 },
|
||||
],
|
||||
plannedWorkUnits: [{ unitKey: 'orders', rawFiles: ['orders'], peerFileCount: 0, dependencyCount: 1 }],
|
||||
};
|
||||
|
|
@ -240,7 +240,7 @@ describe('MemoryFlowTuiApp', () => {
|
|||
{ type: 'source_acquired', adapter: 'dbt-descriptions', trigger: 'manual_resync', fileCount: 3 },
|
||||
{ type: 'diff_computed', added: 11, modified: 0, deleted: 0, unchanged: 0 },
|
||||
{ type: 'chunks_planned', chunkCount: 1, workUnitCount: 1, evictionCount: 0 },
|
||||
{ type: 'work_unit_started', unitKey: 'orders', skills: ['knowledge_capture'], stepBudget: 40 },
|
||||
{ type: 'work_unit_started', unitKey: 'orders', skills: ['wiki_capture'], stepBudget: 40 },
|
||||
],
|
||||
plannedWorkUnits: [{ unitKey: 'orders', rawFiles: ['orders'], peerFileCount: 0, dependencyCount: 1 }],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -257,9 +257,9 @@ describe('setup context build state', () => {
|
|||
it('marks context complete without prompting when initial source ingest already made agent context', async () => {
|
||||
await writeReadyProject(tempDir);
|
||||
await mkdir(join(tempDir, 'semantic-layer', 'dbt-main'), { recursive: true });
|
||||
await mkdir(join(tempDir, 'knowledge', 'global'), { recursive: true });
|
||||
await mkdir(join(tempDir, 'wiki', 'global'), { recursive: true });
|
||||
await writeFile(join(tempDir, 'semantic-layer', 'dbt-main', 'mart_revenue_daily.yaml'), 'name: mart_revenue_daily\n');
|
||||
await writeFile(join(tempDir, 'knowledge', 'global', 'metrics.md'), '# Metrics\n');
|
||||
await writeFile(join(tempDir, 'wiki', 'global', 'metrics.md'), '# Metrics\n');
|
||||
await writeReadyEnrichedScanReport(tempDir);
|
||||
const io = makeIo();
|
||||
const runContextBuildMock = vi.fn(async () => ({ exitCode: 0, detached: false }));
|
||||
|
|
@ -332,8 +332,8 @@ describe('setup context build state', () => {
|
|||
await writeFile(join(tempDir, 'semantic-layer', 'warehouse', '_schema', 'public.yaml'), 'tables: {}\n');
|
||||
const io = makeIo();
|
||||
const runContextBuildMock = vi.fn(async () => {
|
||||
await mkdir(join(tempDir, 'knowledge', 'global'), { recursive: true });
|
||||
await writeFile(join(tempDir, 'knowledge', 'global', 'metrics.md'), '# Metrics\n');
|
||||
await mkdir(join(tempDir, 'wiki', 'global'), { recursive: true });
|
||||
await writeFile(join(tempDir, 'wiki', 'global', 'metrics.md'), '# Metrics\n');
|
||||
await writeReadyEnrichedScanReport(tempDir);
|
||||
return { exitCode: 0, detached: false };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -441,7 +441,7 @@ async function defaultVerifyContextReady(projectDir: string): Promise<KtxSetupCo
|
|||
ignoredDirectoryNames: new Set(['_schema']),
|
||||
},
|
||||
);
|
||||
const wikiReady = await hasFileWithExtension(join(projectDir, 'knowledge'), new Set(['.md']));
|
||||
const wikiReady = await hasFileWithExtension(join(projectDir, 'wiki'), new Set(['.md']));
|
||||
const contextSourceReady =
|
||||
targets.contextSourceConnectionIds.length === 0 || semanticLayerContextReady || wikiReady;
|
||||
const ready = primarySourceScans.ready && contextSourceReady;
|
||||
|
|
|
|||
|
|
@ -229,7 +229,7 @@ function renderDemoContextCompletionSummary(): string {
|
|||
'',
|
||||
' KTX created:',
|
||||
` ${cyan('📊')} 46 semantic layer definitions`,
|
||||
` ${cyan('📝')} 28 knowledge pages`,
|
||||
` ${cyan('📝')} 28 wiki pages`,
|
||||
'',
|
||||
` ${dim('Press Enter to continue, Escape to go back')}`,
|
||||
'',
|
||||
|
|
@ -354,7 +354,7 @@ export async function runDemoTour(
|
|||
if (step === 'databases') {
|
||||
direction = await renderDemoCard('Database connection', ['PostgreSQL — Orbit Analytics (56 tables, 2 schemas)'], io, undefined, waitNav, projectDir);
|
||||
} else if (step === 'sources') {
|
||||
direction = await renderDemoCard('Context sources', ['dbt — 34 transformation models', 'Metabase — 80 dashboard cards', 'Notion — 9 knowledge pages'], io, undefined, waitNav, projectDir);
|
||||
direction = await renderDemoCard('Context sources', ['dbt — 34 transformation models', 'Metabase — 80 dashboard cards', 'Notion — 9 wiki pages'], io, undefined, waitNav, projectDir);
|
||||
} else if (step === 'context') {
|
||||
io.stdout.write(renderDemoBanner(projectDir) + '\n\n');
|
||||
if (deps.skipReplayAnimation) {
|
||||
|
|
|
|||
|
|
@ -624,6 +624,32 @@ describe('setup sources step', () => {
|
|||
expect(options).toContainEqual({ value: 'notion', label: 'Notion' });
|
||||
});
|
||||
|
||||
it('shows already configured context sources in the interactive checklist', async () => {
|
||||
await addPrimarySource();
|
||||
await addConnection('notion-main', {
|
||||
driver: 'notion',
|
||||
auth_token_ref: 'env:NOTION_TOKEN',
|
||||
crawl_mode: 'all_accessible',
|
||||
});
|
||||
const io = makeIo();
|
||||
const testPrompts = prompts({ multiselect: [['back']] });
|
||||
|
||||
await expect(
|
||||
runKtxSetupSourcesStep(
|
||||
{ projectDir, inputMode: 'auto', runInitialSourceIngest: false, skipSources: false },
|
||||
io.io,
|
||||
{ prompts: testPrompts },
|
||||
),
|
||||
).resolves.toEqual({ status: 'back', projectDir });
|
||||
|
||||
expect(testPrompts.multiselect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
initialValues: ['notion'],
|
||||
options: expect.arrayContaining([{ value: 'notion', label: 'Notion', hint: 'configured: notion-main' }]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses a source-specific editable connection name for new interactive connections', async () => {
|
||||
await addPrimarySource();
|
||||
const validateDbt = vi.fn(async () => ({ ok: true as const, detail: 'project=analytics schemas=2' }));
|
||||
|
|
|
|||
|
|
@ -73,7 +73,8 @@ export type KtxSetupSourcesResult =
|
|||
export interface KtxSetupSourcesPromptAdapter {
|
||||
multiselect(options: {
|
||||
message: string;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
options: Array<{ value: string; label: string; hint?: string }>;
|
||||
initialValues?: string[];
|
||||
required?: boolean;
|
||||
}): Promise<string[]>;
|
||||
select(options: { message: string; options: Array<{ value: string; label: string }> }): Promise<string>;
|
||||
|
|
@ -1325,6 +1326,22 @@ function existingConnectionIdsBySource(
|
|||
.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function sourceChecklistForConnections(connections: Record<string, KtxProjectConnectionConfig>): {
|
||||
options: Array<{ value: KtxSetupSourceType; label: string; hint?: string }>;
|
||||
initialValues: KtxSetupSourceType[];
|
||||
} {
|
||||
const initialValues: KtxSetupSourceType[] = [];
|
||||
const options = SOURCE_OPTIONS.map((option) => {
|
||||
const existingIds = existingConnectionIdsBySource(connections, option.value);
|
||||
if (existingIds.length === 0) {
|
||||
return option;
|
||||
}
|
||||
initialValues.push(option.value);
|
||||
return { ...option, hint: `configured: ${existingIds.join(', ')}` };
|
||||
});
|
||||
return { options, initialValues };
|
||||
}
|
||||
|
||||
function defaultConnectionIdForSource(
|
||||
connections: Record<string, KtxProjectConnectionConfig>,
|
||||
source: KtxSetupSourceType,
|
||||
|
|
@ -1483,13 +1500,19 @@ export async function runKtxSetupSourcesStep(
|
|||
}
|
||||
|
||||
while (true) {
|
||||
const contextSourceChecklist = sourceChecklistForConnections(
|
||||
(await loadKtxProject({ projectDir: args.projectDir })).config.connections,
|
||||
);
|
||||
const selected = args.source
|
||||
? [args.source]
|
||||
: args.inputMode === 'disabled'
|
||||
? []
|
||||
: await prompts.multiselect({
|
||||
message: withMultiselectNavigation('Which context sources should KTX ingest?'),
|
||||
options: [...SOURCE_OPTIONS],
|
||||
options: contextSourceChecklist.options,
|
||||
...(contextSourceChecklist.initialValues.length > 0
|
||||
? { initialValues: contextSourceChecklist.initialValues }
|
||||
: {}),
|
||||
required: false,
|
||||
});
|
||||
if (selected.includes('back')) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue