mirror of
https://github.com/ruvnet/RuVector.git
synced 2026-05-25 06:36:37 +00:00
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:
parent
ec12bb163a
commit
31c865cc1e
13 changed files with 1096 additions and 30 deletions
130
examples/obsidian-brain/src/brain-ops.ts
Normal file
130
examples/obsidian-brain/src/brain-ops.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
98
examples/obsidian-brain/src/offline-queue.ts
Normal file
98
examples/obsidian-brain/src/offline-queue.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
230
examples/obsidian-brain/src/qa-modal.ts
Normal file
230
examples/obsidian-brain/src/qa-modal.ts
Normal 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%)`;
|
||||
}
|
||||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue