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/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 diff --git a/internal/consolidation/heuristic.go b/internal/consolidation/heuristic.go index e145b0a..760adc4 100644 --- a/internal/consolidation/heuristic.go +++ b/internal/consolidation/heuristic.go @@ -130,13 +130,12 @@ const MinLineageSourcesForCredit = 2 // Exported for tests; package-private callers don't need it because // HeuristicPass is the single consumer. // -// search_hit_count is folded into the reads bucket at the same weight -// because both signals describe passive/weak consumption — the read -// either came from a deliberate get_trace or from being one of the -// top-N hits an agent's search returned. Auto-injection providers like -// Hermes only ever generate search hits, and without this fold-in -// short-tier traces in those cortexes would never accumulate enough -// signal to promote. +// search_hit_count is normally folded into the reads bucket at the same +// weight because both signals describe consumption — the read either came +// from a deliberate get_trace or from being one of the top-N hits an agent's +// search returned. Auto-injection providers like Hermes only ever generate +// search hits, and without this fold-in short-tier traces in those cortexes +// would never accumulate enough signal to promote. // // Lineage credit is gated by MinLineageSourcesForCredit: a trace with // only one derived_from gets zero lineage points, regardless of the @@ -145,12 +144,22 @@ const MinLineageSourcesForCredit = 2 // single session trace it was extracted from) from gliding past the // promotion threshold purely on the lineage head start. Multi-source // traces still earn the full weight per source. +// +// One-source traces also do not get passive search-hit credit unless they +// have stronger intent (an edit or an explicit tier vote). This keeps +// auto-retrieved Hermes session summaries from leaking into mid-tier just +// because they appeared in search results, while still allowing deliberate +// reads, edits, votes, and real multi-source distillations to promote. func scoreCandidate(pc cortex.PromotionCandidate, cfg PassConfig) int { lineageCredit := 0 if pc.DerivedFromCount >= MinLineageSourcesForCredit { lineageCredit = pc.DerivedFromCount * cfg.WeightLineage } - return (pc.ReadCount+pc.SearchHitCount)*cfg.WeightReads + + searchHitCredit := pc.SearchHitCount + if pc.DerivedFromCount == 1 && pc.ModifyCount == 0 && pc.TierVotes == 0 { + searchHitCredit = 0 + } + return (pc.ReadCount+searchHitCredit)*cfg.WeightReads + pc.ModifyCount*cfg.WeightModifies + lineageCredit + pc.TierVotes*cfg.WeightVotes diff --git a/internal/consolidation/heuristic_test.go b/internal/consolidation/heuristic_test.go index 1d017a9..fcc200c 100644 --- a/internal/consolidation/heuristic_test.go +++ b/internal/consolidation/heuristic_test.go @@ -51,6 +51,10 @@ func TestScoreCandidate_BlendedFormula(t *testing.T) { // Lineage credit is gated at >= 2 sources. A single derived_from // is provenance, not consolidation — see MinLineageSourcesForCredit. {"1 lineage ref earns no credit", cortex.PromotionCandidate{DerivedFromCount: 1}, 0}, + {"1 lineage ref + search hits earns no passive credit", cortex.PromotionCandidate{SearchHitCount: 5, DerivedFromCount: 1}, 0}, + {"1 lineage ref + deliberate reads still counts", cortex.PromotionCandidate{ReadCount: 5, DerivedFromCount: 1}, 5}, + {"1 lineage ref + modify unlocks search-hit credit", cortex.PromotionCandidate{SearchHitCount: 3, ModifyCount: 1, DerivedFromCount: 1}, 5}, + {"1 lineage ref + vote unlocks search-hit credit", cortex.PromotionCandidate{SearchHitCount: 1, TierVotes: 1, DerivedFromCount: 1}, 6}, {"2 lineage refs earn full credit", cortex.PromotionCandidate{DerivedFromCount: 2}, 6}, {"3 lineage refs scale linearly", cortex.PromotionCandidate{DerivedFromCount: 3}, 9}, {"1 vote", cortex.PromotionCandidate{TierVotes: 1}, 5}, @@ -75,10 +79,10 @@ func TestScoreCandidate_BlendedFormula(t *testing.T) { func TestHeuristicPass_PromotesAboveThreshold(t *testing.T) { cx := &fakeHeuristicCortex{ candidates: []cortex.PromotionCandidate{ - {ID: "hot", Tier: trace.TierShort, ReadCount: 10}, // score 10 - promote - {ID: "meh", Tier: trace.TierShort, ReadCount: 2}, // score 2 - skip - {ID: "voted", Tier: trace.TierShort, TierVotes: 1}, // score 5 - promote (at threshold) - {ID: "cold", Tier: trace.TierShort}, // score 0 - skip + {ID: "hot", Tier: trace.TierShort, ReadCount: 10}, // score 10 - promote + {ID: "meh", Tier: trace.TierShort, ReadCount: 2}, // score 2 - skip + {ID: "voted", Tier: trace.TierShort, TierVotes: 1}, // score 5 - promote (at threshold) + {ID: "cold", Tier: trace.TierShort}, // score 0 - skip {ID: "referenced", Tier: trace.TierShort, DerivedFromCount: 2}, // score 6 - promote (real consolidation) // Regression guard: a 1-source provenance link with low // engagement must not glide past the threshold. This is the @@ -87,6 +91,10 @@ func TestHeuristicPass_PromotesAboveThreshold(t *testing.T) { // silently inflating mid-tier before MinLineageSourcesForCredit // landed. Score: 1*1 (read) + 0 (lineage gated) = 1. {ID: "session-summary-shaped", Tier: trace.TierShort, ReadCount: 1, DerivedFromCount: 1}, + // Regression guard for the 1-source mid leak detector: passive + // search hits alone must not promote a Hermes session-summary + // shaped trace. + {ID: "session-summary-search-hit", Tier: trace.TierShort, SearchHitCount: 10, DerivedFromCount: 1}, }, } pass := HeuristicPass(cx, PassConfig{}, nil) @@ -102,7 +110,7 @@ func TestHeuristicPass_PromotesAboveThreshold(t *testing.T) { t.Errorf("expected %q to be promoted, promoted=%v", want, cx.promoted) } } - for _, shouldSkip := range []string{"meh", "cold", "session-summary-shaped"} { + for _, shouldSkip := range []string{"meh", "cold", "session-summary-shaped", "session-summary-search-hit"} { if got[shouldSkip] { t.Errorf("unexpected promotion of %q", shouldSkip) } diff --git a/internal/cortex/cortex.go b/internal/cortex/cortex.go index 03c0b93..2cc43ff 100644 --- a/internal/cortex/cortex.go +++ b/internal/cortex/cortex.go @@ -1311,10 +1311,26 @@ func Open(name, dir string) (*Cortex, error) { fmt.Fprintf(os.Stderr, "[cortex] trace_usage backfill warning: %v\n", err) } } + if err := cx.pruneLegacyVectorClockBuckets(); err != nil { + fmt.Fprintf(os.Stderr, "[cortex] vector-clock cleanup warning: %v\n", err) + } return cx, nil } +func (c *Cortex) pruneLegacyVectorClockBuckets() error { + state := federation.NewState(c.DB.DB) + vc, err := state.GetClock() + if err != nil { + return err + } + clean := federation.KeepCortexIDKeys(vc) + if len(clean) == len(vc) { + return nil + } + return state.SetClock(clean) +} + // backfillTraceUsage populates trace_usage from the legacy per-trace // counter columns, crediting them to the local cortex ID. Idempotent: // skipped when trace_usage already has rows. Safe on fresh cortexes @@ -1346,25 +1362,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 @@ -2947,6 +2969,7 @@ func (c *Cortex) emitEvent(tx *sql.Tx, action event.Action, traceID, timestamp s if err != nil { vc = make(federation.VClock) } + vc = federation.KeepCortexIDKeys(vc) vc.Increment(c.ID) e := &event.Event{ @@ -3251,10 +3274,12 @@ func (c *Cortex) replayUpdate(e event.Event) error { if len(e.VClock) > 0 { localEvents, _ := event.ForTrace(c.DB.DB, e.TraceID) if lastLocal := lastMutationEvent(localEvents); lastLocal != nil && len(lastLocal.VClock) > 0 { - rel := federation.Compare(lastLocal.VClock, e.VClock) + localClock := federation.KeepCortexIDKeys(lastLocal.VClock) + remoteClock := federation.KeepCortexIDKeys(e.VClock) + rel := federation.Compare(localClock, remoteClock) if rel == 0 { // Concurrent — neither clock dominates. Create a divergence trace. - return c.createDivergence(r, e, data.Body, lastLocal.VClock, e.VClock) + return c.createDivergence(r, e, data.Body, localClock, remoteClock) } } } @@ -3856,7 +3881,10 @@ func (c *Cortex) MergeClock(remote federation.VClock) error { if err != nil { return err } - merged, err := federation.MergeCapped(local, remote) + merged, err := federation.MergeCapped( + federation.KeepCortexIDKeys(local), + federation.KeepCortexIDKeys(remote), + ) if err != nil { return err } diff --git a/internal/cortex/cortex_test.go b/internal/cortex/cortex_test.go index 05c5db0..7a5fcc0 100644 --- a/internal/cortex/cortex_test.go +++ b/internal/cortex/cortex_test.go @@ -280,6 +280,79 @@ 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() +} + +func TestOpen_PrunesLegacyVectorClockNameBuckets(t *testing.T) { + dir := t.TempDir() + if _, err := cortex.Create("mycortex", dir); err != nil { + t.Fatalf("Create: %v", err) + } + root := filepath.Join(dir, "mycortex") + + cx, err := cortex.Open("mycortex", root) + if err != nil { + t.Fatalf("first Open: %v", err) + } + state := federation.NewState(cx.DB.DB) + if err := state.SetClock(federation.VClock{ + cx.ID: 4, + "mycortex": 19, + "legacy-p2": 8, + }); err != nil { + t.Fatalf("SetClock: %v", err) + } + cx.Close() + + cx2, err := cortex.Open("mycortex", root) + if err != nil { + t.Fatalf("second Open: %v", err) + } + defer cx2.Close() + + vc, err := cx2.GetClock() + if err != nil { + t.Fatalf("GetClock: %v", err) + } + if vc[cx2.ID] != 4 { + t.Errorf("local ULID bucket = %d, want 4 in %v", vc[cx2.ID], vc) + } + for _, legacy := range []string{"mycortex", "legacy-p2"} { + if _, ok := vc[legacy]; ok { + t.Errorf("legacy bucket %q survived Open cleanup: %v", legacy, vc) + } + } +} + // 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 @@ -1757,6 +1830,43 @@ func TestVectorClockIncrements(t *testing.T) { } } +func TestMergeClock_DropsLegacyNameBuckets(t *testing.T) { + cx := setup(t) + state := federation.NewState(cx.DB.DB) + if err := state.SetClock(federation.VClock{ + cx.ID: 4, + "mycortex": 19, + "legacy-p2": 8, + }); err != nil { + t.Fatalf("SetClock: %v", err) + } + + remoteID := "01REMOTE000000000000000000" + if err := cx.MergeClock(federation.VClock{ + remoteID: 7, + "mycortex": 19, + "legacy-p3": 2, + }); err != nil { + t.Fatalf("MergeClock: %v", err) + } + + vc, err := cx.GetClock() + if err != nil { + t.Fatalf("GetClock: %v", err) + } + if vc[cx.ID] != 4 { + t.Errorf("local ULID bucket = %d, want 4 in %v", vc[cx.ID], vc) + } + if vc[remoteID] != 7 { + t.Errorf("remote ULID bucket = %d, want 7 in %v", vc[remoteID], vc) + } + for _, legacy := range []string{"mycortex", "legacy-p2", "legacy-p3"} { + if _, ok := vc[legacy]; ok { + t.Errorf("legacy bucket %q survived merge: %v", legacy, vc) + } + } +} + // ---- Backfill ---- // TestBackfillCreateEvents_SyncedTraceGetsCreate is the headline scenario: diff --git a/internal/federation/vclock.go b/internal/federation/vclock.go index 656bd80..30a6ee9 100644 --- a/internal/federation/vclock.go +++ b/internal/federation/vclock.go @@ -13,6 +13,27 @@ const MaxVClockEntries = 256 // VClock is a vector clock: one counter per known peer. type VClock map[string]uint64 +// CortexIDKey reports whether key is shaped like a stable cortex identity. +// Cortex IDs are ULIDs, represented as 26-character strings. Older Noema +// releases used display names as vector-clock keys; those legacy buckets must +// not influence current federation causality. +func CortexIDKey(key string) bool { + return len(key) == 26 +} + +// KeepCortexIDKeys returns a copy of vc containing only stable cortex-ID +// buckets. It is used on production federation paths to stop pre-migration +// name-keyed buckets from surviving forever in new event snapshots. +func KeepCortexIDKeys(vc VClock) VClock { + clean := make(VClock, len(vc)) + for k, v := range vc { + if CortexIDKey(k) { + clean[k] = v + } + } + return clean +} + // Increment bumps the counter for the given peer. func (vc VClock) Increment(peer string) { vc[peer]++ diff --git a/internal/federation/vclock_test.go b/internal/federation/vclock_test.go index 9bcd0ac..c6d5aa1 100644 --- a/internal/federation/vclock_test.go +++ b/internal/federation/vclock_test.go @@ -27,6 +27,25 @@ func TestVClock_Clone(t *testing.T) { } } +func TestKeepCortexIDKeys_DropsLegacyNameBuckets(t *testing.T) { + vc := VClock{ + "01TESTCORTEXIDXXXXXXXXXXXX": 12, + "mycortex": 3, + "peer-a": 7, + } + + clean := KeepCortexIDKeys(vc) + if clean["01TESTCORTEXIDXXXXXXXXXXXX"] != 12 { + t.Errorf("ULID bucket missing from cleaned clock: %v", clean) + } + if _, ok := clean["mycortex"]; ok { + t.Errorf("legacy local-name bucket survived: %v", clean) + } + if _, ok := clean["peer-a"]; ok { + t.Errorf("legacy peer-name bucket survived: %v", clean) + } +} + func TestMerge(t *testing.T) { a := VClock{"a": 10, "b": 7, "c": 3} b := VClock{"a": 9, "b": 8, "c": 3} 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);