Files

1467 lines
49 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Memory Viewer — API Server (Hono)
*
* Provides REST endpoints for browsing, reading, editing, and searching
* Markdown files, plus a WebSocket channel that pushes live file-change
* notifications to connected clients.
*/
import { Hono } from "hono";
import { compress } from "hono/compress";
import { cors } from "hono/cors";
import { serveStatic } from "@hono/node-server/serve-static";
import { createNodeWebSocket } from "@hono/node-ws";
import { serve } from "@hono/node-server";
import fs from "fs";
import path from "path";
import os from "os";
import Database from "better-sqlite3";
import { exec as execCallback } from "child_process";
import util from "util";
import { watch } from "chokidar";
import type { WSContext } from "hono/ws";
const exec = util.promisify(execCallback);
// ---------------------------------------------------------------------------
// Agent Config Types
// ---------------------------------------------------------------------------
interface AgentConfig {
id: string;
name: string;
workspace?: string;
agentDir?: string;
identity?: {
name?: string;
emoji?: string;
};
skills?: string[];
}
interface AgentsConfig {
defaults: {
workspace?: string;
};
list: AgentConfig[];
}
interface OpenClawConfig {
agents?: AgentsConfig;
}
interface AgentInfo {
id: string;
name: string;
workspace: string;
emoji: string;
skills?: string[];
}
// ---------------------------------------------------------------------------
// Settings
// ---------------------------------------------------------------------------
const SETTINGS_DIR = path.join(os.homedir(), ".config", "memory-viewer");
const SETTINGS_FILE = path.join(SETTINGS_DIR, "settings.json");
interface AppSettings {
embedding: {
enabled: boolean;
apiUrl: string;
apiKey: string;
model: string;
};
pluginsDir: string;
}
const DEFAULT_SETTINGS: AppSettings = {
embedding: { enabled: false, apiUrl: "", apiKey: "", model: "" },
pluginsDir: "",
};
function loadSettings(): AppSettings {
try {
const raw = fs.readFileSync(SETTINGS_FILE, "utf-8");
return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) };
} catch {
return { ...DEFAULT_SETTINGS };
}
}
function saveSettings(s: AppSettings): void {
fs.mkdirSync(SETTINGS_DIR, { recursive: true });
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(s, null, 2));
}
let appSettings = loadSettings();
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
const PORT = Number(process.env.PORT) || 3001;
const DEFAULT_WORKSPACE = process.env.WORKSPACE_DIR || path.join(os.homedir(), "clawd");
const STATIC_DIR = process.env.STATIC_DIR || path.join(import.meta.dirname, "..", "dist");
const app = new Hono();
export { app }; // Export for testing
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
app.use("*", compress());
app.use("*", cors({ origin: "*" }));
// ---------------------------------------------------------------------------
// Agent Management
// ---------------------------------------------------------------------------
const OPENCLAW_CONFIG_PATH = path.join(os.homedir(), ".openclaw", "openclaw.json");
function loadOpenClawConfig(): OpenClawConfig | null {
try {
if (fs.existsSync(OPENCLAW_CONFIG_PATH)) {
const raw = fs.readFileSync(OPENCLAW_CONFIG_PATH, "utf-8");
return JSON.parse(raw);
}
} catch (e) {
console.error("Failed to load OpenClaw config:", e);
}
return null;
}
function getAgentWorkspace(agentConfig: AgentConfig, defaults: { workspace?: string }): string {
// Priority: workspace > agentDir > defaults.workspace > DEFAULT_WORKSPACE
if (agentConfig.workspace) {
return agentConfig.workspace;
}
if (agentConfig.agentDir) {
return agentConfig.agentDir;
}
if (defaults.workspace) {
return defaults.workspace;
}
return DEFAULT_WORKSPACE;
}
function getAgents(): AgentInfo[] {
const config = loadOpenClawConfig();
if (!config?.agents?.list) {
// Return default agent if no config
return [{
id: "default",
name: "Default Agent",
workspace: DEFAULT_WORKSPACE,
emoji: "🤖",
}];
}
const defaults = config.agents.defaults || {};
return config.agents.list.map((agent) => ({
id: agent.id,
name: agent.name || agent.id,
workspace: getAgentWorkspace(agent, defaults),
emoji: agent.identity?.emoji || "🤖",
skills: agent.skills || undefined,
}));
}
function getAgentById(agentId: string): AgentInfo | null {
const agents = getAgents();
return agents.find((a) => a.id === agentId) || null;
}
// Get workspace for a given agent ID
function getWorkspaceForAgent(agentId: string | null | undefined): string {
if (!agentId || agentId === "default") {
// Try to find "default" agent in config, otherwise use default workspace
const agent = getAgentById("default");
if (agent) return agent.workspace;
return DEFAULT_WORKSPACE;
}
const agent = getAgentById(agentId);
if (agent) return agent.workspace;
// Fallback to default workspace if agent not found
return DEFAULT_WORKSPACE;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function safePath(filePath: string | undefined | null, workspace: string): string | null {
if (!filePath || filePath.includes("..") || !filePath.endsWith(".md")) return null;
const full = path.resolve(workspace, filePath);
if (!full.startsWith(path.resolve(workspace))) return null;
return full;
}
interface TreeNode {
name: string;
type: "file" | "dir";
path: string;
children?: TreeNode[];
}
function scanDir(dir: string, prefix = ""): TreeNode[] {
const result: TreeNode[] = [];
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return result;
}
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
const children = scanDir(path.join(dir, entry.name), relPath);
if (children.length > 0) {
result.push({ name: entry.name, type: "dir", path: relPath, children });
}
} else if (entry.name.endsWith(".md")) {
result.push({ name: entry.name, type: "file", path: relPath });
}
}
return result;
}
function collectMdFiles(dir: string, prefix = ""): string[] {
const files: string[] = [];
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return files;
}
for (const entry of entries) {
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
files.push(...collectMdFiles(path.join(dir, entry.name), relPath));
} else if (entry.name.endsWith(".md")) {
files.push(relPath);
}
}
return files;
}
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// REST API
// ---------------------------------------------------------------------------
// Get agent from query parameter
function getAgentFromQuery(c: any): { agentId: string; workspace: string } {
const agentId = c.req.query("agent") || "default";
const workspace = getWorkspaceForAgent(agentId);
return { agentId, workspace };
}
// Agents API
app.get("/api/agents", (c) => {
return c.json(getAgents());
});
app.get("/api/skills", (c) => {
const { workspace } = getAgentFromQuery(c);
const skillsDir = path.join(workspace, "skills");
const results: { id: string; name: string; description: string; path: string }[] = [];
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(skillsDir, { withFileTypes: true });
} catch {
return c.json([]);
}
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
const skillMd = path.join(skillsDir, entry.name, "SKILL.md");
if (!fs.existsSync(skillMd)) continue;
const content = fs.readFileSync(skillMd, "utf-8");
let name = entry.name;
let description = "";
const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
if (fmMatch) {
const nameMatch = fmMatch[1].match(/^name:\s*(.+)/m);
const descMatch = fmMatch[1].match(/^description:\s*(.+)/m);
if (nameMatch) name = nameMatch[1].trim().replace(/^["']|["']$/g, "");
if (descMatch) description = descMatch[1].trim().replace(/^["']|["']$/g, "");
}
results.push({ id: entry.name, name, description, path: `skills/${entry.name}/SKILL.md` });
}
return c.json(results);
});
app.get("/api/files", (c) => {
const { workspace } = getAgentFromQuery(c);
return c.json(scanDir(workspace));
});
app.get("/api/file", (c) => {
const { workspace } = getAgentFromQuery(c);
const full = safePath(c.req.query("path"), workspace);
if (!full) return c.json({ error: "Invalid path" }, 400);
if (!fs.existsSync(full)) return c.json({ error: "Not found" }, 404);
const content = fs.readFileSync(full, "utf-8");
const stat = fs.statSync(full);
return c.json({ content, mtime: stat.mtime, size: stat.size });
});
app.put("/api/file", async (c) => {
const { workspace } = getAgentFromQuery(c);
const { path: filePath, content, expectedMtime } = await c.req.json();
const full = safePath(filePath, workspace);
if (!full) return c.json({ error: "Invalid path" }, 400);
if (expectedMtime && fs.existsSync(full)) {
const currentMtime = fs.statSync(full).mtime.toISOString();
if (currentMtime !== expectedMtime) {
const currentContent = fs.readFileSync(full, "utf-8");
return c.json({
error: "conflict",
message: "File was modified since you started editing",
serverMtime: currentMtime,
serverContent: currentContent,
}, 409);
}
}
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content, "utf-8");
const stat = fs.statSync(full);
return c.json({ ok: true, mtime: stat.mtime });
});
app.get("/api/resolve-wikilink", (c) => {
const { workspace } = getAgentFromQuery(c);
const link = (c.req.query("link") || "").trim();
if (!link) return c.json({ error: "Missing link parameter" }, 400);
const allFiles = collectMdFiles(workspace);
// Try exact path match first
const exactPath = link.endsWith(".md") ? link : `${link}.md`;
if (allFiles.includes(exactPath)) {
return c.json({ found: true, path: exactPath });
}
// Try case-insensitive exact path
const exactLower = exactPath.toLowerCase();
const ciMatch = allFiles.find((f) => f.toLowerCase() === exactLower);
if (ciMatch) {
return c.json({ found: true, path: ciMatch });
}
// Try filename-only match (fuzzy)
const linkLower = link.toLowerCase();
const byName = allFiles.find((f) => {
const name = path.basename(f, ".md");
return name.toLowerCase() === linkLower;
});
if (byName) {
return c.json({ found: true, path: byName });
}
return c.json({ found: false, path: null });
});
app.get("/api/search", (c) => {
const { workspace } = getAgentFromQuery(c);
const q = (c.req.query("q") || "").trim().toLowerCase();
if (!q || q.length < 2) return c.json([]);
const files = collectMdFiles(workspace);
const results: { path: string; matches: { line: number; text: string }[] }[] = [];
for (const relPath of files) {
const full = path.join(workspace, relPath);
let content: string;
try {
content = fs.readFileSync(full, "utf-8");
} catch {
continue;
}
const lines = content.split("\n");
const matches: { line: number; text: string }[] = [];
for (let i = 0; i < lines.length; i++) {
if (lines[i].toLowerCase().includes(q)) {
matches.push({ line: i + 1, text: lines[i].substring(0, 200) });
if (matches.length >= 5) break;
}
}
if (matches.length > 0) results.push({ path: relPath, matches });
if (results.length >= 50) break;
}
return c.json(results);
});
// QMD availability detection — cached at startup
let qmdAvailable: boolean | null = null;
let qmdHasVectors = false;
async function detectQmd(): Promise<void> {
try {
const { stdout } = await exec(
`export PATH="$HOME/.bun/bin:$PATH" && qmd status 2>/dev/null`,
{ timeout: 5000 }
);
qmdAvailable = stdout.includes("Documents");
qmdHasVectors = /Vectors:\s*[1-9]/.test(stdout);
console.log(`🔍 QMD: ${qmdAvailable ? "available" : "not found"}${qmdHasVectors ? " (vectors ready)" : ""}`);
} catch {
qmdAvailable = false;
qmdHasVectors = false;
console.log("🔍 QMD: not installed");
}
}
// Run detection on startup
detectQmd();
// External plugins directory
const PLUGINS_DIR = appSettings.pluginsDir || process.env.PLUGINS_DIR || "";
app.get("/api/plugins", (c) => {
if (!PLUGINS_DIR || !fs.existsSync(PLUGINS_DIR)) return c.json([]);
try {
const plugins: { id: string; name: string; entry: string }[] = [];
for (const dir of fs.readdirSync(PLUGINS_DIR, { withFileTypes: true })) {
if (!dir.isDirectory()) continue;
const manifestPath = path.join(PLUGINS_DIR, dir.name, "plugin.json");
if (fs.existsSync(manifestPath)) {
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
plugins.push({ id: manifest.id || dir.name, name: manifest.name || dir.name, entry: manifest.entry || "index.js" });
}
}
return c.json(plugins);
} catch { return c.json([]); }
});
// Serve plugin files
app.get("/api/plugins/:id/*", (c) => {
if (!PLUGINS_DIR) return c.text("No plugins dir", 404);
const pluginId = c.req.param("id");
const filePath = c.req.path.replace(`/api/plugins/${pluginId}/`, "");
const full = path.join(PLUGINS_DIR, pluginId, filePath);
if (!full.startsWith(path.join(PLUGINS_DIR, pluginId))) return c.text("Forbidden", 403);
if (!fs.existsSync(full)) return c.text("Not found", 404);
const content = fs.readFileSync(full, "utf-8");
const ext = path.extname(full);
const ct = ext === ".js" ? "application/javascript" : ext === ".css" ? "text/css" : "text/plain";
return c.text(content, 200, { "Content-Type": ct });
});
app.get("/api/capabilities", (c) => {
const embeddingReady = appSettings.embedding.enabled && !!appSettings.embedding.apiUrl && !!appSettings.embedding.apiKey;
return c.json({
qmd: qmdAvailable === true,
qmdBm25: qmdAvailable === true,
qmdVector: qmdHasVectors || embeddingReady,
embeddingApi: embeddingReady,
});
});
// Settings API
app.get("/api/settings", (c) => {
return c.json({
embedding: {
enabled: appSettings.embedding.enabled,
apiUrl: appSettings.embedding.apiUrl,
apiKeySet: !!appSettings.embedding.apiKey,
model: appSettings.embedding.model,
},
});
});
app.put("/api/settings", async (c) => {
const body = await c.req.json();
if (body.embedding) {
appSettings.embedding.enabled = body.embedding.enabled ?? appSettings.embedding.enabled;
appSettings.embedding.apiUrl = body.embedding.apiUrl ?? appSettings.embedding.apiUrl;
appSettings.embedding.model = body.embedding.model ?? appSettings.embedding.model;
if (body.embedding.apiKey) {
appSettings.embedding.apiKey = body.embedding.apiKey;
}
}
saveSettings(appSettings);
return c.json({ ok: true });
});
app.get("/api/settings/embedding-stats", (c) => {
const { workspace } = getAgentFromQuery(c);
try {
const db = getEmbeddingsDb();
const model = appSettings.embedding.model || "text-embedding-3-small";
const total = (db.prepare("SELECT COUNT(*) as n FROM embeddings WHERE model = ?").get(model) as any)?.n || 0;
const allFiles = collectMdFiles(workspace).length;
const dbSize = fs.existsSync(EMBEDDINGS_DB_PATH) ? fs.statSync(EMBEDDINGS_DB_PATH).size : 0;
return c.json({
cachedFiles: total,
totalFiles: allFiles,
coverage: allFiles > 0 ? Math.round((total / allFiles) * 100) : 0,
dbSize,
model,
});
} catch {
return c.json({ cachedFiles: 0, totalFiles: 0, coverage: 0, dbSize: 0, model: "" });
}
});
app.post("/api/settings/test-embedding", async (c) => {
const { apiUrl, apiKey, model } = await c.req.json();
const url = apiUrl || appSettings.embedding.apiUrl;
const key = apiKey || appSettings.embedding.apiKey;
const mdl = model || appSettings.embedding.model || "text-embedding-3-small";
if (!url) return c.json({ ok: false, message: "API URL is required" });
try {
const resp = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(key ? { Authorization: `Bearer ${key}` } : {}),
},
body: JSON.stringify({ input: "test", model: mdl }),
signal: AbortSignal.timeout(10000),
});
const data: any = await resp.json();
if (data.data?.[0]?.embedding) {
const dim = data.data[0].embedding.length;
return c.json({ ok: true, message: `${dim}维向量` });
}
return c.json({ ok: false, message: data.error?.message || "Unexpected response" });
} catch (e: any) {
return c.json({ ok: false, message: e.message || "Connection failed" });
}
});
function qmdUriToRelPath(uri: string): string {
// qmd://clawd-memory/memory/survival.md → memory/survival.md
// qmd://clawd-root/MEMORY.md → MEMORY.md
// All collections index from workspace root, so just strip the qmd://collection/ prefix
const match = uri.match(/^qmd:\/\/[^/]+\/(.+)$/);
return match ? match[1] : uri;
}
// Embedding API helper
async function getEmbedding(text: string): Promise<number[] | null> {
const { apiUrl, apiKey, model } = appSettings.embedding;
if (!apiUrl || !apiKey) return null;
try {
const resp = await fetch(apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({ input: text, model: model || "text-embedding-3-small" }),
signal: AbortSignal.timeout(10000),
});
const data: any = await resp.json();
return data.data?.[0]?.embedding ?? null;
} catch {
return null;
}
}
function cosineSim(a: number[], b: number[]): number {
let dot = 0, na = 0, nb = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
na += a[i] * a[i];
nb += b[i] * b[i];
}
return dot / (Math.sqrt(na) * Math.sqrt(nb) + 1e-10);
}
// SQLite embedding cache — persistent across restarts
const EMBEDDINGS_DB_PATH = path.join(SETTINGS_DIR, "embeddings.sqlite");
let embeddingsDb: Database.Database | null = null;
function getEmbeddingsDb(): Database.Database {
if (!embeddingsDb) {
fs.mkdirSync(SETTINGS_DIR, { recursive: true });
embeddingsDb = new Database(EMBEDDINGS_DB_PATH);
embeddingsDb.pragma("journal_mode = WAL");
embeddingsDb.exec(`
CREATE TABLE IF NOT EXISTS embeddings (
file_path TEXT PRIMARY KEY,
mtime REAL NOT NULL,
model TEXT NOT NULL,
embedding BLOB NOT NULL
)
`);
}
return embeddingsDb;
}
function getCachedEmbedding(filePath: string, mtime: number): number[] | null {
const db = getEmbeddingsDb();
const model = appSettings.embedding.model || "text-embedding-3-small";
const row = db.prepare("SELECT embedding FROM embeddings WHERE file_path = ? AND mtime = ? AND model = ?").get(filePath, mtime, model) as any;
if (!row) return null;
return Array.from(new Float64Array(row.embedding.buffer, row.embedding.byteOffset, row.embedding.byteLength / 8));
}
function setCachedEmbedding(filePath: string, mtime: number, embedding: number[]): void {
const db = getEmbeddingsDb();
const model = appSettings.embedding.model || "text-embedding-3-small";
const buf = Buffer.from(new Float64Array(embedding).buffer);
db.prepare("INSERT OR REPLACE INTO embeddings (file_path, mtime, model, embedding) VALUES (?, ?, ?, ?)").run(filePath, mtime, model, buf);
}
async function getFileEmbeddings(files: string[], workspace: string): Promise<Map<string, number[]>> {
const result = new Map<string, number[]>();
const toEmbed: string[] = [];
for (const relPath of files) {
const full = path.join(workspace, relPath);
try {
const stat = fs.statSync(full);
const cached = getCachedEmbedding(relPath, stat.mtimeMs);
if (cached) {
result.set(relPath, cached);
} else {
toEmbed.push(relPath);
}
} catch { /* skip */ }
}
// Embed uncached files (max 20 per request to stay responsive)
for (const relPath of toEmbed.slice(0, 20)) {
const full = path.join(workspace, relPath);
try {
const content = fs.readFileSync(full, "utf-8").substring(0, 2000);
const emb = await getEmbedding(content);
if (emb) {
const stat = fs.statSync(full);
setCachedEmbedding(relPath, stat.mtimeMs, emb);
result.set(relPath, emb);
}
} catch { /* skip */ }
}
return result;
}
app.get("/api/semantic-search", async (c) => {
const { workspace } = getAgentFromQuery(c);
const q = (c.req.query("q") || "").trim();
const mode = c.req.query("mode") || "bm25"; // bm25 | vector
if (!q || q.length < 2) return c.json([]);
// BM25 mode: use QMD
if (mode === "bm25" && qmdAvailable) {
try {
const { stdout } = await exec(
`export PATH="$HOME/.bun/bin:$PATH" && qmd search ${JSON.stringify(q)} -n 10 --json`,
{ timeout: 20000 }
);
const raw: { docid: string; score: number; file: string; title: string; snippet: string }[] = JSON.parse(stdout);
return c.json(raw.map((r) => ({
path: qmdUriToRelPath(r.file),
title: r.title,
snippet: r.snippet.replace(/@@ [^@]+ @@[^\n]*\n?/, "").substring(0, 300),
score: Math.round(r.score * 100),
})));
} catch (e: any) {
console.error("BM25 search error:", e.message);
return c.json([]);
}
}
// Vector mode: use embedding API
if (mode === "vector" && appSettings.embedding.enabled && appSettings.embedding.apiKey) {
try {
const queryEmb = await getEmbedding(q);
if (!queryEmb) return c.json([]);
const files = collectMdFiles(workspace);
const fileEmbs = await getFileEmbeddings(files, workspace);
const scored: { path: string; score: number; snippet: string; title: string }[] = [];
for (const [filePath, emb] of fileEmbs) {
const sim = cosineSim(queryEmb, emb);
const full = path.join(workspace, filePath);
let content = "";
try { content = fs.readFileSync(full, "utf-8"); } catch { continue; }
const firstLine = content.split("\n").find(l => l.startsWith("#"))?.replace(/^#+\s*/, "") || filePath;
scored.push({
path: filePath,
score: Math.round(sim * 100),
title: firstLine,
snippet: content.substring(0, 300).replace(/\n/g, " "),
});
}
scored.sort((a, b) => b.score - a.score);
return c.json(scored.slice(0, 10));
} catch (e: any) {
console.error("Vector search error:", e.message);
return c.json([]);
}
}
return c.json([]);
});
// ============================================================================
// Tags API - Extract and manage tags from markdown files
// ============================================================================
interface TagInfo {
name: string;
count: number;
files: string[];
}
interface FileWithTags {
path: string;
title: string;
preview: string;
date: string;
tags: string[];
}
// Extract tags from content: ## headers and #hashtags
function extractTags(content: string): string[] {
const tags = new Set<string>();
// Extract ## headers
const headers = content.match(/^##\s+(.+)$/gm) || [];
headers.forEach(h => {
const tag = h.replace(/^##\s+/, "").replace(/[*_`]/g, "").trim();
if (tag.length < 30 && tag.length > 0) tags.add(tag);
});
// Extract #hashtags (but not markdown headers)
const hashtags = content.match(/(?<![#\w])#([\w\u4e00-\u9fa5_-]+)/g) || [];
hashtags.forEach(h => {
const tag = h.replace(/^#/, "").trim();
if (tag.length < 30 && tag.length > 0) tags.add(tag);
});
return Array.from(tags);
}
// Scan all markdown files and extract tags
function scanAllTags(workspace: string): Map<string, TagInfo> {
const tagMap = new Map<string, TagInfo>();
const mdFiles = collectMdFiles(workspace);
for (const relPath of mdFiles) {
const fullPath = path.join(workspace, relPath);
try {
const content = fs.readFileSync(fullPath, "utf-8");
const tags = extractTags(content);
for (const tag of tags) {
if (!tagMap.has(tag)) {
tagMap.set(tag, { name: tag, count: 0, files: [] });
}
const info = tagMap.get(tag)!;
info.count++;
info.files.push(relPath);
}
} catch { /* skip */ }
}
return tagMap;
}
app.get("/api/tags", (c) => {
const { workspace } = getAgentFromQuery(c);
const tagMap = scanAllTags(workspace);
const tags = Array.from(tagMap.values()).sort((a, b) => b.count - a.count);
return c.json(tags);
});
app.get("/api/files-by-tag/:tag", (c) => {
const { workspace } = getAgentFromQuery(c);
const tagParam = decodeURIComponent(c.req.param("tag"));
const tagMap = scanAllTags(workspace);
const tagInfo = tagMap.get(tagParam);
if (!tagInfo) {
return c.json([]);
}
const results: FileWithTags[] = [];
for (const relPath of tagInfo.files) {
const fullPath = path.join(workspace, relPath);
try {
const content = fs.readFileSync(fullPath, "utf-8");
const clean = content.replace(/^---[\s\S]*?---/, "").trim();
const titleMatch = clean.match(/^#\s+(.+)$/m);
const title = titleMatch ? titleMatch[1].trim() : path.basename(relPath, ".md");
const lines = clean.split("\n").filter(l => l.trim() && !l.startsWith("#"));
let preview = lines.slice(0, 2).join(" ").replace(/[*_`\[\]]/g, "").trim();
if (preview.length > 120) preview = preview.slice(0, 120) + "…";
const date = relPath.match(/(\d{4}-\d{2}-\d{2})/)?.[1] || "";
const fileTags = extractTags(content);
results.push({
path: relPath,
title,
preview: preview || "(空)",
date,
tags: fileTags,
});
} catch { /* skip */ }
}
// Sort by date (newest first)
results.sort((a, b) => b.date.localeCompare(a.date));
return c.json(results);
});
app.get("/api/timeline", (c) => {
const { workspace } = getAgentFromQuery(c);
const memoryDir = path.join(workspace, "memory");
const results: { date: string; path: string; title: string; preview: string; tags: string[]; charCount: number }[] = [];
function scanDir(dir: string, rel: string) {
let entries: fs.Dirent[];
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
for (const e of entries) {
if (e.isDirectory()) {
scanDir(path.join(dir, e.name), rel ? `${rel}/${e.name}` : e.name);
} else if (e.isFile() && /^\d{4}-\d{2}-\d{2}(-\w+)?\.md$/.test(e.name)) {
const filePath = `memory/${rel ? rel + "/" : ""}${e.name}`;
const fullPath = path.join(dir, e.name);
try {
const content = fs.readFileSync(fullPath, "utf-8");
const date = e.name.match(/(\d{4}-\d{2}-\d{2})/)?.[1] || "";
const clean = content.replace(/^---[\s\S]*?---/, "").trim();
const titleMatch = clean.match(/^#\s+(.+)$/m);
const title = titleMatch ? titleMatch[1].trim() : e.name.replace(/\.md$/, "");
const lines = clean.split("\n").filter(l => l.trim() && !l.startsWith("#"));
let preview = lines.slice(0, 2).join(" ").replace(/[*_`\[\]]/g, "").trim();
if (preview.length > 120) preview = preview.slice(0, 120) + "…";
const headers = content.match(/^##\s+(.+)$/gm) || [];
const tags = headers.map(h => h.replace(/^##\s+/, "").replace(/[*_`]/g, "").trim()).filter(t => t.length < 20).slice(0, 4);
results.push({ date, path: filePath, title, preview: preview || "(空)", tags, charCount: content.length });
} catch { /* skip */ }
}
}
}
scanDir(memoryDir, "");
results.sort((a, b) => b.date.localeCompare(a.date));
return c.json(results);
});
app.get("/api/recent", (c) => {
const { workspace } = getAgentFromQuery(c);
const files = collectMdFiles(workspace);
const withStats = files.map((relPath) => {
const full = path.join(workspace, relPath);
try {
const stat = fs.statSync(full);
return { path: relPath, mtime: stat.mtime.getTime(), size: stat.size };
} catch {
return null;
}
}).filter(Boolean) as { path: string; mtime: number; size: number }[];
withStats.sort((a, b) => b.mtime - a.mtime);
const limit = Math.min(Number(c.req.query("limit")) || 10, 50);
return c.json(withStats.slice(0, limit));
});
app.get("/api/stats/monthly", (c) => {
const { workspace } = getAgentFromQuery(c);
const memoryDir = path.join(workspace, "memory");
const counts: Record<string, number> = {};
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(memoryDir, { withFileTypes: true });
} catch {
return c.json([]);
}
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
const match = entry.name.match(/^(\d{4}-\d{2})/);
if (match) {
counts[match[1]] = (counts[match[1]] || 0) + 1;
} else {
try {
const stat = fs.statSync(path.join(memoryDir, entry.name));
const month = stat.mtime.toISOString().slice(0, 7);
counts[month] = (counts[month] || 0) + 1;
} catch { /* skip */ }
}
}
const result = Object.entries(counts)
.map(([month, count]) => ({ month, count }))
.sort((a, b) => a.month.localeCompare(b.month));
return c.json(result);
});
app.get("/api/stats/daily", (c) => {
const { workspace } = getAgentFromQuery(c);
const memoryDir = path.join(workspace, "memory");
const results: { date: string; count: number; size: number }[] = [];
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(memoryDir, { withFileTypes: true });
} catch {
return c.json([]);
}
const dateMap = new Map<string, { count: number; size: number }>();
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
const match = entry.name.match(/^(\d{4}-\d{2}-\d{2})/);
if (!match) continue;
const date = match[1];
try {
const stat = fs.statSync(path.join(memoryDir, entry.name));
const existing = dateMap.get(date);
if (existing) {
existing.count++;
existing.size += stat.size;
} else {
dateMap.set(date, { count: 1, size: stat.size });
}
} catch { /* skip */ }
}
for (const [date, val] of dateMap) {
results.push({ date, count: val.count, size: val.size });
}
results.sort((a, b) => a.date.localeCompare(b.date));
return c.json(results);
});
app.get("/api/info", (c) => {
const { workspace } = getAgentFromQuery(c);
let name = "Unknown Bot";
let description = "";
for (const fname of ["IDENTITY.md", "SOUL.md"]) {
const fpath = path.join(workspace, fname);
if (fs.existsSync(fpath)) {
const content = fs.readFileSync(fpath, "utf-8");
const heading = content.match(/^#\s+(.+)/m);
if (heading) name = heading[1].trim();
const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
if (lines.length > 0) description = lines[0].trim().substring(0, 200);
break;
}
}
return c.json({ name, version: "1.0.0", description });
});
app.get("/api/system", (c) => {
const { workspace } = getAgentFromQuery(c);
const uptime = os.uptime();
const memTotal = os.totalmem();
const memFree = os.freemem();
const load = os.loadavg();
const platform = `${os.platform()} ${os.release()}`;
const hostname = os.hostname();
const today = new Date().toISOString().split("T")[0];
const todayPath = path.join(workspace, "memory", `${today}.md`);
let todayMemory = null;
if (fs.existsSync(todayPath)) {
const content = fs.readFileSync(todayPath, "utf-8");
todayMemory = {
filename: `memory/${today}.md`,
snippet: content.split("\n").slice(0, 10).join("\n"),
length: content.length,
};
}
const totalFiles = collectMdFiles(workspace).length;
return c.json({
uptime, memTotal, memFree, memUsed: memTotal - memFree,
load, platform, hostname, todayMemory, totalFiles,
});
});
app.get("/api/agent/status", async (c) => {
// 1. Config
const home = os.homedir();
const configPath = path.join(home, ".openclaw", "openclaw.json");
let safeConfig: any = {};
try {
if (fs.existsSync(configPath)) {
const raw = JSON.parse(fs.readFileSync(configPath, "utf-8"));
// Whitelist specific fields
safeConfig = {
version: raw.version,
update: raw.update,
models: { mode: raw.models?.mode },
agents: { defaults: raw.agents?.defaults },
gateway: {
port: raw.gateway?.port,
mode: raw.gateway?.mode,
},
};
}
} catch (e) {
console.error("Failed to read config", e);
safeConfig = { error: "Could not read config" };
}
// 2. Gateway Status
let gatewayStatus = null;
try {
const { stdout } = await exec("openclaw gateway status --json");
gatewayStatus = JSON.parse(stdout);
} catch (e) {
// console.error("Failed to get gateway status", e);
// fallback or null
}
// 3. Heartbeat
let heartbeat = null;
try {
const hbPath = path.join(DEFAULT_WORKSPACE, "memory", "heartbeat-state.json");
if (fs.existsSync(hbPath)) {
heartbeat = JSON.parse(fs.readFileSync(hbPath, "utf-8"));
}
} catch (e) {
console.error("Failed to read heartbeat", e);
}
return c.json({
config: safeConfig,
gateway: gatewayStatus,
heartbeat
});
});
// ---------------------------------------------------------------------------
// Gateway Chat Proxy
// ---------------------------------------------------------------------------
app.post("/api/gateway/chat", async (c) => {
const { gatewayUrl, token, messages } = await c.req.json();
if (!gatewayUrl || !token || !messages) {
return c.json({ error: "Missing gatewayUrl, token, or messages" }, 400);
}
try {
const url = `${gatewayUrl.replace(/\/+$/, "")}/v1/chat/completions`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30000);
const resp = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ model: "default", messages }),
signal: controller.signal,
});
clearTimeout(timeout);
if (!resp.ok) {
const text = await resp.text();
return c.json({ error: text }, resp.status as any);
}
const data = await resp.json();
return c.json(data);
} catch (err: any) {
if (err.name === "AbortError") {
return c.json({ error: "Gateway request timeout (30s)" }, 504);
}
return c.json({ error: err.message || "Gateway request failed" }, 502);
}
});
// ---------------------------------------------------------------------------
// AI Summarize
// ---------------------------------------------------------------------------
const GATEWAY_CHAT_URL = process.env.GATEWAY_CHAT_URL || "http://silicon-01:3001/v1/chat/completions";
const SUMMARIZE_MODEL = process.env.SUMMARIZE_MODEL || "kimi-k2.5";
app.post("/api/summarize", async (c) => {
const { workspace } = getAgentFromQuery(c);
const { path: filePath, content: providedContent, save } = await c.req.json();
const full = safePath(filePath, workspace);
if (!full) return c.json({ error: "Invalid path" }, 400);
let content = providedContent;
if (!content) {
if (!fs.existsSync(full)) return c.json({ error: "Not found" }, 404);
content = fs.readFileSync(full, "utf-8");
}
// Strip existing frontmatter for summarization
const bodyMatch = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?([\s\S]*)$/);
const body = bodyMatch ? bodyMatch[1] : content;
if (body.trim().length < 50) {
return c.json({ error: "Content too short to summarize" }, 400);
}
try {
const resp = await fetch(GATEWAY_CHAT_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: SUMMARIZE_MODEL,
messages: [
{
role: "system",
content: "You are a concise summarizer. Given a markdown document, produce a brief summary (1-3 sentences, max 200 chars). Reply with ONLY the summary text, no quotes, no prefix.",
},
{ role: "user", content: body.slice(0, 8000) },
],
max_tokens: 256,
temperature: 0.3,
}),
signal: AbortSignal.timeout(30000),
});
if (!resp.ok) {
const text = await resp.text();
return c.json({ error: `Gateway error: ${text}` }, 502);
}
const data: any = await resp.json();
const summary = (data.choices?.[0]?.message?.content || "").trim();
if (!summary) return c.json({ error: "Empty summary returned" }, 502);
// Optionally save to file frontmatter
if (save) {
const existingFm = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
let newContent: string;
if (existingFm) {
// Update or add summary field in existing frontmatter
const fmContent = existingFm[1];
if (/^summary:/m.test(fmContent)) {
const updatedFm = fmContent.replace(/^summary:.*$/m, `summary: "${summary.replace(/"/g, '\\"')}"`);
newContent = content.replace(existingFm[0], `---\n${updatedFm}\n---`);
} else {
newContent = content.replace(existingFm[0], `---\n${fmContent}\nsummary: "${summary.replace(/"/g, '\\"')}"\n---`);
}
} else {
newContent = `---\nsummary: "${summary.replace(/"/g, '\\"')}"\n---\n${content}`;
}
fs.writeFileSync(full, newContent, "utf-8");
const stat = fs.statSync(full);
return c.json({ summary, saved: true, mtime: stat.mtime });
}
return c.json({ summary, saved: false });
} catch (err: any) {
if (err.name === "AbortError" || err.name === "TimeoutError") {
return c.json({ error: "Gateway timeout (30s)" }, 504);
}
return c.json({ error: err.message || "Summarize failed" }, 502);
}
});
// ---------------------------------------------------------------------------
// WebSocket — live file change notifications
// ---------------------------------------------------------------------------
const wsClients = new Set<WSContext>();
app.get("/ws", upgradeWebSocket(() => ({
onOpen(_event, ws) {
wsClients.add(ws);
},
onClose(_event, ws) {
wsClients.delete(ws);
},
})));
function broadcast(data: object) {
const msg = JSON.stringify(data);
for (const ws of wsClients) {
try { ws.send(msg); } catch { /* ignore */ }
}
}
const watcher = watch(path.join(DEFAULT_WORKSPACE, "**/*.md"), {
ignoreInitial: true,
ignored: /(^|[/\\])\.(git|node_modules)/,
awaitWriteFinish: { stabilityThreshold: 300 },
});
watcher.on("all", (event, filePath) => {
const rel = path.relative(DEFAULT_WORKSPACE, filePath);
broadcast({ type: "file-change", event, path: rel });
});
// ---------------------------------------------------------------------------
// Workspace assets (images, SVGs, etc.)
// ---------------------------------------------------------------------------
app.get("/workspace-assets/*", async (c) => {
const { workspace } = getAgentFromQuery(c);
const assetPath = c.req.path.replace("/workspace-assets/", "");
const fullPath = path.join(workspace, "assets", assetPath);
if (!fullPath.startsWith(path.join(workspace, "assets"))) {
return c.json({ error: "Invalid path" }, 403);
}
if (!fs.existsSync(fullPath)) {
return c.json({ error: "Not found" }, 404);
}
const ext = path.extname(fullPath).toLowerCase();
const mimeTypes: Record<string, string> = {
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
};
const contentType = mimeTypes[ext] || "application/octet-stream";
const content = fs.readFileSync(fullPath);
c.header("Content-Type", contentType);
c.header("Cache-Control", "public, max-age=3600");
return c.body(content);
});
// ---------------------------------------------------------------------------
// Cron API — read OpenClaw cron jobs and run history
// ---------------------------------------------------------------------------
const CRON_JOBS_FILE = path.join(os.homedir(), ".openclaw", "cron", "jobs.json");
const CRON_RUNS_DIR = path.join(os.homedir(), ".openclaw", "cron", "runs");
interface CronJob {
id: string;
name?: string;
enabled: boolean;
schedule?: { kind: string; expr?: string; everyMs?: number; at?: string };
payload?: { kind: string; text?: string; message?: string };
sessionTarget?: string;
agentId?: string;
wakeMode?: string;
state?: { nextRunAtMs?: number; lastRunAtMs?: number; lastStatus?: string };
delivery?: { mode?: string };
}
function readCronJobs(): CronJob[] {
try {
const data = fs.readFileSync(CRON_JOBS_FILE, "utf-8");
const json = JSON.parse(data);
return json.jobs || [];
} catch {
return [];
}
}
function writeCronJobs(jobs: CronJob[]): boolean {
try {
fs.copyFileSync(CRON_JOBS_FILE, CRON_JOBS_FILE + ".bak");
fs.writeFileSync(CRON_JOBS_FILE, JSON.stringify({ version: 1, jobs }, null, 2));
return true;
} catch {
return false;
}
}
function readCronRuns(jobId: string): any[] {
try {
const runFile = path.join(CRON_RUNS_DIR, `${jobId}.jsonl`);
const data = fs.readFileSync(runFile, "utf-8");
return data.trim().split("\n").filter(Boolean).map(line => {
try { return JSON.parse(line); } catch { return null; }
}).filter(Boolean).reverse();
} catch {
return [];
}
}
function formatCronJob(job: CronJob) {
let scheduleDisplay = "-";
if (job.schedule) {
if (job.schedule.kind === "cron" && job.schedule.expr) scheduleDisplay = job.schedule.expr;
else if (job.schedule.kind === "every" && job.schedule.everyMs) scheduleDisplay = `every ${Math.round(job.schedule.everyMs / 60000)}m`;
else if (job.schedule.kind === "at" && job.schedule.at) scheduleDisplay = `at ${job.schedule.at}`;
}
return {
id: job.id,
name: job.name || "Unnamed",
enabled: job.enabled !== false,
schedule: scheduleDisplay,
scheduleRaw: job.schedule,
nextRun: job.state?.nextRunAtMs ? new Date(job.state.nextRunAtMs).toISOString() : null,
lastRun: job.state?.lastRunAtMs ? new Date(job.state.lastRunAtMs).toISOString() : null,
lastStatus: job.state?.lastStatus || null,
sessionTarget: job.sessionTarget || job.agentId || "-",
wakeMode: job.wakeMode || "next-heartbeat",
payloadKind: job.payload?.kind || "-",
deliveryMode: job.delivery?.mode || "-",
};
}
app.get("/api/crons", (c) => {
const jobs = readCronJobs();
return c.json({ crons: jobs.map(formatCronJob) });
});
app.get("/api/crons/:id/runs", (c) => {
const { id } = c.req.param();
const runs = readCronRuns(id);
return c.json({ runs: runs.slice(0, 30) });
});
app.post("/api/crons/:id/toggle", async (c) => {
const { id } = c.req.param();
const { enabled } = await c.req.json();
try {
const configPath = path.join(os.homedir(), ".openclaw", "openclaw.json");
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
const port = config.gateway?.port || 18789;
const token = config.gateway?.auth?.token || "";
const method = enabled ? "cron.update" : "cron.update";
const resp = await fetch(`http://127.0.0.1:${port}/rpc`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "cron.update",
params: { jobId: id, patch: { enabled } },
}),
signal: AbortSignal.timeout(10000),
});
const result = await resp.json() as any;
if (result.error) {
return c.json({ success: false, error: result.error.message }, 500);
}
return c.json({ success: true, job: result.result });
} catch (e: any) {
// Fallback to direct file write
const jobs = readCronJobs();
const idx = jobs.findIndex(j => j.id === id);
if (idx === -1) return c.json({ error: "Job not found" }, 404);
jobs[idx].enabled = enabled;
const ok = writeCronJobs(jobs);
return c.json({ success: ok, job: formatCronJob(jobs[idx]) });
}
});
app.post("/api/crons/:id/run", async (c) => {
const { id } = c.req.param();
try {
const { execSync } = await import("child_process");
const scriptPath = path.join(import.meta.dirname || __dirname, "cron-trigger.mjs");
const result = execSync(`node ${scriptPath} ${id}`, {
timeout: 12000,
cwd: path.dirname(scriptPath),
encoding: "utf-8",
}).trim();
const parsed = JSON.parse(result) as any;
return c.json(parsed, parsed.success ? 200 : 500);
} catch (e: any) {
const stderr = e.stderr?.trim() || e.message;
return c.json({ success: false, error: stderr }, 500);
}
});
// System Crons API — heartbeat, compaction, pruning, session cleanup
// ---------------------------------------------------------------------------
app.get("/api/system-crons", (c) => {
try {
const configPath = path.join(os.homedir(), ".openclaw", "openclaw.json");
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
const defaults = config.agents?.defaults || {};
const agentList = config.agents?.list || [];
const systemCrons: any[] = [];
// Heartbeat
const hbEvery = defaults.heartbeat?.every || "disabled";
systemCrons.push({
id: "sys-heartbeat",
name: "💓 心跳检测",
type: "heartbeat",
schedule: hbEvery === "disabled" ? "禁用" : `${hbEvery}`,
enabled: hbEvery !== "disabled",
description: "定期唤醒 agent 执行 HEARTBEAT.md 检查",
agents: agentList.map((a: any) => ({
id: a.id,
name: a.identity?.name || a.name || a.id,
heartbeat: a.heartbeat?.every || hbEvery,
enabled: (a.heartbeat?.every || hbEvery) !== "disabled",
})),
});
// Compaction
const compMode = defaults.compaction?.mode || "off";
const flushEnabled = defaults.compaction?.memoryFlush?.enabled || false;
const flushThreshold = defaults.compaction?.memoryFlush?.softThresholdTokens;
systemCrons.push({
id: "sys-compaction",
name: "🗜️ 上下文压缩",
type: "compaction",
schedule: "按需触发",
enabled: compMode !== "off",
description: `模式: ${compMode}${flushEnabled ? ` | Memory flush: ${flushThreshold ? flushThreshold + " tokens" : "启用"}` : ""}`,
});
// Context Pruning
const pruneMode = defaults.contextPruning?.mode || "off";
const pruneTTL = defaults.contextPruning?.ttl;
systemCrons.push({
id: "sys-context-pruning",
name: "✂️ 上下文修剪",
type: "context-pruning",
schedule: pruneTTL ? `TTL ${pruneTTL}` : "按需",
enabled: pruneMode !== "off",
description: `模式: ${pruneMode}${pruneTTL ? ` | 缓存过期: ${pruneTTL}` : ""}`,
});
// Session cleanup (2026.2.9 feature)
systemCrons.push({
id: "sys-session-cleanup",
name: "🗑️ Session 清理",
type: "session-cleanup",
schedule: "自动",
enabled: true,
description: "自动修剪过期 session防止磁盘占满",
});
// QMD Memory refresh
const qmd = config.memory?.qmd;
if (qmd && config.memory?.backend === "qmd") {
systemCrons.push({
id: "sys-qmd-refresh",
name: "🧠 QMD 记忆索引",
type: "qmd",
schedule: qmd.update?.interval ? `${qmd.update.interval}` : "手动",
enabled: true,
description: `后台刷新: ${qmd.update?.onBoot ? "启动时 + " : ""}${qmd.update?.interval || "手动"}`,
});
}
return c.json({ systemCrons });
} catch (e: any) {
return c.json({ error: e.message }, 500);
}
});
// ---------------------------------------------------------------------------
// Static file serving
// ---------------------------------------------------------------------------
if (fs.existsSync(STATIC_DIR)) {
app.use("/assets/*", serveStatic({
root: STATIC_DIR,
rewriteRequestPath: (p) => p,
}));
app.use("*", serveStatic({ root: STATIC_DIR }));
// SPA fallback
app.get("*", (c) => {
const html = fs.readFileSync(path.join(STATIC_DIR, "index.html"), "utf-8");
c.header("Cache-Control", "no-cache, no-store, must-revalidate");
return c.html(html);
});
}
// ---------------------------------------------------------------------------
// Start
// ---------------------------------------------------------------------------
if (process.env.NODE_ENV !== 'test') {
const server = serve({ fetch: app.fetch, port: PORT, hostname: "0.0.0.0" }, (info) => {
console.log(`📝 Memory Viewer running at http://localhost:${info.port}`);
console.log(`📂 Default Workspace: ${DEFAULT_WORKSPACE}`);
});
injectWebSocket(server);
}