import { describe, it, expect, beforeEach } from 'vitest'; import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { makeKnowledgeTools } from '../../src/tools/knowledge.js'; import { writeCache, type CacheFile } from '../../src/lib/cache.js'; import type { Backend, CommitFileArgs, FileWithSha } from '../../src/lib/backend.js'; let wikiRoot: string; let nodeProject: string; let unknownProject: string; beforeEach(async () => { wikiRoot = await mkdtemp(join(tmpdir(), 'kw-wiki-')); await mkdir(join(wikiRoot, 'node'), { recursive: true }); await mkdir(join(wikiRoot, 'embedded'), { recursive: true }); await mkdir(join(wikiRoot, 'cross'), { recursive: true }); await writeFile( join(wikiRoot, 'node', 'yarn-windows.md'), '---\ntitle: Yarn on Windows\ndomain: node\n---\n\nyarn body', ); await writeFile( join(wikiRoot, 'embedded', 'stm32-flash.md'), '---\ntitle: STM32 Flashing\ndomain: embedded\n---\n\nstm32 body', ); await writeFile( join(wikiRoot, 'cross', 'git-trick.md'), '---\ntitle: Git Trick\ndomain: cross\n---\n\ngit yarn body', ); nodeProject = await mkdtemp(join(tmpdir(), 'kw-proj-')); await writeFile(join(nodeProject, 'package.json'), '{}'); unknownProject = await mkdtemp(join(tmpdir(), 'kw-proj-')); }); function call(tools: ReturnType, name: string, args: Record) { const t = tools.find((x) => x.name === name); if (!t) throw new Error(`tool ${name} missing`); return t.handler(args); } describe('knowledge.search (node project)', () => { it('shows node + cross by default, hides embedded', async () => { const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject }); const r = await call(tools, 'knowledge.search', { query: 'yarn' }); const parsed = JSON.parse(r.content[0].text); const slugs = parsed.results.map((x: { slug: string }) => x.slug).sort(); expect(slugs).toEqual(['cross/git-trick', 'node/yarn-windows']); }); it('domain="all" shows everything', async () => { const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject }); const r = await call(tools, 'knowledge.search', { query: '', domain: 'all' }); const parsed = JSON.parse(r.content[0].text); expect(parsed.results.length).toBe(3); }); it('explicit domain switches filter', async () => { const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject }); const r = await call(tools, 'knowledge.search', { query: '', domain: 'embedded' }); const parsed = JSON.parse(r.content[0].text); const slugs = parsed.results.map((x: { slug: string }) => x.slug).sort(); expect(slugs).toEqual(['cross/git-trick', 'embedded/stm32-flash']); }); }); describe('knowledge.search (unknown project)', () => { it('returns all when cwd domain unknown', async () => { const tools = makeKnowledgeTools({ wikiRoot, projectCwd: unknownProject }); const r = await call(tools, 'knowledge.search', { query: '' }); const parsed = JSON.parse(r.content[0].text); expect(parsed.results.length).toBe(3); }); }); describe('knowledge.get', () => { it('returns full page including frontmatter', async () => { const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject }); const r = await call(tools, 'knowledge.get', { slug: 'node/yarn-windows' }); expect(r.content[0].text).toContain('domain: node'); expect(r.content[0].text).toContain('yarn body'); }); it('errors on missing slug', async () => { const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject }); const r = await call(tools, 'knowledge.get', { slug: 'ghost/page' }); expect(r.isError).toBe(true); }); }); describe('knowledge.suggest_promote', () => { it('returns candidates from project .wiki/concepts', async () => { await mkdir(join(nodeProject, '.wiki', 'concepts'), { recursive: true }); await writeFile( join(nodeProject, '.wiki', 'concepts', 'docker-quirk.md'), '---\ntitle: Docker quirk\n---\n\nDocker daemon stuff.', ); const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject }); const r = await call(tools, 'knowledge.suggest_promote', {}); const parsed = JSON.parse(r.content[0].text); expect(parsed.candidates).toHaveLength(1); expect(parsed.candidates[0].slug).toBe('docker-quirk'); expect(parsed.candidates[0].suggested_domain).toBe('node'); }); }); interface RecordedCommit extends CommitFileArgs {} function recordingBackend(initial: Record = {}): { backend: Backend; commits: RecordedCommit[]; files: Record; } { const files = { ...initial }; const commits: RecordedCommit[] = []; const backend: Backend = { async listUserRepos() { return []; }, async getRawFile(_u, repo, path) { return files[`${repo}:${path}`]?.content ?? null; }, async getFileWithSha(_u, repo, path) { return files[`${repo}:${path}`] ?? null; }, async commitFile(args) { commits.push({ ...args }); const fileSha = `sha-${commits.length}`; files[`${args.repo}:${args.path}`] = { content: args.content, sha: fileSha }; return { fileSha, commitSha: `commit-${commits.length}` }; }, }; return { backend, commits, files }; } const fixedNow = () => new Date('2026-04-29T12:00:00Z'); const fixture: CacheFile = { schemaVersion: 3, synced_at: '2026-04-29T12:00:00.000Z', synced_from: 'https://g', machine: 'm', projects: [ { name: 'OpeItcLoc03/alpha', default_branch: 'main', fetched_at: '2026-04-29T12:00:00Z', active_tasks: [], all_tasks_count: 0, raw: '' }, { name: 'victor/books', default_branch: 'main', fetched_at: '2026-04-29T12:00:00Z', active_tasks: [], all_tasks_count: 0, raw: '' }, { name: 'projects-wiki', default_branch: 'main', fetched_at: '2026-04-29T12:00:00Z', active_tasks: [], all_tasks_count: 0, raw: '' }, ], errors: [], }; async function makeCacheFile(): Promise { const dir = await mkdtemp(join(tmpdir(), 'kw-cache-')); const f = join(dir, 'tasks.json'); await writeCache(f, fixture); return f; } describe('knowledge.ingest — preview', () => { it('returns preview without committing', async () => { const cacheFile = await makeCacheFile(); const { backend, commits } = recordingBackend({}); const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject, cacheFile, backend, giteaUser: 'u', projectsWikiRepo: 'projects-wiki', machine: 'host-x', sourceProject: 'modules-db', now: fixedNow, }); const r = await call(tools, 'knowledge.ingest', { target_project: 'OpeItcLoc03/alpha', type: 'concepts', slug: 'tasks-routing-rule', body: 'Read local STATUS.md for current project.', }); const parsed = JSON.parse(r.content[0].text); expect(parsed.preview).toBe(true); expect(parsed.target_owner).toBe('OpeItcLoc03'); expect(parsed.target_repo).toBe('alpha'); expect(parsed.target_path).toBe('.wiki/concepts/tasks-routing-rule.md'); expect(parsed.content).toContain('title: tasks-routing-rule'); expect(parsed.content).toContain('source_project: modules-db'); expect(commits).toEqual([]); }); it('rejects bare target_project with hint', async () => { const cacheFile = await makeCacheFile(); const { backend, commits } = recordingBackend({}); const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject, cacheFile, backend, giteaUser: 'u', projectsWikiRepo: 'projects-wiki', now: fixedNow, }); const r = await call(tools, 'knowledge.ingest', { target_project: 'alpha', type: 'concepts', slug: 'x', body: 'b', }); expect(r.isError).toBe(true); expect(r.content[0].text).toContain('must be qualified'); expect(commits).toEqual([]); }); }); describe('knowledge.ingest — confirm', () => { it('writes page + index + log (3 commits) and removes (none yet) placeholder', async () => { const cacheFile = await makeCacheFile(); const { backend, commits, files } = recordingBackend({}); const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject, cacheFile, backend, giteaUser: 'u', projectsWikiRepo: 'projects-wiki', machine: 'host-x', sourceProject: 'modules-db', now: fixedNow, }); const r = await call(tools, 'knowledge.ingest', { target_project: 'OpeItcLoc03/alpha', type: 'concepts', slug: 'routing-rule', body: 'rule body', frontmatter: { title: 'Tasks Routing Rule', tags: ['routing'] }, confirm: true, }); const parsed = JSON.parse(r.content[0].text); expect(parsed.committed).toBe(true); expect(parsed.target_owner).toBe('OpeItcLoc03'); expect(parsed.target_repo).toBe('alpha'); expect(parsed.commits).toHaveLength(3); const paths = parsed.commits.map((c: { path: string }) => c.path); expect(paths).toEqual(['.wiki/concepts/routing-rule.md', '.wiki/index.md', '.wiki/log.md']); expect(files['alpha:.wiki/concepts/routing-rule.md'].content).toContain('Tasks Routing Rule'); expect(files['alpha:.wiki/index.md'].content).toContain('- [routing-rule](concepts/routing-rule.md) — Tasks Routing Rule'); expect(files['alpha:.wiki/log.md'].content).toContain('## [2026-04-29] ingest | concepts/routing-rule'); // Three commits = page + index + log expect(commits).toHaveLength(3); expect(commits[0].user).toBe('OpeItcLoc03'); expect(commits[0].repo).toBe('alpha'); }); it('routes victor/books to owner=victor', async () => { const cacheFile = await makeCacheFile(); const { backend, commits } = recordingBackend({}); const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject, cacheFile, backend, giteaUser: 'u', projectsWikiRepo: 'projects-wiki', machine: 'host-x', sourceProject: 'modules-db', now: fixedNow, }); const r = await call(tools, 'knowledge.ingest', { target_project: 'victor/books', type: 'concepts', slug: 'pagination', body: 'how to paginate', confirm: true, }); const parsed = JSON.parse(r.content[0].text); expect(parsed.committed).toBe(true); expect(parsed.target_owner).toBe('victor'); expect(parsed.target_repo).toBe('books'); expect(commits[0].user).toBe('victor'); expect(commits[0].repo).toBe('books'); }); it('rejects when page already exists', async () => { const cacheFile = await makeCacheFile(); const { backend, commits } = recordingBackend({ 'alpha:.wiki/concepts/x.md': { content: '---\ntitle: x\n---\n', sha: 's' }, }); const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject, cacheFile, backend, giteaUser: 'u', projectsWikiRepo: 'projects-wiki', now: fixedNow, }); const r = await call(tools, 'knowledge.ingest', { target_project: 'OpeItcLoc03/alpha', type: 'concepts', slug: 'x', body: 'b', confirm: true, }); expect(r.isError).toBe(true); expect(r.content[0].text).toContain('already exists'); expect(commits).toEqual([]); }); it('rejects unknown target_project (cache miss)', async () => { const cacheFile = await makeCacheFile(); const { backend, commits } = recordingBackend({}); const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject, cacheFile, backend, giteaUser: 'u', projectsWikiRepo: 'projects-wiki', }); const r = await call(tools, 'knowledge.ingest', { target_project: 'ghost/repo', type: 'concepts', slug: 's', body: 'b', confirm: true, }); expect(r.isError).toBe(true); expect(r.content[0].text).toContain('not in cache'); expect(commits).toEqual([]); }); it('write tools disabled when no backend/auth', async () => { const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject }); const r = await call(tools, 'knowledge.ingest', { target_project: 'OpeItcLoc03/alpha', type: 'concepts', slug: 's', body: 'b', }); expect(r.isError).toBe(true); expect(r.content[0].text).toContain('Write tools disabled'); }); }); describe('knowledge.promote', () => { it('preview returns sources content with raw_path frontmatter', async () => { const cacheFile = await makeCacheFile(); const { backend, commits } = recordingBackend({ 'alpha:.wiki/raw/x.md': { content: '# raw\n', sha: 'r' }, }); const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject, cacheFile, backend, giteaUser: 'u', projectsWikiRepo: 'projects-wiki', now: fixedNow, }); const r = await call(tools, 'knowledge.promote', { target_project: 'OpeItcLoc03/alpha', slug: 'x', body: 'summary of raw', }); const parsed = JSON.parse(r.content[0].text); expect(parsed.preview).toBe(true); expect(parsed.target_owner).toBe('OpeItcLoc03'); expect(parsed.target_repo).toBe('alpha'); expect(parsed.target_path).toBe('.wiki/sources/x.md'); expect(parsed.content).toContain('raw_path: raw/x.md'); expect(commits).toEqual([]); }); it('rejects bare target_project', async () => { const cacheFile = await makeCacheFile(); const { backend, commits } = recordingBackend({}); const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject, cacheFile, backend, giteaUser: 'u', projectsWikiRepo: 'projects-wiki', now: fixedNow, }); const r = await call(tools, 'knowledge.promote', { target_project: 'alpha', slug: 'x', body: 'b', confirm: true, }); expect(r.isError).toBe(true); expect(r.content[0].text).toContain('must be qualified'); expect(commits).toEqual([]); }); it('rejects when raw page is missing', async () => { const cacheFile = await makeCacheFile(); const { backend, commits } = recordingBackend({}); const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject, cacheFile, backend, giteaUser: 'u', projectsWikiRepo: 'projects-wiki', now: fixedNow, }); const r = await call(tools, 'knowledge.promote', { target_project: 'OpeItcLoc03/alpha', slug: 'missing', body: 'b', confirm: true, }); expect(r.isError).toBe(true); expect(r.content[0].text).toContain('raw page not found'); expect(commits).toEqual([]); }); it('confirm writes sources + updates index/log', async () => { const cacheFile = await makeCacheFile(); const { backend, commits, files } = recordingBackend({ 'alpha:.wiki/raw/notes.md': { content: '# notes\n', sha: 'r' }, }); const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject, cacheFile, backend, giteaUser: 'u', projectsWikiRepo: 'projects-wiki', now: fixedNow, }); const r = await call(tools, 'knowledge.promote', { target_project: 'OpeItcLoc03/alpha', slug: 'notes', body: 'summary text', frontmatter: { title: 'Notes Summary' }, confirm: true, }); const parsed = JSON.parse(r.content[0].text); expect(parsed.committed).toBe(true); expect(parsed.target_owner).toBe('OpeItcLoc03'); expect(parsed.target_repo).toBe('alpha'); expect(parsed.commits.map((c: { path: string }) => c.path)).toEqual([ '.wiki/sources/notes.md', '.wiki/index.md', '.wiki/log.md', ]); expect(files['alpha:.wiki/sources/notes.md'].content).toContain('raw_path: raw/notes.md'); expect(files['alpha:.wiki/log.md'].content).toContain('promote | raw/notes → sources/notes'); expect(commits).toHaveLength(3); expect(commits[0].user).toBe('OpeItcLoc03'); expect(commits[0].repo).toBe('alpha'); }); });