From 2b47782845ed51803f78e3617e2463be9fe2d983 Mon Sep 17 00:00:00 2001 From: Mark Baker Date: Thu, 11 Jun 2026 00:00:01 -0400 Subject: [PATCH 1/3] docs: list `noema keygen` in the command reference table keygen is a top-level command but was only mentioned in the federation event-signing prose; add it to the command table next to the federation block so it's discoverable when scanning the command list. Co-Authored-By: Claude Opus 4.8 --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 8352f57..1f9b2af 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,9 @@ noema federation resume-peer Resume syncing with a paused peer noema federation key fingerprint Print the SHA-256 fingerprint of the active MCP shared key (safe to say aloud over an out-of-band channel to confirm a pairing) +noema keygen [--force] Generate this cortex's Ed25519 federation signing key so it can sign + the events it emits (--force rotates it; peers must re-pin) + noema serve [--transport stdio|http] [--host ] [--tls-cert --tls-key ] Start the MCP server (http requires --host; endpoint is /mcp) noema serve --print-config Print a ready-to-use .mcp.json snippet and exit From a5bbfe14ea44378b31cb8d3c426e7469c7518347 Mon Sep 17 00:00:00 2001 From: Mark Baker Date: Sat, 13 Jun 2026 00:00:00 -0400 Subject: [PATCH 2/3] fix(federation): allow same-name pinned peer events --- internal/cortex/cortex.go | 36 ++++++++++++++++++++-------------- internal/cortex/cortex_test.go | 32 ++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 15 deletions(-) diff --git a/internal/cortex/cortex.go b/internal/cortex/cortex.go index 03c0b93..d981429 100644 --- a/internal/cortex/cortex.go +++ b/internal/cortex/cortex.go @@ -1346,25 +1346,31 @@ func (c *Cortex) backfillTraceUsage() error { return nil } -// detectCopiedDirectory refuses to start when events this cortex *authored* -// (origin == its display name) are recorded under a cortex_id other than the -// one cortex.md now declares. That is the signature of a directory copied or -// re-identified from another instance: its own history lives under a stale -// identity, and running it would create two physical Cortexes claiming the same -// id in any federation they joined, silently merging vector clocks. +// detectCopiedDirectory refuses to start when events that appear locally +// authored are recorded under a cortex_id other than the one cortex.md now +// declares. That is the signature of a directory copied or re-identified from +// another instance: its own history lives under a stale identity, and running +// it would create two physical Cortexes claiming the same id in any federation +// they joined, silently merging vector clocks. // -// Crucially, the check keys on origin, NOT on "any foreign cortex_id". Events -// replayed from peers via federation legitimately carry the originating -// cortex's id, so a receiver or subscribe cortex normally holds many -// foreign-id events with none of its own — that is expected, not a copy. The -// earlier id-only heuristic flagged exactly that case, making such a cortex -// unopenable (serve restart and every CLI command, including the reset-peer -// recovery) after its first sync. Scoping to origin matches precisely the rows -// `noema migrate cortex-id --reset` re-keys, so the guard and its remedy agree. +// The check cannot rely on origin alone. It is a display label, and real +// federations may contain multiple distinct cortex IDs with the same name. A +// peer event with origin == c.Name is legitimate when the foreign cortex_id is +// one of our pinned peers, so those IDs are excluded from the copy count. func (c *Cortex) detectCopiedDirectory() error { var ownUnderForeignID int if err := c.DB.QueryRow( - `SELECT COUNT(*) FROM events WHERE origin = ? AND cortex_id != '' AND cortex_id != ?`, + `SELECT COUNT(*) + FROM events + WHERE origin = ? + AND cortex_id != '' + AND cortex_id != ? + AND cortex_id NOT IN ( + SELECT value + FROM federation_state + WHERE key LIKE 'peer:%:cortex_id' + AND value != '' + )`, c.Name, c.ID, ).Scan(&ownUnderForeignID); err != nil { return nil // table missing or unreadable — treat as fresh, don't block diff --git a/internal/cortex/cortex_test.go b/internal/cortex/cortex_test.go index 05c5db0..72dabea 100644 --- a/internal/cortex/cortex_test.go +++ b/internal/cortex/cortex_test.go @@ -280,6 +280,38 @@ func TestOpen_AllowsFederatedReceiver(t *testing.T) { cx2.Close() } +func TestOpen_AllowsPinnedPeerWithSameDisplayName(t *testing.T) { + dir := t.TempDir() + if _, err := cortex.Create("agentbrain", dir); err != nil { + t.Fatalf("Create: %v", err) + } + root := filepath.Join(dir, "agentbrain") + + cx, err := cortex.Open("agentbrain", root) + if err != nil { + t.Fatalf("first Open: %v", err) + } + peerID := "01PEERCORTEX0000000000000A" + if err := federation.NewState(cx.DB.DB).SetPeerCortexID("peer-a", peerID); err != nil { + t.Fatalf("pin peer cortex id: %v", err) + } + // Multiple machines may intentionally use the same human-readable cortex + // name. The authenticated cortex_id, not origin, is the peer identity. + if _, err := cx.DB.Exec( + `INSERT INTO events (id, action, trace_id, cortex_id, origin, timestamp) VALUES (?, ?, ?, ?, ?, ?)`, + "01EVPEER0000000000000000B", "create", "20260610-peer-same-name", peerID, "agentbrain", "2026-06-10T00:00:00Z", + ); err != nil { + t.Fatalf("seed same-name peer event: %v", err) + } + cx.Close() + + cx2, err := cortex.Open("agentbrain", root) + if err != nil { + t.Fatalf("reopening with a pinned same-name peer event must succeed, got: %v", err) + } + cx2.Close() +} + // TestOpen_RejectsReIdentifiedCopy keeps the real copy detection: when events // THIS cortex authored (origin == its name) are recorded under a cortex_id that // differs from the one cortex.md now declares, the directory was copied or From 9f94cee822593fcdd4a383d7dea33bfd69260dcd Mon Sep 17 00:00:00 2001 From: Mark Baker Date: Sun, 14 Jun 2026 00:00:01 -0400 Subject: [PATCH 3/3] feat(obsidian): add Noema-backed trace search --- .gitignore | 2 + Makefile | 22 +++- plugins/obsidian/README.md | 8 +- plugins/obsidian/manifest.json | 4 +- plugins/obsidian/package-lock.json | 4 +- plugins/obsidian/package.json | 4 +- plugins/obsidian/src/main.ts | 9 ++ plugins/obsidian/src/mcp-client.ts | 56 ++++++++- plugins/obsidian/src/search-modal.ts | 162 +++++++++++++++++++++++++++ plugins/obsidian/src/settings.ts | 18 +++ plugins/obsidian/styles.css | 121 ++++++++++++++++++++ 11 files changed, 399 insertions(+), 11 deletions(-) create mode 100644 plugins/obsidian/src/search-modal.ts diff --git a/.gitignore b/.gitignore index 807f381..ceff9c1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Build output /noema /dist/ +/noema-*-plugin.tar.gz # SQLite databases *.db @@ -17,6 +18,7 @@ # Caches **/__pycache__ .pytest_cache +.gocache # Editor *.swp diff --git a/Makefile b/Makefile index 024cf06..a7caabe 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,7 @@ LDFLAGS_RELEASE := -s -w -X $(VERSION_PKG).Version=$(VERSION) HOST_OS := $(shell go env GOOS) HOST_ARCH := $(shell go env GOARCH) -.PHONY: help build release release-linux test vet clean +.PHONY: help build release release-linux test vet obsidian-publish clean help: @echo "Noema build targets:" @@ -38,6 +38,8 @@ help: @echo " make release-linux Stripped build for linux/amd64 -> $(DIST_DIR)/$(BIN)-linux-amd64" @echo " make test go test ./..." @echo " make vet go vet ./..." + @echo " make obsidian-publish" + @echo " Build and copy Obsidian plugin into the active cortex vault" @echo " make clean Remove ./$(BIN) and ./$(DIST_DIR)/" @echo "" @echo "Version string for the next build: $(VERSION)" @@ -69,6 +71,24 @@ test: vet: go vet ./... +obsidian-publish: + npm --prefix plugins/obsidian run build + @set -eu; \ + cortex_dir="$(OBSIDIAN_CORTEX_DIR)"; \ + if [ -z "$$cortex_dir" ]; then \ + cortex_dir="$$(noema cortex list | sed -n 's/^[^[:space:]][^[:space:]]*[[:space:]][[:space:]]*\(.*\)[[:space:]][[:space:]]*\*$$/\1/p' | sed 's/[[:space:]]*$$//' | head -n 1)"; \ + fi; \ + if [ -z "$$cortex_dir" ]; then \ + echo "error: no active cortex found; run 'noema use ' or pass OBSIDIAN_CORTEX_DIR=/path/to/cortex" >&2; \ + exit 1; \ + fi; \ + dest="$$cortex_dir/.obsidian/plugins/noema"; \ + install -d "$$dest"; \ + install -m 0644 plugins/obsidian/main.js "$$dest/main.js"; \ + install -m 0644 plugins/obsidian/manifest.json "$$dest/manifest.json"; \ + install -m 0644 plugins/obsidian/styles.css "$$dest/styles.css"; \ + echo "Obsidian plugin published to $$dest" + clean: rm -f $(BIN) rm -rf $(DIST_DIR) diff --git a/plugins/obsidian/README.md b/plugins/obsidian/README.md index 51986c0..fc4131d 100644 --- a/plugins/obsidian/README.md +++ b/plugins/obsidian/README.md @@ -7,8 +7,9 @@ Lineage view and tier visibility for [Noema](https://github.com/Fail-Safe/Noema) - **Lineage sidebar.** When you open a trace, the sidebar shows its `derived_from` ancestors and the traces derived from it, both clickable. Useful for navigating "where did this come from / what came out of this" without leaving the editor. - **Tier badge in the status bar.** Shows `[s]` / `[m]` / `[L]` for the currently-open trace and a tooltip note that long-tier traces are immutable. - **Connection status.** The same status bar item shows whether the plugin is connected to a `noema serve --transport http` endpoint. A keyed-mode server that rejects (or requires) the bearer key shows `noema: unauthorized` instead of `noema: disconnected`, and pops a one-time notice pointing you at the bearer-key setting — so a wrong key reads as a credential problem, not an unreachable server. +- **Noema-backed trace search.** `Noema: Search traces` calls the connected cortex's `search_traces` MCP tool and opens the selected trace in Obsidian. The plugin setting chooses `hybrid`, `semantic`, or `lexical`; the modal can show 5 or 10 results. Server-side `cortex.md` still owns embedding configuration and `hybrid_weight`. -That's intentionally the whole feature set for v0.1. File-explorer decorations, trace creation UI, and FTS5-backed search are reasonable next-version additions but aren't here yet. +That's intentionally the whole feature set for v0.3. File-explorer decorations and federation status panels are reasonable next-version additions but aren't here yet. ## Setup @@ -20,6 +21,7 @@ That's intentionally the whole feature set for v0.1. File-explorer decorations, - **HTTP endpoint** — e.g. `https://noema.local:3000` - **Bearer key** — required if the server is in keyed mode (`NOEMA_MCP_KEY` or `access.shared_key_file`); leave empty for open-mode (loopback only). - **Test connection** — click to probe the endpoint immediately and get a notice telling you whether it connected, was rejected (HTTP 401, fix the key), or was unreachable. + - **Noema search mode** — used by `Noema: Search traces`; defaults to `Hybrid`. 6. Open the lineage sidebar via the command palette: `Noema: Open lineage view`. The status bar will show `noema: ` once the connection succeeds. @@ -46,6 +48,6 @@ Produces `main.js` next to `manifest.json`. For development, `npm run dev` watch ## Why so small -Noema is intentionally lightweight infrastructure — markdown files plus a SQLite index, no opinion about your editor. This plugin matches that spirit: it adds the two pieces of UI that genuinely benefit from being inside Obsidian (lineage navigation and tier visibility) and stays out of the way for everything else. Editing happens in Obsidian's native editor, search uses Obsidian's native search, file management uses Obsidian's native file explorer. +Noema is intentionally lightweight infrastructure — markdown files plus a SQLite index, no opinion about your editor. This plugin matches that spirit: it adds the pieces of UI that genuinely benefit from being inside Obsidian (lineage navigation, tier visibility, trace creation, appends, and Noema-ranked trace search) and stays out of the way for everything else. Editing happens in Obsidian's native editor and file management uses Obsidian's native file explorer. -If you want richer integration (FTS5-backed search, trace creation modals, federation status panel), file an issue against the main repo with the use case — they're easy to add as separate, opt-in commands. +If you want richer integration (file-explorer decorations, saved search views, federation status panel), file an issue against the main repo with the use case — they're easy to add as separate, opt-in commands. diff --git a/plugins/obsidian/manifest.json b/plugins/obsidian/manifest.json index 2832075..8e3b6d6 100644 --- a/plugins/obsidian/manifest.json +++ b/plugins/obsidian/manifest.json @@ -1,9 +1,9 @@ { "id": "noema", "name": "Noema", - "version": "0.2.1", + "version": "0.3.0", "minAppVersion": "1.4.0", - "description": "Lineage view and tier visibility for Noema cortex traces. Connects to a `noema serve --transport http` endpoint.", + "description": "Lineage view, tier visibility, and Noema-backed search for Noema cortex traces. Connects to a `noema serve --transport http` endpoint.", "author": "Mark Baker (https://github.com/Fail-Safe)", "authorUrl": "https://github.com/Fail-Safe/Noema", "fundingUrl": "", diff --git a/plugins/obsidian/package-lock.json b/plugins/obsidian/package-lock.json index 5d9338a..c865bb5 100644 --- a/plugins/obsidian/package-lock.json +++ b/plugins/obsidian/package-lock.json @@ -1,12 +1,12 @@ { "name": "noema-obsidian", - "version": "0.2.1", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "noema-obsidian", - "version": "0.2.1", + "version": "0.3.0", "license": "MIT", "devDependencies": { "@types/node": "^20", diff --git a/plugins/obsidian/package.json b/plugins/obsidian/package.json index cd08c54..3b3a77e 100644 --- a/plugins/obsidian/package.json +++ b/plugins/obsidian/package.json @@ -1,7 +1,7 @@ { "name": "noema-obsidian", - "version": "0.2.1", - "description": "Lineage view and tier visibility for Noema cortex traces inside Obsidian.", + "version": "0.3.0", + "description": "Lineage view, tier visibility, and Noema-backed search for Noema cortex traces inside Obsidian.", "main": "main.js", "scripts": { "dev": "node esbuild.config.mjs", diff --git a/plugins/obsidian/src/main.ts b/plugins/obsidian/src/main.ts index 84ecf3b..2cd03e7 100644 --- a/plugins/obsidian/src/main.ts +++ b/plugins/obsidian/src/main.ts @@ -6,6 +6,7 @@ import { readTraceMetadata, tierGlyph, tierLabel } from "./tier-status"; import { CreateTraceModal } from "./create-modal"; import { ImmutableWarning } from "./immutable-warning"; import { openAppendModalFromActive } from "./append-modal"; +import { SearchModal } from "./search-modal"; const STATUS_PING_INTERVAL_MS = 30_000; @@ -83,6 +84,14 @@ export default class NoemaPlugin extends Plugin { }, }); + this.addCommand({ + id: "search-traces", + name: "Search traces", + callback: () => { + new SearchModal(this.app, this).open(); + }, + }); + // Append-to-trace uses checkCallback so the command is greyed // out in the palette unless the active editor is a trace. // That's the right UX hint: "no trace open" reads as an diff --git a/plugins/obsidian/src/mcp-client.ts b/plugins/obsidian/src/mcp-client.ts index 359be24..894db1e 100644 --- a/plugins/obsidian/src/mcp-client.ts +++ b/plugins/obsidian/src/mcp-client.ts @@ -85,6 +85,16 @@ export const TRACE_TYPES = [ "note", ] as const; export type TraceType = (typeof TRACE_TYPES)[number]; +export type SearchMode = "lexical" | "semantic" | "hybrid"; + +export interface SearchResult { + id: string; + title: string; + type: string; + author: string; + tags: string; + created: string; +} // CreateTraceParams is the input shape for McpClient.createTrace. // tags and derivedFrom are arrays at this layer for caller ergonomics; @@ -199,6 +209,11 @@ export class McpClient { return match[1]; } + async searchTraces(query: string, mode: SearchMode): Promise { + const text = await this.callToolText("search_traces", { query, mode }); + return parseSearchResults(text); + } + // callToolText invokes a tool and returns the concatenated text // content, or throws JsonRpcError if the protocol-layer call // failed. Most noema tools return a single text block; we @@ -279,7 +294,7 @@ export class McpClient { params: { protocolVersion: MCP_PROTOCOL_VERSION, capabilities: {}, - clientInfo: { name: "noema-obsidian", version: "0.2.1" }, + clientInfo: { name: "noema-obsidian", version: "0.3.0" }, }, }; const resp = await fetch(url, { @@ -432,6 +447,45 @@ export function parseLineage(text: string, fallbackId: string): Lineage { return { traceId, derivedFrom, derivedBy }; } +export function parseSearchResults(text: string): SearchResult[] { + const rows: SearchResult[] = []; + const lines = text.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const mcpRow = line.match( + /^\[[^\]]+\]\s+\[([^\]]+)\]\s+(\S+)\s+\((\d{4}-\d{2}-\d{2})\)(?:\s+—\s+([^\[]+?))?(?:\s+\[(.*)\])?$/ + ); + if (mcpRow) { + const titleLine = lines[i + 1]?.startsWith(" ") + ? lines[++i].trim() + : ""; + rows.push({ + id: mcpRow[2], + title: titleLine, + type: mcpRow[1], + author: mcpRow[4]?.trim() ?? "", + tags: mcpRow[5]?.trim() ?? "", + created: mcpRow[3], + }); + continue; + } + + const id = line.trimStart().split(/\s+/, 1)[0]; + if (!/^\d{8}-[a-z0-9][a-z0-9-]*$/.test(id)) continue; + const parts = line.split(/\s{2,}/).map((s) => s.trim()); + if (parts.length < 2 || parts[0] !== id) continue; + rows.push({ + id: parts[0], + title: parts[1] ?? "", + type: parts[2] ?? "", + author: parts[3] ?? "", + tags: parts[4] ?? "", + created: parts[5] ?? "", + }); + } + return rows; +} + function parseIdList(s: string): string[] { const trimmed = s.trim(); if (!trimmed || trimmed === "(none)") return []; diff --git a/plugins/obsidian/src/search-modal.ts b/plugins/obsidian/src/search-modal.ts new file mode 100644 index 0000000..5570c5f --- /dev/null +++ b/plugins/obsidian/src/search-modal.ts @@ -0,0 +1,162 @@ +import { App, Modal, Notice, TFile } from "obsidian"; +import type NoemaPlugin from "./main"; +import type { SearchMode, SearchResult } from "./mcp-client"; + +export class SearchModal extends Modal { + private queryInput!: HTMLInputElement; + private modeSelect!: HTMLSelectElement; + private limitSelect!: HTMLSelectElement; + private resultEl!: HTMLElement; + private errorEl!: HTMLElement; + private searchBtn!: HTMLButtonElement; + private searching = false; + + constructor(app: App, private plugin: NoemaPlugin) { + super(app); + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass("noema-search-modal"); + + this.titleEl.setText("Search traces"); + + const controls = contentEl.createEl("div", { cls: "noema-search-controls" }); + this.queryInput = controls.createEl("input", { + cls: "noema-search-query", + attr: { + type: "search", + placeholder: "Search traces", + }, + }); + this.modeSelect = controls.createEl("select", { cls: "noema-search-mode" }); + for (const mode of ["hybrid", "semantic", "lexical"] as SearchMode[]) { + const opt = this.modeSelect.createEl("option", { text: mode }); + opt.value = mode; + } + this.modeSelect.value = this.plugin.settings.searchMode; + this.limitSelect = controls.createEl("select", { cls: "noema-search-limit" }); + for (const n of [5, 10]) { + const opt = this.limitSelect.createEl("option", { text: `${n} results` }); + opt.value = String(n); + } + this.limitSelect.value = "5"; + this.searchBtn = controls.createEl("button", { + text: "Search", + cls: "mod-cta noema-search-submit", + }); + + this.errorEl = contentEl.createEl("div", { cls: "noema-search-error" }); + this.errorEl.style.display = "none"; + this.resultEl = contentEl.createEl("div", { cls: "noema-search-results" }); + + this.searchBtn.addEventListener("click", () => this.search()); + this.queryInput.addEventListener("keydown", (evt) => { + if (evt.key === "Enter") { + evt.preventDefault(); + this.search(); + } + }); + + setTimeout(() => this.queryInput.focus(), 0); + } + + onClose(): void { + this.contentEl.empty(); + } + + private async search(): Promise { + if (this.searching) return; + this.hideError(); + this.resultEl.empty(); + + const query = this.queryInput.value.trim(); + if (!query) { + this.showError("Search query is required."); + this.queryInput.focus(); + return; + } + const client = this.plugin.client; + if (!client) { + this.showError("No noema endpoint configured. Set one in Settings -> Noema."); + return; + } + + this.searching = true; + this.searchBtn.disabled = true; + this.searchBtn.setText("Searching..."); + try { + const mode = this.modeSelect.value as SearchMode; + const rows = await client.searchTraces(query, mode); + const limit = Number.parseInt(this.limitSelect.value, 10) || 5; + this.renderResults(rows.slice(0, limit), rows.length); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + this.showError(`Search failed: ${msg}`); + } finally { + this.searching = false; + this.searchBtn.disabled = false; + this.searchBtn.setText("Search"); + } + } + + private renderResults(rows: SearchResult[], total: number): void { + this.resultEl.empty(); + if (rows.length === 0) { + this.resultEl.createEl("div", { + cls: "noema-search-empty", + text: "No matching traces.", + }); + return; + } + if (total > rows.length) { + this.resultEl.createEl("div", { + cls: "noema-search-count", + text: `Showing ${rows.length} of ${total} results`, + }); + } + for (const row of rows) { + const item = this.resultEl.createEl("button", { cls: "noema-search-result" }); + item.type = "button"; + const title = this.localTitle(row.id) ?? row.title; + item.createEl("div", { cls: "noema-search-result-title", text: title }); + item.createEl("div", { cls: "noema-search-result-id", text: row.id }); + const meta = item.createEl("div", { cls: "noema-search-result-meta" }); + if (row.type) meta.createEl("span", { text: row.type }); + if (row.author) meta.createEl("span", { text: row.author }); + if (row.created) meta.createEl("span", { text: row.created }); + item.addEventListener("click", () => this.openTrace(row.id)); + } + } + + private async openTrace(traceId: string): Promise { + const folder = this.plugin.settings.tracesFolder.replace(/\/+$/, ""); + const file = this.app.vault.getFileByPath(`${folder}/${traceId}.md`); + if (!(file instanceof TFile)) { + new Notice(`Noema: ${traceId}.md was not found in ${folder}.`); + return; + } + await this.app.workspace.getLeaf(false).openFile(file); + this.close(); + } + + private localTitle(traceId: string): string | null { + const folder = this.plugin.settings.tracesFolder.replace(/\/+$/, ""); + const file = this.app.vault.getFileByPath(`${folder}/${traceId}.md`); + if (!(file instanceof TFile)) return null; + const cache = this.app.metadataCache.getFileCache(file); + const title = cache?.frontmatter?.title; + return typeof title === "string" && title.trim() ? title.trim() : null; + } + + private showError(text: string): void { + this.errorEl.setText(text); + this.errorEl.style.display = ""; + } + + private hideError(): void { + this.errorEl.empty(); + this.errorEl.style.display = "none"; + } +} diff --git a/plugins/obsidian/src/settings.ts b/plugins/obsidian/src/settings.ts index cf5321d..8a781a2 100644 --- a/plugins/obsidian/src/settings.ts +++ b/plugins/obsidian/src/settings.ts @@ -6,6 +6,7 @@ export interface NoemaSettings { bearerKey: string; tracesFolder: string; defaultAuthor: string; + searchMode: "lexical" | "semantic" | "hybrid"; } // DEFAULT_SETTINGS deliberately leave endpoint and bearerKey empty so @@ -21,6 +22,7 @@ export const DEFAULT_SETTINGS: NoemaSettings = { bearerKey: "", tracesFolder: "traces", defaultAuthor: "", + searchMode: "hybrid", }; export class NoemaSettingTab extends PluginSettingTab { @@ -115,5 +117,21 @@ export class NoemaSettingTab extends PluginSettingTab { await this.plugin.saveSettings(); }) ); + + new Setting(containerEl) + .setName("Noema search mode") + .setDesc("Used by the 'Search traces' command. Hybrid uses the cortex's server-side hybrid_weight.") + .addDropdown((dropdown) => + dropdown + .addOption("hybrid", "Hybrid") + .addOption("semantic", "Semantic") + .addOption("lexical", "Lexical") + .setValue(this.plugin.settings.searchMode) + .onChange(async (value) => { + this.plugin.settings.searchMode = + value as NoemaSettings["searchMode"]; + await this.plugin.saveSettings(); + }) + ); } } diff --git a/plugins/obsidian/styles.css b/plugins/obsidian/styles.css index 78c82e9..491dd35 100644 --- a/plugins/obsidian/styles.css +++ b/plugins/obsidian/styles.css @@ -260,6 +260,127 @@ min-height: 120px; } +/* ---- Search modal ---- */ + +.noema-search-modal .noema-search-controls { + display: grid; + grid-template-columns: minmax(0, 1fr) 120px 120px auto; + gap: 8px; + align-items: center; + margin-bottom: 12px; +} + +.noema-search-modal .noema-search-query, +.noema-search-modal .noema-search-mode, +.noema-search-modal .noema-search-limit { + width: 100%; +} + +.noema-search-modal .noema-search-error { + color: var(--text-error); + font-size: var(--font-ui-smaller); + margin: 8px 0; + padding: 6px 8px; + background: var(--background-modifier-error); + border-radius: 4px; +} + +.noema-search-modal .noema-search-results { + display: flex; + flex-direction: column; + gap: 4px; + max-height: min(55vh, 520px); + padding-bottom: 6px; + overflow: auto; +} + +.noema-search-modal .noema-search-result { + display: flex; + flex-direction: column; + gap: 4px; + width: 100%; + height: auto; + min-height: 76px; + text-align: left; + padding: 9px 12px; + line-height: 1.3; + border-radius: 6px; + border: 1px solid var(--background-modifier-border); + background: var(--background-primary); + color: var(--text-normal); + cursor: pointer; +} + +.noema-search-modal .noema-search-result:hover, +.noema-search-modal .noema-search-result:focus { + background: var(--background-modifier-hover); +} + +.noema-search-modal .noema-search-result-title { + font-weight: 600; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.35; +} + +.noema-search-modal .noema-search-result-meta { + display: flex; + flex-wrap: nowrap; + gap: 6px; + color: var(--text-muted); + font-size: var(--font-ui-smaller); + line-height: 1.25; + overflow: hidden; + white-space: nowrap; + width: 100%; +} + +.noema-search-modal .noema-search-result-meta span { + flex: 0 0 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.noema-search-modal .noema-search-result-meta span:not(:last-child)::after { + content: "·"; + color: var(--text-faint); + margin-left: 6px; +} + +.noema-search-modal .noema-search-result-id { + width: 100%; + font-family: var(--font-monospace); + color: var(--text-faint); + font-size: var(--font-ui-smaller); + line-height: 1.25; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.noema-search-modal .noema-search-empty { + color: var(--text-muted); + font-style: italic; + padding: 8px 0; +} + +.noema-search-modal .noema-search-count { + color: var(--text-muted); + font-size: var(--font-ui-smaller); + padding: 0 2px 2px; +} + +@media (max-width: 520px) { + .noema-search-modal .noema-search-controls { + grid-template-columns: 1fr; + } + +} + .noema-append-modal .noema-append-hint { color: var(--text-faint); font-size: var(--font-ui-smaller);