Sprint 0-2: TS plugin scaffolding, LanceDB utils, tooling updates

- Add index-tool.ts command implementation
- Wire lancedb.ts vector search into plugin
- Update src/tools/index.ts exports
- Bump package deps (ts-jest, jest, typescript, lancedb)
- Add .claude/settings.local.json

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-11 13:24:26 -04:00
parent 83a54b2af6
commit 208531d28d
9 changed files with 10297 additions and 53 deletions

View File

@@ -1,27 +1,34 @@
/**
* OpenClaw plugin entry point.
* Registers 4 obsidian_rag_* tools via the OpenClaw SDK.
*/
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { registerTools } from "./tools/index.js";
import { loadConfig } from "./utils/config.js";
import { createHealthMachine, probeAll } from "./services/health.js";
import { VaultWatcher } from "./services/vault-watcher.js";
/** OpenClaw plugin entry point. */
export async function onLoad(): Promise<void> {
const config = loadConfig();
const health = createHealthMachine(config);
export default definePluginEntry({
id: "obsidian-rag",
name: "Obsidian RAG",
description:
"Semantic search through Obsidian vault notes using RAG. Powers natural language queries like 'How was my mental health in 2024?' across journal entries, financial records, health data, and more.",
register(api) {
const config = loadConfig();
const health = createHealthMachine(config);
// Probe dependencies immediately
const probe = await probeAll(config);
health.transition(probe);
// Start vault watcher for auto-sync
const watcher = new VaultWatcher(config, health);
watcher.start();
// Start vault watcher for auto-sync
const watcher = new VaultWatcher(config, health);
watcher.start();
// Register all 4 tools
registerTools(api, config, health);
// Register all 4 tools
await registerTools(config, health);
console.log("[obsidian-rag] Plugin loaded — tools registered");
console.log("[obsidian-rag] Plugin loaded");
}
export async function onUnload(): Promise<void> {
console.log("[obsidian-rag] Plugin unloading");
}
// Probe dependencies and start health reprobing in background
probeAll(config).then((probe) => health.transition(probe));
health.startReprobing(() => probeAll(config));
},
});

44
src/tools/index-tool.ts Normal file
View File

@@ -0,0 +1,44 @@
/** obsidian_rag_index tool — spawns the Python indexer CLI. */
import type { ObsidianRagConfig } from "../utils/config.js";
import type { HealthState } from "../services/health.js";
import type { ResponseEnvelope } from "../utils/types.js";
import { makeEnvelope } from "../utils/response.js";
import { spawnIndexer } from "../services/indexer-bridge.js";
export interface IndexParams {
mode: "full" | "sync" | "reindex";
}
export async function runIndexTool(
config: ObsidianRagConfig,
health: { get: () => { state: HealthState }; setActiveJob: (job: { id: string; mode: string; progress: number } | null) => void },
params: IndexParams,
): Promise<ResponseEnvelope<{ job_id: string; status: string; mode: string; message: string } | null>> {
const modeMap = { full: "index", sync: "sync", reindex: "reindex" } as const;
const cliMode = modeMap[params.mode];
try {
const job = await spawnIndexer(cliMode, config);
health.setActiveJob({ id: job.id, mode: job.mode, progress: job.progress });
return makeEnvelope(
"healthy",
{
job_id: job.id,
status: "started",
mode: params.mode,
message: `Indexing job ${job.id} started in ${params.mode} mode`,
},
null,
);
} catch (err) {
return makeEnvelope("unavailable", null, {
code: "INDEXER_SPAWN_FAILED",
message: String(err),
recoverable: true,
suggestion: "Ensure the Python indexer is installed: pip install -e python/",
});
}
}

View File

