Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions plugins/obsidian/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ 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.
- **Tier badges in the file explorer.** Shows the same tier shorthand beside every trace in the configured traces folder, including while disconnected from the MCP endpoint. Enabled by default and independently toggleable in the plugin settings.
- **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.3. File-explorer decorations and federation status panels are reasonable next-version additions but aren't here yet.
That's intentionally the whole feature set for v0.4. Saved search views and federation status panels are reasonable next-version additions but aren't here yet.

## Setup

Expand All @@ -31,6 +32,7 @@ That's intentionally the whole feature set for v0.3. File-explorer decorations a
- **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.
- **Show tier badges in file explorer** — enabled by default; turn it off to hide `[s]`, `[m]`, and `[L]` beside trace filenames.
- **Noema search mode** — used by `Noema: Search traces`; defaults to `Hybrid`.
6. Open the lineage sidebar via the command palette: `Noema: Open lineage view`.

Expand Down Expand Up @@ -60,4 +62,4 @@ Produces `main.js` next to `manifest.json`. For development, `npm run dev` watch

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 (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.
If you want richer integration (saved search views or a federation status panel), file an issue against the main repo with the use case — they're easy to add as separate, opt-in commands.
6 changes: 3 additions & 3 deletions plugins/obsidian/main.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion plugins/obsidian/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "noema",
"name": "Noema",
"version": "0.3.0",
"version": "0.4.0",
"minAppVersion": "1.4.0",
"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)",
Expand Down
4 changes: 2 additions & 2 deletions plugins/obsidian/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion plugins/obsidian/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "noema-obsidian",
"version": "0.3.0",
"version": "0.4.0",
"description": "Lineage view, tier visibility, and Noema-backed search for Noema cortex traces inside Obsidian.",
"main": "main.js",
"scripts": {
Expand Down
150 changes: 150 additions & 0 deletions plugins/obsidian/src/file-explorer-tiers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { App, normalizePath, Plugin, TFile } from "obsidian";
import { readTraceMetadata, tierGlyph, tierLabel } from "./tier-status";

const FILE_EXPLORER_VIEW_TYPE = "file-explorer";
const FILE_ROW_SELECTOR = ".nav-file-title[data-path]";
const BADGE_CLASS = "noema-file-tier-badge";

// FileExplorerTierBadges adds tier visibility to Obsidian's native file
// explorer without taking ownership of its tree. Obsidian does not expose a
// public row-render hook, so the DOM selectors are deliberately isolated in
// this class. Metadata and lifecycle updates still use supported APIs.
export class FileExplorerTierBadges {
private readonly observers = new Map<HTMLElement, MutationObserver>();
private active = false;
private refreshFrame: number | null = null;

constructor(
private readonly app: App,
private readonly plugin: Plugin,
private readonly getTracesFolder: () => string,
private readonly getEnabled: () => boolean
) {}

start(): void {
this.active = true;
this.app.workspace.onLayoutReady(() => {
if (!this.active) return;
this.bindExplorerViews();

this.plugin.registerEvent(
this.app.workspace.on("layout-change", () => this.bindExplorerViews())
);
this.plugin.registerEvent(
this.app.metadataCache.on("changed", (file) => this.refreshFile(file.path))
);
this.plugin.registerEvent(this.app.vault.on("create", () => this.refresh()));
this.plugin.registerEvent(this.app.vault.on("rename", () => this.refresh()));
this.plugin.registerEvent(this.app.vault.on("delete", () => this.refresh()));
});
}

stop(): void {
this.active = false;
if (this.refreshFrame !== null) {
window.cancelAnimationFrame(this.refreshFrame);
this.refreshFrame = null;
}
for (const [root, observer] of this.observers) {
observer.disconnect();
this.removeBadges(root);
}
this.observers.clear();
}

refresh(): void {
if (!this.active || this.refreshFrame !== null) return;
this.refreshFrame = window.requestAnimationFrame(() => {
this.refreshFrame = null;
for (const root of this.observers.keys()) {
root
.querySelectorAll<HTMLElement>(FILE_ROW_SELECTOR)
.forEach((row) => this.decorateRow(row));
}
});
}

private bindExplorerViews(): void {
const currentRoots = new Set(
this.app.workspace
.getLeavesOfType(FILE_EXPLORER_VIEW_TYPE)
.map((leaf) => leaf.view.containerEl)
);

for (const [root, observer] of this.observers) {
if (currentRoots.has(root)) continue;
observer.disconnect();
this.removeBadges(root);
this.observers.delete(root);
}

for (const root of currentRoots) {
if (this.observers.has(root)) continue;
const observer = new MutationObserver(() => this.refresh());
observer.observe(root, { childList: true, subtree: true });
this.observers.set(root, observer);
}

this.refresh();
}

private refreshFile(path: string): void {
if (!this.active) return;
for (const root of this.observers.keys()) {
root.querySelectorAll<HTMLElement>(FILE_ROW_SELECTOR).forEach((row) => {
if (row.dataset.path === path) this.decorateRow(row);
});
}
}

private decorateRow(row: HTMLElement): void {
if (!this.getEnabled()) {
this.removeBadge(row);
return;
}

const path = row.dataset.path;
const folder = normalizePath(this.getTracesFolder().trim() || "traces").replace(/\/$/, "");
if (!path || !path.startsWith(`${folder}/`)) {
this.removeBadge(row);
return;
}

const file = this.app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile) || file.extension !== "md") {
this.removeBadge(row);
return;
}

const metadata = readTraceMetadata(this.app, file);
if (!metadata?.id) {
this.removeBadge(row);
return;
}

let badge = row.querySelector<HTMLElement>(`.${BADGE_CLASS}`);
if (!badge) {
badge = document.createElement("span");
badge.classList.add(BADGE_CLASS);
const title = row.querySelector<HTMLElement>(".nav-file-title-content");
if (title) title.before(badge);
else row.prepend(badge);
}

const glyph = `[${tierGlyph(metadata.tier)}]`;
const label = tierLabel(metadata.tier);
badge.className = `${BADGE_CLASS} noema-file-tier-${metadata.tier}`;
if (badge.textContent !== glyph) badge.textContent = glyph;
badge.dataset.tier = metadata.tier;
badge.setAttribute("title", label);
badge.setAttribute("aria-label", label);
}

private removeBadge(row: HTMLElement): void {
row.querySelector(`.${BADGE_CLASS}`)?.remove();
}

private removeBadges(root: HTMLElement): void {
root.querySelectorAll(`.${BADGE_CLASS}`).forEach((badge) => badge.remove());
}
}
14 changes: 14 additions & 0 deletions plugins/obsidian/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { CreateTraceModal } from "./create-modal";
import { ImmutableWarning } from "./immutable-warning";
import { openAppendModalFromActive } from "./append-modal";
import { SearchModal } from "./search-modal";
import { FileExplorerTierBadges } from "./file-explorer-tiers";

