feat(obsidian-brain): phase-4 capabilities — Q&A, pi publish, ops,

selection search, tag filter, daily recall, offline queue, related
keyboard nav, MCP cross-link

Adds the P0/P1/P2/P3 items from ADR-152 §10:

New modules
-----------
  src/qa-modal.ts        Retrieval-grounded Q&A modal (Cmd+Shift+K).
                         Blends top-k from local brain + pi.ruv.io,
                         dedups by normalized content, renders as
                         context cards with "Open note" / "Insert" /
                         "Copy as quote" actions. Uses real
                         MarkdownRenderer for fidelity.
  src/brain-ops.ts       Brain ops modal — surfaces the previously
                         unused endpoints (/brain/workload,
                         /brain/training-stats, /learning/stats,
                         /brain/store_mode, /brain/info,
                         /brain/index_stats) plus WAL checkpoint +
                         DPO JSONL export.
  src/offline-queue.ts   Persisted queue of pending /memories POSTs.
                         Fills on BrainError.status===0 (network), drains
                         every 30s + opportunistically on each status
                         refresh. Survives reloads via data.json.

Extensions
----------
  src/search-modal.ts    Accepts a `seed` argument (used by the new
                         selection-search command); parses
                         `category:<token>` prefix to filter results
                         server-side-equivalent (client filter after
                         fetch — the local brain lacks ?category= on
                         /brain/search).
  src/related-view.ts    Keyboard nav (↑/↓/j/k move, Enter/o open,
                         r refresh); mouse-enter hints; on-screen
                         hint strip.
  src/pi-client.ts       POST /v1/memories write-through with typed
                         PI_CATEGORIES enum + { custom } newtype;
                         returns { id, quality_score, witness_hash,
                         rvf_segments }.
  src/pi-sync.ts         publishActiveNoteToPi() + category suggest
                         modal; honors new piPullCategory on list
                         pulls.
  src/settings.ts        Adds piPullCategory + an "Agent access (MCP)"
                         section linking to mcp-brain-server / SSE
                         surface with a "Copy MCP endpoint" button.
  src/main.ts            Wires:
                           - Cmd+Shift+K  → Brain Q&A
                           - brain-search-selection (editor callback)
                           - brain-ops modal
                           - pi.ruv.io: publish current note
                           - brain-daily-recall (generates
                             Brain/Recall/Recall-YYYY-MM-DD.md)
                           - brain-offline-queue-flush manual retry
                         Status bar shows "· N queued" when pending.

Indexer → offline queue
-----------------------
  indexer.ts: catches BrainError status=0 on createMemory, enqueues the
  pending write (deduped by path) rather than losing it. Non-network
  errors (422, 500) still surface.

Tests
-----
  tests/protocol/pi-server.test.ts: 2 new tests — POST /v1/memories
  write-through (gated on BRAIN_API_KEY, 40s timeout for server-side
  RVF segmentation + DP noising) and 422-on-bogus-category.
  tests/e2e/harness/main.ts: 2 new harness checks — phase-4 commands
  registered + offline queue API accessible.
  tests/e2e/obsidian-e2e.test.ts: min-expected-checks bumped to 10/11.

Verified
--------
  - 15/15 protocol tests pass (9 local brain + 6 pi incl. write-through)
  - 11/11 real-Obsidian harness checks pass under xvfb
  - tsc + esbuild clean; bundle now 55 KB

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruvnet 2026-04-20 15:35:56 -04:00
parent ec12bb163a
commit 31c865cc1e
13 changed files with 1096 additions and 30 deletions

View file

@ -0,0 +1,130 @@
import { App, Modal, Notice, requestUrl } from "obsidian";
import { BrainClient } from "./brain";
import type { BrainSettings } from "./settings";
/**
* Surfaces the "second-tier" brain endpoints that were previously
* unused by the plugin: /brain/workload, /brain/training-stats,
* /learning/stats, /brain/checkpoint, /brain/export-pairs,
* /brain/store_mode. One modal, read-only + two buttons.
*/
export class BrainOpsModal extends Modal {
constructor(
app: App,
private brain: BrainClient,
private settings: BrainSettings,
) {
super(app);
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl("h2", { text: "Brain Ops" });
contentEl.createEl("p", {
cls: "brain-ops-hint",
text: `Endpoint: ${this.settings.brainUrl}`,
});
const grid = contentEl.createEl("div", { cls: "brain-ops-grid" });
const left = grid.createEl("div", { cls: "brain-ops-col" });
const right = grid.createEl("div", { cls: "brain-ops-col" });
const panels: Array<{
title: string;
path: string;
el: HTMLElement;
kind?: "left" | "right";
}> = [
{ title: "Store mode", path: "/brain/store_mode", el: left },
{ title: "Index stats", path: "/brain/index_stats", el: left },
{ title: "Learning stats", path: "/learning/stats", el: left },
{ title: "Workload", path: "/brain/workload", el: right },
{ title: "Training stats", path: "/brain/training-stats", el: right },
{ title: "Info", path: "/brain/info", el: right },
];
for (const p of panels) {
const card = p.el.createEl("div", { cls: "brain-ops-card" });
card.createEl("h3", { text: p.title });
const pre = card.createEl("pre", { cls: "brain-ops-pre", text: "loading…" });
void this.loadInto(pre, p.path);
}
const actions = contentEl.createEl("div", { cls: "brain-ops-actions" });
const checkpoint = actions.createEl("button", {
cls: "mod-cta",
text: "Checkpoint (WAL flush)",
});
checkpoint.addEventListener("click", () => void this.checkpoint());
const exportPairs = actions.createEl("button", {
text: "Export DPO pairs (JSONL)",
});
exportPairs.addEventListener("click", () => void this.exportPairs());
}
onClose(): void {
this.contentEl.empty();
}
private async loadInto(target: HTMLElement, path: string): Promise<void> {
try {
const resp = await requestUrl({
url: this.settings.brainUrl.replace(/\/$/, "") + path,
method: "GET",
throw: false,
});
if (resp.status >= 400) {
target.setText(`HTTP ${resp.status}`);
return;
}
target.setText(JSON.stringify(resp.json, null, 2));
} catch (e) {
target.setText(`error: ${(e as Error).message}`);
}
}
private async checkpoint(): Promise<void> {
try {
const resp = await requestUrl({
url: this.settings.brainUrl.replace(/\/$/, "") + "/brain/checkpoint",
method: "POST",
throw: false,
contentType: "application/json",
body: "{}",
});
if (resp.status >= 400) {
new Notice(`Checkpoint failed: ${resp.status}`);
return;
}
new Notice(`Checkpoint OK: ${JSON.stringify(resp.json)}`);
} catch (e) {
new Notice(`Checkpoint failed: ${(e as Error).message}`, 6000);
}
}
private async exportPairs(): Promise<void> {
try {
const resp = await requestUrl({
url:
this.settings.brainUrl.replace(/\/$/, "") +
"/brain/export-pairs?limit=500",
method: "GET",
throw: false,
});
if (resp.status >= 400) {
new Notice(`Export failed: ${resp.status}`);
return;
}
const folder = "Brain/Exports";
if (!this.app.vault.getAbstractFileByPath(folder)) {
await this.app.vault.createFolder(folder).catch(() => undefined);
}
const path = `${folder}/dpo-pairs-${Date.now()}.json`;
await this.app.vault.create(path, JSON.stringify(resp.json, null, 2));
new Notice(`Exported DPO pairs → ${path}`);
void this.brain; // kept for parity with other modules
} catch (e) {
new Notice(`Export failed: ${(e as Error).message}`, 6000);
}
}
}

