2026-01-15 11:34:29 +01:00
|
|
|
import { Notice, Plugin, TFile } from "obsidian";
|
|
|
|
|
import { DEFAULT_SETTINGS, type MindnetSettings, normalizeVaultPath } from "./settings";
|
|
|
|
|
import { VocabularyLoader } from "./vocab/VocabularyLoader";
|
|
|
|
|
import { parseEdgeVocabulary } from "./vocab/parseEdgeVocabulary";
|
|
|
|
|
import { Vocabulary } from "./vocab/Vocabulary";
|
|
|
|
|
import { LintEngine } from "./lint/LintEngine";
|
|
|
|
|
import { MindnetSettingTab } from "./ui/MindnetSettingTab";
|
2026-01-15 12:27:16 +01:00
|
|
|
import { exportGraph } from "./export/exportGraph";
|
|
|
|
|
import { buildGraph } from "./graph/GraphBuilder";
|
|
|
|
|
import { buildIndex } from "./graph/GraphIndex";
|
|
|
|
|
import { traverseForward, traverseBackward, type Path } from "./graph/traverse";
|
|
|
|
|
import { renderChainReport } from "./graph/renderChainReport";
|
|
|
|
|
import { extractFrontmatterId } from "./parser/parseFrontmatter";
|
2026-01-15 11:34:29 +01:00
|
|
|
|
|
|
|
|
export default class MindnetCausalAssistantPlugin extends Plugin {
|
|
|
|
|
settings: MindnetSettings;
|
|
|
|
|
private vocabulary: Vocabulary | null = null;
|
|
|
|
|
private reloadDebounceTimer: number | null = null;
|
|
|
|
|
|
|
|
|
|
async onload(): Promise<void> {
|
|
|
|
|
await this.loadSettings();
|
|
|
|
|
|
|
|
|
|
// Add settings tab
|
|
|
|
|
this.addSettingTab(new MindnetSettingTab(this.app, this));
|
|
|
|
|
|
|
|
|
|
// Register live reload for edge vocabulary file
|
|
|
|
|
this.registerEvent(
|
|
|
|
|
this.app.vault.on("modify", async (file: TFile) => {
|
|
|
|
|
const normalizedFilePath = normalizeVaultPath(file.path);
|
|
|
|
|
const normalizedVocabPath = normalizeVaultPath(this.settings.edgeVocabularyPath);
|
|
|
|
|
|
|
|
|
|
// Check if modified file matches vocabulary path (exact match or ends with)
|
|
|
|
|
if (normalizedFilePath === normalizedVocabPath ||
|
|
|
|
|
normalizedFilePath === `/${normalizedVocabPath}` ||
|
|
|
|
|
normalizedFilePath.endsWith(`/${normalizedVocabPath}`)) {
|
|
|
|
|
// Debounce reload to avoid multiple rapid reloads
|
|
|
|
|
if (this.reloadDebounceTimer !== null) {
|
|
|
|
|
window.clearTimeout(this.reloadDebounceTimer);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.reloadDebounceTimer = window.setTimeout(async () => {
|
|
|
|
|
await this.reloadVocabulary();
|
|
|
|
|
this.reloadDebounceTimer = null;
|
|
|
|
|
}, 200);
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
this.addCommand({
|
|
|
|
|
id: "mindnet-reload-edge-vocabulary",
|
|
|
|
|
name: "Mindnet: Reload edge vocabulary",
|
|
|
|
|
callback: async () => {
|
|
|
|
|
await this.reloadVocabulary();
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.addCommand({
|
|
|
|
|
id: "mindnet-validate-current-note",
|
|
|
|
|
name: "Mindnet: Validate current note",
|
|
|
|
|
callback: async () => {
|
|
|
|
|
try {
|
|
|
|
|
const vocabulary = await this.ensureVocabularyLoaded();
|
|
|
|
|
if (!vocabulary) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 12:27:16 +01:00
|
|
|
const findings = await LintEngine.lintCurrentNote(
|
|
|
|
|
this.app,
|
|
|
|
|
vocabulary,
|
|
|
|
|
{ showCanonicalHints: this.settings.showCanonicalHints }
|
|
|
|
|
);
|
2026-01-15 11:34:29 +01:00
|
|
|
|
|
|
|
|
// Count findings by severity
|
|
|
|
|
const errorCount = findings.filter(f => f.severity === "ERROR").length;
|
|
|
|
|
const warnCount = findings.filter(f => f.severity === "WARN").length;
|
|
|
|
|
const infoCount = findings.filter(f => f.severity === "INFO").length;
|
|
|
|
|
|
|
|
|
|
// Show summary notice
|
|
|
|
|
new Notice(`Lint: ${errorCount} errors, ${warnCount} warnings, ${infoCount} info`);
|
|
|
|
|
|
|
|
|
|
// Log findings to console
|
|
|
|
|
console.log("=== Lint Findings ===");
|
|
|
|
|
for (const finding of findings) {
|
2026-01-15 12:27:16 +01:00
|
|
|
console.log(`[${finding.severity}] ${finding.ruleId}: ${finding.message} (${finding.filePath}:${finding.lineStart})`);
|
2026-01-15 11:34:29 +01:00
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
|
|
|
new Notice(`Failed to validate note: ${msg}`);
|
|
|
|
|
console.error(e);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-01-15 12:27:16 +01:00
|
|
|
|
|
|
|
|
this.addCommand({
|
|
|
|
|
id: "mindnet-export-graph",
|
|
|
|
|
name: "Mindnet: Export graph",
|
|
|
|
|
callback: async () => {
|
|
|
|
|
try {
|
|
|
|
|
const vocabulary = await this.ensureVocabularyLoaded();
|
|
|
|
|
if (!vocabulary) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const outputPath = "_system/exports/graph_export.json";
|
|
|
|
|
await exportGraph(this.app, vocabulary, outputPath);
|
|
|
|
|
|
|
|
|
|
new Notice(`Graph exported to ${outputPath}`);
|
|
|
|
|
console.log(`Graph exported: ${outputPath}`);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
|
|
|
new Notice(`Failed to export graph: ${msg}`);
|
|
|
|
|
console.error(e);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.addCommand({
|
|
|
|
|
id: "mindnet-show-chains-from-current-note",
|
|
|
|
|
name: "Mindnet: Show chains from current note",
|
|
|
|
|
callback: async () => {
|
|
|
|
|
try {
|
|
|
|
|
const vocabulary = await this.ensureVocabularyLoaded();
|
|
|
|
|
if (!vocabulary) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const activeFile = this.app.workspace.getActiveFile();
|
|
|
|
|
if (!activeFile) {
|
|
|
|
|
new Notice("No active file");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (activeFile.extension !== "md") {
|
|
|
|
|
new Notice("Active file is not a markdown file");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Extract start ID from frontmatter
|
|
|
|
|
const content = await this.app.vault.read(activeFile);
|
|
|
|
|
const startId = extractFrontmatterId(content);
|
|
|
|
|
|
|
|
|
|
if (!startId) {
|
|
|
|
|
new Notice("Current note has no frontmatter ID. Add 'id: <value>' to frontmatter.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build graph
|
|
|
|
|
const graph = await buildGraph(this.app, vocabulary);
|
|
|
|
|
|
|
|
|
|
// Get start node meta
|
|
|
|
|
const startMeta = graph.idToMeta.get(startId);
|
|
|
|
|
if (!startMeta) {
|
|
|
|
|
new Notice(`Start node ID '${startId}' not found in graph`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build index
|
|
|
|
|
const index = buildIndex(graph.edges);
|
|
|
|
|
|
|
|
|
|
// Run traversal
|
|
|
|
|
let allPaths: Path[] = [];
|
|
|
|
|
if (this.settings.chainDirection === "forward" || this.settings.chainDirection === "both") {
|
|
|
|
|
const forwardPaths = traverseForward(
|
|
|
|
|
index,
|
|
|
|
|
startId,
|
|
|
|
|
this.settings.maxHops,
|
|
|
|
|
200
|
|
|
|
|
);
|
|
|
|
|
allPaths = [...allPaths, ...forwardPaths];
|
|
|
|
|
}
|
|
|
|
|
if (this.settings.chainDirection === "backward" || this.settings.chainDirection === "both") {
|
|
|
|
|
const backwardPaths = traverseBackward(
|
|
|
|
|
index,
|
|
|
|
|
startId,
|
|
|
|
|
this.settings.maxHops,
|
|
|
|
|
200
|
|
|
|
|
);
|
|
|
|
|
allPaths = [...allPaths, ...backwardPaths];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Render report
|
|
|
|
|
const report = renderChainReport({
|
|
|
|
|
startId,
|
|
|
|
|
startMeta,
|
|
|
|
|
paths: allPaths,
|
|
|
|
|
direction: this.settings.chainDirection,
|
|
|
|
|
maxHops: this.settings.maxHops,
|
|
|
|
|
maxPaths: 200,
|
|
|
|
|
warnings: graph.warnings,
|
|
|
|
|
idToMeta: graph.idToMeta,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Write report file
|
|
|
|
|
const reportPath = "_system/exports/chain_report.md";
|
|
|
|
|
await this.app.vault.adapter.write(reportPath, report);
|
|
|
|
|
|
|
|
|
|
// Open report
|
|
|
|
|
const reportFile = this.app.vault.getAbstractFileByPath(reportPath);
|
|
|
|
|
if (reportFile && reportFile instanceof TFile) {
|
|
|
|
|
await this.app.workspace.openLinkText(reportPath, "", true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Show summary
|
|
|
|
|
const uniqueNodes = new Set<string>();
|
|
|
|
|
for (const path of allPaths) {
|
|
|
|
|
for (const nodeId of path.nodes) {
|
|
|
|
|
uniqueNodes.add(nodeId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const totalWarnings =
|
|
|
|
|
graph.warnings.missingFrontmatterId.length +
|
|
|
|
|
graph.warnings.missingTargetFile.length +
|
|
|
|
|
graph.warnings.missingTargetId.length;
|
|
|
|
|
|
|
|
|
|
new Notice(
|
|
|
|
|
`Chains: ${allPaths.length} paths, ${uniqueNodes.size} nodes, ${totalWarnings} warnings`
|
|
|
|
|
);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
|
|
|
new Notice(`Failed to generate chain report: ${msg}`);
|
|
|
|
|
console.error(e);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-01-15 11:34:29 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onunload(): void {
|
|
|
|
|
// nothing yet
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async loadSettings(): Promise<void> {
|
|
|
|
|
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async saveSettings(): Promise<void> {
|
|
|
|
|
await this.saveData(this.settings);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Ensure vocabulary is loaded. Auto-loads if not present.
|
|
|
|
|
* Returns Vocabulary instance or null on failure.
|
|
|
|
|
*/
|
|
|
|
|
private async ensureVocabularyLoaded(): Promise<Vocabulary | null> {
|
|
|
|
|
if (this.vocabulary) {
|
|
|
|
|
return this.vocabulary;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const text = await VocabularyLoader.loadText(
|
|
|
|
|
this.app,
|
|
|
|
|
this.settings.edgeVocabularyPath
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const parsed = parseEdgeVocabulary(text);
|
|
|
|
|
this.vocabulary = new Vocabulary(parsed);
|
|
|
|
|
|
|
|
|
|
const stats = this.vocabulary.getStats();
|
|
|
|
|
console.log("Vocabulary auto-loaded", stats);
|
|
|
|
|
return this.vocabulary;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
|
|
|
if (msg.includes("not found") || msg.includes("Vocabulary file not found")) {
|
|
|
|
|
new Notice("edge_vocabulary.md not found. Check the path in plugin settings.");
|
|
|
|
|
} else {
|
|
|
|
|
new Notice(`Failed to load vocabulary: ${msg}. Check plugin settings.`);
|
|
|
|
|
}
|
|
|
|
|
console.error("Failed to load vocabulary:", e);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Reload vocabulary from file. Used by manual command and live reload.
|
|
|
|
|
*/
|
|
|
|
|
private async reloadVocabulary(): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
const text = await VocabularyLoader.loadText(
|
|
|
|
|
this.app,
|
|
|
|
|
this.settings.edgeVocabularyPath
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const parsed = parseEdgeVocabulary(text);
|
|
|
|
|
this.vocabulary = new Vocabulary(parsed);
|
|
|
|
|
|
|
|
|
|
const stats = this.vocabulary.getStats();
|
|
|
|
|
console.log("Vocabulary loaded", stats);
|
|
|
|
|
new Notice(`Edge vocabulary reloaded: ${stats.canonicalCount} canonical, ${stats.aliasCount} aliases`);
|
|
|
|
|
|
|
|
|
|
// Log normalization examples
|
|
|
|
|
if (stats.canonicalCount > 0) {
|
|
|
|
|
const firstCanonical = Array.from(parsed.byCanonical.keys())[0];
|
|
|
|
|
if (firstCanonical) {
|
|
|
|
|
const canonicalNorm = this.vocabulary.normalize(firstCanonical);
|
|
|
|
|
console.log(`Normalization example (canonical): "${firstCanonical}" -> canonical: ${canonicalNorm.canonical}, inverse: ${canonicalNorm.inverse}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (stats.aliasCount > 0) {
|
|
|
|
|
const firstAlias = Array.from(parsed.aliasToCanonical.keys())[0];
|
|
|
|
|
if (firstAlias) {
|
|
|
|
|
const aliasNorm = this.vocabulary.normalize(firstAlias);
|
|
|
|
|
console.log(`Normalization example (alias): "${firstAlias}" -> canonical: ${aliasNorm.canonical}, inverse: ${aliasNorm.inverse}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
|
|
|
if (msg.includes("not found") || msg.includes("Vocabulary file not found")) {
|
|
|
|
|
new Notice("edge_vocabulary.md not found. Configure path in plugin settings.");
|
|
|
|
|
} else {
|
|
|
|
|
new Notice(`Failed to reload vocabulary: ${msg}`);
|
|
|
|
|
}
|
|
|
|
|
console.error(e);
|
|
|
|
|
}
|
|
|
|
|
}
|
2022-04-15 20:13:31 +02:00
|
|
|
}
|