mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-10 08:05:14 +02:00
* feat(cli): define full warehouse dialect contract
* test(cli): keep dialect edge tests focused
* fix(cli): stabilize dialect contract foundation
* refactor(connectors): own read-only query preparation
* refactor(connectors): resolve dialects through registry
* refactor(connectors): keep concrete dialect classes internal
* chore(workspace): enforce dialect import boundary
* refactor(cli): resolve relationship dialect at scan boundary
* refactor(cli): use dialect display parsing for entity details
* refactor(cli): use dialect display parsing for warehouse catalog
* refactor(cli): use dialect SQL in relationship workflows
* test(cli): verify solid dialect scan workflow closure
* test: split cli tests from source tree
* refactor(cli): standardize BigQuery scope listing
* feat(sqlite): implement connector scope listing
* test(connectors): cover required table listing
* feat(cli): add warehouse driver registry
* refactor(setup): route scope discovery through driver registry
* refactor(cli): route local query execution through driver registry
* refactor(historic-sql): route dialect support through driver registry
* refactor(cli): test warehouse connections through driver registry
* fix(cli): close driver registry type export gaps
* Improve setup daemon diagnostics
* refactor(setup): centralize rail-prefixed diagnostics + query-history fallback
Extract errorMessage, writePrefixedLines, and flushPrefixedBufferedCommandOutput
into clack.ts so the setup wizard, managed daemons, and embedding/agent steps
share one rail-formatted writer. setup-databases.ts also adds a
"disable query history and retry" option when the schema-context build fails
and query history is the likely culprit, surfaced via a new
failed-query-history-unavailable status.
* fix(cli): carry catalog through the picker so BigQuery/Snowflake/SQL Server scope filters match
The setup picker's KtxTableListEntry was a 2-level { schema, name }, so
qualifiedTableId always wrote db.name into enabled_tables. When BigQuery,
Snowflake, or SQL Server later ran fast ingest, their introspect step filtered
the scope set with scopedTableNames(scope, { catalog: projectId|database, db })
— catalog was non-null on the introspect side but null in the scope refs, so
every entry was rejected, the live-database adapter staged zero table files,
and detect() failed with 'Adapter "live-database" did not recognize fetched
source output'.
Align the picker boundary with the canonical 3-level KtxTableRef:
- Add catalog: string | null to KtxTableListEntry.
- BigQuery/Snowflake/SQL Server listTables populate catalog from the
resolved projectId / database; Postgres/MySQL/ClickHouse/SQLite set null.
- qualifiedTableId emits catalog.schema.name when catalog is non-null
(resolveEnabledTables already accepts the 3-part shape) and
schemasFromEnabledTables now goes through parseDottedTableEntry so it
recovers the schema correctly from both 2-part and 3-part entries.
- Export parseDottedTableEntry from enabled-tables.ts (@internal) for picker
reuse.
Update listTables expectations in all seven connector tests and the setup /
picker test fixtures. Add a picker regression test that covers the
catalog-bearing round-trip (save + refine).
* fix(cli): allow debug telemetry under opt-out env
212 lines
8.4 KiB
TypeScript
212 lines
8.4 KiB
TypeScript
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
import { SkillsRegistryService } from '../../../src/context/skills/skills-registry.service.js';
|
|
|
|
describe('SkillsRegistryService', () => {
|
|
let service: SkillsRegistryService;
|
|
let tempDir: string;
|
|
|
|
beforeEach(async () => {
|
|
tempDir = await mkdtemp(join(tmpdir(), 'skills-registry-'));
|
|
service = new SkillsRegistryService({ skillsDir: tempDir });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
const writeSkill = async (dirName: string, body: string) => {
|
|
const dir = join(tempDir, dirName);
|
|
await mkdir(dir, { recursive: true });
|
|
await writeFile(join(dir, 'SKILL.md'), body, 'utf-8');
|
|
};
|
|
|
|
describe('parseFrontmatter', () => {
|
|
it('parses name and description', () => {
|
|
const frontmatter = service.parseFrontmatter('---\nname: foo\ndescription: Bar baz\n---\n\n# body');
|
|
expect(frontmatter).toEqual({ name: 'foo', description: 'Bar baz' });
|
|
});
|
|
|
|
it('supports wrapped description continuation lines', () => {
|
|
const frontmatter = service.parseFrontmatter(
|
|
['---', 'name: sl', 'description: Line one', ' continuation of the description.', '---', '', '# body'].join(
|
|
'\n',
|
|
),
|
|
);
|
|
expect(frontmatter.name).toBe('sl');
|
|
expect(frontmatter.description).toContain('Line one');
|
|
expect(frontmatter.description).toContain('continuation');
|
|
});
|
|
|
|
it('returns empty fields when no frontmatter block', () => {
|
|
expect(service.parseFrontmatter('# just a heading')).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe('stripFrontmatter', () => {
|
|
it('removes the frontmatter block and leading blank line', () => {
|
|
const body = '---\nname: x\ndescription: y\n---\n\n# Hello\n\nparagraph';
|
|
expect(service.stripFrontmatter(body)).toBe('# Hello\n\nparagraph');
|
|
});
|
|
|
|
it('is a no-op when no frontmatter exists', () => {
|
|
expect(service.stripFrontmatter('# hello')).toBe('# hello');
|
|
});
|
|
});
|
|
|
|
describe('discoverSkills', () => {
|
|
it('returns an empty map when the directory does not exist', async () => {
|
|
const catalog = await service.discoverSkills(join(tempDir, 'missing'));
|
|
expect(catalog.size).toBe(0);
|
|
});
|
|
|
|
it('discovers valid skills and skips invalid ones', async () => {
|
|
await writeSkill('sl', '---\nname: sl\ndescription: Semantic layer.\n---\n\n# SL');
|
|
await writeSkill('wiki_capture', '---\nname: wiki_capture\ndescription: Wiki capture.\n---\n\n# KC');
|
|
await writeSkill('broken', '# no frontmatter at all');
|
|
await mkdir(join(tempDir, 'not_a_skill'), { recursive: true });
|
|
|
|
const catalog = await service.discoverSkills(tempDir);
|
|
expect(catalog.size).toBe(2);
|
|
expect(catalog.get('sl')?.name).toBe('sl');
|
|
expect(catalog.get('wiki_capture')?.description).toContain('Wiki capture');
|
|
expect(catalog.has('broken')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('buildSkillsPrompt', () => {
|
|
it('formats bullet list with name and description', () => {
|
|
const output = service.buildSkillsPrompt([
|
|
{ name: 'sl', description: 'Semantic layer.', path: '/tmp/sl' },
|
|
{ name: 'wiki_capture', description: 'Wiki capture.', path: '/tmp/kc' },
|
|
]);
|
|
expect(output).toContain('- sl: Semantic layer.');
|
|
expect(output).toContain('- wiki_capture: Wiki capture.');
|
|
expect(output).toContain('Use the `load_skill` tool');
|
|
});
|
|
|
|
it('returns empty string when no skills are available', () => {
|
|
expect(service.buildSkillsPrompt([])).toBe('');
|
|
});
|
|
|
|
it('appends the async capture note for the research caller', () => {
|
|
const output = service.buildSkillsPrompt(
|
|
[{ name: 'sl', description: 'Semantic layer.', path: '/tmp/sl' }],
|
|
'research',
|
|
);
|
|
expect(output).toContain('captured automatically by a post-turn memory agent');
|
|
expect(output).toContain('Focus on answering, not on saving');
|
|
});
|
|
|
|
it('does not append the note for memory_agent caller', () => {
|
|
const output = service.buildSkillsPrompt(
|
|
[{ name: 'sl_capture', description: 'Capture skill.', path: '/tmp/cap' }],
|
|
'memory_agent',
|
|
);
|
|
expect(output).not.toContain('captured automatically by a post-turn memory agent');
|
|
});
|
|
});
|
|
|
|
describe('parseFrontmatter callers field', () => {
|
|
it('parses inline-array form', () => {
|
|
const frontmatter = service.parseFrontmatter('---\nname: x\ndescription: y\ncallers: [memory_agent]\n---\n');
|
|
expect(frontmatter.callers).toEqual(['memory_agent']);
|
|
});
|
|
|
|
it('parses comma-separated form', () => {
|
|
const frontmatter = service.parseFrontmatter('---\nname: x\ndescription: y\ncallers: research, memory_agent\n---\n');
|
|
expect(frontmatter.callers).toEqual(['research', 'memory_agent']);
|
|
});
|
|
|
|
it('returns undefined when callers is absent', () => {
|
|
const frontmatter = service.parseFrontmatter('---\nname: x\ndescription: y\n---\n');
|
|
expect(frontmatter.callers).toBeUndefined();
|
|
});
|
|
|
|
it('drops unknown caller names with a warning', () => {
|
|
const frontmatter = service.parseFrontmatter('---\nname: x\ndescription: y\ncallers: [bogus, memory_agent]\n---\n');
|
|
expect(frontmatter.callers).toEqual(['memory_agent']);
|
|
});
|
|
|
|
it('returns undefined when the value is empty', () => {
|
|
const frontmatter = service.parseFrontmatter('---\nname: x\ndescription: y\ncallers:\n---\n');
|
|
expect(frontmatter.callers).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('listSkills and getSkill caller filter', () => {
|
|
beforeEach(async () => {
|
|
await writeSkill('sl', '---\nname: sl\ndescription: Open to all.\n---\n\n# SL');
|
|
await writeSkill(
|
|
'sl_capture',
|
|
'---\nname: sl_capture\ndescription: Memory-only capture skill.\ncallers: [memory_agent]\n---\n\n# Capture',
|
|
);
|
|
await writeSkill(
|
|
'wiki_capture',
|
|
'---\nname: wiki_capture\ndescription: Wiki capture.\ncallers: [memory_agent]\n---\n\n# KC',
|
|
);
|
|
service = new SkillsRegistryService({ skillsDir: tempDir });
|
|
});
|
|
|
|
it('research caller sees only open skills', async () => {
|
|
const skills = await service.listSkills('research');
|
|
expect(skills.map((skill) => skill.name).sort()).toEqual(['sl']);
|
|
});
|
|
|
|
it('memory_agent caller sees memory-only and open skills', async () => {
|
|
const skills = await service.listSkills('memory_agent');
|
|
expect(skills.map((skill) => skill.name).sort()).toEqual(['sl', 'sl_capture', 'wiki_capture']);
|
|
});
|
|
|
|
it('listSkills with names and caller intersects both filters', async () => {
|
|
const skills = await service.listSkills(['sl', 'sl_capture'], 'research');
|
|
expect(skills.map((skill) => skill.name)).toEqual(['sl']);
|
|
});
|
|
|
|
it('getSkill returns null for memory-only skill when caller is research', async () => {
|
|
const skill = await service.getSkill('sl_capture', 'research');
|
|
expect(skill).toBeNull();
|
|
});
|
|
|
|
it('getSkill returns the skill when caller has access', async () => {
|
|
const skill = await service.getSkill('sl_capture', 'memory_agent');
|
|
expect(skill?.name).toBe('sl_capture');
|
|
});
|
|
|
|
it('getSkill without caller returns the skill regardless of callers field', async () => {
|
|
const skill = await service.getSkill('sl_capture');
|
|
expect(skill?.name).toBe('sl_capture');
|
|
});
|
|
|
|
});
|
|
|
|
it('discovers skills from additional directories when the primary directory misses', async () => {
|
|
const extraDir = await mkdtemp(join(tmpdir(), 'skills-registry-extra-'));
|
|
try {
|
|
await mkdir(join(extraDir, 'wiki_capture'), { recursive: true });
|
|
await writeFile(
|
|
join(extraDir, 'wiki_capture', 'SKILL.md'),
|
|
[
|
|
'---',
|
|
'name: wiki_capture',
|
|
'description: Packaged knowledge capture skill.',
|
|
'callers: [memory_agent]',
|
|
'---',
|
|
'',
|
|
'# Wiki Capture',
|
|
].join('\n'),
|
|
'utf-8',
|
|
);
|
|
service = new SkillsRegistryService({ skillsDir: tempDir, additionalSkillDirs: [extraDir] });
|
|
|
|
const skills = await service.listSkills(['wiki_capture'], 'memory_agent');
|
|
|
|
expect(skills.map((skill) => skill.name)).toEqual(['wiki_capture']);
|
|
expect(skills[0]?.path).toBe(join(extraDir, 'wiki_capture'));
|
|
} finally {
|
|
await rm(extraDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|