const STATUS_PING_INTERVAL_MS = 30_000;

Expand Down Expand Up @@ -44,6 +45,7 @@ export default class NoemaPlugin extends Plugin {
// unreachable server (actionable: check the endpoint/network).
private connState: ConnState = "disconnected";
private immutableWarning: ImmutableWarning | null = null;
private fileExplorerTierBadges: FileExplorerTierBadges | null = null;

async onload(): Promise<void> {
await this.loadSettings();
Expand Down Expand Up @@ -119,6 +121,13 @@ export default class NoemaPlugin extends Plugin {
});

this.immutableWarning = new ImmutableWarning(this.app, this);
this.fileExplorerTierBadges = new FileExplorerTierBadges(
this.app,
this,
() => this.settings.tracesFolder,
() => this.settings.showFileExplorerTierBadges
);
this.fileExplorerTierBadges.start();

// Re-render the status bar AND immutable-warning banner when
// the active file changes (tier glyph follows the user) or
Expand Down Expand Up @@ -159,6 +168,7 @@ export default class NoemaPlugin extends Plugin {
// Clean up any lingering banner DOM so a plugin reload during
// active development doesn't leave orphan elements behind.
this.immutableWarning?.removeAll();
this.fileExplorerTierBadges?.stop();
}

async loadSettings(): Promise<void> {
Expand All @@ -170,6 +180,10 @@ export default class NoemaPlugin extends Plugin {
await this.saveData(this.settings);
}

refreshFileExplorerTierBadges(): void {
this.fileExplorerTierBadges?.refresh();
}

// refreshClient is called from the settings tab when the
// endpoint or bearer key changes. We keep a single McpClient
// instance so the JSON-RPC id counter stays monotonic across
Expand Down
2 changes: 1 addition & 1 deletion plugins/obsidian/src/mcp-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ export class McpClient {
params: {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: "noema-obsidian", version: "0.3.0" },
clientInfo: { name: "noema-obsidian", version: "0.4.0" },
},
};
const resp = await mcpFetch(
Expand Down
25 changes: 21 additions & 4 deletions plugins/obsidian/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export interface NoemaSettings {
endpoint: string;
bearerKey: string;
tracesFolder: string;
showFileExplorerTierBadges: boolean;
defaultAuthor: string;
searchMode: "lexical" | "semantic" | "hybrid";
}
Expand All @@ -13,14 +14,16 @@ export interface NoemaSettings {
// the plugin starts in "disconnected" state on a fresh install rather
// than blindly trying to reach a localhost URL. tracesFolder defaults
// to "traces" because that's the cortex layout convention; users with
// a non-standard layout (rare) can override. defaultAuthor is empty
// by default — the create-trace flow omits the author field entirely
// when the setting is empty, letting the cortex's own author logic
// decide what to record.
// a non-standard layout (rare) can override. File explorer badges are
// visible by default and can be hidden as an Obsidian display preference.
// defaultAuthor is empty by default — the create-trace flow omits the
// author field entirely when the setting is empty, letting the cortex's
// own author logic decide what to record.
export const DEFAULT_SETTINGS: NoemaSettings = {
endpoint: "",
bearerKey: "",
tracesFolder: "traces",
showFileExplorerTierBadges: true,
defaultAuthor: "",
searchMode: "hybrid",
};
Expand Down Expand Up @@ -100,6 +103,20 @@ export class NoemaSettingTab extends PluginSettingTab {
.onChange(async (value) => {
this.plugin.settings.tracesFolder = value.trim() || "traces";
await this.plugin.saveSettings();
this.plugin.refreshFileExplorerTierBadges();
})
);

new Setting(containerEl)
.setName("Show tier badges in file explorer")
.setDesc("Show [s], [m], and [L] beside traces in the configured traces folder.")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.showFileExplorerTierBadges)
.onChange(async (value) => {
this.plugin.settings.showFileExplorerTierBadges = value;
await this.plugin.saveSettings();
this.plugin.refreshFileExplorerTierBadges();
})
);

Expand Down
27 changes: 27 additions & 0 deletions plugins/obsidian/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,33 @@
margin-right: 4px;
}

/* ---- File explorer tier badges ---- */

.noema-file-tier-badge {
display: inline-block;
flex: 0 0 auto;
min-width: 2.25em;
margin-inline-end: 4px;
font-family: var(--font-monospace);
font-size: var(--font-ui-smaller);
font-weight: 600;
line-height: var(--line-height-tight, 1.2);
text-align: center;
color: var(--text-faint);
}

.noema-file-tier-mid {
color: var(--text-muted);
}

.noema-file-tier-long {
color: var(--text-warning, var(--text-accent));
}

.noema-file-tier-unknown {
color: var(--text-error, var(--text-muted));
}

.noema-status-ok {
color: var(--text-success, var(--text-normal));
}
Expand Down
Loading