diff --git a/main.js b/main.js index 7ee7959..6c686e9 100644 --- a/main.js +++ b/main.js @@ -32293,6 +32293,8 @@ var EmbedSearch = class { constructor(app, modelId) { this.excludeFolders = []; // vault folder prefixes to skip + this.contextProperties = []; + // frontmatter keys whose links get a score boost // eslint-disable-next-line @typescript-eslint/no-explicit-any this.pipe = null; this.cache = /* @__PURE__ */ new Map(); @@ -32508,18 +32510,51 @@ var EmbedSearch = class { return []; } } + const linkedPaths = /* @__PURE__ */ new Set(); + if (this.contextProperties.length > 0) { + const meta = this.app.metadataCache.getFileCache(file); + const links = meta?.frontmatterLinks ?? []; + for (const link of links) { + if (this.contextProperties.includes(link.key.split(".")[0])) { + const resolved = this.app.metadataCache.getFirstLinkpathDest(link.link, file.path); + if (resolved) + linkedPaths.add(resolved.path); + } + } + } + const fileMeta = this.app.metadataCache.getFileCache(file); + const fileTags = new Set( + (fileMeta?.tags ?? []).map((t) => t.tag.toLowerCase()) + ); 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]); + let s = this.cosine(qvec, vec); + if (s < 0.15) + continue; + if (linkedPaths.has(path3)) { + s = Math.min(1, s + 0.15); + } + if (fileTags.size > 0) { + const otherMeta = this.app.metadataCache.getFileCache(this.vecs.get(path3).file); + const otherTags = (otherMeta?.tags ?? []).map((t) => t.tag.toLowerCase()); + let sharedTags = 0; + for (const tag of otherTags) { + if (fileTags.has(tag)) + sharedTags++; + if (sharedTags >= 3) + break; + } + if (sharedTags > 0) + s = Math.min(1, s + sharedTags * 0.05); + } + 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 }; + return { file: f, score, excerpt: "", title: f.basename, linked: linkedPaths.has(path3) }; }); } async search(query, topK = 8) { @@ -33199,7 +33234,10 @@ var RelatedNotesView = class extends import_obsidian4.ItemView { 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 nameRow = info.createDiv("vc-related-name-row"); + nameRow.createSpan({ cls: "vc-related-name", text: r.title }); + if (r.linked) + nameRow.createSpan({ cls: "vc-related-linked", text: "verkn\xFCpft" }); const folder = r.file.parent?.path; if (folder && folder !== "/") { info.createDiv({ cls: "vc-related-folder", text: folder }); @@ -33334,6 +33372,7 @@ var MemexChatPlugin = class extends import_obsidian5.Plugin { } this.embedSearch = new EmbedSearch(this.app, this.settings.embeddingModel); this.embedSearch.excludeFolders = this.settings.embedExcludeFolders ?? []; + this.embedSearch.contextProperties = this.settings.contextProperties ?? []; this.registerEvent( this.app.vault.on("modify", (file) => { if (this.embedSearch && file instanceof import_obsidian5.TFile && file.extension === "md") diff --git a/manifest.json b/manifest.json index 50b2001..a84db28 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "memex-chat", "name": "Memex Chat", - "version": "1.0.0", + "version": "1.0.1", "minAppVersion": "1.4.0", "description": "Chat with your Obsidian vault using Claude AI — semantic context retrieval, @ mentions, thread history.", "author": "Sven", diff --git a/package.json b/package.json index b4ccbe5..5e011cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "memex-chat", - "version": "1.0.0", + "version": "1.0.1", "description": "Obsidian plugin: Chat with your vault using Claude AI", "main": "main.js", "scripts": { diff --git a/src/EmbedSearch.ts b/src/EmbedSearch.ts index 3fd366d..6c2ec04 100644 --- a/src/EmbedSearch.ts +++ b/src/EmbedSearch.ts @@ -27,6 +27,7 @@ export class EmbedSearch { private app: App; private modelId: string; excludeFolders: string[] = []; // vault folder prefixes to skip + contextProperties: string[] = []; // frontmatter keys whose links get a score boost // eslint-disable-next-line @typescript-eslint/no-explicit-any private pipe: ((text: string, opts: object) => Promise<{ data: Float32Array }>) | null = null; private cache: Map = new Map(); // vaultPath → entry @@ -278,16 +279,53 @@ export class EmbedSearch { qvec = await this.embedWithTimeout(text); } catch { return []; } } + + // Collect paths explicitly linked via contextProperty frontmatter fields + const linkedPaths = new Set(); + if (this.contextProperties.length > 0) { + const meta = this.app.metadataCache.getFileCache(file); + const links = meta?.frontmatterLinks ?? []; + for (const link of links) { + if (this.contextProperties.includes(link.key.split(".")[0])) { + const resolved = this.app.metadataCache.getFirstLinkpathDest(link.link, file.path); + if (resolved) linkedPaths.add(resolved.path); + } + } + } + + // Collect tags of the current file + const fileMeta = this.app.metadataCache.getFileCache(file); + const fileTags = new Set( + (fileMeta?.tags ?? []).map((t) => t.tag.toLowerCase()) + ); + 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]); + let s = this.cosine(qvec, vec); + if (s < 0.15) continue; // broader pre-filter to allow boosted notes through + + if (linkedPaths.has(path)) { + s = Math.min(1.0, s + 0.15); + } + + if (fileTags.size > 0) { + const otherMeta = this.app.metadataCache.getFileCache(this.vecs.get(path)!.file); + const otherTags = (otherMeta?.tags ?? []).map((t) => t.tag.toLowerCase()); + let sharedTags = 0; + for (const tag of otherTags) { + if (fileTags.has(tag)) sharedTags++; + if (sharedTags >= 3) break; + } + if (sharedTags > 0) s = Math.min(1.0, s + sharedTags * 0.05); + } + + 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 }; + return { file: f, score, excerpt: "", title: f.basename, linked: linkedPaths.has(path) }; }); } diff --git a/src/RelatedNotesView.ts b/src/RelatedNotesView.ts index 8418f7d..09f7643 100644 --- a/src/RelatedNotesView.ts +++ b/src/RelatedNotesView.ts @@ -68,7 +68,9 @@ export class RelatedNotesView extends ItemView { const item = list.createDiv("vc-related-item"); const info = item.createDiv("vc-related-info"); - info.createDiv({ cls: "vc-related-name", text: r.title }); + const nameRow = info.createDiv("vc-related-name-row"); + nameRow.createSpan({ cls: "vc-related-name", text: r.title }); + if (r.linked) nameRow.createSpan({ cls: "vc-related-linked", text: "verknüpft" }); // Folder path (dimmed) const folder = r.file.parent?.path; diff --git a/src/VaultSearch.ts b/src/VaultSearch.ts index 2c4e74b..b404d82 100644 --- a/src/VaultSearch.ts +++ b/src/VaultSearch.ts @@ -5,6 +5,8 @@ export interface SearchResult { score: number; excerpt: string; title: string; + /** True when the note is explicitly linked via a contextProperty frontmatter field */ + linked?: boolean; } /** Minimal TF-IDF search engine over the Obsidian vault */ diff --git a/src/main.ts b/src/main.ts index 62342f8..85822de 100644 --- a/src/main.ts +++ b/src/main.ts @@ -146,6 +146,7 @@ export default class MemexChatPlugin extends Plugin { } this.embedSearch = new EmbedSearch(this.app, this.settings.embeddingModel); this.embedSearch.excludeFolders = this.settings.embedExcludeFolders ?? []; + this.embedSearch.contextProperties = this.settings.contextProperties ?? []; // Re-embed modified notes as they change this.registerEvent( diff --git a/styles.css b/styles.css index 3fe472c..aef3268 100644 --- a/styles.css +++ b/styles.css @@ -51,6 +51,13 @@ min-width: 0; } +.vc-related-name-row { + display: flex; + align-items: baseline; + gap: 5px; + min-width: 0; +} + .vc-related-name { font-size: 12px; font-weight: 500; @@ -58,6 +65,20 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + min-width: 0; +} + +.vc-related-linked { + font-size: 9px; + font-weight: 600; + color: var(--color-accent); + background: color-mix(in srgb, var(--color-accent) 15%, transparent); + border-radius: 3px; + padding: 0 4px; + white-space: nowrap; + flex-shrink: 0; + text-transform: uppercase; + letter-spacing: 0.03em; } .vc-related-folder {