View file

@ -1,6 +1,7 @@
import { TFile, Notice, Plugin, parseYaml } from "obsidian";
import { BrainClient, BrainError } from "./brain";
import type { BrainSettings } from "./settings";
import type { OfflineQueue } from "./offline-queue";
/**
* Map content_hash memory id so the plugin can tell Obsidian files
@ -72,6 +73,7 @@ export function stripFrontmatter(content: string): string {
export class Indexer {
private debounceTimers = new Map<string, number>();
state: IndexState = { ...EMPTY_INDEX_STATE };
queue: OfflineQueue | null = null;
constructor(
private plugin: Plugin,
@ -79,6 +81,10 @@ export class Indexer {
private settings: BrainSettings,
) {}
setQueue(queue: OfflineQueue): void {
this.queue = queue;
}
setState(s: IndexState): void {
this.state = s;
}
@ -178,15 +184,35 @@ export class Indexer {
}
}
const category = extractCategory(raw, this.settings.defaultCategory);
const result = await this.brain.createMemory(category, body);
this.state.pathToHash[file.path] = result.content_hash;
this.state.hashToId[result.content_hash] = result.id;
this.state.idToPath[result.id] = file.path;
this.state.lastSync = Math.floor(Date.now() / 1000);
if (opts.notify) {
new Notice(`Indexed ${file.name}${category}`);
try {
const result = await this.brain.createMemory(category, body);
this.state.pathToHash[file.path] = result.content_hash;
this.state.hashToId[result.content_hash] = result.id;
this.state.idToPath[result.id] = file.path;
this.state.lastSync = Math.floor(Date.now() / 1000);
if (opts.notify) {
new Notice(`Indexed ${file.name}${category}`);
}
return { indexed: true, id: result.id };
} catch (e) {
// Network-level failure → enqueue for retry when the brain
// comes back. Everything else bubbles up.
if (e instanceof BrainError && e.status === 0 && this.queue) {
this.queue.enqueue({
path: file.path,
category,
content: body,
queuedAt: Date.now(),
});
if (opts.notify !== false) {
new Notice(
`Brain offline — queued ${file.name} for later (${this.queue.size()} pending)`,
);
}
return { indexed: false, reason: "queued-offline" };
}
throw e;
}
return { indexed: true, id: result.id };
}
async bulkSync(reporter: ProgressReporter): Promise<{

View file

@ -1,4 +1,4 @@
import { Notice, Plugin, TFile, WorkspaceLeaf } from "obsidian";
import { Component, MarkdownView, Notice, Plugin, TFile, WorkspaceLeaf } from "obsidian";
import { BrainClient } from "./brain";
import {
BrainSettings,
@ -16,11 +16,24 @@ import { BulkSyncModal } from "./bulk-sync";
import { DpoController, DpoStatusModal } from "./dpo";
import { GraphOverlay } from "./graph-overlay";
import { PiClient } from "./pi-client";
import { PiSync, PiSyncModal, PiSearchModal } from "./pi-sync";
import {
PiSync,
PiSyncModal,
PiSearchModal,
publishActiveNoteToPi,
} from "./pi-sync";
import { BrainQaModal } from "./qa-modal";
import { BrainOpsModal } from "./brain-ops";
import {
EMPTY_QUEUE_STATE,
OfflineQueue,
OfflineQueueState,
} from "./offline-queue";
interface PluginData {
settings: BrainSettings;
indexState: IndexState;
offlineQueue?: OfflineQueueState;
}
export default class ObsidianBrainPlugin extends Plugin {
@ -29,10 +42,12 @@ export default class ObsidianBrainPlugin extends Plugin {
indexer!: Indexer;
pi!: PiClient;
piSync!: PiSync;
queue!: OfflineQueue;
private dpo!: DpoController;
private graph!: GraphOverlay;
private statusBar!: HTMLElement;
private statusTimer: number | null = null;
private mdComponent = new Component();
async onload(): Promise<void> {
const data = (await this.loadData()) as PluginData | null;
@ -54,6 +69,16 @@ export default class ObsidianBrainPlugin extends Plugin {
indexState,
);
const queueState: OfflineQueueState = data?.offlineQueue ?? {
...EMPTY_QUEUE_STATE,
};
this.queue = new OfflineQueue(this, this.brain, queueState, () =>
void this.persist(),
);
this.indexer.setQueue(this.queue);
this.queue.start(30_000);
this.mdComponent.load();
this.registerView(
RELATED_VIEW_TYPE,
(leaf) => new RelatedView(leaf, this.brain, this.settings, indexState),
@ -72,6 +97,47 @@ export default class ObsidianBrainPlugin extends Plugin {
},
});
this.addCommand({
id: "brain-search-selection",
name: "Semantic search on current selection",
editorCallback: (editor) => {
const sel = editor.getSelection().trim();
if (!sel) {
new Notice("Select some text first.");
return;
}
new BrainSearchModal(
this.app,
this.brain,
this.settings,
indexState,
sel.slice(0, 400),
).open();
},
});
this.addCommand({
id: "brain-qa",
name: "Ask the brain (Q&A modal)",
hotkeys: [{ modifiers: ["Mod", "Shift"], key: "k" }],
callback: () => {
new BrainQaModal(
this.app,
this.brain,
this.pi,
this.settings,
indexState,
this.mdComponent,
).open();
},
});
this.addCommand({
id: "brain-ops",
name: "Brain ops (workload, training-stats, checkpoint, export)",
callback: () => new BrainOpsModal(this.app, this.brain, this.settings).open(),
});
this.addCommand({
id: "brain-related-panel",
name: "Toggle related panel",
@ -152,6 +218,32 @@ export default class ObsidianBrainPlugin extends Plugin {
new PiSearchModal(this.app, this.pi, this.settings.piPullLimit).open(),
});
this.addCommand({
id: "brain-pi-publish",
name: "pi.ruv.io: publish current note",
callback: () =>
void publishActiveNoteToPi(this.app, this.pi, this.settings),
});
this.addCommand({
id: "brain-daily-recall",
name: "Daily recall — memories from this day",
callback: () => void this.dailyRecall(),
});
this.addCommand({
id: "brain-offline-queue-flush",
name: "Offline queue: retry pending now",
callback: async () => {
const before = this.queue.size();
const r = await this.queue.drain();
new Notice(
`Queue: ${before} pending → ${this.queue.size()} remaining (sent ${r.sent}, dropped ${r.failed})`,
6000,
);
},
});
this.addCommand({
id: "brain-pi-status",
name: "pi.ruv.io: status",
@ -202,6 +294,8 @@ export default class ObsidianBrainPlugin extends Plugin {
async onunload(): Promise<void> {
if (this.statusTimer) window.clearTimeout(this.statusTimer);
this.queue?.stop();
this.mdComponent.unload();
this.app.workspace.getLeavesOfType(RELATED_VIEW_TYPE).forEach((l) => l.detach());
await this.persist();
}
@ -218,10 +312,75 @@ export default class ObsidianBrainPlugin extends Plugin {
const payload: PluginData = {
settings: this.settings,
indexState: this.indexer.state,
offlineQueue: this.queue?.state,
};
await this.saveData(payload);
}
/**
* Daily recall list memories whose created_at matches today's
* month/day across any year (~"on this day"). We use /memories with
* a high limit and filter client-side because the brain's list
* endpoint has no native date filter.
*/
private async dailyRecall(): Promise<void> {
try {
const today = new Date();
const thisMonth = today.getMonth();
const thisDay = today.getDate();
const resp = await this.brain.listMemories(0, 500);
const matches = resp.memories.filter((m) => {
const d = new Date(m.created_at * 1000);
return (
d.getMonth() === thisMonth &&
d.getDate() === thisDay &&
d.getFullYear() !== today.getFullYear()
);
});
if (matches.length === 0) {
new Notice("No memories from prior years on this day.");
return;
}
const folder = "Brain/Recall";
if (!this.app.vault.getAbstractFileByPath(folder)) {
await this.app.vault.createFolder(folder).catch(() => undefined);
}
const stamp = `${today.getFullYear()}-${String(thisMonth + 1).padStart(2, "0")}-${String(thisDay).padStart(2, "0")}`;
const path = `${folder}/Recall-${stamp}.md`;
const lines = [
"---",
`brain-category: recall`,
"tags: [brain/recall]",
"---",
"",
`# Brain recall for ${stamp}`,
"",
`${matches.length} memor${matches.length === 1 ? "y" : "ies"} from earlier years.`,
"",
];
for (const m of matches) {
const when = new Date(m.created_at * 1000).toISOString().slice(0, 10);
lines.push(`## ${when}${m.category} · ${m.id.slice(0, 12)}`);
lines.push("");
lines.push((m.content ?? "").slice(0, 600));
lines.push("");
}
const existing = this.app.vault.getAbstractFileByPath(path);
if (existing instanceof TFile) {
await this.app.vault.modify(existing, lines.join("\n"));
} else {
await this.app.vault.create(path, lines.join("\n"));
const file = this.app.vault.getAbstractFileByPath(path);
if (file instanceof TFile) {
await this.app.workspace.getLeaf(false).openFile(file);
}
}
new Notice(`Daily recall: ${matches.length} memories → ${path}`, 6000);
} catch (e) {
new Notice(`Daily recall failed: ${(e as Error).message}`, 6000);
}
}
private async activateRelatedView(): Promise<void> {
const { workspace } = this.app;
let leaf: WorkspaceLeaf | null = workspace.getLeavesOfType(RELATED_VIEW_TYPE)[0] ?? null;
@ -244,12 +403,18 @@ export default class ObsidianBrainPlugin extends Plugin {
this.brain.info().catch(() => null),
]);
const count = info?.memories_count ?? 0;
const pending = this.queue?.size() ?? 0;
const suffix = pending > 0 ? ` · ${pending} queued` : "";
this.statusBar.setText(
`Brain: ${h.backend} · ${count.toLocaleString()} memories`,
`Brain: ${h.backend} · ${count.toLocaleString()} memories${suffix}`,
);
this.statusBar.removeClass("brain-status-offline");
// Opportunistic drain when the status call succeeded.
if (pending > 0) void this.queue.drain();
} catch (e) {
this.statusBar.setText(`Brain: offline`);
const pending = this.queue?.size() ?? 0;
const suffix = pending > 0 ? ` · ${pending} queued` : "";
this.statusBar.setText(`Brain: offline${suffix}`);
this.statusBar.addClass("brain-status-offline");
void e;
}

