ktworkflow-mcp/src/tools/obsidian.ts

1056 lines
33 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;
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 {
path?: 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/',
'_exports/',
'Node/',
'Plugins/',
'Vue/',
'mcp/',
];
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',
'_exports',
'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');
}
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))
);
}
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));
}
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);
}
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,
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
);
}
function validateCanvas(findings: ObsidianFinding[], checkLayout: boolean): { edgeCount: number; nodeCount: number } {
const canvasPath = 'docs/obsidian/KT 关系图谱.canvas';
const canvas = readJsonFile<CanvasDocument>(canvasPath, findings);
if (!canvas) return { edgeCount: 0, nodeCount: 0 };
const nodes = Array.isArray(canvas.nodes) ? canvas.nodes : [];
const edges = Array.isArray(canvas.edges) ? canvas.edges : [];
const nodeIds = new Set<string>();
const edgeIds = new Set<string>();
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' && 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,
});
}
}
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, nodeCount: nodes.length };
}
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,
});
}
}
return {
hasCardsView: content.includes('type: cards'),
hasTableView: content.includes('type: table'),
views: [...content.matchAll(/^\s*-\s+type:\s+/gm)].length,
};
}
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 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,
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;
}
}
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((bookmarks?.items || []).map((item) => item.path).filter(Boolean));
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,
};
}