mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
5365f971ef
Signed-off-by: phernandez <paul@basicmachines.co>
916 lines
22 KiB
TypeScript
916 lines
22 KiB
TypeScript
import { setTimeout as delay } from "node:timers/promises"
|
|
import { Client } from "@modelcontextprotocol/sdk/client"
|
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
|
import { log } from "./logger.ts"
|
|
|
|
const DEFAULT_RETRY_DELAYS_MS = [500, 1000, 2000]
|
|
|
|
export class NoteAlreadyExistsError extends Error {
|
|
readonly permalink: string
|
|
constructor(title: string, permalink: string) {
|
|
super(`Note already exists: "${title}" (${permalink})`)
|
|
this.name = "NoteAlreadyExistsError"
|
|
this.permalink = permalink
|
|
}
|
|
}
|
|
|
|
const REQUIRED_TOOLS = [
|
|
"search_notes",
|
|
"read_note",
|
|
"write_note",
|
|
"edit_note",
|
|
"build_context",
|
|
"recent_activity",
|
|
"list_memory_projects",
|
|
"list_workspaces",
|
|
"create_memory_project",
|
|
"delete_note",
|
|
"move_note",
|
|
"schema_validate",
|
|
"schema_infer",
|
|
"schema_diff",
|
|
]
|
|
|
|
export interface SearchResult {
|
|
title: string
|
|
permalink: string
|
|
content: string
|
|
score?: number
|
|
file_path: string
|
|
}
|
|
|
|
export interface NoteResult {
|
|
title: string
|
|
permalink: string
|
|
content: string
|
|
file_path: string
|
|
frontmatter?: Record<string, unknown> | null
|
|
checksum?: string | null
|
|
action?: "created" | "updated"
|
|
}
|
|
|
|
export interface EditNoteResult {
|
|
title: string
|
|
permalink: string
|
|
file_path: string
|
|
operation: "append" | "prepend" | "find_replace" | "replace_section"
|
|
checksum?: string | null
|
|
}
|
|
|
|
interface ReadNoteOptions {
|
|
includeFrontmatter?: boolean
|
|
}
|
|
|
|
interface EditNoteOptions {
|
|
find_text?: string
|
|
section?: string
|
|
expected_replacements?: number
|
|
}
|
|
|
|
export interface ContextResult {
|
|
results: Array<{
|
|
primary_result: NoteResult
|
|
observations: Array<{
|
|
category: string
|
|
content: string
|
|
}>
|
|
related_results: Array<{
|
|
type: "relation" | "entity"
|
|
title?: string
|
|
permalink: string
|
|
relation_type?: string
|
|
from_entity?: string
|
|
to_entity?: string
|
|
}>
|
|
}>
|
|
}
|
|
|
|
export interface RecentResult {
|
|
title: string
|
|
permalink: string
|
|
file_path: string
|
|
created_at: string
|
|
}
|
|
|
|
export interface ProjectListResult {
|
|
name: string
|
|
path: string
|
|
display_name?: string | null
|
|
is_private?: boolean
|
|
is_default?: boolean
|
|
isDefault?: boolean
|
|
workspace_name?: string | null
|
|
workspace_slug?: string | null
|
|
workspace_type?: string | null
|
|
workspace_tenant_id?: string | null
|
|
}
|
|
|
|
export interface WorkspaceResult {
|
|
tenant_id: string
|
|
name: string
|
|
workspace_type: string
|
|
role: string
|
|
organization_id?: string | null
|
|
has_active_subscription: boolean
|
|
}
|
|
|
|
export interface SchemaValidationResult {
|
|
entity_type: string | null
|
|
total_notes: number
|
|
total_entities: number
|
|
valid_count: number
|
|
warning_count: number
|
|
error_count: number
|
|
results: Array<{
|
|
identifier: string
|
|
valid: boolean
|
|
warnings: string[]
|
|
errors: string[]
|
|
}>
|
|
}
|
|
|
|
export interface SchemaInferResult {
|
|
entity_type: string
|
|
notes_analyzed: number
|
|
field_frequencies: Array<{
|
|
name: string
|
|
percentage: number
|
|
count: number
|
|
total: number
|
|
source: string
|
|
sample_values?: string[]
|
|
is_array?: boolean
|
|
target_type?: string | null
|
|
}>
|
|
suggested_schema: Record<string, unknown>
|
|
suggested_required: string[]
|
|
suggested_optional: string[]
|
|
excluded: string[]
|
|
}
|
|
|
|
export interface SchemaDiffResult {
|
|
entity_type: string
|
|
schema_found: boolean
|
|
new_fields: Array<{
|
|
name: string
|
|
source: string
|
|
count: number
|
|
total: number
|
|
percentage: number
|
|
}>
|
|
dropped_fields: Array<{ name: string; source: string; declared_in?: string }>
|
|
cardinality_changes: string[]
|
|
}
|
|
|
|
function getErrorMessage(err: unknown): string {
|
|
return err instanceof Error ? err.message : String(err)
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
}
|
|
|
|
function extractTextFromContent(content: unknown): string {
|
|
if (!Array.isArray(content)) return ""
|
|
|
|
const textBlocks = content
|
|
.filter(
|
|
(block): block is { type: "text"; text: string } =>
|
|
isRecord(block) &&
|
|
block.type === "text" &&
|
|
typeof block.text === "string",
|
|
)
|
|
.map((block) => block.text)
|
|
|
|
return textBlocks.join("\n").trim()
|
|
}
|
|
|
|
function isRecoverableConnectionError(err: unknown): boolean {
|
|
const msg = getErrorMessage(err).toLowerCase()
|
|
return (
|
|
msg.includes("connection closed") ||
|
|
msg.includes("not connected") ||
|
|
msg.includes("transport") ||
|
|
msg.includes("broken pipe") ||
|
|
msg.includes("econnreset") ||
|
|
msg.includes("epipe") ||
|
|
msg.includes("failed to start bm mcp stdio") ||
|
|
msg.includes("client is closed")
|
|
)
|
|
}
|
|
|
|
function isNoteNotFoundError(err: unknown): boolean {
|
|
const msg = getErrorMessage(err).toLowerCase()
|
|
return (
|
|
msg.includes("entity not found") ||
|
|
msg.includes("note not found") ||
|
|
msg.includes("resource not found") ||
|
|
msg.includes("could not find note matching") ||
|
|
msg.includes("404")
|
|
)
|
|
}
|
|
|
|
function asString(value: unknown): string | null {
|
|
return typeof value === "string" ? value : null
|
|
}
|
|
|
|
function projectMatchesWorkspace(
|
|
project: ProjectListResult,
|
|
workspace: string,
|
|
): boolean {
|
|
const requested = workspace.trim().toLowerCase()
|
|
if (!requested) return true
|
|
|
|
return [
|
|
project.workspace_name,
|
|
project.workspace_slug,
|
|
project.workspace_tenant_id,
|
|
].some(
|
|
(value) => typeof value === "string" && value.toLowerCase() === requested,
|
|
)
|
|
}
|
|
|
|
export class BmClient {
|
|
private bmPath: string
|
|
private project: string
|
|
private cwd?: string
|
|
private env?: Record<string, string>
|
|
private shouldRun = false
|
|
|
|
private client: Client | null = null
|
|
private transport: StdioClientTransport | null = null
|
|
private connectPromise: Promise<void> | null = null
|
|
private retryDelaysMs = [...DEFAULT_RETRY_DELAYS_MS]
|
|
|
|
constructor(bmPath: string, project: string) {
|
|
this.bmPath = bmPath
|
|
this.project = project
|
|
}
|
|
|
|
async start(options?: {
|
|
cwd?: string
|
|
env?: Record<string, string>
|
|
}): Promise<void> {
|
|
this.shouldRun = true
|
|
if (options?.cwd) {
|
|
this.cwd = options.cwd
|
|
}
|
|
if (options?.env) {
|
|
this.env = options.env
|
|
}
|
|
|
|
await this.connectWithRetries()
|
|
}
|
|
|
|
async stop(): Promise<void> {
|
|
this.shouldRun = false
|
|
await this.disconnectCurrent(this.client, this.transport)
|
|
this.client = null
|
|
this.transport = null
|
|
}
|
|
|
|
private async connectWithRetries(): Promise<void> {
|
|
let lastErr: unknown
|
|
|
|
for (let attempt = 0; attempt <= this.retryDelaysMs.length; attempt++) {
|
|
try {
|
|
await this.ensureConnected()
|
|
return
|
|
} catch (err) {
|
|
lastErr = err
|
|
await this.disconnectCurrent(this.client, this.transport)
|
|
this.client = null
|
|
this.transport = null
|
|
|
|
if (attempt === this.retryDelaysMs.length) {
|
|
break
|
|
}
|
|
|
|
const waitMs = this.retryDelaysMs[attempt]
|
|
log.warn(
|
|
`BM MCP connect failed (attempt ${attempt + 1}/${this.retryDelaysMs.length + 1}): ${getErrorMessage(err)}; retrying in ${waitMs}ms`,
|
|
)
|
|
await delay(waitMs)
|
|
}
|
|
}
|
|
|
|
throw new Error(`BM MCP unavailable: ${getErrorMessage(lastErr)}`)
|
|
}
|
|
|
|
private async ensureConnected(): Promise<Client> {
|
|
if (!this.shouldRun) {
|
|
this.shouldRun = true
|
|
}
|
|
|
|
if (this.client && this.transport) {
|
|
return this.client
|
|
}
|
|
|
|
if (!this.connectPromise) {
|
|
this.connectPromise = this.connectFresh()
|
|
}
|
|
|
|
try {
|
|
await this.connectPromise
|
|
} finally {
|
|
this.connectPromise = null
|
|
}
|
|
|
|
if (!this.client) {
|
|
throw new Error("BM MCP client was not initialized")
|
|
}
|
|
|
|
return this.client
|
|
}
|
|
|
|
private async connectFresh(): Promise<void> {
|
|
const transport = new StdioClientTransport({
|
|
command: this.bmPath,
|
|
args: ["mcp", "--transport", "stdio"],
|
|
cwd: this.cwd,
|
|
env: this.env,
|
|
stderr: "pipe",
|
|
})
|
|
|
|
const client = new Client(
|
|
{
|
|
name: "openclaw-basic-memory",
|
|
version: "0.1.0",
|
|
},
|
|
{ capabilities: {} },
|
|
)
|
|
|
|
const stderr = transport.stderr
|
|
if (stderr) {
|
|
stderr.on("data", (data: Buffer) => {
|
|
const msg = data.toString().trim()
|
|
if (msg.length > 0) {
|
|
log.debug(`[bm mcp] ${msg}`)
|
|
}
|
|
})
|
|
}
|
|
|
|
transport.onclose = () => {
|
|
if (this.transport !== transport) return
|
|
log.warn("BM MCP stdio session closed")
|
|
this.client = null
|
|
this.transport = null
|
|
}
|
|
|
|
transport.onerror = (err: unknown) => {
|
|
if (this.transport !== transport) return
|
|
log.warn(`BM MCP transport error: ${getErrorMessage(err)}`)
|
|
}
|
|
|
|
this.client = client
|
|
this.transport = transport
|
|
|
|
try {
|
|
await client.connect(transport)
|
|
const tools = await client.listTools()
|
|
this.assertRequiredTools(tools.tools.map((tool) => tool.name))
|
|
|
|
log.info(
|
|
`connected to BM MCP stdio (project=${this.project}, pid=${transport.pid ?? "unknown"})`,
|
|
)
|
|
} catch (err) {
|
|
await this.disconnectCurrent(client, transport)
|
|
if (this.client === client) {
|
|
this.client = null
|
|
}
|
|
if (this.transport === transport) {
|
|
this.transport = null
|
|
}
|
|
|
|
throw new Error(`failed to start BM MCP stdio: ${getErrorMessage(err)}`)
|
|
}
|
|
}
|
|
|
|
private assertRequiredTools(toolNames: string[]): void {
|
|
const available = new Set(toolNames)
|
|
const missing = REQUIRED_TOOLS.filter((name) => !available.has(name))
|
|
if (missing.length > 0) {
|
|
throw new Error(
|
|
`BM MCP server missing required tools: ${missing.join(", ")}`,
|
|
)
|
|
}
|
|
}
|
|
|
|
private async disconnectCurrent(
|
|
client: Client | null,
|
|
transport: StdioClientTransport | null,
|
|
): Promise<void> {
|
|
if (client) {
|
|
try {
|
|
await client.close()
|
|
} catch {
|
|
// ignore shutdown errors
|
|
}
|
|
}
|
|
|
|
if (transport) {
|
|
try {
|
|
await transport.close()
|
|
} catch {
|
|
// ignore shutdown errors
|
|
}
|
|
}
|
|
}
|
|
|
|
private async callToolRaw(
|
|
name: string,
|
|
args: Record<string, unknown>,
|
|
): Promise<unknown> {
|
|
let lastErr: unknown
|
|
|
|
for (let attempt = 0; attempt <= this.retryDelaysMs.length; attempt++) {
|
|
try {
|
|
const client = await this.ensureConnected()
|
|
const result = await client.callTool({
|
|
name,
|
|
arguments: args,
|
|
})
|
|
|
|
if (isRecord(result) && result.isError === true) {
|
|
const message = extractTextFromContent(result.content)
|
|
throw new Error(
|
|
`BM MCP tool ${name} failed${message ? `: ${message}` : ""}`,
|
|
)
|
|
}
|
|
|
|
return result
|
|
} catch (err) {
|
|
if (!isRecoverableConnectionError(err)) {
|
|
throw err
|
|
}
|
|
|
|
lastErr = err
|
|
await this.disconnectCurrent(this.client, this.transport)
|
|
this.client = null
|
|
this.transport = null
|
|
|
|
if (attempt === this.retryDelaysMs.length) {
|
|
break
|
|
}
|
|
|
|
const waitMs = this.retryDelaysMs[attempt]
|
|
log.warn(
|
|
`BM MCP call ${name} failed (attempt ${attempt + 1}/${this.retryDelaysMs.length + 1}): ${getErrorMessage(err)}; retrying in ${waitMs}ms`,
|
|
)
|
|
await delay(waitMs)
|
|
}
|
|
}
|
|
|
|
throw new Error(`BM MCP unavailable: ${getErrorMessage(lastErr)}`)
|
|
}
|
|
|
|
private async callTool(
|
|
name: string,
|
|
args: Record<string, unknown>,
|
|
): Promise<unknown> {
|
|
const result = await this.callToolRaw(name, args)
|
|
|
|
if (!isRecord(result) || result.structuredContent === undefined) {
|
|
throw new Error(`BM MCP tool ${name} returned no structured payload`)
|
|
}
|
|
|
|
const structuredPayload = result.structuredContent
|
|
if (isRecord(structuredPayload) && structuredPayload.result !== undefined) {
|
|
return structuredPayload.result
|
|
}
|
|
|
|
return structuredPayload
|
|
}
|
|
|
|
private routedProject(project?: string): string {
|
|
return project ?? this.project
|
|
}
|
|
|
|
async ensureProject(projectPath: string): Promise<void> {
|
|
const payload = await this.callTool("create_memory_project", {
|
|
project_name: this.project,
|
|
project_path: projectPath,
|
|
set_default: true,
|
|
output_format: "json",
|
|
})
|
|
|
|
if (!isRecord(payload)) {
|
|
throw new Error("invalid create_memory_project response")
|
|
}
|
|
}
|
|
|
|
async listWorkspaces(): Promise<WorkspaceResult[]> {
|
|
const payload = await this.callTool("list_workspaces", {
|
|
output_format: "json",
|
|
})
|
|
|
|
if (isRecord(payload) && Array.isArray(payload.workspaces)) {
|
|
return payload.workspaces as WorkspaceResult[]
|
|
}
|
|
|
|
throw new Error("invalid list_workspaces response")
|
|
}
|
|
|
|
async listProjects(workspace?: string): Promise<ProjectListResult[]> {
|
|
const payload = await this.callTool("list_memory_projects", {
|
|
output_format: "json",
|
|
})
|
|
|
|
if (isRecord(payload) && Array.isArray(payload.projects)) {
|
|
const projects = payload.projects as ProjectListResult[]
|
|
if (workspace) {
|
|
return projects.filter((project) =>
|
|
projectMatchesWorkspace(project, workspace),
|
|
)
|
|
}
|
|
return projects
|
|
}
|
|
|
|
throw new Error("invalid list_memory_projects response")
|
|
}
|
|
|
|
async search(
|
|
query?: string,
|
|
limit = 10,
|
|
project?: string,
|
|
metadata?: {
|
|
filters?: Record<string, unknown>
|
|
tags?: string[]
|
|
status?: string
|
|
note_types?: string[]
|
|
entity_types?: string[]
|
|
},
|
|
): Promise<SearchResult[]> {
|
|
const args: Record<string, unknown> = {
|
|
page: 1,
|
|
page_size: limit,
|
|
output_format: "json",
|
|
project: this.routedProject(project),
|
|
}
|
|
if (query) args.query = query
|
|
if (metadata?.filters) args.metadata_filters = metadata.filters
|
|
if (metadata?.tags) args.tags = metadata.tags
|
|
if (metadata?.status) args.status = metadata.status
|
|
if (metadata?.note_types) args.note_types = metadata.note_types
|
|
if (metadata?.entity_types) args.entity_types = metadata.entity_types
|
|
|
|
const payload = await this.callTool("search_notes", args)
|
|
|
|
if (!isRecord(payload) || !Array.isArray(payload.results)) {
|
|
throw new Error("invalid search_notes response")
|
|
}
|
|
|
|
return payload.results as SearchResult[]
|
|
}
|
|
|
|
async readNote(
|
|
identifier: string,
|
|
options: ReadNoteOptions = {},
|
|
project?: string,
|
|
): Promise<NoteResult> {
|
|
const args: Record<string, unknown> = {
|
|
identifier,
|
|
include_frontmatter: options.includeFrontmatter === true,
|
|
output_format: "json",
|
|
project: this.routedProject(project),
|
|
}
|
|
|
|
const payload = await this.callTool("read_note", args)
|
|
|
|
if (!isRecord(payload)) {
|
|
throw new Error("invalid read_note response")
|
|
}
|
|
|
|
const title = asString(payload.title)
|
|
const permalink = asString(payload.permalink)
|
|
const content = asString(payload.content)
|
|
const filePath = asString(payload.file_path)
|
|
|
|
if (!title || !permalink || content === null || !filePath) {
|
|
throw new Error("invalid read_note payload")
|
|
}
|
|
|
|
return {
|
|
title,
|
|
permalink,
|
|
content,
|
|
file_path: filePath,
|
|
frontmatter: isRecord(payload.frontmatter) ? payload.frontmatter : null,
|
|
}
|
|
}
|
|
|
|
async writeNote(
|
|
title: string,
|
|
content: string,
|
|
folder: string,
|
|
project?: string,
|
|
overwrite?: boolean,
|
|
): Promise<NoteResult> {
|
|
const args: Record<string, unknown> = {
|
|
title,
|
|
content,
|
|
directory: folder,
|
|
output_format: "json",
|
|
project: this.routedProject(project),
|
|
}
|
|
if (overwrite !== undefined) args.overwrite = overwrite
|
|
|
|
const payload = await this.callTool("write_note", args)
|
|
|
|
if (!isRecord(payload)) {
|
|
throw new Error("invalid write_note response")
|
|
}
|
|
|
|
if (payload.error === "NOTE_ALREADY_EXISTS") {
|
|
throw new NoteAlreadyExistsError(
|
|
asString(payload.title) ?? title,
|
|
asString(payload.permalink) ?? "",
|
|
)
|
|
}
|
|
|
|
const resultTitle = asString(payload.title)
|
|
const permalink = asString(payload.permalink)
|
|
const filePath = asString(payload.file_path)
|
|
|
|
if (!resultTitle || !permalink || !filePath) {
|
|
throw new Error("invalid write_note payload")
|
|
}
|
|
|
|
return {
|
|
title: resultTitle,
|
|
permalink,
|
|
content,
|
|
file_path: filePath,
|
|
checksum: asString(payload.checksum),
|
|
action:
|
|
payload.action === "created" || payload.action === "updated"
|
|
? payload.action
|
|
: undefined,
|
|
}
|
|
}
|
|
|
|
async buildContext(
|
|
url: string,
|
|
depth = 1,
|
|
project?: string,
|
|
): Promise<ContextResult> {
|
|
const args: Record<string, unknown> = {
|
|
url,
|
|
depth,
|
|
output_format: "json",
|
|
project: this.routedProject(project),
|
|
}
|
|
|
|
const payload = await this.callTool("build_context", args)
|
|
|
|
if (!isRecord(payload) || !Array.isArray(payload.results)) {
|
|
throw new Error("invalid build_context response")
|
|
}
|
|
|
|
return payload as unknown as ContextResult
|
|
}
|
|
|
|
async recentActivity(
|
|
timeframe = "24h",
|
|
project?: string,
|
|
): Promise<RecentResult[]> {
|
|
const args: Record<string, unknown> = {
|
|
timeframe,
|
|
output_format: "json",
|
|
project: this.routedProject(project),
|
|
}
|
|
|
|
const payload = await this.callTool("recent_activity", args)
|
|
|
|
if (Array.isArray(payload)) {
|
|
return payload as RecentResult[]
|
|
}
|
|
|
|
throw new Error("invalid recent_activity response")
|
|
}
|
|
|
|
async editNote(
|
|
identifier: string,
|
|
operation: "append" | "prepend" | "find_replace" | "replace_section",
|
|
content: string,
|
|
options: EditNoteOptions = {},
|
|
project?: string,
|
|
): Promise<EditNoteResult> {
|
|
const args: Record<string, unknown> = {
|
|
identifier,
|
|
operation,
|
|
content,
|
|
output_format: "json",
|
|
project: this.routedProject(project),
|
|
}
|
|
if (options.find_text) args.find_text = options.find_text
|
|
if (options.section) args.section = options.section
|
|
if (options.expected_replacements != null)
|
|
args.expected_replacements = options.expected_replacements
|
|
|
|
const payload = await this.callTool("edit_note", args)
|
|
|
|
if (!isRecord(payload)) {
|
|
throw new Error("invalid edit_note response")
|
|
}
|
|
|
|
const title = asString(payload.title)
|
|
const permalink = asString(payload.permalink)
|
|
const filePath = asString(payload.file_path)
|
|
|
|
if (!title || !permalink || !filePath) {
|
|
throw new Error("invalid edit_note payload")
|
|
}
|
|
|
|
return {
|
|
title,
|
|
permalink,
|
|
file_path: filePath,
|
|
operation,
|
|
checksum: asString(payload.checksum),
|
|
}
|
|
}
|
|
|
|
async deleteNote(
|
|
identifier: string,
|
|
project?: string,
|
|
): Promise<{ title: string; permalink: string; file_path: string }> {
|
|
const args: Record<string, unknown> = {
|
|
identifier,
|
|
output_format: "json",
|
|
project: this.routedProject(project),
|
|
}
|
|
|
|
const payload = await this.callTool("delete_note", args)
|
|
|
|
if (!isRecord(payload)) {
|
|
throw new Error("invalid delete_note response")
|
|
}
|
|
|
|
if (payload.deleted !== true) {
|
|
throw new Error(`delete_note did not delete "${identifier}"`)
|
|
}
|
|
|
|
return {
|
|
title: asString(payload.title) ?? identifier,
|
|
permalink: asString(payload.permalink) ?? identifier,
|
|
file_path: asString(payload.file_path) ?? identifier,
|
|
}
|
|
}
|
|
|
|
async moveNote(
|
|
identifier: string,
|
|
newFolder: string,
|
|
project?: string,
|
|
): Promise<NoteResult> {
|
|
const args: Record<string, unknown> = {
|
|
identifier,
|
|
destination_folder: newFolder,
|
|
output_format: "json",
|
|
project: this.routedProject(project),
|
|
}
|
|
|
|
const payload = await this.callTool("move_note", args)
|
|
|
|
if (!isRecord(payload)) {
|
|
throw new Error("invalid move_note response")
|
|
}
|
|
|
|
if (payload.moved !== true) {
|
|
throw new Error(
|
|
asString(payload.error) ??
|
|
`move_note did not move "${identifier}" to "${newFolder}"`,
|
|
)
|
|
}
|
|
|
|
return {
|
|
title: asString(payload.title) ?? identifier,
|
|
permalink: asString(payload.permalink) ?? identifier,
|
|
content: "",
|
|
file_path: asString(payload.file_path) ?? "",
|
|
}
|
|
}
|
|
|
|
async schemaValidate(
|
|
noteType?: string,
|
|
identifier?: string,
|
|
project?: string,
|
|
): Promise<SchemaValidationResult> {
|
|
const args: Record<string, unknown> = {
|
|
output_format: "json",
|
|
project: this.routedProject(project),
|
|
}
|
|
if (noteType) args.note_type = noteType
|
|
if (identifier) args.identifier = identifier
|
|
|
|
const payload = await this.callTool("schema_validate", args)
|
|
|
|
if (!isRecord(payload)) {
|
|
throw new Error("invalid schema_validate response")
|
|
}
|
|
|
|
return payload as unknown as SchemaValidationResult
|
|
}
|
|
|
|
async schemaInfer(
|
|
noteType: string,
|
|
threshold = 0.25,
|
|
project?: string,
|
|
): Promise<SchemaInferResult> {
|
|
const args: Record<string, unknown> = {
|
|
note_type: noteType,
|
|
threshold,
|
|
output_format: "json",
|
|
project: this.routedProject(project),
|
|
}
|
|
|
|
const payload = await this.callTool("schema_infer", args)
|
|
|
|
if (!isRecord(payload)) {
|
|
throw new Error("invalid schema_infer response")
|
|
}
|
|
|
|
return payload as unknown as SchemaInferResult
|
|
}
|
|
|
|
async schemaDiff(
|
|
noteType: string,
|
|
project?: string,
|
|
): Promise<SchemaDiffResult> {
|
|
const args: Record<string, unknown> = {
|
|
note_type: noteType,
|
|
output_format: "json",
|
|
project: this.routedProject(project),
|
|
}
|
|
|
|
const payload = await this.callTool("schema_diff", args)
|
|
|
|
if (!isRecord(payload)) {
|
|
throw new Error("invalid schema_diff response")
|
|
}
|
|
|
|
return payload as unknown as SchemaDiffResult
|
|
}
|
|
|
|
async indexConversation(
|
|
userMessage: string,
|
|
assistantResponse: string,
|
|
): Promise<void> {
|
|
const now = new Date()
|
|
const dateStr = now.toISOString().split("T")[0]
|
|
const timeStr = now.toTimeString().slice(0, 5)
|
|
const title = `conversations-${dateStr}`
|
|
|
|
const entry = [
|
|
`### ${timeStr}`,
|
|
"",
|
|
"**User:**",
|
|
userMessage,
|
|
"",
|
|
"**Assistant:**",
|
|
assistantResponse,
|
|
"",
|
|
"---",
|
|
].join("\n")
|
|
|
|
try {
|
|
await this.editNote(title, "append", entry)
|
|
log.debug(`appended conversation to: ${title}`)
|
|
return
|
|
} catch (err) {
|
|
if (!isNoteNotFoundError(err)) {
|
|
log.error(`conversation append failed: ${getErrorMessage(err)}`, err)
|
|
throw err
|
|
}
|
|
|
|
log.debug(
|
|
`conversation note missing, will create: ${getErrorMessage(err)}`,
|
|
)
|
|
}
|
|
|
|
// Create the note with frontmatter and first entry
|
|
const content = [
|
|
"---",
|
|
`title: Conversations ${dateStr}`,
|
|
"type: Conversation",
|
|
`date: "${dateStr}"`,
|
|
"---",
|
|
"",
|
|
`# Conversations ${dateStr}`,
|
|
"",
|
|
entry,
|
|
].join("\n")
|
|
|
|
try {
|
|
await this.writeNote(title, content, "conversations")
|
|
log.debug(`created conversation note: ${title}`)
|
|
} catch (err) {
|
|
log.error("conversation index failed", err)
|
|
}
|
|
}
|
|
|
|
getProject(): string {
|
|
return this.project
|
|
}
|
|
}
|