── Tiptap Markdown Editor ── - Add full Tiptap-based WYSIWYG markdown editor (markdown-editor.tsx, 709 LOC) with bubble menu, auto-save (debounced), image drag-and-drop/paste upload, table editing, task list checkboxes, and frontmatter preservation on save. - Add slash command system (slash-command.tsx, 607 LOC) with "/" trigger for block insertion (headings, lists, tables, code blocks, images, reports) and "@" trigger for file/document mention with fuzzy search across the workspace tree. - Add ReportBlockNode (report-block-node.tsx) — custom Tiptap node that renders embedded report-json blocks as interactive ReportCard widgets inline in the editor, with expand/collapse and edit-JSON support. - Add workspace asset serving API (api/workspace/assets/[...path]/route.ts) to serve images from the workspace with proper MIME types. - Add workspace file upload orkspace/upload/route.ts) for multipart image uploads (10 MB limit, image types only), saving to assets/ directory. - Add ~500 lines of Tiptap editor CSS to globals.css (editor layout, task lists, images, tables, slash command dropdown, bubble menu toolbar, code blocks, etc.). - Add 14 @tiptap/* dependencies to apps/web/package.json (react, starter-kit, markdown, image, link, table, task-list, suggestion, placeholder, etc.). ── Document View: Edit/Read Mode Toggle ── - document-view.tsx: Add edit/read mode toggle; defaults to edit mode when a filePath is available. Lazy-loads MarkdownEditor to keep initial bundle light. - workspace/page.tsx: Pass activePath, tree, onSave, onNavigate, and onRefreshTree through to DocumentView for full editor integration with workspace navigation and tree refresh after saves. ── Subagent Session Isolation ── - agent-runner.ts: Add RunAgentOptions with optional sessionId; when set, spawns the agent with --session-key agent:main:subagent:<id> ant so file-scoped sidebar chats run in isolated sessions independent of the main agent. - route.ts (chat API): Accept sessionId from request body and forward it to runAgent. Resolve workspace file path prefixes (resolveAgentWorkspacePrefix) so tree-relative paths become agent-cwd-relative. - chat-panel.tsx: Create per-instance DefaultChatTransport that injects sessionId via body function and a ref (avoids stale closures). On file change, auto-load the most recent session and its messages. Refresh session tab list after streaming ends. Stop ongoing stream when switching sessions. - register.agent.ts: Add --session-key <key> and --lane <lane> CLI flags. - agent-via-gateway.ts: Wire sessionKey into session resolution and validation for both interactive and --stream-json code paths. - workspace.ts: Add resolveAgentWorkspacePrefix() to map workspace-root-relative paths to repo-root-relative paths for the agent process. ── Error Surfacing ── - agent-runner.ts: Add onAgentError callback extraction helpers (parseAgentErrorMessage, parseErrorBody, parseErrorFromStderr) to surface API-level errors (402 payment, rate limits, etc.) to the UI. Captures stderr for fallback error detection on non-zero exit. - route.ts: Wire onAgentError into the SSE stream as [error]-prefixed text parts. Improve onError and onClose handlers with clearer error messages and exit code reporting. - chat-message.tsx: Detect [error]-prefixed text segments and render them as styled error banners with alert icon instead of plain text. - chat-panel.tsx: Restyle the transport-level error bar with themed colors and an alert icon consistent with in-message error styling.
331 lines
9.0 KiB
TypeScript
331 lines
9.0 KiB
TypeScript
import { existsSync, readFileSync } from "node:fs";
|
|
import { execSync } from "node:child_process";
|
|
import { join, resolve, normalize, relative } from "node:path";
|
|
import { homedir } from "node:os";
|
|
|
|
/**
|
|
* Resolve the dench workspace directory, checking in order:
|
|
* 1. DENCH_WORKSPACE env var
|
|
* 2. ~/.openclaw/workspace/dench/
|
|
* 3. ./dench/ (relative to process cwd)
|
|
*/
|
|
export function resolveDenchRoot(): string | null {
|
|
const candidates = [
|
|
process.env.DENCH_WORKSPACE,
|
|
join(homedir(), ".openclaw", "workspace", "dench"),
|
|
join(process.cwd(), "dench"),
|
|
].filter(Boolean) as string[];
|
|
|
|
for (const dir of candidates) {
|
|
if (existsSync(dir)) {return dir;}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Return the workspace path prefix relative to the repo root (agent's cwd).
|
|
* Tree paths are relative to the dench workspace root (e.g. "knowledge/leads/foo.md"),
|
|
* but the agent runs from the repo root, so it needs "dench/knowledge/leads/foo.md".
|
|
* Returns e.g. "dench", or null if the workspace isn't found.
|
|
*/
|
|
export function resolveAgentWorkspacePrefix(): string | null {
|
|
const root = resolveDenchRoot();
|
|
if (!root) {return null;}
|
|
|
|
const cwd = process.cwd();
|
|
const repoRoot = cwd.endsWith(join("apps", "web"))
|
|
? resolve(cwd, "..", "..")
|
|
: cwd;
|
|
|
|
const rel = relative(repoRoot, root);
|
|
return rel || null;
|
|
}
|
|
|
|
/** Path to the DuckDB database file, or null if workspace doesn't exist. */
|
|
export function duckdbPath(): string | null {
|
|
const root = resolveDenchRoot();
|
|
if (!root) {return null;}
|
|
const dbPath = join(root, "workspace.duckdb");
|
|
return existsSync(dbPath) ? dbPath : null;
|
|
}
|
|
|
|
/**
|
|
* Resolve the duckdb CLI binary path.
|
|
* Checks common locations since the Next.js server may have a minimal PATH.
|
|
*/
|
|
function resolveDuckdbBin(): string | null {
|
|
const home = homedir();
|
|
const candidates = [
|
|
// User-local installs
|
|
join(home, ".duckdb", "cli", "latest", "duckdb"),
|
|
join(home, ".local", "bin", "duckdb"),
|
|
// Homebrew
|
|
"/opt/homebrew/bin/duckdb",
|
|
"/usr/local/bin/duckdb",
|
|
// System
|
|
"/usr/bin/duckdb",
|
|
];
|
|
|
|
for (const bin of candidates) {
|
|
if (existsSync(bin)) {return bin;}
|
|
}
|
|
|
|
// Fallback: try bare `duckdb` and hope it's in PATH
|
|
try {
|
|
execSync("which duckdb", { encoding: "utf-8", timeout: 2000 });
|
|
return "duckdb";
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute a DuckDB query and return parsed JSON rows.
|
|
* Uses the duckdb CLI with -json output format.
|
|
*/
|
|
export function duckdbQuery<T = Record<string, unknown>>(
|
|
sql: string,
|
|
): T[] {
|
|
const db = duckdbPath();
|
|
if (!db) {return [];}
|
|
|
|
const bin = resolveDuckdbBin();
|
|
if (!bin) {return [];}
|
|
|
|
try {
|
|
// Escape single quotes in SQL for shell safety
|
|
const escapedSql = sql.replace(/'/g, "'\\''");
|
|
const result = execSync(`'${bin}' -json '${db}' '${escapedSql}'`, {
|
|
encoding: "utf-8",
|
|
timeout: 10_000,
|
|
maxBuffer: 10 * 1024 * 1024, // 10 MB
|
|
shell: "/bin/sh",
|
|
});
|
|
|
|
const trimmed = result.trim();
|
|
if (!trimmed || trimmed === "[]") {return [];}
|
|
return JSON.parse(trimmed) as T[];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute a DuckDB statement (no JSON output expected).
|
|
* Used for INSERT/UPDATE/ALTER operations.
|
|
*/
|
|
export function duckdbExec(sql: string): boolean {
|
|
const db = duckdbPath();
|
|
if (!db) {return false;}
|
|
|
|
const bin = resolveDuckdbBin();
|
|
if (!bin) {return false;}
|
|
|
|
try {
|
|
const escapedSql = sql.replace(/'/g, "'\\''");
|
|
execSync(`'${bin}' '${db}' '${escapedSql}'`, {
|
|
encoding: "utf-8",
|
|
timeout: 10_000,
|
|
shell: "/bin/sh",
|
|
});
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse a relation field value which may be a single ID or a JSON array of IDs.
|
|
* Handles both many_to_one (single ID string) and many_to_many (JSON array).
|
|
*/
|
|
export function parseRelationValue(value: string | null | undefined): string[] {
|
|
if (!value) {return [];}
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {return [];}
|
|
|
|
// Try JSON array first (many-to-many)
|
|
if (trimmed.startsWith("[")) {
|
|
try {
|
|
const parsed = JSON.parse(trimmed);
|
|
if (Array.isArray(parsed)) {return parsed.map(String).filter(Boolean);}
|
|
} catch {
|
|
// not valid JSON array, treat as single value
|
|
}
|
|
}
|
|
|
|
return [trimmed];
|
|
}
|
|
|
|
/** Database file extensions that trigger the database viewer. */
|
|
export const DB_EXTENSIONS = new Set([
|
|
"duckdb",
|
|
"sqlite",
|
|
"sqlite3",
|
|
"db",
|
|
"postgres",
|
|
]);
|
|
|
|
/** Check whether a filename has a database extension. */
|
|
export function isDatabaseFile(filename: string): boolean {
|
|
const ext = filename.split(".").pop()?.toLowerCase();
|
|
return ext ? DB_EXTENSIONS.has(ext) : false;
|
|
}
|
|
|
|
/**
|
|
* Execute a DuckDB query against an arbitrary database file and return parsed JSON rows.
|
|
* This is used by the database viewer to introspect any .duckdb/.sqlite/.db file.
|
|
*/
|
|
export function duckdbQueryOnFile<T = Record<string, unknown>>(
|
|
dbFilePath: string,
|
|
sql: string,
|
|
): T[] {
|
|
const bin = resolveDuckdbBin();
|
|
if (!bin) {return [];}
|
|
|
|
try {
|
|
const escapedSql = sql.replace(/'/g, "'\\''");
|
|
const result = execSync(`'${bin}' -json '${dbFilePath}' '${escapedSql}'`, {
|
|
encoding: "utf-8",
|
|
timeout: 15_000,
|
|
maxBuffer: 10 * 1024 * 1024,
|
|
shell: "/bin/sh",
|
|
});
|
|
|
|
const trimmed = result.trim();
|
|
if (!trimmed || trimmed === "[]") {return [];}
|
|
return JSON.parse(trimmed) as T[];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate and resolve a path within the dench workspace.
|
|
* Prevents path traversal by ensuring the resolved path stays within root.
|
|
* Returns the absolute path or null if invalid/nonexistent.
|
|
*/
|
|
export function safeResolvePath(
|
|
relativePath: string,
|
|
): string | null {
|
|
const root = resolveDenchRoot();
|
|
if (!root) {return null;}
|
|
|
|
// Reject obvious traversal attempts
|
|
const normalized = normalize(relativePath);
|
|
if (normalized.startsWith("..") || normalized.includes("/../")) {return null;}
|
|
|
|
const absolute = resolve(root, normalized);
|
|
|
|
// Ensure the resolved path is still within the workspace root
|
|
if (!absolute.startsWith(resolve(root))) {return null;}
|
|
if (!existsSync(absolute)) {return null;}
|
|
|
|
return absolute;
|
|
}
|
|
|
|
/**
|
|
* Lightweight YAML frontmatter / simple-value parser.
|
|
* Handles flat key: value pairs and simple nested structures.
|
|
* Good enough for .object.yaml and workspace_context.yaml top-level fields.
|
|
*/
|
|
export function parseSimpleYaml(
|
|
content: string,
|
|
): Record<string, unknown> {
|
|
const result: Record<string, unknown> = {};
|
|
const lines = content.split("\n");
|
|
|
|
for (const line of lines) {
|
|
// Skip comments and empty lines
|
|
if (line.trim().startsWith("#") || !line.trim()) {continue;}
|
|
|
|
// Match top-level key: value
|
|
const match = line.match(/^(\w[\w_-]*)\s*:\s*(.+)/);
|
|
if (match) {
|
|
const key = match[1];
|
|
let value: unknown = match[2].trim();
|
|
|
|
// Strip quotes
|
|
if (
|
|
typeof value === "string" &&
|
|
((value.startsWith('"') && value.endsWith('"')) ||
|
|
(value.startsWith("'") && value.endsWith("'")))
|
|
) {
|
|
value = (value as string).slice(1, -1);
|
|
}
|
|
|
|
// Parse booleans and numbers
|
|
if (value === "true") {value = true;}
|
|
else if (value === "false") {value = false;}
|
|
else if (value === "null") {value = null;}
|
|
else if (
|
|
typeof value === "string" &&
|
|
/^-?\d+(\.\d+)?$/.test(value)
|
|
) {
|
|
value = Number(value);
|
|
}
|
|
|
|
result[key] = value;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// --- System file protection ---
|
|
|
|
const SYSTEM_FILE_PATTERNS = [
|
|
/^\.object\.yaml$/,
|
|
/^workspace\.duckdb/,
|
|
/^workspace_context\.yaml$/,
|
|
/\.wal$/,
|
|
/\.tmp$/,
|
|
];
|
|
|
|
/** Check if a workspace-relative path refers to a protected system file. */
|
|
export function isSystemFile(relativePath: string): boolean {
|
|
const base = relativePath.split("/").pop() ?? "";
|
|
return SYSTEM_FILE_PATTERNS.some((p) => p.test(base));
|
|
}
|
|
|
|
/**
|
|
* Like safeResolvePath but does NOT require the target to exist on disk.
|
|
* Useful for mkdir / create / rename-target validation.
|
|
* Still prevents path traversal.
|
|
*/
|
|
export function safeResolveNewPath(relativePath: string): string | null {
|
|
const root = resolveDenchRoot();
|
|
if (!root) {return null;}
|
|
|
|
const normalized = normalize(relativePath);
|
|
if (normalized.startsWith("..") || normalized.includes("/../")) {return null;}
|
|
|
|
const absolute = resolve(root, normalized);
|
|
if (!absolute.startsWith(resolve(root))) {return null;}
|
|
|
|
return absolute;
|
|
}
|
|
|
|
/**
|
|
* Read a file from the workspace safely.
|
|
* Returns content and detected type, or null if not found.
|
|
*/
|
|
export function readWorkspaceFile(
|
|
relativePath: string,
|
|
): { content: string; type: "markdown" | "yaml" | "text" } | null {
|
|
const absolute = safeResolvePath(relativePath);
|
|
if (!absolute) {return null;}
|
|
|
|
try {
|
|
const content = readFileSync(absolute, "utf-8");
|
|
const ext = relativePath.split(".").pop()?.toLowerCase();
|
|
|
|
let type: "markdown" | "yaml" | "text" = "text";
|
|
if (ext === "md" || ext === "mdx") {type = "markdown";}
|
|
else if (ext === "yaml" || ext === "yml") {type = "yaml";}
|
|
|
|
return { content, type };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|