View file

@ -0,0 +1,98 @@
import { Notice, Plugin } from "obsidian";
import { BrainClient, BrainError } from "./brain";
/**
* Persisted queue of pending POST /memories writes. Filled when the
* brain is unreachable and drained on a periodic timer once health
* comes back. Survives plugin reloads via data.json.
*
* Only applies to local-brain writes pi.ruv.io writes are explicit
* user actions and aren't queued.
*/
export interface QueuedMemory {
category: string;
content: string;
queuedAt: number;
/** Per-path deduping: one queued entry per vault path. */
path?: string;
}
export interface OfflineQueueState {
pending: QueuedMemory[];
}
export const EMPTY_QUEUE_STATE: OfflineQueueState = { pending: [] };
export class OfflineQueue {
private timer: number | null = null;
private draining = false;
constructor(
private plugin: Plugin,
private brain: BrainClient,
public state: OfflineQueueState,
private onChange: () => void,
) {}
enqueue(entry: QueuedMemory): void {
if (entry.path) {
this.state.pending = this.state.pending.filter((q) => q.path !== entry.path);
}
this.state.pending.push(entry);
this.onChange();
}
size(): number {
return this.state.pending.length;
}
start(intervalMs = 30_000): void {
if (this.timer) window.clearInterval(this.timer);
this.timer = window.setInterval(() => void this.drain(), intervalMs);
}
stop(): void {
if (this.timer) {
window.clearInterval(this.timer);
this.timer = null;
}
}
/** Try to flush everything. Stops at the first network error. */
async drain(): Promise<{ sent: number; failed: number }> {
if (this.draining) return { sent: 0, failed: 0 };
if (this.state.pending.length === 0) return { sent: 0, failed: 0 };
this.draining = true;
let sent = 0;
let failed = 0;
try {
const remaining: QueuedMemory[] = [];
for (const q of this.state.pending) {
try {
await this.brain.createMemory(q.category, q.content);
sent++;
} catch (e) {
if (e instanceof BrainError && e.status === 0) {
// Still offline — keep the rest queued.
remaining.push(q);
const idx = this.state.pending.indexOf(q);
for (const later of this.state.pending.slice(idx + 1)) {
remaining.push(later);
}
break;
}
// Hard rejection (422, 500, etc.) — drop it; we'd re-reject forever.
failed++;
}
}
this.state.pending = remaining;
this.onChange();
if (sent > 0) {
new Notice(`Brain offline queue: replayed ${sent} pending write${sent === 1 ? "" : "s"}`);
}
} finally {
this.draining = false;
}
return { sent, failed };
}
}

