diff --git a/config.ts b/config.ts index 09d8d06..30ea833 100644 --- a/config.ts +++ b/config.ts @@ -6,6 +6,11 @@ export type CloudConfig = { api_key: string } +export type DashboardConfig = { + enabled: boolean + port: number +} + export type BasicMemoryConfig = { project: string bmPath: string @@ -18,6 +23,7 @@ export type BasicMemoryConfig = { recallPrompt: string debug: boolean cloud?: CloudConfig + dashboard: DashboardConfig } const ALLOWED_KEYS = [ @@ -37,6 +43,7 @@ const ALLOWED_KEYS = [ "recall_prompt", "debug", "cloud", + "dashboard", ] function assertAllowedKeys( @@ -106,6 +113,22 @@ export function parseConfig(raw: unknown): BasicMemoryConfig { } } + let dashboard: DashboardConfig = { enabled: false, port: 3838 } + if ( + cfg.dashboard && + typeof cfg.dashboard === "object" && + !Array.isArray(cfg.dashboard) + ) { + const d = cfg.dashboard as Record + dashboard = { + enabled: typeof d.enabled === "boolean" ? d.enabled : false, + port: + typeof d.port === "number" && d.port > 0 && d.port < 65536 + ? d.port + : 3838, + } + } + return { project: typeof cfg.project === "string" && cfg.project.length > 0 @@ -144,6 +167,7 @@ export function parseConfig(raw: unknown): BasicMemoryConfig { : "Check for active tasks and recent activity. Summarize anything relevant to the current session.", debug: typeof cfg.debug === "boolean" ? cfg.debug : false, cloud, + dashboard, } } diff --git a/dashboard/index.html b/dashboard/index.html new file mode 100644 index 0000000..92b6526 --- /dev/null +++ b/dashboard/index.html @@ -0,0 +1,182 @@ + + + + + +Memory Dashboard + + + + +
+
-
Total Notes
+
-
Active Tasks
+
-
Completed
+
-
Explorations
+
+ +
+
+
+
Active
+
Blocked
+
Done
+
Abandoned
+
+
+ +
+ +
+ + + + diff --git a/dashboard/server.test.ts b/dashboard/server.test.ts new file mode 100644 index 0000000..5021462 --- /dev/null +++ b/dashboard/server.test.ts @@ -0,0 +1,186 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + jest, +} from "bun:test" +import type { Server } from "node:http" +import type { BmClient } from "../bm-client.ts" +import { createDashboardServer } from "./server.ts" + +describe("dashboard server", () => { + let server: Server + let mockClient: BmClient + let baseUrl: string + + beforeAll(async () => { + mockClient = { + search: jest.fn().mockResolvedValue([]), + recentActivity: jest.fn().mockResolvedValue([]), + readNote: jest.fn().mockResolvedValue({ + title: "Test", + permalink: "test", + content: "", + file_path: "test.md", + frontmatter: { + status: "active", + current_step: 1, + total_steps: 3, + assigned_to: "claw", + }, + }), + } as any + + server = createDashboardServer({ port: 0, client: mockClient }) + await new Promise((resolve) => { + server.listen(0, () => resolve()) + }) + const addr = server.address() + const port = typeof addr === "object" && addr ? addr.port : 0 + baseUrl = `http://localhost:${port}` + }) + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())) + }) + + beforeEach(() => { + ;(mockClient.search as any).mockClear() + ;(mockClient.recentActivity as any).mockClear() + ;(mockClient.readNote as any).mockClear() + }) + + it("serves index.html at /", async () => { + const res = await fetch(`${baseUrl}/`) + expect(res.status).toBe(200) + expect(res.headers.get("content-type")).toContain("text/html") + const body = await res.text() + expect(body).toContain("Memory Dashboard") + }) + + it("returns 404 for unknown routes", async () => { + const res = await fetch(`${baseUrl}/api/unknown`) + expect(res.status).toBe(404) + }) + + it("GET /api/tasks calls search with type:Task", async () => { + ;(mockClient.search as any).mockResolvedValue([ + { + title: "My Task", + permalink: "tasks/my-task", + content: "do stuff", + file_path: "tasks/my-task.md", + }, + ]) + + const res = await fetch(`${baseUrl}/api/tasks`) + expect(res.status).toBe(200) + const data = await res.json() + expect(Array.isArray(data)).toBe(true) + expect(data.length).toBe(1) + expect(data[0].title).toBe("My Task") + expect(data[0].frontmatter).toBeDefined() + + expect(mockClient.search).toHaveBeenCalledWith("type:Task", 50, undefined, { + filters: { type: "Task" }, + }) + }) + + it("GET /api/activity calls recentActivity", async () => { + ;(mockClient.recentActivity as any).mockResolvedValue([ + { + title: "Daily Note", + permalink: "2026-02-24", + file_path: "memory/2026-02-24.md", + created_at: "2026-02-24T12:00:00Z", + }, + ]) + + const res = await fetch(`${baseUrl}/api/activity`) + expect(res.status).toBe(200) + const data = await res.json() + expect(data.length).toBe(1) + expect(data[0].title).toBe("Daily Note") + expect(mockClient.recentActivity).toHaveBeenCalledWith("24h") + }) + + it("GET /api/explorations searches for type:Exploration", async () => { + ;(mockClient.search as any).mockResolvedValue([]) + + const res = await fetch(`${baseUrl}/api/explorations`) + expect(res.status).toBe(200) + expect(mockClient.search).toHaveBeenCalledWith( + "type:Exploration", + 50, + undefined, + { + filters: { type: "Exploration" }, + }, + ) + }) + + it("GET /api/notes/daily searches for today's date", async () => { + const today = new Date().toISOString().split("T")[0] + ;(mockClient.search as any).mockResolvedValue([ + { + title: today, + permalink: today, + content: "daily stuff", + file_path: `memory/${today}.md`, + }, + ]) + + const res = await fetch(`${baseUrl}/api/notes/daily`) + expect(res.status).toBe(200) + const data = await res.json() + expect(data.length).toBe(1) + expect(mockClient.search).toHaveBeenCalledWith(today, 5) + }) + + it("GET /api/stats returns counts", async () => { + ;(mockClient.recentActivity as any).mockResolvedValue([{}, {}, {}]) + ;(mockClient.search as any).mockImplementation(async (query: string) => { + if (query === "type:Task") + return [ + { title: "T1", permalink: "t1", content: "", file_path: "t1.md" }, + { title: "T2", permalink: "t2", content: "", file_path: "t2.md" }, + ] + return [{ title: "E1", permalink: "e1", content: "", file_path: "e1.md" }] + }) + ;(mockClient.readNote as any) + .mockResolvedValueOnce({ + title: "T1", + permalink: "t1", + content: "", + file_path: "t1.md", + frontmatter: { status: "active" }, + }) + .mockResolvedValueOnce({ + title: "T2", + permalink: "t2", + content: "", + file_path: "t2.md", + frontmatter: { status: "done" }, + }) + + const res = await fetch(`${baseUrl}/api/stats`) + expect(res.status).toBe(200) + const data = await res.json() + expect(data.totalNotes).toBe(3) + expect(data.activeTasks).toBe(1) + expect(data.completedTasks).toBe(1) + expect(data.explorations).toBe(1) + }) + + it("returns 500 on client error", async () => { + ;(mockClient.search as any).mockRejectedValue(new Error("MCP down")) + + const res = await fetch(`${baseUrl}/api/tasks`) + expect(res.status).toBe(500) + const data = await res.json() + expect(data.error).toBe("MCP down") + }) +}) diff --git a/dashboard/server.ts b/dashboard/server.ts new file mode 100644 index 0000000..8bdba12 --- /dev/null +++ b/dashboard/server.ts @@ -0,0 +1,146 @@ +import { readFileSync } from "node:fs" +import { + createServer, + type IncomingMessage, + type Server, + type ServerResponse, +} from "node:http" +import { join } from "node:path" +import type { BmClient, SearchResult } from "../bm-client.ts" + +export interface DashboardServerOptions { + port: number + client: BmClient +} + +export function createDashboardServer(options: DashboardServerOptions): Server { + const { client, port } = options + + const indexHtml = readFileSync( + join(import.meta.dirname ?? __dirname, "index.html"), + "utf-8", + ) + + const server = createServer( + async (req: IncomingMessage, res: ServerResponse) => { + const url = new URL(req.url ?? "/", `http://localhost:${port}`) + const path = url.pathname + + try { + if (path === "/" && req.method === "GET") { + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) + res.end(indexHtml) + return + } + + if (path === "/api/tasks" && req.method === "GET") { + const results = await client.search("type:Task", 50, undefined, { + filters: { type: "Task" }, + }) + const tasks = await enrichWithFrontmatter(client, results) + json(res, tasks) + return + } + + if (path === "/api/activity" && req.method === "GET") { + const results = await client.recentActivity("24h") + json(res, results) + return + } + + if (path === "/api/explorations" && req.method === "GET") { + const results = await client.search( + "type:Exploration", + 50, + undefined, + { + filters: { type: "Exploration" }, + }, + ) + json(res, results) + return + } + + if (path === "/api/notes/daily" && req.method === "GET") { + const today = new Date().toISOString().split("T")[0] + const results = await client.search(today, 5) + const daily = results.filter((r) => r.title.includes(today)) + json(res, daily) + return + } + + if (path === "/api/stats" && req.method === "GET") { + const [allNotes, tasks, explorations] = await Promise.all([ + client.recentActivity("720h").catch(() => []), + client + .search("type:Task", 100, undefined, { + filters: { type: "Task" }, + }) + .catch(() => []), + client + .search("type:Exploration", 100, undefined, { + filters: { type: "Exploration" }, + }) + .catch(() => []), + ]) + + const tasksWithFm = await enrichWithFrontmatter(client, tasks) + const active = tasksWithFm.filter( + (t) => t.frontmatter?.status === "active", + ).length + const completed = tasksWithFm.filter( + (t) => + t.frontmatter?.status === "done" || + t.frontmatter?.status === "completed", + ).length + + json(res, { + totalNotes: allNotes.length, + activeTasks: active, + completedTasks: completed, + explorations: explorations.length, + }) + return + } + + res.writeHead(404, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ error: "not found" })) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err) + res.writeHead(500, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ error: message })) + } + }, + ) + + return server +} + +function json(res: ServerResponse, data: unknown): void { + res.writeHead(200, { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + }) + res.end(JSON.stringify(data)) +} + +async function enrichWithFrontmatter( + client: BmClient, + results: SearchResult[], +): Promise< + Array | null }> +> { + const enriched = await Promise.all( + results.map(async (r) => { + try { + const note = await client.readNote(r.permalink, { + includeFrontmatter: true, + }) + return { ...r, frontmatter: note.frontmatter ?? null } + } catch { + return { ...r, frontmatter: null } + } + }), + ) + return enriched +} diff --git a/index.ts b/index.ts index 676c832..a6c950f 100644 --- a/index.ts +++ b/index.ts @@ -1,3 +1,4 @@ +import type { Server } from "node:http" import type { OpenClawPluginApi } from "openclaw/plugin-sdk" import { BmClient } from "./bm-client.ts" import { registerCli } from "./commands/cli.ts" @@ -8,6 +9,7 @@ import { parseConfig, resolveProjectPath, } from "./config.ts" +import { createDashboardServer } from "./dashboard/server.ts" import { buildCaptureHandler } from "./hooks/capture.ts" import { buildRecallHandler } from "./hooks/recall.ts" import { initLogger, log } from "./logger.ts" @@ -80,6 +82,8 @@ export default { registerCli(api, client, cfg) // --- Service lifecycle --- + let dashboardServer: Server | undefined + api.registerService({ id: "openclaw-basic-memory", start: async (ctx: { config?: unknown; workspaceDir?: string }) => { @@ -108,10 +112,30 @@ export default { setWorkspaceDir(workspace) + // Start dashboard if enabled + if (cfg.dashboard.enabled) { + dashboardServer = createDashboardServer({ + port: cfg.dashboard.port, + client, + }) + dashboardServer.listen(cfg.dashboard.port, () => { + log.info( + `dashboard running at http://localhost:${cfg.dashboard.port}`, + ) + }) + } + log.info("connected — BM MCP stdio session running") }, stop: async () => { log.info("stopping BM MCP session...") + if (dashboardServer) { + await new Promise((resolve) => + dashboardServer?.close(() => resolve()), + ) + dashboardServer = undefined + log.info("dashboard stopped") + } await client.stop() log.info("stopped") }, diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 768ba3c..f107331 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -56,6 +56,11 @@ "label": "Cloud Backend", "help": "Optional cloud backend config (url + api_key). If present, uses cloud instead of local BM.", "advanced": true + }, + "dashboard": { + "label": "Dashboard", + "help": "Web dashboard for visualizing the knowledge graph (enabled, port)", + "advanced": true } }, "configSchema": { @@ -76,6 +81,13 @@ "url": { "type": "string" }, "api_key": { "type": "string" } } + }, + "dashboard": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "port": { "type": "number", "minimum": 1, "maximum": 65535 } + } } }, "required": []