@@ -1,12 +1,118 @@
/** Tool registration — wires all 4 obsidian_rag_* tools into OpenClaw. */
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import type { ObsidianRagConfig } from "../utils/config.js";
import type { HealthState } from "../services/health.js";
import { Type } from "@sinclair/typebox";
import { searchTool, type SearchParams } from "./search.js";
import { runIndexTool, type IndexParams } from "./index-tool.js";
import { statusTool } from "./status.js";
import { memoryStoreTool, type MemoryStoreParams } from "./memory.js";
export async function registerTools(
_config: ObsidianRagConfig,
_health: { get: () => { state: HealthState } },
): Promise<void> {
// TODO: Wire into OpenClaw tool registry once SDK is available
console.log("[obsidian-rag] Tools registered (stub — OpenClaw SDK TBD)");
function textEnvelope<T>(text: string, details: T): AgentToolResult<T> {
return { content: [{ type: "text", text }], details };
}
export function registerTools(
api: OpenClawPluginApi,
config: ObsidianRagConfig,
health: { get: () => { state: HealthState }; setActiveJob: (job: { id: string; mode: string; progress: number } | null) => void },
): void {
// obsidian_rag_search — primary semantic search
api.registerTool({
name: "obsidian_rag_search",
description:
"Primary semantic search tool. Given a natural language query, searches the Obsidian vault index and returns the most relevant note chunks ranked by semantic similarity. Supports filtering by directory, date range, and tags.",
label: "Search Obsidian Vault",
parameters: Type.Object({
query: Type.String({ description: "Natural language question or topic to search for" }),
max_results: Type.Optional(
Type.Number({ minimum: 1, maximum: 50, description: "Maximum number of chunks to return" }),
),
directory_filter: Type.Optional(
Type.Array(Type.String(), {
description: "Limit search to specific vault subdirectories (e.g. ['Journal', 'Finance'])",
}),
),
date_range: Type.Optional(
Type.Object({
from: Type.Optional(Type.String({ description: "Start date (YYYY-MM-DD)" })),
to: Type.Optional(Type.String({ description: "End date (YYYY-MM-DD)" })),
}),
),
tags: Type.Optional(
Type.Array(Type.String(), {
description: "Filter by hashtags found in notes (e.g. ['#mentalhealth', '#therapy'])",
}),
),
}),
async execute(_id, params) {
const searchParams: SearchParams = {
query: String(params.query),
max_results: params.max_results != null ? Number(params.max_results) : undefined,
directory_filter: params.directory_filter as string[] | undefined,
date_range: params.date_range as { from?: string; to?: string } | undefined,
tags: params.tags as string[] | undefined,
};
const result = await searchTool(config, searchParams);
return textEnvelope(JSON.stringify(result), result);
},
});
// obsidian_rag_index — trigger indexing
api.registerTool({
name: "obsidian_rag_index",
description:
"Trigger indexing of the Obsidian vault. Use 'full' for first-time setup, 'sync' for incremental updates, 'reindex' to force a clean rebuild.",
label: "Index Obsidian Vault",
parameters: Type.Object({
mode: Type.Union(
[Type.Literal("full"), Type.Literal("sync"), Type.Literal("reindex")],
{ description: "Indexing mode" },
),
}),
async execute(_id, params) {
const indexParams: IndexParams = { mode: String(params.mode) as "full" | "sync" | "reindex" };
const result = await runIndexTool(config, health, indexParams);
return textEnvelope(JSON.stringify(result), result);
},
});
// obsidian_rag_status — health check
api.registerTool({
name: "obsidian_rag_status",
description:
"Check the health of the Obsidian RAG plugin — index statistics, last sync time, unindexed files, and Ollama status. Call this first when unsure if the index is ready.",
label: "Obsidian RAG Status",
parameters: Type.Object({}),
async execute(_id) {
const result = await statusTool(config);
return textEnvelope(JSON.stringify(result), result);
},
});
// obsidian_rag_memory_store — commit facts to memory
api.registerTool({
name: "obsidian_rag_memory_store",
description:
"Commit an important fact from search results to OpenClaw's memory for faster future retrieval. Use after finding significant information (e.g. 'I owe Sreenivas $50') that should be remembered.",
label: "Store in Memory",
parameters: Type.Object({
key: Type.String({ description: "Identifier for the fact (e.g. 'debt_to_sreenivas')" }),
value: Type.String({ description: "The fact to remember" }),
source: Type.String({
description: "Source file path in the vault (e.g. 'Journal/2025-03-15.md')",
}),
}),
async execute(_id, params) {
const memParams: MemoryStoreParams = {
key: String(params.key),
value: String(params.value),
source: String(params.source),
};
const result = await memoryStoreTool(memParams);
return textEnvelope(JSON.stringify(result), result);
},
});
}

View File

@@ -52,9 +52,6 @@ export async function searchVectorDb(
}
const table = await db.openTable("obsidian_chunks");
// Embed the query text
const queryVector = await embedQuery(query, config);
// Build WHERE clause from filters
const conditions: string[] = [];
if (options.directory_filter && options.directory_filter.length > 0) {
@@ -79,12 +76,24 @@ export async function searchVectorDb(
const limit = options.max_results ?? 5;
// LanceDB JS SDK: table.vectorSearch(vector).filter(...).limit(...).toArray()
let queryBuilder = table.vectorSearch(queryVector);
if (whereClause) {
queryBuilder = queryBuilder.filter(whereClause);
// Try vector search first; if Ollama is down embedQuery throws → fallback to FTS
let rows: Record<string, unknown>[];
try {
const queryVector = await embedQuery(query, config);
let queryBuilder = table.vectorSearch(queryVector);
if (whereClause) {
queryBuilder = queryBuilder.filter(whereClause);
}
rows = await queryBuilder.limit(limit).toArray();
} catch {
// Ollama unavailable — fallback to full-text search on chunk_text (BM25 scoring)
let ftsBuilder = table.query().fullTextSearch(query);
if (whereClause) {
ftsBuilder = ftsBuilder.filter(whereClause);
}
rows = await ftsBuilder.limit(limit).toArray();
}
const rows = await queryBuilder.limit(limit).toArray();
return rows.map((r: Record<string, unknown>) => ({
chunk_id: r["chunk_id"] as string,
@@ -95,6 +104,6 @@ export async function searchVectorDb(
date: (r["date"] as string) ?? null,
tags: (r["tags"] as string[]) ?? [],
chunk_index: (r["chunk_index"] as number) ?? 0,
score: (r["_distance"] as number) ?? 0.0,
score: (r["_score"] as number) ?? (r["_distance"] as number) ?? 0.0,
}));
}