View file

@ -31,6 +31,63 @@ export interface PiMemory {
contributor_id?: string;
}
export interface PiCreateMemoryResult {
id: string;
partition_id?: string | null;
quality_score: number;
witness_hash: string;
rvf_segments?: number;
}
/**
* Fixed enum accepted by pi.ruv.io's POST /v1/memories endpoint. Anything
* outside this set must be submitted via the `custom` newtype variant.
*/
export const PI_CATEGORIES = [
"architecture",
"pattern",
"solution",
"convention",
"security",
"performance",
"tooling",
"debug",
"sota",
"discovery",
"hypothesis",
"cross_domain",
"neural_architecture",
"compression",
"self_learning",
"reinforcement_learning",
"graph_intelligence",
"distributed_systems",
"edge_computing",
"hardware_acceleration",
"quantum",
"neuromorphic",
"bio_computing",
"cognitive_science",
"formal_methods",
"geopolitics",
"climate",
"biomedical",
"space",
"finance",
"meta_cognition",
"benchmark",
"consciousness",
"information_decomposition",
] as const;
export type PiCategory = (typeof PI_CATEGORIES)[number] | { custom: string };
export function piCategory(raw: string): PiCategory {
const known = PI_CATEGORIES.find((c) => c === raw);
if (known) return known;
return { custom: raw };
}
export interface PiSearchResult {
memories: PiMemory[];
total_count: number;
@ -57,27 +114,33 @@ export class PiClient {
return !!this.url && !!this.token;
}
private async req<T>(path: string): Promise<T> {
private async req<T>(
method: "GET" | "POST",
path: string,
body?: unknown,
): Promise<T> {
const base = this.url.replace(/\/$/, "");
let resp: RequestUrlResponse;
try {
resp = await requestUrl({
url: base + path,
method: "GET",
method,
headers: this.token ? { Authorization: `Bearer ${this.token}` } : {},
contentType: body ? "application/json" : undefined,
body: body ? JSON.stringify(body) : undefined,
throw: false,
});
} catch (e) {
throw new PiError(0, `pi.ruv.io network error: ${(e as Error).message}`);
}
if (resp.status >= 400) {
throw new PiError(resp.status, `pi.ruv.io ${path}${resp.status}`, resp.json);
throw new PiError(resp.status, `pi.ruv.io ${path}${resp.status}`, resp.json ?? resp.text);
}
return resp.json as T;
}
async status(): Promise<PiStatus> {
return this.req<PiStatus>("/v1/status");
return this.req<PiStatus>("GET", "/v1/status");
}
async list(limit: number, offset = 0, category?: string): Promise<PiMemory[]> {
@ -87,6 +150,7 @@ export class PiClient {
});
if (category) q.set("category", category);
const resp = await this.req<{ memories?: PiMemory[] } | PiMemory[]>(
"GET",
`/v1/memories/list?${q.toString()}`,
);
if (Array.isArray(resp)) return resp;
@ -96,9 +160,28 @@ export class PiClient {
async search(query: string, limit: number): Promise<PiMemory[]> {
const q = new URLSearchParams({ q: query, limit: String(limit) });
const resp = await this.req<PiMemory[] | { memories?: PiMemory[] }>(
"GET",
`/v1/memories/search?${q.toString()}`,
);
if (Array.isArray(resp)) return resp;
return resp.memories ?? [];
}
/**
* Publish a memory to pi.ruv.io. The server runs AIDefence, DP-noising
* and RVF segmentation on ingest so requests can take ~20s to return.
*/
async createMemory(
category: PiCategory,
content: string,
title: string,
tags: string[],
): Promise<PiCreateMemoryResult> {
return this.req<PiCreateMemoryResult>("POST", "/v1/memories", {
category,
content,
title,
tags,
});
}
}

View file

@ -1,8 +1,9 @@
import { App, Modal, Notice, TFile } from "obsidian";
import { App, Modal, Notice, TFile, SuggestModal } from "obsidian";
import { BrainClient } from "./brain";
import { PiClient, PiMemory } from "./pi-client";
import { PiClient, PiMemory, PI_CATEGORIES, piCategory } from "./pi-client";
import type { BrainSettings } from "./settings";
import type { IndexState, Indexer } from "./indexer";
import { extractCategory, stripFrontmatter } from "./indexer";
/**
* Pulls a slice of pi.ruv.io memories and mirrors them into the local
@ -35,8 +36,15 @@ export class PiSync {
let memories: PiMemory[];
try {
memories = this.settings.piPullQuery
? await this.pi.search(this.settings.piPullQuery, this.settings.piPullLimit)
: await this.pi.list(this.settings.piPullLimit);
? await this.pi.search(
this.settings.piPullQuery,
this.settings.piPullLimit,
)
: await this.pi.list(
this.settings.piPullLimit,
0,
this.settings.piPullCategory || undefined,
);
} catch (e) {
new Notice(`pi.ruv.io pull failed: ${(e as Error).message}`, 8000);
return { pulled: 0, blocked: 0, total: 0 };
@ -164,6 +172,112 @@ export class PiSyncModal extends Modal {
}
}
/**
* Publish the active note to pi.ruv.io. Prompts for a category from the
* accepted pi enum (falls back to the `custom` newtype) and assembles
* tags from frontmatter + brain-category. Write is async and can take
* ~20s server-side we show a spinner.
*/
export class PiPublishCategoryModal extends SuggestModal<string> {
constructor(
app: App,
private defaultCat: string,
private done: (c: string | null) => void,
) {
super(app);
this.setPlaceholder(
`Pi category — default ${defaultCat} (or type a custom name)`,
);
}
getSuggestions(q: string): string[] {
const base = new Set<string>(PI_CATEGORIES as unknown as string[]);
if (this.defaultCat) base.add(this.defaultCat);
if (q.trim()) base.add(q.trim());
return Array.from(base);
}
renderSuggestion(v: string, el: HTMLElement): void {
el.setText(v);
}
onChooseSuggestion(v: string): void {
this.done(v);
}
onClose(): void {
setTimeout(() => this.done(null), 0);
}
}
export async function publishActiveNoteToPi(
app: App,
pi: PiClient,
settings: BrainSettings,
): Promise<void> {
if (!pi.configured) {
new Notice("pi.ruv.io: set URL + bearer token in settings first");
return;
}
const file = app.workspace.getActiveFile();
if (!file || file.extension !== "md") {
new Notice("Open a markdown note to publish");
return;
}
const raw = await app.vault.read(file);
const body = stripFrontmatter(raw).trim();
if (!body) {
new Notice("Note is empty after frontmatter strip");
return;
}
const derived = extractCategory(raw, settings.defaultCategory);
new PiPublishCategoryModal(app, derived, async (chosen) => {
if (!chosen) return;
const tags = collectTags(raw, chosen);
const notice = new Notice(
`pi.ruv.io: publishing '${file.basename}' (can take ~20s)…`,
0,
);
try {
const res = await pi.createMemory(
piCategory(chosen),
body,
file.basename,
tags,
);
notice.hide();
new Notice(
`pi.ruv.io: published ${file.basename} — id ${res.id.slice(0, 12)}, rvf_segments=${res.rvf_segments ?? "?"}`,
8000,
);
} catch (e) {
notice.hide();
new Notice(`pi.ruv.io publish failed: ${(e as Error).message}`, 8000);
}
}).open();
}
function collectTags(raw: string, category: string): string[] {
const tags = new Set<string>();
tags.add(`brain/${category}`);
if (raw.startsWith("---")) {
const end = raw.indexOf("\n---", 3);
if (end > 0) {
const m = /\btags:\s*\[([^\]]+)\]/i.exec(raw.slice(3, end));
if (m) {
for (const t of m[1].split(",")) {
const s = t.trim().replace(/^["']|["']$/g, "");
if (s) tags.add(s);
}
}
}
}
// Inline #tags in body
const bodyTags = raw.match(/(?:^|\s)#([A-Za-z0-9_\/-]+)/g);
if (bodyTags) for (const t of bodyTags) tags.add(t.trim().slice(1));
return Array.from(tags).slice(0, 16);
}
export class PiSearchModal extends Modal {
private inputEl!: HTMLInputElement;
private resultsEl!: HTMLElement;

View file

@ -0,0 +1,230 @@
import { App, Modal, Notice, TFile, MarkdownRenderer, Component } from "obsidian";
import { BrainClient, Memory } from "./brain";
import { PiClient, PiMemory } from "./pi-client";
import type { BrainSettings } from "./settings";
import type { IndexState } from "./indexer";
/**
* Retrieval-first Q&A no LLM required. Retrieves top-k from both the
* local brain and (optionally) pi.ruv.io, shows the grounded context
* inline, and lets the user "open" or "insert" any card. This keeps the
* plugin honest: it surfaces *what the brain actually knows* without
* fabricating an answer.
*
* The answer panel is intentionally simple: a synthesized excerpt list
* with per-card provenance. A future phase can route through a local
* LLM by setting `qaLlmUrl` in settings (not implemented here).
*/
interface QaRow {
source: "local" | "pi";
id: string;
category: string;
content: string;
score?: number;
title?: string;
path?: string;
}
export class BrainQaModal extends Modal {
private inputEl!: HTMLInputElement;
private answerEl!: HTMLElement;
private statusEl!: HTMLElement;
private debounceHandle: number | null = null;
private generation = 0;
constructor(
app: App,
private brain: BrainClient,
private pi: PiClient,
private settings: BrainSettings,
private indexState: IndexState,
private component: Component,
) {
super(app);
}
onOpen(): void {
const { contentEl, modalEl } = this;
modalEl.addClass("obsidian-brain-search-modal");
modalEl.addClass("obsidian-brain-qa-modal");
contentEl.empty();
contentEl.createEl("div", { cls: "brain-search-title", text: "Brain Q&A — retrieval-grounded" });
this.inputEl = contentEl.createEl("input", {
type: "text",
cls: "brain-search-input",
attr: {
placeholder: "Ask the brain (local + pi if configured)…",
spellcheck: "false",
},
});
this.statusEl = contentEl.createEl("div", { cls: "brain-search-status" });
this.answerEl = contentEl.createEl("div", { cls: "brain-qa-answer" });
this.inputEl.addEventListener("input", () => this.schedule());
this.inputEl.addEventListener("keydown", (e) => {
if (e.key === "Escape") this.close();
if (e.key === "Enter") void this.run();
});
setTimeout(() => this.inputEl.focus(), 0);
}
onClose(): void {
if (this.debounceHandle) window.clearTimeout(this.debounceHandle);
this.contentEl.empty();
}
private schedule(): void {
if (this.debounceHandle) window.clearTimeout(this.debounceHandle);
this.debounceHandle = window.setTimeout(() => void this.run(), 300);
}
private async run(): Promise<void> {
const q = this.inputEl.value.trim();
if (!q) {
this.answerEl.empty();
this.statusEl.setText("");
return;
}
const gen = ++this.generation;
this.statusEl.setText("Retrieving context…");
this.answerEl.empty();
const rows: QaRow[] = [];
const k = this.settings.searchLimit;
const [local, pi] = await Promise.allSettled([
this.brain.search(q, k),
this.pi.configured
? this.pi.search(q, Math.max(3, Math.floor(k / 2)))
: Promise.resolve<PiMemory[]>([]),
]);
if (gen !== this.generation) return;
if (local.status === "fulfilled") {
for (const m of local.value.results) {
rows.push({
source: "local",
id: m.id,
category: m.category,
content: (m.content ?? "").trim(),
score: m.score,
path: this.indexState.idToPath[m.id],
});
}
}
if (pi.status === "fulfilled") {
for (const m of pi.value) {
rows.push({
source: "pi",
id: m.id,
category: m.category ?? "pi",
content: (m.content ?? "").trim(),
score: m.score,
title: m.title,
});
}
}
// Deduplicate by normalized content prefix so local-mirror-of-pi
// doesn't show the same passage twice.
const seen = new Set<string>();
const deduped: QaRow[] = [];
for (const r of rows) {
const key = r.content.replace(/\s+/g, " ").slice(0, 140).toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
deduped.push(r);
}
// Rank: local first by score desc, then pi by score desc.
deduped.sort((a, b) => {
if (a.source !== b.source) return a.source === "local" ? -1 : 1;
return (b.score ?? 0) - (a.score ?? 0);
});
if (deduped.length === 0) {
this.statusEl.setText("No grounded context found — try a different phrasing.");
return;
}
this.statusEl.setText(
`${deduped.filter((r) => r.source === "local").length} local · ${deduped.filter((r) => r.source === "pi").length} pi`,
);
await this.renderRows(deduped);
}
private async renderRows(rows: QaRow[]): Promise<void> {
for (const r of rows) {
const card = this.answerEl.createEl("div", { cls: "brain-qa-card" });
const header = card.createEl("div", { cls: "brain-qa-card-header" });
const badge = header.createEl("span", {
cls: `brain-search-category brain-qa-source-${r.source}`,
text: `${r.source} · ${r.category}`,
});
badge.style.setProperty("--brain-category", hashColor(r.category));
if (r.score !== undefined) {
header.createEl("span", {
cls: "brain-search-meta",
text: `score ${r.score.toFixed(3)}`,
});
}
const body = card.createEl("div", { cls: "brain-qa-card-body" });
await MarkdownRenderer.render(
this.app,
truncate(r.content, 600),
body,
r.path ?? "",
this.component,
);
const actions = card.createEl("div", { cls: "brain-qa-card-actions" });
if (r.source === "local" && r.path) {
const open = actions.createEl("button", { text: "Open note" });
open.addEventListener("click", () => void this.openNote(r.path!));
}
const insert = actions.createEl("button", { text: "Insert into active note" });
insert.addEventListener("click", () => void this.insertIntoActive(r));
const copy = actions.createEl("button", { text: "Copy as quote" });
copy.addEventListener("click", () => {
navigator.clipboard.writeText(formatAsQuote(r));
new Notice("Copied to clipboard");
});
}
}
private async openNote(path: string): Promise<void> {
const f = this.app.vault.getAbstractFileByPath(path);
if (f instanceof TFile) {
await this.app.workspace.getLeaf(false).openFile(f);
this.close();
}
}
private async insertIntoActive(r: QaRow): Promise<void> {
const file = this.app.workspace.getActiveFile();
if (!file || file.extension !== "md") {
new Notice("Open a markdown note to insert into.");
return;
}
const existing = await this.app.vault.read(file);
const block = `\n${formatAsQuote(r)}\n`;
await this.app.vault.modify(file, existing + block);
new Notice(`Inserted ${r.source}:${r.category} quote`);
}
}
function truncate(s: string, n: number): string {
if (s.length <= n) return s;
return s.slice(0, n) + "…";
}
function formatAsQuote(r: QaRow): string {
const lines = truncate(r.content, 600).split("\n").map((l) => `> ${l}`);
lines.push(
`> — *brain:${r.source} · ${r.category}${r.score !== undefined ? ` · score ${r.score.toFixed(3)}` : ""}*`,
);
return lines.join("\n");
}
function hashColor(s: string): string {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
return `hsl(${Math.abs(h) % 360}, 55%, 55%)`;
}

View file

@ -10,6 +10,7 @@ interface ViewState {
results: Memory[];
error: string | null;
sourcePath: string | null;
selected: number;
}
export class RelatedView extends ItemView {
@ -18,8 +19,10 @@ export class RelatedView extends ItemView {
results: [],
error: null,
sourcePath: null,
selected: 0,
};
private generation = 0;
private keyHandler: ((e: KeyboardEvent) => void) | null = null;
constructor(
leaf: WorkspaceLeaf,
@ -47,11 +50,42 @@ export class RelatedView extends ItemView {
this.registerEvent(
this.app.workspace.on("active-leaf-change", () => void this.refreshForActive()),
);
this.keyHandler = (e) => this.handleKey(e);
this.contentEl.addEventListener("keydown", this.keyHandler);
// Allow the view itself to receive focus for keyboard nav.
this.contentEl.tabIndex = 0;
await this.refreshForActive();
}
async onClose(): Promise<void> {
this.generation++;
if (this.keyHandler) {
this.contentEl.removeEventListener("keydown", this.keyHandler);
this.keyHandler = null;
}
}
private handleKey(e: KeyboardEvent): void {
if (this.state.results.length === 0) return;
if (e.key === "ArrowDown" || e.key === "j") {
e.preventDefault();
this.state.selected = Math.min(
this.state.results.length - 1,
this.state.selected + 1,
);
this.render();
} else if (e.key === "ArrowUp" || e.key === "k") {
e.preventDefault();
this.state.selected = Math.max(0, this.state.selected - 1);
this.render();
} else if (e.key === "Enter" || e.key === "o") {
e.preventDefault();
const r = this.state.results[this.state.selected];
if (r) void this.activate(r);
} else if (e.key === "r") {
e.preventDefault();
void this.refreshForActive();
}
}
async refreshForActive(): Promise<void> {
@ -62,7 +96,13 @@ export class RelatedView extends ItemView {
async loadForFile(file: TFile): Promise<void> {
const gen = ++this.generation;
this.state = { loading: true, results: [], error: null, sourcePath: file.path };
this.state = {
loading: true,
results: [],
error: null,
sourcePath: file.path,
selected: 0,
};
this.render();
try {
const raw = await this.app.vault.read(file);
@ -126,8 +166,10 @@ export class RelatedView extends ItemView {
}
const list = root.createEl("div", { cls: "brain-related-list" });
this.state.results.forEach((mem) => {
const row = list.createEl("div", { cls: "brain-related-row" });
this.state.results.forEach((mem, idx) => {
const row = list.createEl("div", {
cls: `brain-related-row${idx === this.state.selected ? " selected" : ""}`,
});
const cat = row.createEl("span", {
cls: "brain-related-category",
text: mem.category,
@ -143,6 +185,14 @@ export class RelatedView extends ItemView {
mem.id.slice(0, 16);
row.createEl("div", { cls: "brain-related-snippet", text: snippet });
row.addEventListener("click", () => void this.activate(mem));
row.addEventListener("mouseenter", () => {
this.state.selected = idx;
this.render();
});
});
root.createEl("div", {
cls: "brain-related-hint",
text: "↑↓ or j/k move · Enter / o open · r refresh",
});
}

View file

@ -35,6 +35,7 @@ export class BrainSearchModal extends Modal {
private brain: BrainClient,
private settings: BrainSettings,
private indexState: IndexState,
private seed?: string,
) {
super(app);
}
@ -59,7 +60,18 @@ export class BrainSearchModal extends Modal {
this.scheduleSearch();
});
this.inputEl.addEventListener("keydown", (e) => this.onKey(e));
setTimeout(() => this.inputEl.focus(), 0);
setTimeout(() => {
this.inputEl.focus();
if (this.seed) {
this.inputEl.value = this.seed;
this.inputEl.setSelectionRange(
this.seed.length,
this.seed.length,
);
this.state.query = this.seed;
void this.runSearch();
}
}, 0);
this.render();
}
@ -82,18 +94,24 @@ export class BrainSearchModal extends Modal {
this.render();
return;
}
const { query, category } = parseCategoryPrefix(q);
const gen = ++this.generation;
this.state.loading = true;
this.state.error = null;
this.render();
try {
const resp = await this.brain.search(q, this.settings.searchLimit);
const resp = await this.brain.search(
query || q,
this.settings.searchLimit,
);
if (gen !== this.generation) return;
this.state.results = resp.results;
this.state.results = category
? resp.results.filter((r) => r.category === category)
: resp.results;
} catch (e) {
if (gen !== this.generation) return;
this.state.error = (e as Error).message;
this.state.results = this.localFallback(q);
this.state.results = this.localFallback(query || q);
} finally {
if (gen === this.generation) {
this.state.loading = false;
@ -229,6 +247,20 @@ function hashColor(s: string): string {
return `hsl(${hue}, 55%, 55%)`;
}
/**
* Parse a `category:<token> <rest of query>` prefix. Returns the
* extracted category (or null) and the remaining query text. Supports
* quoted categories: `category:"design patterns" semantic search`.
*/
export function parseCategoryPrefix(q: string): {
query: string;
category: string | null;
} {
const m = /^category:(?:"([^"]+)"|(\S+))\s*(.*)$/i.exec(q);
if (!m) return { query: q, category: null };
return { category: (m[1] ?? m[2]).trim(), query: (m[3] ?? "").trim() };
}
export async function quickSearchAndOpen(
app: App,
brain: BrainClient,

View file

@ -21,6 +21,7 @@ export interface BrainSettings {
piToken: string;
piPullLimit: number;
piPullQuery: string;
piPullCategory: string;
}
export const DEFAULT_SETTINGS: BrainSettings = {
@ -42,6 +43,7 @@ export const DEFAULT_SETTINGS: BrainSettings = {
piToken: "",
piPullLimit: 20,
piPullQuery: "",
piPullCategory: "",
};
export class BrainSettingTab extends PluginSettingTab {
@ -302,6 +304,49 @@ export class BrainSettingTab extends PluginSettingTab {
}),
);
new Setting(containerEl)
.setName("Pull category filter")
.setDesc(
"Optional — limit list pulls to one pi category (e.g. pattern, solution, architecture). Ignored when a query is set.",
)
.addText((t) =>
t
.setValue(this.plugin.settings.piPullCategory)
.onChange(async (v) => {
this.plugin.settings.piPullCategory = v.trim();
await this.plugin.saveSettings();
}),
);
containerEl.createEl("h3", { text: "Agent access (MCP)" });
containerEl.createEl("p", {
cls: "brain-settings-hint",
text:
"The RuVector brain ships an MCP server (`mcp-brain-server`, plus " +
"`ruvbrain-sse` for SSE transport). Any agent that speaks MCP — " +
"Claude Code, Codex CLI, Gemini CLI — can read and write the same " +
"memories this plugin indexes. Point your agent's MCP config at " +
"the brain URL below.",
});
new Setting(containerEl)
.setName("Copy MCP endpoint")
.setDesc(
"Drops the current brain URL onto the clipboard so you can paste it " +
"into claude_desktop_config.json, .codex/mcp.json, etc.",
)
.addButton((b) =>
b.setButtonText("Copy").onClick(async () => {
const url = this.plugin.settings.brainUrl;
try {
await navigator.clipboard.writeText(url);
new Notice(`MCP endpoint copied: ${url}`);
} catch {
new Notice(url, 6000);
}
}),
);
new Setting(containerEl)
.setName("Test pi.ruv.io")
.setDesc("Fetch /v1/status — no auth required")

View file

@ -231,6 +231,43 @@ export default class HarnessPlugin extends Plugin {
});
}
await addCheck("phase-4 commands registered", () => {
for (const id of [
"obsidian-brain:brain-qa",
"obsidian-brain:brain-ops",
"obsidian-brain:brain-search-selection",
"obsidian-brain:brain-pi-publish",
"obsidian-brain:brain-daily-recall",
"obsidian-brain:brain-offline-queue-flush",
]) {
if (!lookupCommand(this.app, id))
throw new Error(`missing command ${id}`);
}
return "qa/ops/selection/pi-publish/daily-recall/queue all present";
});
await addCheck("offline queue API accessible", async () => {
const queueApi = (
this.app as unknown as {
plugins: {
plugins: Record<
string,
{
queue?: {
size: () => number;
drain: () => Promise<{ sent: number; failed: number }>;
};
}
>;
};
}
).plugins.plugins["obsidian-brain"]?.queue;
if (!queueApi) throw new Error("plugin.queue not exposed");
const n = queueApi.size();
const r = await queueApi.drain();
return `size=${n}, drain sent=${r.sent} failed=${r.failed}`;
});
this.writeReport({
startedAt,
checks,

View file

@ -29,9 +29,19 @@ describe.skipIf(!RUN)("real Obsidian + obsidian-brain plugin (E2E)", () => {
failed.map((c) => `${c.name}: ${c.detail}`).join("\n"),
);
}
// 7 baseline checks + 1 "pi commands registered" always;
// +1 when BRAIN_API_KEY reaches the plugin (live pi status).
const minExpected = process.env.BRAIN_API_KEY ? 9 : 8;
// Checks:
// 1 brain plugin loaded
// 2 base commands registered
// 3 status bar populated
// 4 index current note
// 5 brain.search roundtrip
// 6 bulk sync
// 7 graph overlay
// 8 pi commands registered
// 9 pi status roundtrip (only when BRAIN_API_KEY plumbed)
// 10 phase-4 commands registered (qa/ops/selection/publish/recall/queue)
// 11 offline queue API accessible
const minExpected = process.env.BRAIN_API_KEY ? 11 : 10;
expect(result.report.passed).toBeGreaterThanOrEqual(minExpected);
},
300_000,

View file

@ -76,4 +76,50 @@ describe.skipIf(!RUN)("protocol: live pi.ruv.io contract", () => {
const resp = await fetch(base.replace(/\/$/, "") + "/v1/memories/list?limit=1");
expect(resp.status).toBe(401);
});
it("POST /v1/memories write-through (slow ~20s) returns created shape", async () => {
const resp = await fetch(base.replace(/\/$/, "") + "/v1/memories", {
method: "POST",
headers: {
"content-type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
category: "pattern",
content:
"obsidian-brain plugin protocol test — delete-safe placeholder",
title: "obsidian-brain protocol probe",
tags: ["probe", "obsidian-brain"],
}),
});
expect([200, 201]).toContain(resp.status);
const body = (await resp.json()) as {
id: string;
quality_score: number;
witness_hash: string;
rvf_segments?: number;
};
expect(body.id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
expect(typeof body.quality_score).toBe("number");
expect(typeof body.witness_hash).toBe("string");
}, 40_000);
it("POST /v1/memories rejects unknown category with 422", async () => {
const resp = await fetch(base.replace(/\/$/, "") + "/v1/memories", {
method: "POST",
headers: {
"content-type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
category: "totally-bogus-category-does-not-exist",
content: "whatever",
title: "unused",
tags: [],
}),
});
expect(resp.status).toBe(422);
});
});