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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Build output
/noema
/dist/
/noema-*-plugin.tar.gz

# SQLite databases
*.db
Expand All @@ -17,6 +18,7 @@
# Caches
**/__pycache__
.pytest_cache
.gocache

# Editor
*.swp
Expand Down
22 changes: 21 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
Expand All @@ -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)"
Expand Down Expand Up @@ -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 <name>' 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)
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,9 @@ noema federation resume-peer <name> 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 <addr>] [--tls-cert <file> --tls-key <file>]
Start the MCP server (http requires --host; endpoint is /mcp)
noema serve --print-config Print a ready-to-use .mcp.json snippet and exit
Expand Down
36 changes: 21 additions & 15 deletions internal/cortex/cortex.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions internal/cortex/cortex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions plugins/obsidian/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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: <cortex-name>` once the connection succeeds.
Expand All @@ -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.
4 changes: 2 additions & 2 deletions plugins/obsidian/manifest.json
Original file line number Diff line number Diff line change
@@ -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": "",
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.

4 changes: 2 additions & 2 deletions plugins/obsidian/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
9 changes: 9 additions & 0 deletions plugins/obsidian/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down
56 changes: 55 additions & 1 deletion plugins/obsidian/src/mcp-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -199,6 +209,11 @@ export class McpClient {
return match[1];
}

async searchTraces(query: string, mode: SearchMode): Promise<SearchResult[]> {
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
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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 [];
Expand Down
Loading
Loading