ktworkflow-mcp/src/tools/obsidian.ts

1355 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { existsSync, readdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
import type { ObsidianContextInput, ObsidianSyncInput, ObsidianValidateInput, ProjectAlias } from '../types.js';
import { projectAliases, projectLabels } from '../core/constants.js';
import { detectRepoType } from '../core/project.js';
import { resolveInsideRoot, resolveProject, toPosix, workspaceRoot } from '../core/workspace.js';
type FindingLevel = 'error' | 'info' | 'warning';
interface ObsidianFinding {
check: string;
level: FindingLevel;
message: string;
path?: string;
target?: string;
}
interface CanvasNode {
color?: string;
file?: string;
height?: number;
id?: string;
label?: string;
text?: string;
type?: string;
width?: number;
x?: number;
y?: number;
}
interface CanvasEdge {
fromNode?: string;
id?: string;
toNode?: string;
}
interface CanvasDocument {
edges?: CanvasEdge[];
nodes?: CanvasNode[];
}
interface ObsidianSettings {
attachmentFolderPath?: string;
userIgnoreFilters?: string[];
}
interface BookmarkItem {
items?: BookmarkItem[];
path?: string;
title?: string;
type?: string;
}
interface BookmarkSettings {
items?: BookmarkItem[];
}
interface ObsidianContextDocument {
excerpt?: string;
links: string[];
path: string;
tags: string[];
title: string;
}
interface CodeAnchorCatalogItem {
alias: ProjectAlias;
keyFiles: string[];
keywords: string[];
ruleFiles: string[];
sourceRoots: string[];
}
interface CodeAnchorResult {
alias: ProjectAlias;
exists: boolean;
inspectTool: {
input: {
includeGit: boolean;
project: ProjectAlias;
};
name: 'kt_inspect_project';
};
keyFiles: Array<{
exists: boolean;
path: string;
}>;
label: string;
projectPath: string;
repoType: string;
riskTool: {
input: {
changedFiles: string[];
project: ProjectAlias;
};
name: 'kt_risk_scan';
};
score: number;
ruleFiles: Array<{
exists: boolean;
path: string;
}>;
sourceRoots: Array<{
exists: boolean;
path: string;
}>;
verificationTool: {
input: {
changeType: 'general' | 'mcp';
project: ProjectAlias;
};
name: 'kt_suggest_verification';
};
}
const obsidianConfigFiles = [
'.obsidian/app.json',
'.obsidian/appearance.json',
'.obsidian/bookmarks.json',
'.obsidian/community-plugins.json',
'.obsidian/core-plugins.json',
'.obsidian/graph.json',
'.obsidian/templates.json',
];
const expectedObsidianFiles = [
'docs/obsidian/KT 知识图谱.md',
'docs/obsidian/KT 项目关系.md',
'docs/obsidian/KT 业务能力关系.md',
'docs/obsidian/KT 发布运维关系.md',
'docs/obsidian/KT 工作流关系.md',
'docs/obsidian/KT Obsidian 工程化.md',
'docs/obsidian/KT 工作流工程化.md',
'docs/obsidian/KT 文档矩阵.md',
'docs/obsidian/KT 文档矩阵.base',
'docs/obsidian/KT 关系图谱.canvas',
'docs/obsidian/modules/KT 模块索引.md',
'docs/obsidian/modules/KT 模块 - Admin.md',
'docs/obsidian/modules/KT 模块 - API Backend.md',
'docs/obsidian/modules/KT 模块 - Blog Web.md',
'docs/obsidian/modules/KT 模块 - CI CD NAS.md',
'docs/obsidian/modules/KT 模块 - Data Observability.md',
'docs/obsidian/modules/KT 模块 - KtTable.md',
'docs/obsidian/modules/KT 模块 - Plugins.md',
'docs/obsidian/modules/KT 模块 - Public Web Playground.md',
'docs/obsidian/modules/KT 模块 - QQBot BangDream FFLogs.md',
'docs/obsidian/modules/KT 模块 - Root Governance.md',
'docs/obsidian/modules/KT 模块 - ktWorkflow Skills.md',
];
const requiredBookmarkPaths = [
'AGENTS.md',
'TASKS.md',
'SKILLS.md',
'docs/obsidian/KT 知识图谱.md',
'docs/obsidian/modules/KT 模块索引.md',
'docs/obsidian/KT 文档矩阵.md',
'docs/obsidian/KT 关系图谱.canvas',
'docs/obsidian/KT 文档矩阵.base',
'docs/kt-testing-workflows.md',
];
const requiredCorePlugins = [
'backlink',
'bases',
'bookmarks',
'canvas',
'command-palette',
'global-search',
'graph',
'outgoing-link',
'templates',
];
const expectedIgnoredRoots = [
'.git/',
'.kt-workspace/',
'Node/',
'Vue/',
'Plugins/',
'mcp/',
'GitHub/',
'Other/',
'workspace-assets/',
];
const requiredBaseViewNames = [
'开工路由',
'模块-仓库矩阵',
'工作流入口',
'验证与收尾',
'运行态与远程排查',
'文档归口',
];
const requiredCanvasGroupLabels = [
'00 当前上下文',
'10 路由索引',
'20 模块仓库',
'30 工作流执行',
'40 验证与固化',
];
const canvasTextNodeMaxChars = 180;
const defaultContextPaths = [
'docs/obsidian/KT 知识图谱.md',
'docs/obsidian/modules/KT 模块索引.md',
'docs/obsidian/KT 文档矩阵.md',
'docs/obsidian/KT 工作流关系.md',
];
const codeAnchorCatalog: CodeAnchorCatalogItem[] = [
{
alias: 'root',
keyFiles: ['AGENTS.md', 'SKILLS.md', 'TASKS.md', 'docs/kt-workspace-structure.md', 'docs/kt-context-semantics.md'],
keywords: ['root', 'governance', 'agents', 'skills', 'tasks', 'obsidian', '上下文', '根目录', '治理', '图谱'],
ruleFiles: ['AGENTS.md', 'SKILLS.md', 'TASKS.md', 'docs/kt-context-semantics.md', 'docs/kt-workspace-structure.md'],
sourceRoots: ['docs', 'skills', 'ci'],
},
{
alias: 'mcp',
keyFiles: ['mcp/ktWorkflow/README.md', 'mcp/ktWorkflow/src/server.ts', 'mcp/ktWorkflow/src/registerTools.ts', 'mcp/ktWorkflow/src/tools'],
keywords: ['mcp', 'ktworkflow', 'workflow', 'skill', 'tools', '工作流', '校验', '上下文工具'],
ruleFiles: ['AGENTS.md', 'SKILLS.md', 'TASKS.md', 'docs/kt-testing-workflows.md', 'mcp/ktWorkflow/README.md'],
sourceRoots: ['mcp/ktWorkflow/src', 'mcp/ktWorkflow/scripts'],
},
{
alias: 'api',
keyFiles: ['Node/kt-template-online-api/README.md', 'Node/kt-template-online-api/API.md', 'Node/kt-template-online-api/src/qqbot', 'Node/kt-template-online-api/src/admin'],
keywords: ['api', 'backend', 'nestjs', 'qqbot', 'bangdream', 'fflogs', 'napcat', 'systemlog', '后端', '接口', '日志'],
ruleFiles: ['AGENTS.md', 'Node/kt-template-online-api/README.md', 'Node/kt-template-online-api/API.md', 'docs/kt-testing-workflows.md', 'docs/qqbot-nas-runtime.md'],
sourceRoots: ['Node/kt-template-online-api/src', 'Node/kt-template-online-api/test'],
},
{
alias: 'admin',
keyFiles: ['Vue/kt-template-admin/README.md', 'Vue/kt-template-admin/skills/SKILL.md', 'Vue/kt-template-admin/apps/web-antdv-next/src'],
keywords: ['admin', 'vben', 'kttable', 'profile', 'system', '后台', '表格', '个人中心', '系统管理'],
ruleFiles: ['AGENTS.md', 'Vue/kt-template-admin/README.md', 'Vue/kt-template-admin/skills/SKILL.md', 'docs/kt-testing-workflows.md'],
sourceRoots: ['Vue/kt-template-admin/apps/web-antdv-next/src'],
},
{
alias: 'blog',
keyFiles: ['Vue/kt-blog-web/README.md', 'Vue/kt-blog-web/src'],
keywords: ['blog', 'argon', 'wordpress', '博客', '主题'],
ruleFiles: ['AGENTS.md', 'Vue/kt-blog-web/README.md', 'skills/kt-component-specialist/SKILL.md', 'docs/kt-testing-workflows.md'],
sourceRoots: ['Vue/kt-blog-web/src'],
},
{
alias: 'web',
keyFiles: ['Vue/kt-template-online-web/README.md', 'Vue/kt-template-online-web/src'],
keywords: ['web', 'public', 'online', '前台', '展示'],
ruleFiles: ['AGENTS.md', 'Vue/kt-template-online-web/README.md', 'docs/kt-testing-workflows.md'],
sourceRoots: ['Vue/kt-template-online-web/src'],
},
{
alias: 'playground',
keyFiles: ['Vue/kt-template-online-playground/README.md', 'Vue/kt-template-online-playground/src'],
keywords: ['playground', 'repl', 'component', '组件', '预览', '保存'],
ruleFiles: ['AGENTS.md', 'Vue/kt-template-online-playground/README.md', 'docs/kt-testing-workflows.md'],
sourceRoots: ['Vue/kt-template-online-playground/src'],
},
{
alias: 'knife4j',
keyFiles: ['Plugins/knife4j-swagger-vue3/README.md', 'Plugins/knife4j-swagger-vue3/src'],
keywords: ['knife4j', 'swagger', 'openapi', '插件'],
ruleFiles: ['AGENTS.md', 'Plugins/knife4j-swagger-vue3/AGENTS.md', 'Plugins/knife4j-swagger-vue3/README.md', 'docs/kt-testing-workflows.md'],
sourceRoots: ['Plugins/knife4j-swagger-vue3/src'],
},
{
alias: 'fnosK8s',
keyFiles: ['Plugins/fnos-k8s-dashboard-fpk/README.md', 'Plugins/fnos-k8s-dashboard-fpk/AGENTS.md', 'Plugins/fnos-k8s-dashboard-fpk/apps/kt-k8s-dashboard'],
keywords: ['fnos', 'k8s', 'kubernetes', 'fpk', 'nas', 'jenkins', 'caddy', '飞牛', '插件', '部署'],
ruleFiles: ['AGENTS.md', 'Plugins/fnos-k8s-dashboard-fpk/AGENTS.md', 'Plugins/fnos-k8s-dashboard-fpk/README.md', 'docs/fnos-docker-jenkins-k8s-standard.md', 'docs/tencent-cloud-gateway-runtime.md'],
sourceRoots: ['Plugins/fnos-k8s-dashboard-fpk/apps/kt-k8s-dashboard'],
},
];
const staleReferencePatterns = [
'docs/next-session-context-019e2f8f.md',
'next-session-context-019e2f8f',
'docs/fnos-docker-jenkins-k8s-technical-solution.md',
'fnos-docker-jenkins-k8s-technical-solution',
'yd.frp-bag.com',
'45122',
];
const allowedStaleReferenceHints = [
'Do not use stale historical',
'不要使用旧入口',
'旧 FRP',
'historical FRP',
'历史 FRP',
'旧文档清理',
];
const ignoredDirectoryNames = new Set([
'.cache',
'.git',
'.kt-workspace',
'.next',
'.pnpm',
'.turbo',
'.vite',
'build',
'coverage',
'dist',
'node_modules',
'target',
]);
function normalizeInput(input: ObsidianValidateInput = {}): Required<ObsidianValidateInput> {
return {
checkLayout: input.checkLayout !== false,
failOnWarnings: input.failOnWarnings === true,
includeMarkdownLinks: input.includeMarkdownLinks !== false,
includeStaleReferences: input.includeStaleReferences !== false,
};
}
function readTextFile(relativePath: string): string {
return readFileSync(resolveInsideRoot(relativePath), 'utf8');
}
/**
* Resolves the Obsidian display title from frontmatter, the first H1, or the file name.
* @param content - Markdown file content read from the KT vault.
* @param fallback - Workspace-relative file path used when title metadata is absent.
* @returns The title shown in Obsidian context output.
*/
function getMarkdownTitle(content: string, fallback: string): string {
return (
content.match(/^title:\s*(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, '') ||
content.match(/^#\s+(.+)$/m)?.[1]?.trim() ||
path.basename(fallback, path.extname(fallback))
);
}
/**
* Extracts tags from the YAML frontmatter subset used by KT Obsidian notes.
* @param content - Markdown content that may start with frontmatter.
* @returns Tag values without additional normalization.
*/
function getMarkdownTags(content: string): string[] {
const frontmatter = content.match(/^---\n([\s\S]*?)\n---/);
if (!frontmatter) return [];
const tagsBlock = frontmatter[1]?.match(/^tags:\n((?:\s+-\s+.+\n?)+)/m)?.[1] || '';
return tagsBlock
.split(/\r?\n/)
.map((line) => line.match(/^\s+-\s+(.+)$/)?.[1]?.trim())
.filter((tag): tag is string => Boolean(tag));
}
/**
* Removes matching quote wrappers from a YAML-like scalar used by KT Obsidian metadata.
* @param value - Raw scalar value from frontmatter or a Base view line.
* @returns The unquoted scalar value.
*/
function stripYamlQuotes(value: string): string {
return value.trim().replace(/^["']|["']$/g, '');
}
/**
* Parses the small frontmatter subset that KT uses for Obsidian routing fields.
* @param content - Markdown content with optional leading frontmatter.
* @returns Frontmatter fields as scalar strings or string arrays.
*/
function parseFrontmatterFields(content: string): Map<string, string | string[]> {
const frontmatter = content.match(/^---\n([\s\S]*?)\n---/);
const fields = new Map<string, string | string[]>();
if (!frontmatter?.[1]) return fields;
const lines = frontmatter[1].split(/\r?\n/);
let activeArrayKey: string | null = null;
for (const line of lines) {
const arrayItem = line.match(/^\s+-\s+(.+)$/);
if (arrayItem?.[1] && activeArrayKey) {
const current = fields.get(activeArrayKey);
const values = Array.isArray(current) ? current : [];
values.push(stripYamlQuotes(arrayItem[1]));
fields.set(activeArrayKey, values);
continue;
}
const keyValue = line.match(/^([A-Za-z0-9_-]+):(?:\s*(.*))?$/);
if (!keyValue?.[1]) {
activeArrayKey = null;
continue;
}
const key = keyValue[1];
const rawValue = keyValue[2]?.trim() || '';
if (!rawValue) {
fields.set(key, []);
activeArrayKey = key;
continue;
}
fields.set(key, stripYamlQuotes(rawValue));
activeArrayKey = null;
}
return fields;
}
function extractWikiLinks(content: string): string[] {
return [...content.matchAll(/\[\[([^\]\n]+)\]\]/g)]
.map((match) => match[1]?.split('|')[0]?.split('#')[0]?.trim())
.filter((value): value is string => Boolean(value));
}
function normalizeSearchValue(value: string): string {
return value.trim().toLowerCase();
}
function buildExcerpt(content: string, terms: string[]): string {
const body = content.replace(/^---\n[\s\S]*?\n---\n?/, '');
const lines = body
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('---'));
const matchedLine = lines.find((line) => {
const loweredLine = normalizeSearchValue(line);
return terms.some((term) => loweredLine.includes(term));
});
return (matchedLine || lines[0] || '').slice(0, 220);
}
function readContextDocument(relativePath: string, includeContent: boolean, terms: string[] = []): ObsidianContextDocument {
const content = readTextFile(relativePath);
const document: ObsidianContextDocument = {
links: extractWikiLinks(content),
path: relativePath,
tags: getMarkdownTags(content),
title: getMarkdownTitle(content, relativePath),
};
if (includeContent) {
document.excerpt = buildExcerpt(content, terms);
}
return document;
}
function listObsidianMarkdownPaths(): string[] {
return [
...listFiles(resolveInsideRoot('docs/obsidian'), new Set(['.md'])).map(relativeToWorkspace),
'AGENTS.md',
'SKILLS.md',
'TASKS.md',
].filter((file) => existsSync(resolveInsideRoot(file)));
}
function scoreContextPath(relativePath: string, terms: string[]): number {
if (terms.length === 0) return defaultContextPaths.includes(relativePath) ? 10 : 0;
const content = readTextFile(relativePath);
const haystack = normalizeSearchValue(`${relativePath}\n${content}`);
return terms.reduce((score, term) => score + (haystack.includes(term) ? 1 : 0), 0);
}
function buildContextTerms(input: ObsidianContextInput): string[] {
return [input.module, input.query]
.filter((value): value is string => Boolean(value?.trim()))
.flatMap((value) => value.split(/[,\s]+/))
.map(normalizeSearchValue)
.filter(Boolean);
}
/**
* Extracts linked index targets from text nodes so Canvas can stay readable without file previews.
* @param nodes - Parsed JSON Canvas nodes from the KT relationship graph.
* @returns Text-node link summaries used by obsidian-context output.
*/
function extractCanvasIndexLinks(nodes: CanvasNode[]): Array<{ color?: string; id?: string; links: string[] }> {
return nodes
.filter((node) => node.type === 'text' && node.text)
.map((node) => ({
color: node.color,
id: node.id,
links: extractWikiLinks(node.text || ''),
}))
.filter((node) => node.links.length > 0);
}
/**
* Reads a lightweight Canvas summary for context routing without embedding full note previews.
* @param includeCanvas - Explicit false disables the Canvas summary for callers that need smaller output.
* @returns Canvas node/edge counts plus indexed wikilink targets, or null when disabled/unreadable.
*/
function readCanvasSummary(includeCanvas: boolean | undefined): Record<string, unknown> | null {
if (includeCanvas === false) return null;
const canvas = readJsonFile<CanvasDocument>('docs/obsidian/KT 关系图谱.canvas', []);
if (!canvas) return null;
const nodes = Array.isArray(canvas.nodes) ? canvas.nodes : [];
const fileNodes = nodes
.filter((node) => node.type === 'file' && node.file)
.map((node) => ({
color: node.color,
file: node.file,
id: node.id,
}));
return {
edges: Array.isArray(canvas.edges) ? canvas.edges.length : 0,
fileNodes,
indexLinks: extractCanvasIndexLinks(nodes),
nodes: nodes.length,
};
}
function scoreCodeAnchor(anchor: CodeAnchorCatalogItem, terms: string[]): number {
if (terms.length === 0) return anchor.alias === 'root' || anchor.alias === 'mcp' ? 1 : 0;
const haystack = normalizeSearchValue([
anchor.alias,
projectLabels[anchor.alias],
...anchor.keywords,
...anchor.keyFiles,
...anchor.sourceRoots,
].join('\n'));
return terms.reduce((score, term) => score + (haystack.includes(term) ? 1 : 0), 0);
}
function buildFileAnchor(relativePath: string): { exists: boolean; path: string } {
return {
exists: existsSync(resolveInsideRoot(relativePath)),
path: relativePath,
};
}
function buildRuleAnchors(codeAnchors: CodeAnchorResult[]): Array<{ exists: boolean; path: string }> {
const rulePaths = new Set([
'AGENTS.md',
'SKILLS.md',
'TASKS.md',
'docs/kt-testing-workflows.md',
...codeAnchors.flatMap((anchor) => anchor.ruleFiles.map((file) => file.path)),
]);
return [...rulePaths].map(buildFileAnchor);
}
function buildCodeAnchors(terms: string[], maxAnchors = 5): CodeAnchorResult[] {
return codeAnchorCatalog
.map((anchor) => ({
anchor,
score: scoreCodeAnchor(anchor, terms),
}))
.filter((item) => item.score > 0)
.sort((left, right) => {
if (right.score !== left.score) return right.score - left.score;
return left.anchor.alias.localeCompare(right.anchor.alias);
})
.slice(0, maxAnchors)
.map(({ anchor, score }) => {
const project = resolveProject(anchor.alias);
return {
alias: anchor.alias,
exists: existsSync(project.path),
inspectTool: {
input: {
includeGit: true,
project: anchor.alias,
},
name: 'kt_inspect_project',
},
keyFiles: anchor.keyFiles.map((file) => ({
exists: existsSync(resolveInsideRoot(file)),
path: file,
})),
label: projectLabels[anchor.alias],
projectPath: project.relativePath,
repoType: existsSync(project.path) ? detectRepoType(project.path) : 'missing',
riskTool: {
input: {
changedFiles: [],
project: anchor.alias,
},
name: 'kt_risk_scan',
},
score,
ruleFiles: anchor.ruleFiles.map(buildFileAnchor),
sourceRoots: anchor.sourceRoots.map((file) => ({
exists: existsSync(resolveInsideRoot(file)),
path: file,
})),
verificationTool: {
input: {
changeType: anchor.alias === 'mcp' ? 'mcp' : 'general',
project: anchor.alias,
},
name: 'kt_suggest_verification',
},
};
});
}
function readJsonFile<T>(relativePath: string, findings: ObsidianFinding[]): T | null {
if (!existsSync(resolveInsideRoot(relativePath))) {
findings.push({
check: 'json',
level: 'error',
message: '文件不存在。',
path: relativePath,
});
return null;
}
try {
return JSON.parse(readTextFile(relativePath)) as T;
} catch (error) {
findings.push({
check: 'json',
level: 'error',
message: error instanceof Error ? error.message : 'JSON 解析失败。',
path: relativePath,
});
return null;
}
}
function listFiles(absoluteRoot: string, extensions: Set<string>, output: string[] = []): string[] {
if (!existsSync(absoluteRoot)) return output;
for (const entry of readdirSync(absoluteRoot, { withFileTypes: true })) {
if (entry.isDirectory()) {
if (!ignoredDirectoryNames.has(entry.name)) {
listFiles(path.join(absoluteRoot, entry.name), extensions, output);
}
continue;
}
if (entry.isFile() && extensions.has(path.extname(entry.name).toLowerCase())) {
output.push(path.join(absoluteRoot, entry.name));
}
}
return output;
}
function relativeToWorkspace(filePath: string): string {
return toPosix(path.relative(workspaceRoot, filePath));
}
function stripKnownExtension(value: string): string {
return value.replace(/\.(base|canvas|md)$/i, '');
}
function buildFileIndex(): { basename: Map<string, string[]>; full: Set<string> } {
const files = listFiles(workspaceRoot, new Set(['.base', '.canvas', '.md']));
const full = new Set<string>();
const basename = new Map<string, string[]>();
for (const file of files) {
const relativePath = relativeToWorkspace(file);
const fullKey = stripKnownExtension(relativePath);
const baseKey = stripKnownExtension(path.basename(relativePath));
full.add(fullKey);
full.add(relativePath);
basename.set(baseKey, [...(basename.get(baseKey) || []), relativePath]);
}
return { basename, full };
}
function isWikiLinkResolved(target: string, fileIndex: ReturnType<typeof buildFileIndex>): boolean {
const normalizedTarget = stripKnownExtension(target.replaceAll('\\', '/'));
if (fileIndex.full.has(normalizedTarget) || fileIndex.full.has(target)) return true;
if (normalizedTarget.includes('/')) return false;
return (fileIndex.basename.get(normalizedTarget) || []).length === 1;
}
function getObsidianMarkdownFiles(): string[] {
return [
...listFiles(resolveInsideRoot('docs/obsidian'), new Set(['.md'])),
...listFiles(resolveInsideRoot('.obsidian/templates'), new Set(['.md'])),
];
}
function validateWikiLinks(findings: ObsidianFinding[]): number {
const fileIndex = buildFileIndex();
let linkCount = 0;
for (const absolutePath of getObsidianMarkdownFiles()) {
const relativePath = relativeToWorkspace(absolutePath);
const content = readFileSync(absolutePath, 'utf8');
const matches = content.matchAll(/\[\[([^\]\n]+)\]\]/g);
for (const match of matches) {
linkCount += 1;
const rawTarget = match[1]?.split('|')[0]?.split('#')[0]?.trim() || '';
if (!rawTarget) continue;
if (!isWikiLinkResolved(rawTarget, fileIndex)) {
findings.push({
check: 'wikilink',
level: 'error',
message: '内部链接无法解析到工作区文件。',
path: relativePath,
target: rawTarget,
});
}
}
}
return linkCount;
}
function isExternalMarkdownTarget(target: string): boolean {
return /^(https?:|mailto:|data:|obsidian:)/i.test(target);
}
function decodeLinkTarget(target: string): string {
try {
return decodeURIComponent(target);
} catch {
return target;
}
}
function validateMarkdownLinks(findings: ObsidianFinding[]): number {
let linkCount = 0;
for (const absolutePath of getObsidianMarkdownFiles()) {
const relativePath = relativeToWorkspace(absolutePath);
const content = readFileSync(absolutePath, 'utf8');
const matches = content.matchAll(/!?\[[^\]\n]*\]\(([^)\n]+)\)/g);
for (const match of matches) {
const rawTarget = match[1]?.trim().replace(/^<|>$/g, '') || '';
const targetWithoutAnchor = decodeLinkTarget(rawTarget.split('#')[0]?.trim() || '');
if (!targetWithoutAnchor || targetWithoutAnchor.startsWith('#') || isExternalMarkdownTarget(targetWithoutAnchor)) {
continue;
}
linkCount += 1;
const absoluteTarget = path.resolve(path.dirname(absolutePath), targetWithoutAnchor);
const relativeTarget = path.relative(workspaceRoot, absoluteTarget);
if (relativeTarget.startsWith('..') || path.isAbsolute(relativeTarget)) {
findings.push({
check: 'markdown-link',
level: 'error',
message: 'Markdown 相对链接指向工作区外。',
path: relativePath,
target: rawTarget,
});
continue;
}
if (!existsSync(absoluteTarget)) {
findings.push({
check: 'markdown-link',
level: 'error',
message: 'Markdown 相对链接目标不存在。',
path: relativePath,
target: rawTarget,
});
}
}
}
return linkCount;
}
function hasNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function rectanglesOverlap(a: CanvasNode, b: CanvasNode): boolean {
if (!hasNumber(a.x) || !hasNumber(a.y) || !hasNumber(a.width) || !hasNumber(a.height)) return false;
if (!hasNumber(b.x) || !hasNumber(b.y) || !hasNumber(b.width) || !hasNumber(b.height)) return false;
return (
a.x < b.x + b.width &&
a.x + a.width > b.x &&
a.y < b.y + b.height &&
a.y + a.height > b.y
);
}
/**
* Validates JSON Canvas IDs, file targets, edge references, lane groups, and optional visual overlap.
* @param findings - Shared finding accumulator for the current Obsidian validation run.
* @param checkLayout - Whether non-group node overlap should be reported as a warning.
* @returns Basic Canvas counts for command output summaries.
*/
function validateCanvas(
findings: ObsidianFinding[],
checkLayout: boolean,
): { edgeCount: number; fileNodeCount: number; groupCount: number; indexLinkCount: number; nodeCount: number } {
const canvasPath = 'docs/obsidian/KT 关系图谱.canvas';
const canvas = readJsonFile<CanvasDocument>(canvasPath, findings);
if (!canvas) return { edgeCount: 0, fileNodeCount: 0, groupCount: 0, indexLinkCount: 0, nodeCount: 0 };
const nodes = Array.isArray(canvas.nodes) ? canvas.nodes : [];
const edges = Array.isArray(canvas.edges) ? canvas.edges : [];
const fileNodeCount = nodes.filter((node) => node.type === 'file').length;
const indexLinkCount = extractCanvasIndexLinks(nodes).reduce((count, node) => count + node.links.length, 0);
const nodeIds = new Set<string>();
const edgeIds = new Set<string>();
const groupLabels = new Set(nodes.filter((node) => node.type === 'group').map((node) => node.label).filter(Boolean));
for (const node of nodes) {
if (!node.id) {
findings.push({
check: 'canvas-node',
level: 'error',
message: 'Canvas 节点缺少 id。',
path: canvasPath,
});
continue;
}
if (nodeIds.has(node.id)) {
findings.push({
check: 'canvas-node',
level: 'error',
message: 'Canvas 节点 id 重复。',
path: canvasPath,
target: node.id,
});
}
nodeIds.add(node.id);
if (node.type === 'file') {
findings.push({
check: 'canvas-node',
level: 'error',
message: 'KT 关系图谱必须使用文本索引节点,不能使用会嵌入全文预览的 file 节点。',
path: canvasPath,
target: node.id,
});
}
if (node.type === 'text' && (node.text?.length || 0) > canvasTextNodeMaxChars) {
findings.push({
check: 'canvas-node',
level: 'error',
message: 'Canvas 文本节点过长,可能在 Obsidian 中出现内部滚动条。',
path: canvasPath,
target: `${node.id || 'unknown'}:${node.text?.length || 0}`,
});
}
if (node.type === 'file' && node.file && !existsSync(resolveInsideRoot(node.file))) {
findings.push({
check: 'canvas-file',
level: 'error',
message: 'Canvas 文件节点目标不存在。',
path: canvasPath,
target: node.file,
});
}
}
for (const edge of edges) {
if (!edge.id) {
findings.push({
check: 'canvas-edge',
level: 'error',
message: 'Canvas 边缺少 id。',
path: canvasPath,
});
continue;
}
if (edgeIds.has(edge.id)) {
findings.push({
check: 'canvas-edge',
level: 'error',
message: 'Canvas 边 id 重复。',
path: canvasPath,
target: edge.id,
});
}
edgeIds.add(edge.id);
if (!edge.fromNode || !nodeIds.has(edge.fromNode) || !edge.toNode || !nodeIds.has(edge.toNode)) {
findings.push({
check: 'canvas-edge',
level: 'error',
message: 'Canvas 边引用了不存在的节点。',
path: canvasPath,
target: edge.id,
});
}
}
requiredCanvasGroupLabels
.filter((label) => !groupLabels.has(label))
.forEach((label) => {
findings.push({
check: 'canvas-group',
level: 'error',
message: 'Canvas 缺少工程任务流泳道。',
path: canvasPath,
target: label,
});
});
if (checkLayout) {
const visibleNodes = nodes.filter((node) => node.type !== 'group');
for (let index = 0; index < visibleNodes.length; index += 1) {
for (let nextIndex = index + 1; nextIndex < visibleNodes.length; nextIndex += 1) {
const current = visibleNodes[index];
const next = visibleNodes[nextIndex];
if (current && next && rectanglesOverlap(current, next)) {
findings.push({
check: 'canvas-layout',
level: 'warning',
message: 'Canvas 非分组节点存在重叠,可能影响图谱可读性。',
path: canvasPath,
target: `${current.id || 'unknown'} <-> ${next.id || 'unknown'}`,
});
}
}
}
}
return {
edgeCount: edges.length,
fileNodeCount,
groupCount: groupLabels.size,
indexLinkCount,
nodeCount: nodes.length,
};
}
/**
* Extracts Obsidian Base view names from the repository-owned YAML subset without adding a parser dependency.
* @param content - Text content of `KT 文档矩阵.base`.
* @returns View names declared below the `views:` section.
*/
function extractBaseViewNames(content: string): string[] {
const names: string[] = [];
const lines = content.split(/\r?\n/);
let inViews = false;
let currentView = false;
for (const line of lines) {
if (/^views:\s*$/.test(line)) {
inViews = true;
currentView = false;
continue;
}
if (inViews && /^[A-Za-z_][A-Za-z0-9_-]*:\s*/.test(line)) {
break;
}
if (!inViews) continue;
if (/^\s*-\s+type:\s+/.test(line)) {
currentView = true;
continue;
}
const name = line.match(/^\s+name:\s+(.+)$/)?.[1];
if (currentView && name) {
names.push(stripYamlQuotes(name));
currentView = false;
}
}
return names;
}
/**
* Validates the engineering Base shape used by the KT Obsidian document matrix.
* @param findings - Shared finding accumulator for the current Obsidian validation run.
* @returns Base section and view summary for command output.
*/
function validateBaseFile(findings: ObsidianFinding[]): Record<string, unknown> {
const basePath = 'docs/obsidian/KT 文档矩阵.base';
if (!existsSync(resolveInsideRoot(basePath))) {
findings.push({
check: 'base',
level: 'error',
message: 'Base 文件不存在。',
path: basePath,
});
return { views: 0 };
}
const content = readTextFile(basePath);
const requiredTokens = ['filters:', 'properties:', 'views:', 'type: table', 'type: cards'];
for (const token of requiredTokens) {
if (!content.includes(token)) {
findings.push({
check: 'base',
level: 'error',
message: `Base 文件缺少必要结构:${token}`,
path: basePath,
});
}
}
const viewNames = extractBaseViewNames(content);
requiredBaseViewNames
.filter((viewName) => !viewNames.includes(viewName))
.forEach((viewName) => {
findings.push({
check: 'base-view',
level: 'error',
message: 'Base 缺少工程控制视图。',
path: basePath,
target: viewName,
});
});
return {
hasCardsView: content.includes('type: cards'),
hasTableView: content.includes('type: table'),
requiredViews: requiredBaseViewNames.length,
viewNames,
views: viewNames.length,
};
}
/**
* Validates that module MOC files carry routing metadata for Base views and `obsidian-context`.
* @param findings - Shared finding accumulator for the current Obsidian validation run.
* @returns Number of module notes inspected.
*/
function validateModuleFrontmatter(findings: ObsidianFinding[]): number {
const moduleFiles = listFiles(resolveInsideRoot('docs/obsidian/modules'), new Set(['.md']))
.map(relativeToWorkspace)
.filter((file) => file !== 'docs/obsidian/modules/KT 模块索引.md');
const routeFields = ['rule_anchors', 'code_anchors', 'workflow_entries', 'validation_entries'];
for (const file of moduleFiles) {
const fields = parseFrontmatterFields(readTextFile(file));
for (const requiredField of ['kt_type', 'module']) {
const value = fields.get(requiredField);
if (!value || (Array.isArray(value) && value.length === 0)) {
findings.push({
check: 'module-frontmatter',
level: 'error',
message: '模块页缺少 Obsidian 工程路由 frontmatter 字段。',
path: file,
target: requiredField,
});
}
}
const hasRouteField = routeFields.some((field) => {
const value = fields.get(field);
return Array.isArray(value) ? value.length > 0 : Boolean(value);
});
if (!hasRouteField) {
findings.push({
check: 'module-frontmatter',
level: 'error',
message: '模块页没有 rule/code/workflow/validation 任一工程锚点。',
path: file,
target: routeFields.join('|'),
});
}
}
return moduleFiles.length;
}
/**
* Rejects accidental nested Obsidian documents created by resolving wiki links from inside `docs/obsidian`.
* @param findings - Shared finding accumulator for the current Obsidian validation run.
* @returns Number of nested files found, or 1 when only the invalid directory exists.
*/
function validateNoNestedObsidianDocs(findings: ObsidianFinding[]): number {
const nestedRoot = 'docs/obsidian/docs';
const absoluteRoot = resolveInsideRoot(nestedRoot);
if (!existsSync(absoluteRoot)) return 0;
const nestedFiles = listFiles(absoluteRoot, new Set(['.base', '.canvas', '.json', '.md']))
.map(relativeToWorkspace);
findings.push({
check: 'obsidian-nested-docs',
level: 'error',
message: 'docs/obsidian 下出现误嵌套 docs 目录Obsidian 索引只能放在 docs/obsidian 根层和 modules 层。',
path: nestedRoot,
target: nestedFiles.join(', ') || nestedRoot,
});
return nestedFiles.length || 1;
}
function validateStaleReferences(findings: ObsidianFinding[]): number {
const scanFiles = listFiles(workspaceRoot, new Set(['.md', '.ps1', '.sh', '.ts', '.yaml', '.yml']));
let hitCount = 0;
for (const absolutePath of scanFiles) {
const relativePath = relativeToWorkspace(absolutePath);
if (relativePath === 'mcp/ktWorkflow/src/tools/obsidian.ts') continue;
const lines = readFileSync(absolutePath, 'utf8').split(/\r?\n/);
lines.forEach((line, index) => {
const matchedPattern = staleReferencePatterns.find((pattern) => line.includes(pattern));
if (!matchedPattern) return;
hitCount += 1;
const allowed = allowedStaleReferenceHints.some((hint) => line.includes(hint));
findings.push({
check: 'stale-reference',
level: allowed ? 'info' : 'warning',
message: allowed ? '旧引用只作为禁用规则或清理记录保留。' : '发现旧入口或已删除文档引用,请确认是否还需要保留。',
path: `${relativePath}:${index + 1}`,
target: matchedPattern,
});
});
}
return hitCount;
}
function countByLevel(findings: ObsidianFinding[]): Record<FindingLevel, number> {
return findings.reduce<Record<FindingLevel, number>>(
(result, finding) => {
result[finding.level] += 1;
return result;
},
{ error: 0, info: 0, warning: 0 },
);
}
export function validateObsidianVault(input: ObsidianValidateInput = {}): Record<string, unknown> {
const options = normalizeInput(input);
const findings: ObsidianFinding[] = [];
for (const configFile of obsidianConfigFiles) {
readJsonFile<Record<string, unknown>>(configFile, findings);
}
const base = validateBaseFile(findings);
const canvas = validateCanvas(findings, options.checkLayout);
const moduleFrontmatterCount = validateModuleFrontmatter(findings);
const nestedDocsCount = validateNoNestedObsidianDocs(findings);
const wikiLinkCount = validateWikiLinks(findings);
const markdownLinkCount = options.includeMarkdownLinks ? validateMarkdownLinks(findings) : 0;
const staleReferenceCount = options.includeStaleReferences ? validateStaleReferences(findings) : 0;
const levels = countByLevel(findings);
const ok = levels.error === 0 && (!options.failOnWarnings || levels.warning === 0);
return {
checks: {
base,
canvas,
configFiles: obsidianConfigFiles.length,
markdownLinks: markdownLinkCount,
moduleFrontmatter: moduleFrontmatterCount,
nestedDocs: nestedDocsCount,
staleReferences: staleReferenceCount,
wikiLinks: wikiLinkCount,
},
findings,
ok,
options,
summary: {
errors: levels.error,
infos: levels.info,
warnings: levels.warning,
},
};
}
export function readObsidianContext(input: ObsidianContextInput = {}): Record<string, unknown> {
const maxDocuments = Math.min(Math.max(input.maxDocuments || 8, 1), 30);
const terms = buildContextTerms(input);
const codeAnchors = buildCodeAnchors(terms, Math.min(maxDocuments, 8));
const ruleAnchors = buildRuleAnchors(codeAnchors);
const includeContent = input.includeContent !== false;
const allPaths = Array.from(new Set([...defaultContextPaths, ...listObsidianMarkdownPaths()]));
const scoredPaths = allPaths
.map((relativePath) => ({
relativePath,
score: scoreContextPath(relativePath, terms),
}))
.filter((item) => defaultContextPaths.includes(item.relativePath) || item.score > 0)
.sort((left, right) => {
if (right.score !== left.score) return right.score - left.score;
return left.relativePath.localeCompare(right.relativePath, 'zh-Hans-CN');
});
const selectedPaths = scoredPaths.slice(0, maxDocuments).map((item) => item.relativePath);
const documents = selectedPaths.map((relativePath) => readContextDocument(relativePath, includeContent, terms));
return {
canvas: readCanvasSummary(input.includeCanvas),
codeAnchors,
commands: [
'pnpm --dir mcp/ktWorkflow run obsidian-context',
'pnpm --dir mcp/ktWorkflow run obsidian-validate',
],
documents,
ok: true,
route: {
defaultIndex: defaultContextPaths,
module: input.module?.trim() || null,
query: input.query?.trim() || null,
terms,
},
ruleAnchors,
summary: {
allIndexedDocuments: allPaths.length,
codeAnchors: codeAnchors.length,
ruleAnchors: ruleAnchors.length,
returnedDocuments: documents.length,
},
workflowAnchors: [
{
input: {
includeFullDocs: false,
taskRecordCount: 3,
},
name: 'kt_read_context',
purpose: '读取当前任务记录、项目清单和根目录规则,补齐 Obsidian 文档索引之外的运行上下文。',
},
...codeAnchors.map((anchor) => ({
input: {
includeGit: true,
project: anchor.alias,
},
name: 'kt_inspect_project',
purpose: `锚定 ${anchor.label} 的仓库状态、包管理器、Node 和 env。`,
})),
],
};
}
function readOptionalJson<T>(relativePath: string): T | null {
if (!existsSync(resolveInsideRoot(relativePath))) return null;
try {
return JSON.parse(readTextFile(relativePath)) as T;
} catch {
return null;
}
}
/**
* Recursively collects file paths from Obsidian bookmarks, including grouped bookmark sections.
* @param items - Bookmark items from `.obsidian/bookmarks.json`.
* @returns File paths referenced by bookmark entries.
*/
function collectBookmarkPaths(items: BookmarkItem[]): string[] {
return items.flatMap((item) => [
...(item.path ? [item.path] : []),
...(item.items ? collectBookmarkPaths(item.items) : []),
]);
}
function buildSyncFindings(): ObsidianFinding[] {
const findings: ObsidianFinding[] = [];
const missingFiles = expectedObsidianFiles.filter((file) => !existsSync(resolveInsideRoot(file)));
missingFiles.forEach((file) => {
findings.push({
check: 'expected-file',
level: 'error',
message: 'Obsidian 工作流入口文件缺失。',
path: file,
});
});
const bookmarks = readOptionalJson<BookmarkSettings>('.obsidian/bookmarks.json');
const bookmarkPaths = new Set(collectBookmarkPaths(bookmarks?.items || []));
requiredBookmarkPaths
.filter((file) => !bookmarkPaths.has(file))
.forEach((file) => {
findings.push({
check: 'bookmarks',
level: 'warning',
message: '常用入口没有加入 Obsidian 书签。',
path: '.obsidian/bookmarks.json',
target: file,
});
});
const corePlugins = readOptionalJson<Record<string, boolean>>('.obsidian/core-plugins.json') || {};
requiredCorePlugins
.filter((pluginName) => corePlugins[pluginName] !== true)
.forEach((pluginName) => {
findings.push({
check: 'core-plugins',
level: 'warning',
message: '推荐启用的 Obsidian 核心插件没有开启。',
path: '.obsidian/core-plugins.json',
target: pluginName,
});
});
const appSettings = readOptionalJson<ObsidianSettings>('.obsidian/app.json');
const ignoredRoots = new Set(appSettings?.userIgnoreFilters || []);
expectedIgnoredRoots
.filter((root) => !ignoredRoots.has(root))
.forEach((root) => {
findings.push({
check: 'app-ignore',
level: 'warning',
message: '源码或运行态目录没有加入 Obsidian 忽略列表,可能污染关系图。',
path: '.obsidian/app.json',
target: root,
});
});
if (appSettings?.attachmentFolderPath !== 'docs/assets/obsidian') {
findings.push({
check: 'attachments',
level: 'warning',
message: '附件目录建议固定到 docs/assets/obsidian。',
path: '.obsidian/app.json',
target: appSettings?.attachmentFolderPath || '',
});
}
return findings;
}
export function syncObsidianWorkflow(input: ObsidianSyncInput = {}): Record<string, unknown> {
const includeValidation = input.includeValidation !== false;
const syncFindings = buildSyncFindings();
const validation = includeValidation ? validateObsidianVault(input) : null;
const validationFindings = (validation?.findings || []) as ObsidianFinding[];
const findings = [...syncFindings, ...validationFindings];
const levels = countByLevel(findings);
const ok = levels.error === 0 && (input.failOnWarnings !== true || levels.warning === 0);
return {
commands: [
'pnpm --dir mcp/ktWorkflow run obsidian-validate',
'pnpm --dir mcp/ktWorkflow run obsidian-sync',
'pnpm --dir mcp/ktWorkflow run self-test',
],
findings,
nextActions:
findings.length === 0
? ['Obsidian 图谱、配置和工作流入口已连通;后续文档改动后复跑 obsidian-validate。']
: ['先修复 errorwarning 确认后可进入 TASKS.md 或 docs/obsidian 记录。'],
ok,
summary: {
errors: levels.error,
expectedFiles: expectedObsidianFiles.length,
infos: levels.info,
warnings: levels.warning,
},
validation,
};
}