v1.0.0: Local semantic embeddings, Related Notes panel, folder autocomplete

- EmbedSearch: local ONNX embeddings via @xenova/transformers (BGE Micro v2)
  - Incremental indexing with per-100-note disk flush
  - 13s per-embed timeout, 120s for first call (WASM init)
  - Re-embed on file modify (debounced 2s)
  - Exclude folders setting
- RelatedNotesView: sidebar panel showing semantically related notes
  - Similarity bar + percentage per result
  - Auto-refreshes on active note change
  - Uses cached vectors (no re-embedding on lookup)
- Startup: waits for Obsidian Sync to finish before indexing
- Settings: folder autocomplete for all folder inputs
- Obsidian Notice with N/s speed + ETA during indexing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
svemagie
2026-03-05 02:17:02 +01:00
parent 74f7ac4a30
commit 32006149e7
8 changed files with 944 additions and 79 deletions
+385 -28
View File
@@ -31134,7 +31134,7 @@ __export(main_exports, {
default: () => MemexChatPlugin
});
module.exports = __toCommonJS(main_exports);
var import_obsidian4 = require("obsidian");
var import_obsidian5 = require("obsidian");
// src/ChatView.ts
var import_obsidian = require("obsidian");
@@ -32291,6 +32291,8 @@ var EMBEDDING_MODELS = [
];
var EmbedSearch = class {
constructor(app, modelId) {
this.excludeFolders = [];
// vault folder prefixes to skip
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.pipe = null;
this.cache = /* @__PURE__ */ new Map();
@@ -32298,6 +32300,8 @@ var EmbedSearch = class {
this.vecs = /* @__PURE__ */ new Map();
this.indexed = false;
this.indexing = false;
// ─── Incremental re-embed on file change ─────────────────────────────────
this.reembedTimers = /* @__PURE__ */ new Map();
this.app = app;
this.modelId = modelId;
}
@@ -32356,10 +32360,22 @@ var EmbedSearch = class {
});
}
async embed(text) {
console.log("[Memex] embed: loadPipeline\u2026");
await this.loadPipeline();
console.log("[Memex] embed: pipe call\u2026");
const result = await this.pipe(text.slice(0, 512), { pooling: "mean", normalize: true });
console.log("[Memex] embed: done, dims:", result.data.length);
return Array.from(result.data);
}
/** embed() with a hard timeout; rejects with "embed timeout" if exceeded. */
embedWithTimeout(text, ms = 13e3) {
return Promise.race([
this.embed(text),
new Promise(
(_, reject) => setTimeout(() => reject(new Error("embed timeout")), ms)
)
]);
}
cosine(a, b) {
let dot2 = 0;
for (let i = 0; i < a.length; i++)
@@ -32368,6 +32384,7 @@ var EmbedSearch = class {
}
// ─── Index ────────────────────────────────────────────────────────────────
async buildIndex() {
console.log("[Memex] buildIndex START, indexing:", this.indexing);
if (this.indexing)
return;
this.indexing = true;
@@ -32378,13 +32395,17 @@ var EmbedSearch = class {
try {
await import_fs3.promises.mkdir(this.modelsDir, { recursive: true });
await import_fs3.promises.mkdir(this.embedDir, { recursive: true });
console.log("[Memex] Verzeichnisse OK:", this.embedDir);
} catch (e) {
console.error("[Memex] Verzeichnisse konnten nicht angelegt werden:", e);
}
try {
await this.loadCache();
const files = this.app.vault.getMarkdownFiles();
console.log("[Memex] Cache geladen, Eintr\xE4ge:", this.cache.size);
const allFiles = this.app.vault.getMarkdownFiles();
const files = this.excludeFolders.length ? allFiles.filter((f) => !this.excludeFolders.some((ex) => f.path.startsWith(ex + "/"))) : allFiles;
const total = files.length;
console.log("[Memex] Dateien gesamt:", total, "(ausgeschlossen:", allFiles.length - total, ")");
let done = 0;
let windowStart = Date.now();
let windowEmbedded = 0;
@@ -32399,17 +32420,22 @@ var EmbedSearch = class {
await new Promise((r) => setTimeout(r, 0));
const raw = await this.app.vault.cachedRead(file);
const text = this.preprocess(raw).slice(0, 800) + " " + file.basename;
const vec = await this.embed(text);
const vec = await this.embedWithTimeout(text, this.pipe ? 13e3 : 12e4);
this.cache.set(file.path, { mtime, vec });
this.vecs.set(file.path, { vec, file });
changed.push(file.path);
windowEmbedded++;
if (changed.length === 1 || changed.length % 50 === 0)
console.log(`[Memex] Eingebettet: ${changed.length}/${total}`);
if (changed.length % 100 === 0)
await this.flushBatch(changed.slice(-100));
} catch (e) {
if (!this.pipe && !pipelineError) {
pipelineError = e;
console.error("[Memex] Pipeline-Ladefehler:", e);
break;
}
console.warn("[Memex] Datei \xFCbersprungen:", file.path, e);
}
}
done++;
@@ -32425,17 +32451,77 @@ var EmbedSearch = class {
this.onProgress(done, total, speed);
}
}
console.log("[Memex] Loop fertig, changed:", changed.length, "pipelineError:", !!pipelineError);
if (pipelineError)
throw pipelineError;
const allPaths = new Set(files.map((f) => f.path));
await this.saveCache(changed, allPaths);
const remainder = changed.length % 100;
await this.saveCache(remainder > 0 ? changed.slice(-remainder) : [], allPaths);
this.indexed = true;
if (this.onProgress)
this.onProgress(total, total, speed);
} catch (e) {
console.error("[Memex] buildIndex Fehler:", e);
} finally {
this.indexing = false;
console.log("[Memex] buildIndex END, indexed:", this.indexed);
}
}
/**
* Debounced re-embed for a single file (called on vault modify events).
* Waits 2 s after the last write before embedding.
*/
reembedFile(file) {
if (!this.indexed || this.indexing)
return;
const existing = this.reembedTimers.get(file.path);
if (existing)
clearTimeout(existing);
const timer = setTimeout(async () => {
this.reembedTimers.delete(file.path);
try {
const raw = await this.app.vault.cachedRead(file);
const text = this.preprocess(raw).slice(0, 800) + " " + file.basename;
const vec = await this.embedWithTimeout(text);
const mtime = file.stat.mtime;
this.cache.set(file.path, { mtime, vec });
this.vecs.set(file.path, { vec, file });
await this.saveCache([file.path], new Set(this.vecs.keys()));
console.log("[Memex] Re-embedded:", file.path);
} catch (e) {
console.warn("[Memex] Re-embed fehlgeschlagen:", file.path, e);
}
}, 2e3);
this.reembedTimers.set(file.path, timer);
}
/** Find notes similar to a given file using its cached vector (no re-embedding). */
async searchSimilarToFile(file, topK = 10) {
if (!this.indexed)
return [];
let qvec = this.vecs.get(file.path)?.vec;
if (!qvec) {
try {
const raw = await this.app.vault.cachedRead(file);
const text = this.preprocess(raw).slice(0, 800) + " " + file.basename;
qvec = await this.embedWithTimeout(text);
} catch {
return [];
}
}
const scores = [];
for (const [path3, { vec }] of this.vecs) {
if (path3 === file.path)
continue;
const s = this.cosine(qvec, vec);
if (s > 0.2)
scores.push([path3, s]);
}
scores.sort((a, b) => b[1] - a[1]);
return scores.slice(0, topK).map(([path3, score]) => {
const { file: f } = this.vecs.get(path3);
return { file: f, score, excerpt: "", title: f.basename };
});
}
async search(query, topK = 8) {
if (!this.indexed)
await this.buildIndex();
@@ -32494,16 +32580,12 @@ var EmbedSearch = class {
}
}
}
/**
* Write .ajson for each newly embedded note; delete .ajson for removed notes;
* write/update the manifest.
*/
async saveCache(changed, allVaultPaths) {
/** Write .ajson files for a batch of vault paths (no pruning). Called incrementally. */
async flushBatch(vaultPaths) {
try {
await import_fs3.promises.mkdir(this.embedDir, { recursive: true });
const manifest = { model: this.modelId, version: 1 };
await import_fs3.promises.writeFile(this.manifestPath, JSON.stringify(manifest), "utf8");
for (const vaultPath of changed) {
for (const vaultPath of vaultPaths) {
const entry = this.cache.get(vaultPath);
if (!entry)
continue;
@@ -32511,11 +32593,18 @@ var EmbedSearch = class {
await import_fs3.promises.mkdir((0, import_path3.dirname)(filePath), { recursive: true });
await import_fs3.promises.writeFile(filePath, JSON.stringify({ mtime: entry.mtime, vec: entry.vec }), "utf8");
}
await this.pruneStale(this.embedDir, allVaultPaths);
} catch (e) {
console.error("[Memex] Embedding-Cache konnte nicht gespeichert werden:", e);
console.error("[Memex] flushBatch Fehler:", e);
}
}
/**
* Final save: flush any remaining changed notes, then prune stale .ajson files.
*/
async saveCache(changed, allVaultPaths) {
if (changed.length > 0)
await this.flushBatch(changed);
await this.pruneStale(this.embedDir, allVaultPaths);
}
async pruneStale(dir, allVaultPaths) {
let entries;
try {
@@ -32640,6 +32729,7 @@ Wenn du Fragen beantwortest:
systemContextFile: "",
useEmbeddings: false,
embeddingModel: "TaylorAI/bge-micro-v2",
embedExcludeFolders: [],
promptButtons: [
{
label: "Draft Check",
@@ -32667,6 +32757,41 @@ var MemexChatSettingsTab = class extends import_obsidian3.PluginSettingTab {
display() {
const { containerEl } = this;
containerEl.empty();
const allFolders = this.app.vault.getAllFolders().map((f) => f.path).filter((p) => p !== "/").sort();
const attachFolderDropdown = (wrap, input, getExcluded, onPick) => {
const dropdown = wrap.createDiv("vc-folder-dropdown");
dropdown.style.display = "none";
const refresh = () => {
const q = input.value.toLowerCase();
const excluded = getExcluded();
const matches = allFolders.filter((f) => f.toLowerCase().includes(q) && !excluded.includes(f)).slice(0, 12);
dropdown.empty();
if (!matches.length) {
dropdown.style.display = "none";
return;
}
for (const f of matches) {
const item = dropdown.createDiv("vc-folder-item");
item.textContent = f;
item.addEventListener("mousedown", (e) => {
e.preventDefault();
onPick(f);
});
}
dropdown.style.display = "block";
};
input.addEventListener("input", refresh);
input.addEventListener("focus", refresh);
input.addEventListener("blur", () => setTimeout(() => {
dropdown.style.display = "none";
}, 150));
input.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
dropdown.style.display = "none";
input.blur();
}
});
};
containerEl.createEl("h2", { text: "Memex Chat Einstellungen" });
containerEl.createEl("h3", { text: "Claude API" });
new import_obsidian3.Setting(containerEl).setName("API Key").setDesc("Dein Anthropic API Key (sk-ant-...)").addText(
@@ -32716,6 +32841,50 @@ var MemexChatSettingsTab = class extends import_obsidian3.PluginSettingTab {
await this.plugin.initEmbedSearch();
});
});
const exclSetting = new import_obsidian3.Setting(containerEl).setName("Ordner ausschlie\xDFen").setDesc("Diese Ordner werden beim Embedding \xFCbersprungen. Nach \xC4nderung Index neu aufbauen.");
exclSetting.settingEl.style.flexWrap = "wrap";
exclSetting.settingEl.style.alignItems = "flex-start";
const exclTagContainer = exclSetting.controlEl.createDiv("vc-prop-tags");
const renderExclTags = () => {
exclTagContainer.empty();
for (const folder of this.plugin.settings.embedExcludeFolders) {
const tag = exclTagContainer.createEl("span", { cls: "vc-prop-tag" });
tag.createEl("span", { text: folder });
const x = tag.createEl("button", { cls: "vc-prop-tag-remove", text: "\xD7" });
x.onclick = async () => {
this.plugin.settings.embedExcludeFolders = this.plugin.settings.embedExcludeFolders.filter((f) => f !== folder);
await this.plugin.saveSettings();
renderExclTags();
};
}
};
renderExclTags();
const exclWrap = exclSetting.controlEl.createDiv("vc-folder-search-wrap");
const exclInput = exclWrap.createEl("input", {
cls: "vc-prop-input",
attr: { type: "text", placeholder: "Ordner suchen\u2026" }
});
const addExclFolder = async (folder) => {
folder = folder.trim().replace(/\/$/, "");
if (!folder || this.plugin.settings.embedExcludeFolders.includes(folder))
return;
this.plugin.settings.embedExcludeFolders = [...this.plugin.settings.embedExcludeFolders, folder];
await this.plugin.saveSettings();
exclInput.value = "";
renderExclTags();
};
attachFolderDropdown(
exclWrap,
exclInput,
() => this.plugin.settings.embedExcludeFolders,
(f) => addExclFolder(f)
);
exclInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
addExclFolder(exclInput.value);
}
});
containerEl.createEl("h3", { text: "Kontext-Einstellungen" });
new import_obsidian3.Setting(containerEl).setName("Max. Kontext-Notizen").setDesc("Wie viele Notizen werden automatisch als Kontext hinzugef\xFCgt? (1\u201315)").addSlider(
(slider) => slider.setLimits(1, 15, 1).setValue(this.plugin.settings.maxContextNotes).setDynamicTooltip().onChange(async (value) => {
@@ -32842,26 +33011,27 @@ var MemexChatSettingsTab = class extends import_obsidian3.PluginSettingTab {
renderFolders();
};
}
const folderInput = folderSection.createEl("input", {
const folderWrap = folderSection.createDiv("vc-folder-search-wrap");
folderWrap.style.width = "200px";
const folderInput = folderWrap.createEl("input", {
cls: "vc-pbtn-input",
attr: { type: "text", placeholder: "Ordner hinzuf\xFCgen\u2026", style: "width:180px" }
attr: { type: "text", placeholder: "Ordner suchen\u2026" }
});
const doAddFolder = async () => {
const val = folderInput.value.trim().replace(/\/$/, "");
if (!val)
const doAddFolder = async (val) => {
val = val.trim().replace(/\/$/, "");
if (!val || (pb.searchFolders ?? []).includes(val))
return;
pb.searchFolders = [...pb.searchFolders ?? [], val];
await this.plugin.saveSettings();
renderFolders();
};
attachFolderDropdown(folderWrap, folderInput, () => pb.searchFolders ?? [], (f) => doAddFolder(f));
folderInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
doAddFolder();
doAddFolder(folderInput.value);
}
});
const addFolderBtn = folderSection.createEl("button", { cls: "vc-prop-add-btn", text: "+" });
addFolderBtn.onclick = doAddFolder;
};
renderFolders();
checkbox.addEventListener("change", async () => {
@@ -32916,12 +33086,22 @@ var MemexChatSettingsTab = class extends import_obsidian3.PluginSettingTab {
await this.plugin.saveSettings();
})
);
new import_obsidian3.Setting(containerEl).setName("Threads-Ordner").setDesc("Pfad im Vault, wo Chat-Threads gespeichert werden").addText(
(text) => text.setPlaceholder("Calendar/Chat").setValue(this.plugin.settings.threadsFolder).onChange(async (value) => {
this.plugin.settings.threadsFolder = value;
const threadsFolderSetting = new import_obsidian3.Setting(containerEl).setName("Threads-Ordner").setDesc("Pfad im Vault, wo Chat-Threads gespeichert werden");
const tfWrap = threadsFolderSetting.controlEl.createDiv("vc-folder-search-wrap");
const tfInput = tfWrap.createEl("input", {
cls: "vc-prop-input",
attr: { type: "text", placeholder: "Calendar/Chat" }
});
tfInput.value = this.plugin.settings.threadsFolder;
tfInput.addEventListener("input", async () => {
this.plugin.settings.threadsFolder = tfInput.value;
await this.plugin.saveSettings();
})
);
});
attachFolderDropdown(tfWrap, tfInput, () => [], async (f) => {
tfInput.value = f;
this.plugin.settings.threadsFolder = f;
await this.plugin.saveSettings();
});
containerEl.createEl("h3", { text: "System Prompt" });
new import_obsidian3.Setting(containerEl).setName("System Prompt").setDesc("Instruktionen f\xFCr Claude (wie soll er sich verhalten?)").addTextArea((textarea) => {
textarea.setValue(this.plugin.settings.systemPrompt).onChange(async (value) => {
@@ -32955,8 +33135,89 @@ var MemexChatSettingsTab = class extends import_obsidian3.PluginSettingTab {
}
};
// src/RelatedNotesView.ts
var import_obsidian4 = require("obsidian");
var VIEW_TYPE_RELATED = "memex-related-notes";
var RelatedNotesView = class extends import_obsidian4.ItemView {
constructor(leaf, plugin) {
super(leaf);
this.refreshTimer = null;
this.plugin = plugin;
}
getViewType() {
return VIEW_TYPE_RELATED;
}
getDisplayText() {
return "Verwandte Notizen";
}
getIcon() {
return "sparkles";
}
async onOpen() {
this.registerEvent(this.app.workspace.on("active-leaf-change", () => this.scheduleRefresh()));
this.registerEvent(this.app.workspace.on("file-open", () => this.scheduleRefresh()));
this.render([]);
this.scheduleRefresh();
}
scheduleRefresh(delay = 400) {
if (this.refreshTimer)
clearTimeout(this.refreshTimer);
this.refreshTimer = setTimeout(() => this.refresh(), delay);
}
/** Called by the plugin when the embedding index finishes building. */
onIndexReady() {
this.scheduleRefresh(0);
}
async refresh() {
const file = this.app.workspace.getActiveFile();
if (!file || file.extension !== "md")
return;
const es = this.plugin.embedSearch;
if (!es || !es.isIndexed()) {
this.renderStatus("Embedding-Index wird aufgebaut\u2026");
return;
}
this.renderStatus("Suche verwandte Notizen\u2026");
const results = await es.searchSimilarToFile(file);
this.render(results, file.basename);
}
renderStatus(msg) {
this.contentEl.empty();
this.contentEl.createDiv({ cls: "vc-related-status", text: msg });
}
render(results, forNote) {
this.contentEl.empty();
const header = this.contentEl.createDiv("vc-related-header");
header.createDiv({ cls: "vc-related-title", text: "Verwandte Notizen" });
if (forNote)
header.createDiv({ cls: "vc-related-subtitle", text: forNote });
if (!results.length) {
this.contentEl.createDiv({ cls: "vc-related-status", text: forNote ? "Keine Treffer." : "" });
return;
}
const list = this.contentEl.createDiv("vc-related-list");
for (const r of results) {
const item = list.createDiv("vc-related-item");
const info = item.createDiv("vc-related-info");
info.createDiv({ cls: "vc-related-name", text: r.title });
const folder = r.file.parent?.path;
if (folder && folder !== "/") {
info.createDiv({ cls: "vc-related-folder", text: folder });
}
const scoreWrap = item.createDiv("vc-related-score-wrap");
const pct = Math.round(r.score * 100);
const bar = scoreWrap.createDiv("vc-related-bar");
bar.createDiv({ cls: "vc-related-bar-fill" }).style.width = `${pct}%`;
scoreWrap.createDiv({ cls: "vc-related-pct", text: `${pct}%` });
item.addEventListener("click", () => {
this.app.workspace.openLinkText(r.file.path, r.file.path, false);
});
}
}
};
// src/main.ts
var MemexChatPlugin = class extends import_obsidian4.Plugin {
var MemexChatPlugin = class extends import_obsidian5.Plugin {
constructor() {
super(...arguments);
this.embedSearch = null;
@@ -32982,14 +33243,23 @@ var MemexChatPlugin = class extends import_obsidian4.Plugin {
this.search = new VaultSearch(this.app);
this.claude = new ClaudeClient();
this.registerView(VIEW_TYPE_MEMEX_CHAT, (leaf) => new ChatView(leaf, this));
this.registerView(VIEW_TYPE_RELATED, (leaf) => new RelatedNotesView(leaf, this));
this.addRibbonIcon("message-circle", "Memex Chat \xF6ffnen", () => {
this.activateView();
});
this.addRibbonIcon("sparkles", "Verwandte Notizen", () => {
this.activateRelatedView();
});
this.addCommand({
id: "open-memex-chat",
name: "Memex Chat \xF6ffnen",
callback: () => this.activateView()
});
this.addCommand({
id: "memex-related-notes",
name: "Verwandte Notizen anzeigen",
callback: () => this.activateRelatedView()
});
this.addCommand({
id: "memex-chat-rebuild-index",
name: "Memex Chat: Index neu aufbauen",
@@ -33039,6 +33309,23 @@ var MemexChatPlugin = class extends import_obsidian4.Plugin {
await leaf.setViewState({ type: VIEW_TYPE_MEMEX_CHAT, active: true });
this.app.workspace.revealLeaf(leaf);
}
async activateRelatedView() {
const existing = this.app.workspace.getLeavesOfType(VIEW_TYPE_RELATED);
if (existing.length > 0) {
this.app.workspace.revealLeaf(existing[0]);
return;
}
const leaf = this.app.workspace.getRightLeaf(false);
if (!leaf)
return;
await leaf.setViewState({ type: VIEW_TYPE_RELATED, active: true });
this.app.workspace.revealLeaf(leaf);
}
notifyRelatedView() {
this.app.workspace.getLeavesOfType(VIEW_TYPE_RELATED).forEach((l) => {
l.view.onIndexReady();
});
}
/** Create or recreate the EmbedSearch instance (called when settings change) */
async initEmbedSearch() {
if (!this.settings.useEmbeddings) {
@@ -33046,7 +33333,37 @@ var MemexChatPlugin = class extends import_obsidian4.Plugin {
return;
}
this.embedSearch = new EmbedSearch(this.app, this.settings.embeddingModel);
this.embedSearch.buildIndex().catch(console.error);
this.embedSearch.excludeFolders = this.settings.embedExcludeFolders ?? [];
this.registerEvent(
this.app.vault.on("modify", (file) => {
if (this.embedSearch && file instanceof import_obsidian5.TFile && file.extension === "md")
this.embedSearch.reembedFile(file);
})
);
const notice = new import_obsidian5.Notice("Memex: Embedding wird vorbereitet\u2026", 0);
this.embedSearch.onModelStatus = (status) => {
notice.setMessage(`Memex: ${status}`);
};
this.embedSearch.onProgress = (done, total, speed) => {
const speedStr = speed > 0 ? ` \u2022 ${speed.toFixed(1)} N/s` : "";
const remaining = speed > 0 && done < total ? (total - done) / speed : 0;
const eta = remaining > 0 ? ` \u2022 ~${remaining < 60 ? Math.ceil(remaining) + "s" : Math.ceil(remaining / 60) + "min"}` : "";
notice.setMessage(`Memex Embedding: ${done}/${total}${speedStr}${eta}`);
};
this.waitForSyncIdle(notice).then(() => this.embedSearch?.buildIndex()).then(() => {
notice.setMessage(`\u2713 Memex: ${this.app.vault.getMarkdownFiles().length} Notizen eingebettet`);
setTimeout(() => notice.hide(), 4e3);
this.notifyRelatedView();
}).catch((e) => {
notice.setMessage(`\u2717 Memex Embedding: ${e.message}`);
setTimeout(() => notice.hide(), 6e3);
console.error(e);
}).finally(() => {
if (this.embedSearch) {
this.embedSearch.onProgress = void 0;
this.embedSearch.onModelStatus = void 0;
}
});
}
async rebuildIndex() {
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_MEMEX_CHAT);
@@ -33081,6 +33398,46 @@ var MemexChatPlugin = class extends import_obsidian4.Plugin {
setTimeout(() => view.setStatus(""), 3e3);
}
}
/**
* Waits until Obsidian Sync is idle.
* Strategy: watch for vault changes; if activity stops for 15 s, sync is done.
* If no activity within the first 5 s, sync isn't running return immediately.
* Falls back after 5 minutes regardless.
*/
async waitForSyncIdle(notice) {
const syncPlugin = this.app.internalPlugins?.plugins?.["sync"]?.instance;
if (!syncPlugin)
return;
const PROBE_MS = 5e3;
const QUIET_MS = 15e3;
const MAX_MS = 5 * 6e4;
let lastChange = 0;
let activitySeen = false;
const tick = () => {
lastChange = Date.now();
activitySeen = true;
};
this.app.vault.on("create", tick);
this.app.vault.on("modify", tick);
this.app.vault.on("delete", tick);
try {
notice.setMessage("Memex: Pr\xFCfe Sync-Status\u2026");
await new Promise((r) => setTimeout(r, PROBE_MS));
if (!activitySeen)
return;
notice.setMessage("Memex: Warte auf Obsidian Sync\u2026");
const deadline = Date.now() + MAX_MS;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 2e3));
if (Date.now() - lastChange >= QUIET_MS)
return;
}
} finally {
this.app.vault.off("create", tick);
this.app.vault.off("modify", tick);
this.app.vault.off("delete", tick);
}
}
async saveSettings() {
this.data.settings = this.settings;
await this.saveData(this.data);
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "memex-chat",
"name": "Memex Chat",
"version": "0.3.0",
"version": "1.0.0",
"minAppVersion": "1.4.0",
"description": "Chat with your Obsidian vault using Claude AI — semantic context retrieval, @ mentions, thread history.",
"author": "Sven",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "memex-chat",
"version": "0.3.0",
"version": "1.0.0",
"description": "Obsidian plugin: Chat with your vault using Claude AI",
"main": "main.js",
"scripts": {
+103 -18
View File
@@ -26,6 +26,7 @@ interface Manifest { model: string; version: number }
export class EmbedSearch {
private app: App;
private modelId: string;
excludeFolders: string[] = []; // vault folder prefixes to skip
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private pipe: ((text: string, opts: object) => Promise<{ data: Float32Array }>) | null = null;
private cache: Map<string, EmbedCacheEntry> = new Map(); // vaultPath → entry
@@ -112,11 +113,24 @@ export class EmbedSearch {
}
private async embed(text: string): Promise<number[]> {
console.log("[Memex] embed: loadPipeline…");
await this.loadPipeline();
console.log("[Memex] embed: pipe call…");
const result = await this.pipe!(text.slice(0, 512), { pooling: "mean", normalize: true });
console.log("[Memex] embed: done, dims:", result.data.length);
return Array.from(result.data);
}
/** embed() with a hard timeout; rejects with "embed timeout" if exceeded. */
private embedWithTimeout(text: string, ms = 13000): Promise<number[]> {
return Promise.race([
this.embed(text),
new Promise<number[]>((_, reject) =>
setTimeout(() => reject(new Error("embed timeout")), ms)
),
]);
}
private cosine(a: number[], b: number[]): number {
let dot = 0;
for (let i = 0; i < a.length; i++) dot += a[i] * b[i];
@@ -126,6 +140,7 @@ export class EmbedSearch {
// ─── Index ────────────────────────────────────────────────────────────────
async buildIndex(): Promise<void> {
console.log("[Memex] buildIndex START, indexing:", this.indexing);
if (this.indexing) return;
this.indexing = true;
this.indexed = false;
@@ -138,15 +153,21 @@ export class EmbedSearch {
try {
await fsp.mkdir(this.modelsDir, { recursive: true });
await fsp.mkdir(this.embedDir, { recursive: true });
console.log("[Memex] Verzeichnisse OK:", this.embedDir);
} catch (e) {
console.error("[Memex] Verzeichnisse konnten nicht angelegt werden:", e);
}
try {
await this.loadCache();
console.log("[Memex] Cache geladen, Einträge:", this.cache.size);
const files = this.app.vault.getMarkdownFiles();
const allFiles = this.app.vault.getMarkdownFiles();
const files = this.excludeFolders.length
? allFiles.filter((f) => !this.excludeFolders.some((ex) => f.path.startsWith(ex + "/")))
: allFiles;
const total = files.length;
console.log("[Memex] Dateien gesamt:", total, "(ausgeschlossen:", allFiles.length - total, ")");
let done = 0;
let windowStart = Date.now();
let windowEmbedded = 0;
@@ -165,11 +186,16 @@ export class EmbedSearch {
await new Promise((r) => setTimeout(r, 0));
const raw = await this.app.vault.cachedRead(file);
const text = this.preprocess(raw).slice(0, 800) + " " + file.basename;
const vec = await this.embed(text);
// First call initialises WASM + loads model — allow extra time
const vec = await this.embedWithTimeout(text, this.pipe ? 13000 : 120000);
this.cache.set(file.path, { mtime, vec });
this.vecs.set(file.path, { vec, file });
changed.push(file.path);
windowEmbedded++;
if (changed.length === 1 || changed.length % 50 === 0)
console.log(`[Memex] Eingebettet: ${changed.length}/${total}`);
// Flush newly embedded notes to disk every 100 to preserve progress
if (changed.length % 100 === 0) await this.flushBatch(changed.slice(-100));
} catch (e) {
if (!this.pipe && !pipelineError) {
// Pipeline failed to load — log once and abort embedding loop
@@ -177,6 +203,7 @@ export class EmbedSearch {
console.error("[Memex] Pipeline-Ladefehler:", e);
break;
}
console.warn("[Memex] Datei übersprungen:", file.path, e);
// skip individual file
}
}
@@ -192,17 +219,78 @@ export class EmbedSearch {
}
}
console.log("[Memex] Loop fertig, changed:", changed.length, "pipelineError:", !!pipelineError);
if (pipelineError) throw pipelineError;
const allPaths = new Set(files.map((f) => f.path));
await this.saveCache(changed, allPaths);
// Flush remainder (notes not yet flushed by the every-100 batches)
const remainder = changed.length % 100;
await this.saveCache(remainder > 0 ? changed.slice(-remainder) : [], allPaths);
this.indexed = true;
if (this.onProgress) this.onProgress(total, total, speed);
} catch (e) {
console.error("[Memex] buildIndex Fehler:", e);
} finally {
this.indexing = false;
console.log("[Memex] buildIndex END, indexed:", this.indexed);
}
}
// ─── Incremental re-embed on file change ─────────────────────────────────
private reembedTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
/**
* Debounced re-embed for a single file (called on vault modify events).
* Waits 2 s after the last write before embedding.
*/
reembedFile(file: TFile): void {
if (!this.indexed || this.indexing) return;
const existing = this.reembedTimers.get(file.path);
if (existing) clearTimeout(existing);
const timer = setTimeout(async () => {
this.reembedTimers.delete(file.path);
try {
const raw = await this.app.vault.cachedRead(file);
const text = this.preprocess(raw).slice(0, 800) + " " + file.basename;
const vec = await this.embedWithTimeout(text);
const mtime = file.stat.mtime;
this.cache.set(file.path, { mtime, vec });
this.vecs.set(file.path, { vec, file });
await this.saveCache([file.path], new Set(this.vecs.keys()));
console.log("[Memex] Re-embedded:", file.path);
} catch (e) {
console.warn("[Memex] Re-embed fehlgeschlagen:", file.path, e);
}
}, 2000);
this.reembedTimers.set(file.path, timer);
}
/** Find notes similar to a given file using its cached vector (no re-embedding). */
async searchSimilarToFile(file: TFile, topK = 10): Promise<SearchResult[]> {
if (!this.indexed) return [];
let qvec = this.vecs.get(file.path)?.vec;
if (!qvec) {
// File not yet indexed — embed on the fly
try {
const raw = await this.app.vault.cachedRead(file);
const text = this.preprocess(raw).slice(0, 800) + " " + file.basename;
qvec = await this.embedWithTimeout(text);
} catch { return []; }
}
const scores: Array<[string, number]> = [];
for (const [path, { vec }] of this.vecs) {
if (path === file.path) continue;
const s = this.cosine(qvec, vec);
if (s > 0.2) scores.push([path, s]);
}
scores.sort((a, b) => b[1] - a[1]);
return scores.slice(0, topK).map(([path, score]) => {
const { file: f } = this.vecs.get(path)!;
return { file: f, score, excerpt: "", title: f.basename };
});
}
async search(query: string, topK = 8): Promise<SearchResult[]> {
if (!this.indexed) await this.buildIndex();
@@ -265,34 +353,31 @@ export class EmbedSearch {
}
}
/**
* Write .ajson for each newly embedded note; delete .ajson for removed notes;
* write/update the manifest.
*/
private async saveCache(changed: string[], allVaultPaths: Set<string>): Promise<void> {
/** Write .ajson files for a batch of vault paths (no pruning). Called incrementally. */
private async flushBatch(vaultPaths: string[]): Promise<void> {
try {
await fsp.mkdir(this.embedDir, { recursive: true });
// Manifest
const manifest: Manifest = { model: this.modelId, version: 1 };
await fsp.writeFile(this.manifestPath, JSON.stringify(manifest), "utf8");
// Write only the newly embedded notes
for (const vaultPath of changed) {
for (const vaultPath of vaultPaths) {
const entry = this.cache.get(vaultPath);
if (!entry) continue;
const filePath = this.noteEmbedPath(vaultPath);
await fsp.mkdir(dirname(filePath), { recursive: true });
await fsp.writeFile(filePath, JSON.stringify({ mtime: entry.mtime, vec: entry.vec }), "utf8");
}
// Prune .ajson files whose notes no longer exist
await this.pruneStale(this.embedDir, allVaultPaths);
} catch (e) {
console.error("[Memex] Embedding-Cache konnte nicht gespeichert werden:", e);
console.error("[Memex] flushBatch Fehler:", e);
}
}
/**
* Final save: flush any remaining changed notes, then prune stale .ajson files.
*/
private async saveCache(changed: string[], allVaultPaths: Set<string>): Promise<void> {
if (changed.length > 0) await this.flushBatch(changed);
await this.pruneStale(this.embedDir, allVaultPaths);
}
private async pruneStale(dir: string, allVaultPaths: Set<string>): Promise<void> {
let entries;
try { entries = await fsp.readdir(dir, { withFileTypes: true }); }
+91
View File
@@ -0,0 +1,91 @@
import { ItemView, TFile, WorkspaceLeaf } from "obsidian";
import type MemexChatPlugin from "./main";
export const VIEW_TYPE_RELATED = "memex-related-notes";
export class RelatedNotesView extends ItemView {
private plugin: MemexChatPlugin;
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
constructor(leaf: WorkspaceLeaf, plugin: MemexChatPlugin) {
super(leaf);
this.plugin = plugin;
}
getViewType() { return VIEW_TYPE_RELATED; }
getDisplayText() { return "Verwandte Notizen"; }
getIcon() { return "sparkles"; }
async onOpen(): Promise<void> {
this.registerEvent(this.app.workspace.on("active-leaf-change", () => this.scheduleRefresh()));
this.registerEvent(this.app.workspace.on("file-open", () => this.scheduleRefresh()));
this.render([]);
this.scheduleRefresh();
}
private scheduleRefresh(delay = 400) {
if (this.refreshTimer) clearTimeout(this.refreshTimer);
this.refreshTimer = setTimeout(() => this.refresh(), delay);
}
/** Called by the plugin when the embedding index finishes building. */
onIndexReady() { this.scheduleRefresh(0); }
private async refresh() {
const file = this.app.workspace.getActiveFile();
if (!file || file.extension !== "md") return;
const es = this.plugin.embedSearch;
if (!es || !es.isIndexed()) {
this.renderStatus("Embedding-Index wird aufgebaut…");
return;
}
this.renderStatus("Suche verwandte Notizen…");
const results = await es.searchSimilarToFile(file);
this.render(results, file.basename);
}
private renderStatus(msg: string) {
this.contentEl.empty();
this.contentEl.createDiv({ cls: "vc-related-status", text: msg });
}
private render(results: Array<{ file: TFile; score: number; title: string }>, forNote?: string) {
this.contentEl.empty();
const header = this.contentEl.createDiv("vc-related-header");
header.createDiv({ cls: "vc-related-title", text: "Verwandte Notizen" });
if (forNote) header.createDiv({ cls: "vc-related-subtitle", text: forNote });
if (!results.length) {
this.contentEl.createDiv({ cls: "vc-related-status", text: forNote ? "Keine Treffer." : "" });
return;
}
const list = this.contentEl.createDiv("vc-related-list");
for (const r of results) {
const item = list.createDiv("vc-related-item");
const info = item.createDiv("vc-related-info");
info.createDiv({ cls: "vc-related-name", text: r.title });
// Folder path (dimmed)
const folder = r.file.parent?.path;
if (folder && folder !== "/") {
info.createDiv({ cls: "vc-related-folder", text: folder });
}
// Similarity bar + percentage
const scoreWrap = item.createDiv("vc-related-score-wrap");
const pct = Math.round(r.score * 100);
const bar = scoreWrap.createDiv("vc-related-bar");
bar.createDiv({ cls: "vc-related-bar-fill" }).style.width = `${pct}%`;
scoreWrap.createDiv({ cls: "vc-related-pct", text: `${pct}%` });
item.addEventListener("click", () => {
this.app.workspace.openLinkText(r.file.path, r.file.path, false);
});
}
}
}
+113 -18
View File
@@ -27,6 +27,7 @@ export interface MemexChatSettings {
systemContextFile: string; // optional vault path for extended system context
useEmbeddings: boolean; // use local embedding model instead of TF-IDF
embeddingModel: string; // HuggingFace model ID
embedExcludeFolders: string[]; // vault folders to skip during embedding
}
export const DEFAULT_SETTINGS: MemexChatSettings = {
@@ -52,6 +53,7 @@ Wenn du Fragen beantwortest:
systemContextFile: "",
useEmbeddings: false,
embeddingModel: "TaylorAI/bge-micro-v2",
embedExcludeFolders: [],
promptButtons: [
{
label: "Draft Check",
@@ -85,6 +87,44 @@ export class MemexChatSettingsTab extends PluginSettingTab {
const { containerEl } = this;
containerEl.empty();
// Sorted vault folder list — used by all folder autocompletes in this settings page
const allFolders = this.app.vault.getAllFolders()
.map((f) => f.path)
.filter((p) => p !== "/")
.sort();
/** Attaches a folder-search dropdown to a wrapper element. onPick is called with the selected folder. */
const attachFolderDropdown = (
wrap: HTMLElement,
input: HTMLInputElement,
getExcluded: () => string[],
onPick: (folder: string) => void,
) => {
const dropdown = wrap.createDiv("vc-folder-dropdown");
dropdown.style.display = "none";
const refresh = () => {
const q = input.value.toLowerCase();
const excluded = getExcluded();
const matches = allFolders
.filter((f) => f.toLowerCase().includes(q) && !excluded.includes(f))
.slice(0, 12);
dropdown.empty();
if (!matches.length) { dropdown.style.display = "none"; return; }
for (const f of matches) {
const item = dropdown.createDiv("vc-folder-item");
item.textContent = f;
item.addEventListener("mousedown", (e) => { e.preventDefault(); onPick(f); });
}
dropdown.style.display = "block";
};
input.addEventListener("input", refresh);
input.addEventListener("focus", refresh);
input.addEventListener("blur", () => setTimeout(() => { dropdown.style.display = "none"; }, 150));
input.addEventListener("keydown", (e) => {
if (e.key === "Escape") { dropdown.style.display = "none"; input.blur(); }
});
};
containerEl.createEl("h2", { text: "Memex Chat Einstellungen" });
// --- API ---
@@ -168,6 +208,52 @@ export class MemexChatSettingsTab extends PluginSettingTab {
});
});
// Exclude folders from embedding
const exclSetting = new Setting(containerEl)
.setName("Ordner ausschließen")
.setDesc("Diese Ordner werden beim Embedding übersprungen. Nach Änderung Index neu aufbauen.");
exclSetting.settingEl.style.flexWrap = "wrap";
exclSetting.settingEl.style.alignItems = "flex-start";
const exclTagContainer = exclSetting.controlEl.createDiv("vc-prop-tags");
const renderExclTags = () => {
exclTagContainer.empty();
for (const folder of this.plugin.settings.embedExcludeFolders) {
const tag = exclTagContainer.createEl("span", { cls: "vc-prop-tag" });
tag.createEl("span", { text: folder });
const x = tag.createEl("button", { cls: "vc-prop-tag-remove", text: "×" });
x.onclick = async () => {
this.plugin.settings.embedExcludeFolders =
this.plugin.settings.embedExcludeFolders.filter((f) => f !== folder);
await this.plugin.saveSettings();
renderExclTags();
};
}
};
renderExclTags();
const exclWrap = exclSetting.controlEl.createDiv("vc-folder-search-wrap");
const exclInput = exclWrap.createEl("input", {
cls: "vc-prop-input",
attr: { type: "text", placeholder: "Ordner suchen…" },
}) as HTMLInputElement;
const addExclFolder = async (folder: string) => {
folder = folder.trim().replace(/\/$/, "");
if (!folder || this.plugin.settings.embedExcludeFolders.includes(folder)) return;
this.plugin.settings.embedExcludeFolders = [...this.plugin.settings.embedExcludeFolders, folder];
await this.plugin.saveSettings();
exclInput.value = "";
renderExclTags();
};
attachFolderDropdown(exclWrap, exclInput,
() => this.plugin.settings.embedExcludeFolders,
(f) => addExclFolder(f),
);
exclInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); addExclFolder(exclInput.value); }
});
// --- Context ---
containerEl.createEl("h3", { text: "Kontext-Einstellungen" });
@@ -334,20 +420,23 @@ export class MemexChatSettingsTab extends PluginSettingTab {
renderFolders();
};
}
const folderInput = folderSection.createEl("input", {
const folderWrap = folderSection.createDiv("vc-folder-search-wrap");
folderWrap.style.width = "200px";
const folderInput = folderWrap.createEl("input", {
cls: "vc-pbtn-input",
attr: { type: "text", placeholder: "Ordner hinzufügen…", style: "width:180px" },
attr: { type: "text", placeholder: "Ordner suchen…" },
}) as HTMLInputElement;
const doAddFolder = async () => {
const val = folderInput.value.trim().replace(/\/$/, "");
if (!val) return;
const doAddFolder = async (val: string) => {
val = val.trim().replace(/\/$/, "");
if (!val || (pb.searchFolders ?? []).includes(val)) return;
pb.searchFolders = [...(pb.searchFolders ?? []), val];
await this.plugin.saveSettings();
renderFolders();
};
folderInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); doAddFolder(); } });
const addFolderBtn = folderSection.createEl("button", { cls: "vc-prop-add-btn", text: "+" });
addFolderBtn.onclick = doAddFolder;
attachFolderDropdown(folderWrap, folderInput, () => pb.searchFolders ?? [], (f) => doAddFolder(f));
folderInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); doAddFolder(folderInput.value); }
});
};
renderFolders();
@@ -413,18 +502,24 @@ export class MemexChatSettingsTab extends PluginSettingTab {
})
);
new Setting(containerEl)
const threadsFolderSetting = new Setting(containerEl)
.setName("Threads-Ordner")
.setDesc("Pfad im Vault, wo Chat-Threads gespeichert werden")
.addText((text) =>
text
.setPlaceholder("Calendar/Chat")
.setValue(this.plugin.settings.threadsFolder)
.onChange(async (value) => {
this.plugin.settings.threadsFolder = value;
.setDesc("Pfad im Vault, wo Chat-Threads gespeichert werden");
const tfWrap = threadsFolderSetting.controlEl.createDiv("vc-folder-search-wrap");
const tfInput = tfWrap.createEl("input", {
cls: "vc-prop-input",
attr: { type: "text", placeholder: "Calendar/Chat" },
}) as HTMLInputElement;
tfInput.value = this.plugin.settings.threadsFolder;
tfInput.addEventListener("input", async () => {
this.plugin.settings.threadsFolder = tfInput.value;
await this.plugin.saveSettings();
})
);
});
attachFolderDropdown(tfWrap, tfInput, () => [], async (f) => {
tfInput.value = f;
this.plugin.settings.threadsFolder = f;
await this.plugin.saveSettings();
});
// --- System Prompt ---
containerEl.createEl("h3", { text: "System Prompt" });
+116 -9
View File
@@ -1,9 +1,10 @@
import { Plugin, WorkspaceLeaf } from "obsidian";
import { Notice, Plugin, TFile, WorkspaceLeaf } from "obsidian";
import { ChatView, VIEW_TYPE_MEMEX_CHAT } from "./ChatView";
import { VaultSearch } from "./VaultSearch";
import { EmbedSearch } from "./EmbedSearch";
import { ClaudeClient } from "./ClaudeClient";
import { MemexChatSettingsTab, MemexChatSettings, DEFAULT_SETTINGS } from "./SettingsTab";
import { RelatedNotesView, VIEW_TYPE_RELATED } from "./RelatedNotesView";
interface PluginData {
settings: MemexChatSettings;
@@ -43,13 +44,17 @@ export default class MemexChatPlugin extends Plugin {
this.search = new VaultSearch(this.app);
this.claude = new ClaudeClient();
// Register view
// Register views
this.registerView(VIEW_TYPE_MEMEX_CHAT, (leaf) => new ChatView(leaf, this));
this.registerView(VIEW_TYPE_RELATED, (leaf) => new RelatedNotesView(leaf, this));
// Ribbon icon
// Ribbon icons
this.addRibbonIcon("message-circle", "Memex Chat öffnen", () => {
this.activateView();
});
this.addRibbonIcon("sparkles", "Verwandte Notizen", () => {
this.activateRelatedView();
});
// Commands
this.addCommand({
@@ -57,6 +62,11 @@ export default class MemexChatPlugin extends Plugin {
name: "Memex Chat öffnen",
callback: () => this.activateView(),
});
this.addCommand({
id: "memex-related-notes",
name: "Verwandte Notizen anzeigen",
callback: () => this.activateRelatedView(),
});
this.addCommand({
id: "memex-chat-rebuild-index",
@@ -106,17 +116,28 @@ export default class MemexChatPlugin extends Plugin {
async activateView(): Promise<void> {
const existing = this.app.workspace.getLeavesOfType(VIEW_TYPE_MEMEX_CHAT);
if (existing.length > 0) {
this.app.workspace.revealLeaf(existing[0]);
return;
}
if (existing.length > 0) { this.app.workspace.revealLeaf(existing[0]); return; }
const leaf = this.app.workspace.getLeaf("tab");
if (!leaf) return;
await leaf.setViewState({ type: VIEW_TYPE_MEMEX_CHAT, active: true });
this.app.workspace.revealLeaf(leaf);
}
async activateRelatedView(): Promise<void> {
const existing = this.app.workspace.getLeavesOfType(VIEW_TYPE_RELATED);
if (existing.length > 0) { this.app.workspace.revealLeaf(existing[0]); return; }
const leaf = this.app.workspace.getRightLeaf(false);
if (!leaf) return;
await leaf.setViewState({ type: VIEW_TYPE_RELATED, active: true });
this.app.workspace.revealLeaf(leaf);
}
private notifyRelatedView() {
this.app.workspace.getLeavesOfType(VIEW_TYPE_RELATED).forEach((l) => {
(l.view as RelatedNotesView).onIndexReady();
});
}
/** Create or recreate the EmbedSearch instance (called when settings change) */
async initEmbedSearch(): Promise<void> {
if (!this.settings.useEmbeddings) {
@@ -124,7 +145,50 @@ export default class MemexChatPlugin extends Plugin {
return;
}
this.embedSearch = new EmbedSearch(this.app, this.settings.embeddingModel);
// Don't build immediately — build on first search or explicit rebuild
this.embedSearch.excludeFolders = this.settings.embedExcludeFolders ?? [];
// Re-embed modified notes as they change
this.registerEvent(
this.app.vault.on("modify", (file) => {
if (this.embedSearch && file instanceof TFile && file.extension === "md")
this.embedSearch.reembedFile(file);
})
);
// Persistent notice updated during background indexing
const notice = new Notice("Memex: Embedding wird vorbereitet…", 0);
this.embedSearch.onModelStatus = (status) => {
notice.setMessage(`Memex: ${status}`);
};
this.embedSearch.onProgress = (done, total, speed) => {
const speedStr = speed > 0 ? `${speed.toFixed(1)} N/s` : "";
const remaining = speed > 0 && done < total ? (total - done) / speed : 0;
const eta = remaining > 0
? ` • ~${remaining < 60 ? Math.ceil(remaining) + "s" : Math.ceil(remaining / 60) + "min"}`
: "";
notice.setMessage(`Memex Embedding: ${done}/${total}${speedStr}${eta}`);
};
// Wait for Obsidian Sync to finish before starting (avoids embedding stale/partial files)
this.waitForSyncIdle(notice).then(() => this.embedSearch?.buildIndex())
.then(() => {
notice.setMessage(`✓ Memex: ${this.app.vault.getMarkdownFiles().length} Notizen eingebettet`);
setTimeout(() => notice.hide(), 4000);
this.notifyRelatedView();
})
.catch((e) => {
notice.setMessage(`✗ Memex Embedding: ${(e as Error).message}`);
setTimeout(() => notice.hide(), 6000);
console.error(e);
})
.finally(() => {
if (this.embedSearch) {
this.embedSearch.onProgress = undefined;
this.embedSearch.onModelStatus = undefined;
}
});
}
async rebuildIndex(): Promise<void> {
@@ -166,6 +230,49 @@ export default class MemexChatPlugin extends Plugin {
}
}
/**
* Waits until Obsidian Sync is idle.
* Strategy: watch for vault changes; if activity stops for 15 s, sync is done.
* If no activity within the first 5 s, sync isn't running — return immediately.
* Falls back after 5 minutes regardless.
*/
private async waitForSyncIdle(notice: Notice): Promise<void> {
// Only wait if the Sync plugin is installed
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const syncPlugin = (this.app as any).internalPlugins?.plugins?.["sync"]?.instance;
if (!syncPlugin) return;
const PROBE_MS = 5_000; // time to detect if sync is active
const QUIET_MS = 15_000; // idle period that signals sync completion
const MAX_MS = 5 * 60_000;
let lastChange = 0;
let activitySeen = false;
const tick = () => { lastChange = Date.now(); activitySeen = true; };
this.app.vault.on("create", tick);
this.app.vault.on("modify", tick);
this.app.vault.on("delete", tick);
try {
notice.setMessage("Memex: Prüfe Sync-Status…");
await new Promise((r) => setTimeout(r, PROBE_MS));
if (!activitySeen) return; // no sync activity → proceed immediately
notice.setMessage("Memex: Warte auf Obsidian Sync…");
const deadline = Date.now() + MAX_MS;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 2_000));
if (Date.now() - lastChange >= QUIET_MS) return; // 15 s quiet → done
}
// Max wait reached — proceed anyway
} finally {
this.app.vault.off("create", tick);
this.app.vault.off("modify", tick);
this.app.vault.off("delete", tick);
}
}
async saveSettings(): Promise<void> {
this.data.settings = this.settings;
await this.saveData(this.data);
+130
View File
@@ -1,5 +1,100 @@
/* ─── Memex Chat Plugin Styles ───────────────────────────────────────── */
/* ─── Related Notes Panel ────────────────────────────────────────────── */
.vc-related-header {
padding: 10px 12px 6px;
border-bottom: 1px solid var(--background-modifier-border);
}
.vc-related-title {
font-weight: 600;
font-size: 13px;
color: var(--text-normal);
}
.vc-related-subtitle {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.vc-related-status {
padding: 16px 12px;
font-size: 12px;
color: var(--text-muted);
}
.vc-related-list {
overflow-y: auto;
}
.vc-related-item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
cursor: pointer;
border-bottom: 1px solid var(--background-modifier-border);
transition: background 0.1s;
}
.vc-related-item:hover {
background: var(--background-modifier-hover);
}
.vc-related-info {
flex: 1;
min-width: 0;
}
.vc-related-name {
font-size: 12px;
font-weight: 500;
color: var(--text-normal);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.vc-related-folder {
font-size: 10px;
color: var(--text-faint);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.vc-related-score-wrap {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 3px;
flex-shrink: 0;
}
.vc-related-bar {
width: 48px;
height: 3px;
background: var(--background-modifier-border);
border-radius: 2px;
overflow: hidden;
}
.vc-related-bar-fill {
height: 100%;
background: var(--interactive-accent);
border-radius: 2px;
}
.vc-related-pct {
font-size: 10px;
color: var(--text-muted);
}
.vc-root {
display: flex;
flex-direction: column;
@@ -797,6 +892,41 @@
opacity: 0.85;
}
/* Folder autocomplete */
.vc-folder-search-wrap {
position: relative;
flex: 1;
}
.vc-folder-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 100;
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
max-height: 220px;
overflow-y: auto;
margin-top: 2px;
}
.vc-folder-item {
padding: 5px 10px;
font-size: 12px;
cursor: pointer;
color: var(--text-normal);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.vc-folder-item:hover {
background: var(--background-modifier-hover);
}
/* Prompt button settings list */
.vc-pbtn-list {
display: flex;