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:
43
src/index.ts
43
src/index.ts
@@ -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
44
src/tools/index-tool.ts
Normal 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/",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user