diff --git a/examples/obsidian-brain/src/brain-ops.ts b/examples/obsidian-brain/src/brain-ops.ts new file mode 100644 index 000000000..802f19ece --- /dev/null +++ b/examples/obsidian-brain/src/brain-ops.ts @@ -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 { + 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 { + 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 { + 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); + } + } +} diff --git a/examples/obsidian-brain/src/indexer.ts b/examples/obsidian-brain/src/indexer.ts index 03ad54316..aaa8e11be 100644 --- a/examples/obsidian-brain/src/indexer.ts +++ b/examples/obsidian-brain/src/indexer.ts @@ -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(); 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<{ diff --git a/examples/obsidian-brain/src/main.ts b/examples/obsidian-brain/src/main.ts index 78a760126..b3404138a 100644 --- a/examples/obsidian-brain/src/main.ts +++ b/examples/obsidian-brain/src/main.ts @@ -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 { 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 { 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 { + 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 { 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; } diff --git a/examples/obsidian-brain/src/offline-queue.ts b/examples/obsidian-brain/src/offline-queue.ts new file mode 100644 index 000000000..2d1f1f268 --- /dev/null +++ b/examples/obsidian-brain/src/offline-queue.ts @@ -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 }; + } +} diff --git a/examples/obsidian-brain/src/pi-client.ts b/examples/obsidian-brain/src/pi-client.ts index 919c1e8b0..ca273e957 100644 --- a/examples/obsidian-brain/src/pi-client.ts +++ b/examples/obsidian-brain/src/pi-client.ts @@ -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(path: string): Promise { + private async req( + method: "GET" | "POST", + path: string, + body?: unknown, + ): Promise { 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 { - return this.req("/v1/status"); + return this.req("GET", "/v1/status"); } async list(limit: number, offset = 0, category?: string): Promise { @@ -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 { const q = new URLSearchParams({ q: query, limit: String(limit) }); const resp = await this.req( + "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 { + return this.req("POST", "/v1/memories", { + category, + content, + title, + tags, + }); + } } diff --git a/examples/obsidian-brain/src/pi-sync.ts b/examples/obsidian-brain/src/pi-sync.ts index c292e137a..451f0c1ed 100644 --- a/examples/obsidian-brain/src/pi-sync.ts +++ b/examples/obsidian-brain/src/pi-sync.ts @@ -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 { + 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(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 { + 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(); + 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; diff --git a/examples/obsidian-brain/src/qa-modal.ts b/examples/obsidian-brain/src/qa-modal.ts new file mode 100644 index 000000000..dfbe18750 --- /dev/null +++ b/examples/obsidian-brain/src/qa-modal.ts @@ -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 { + 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([]), + ]); + 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(); + 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 { + 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 { + 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 { + 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%)`; +} diff --git a/examples/obsidian-brain/src/related-view.ts b/examples/obsidian-brain/src/related-view.ts index ecb9dd15c..31485bb3b 100644 --- a/examples/obsidian-brain/src/related-view.ts +++ b/examples/obsidian-brain/src/related-view.ts @@ -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 { 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 { @@ -62,7 +96,13 @@ export class RelatedView extends ItemView { async loadForFile(file: TFile): Promise { 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", }); } diff --git a/examples/obsidian-brain/src/search-modal.ts b/examples/obsidian-brain/src/search-modal.ts index c9e173bca..65a2c408d 100644 --- a/examples/obsidian-brain/src/search-modal.ts +++ b/examples/obsidian-brain/src/search-modal.ts @@ -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: ` 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, diff --git a/examples/obsidian-brain/src/settings.ts b/examples/obsidian-brain/src/settings.ts index ba0b632c5..fe25631d1 100644 --- a/examples/obsidian-brain/src/settings.ts +++ b/examples/obsidian-brain/src/settings.ts @@ -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") diff --git a/examples/obsidian-brain/tests/e2e/harness/main.ts b/examples/obsidian-brain/tests/e2e/harness/main.ts index 072cb5f43..6cc29eef1 100644 --- a/examples/obsidian-brain/tests/e2e/harness/main.ts +++ b/examples/obsidian-brain/tests/e2e/harness/main.ts @@ -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, diff --git a/examples/obsidian-brain/tests/e2e/obsidian-e2e.test.ts b/examples/obsidian-brain/tests/e2e/obsidian-e2e.test.ts index 48b5f2988..d7bc6df83 100644 --- a/examples/obsidian-brain/tests/e2e/obsidian-e2e.test.ts +++ b/examples/obsidian-brain/tests/e2e/obsidian-e2e.test.ts @@ -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, diff --git a/examples/obsidian-brain/tests/protocol/pi-server.test.ts b/examples/obsidian-brain/tests/protocol/pi-server.test.ts index c9199c827..ca43006d7 100644 --- a/examples/obsidian-brain/tests/protocol/pi-server.test.ts +++ b/examples/obsidian-brain/tests/protocol/pi-server.test.ts @@ -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); + }); });