👌 IMPROVE: never miss yaml

This commit is contained in:
kumarabhirup 2026-02-11 17:01:28 -08:00
parent f74327445e
commit 19259b1e15
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
10 changed files with 1385 additions and 33 deletions

View File

@ -538,18 +538,41 @@ Identical patterns, but with SQL examples instead of tool call examples:
- NEVER modify `workspace_context.yaml` -- it is read-only context from Dench
- Members list is authoritative for user-type field resolution
### Section 11: Post-Mutation Pipeline (new)
### Section 11: Post-Mutation Checklist (MANDATORY -- revised after agent testing)
After any schema mutation (create/update/delete object, field, or document), run a 3-step pipeline:
**Problem identified:** In testing, the agent correctly executed SQL (object + fields + entries) but skipped the filesystem projection (.object.yaml) and sometimes the PIVOT view. Root cause: the original skill mentioned these as afterthoughts ("Then project the filesystem...") with no concrete template or examples. The agent follows examples literally -- if examples only show SQL, it only does SQL.
1. **Regenerate views**: `CREATE OR REPLACE VIEW v_{object}` for any affected objects
2. **Project filesystem**: Sync the `knowledge/` directory structure from DuckDB (mkdir/rmdir for objects, write `.object.yaml` summaries, move `.md` files if nesting changed)
3. **Sync to S3**: Run `dench/sync.sh` to persist workspace.duckdb + knowledge/ to S3
4. **Regenerate WORKSPACE.md**: Human-readable summary of all objects, fields, entry counts, and views
**Fix:** Every workflow example now uses an explicit 3-step structure. The post-mutation section is now a checklist, not a description.
After creating/modifying an OBJECT or FIELDS:
- `CREATE OR REPLACE VIEW v_{object_name}` -- regenerate PIVOT view
- `mkdir -p dench/knowledge/{object_name}/` -- create directory
- Write `.object.yaml` with id, name, description, icon, default_view, entry_count, and full field list
- Update WORKSPACE.md
After adding ENTRIES:
- Update `entry_count` in `.object.yaml`
- Verify: `SELECT * FROM v_{object} LIMIT 5`
After deleting an OBJECT:
- `DROP VIEW IF EXISTS v_{object_name}`
- `rm -rf dench/knowledge/{object_name}/`
The skill now includes:
- A concrete `.object.yaml` template with example content (previously missing entirely)
- Full bash commands for generating `.object.yaml` from DuckDB queries
- "Step 1 / Step 2 / Step 3" structure in every workflow example (SQL, Filesystem, Verify)
- Critical Reminders section leads with "NEVER SKIP FILESYSTEM PROJECTION" and "THREE STEPS, EVERY TIME"
### Section 12: Critical Reminders (adapted from `<critical_reminders>`)
- Handle the ENTIRE CRM operation from analysis to SQL execution to summary
- Handle the ENTIRE CRM operation from analysis to SQL execution **to filesystem projection** to summary
- **NEVER SKIP FILESYSTEM PROJECTION**: After any object mutation, create/update `.object.yaml` AND the `v_{object}` view. If missing, the object is invisible in the sidebar.
- **THREE STEPS, EVERY TIME**: (1) SQL transaction, (2) filesystem projection, (3) verify
- Always check existing data before creating (SELECT before INSERT, or ON CONFLICT)
- Search proactively to provide better UX (PIVOT with filters)
- Never assume field names -- always verify with `SELECT * FROM fields WHERE object_id = ?`
@ -561,6 +584,7 @@ After any schema mutation (create/update/delete object, field, or document), run
- KANBAN BOARDS: `default_view = 'kanban'`, auto-create Status and Assigned To fields
- PROTECTED OBJECTS: Never delete objects listed in `workspace_context.yaml` `protected_objects`
- ONE EXEC CALL: Batch related SQL in a single transaction whenever possible -- this is the entire point of the filesystem-first approach
- ENTRY COUNT: After adding entries, update `entry_count` in `.object.yaml`
---

View File

@ -0,0 +1,93 @@
import { safeResolvePath, duckdbQueryOnFile } from "@/lib/workspace";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
type TableInfo = {
table_name: string;
column_count: number;
estimated_row_count: number;
columns: Array<{
name: string;
type: string;
is_nullable: boolean;
}>;
};
/**
* GET /api/workspace/db/introspect?path=<relative-path>
*
* Introspects a DuckDB / SQLite / generic DB file using the duckdb CLI.
* Returns the list of tables with their columns and approximate row counts.
*/
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const relPath = searchParams.get("path");
if (!relPath) {
return Response.json(
{ error: "Missing required `path` query parameter" },
{ status: 400 },
);
}
const absPath = safeResolvePath(relPath);
if (!absPath) {
return Response.json(
{ error: "File not found or path traversal rejected" },
{ status: 404 },
);
}
// Get all user tables (skip internal DuckDB catalogs)
const rawTables = duckdbQueryOnFile<{
table_name: string;
table_type: string;
}>(
absPath,
"SELECT table_name, table_type FROM information_schema.tables WHERE table_schema = 'main' ORDER BY table_name",
);
if (rawTables.length === 0) {
return Response.json({ tables: [], path: relPath });
}
const tables: TableInfo[] = [];
for (const t of rawTables) {
// Fetch columns for this table
const cols = duckdbQueryOnFile<{
column_name: string;
data_type: string;
is_nullable: string;
}>(
absPath,
`SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_schema = 'main' AND table_name = '${t.table_name.replace(/'/g, "''")}' ORDER BY ordinal_position`,
);
// Get approximate row count
let rowCount = 0;
try {
const countResult = duckdbQueryOnFile<{ cnt: number }>(
absPath,
`SELECT count(*) as cnt FROM "${t.table_name.replace(/"/g, '""')}"`,
);
rowCount = countResult[0]?.cnt ?? 0;
} catch {
// skip if we can't count
}
tables.push({
table_name: t.table_name,
column_count: cols.length,
estimated_row_count: rowCount,
columns: cols.map((c) => ({
name: c.column_name,
type: c.data_type,
is_nullable: c.is_nullable === "YES",
})),
});
}
return Response.json({ tables, path: relPath });
}

View File

@ -0,0 +1,56 @@
import { safeResolvePath, duckdbQueryOnFile } from "@/lib/workspace";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
/**
* POST /api/workspace/db/query
* Body: { path: string, sql: string }
*
* Executes a read-only SQL query against a database file and returns JSON rows.
* Only SELECT statements are allowed for safety.
*/
export async function POST(request: Request) {
let body: { path?: string; sql?: string };
try {
body = await request.json();
} catch {
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
}
const { path: relPath, sql } = body;
if (!relPath || !sql) {
return Response.json(
{ error: "Missing required `path` and `sql` fields" },
{ status: 400 },
);
}
// Basic safety: only allow SELECT-like statements
const trimmedSql = sql.trim().toUpperCase();
if (
!trimmedSql.startsWith("SELECT") &&
!trimmedSql.startsWith("PRAGMA") &&
!trimmedSql.startsWith("DESCRIBE") &&
!trimmedSql.startsWith("SHOW") &&
!trimmedSql.startsWith("EXPLAIN") &&
!trimmedSql.startsWith("WITH")
) {
return Response.json(
{ error: "Only read-only queries (SELECT, DESCRIBE, SHOW, EXPLAIN, WITH) are allowed" },
{ status: 403 },
);
}
const absPath = safeResolvePath(relPath);
if (!absPath) {
return Response.json(
{ error: "File not found or path traversal rejected" },
{ status: 404 },
);
}
const rows = duckdbQueryOnFile(absPath, sql);
return Response.json({ rows, sql });
}

View File

@ -1,6 +1,6 @@
import { readdirSync, readFileSync, existsSync, type Dirent } from "node:fs";
import { join } from "node:path";
import { resolveDenchRoot, parseSimpleYaml, duckdbQuery } from "@/lib/workspace";
import { resolveDenchRoot, parseSimpleYaml, duckdbQuery, isDatabaseFile } from "@/lib/workspace";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
@ -8,7 +8,7 @@ export const runtime = "nodejs";
export type TreeNode = {
name: string;
path: string; // relative to dench/
type: "object" | "document" | "folder" | "file";
type: "object" | "document" | "folder" | "file" | "database";
icon?: string;
defaultView?: "table" | "kanban";
children?: TreeNode[];
@ -118,11 +118,12 @@ function buildTree(
} else if (entry.isFile()) {
const ext = entry.name.split(".").pop()?.toLowerCase();
const isDocument = ext === "md" || ext === "mdx";
const isDatabase = isDatabaseFile(entry.name);
nodes.push({
name: entry.name,
path: relPath,
type: isDocument ? "document" : "file",
type: isDatabase ? "database" : isDocument ? "document" : "file",
});
}
}
@ -130,6 +131,14 @@ function buildTree(
return nodes;
}
/** Classify a top-level file's type. */
function classifyFileType(name: string): TreeNode["type"] {
if (isDatabaseFile(name)) {return "database";}
const ext = name.split(".").pop()?.toLowerCase();
if (ext === "md" || ext === "mdx") {return "document";}
return "file";
}
export async function GET() {
const root = resolveDenchRoot();
if (!root) {
@ -147,19 +156,17 @@ export async function GET() {
tree.push(...buildTree(knowledgeDir, "knowledge", dbObjects));
}
// Add top-level files (WORKSPACE.md, workspace_context.yaml, etc.)
// Add top-level files (WORKSPACE.md, workspace_context.yaml, workspace.duckdb, etc.)
try {
const topLevel = readdirSync(root, { withFileTypes: true });
for (const entry of topLevel) {
if (!entry.isFile()) {continue;}
if (entry.name.startsWith(".")) {continue;}
const ext = entry.name.split(".").pop()?.toLowerCase();
const isDocument = ext === "md" || ext === "mdx";
tree.push({
name: entry.name,
path: entry.name,
type: isDocument ? "document" : "file",
type: classifyFileType(entry.name),
});
}
} catch {

View File

@ -27,7 +27,7 @@ type MemoryFile = {
type TreeNode = {
name: string;
path: string;
type: "object" | "document" | "folder" | "file";
type: "object" | "document" | "folder" | "file" | "database";
icon?: string;
defaultView?: "table" | "kanban";
children?: TreeNode[];
@ -231,7 +231,9 @@ function WorkspaceTreeNode({
? "var(--color-accent)"
: node.type === "document"
? "#60a5fa"
: "var(--color-text-muted)";
: node.type === "database"
? "#c084fc"
: "var(--color-text-muted)";
return (
<div>
@ -241,7 +243,7 @@ function WorkspaceTreeNode({
onClick={() => {
if (isExpandable) {onToggle(node.path);}
// Navigate to workspace page for actionable items
if (node.type === "object" || node.type === "document" || node.type === "file") {
if (node.type === "object" || node.type === "document" || node.type === "file" || node.type === "database") {
window.location.href = `/workspace?path=${encodeURIComponent(node.path)}`;
}
}}
@ -279,6 +281,12 @@ function WorkspaceTreeNode({
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
</svg>
) : node.type === "database" ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M3 5V19A9 3 0 0 0 21 19V5" />
<path d="M3 12A9 3 0 0 0 21 12" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" />

View File

@ -0,0 +1,918 @@
"use client";
import { useEffect, useState, useCallback, useMemo } from "react";
// --- Types ---
type ColumnInfo = {
name: string;
type: string;
is_nullable: boolean;
};
type TableInfo = {
table_name: string;
column_count: number;
estimated_row_count: number;
columns: ColumnInfo[];
};
type SortState = {
column: string;
direction: "asc" | "desc";
} | null;
type DatabaseViewerProps = {
/** Relative path to the database file within the dench workspace */
dbPath: string;
filename: string;
};
// --- Icons ---
function DatabaseIcon({ size = 16 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M3 5V19A9 3 0 0 0 21 19V5" />
<path d="M3 12A9 3 0 0 0 21 12" />
</svg>
);
}
function TableIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3v18" /><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M3 9h18" /><path d="M3 15h18" />
</svg>
);
}
function ViewIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
}
function ColumnIcon() {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="1" />
</svg>
);
}
function PlayIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="6 3 20 12 6 21 6 3" />
</svg>
);
}
function ChevronIcon({ direction }: { direction: "left" | "right" }) {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{direction === "left" ? (
<path d="m15 18-6-6 6-6" />
) : (
<path d="m9 18 6-6-6-6" />
)}
</svg>
);
}
function SortIndicator({ active, direction }: { active: boolean; direction: "asc" | "desc" }) {
return (
<svg
width="10" height="10" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
style={{ opacity: active ? 1 : 0.25 }}
>
{direction === "asc" ? <path d="m5 12 7-7 7 7" /> : <path d="m19 12-7 7-7-7" />}
</svg>
);
}
// --- Helpers ---
function formatRowCount(n: number): string {
if (n >= 1_000_000) {return `${(n / 1_000_000).toFixed(1)}M`;}
if (n >= 1_000) {return `${(n / 1_000).toFixed(1)}K`;}
return String(n);
}
/** Map DuckDB type names to short display labels + color hints */
function typeDisplay(dtype: string): { label: string; color: string } {
const t = dtype.toUpperCase();
if (t.includes("INT") || t.includes("BIGINT") || t.includes("SMALLINT") || t.includes("TINYINT") || t.includes("HUGEINT"))
{return { label: "int", color: "#c084fc" };}
if (t.includes("FLOAT") || t.includes("DOUBLE") || t.includes("DECIMAL") || t.includes("NUMERIC") || t.includes("REAL"))
{return { label: "float", color: "#c084fc" };}
if (t.includes("BOOL"))
{return { label: "bool", color: "#f59e0b" };}
if (t.includes("VARCHAR") || t.includes("TEXT") || t.includes("STRING") || t.includes("CHAR") || t === "UUID" || t === "BLOB")
{return { label: t.includes("UUID") ? "uuid" : "text", color: "#22c55e" };}
if (t.includes("TIMESTAMP") || t.includes("DATETIME"))
{return { label: "timestamp", color: "#60a5fa" };}
if (t.includes("DATE"))
{return { label: "date", color: "#60a5fa" };}
if (t.includes("TIME"))
{return { label: "time", color: "#60a5fa" };}
if (t.includes("JSON"))
{return { label: "json", color: "#fb923c" };}
return { label: dtype.toLowerCase(), color: "var(--color-text-muted)" };
}
// --- Main Component ---
export function DatabaseViewer({ dbPath, filename }: DatabaseViewerProps) {
const [tables, setTables] = useState<TableInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Selected table
const [selectedTable, setSelectedTable] = useState<string | null>(null);
// Table data
const [tableData, setTableData] = useState<Record<string, unknown>[]>([]);
const [dataLoading, setDataLoading] = useState(false);
const [sort, setSort] = useState<SortState>(null);
// Pagination
const [page, setPage] = useState(0);
const pageSize = 100;
// Custom SQL query
const [queryMode, setQueryMode] = useState(false);
const [sqlInput, setSqlInput] = useState("");
const [queryResult, setQueryResult] = useState<Record<string, unknown>[] | null>(null);
const [queryError, setQueryError] = useState<string | null>(null);
const [queryRunning, setQueryRunning] = useState(false);
// Schema panel toggle
const [showSchema, setShowSchema] = useState(false);
// Fetch table list on mount
useEffect(() => {
let cancelled = false;
async function introspect() {
setLoading(true);
setError(null);
try {
const res = await fetch(
`/api/workspace/db/introspect?path=${encodeURIComponent(dbPath)}`,
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `HTTP ${res.status}`);
}
const data = await res.json();
if (!cancelled) {
setTables(data.tables ?? []);
// Auto-select first table
if (data.tables?.length > 0) {
setSelectedTable(data.tables[0].table_name);
}
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Failed to introspect database");
}
} finally {
if (!cancelled) {setLoading(false);}
}
}
introspect();
return () => { cancelled = true; };
}, [dbPath]);
// Fetch table data when selection or page changes
const fetchTableData = useCallback(
async (tableName: string, offset: number) => {
setDataLoading(true);
try {
const safeName = tableName.replace(/"/g, '""');
const sql = `SELECT * FROM "${safeName}" LIMIT ${pageSize} OFFSET ${offset}`;
const res = await fetch("/api/workspace/db/query", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: dbPath, sql }),
});
if (!res.ok) {
setTableData([]);
return;
}
const data = await res.json();
setTableData(data.rows ?? []);
} catch {
setTableData([]);
} finally {
setDataLoading(false);
}
},
[dbPath],
);
useEffect(() => {
if (selectedTable) {
setSort(null);
fetchTableData(selectedTable, page * pageSize);
}
}, [selectedTable, page, fetchTableData]);
// Run custom query
const runQuery = useCallback(async () => {
if (!sqlInput.trim()) {return;}
setQueryRunning(true);
setQueryError(null);
setQueryResult(null);
try {
const res = await fetch("/api/workspace/db/query", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: dbPath, sql: sqlInput }),
});
const data = await res.json();
if (!res.ok) {
setQueryError(data.error || `HTTP ${res.status}`);
} else {
setQueryResult(data.rows ?? []);
}
} catch (err) {
setQueryError(err instanceof Error ? err.message : "Query failed");
} finally {
setQueryRunning(false);
}
}, [dbPath, sqlInput]);
// Get selected table info
const selectedTableInfo = useMemo(
() => tables.find((t) => t.table_name === selectedTable) ?? null,
[tables, selectedTable],
);
// Sort client-side
const sortedData = useMemo(() => {
const data = queryMode && queryResult ? queryResult : tableData;
if (!sort) {return data;}
return [...data].toSorted((a, b) => {
const aVal = String(a[sort.column] ?? "");
const bVal = String(b[sort.column] ?? "");
const cmp = aVal.localeCompare(bVal, undefined, { numeric: true });
return sort.direction === "asc" ? cmp : -cmp;
});
}, [queryMode, queryResult, tableData, sort]);
const handleSort = (column: string) => {
setSort((prev) => {
if (prev?.column === column) {
return prev.direction === "asc"
? { column, direction: "desc" }
: null;
}
return { column, direction: "asc" };
});
};
// Derive columns from data
const dataColumns = useMemo(() => {
const data = queryMode && queryResult ? queryResult : tableData;
if (data.length === 0) {return [];}
return Object.keys(data[0]);
}, [queryMode, queryResult, tableData]);
// Detect database engine from filename
const dbEngine = useMemo(() => {
const ext = filename.split(".").pop()?.toLowerCase();
if (ext === "duckdb") {return "DuckDB";}
if (ext === "sqlite" || ext === "sqlite3") {return "SQLite";}
if (ext === "postgres") {return "PostgreSQL";}
if (ext === "db") {return "Database";}
return "Database";
}, [filename]);
// --- Loading state ---
if (loading) {
return (
<div className="flex items-center justify-center h-full gap-3">
<div
className="w-5 h-5 border-2 rounded-full animate-spin"
style={{ borderColor: "var(--color-border)", borderTopColor: "var(--color-accent)" }}
/>
<span className="text-sm" style={{ color: "var(--color-text-muted)" }}>
Loading database...
</span>
</div>
);
}
// --- Error state ---
if (error) {
return (
<div className="flex flex-col items-center justify-center h-full gap-4 p-8">
<DatabaseIcon size={48} />
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
Failed to open database
</p>
<p
className="text-xs px-3 py-2 rounded-lg max-w-md text-center"
style={{ background: "var(--color-surface)", color: "#f87171" }}
>
{error}
</p>
</div>
);
}
return (
<div className="flex h-full">
{/* Left panel: Table list */}
<div
className="w-56 flex-shrink-0 border-r flex flex-col overflow-hidden"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
{/* Database header */}
<div className="px-3 py-3 border-b flex items-center gap-2" style={{ borderColor: "var(--color-border)" }}>
<span style={{ color: "var(--color-accent)" }}>
<DatabaseIcon size={18} />
</span>
<div className="min-w-0 flex-1">
<div className="text-xs font-medium truncate" style={{ color: "var(--color-text)" }}>
{filename}
</div>
<div className="text-[10px]" style={{ color: "var(--color-text-muted)" }}>
{dbEngine} &middot; {tables.length} table{tables.length !== 1 ? "s" : ""}
</div>
</div>
</div>
{/* Table list */}
<div className="flex-1 overflow-y-auto py-1">
{tables.length === 0 ? (
<div className="px-3 py-6 text-center text-xs" style={{ color: "var(--color-text-muted)" }}>
No tables found
</div>
) : (
tables.map((t) => {
const isView = t.table_name.startsWith("v_");
const isActive = selectedTable === t.table_name;
return (
<button
type="button"
key={t.table_name}
onClick={() => {
setSelectedTable(t.table_name);
setPage(0);
setQueryMode(false);
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors duration-75 cursor-pointer"
style={{
background: isActive ? "var(--color-surface-hover)" : "transparent",
color: isActive ? "var(--color-text)" : "var(--color-text-muted)",
}}
onMouseEnter={(e) => {
if (!isActive) {(e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";}
}}
onMouseLeave={(e) => {
if (!isActive) {(e.currentTarget as HTMLElement).style.background = "transparent";}
}}
>
<span className="flex-shrink-0" style={{ color: isView ? "#60a5fa" : "var(--color-accent)" }}>
{isView ? <ViewIcon /> : <TableIcon />}
</span>
<span className="truncate flex-1">{t.table_name}</span>
<span className="flex-shrink-0 text-[10px] tabular-nums" style={{ color: "var(--color-text-muted)" }}>
{formatRowCount(t.estimated_row_count)}
</span>
</button>
);
})
)}
</div>
{/* Query mode toggle */}
<div className="px-3 py-2 border-t" style={{ borderColor: "var(--color-border)" }}>
<button
type="button"
onClick={() => setQueryMode(!queryMode)}
className="w-full flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors duration-100 cursor-pointer"
style={{
background: queryMode ? "rgba(232, 93, 58, 0.15)" : "var(--color-surface-hover)",
color: queryMode ? "var(--color-accent)" : "var(--color-text-muted)",
border: `1px solid ${queryMode ? "var(--color-accent)" : "var(--color-border)"}`,
}}
>
<PlayIcon />
SQL Query
</button>
</div>
</div>
{/* Right panel: Data / Query */}
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
{queryMode ? (
<QueryPanel
sqlInput={sqlInput}
setSqlInput={setSqlInput}
queryResult={queryResult}
queryError={queryError}
queryRunning={queryRunning}
runQuery={runQuery}
dataColumns={dataColumns}
sortedData={sortedData}
sort={sort}
onSort={handleSort}
/>
) : selectedTableInfo ? (
<TableDataPanel
table={selectedTableInfo}
data={sortedData}
dataLoading={dataLoading}
dataColumns={dataColumns}
sort={sort}
onSort={handleSort}
page={page}
pageSize={pageSize}
onPageChange={setPage}
showSchema={showSchema}
onToggleSchema={() => setShowSchema(!showSchema)}
/>
) : (
<div className="flex items-center justify-center h-full">
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
Select a table to view its data
</p>
</div>
)}
</div>
</div>
);
}
// --- Table Data Panel ---
function TableDataPanel({
table,
data,
dataLoading,
dataColumns,
sort,
onSort,
page,
pageSize,
onPageChange,
showSchema,
onToggleSchema,
}: {
table: TableInfo;
data: Record<string, unknown>[];
dataLoading: boolean;
dataColumns: string[];
sort: SortState;
onSort: (col: string) => void;
page: number;
pageSize: number;
onPageChange: (page: number) => void;
showSchema: boolean;
onToggleSchema: () => void;
}) {
const totalRows = table.estimated_row_count;
const totalPages = Math.max(1, Math.ceil(totalRows / pageSize));
return (
<div className="flex flex-col h-full">
{/* Table header bar */}
<div
className="flex items-center gap-3 px-4 py-2.5 border-b flex-shrink-0"
style={{ borderColor: "var(--color-border)" }}
>
<span style={{ color: "var(--color-accent)" }}>
<TableIcon />
</span>
<span className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
{table.table_name}
</span>
{/* Stats */}
<div className="flex items-center gap-2 ml-auto">
<span
className="text-[10px] px-2 py-0.5 rounded-full"
style={{
background: "var(--color-surface)",
color: "var(--color-text-muted)",
border: "1px solid var(--color-border)",
}}
>
{table.estimated_row_count.toLocaleString()} rows
</span>
<span
className="text-[10px] px-2 py-0.5 rounded-full"
style={{
background: "var(--color-surface)",
color: "var(--color-text-muted)",
border: "1px solid var(--color-border)",
}}
>
{table.column_count} columns
</span>
<button
type="button"
onClick={onToggleSchema}
className="text-[10px] px-2 py-0.5 rounded-full cursor-pointer transition-colors duration-100"
style={{
background: showSchema ? "rgba(96, 165, 250, 0.15)" : "var(--color-surface)",
color: showSchema ? "#60a5fa" : "var(--color-text-muted)",
border: `1px solid ${showSchema ? "#60a5fa" : "var(--color-border)"}`,
}}
>
Schema
</button>
</div>
</div>
{/* Schema panel (collapsible) */}
{showSchema && (
<div
className="px-4 py-3 border-b overflow-x-auto flex-shrink-0"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<div className="flex flex-wrap gap-x-6 gap-y-1.5">
{table.columns.map((col) => {
const display = typeDisplay(col.type);
return (
<div key={col.name} className="flex items-center gap-1.5 text-xs">
<span style={{ color: "var(--color-text-muted)" }}>
<ColumnIcon />
</span>
<span className="font-medium" style={{ color: "var(--color-text)" }}>
{col.name}
</span>
<span
className="px-1 py-px rounded text-[10px]"
style={{ background: `${display.color}18`, color: display.color }}
>
{display.label}
</span>
{col.is_nullable && (
<span className="text-[10px]" style={{ color: "var(--color-text-muted)", opacity: 0.5 }}>
null
</span>
)}
</div>
);
})}
</div>
</div>
)}
{/* Data table */}
<div className="flex-1 overflow-auto relative">
{dataLoading ? (
<div className="flex items-center justify-center h-32">
<div
className="w-5 h-5 border-2 rounded-full animate-spin"
style={{ borderColor: "var(--color-border)", borderTopColor: "var(--color-accent)" }}
/>
</div>
) : data.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 gap-2">
<span style={{ color: "var(--color-text-muted)", opacity: 0.4 }}>
<TableIcon />
</span>
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
No data
</p>
</div>
) : (
<DataTable
columns={dataColumns}
rows={data}
sort={sort}
onSort={onSort}
schemaColumns={table.columns}
/>
)}
</div>
{/* Pagination */}
{totalRows > pageSize && (
<div
className="flex items-center justify-between px-4 py-2 border-t flex-shrink-0"
style={{ borderColor: "var(--color-border)" }}
>
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>
Page {page + 1} of {totalPages}
</span>
<div className="flex items-center gap-1">
<button
type="button"
disabled={page === 0}
onClick={() => onPageChange(page - 1)}
className="p-1 rounded transition-colors duration-100 cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
style={{ color: "var(--color-text-muted)" }}
>
<ChevronIcon direction="left" />
</button>
<button
type="button"
disabled={page >= totalPages - 1}
onClick={() => onPageChange(page + 1)}
className="p-1 rounded transition-colors duration-100 cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
style={{ color: "var(--color-text-muted)" }}
>
<ChevronIcon direction="right" />
</button>
</div>
</div>
)}
</div>
);
}
// --- Query Panel ---
function QueryPanel({
sqlInput,
setSqlInput,
queryResult,
queryError,
queryRunning,
runQuery,
dataColumns,
sortedData,
sort,
onSort,
}: {
sqlInput: string;
setSqlInput: (v: string) => void;
queryResult: Record<string, unknown>[] | null;
queryError: string | null;
queryRunning: boolean;
runQuery: () => void;
dataColumns: string[];
sortedData: Record<string, unknown>[];
sort: SortState;
onSort: (col: string) => void;
}) {
return (
<div className="flex flex-col h-full">
{/* SQL input */}
<div
className="px-4 py-3 border-b flex-shrink-0"
style={{ borderColor: "var(--color-border)" }}
>
<div className="flex items-start gap-2">
<div className="flex-1">
<textarea
value={sqlInput}
onChange={(e) => setSqlInput(e.target.value)}
placeholder="SELECT * FROM table_name LIMIT 100"
className="w-full text-xs rounded-lg px-3 py-2 resize-none outline-none"
style={{
background: "var(--color-surface)",
color: "var(--color-text)",
border: "1px solid var(--color-border)",
fontFamily: "'SF Mono', 'Fira Code', 'JetBrains Mono', monospace",
minHeight: "60px",
}}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
runQuery();
}
}}
/>
<div className="text-[10px] mt-1" style={{ color: "var(--color-text-muted)" }}>
Press Cmd+Enter to run
</div>
</div>
<button
type="button"
onClick={runQuery}
disabled={queryRunning || !sqlInput.trim()}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium transition-colors duration-100 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
style={{
background: "var(--color-accent)",
color: "white",
}}
>
{queryRunning ? (
<div
className="w-3.5 h-3.5 border-2 rounded-full animate-spin"
style={{ borderColor: "rgba(255,255,255,0.3)", borderTopColor: "white" }}
/>
) : (
<PlayIcon />
)}
Run
</button>
</div>
</div>
{/* Results */}
<div className="flex-1 overflow-auto">
{queryError && (
<div className="px-4 py-3">
<div
className="px-3 py-2 rounded-lg text-xs"
style={{ background: "rgba(248, 113, 113, 0.1)", color: "#f87171", border: "1px solid rgba(248, 113, 113, 0.2)" }}
>
{queryError}
</div>
</div>
)}
{queryResult !== null && queryResult.length === 0 && !queryError && (
<div className="flex items-center justify-center py-12">
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
Query returned no results
</p>
</div>
)}
{queryResult !== null && queryResult.length > 0 && (
<>
<div className="px-4 py-1.5">
<span className="text-[10px]" style={{ color: "var(--color-text-muted)" }}>
{queryResult.length} row{queryResult.length !== 1 ? "s" : ""}
</span>
</div>
<DataTable
columns={dataColumns}
rows={sortedData}
sort={sort}
onSort={onSort}
/>
</>
)}
{queryResult === null && !queryError && (
<div className="flex flex-col items-center justify-center py-20 gap-3">
<span style={{ color: "var(--color-text-muted)", opacity: 0.3 }}>
<PlayIcon />
</span>
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
Write a SQL query and press Run
</p>
</div>
)}
</div>
</div>
);
}
// --- Shared Data Table ---
function DataTable({
columns,
rows,
sort,
onSort,
schemaColumns,
}: {
columns: string[];
rows: Record<string, unknown>[];
sort: SortState;
onSort: (col: string) => void;
schemaColumns?: ColumnInfo[];
}) {
return (
<table
className="w-full text-xs"
style={{ borderCollapse: "separate", borderSpacing: 0 }}
>
<thead>
<tr>
{/* Row number column */}
<th
className="text-right px-2 py-2 font-normal whitespace-nowrap border-b sticky top-0 z-[1]"
style={{
color: "var(--color-text-muted)",
borderColor: "var(--color-border)",
background: "var(--color-surface)",
width: "2.5rem",
opacity: 0.5,
}}
>
#
</th>
{columns.map((col) => {
const schema = schemaColumns?.find((c) => c.name === col);
const display = schema ? typeDisplay(schema.type) : null;
return (
<th
key={col}
className="text-left px-3 py-2 font-medium whitespace-nowrap border-b cursor-pointer select-none sticky top-0 z-[1]"
style={{
color: "var(--color-text-muted)",
borderColor: "var(--color-border)",
background: "var(--color-surface)",
}}
onClick={() => onSort(col)}
>
<span className="flex items-center gap-1">
{col}
{display && (
<span
className="text-[9px] px-1 rounded"
style={{ color: display.color, opacity: 0.6 }}
>
{display.label}
</span>
)}
<SortIndicator
active={sort?.column === col}
direction={sort?.column === col ? sort.direction : "asc"}
/>
</span>
</th>
);
})}
</tr>
</thead>
<tbody>
{rows.map((row, idx) => (
<tr
key={idx}
className="transition-colors duration-75 group"
style={{
background: idx % 2 === 0 ? "transparent" : "var(--color-surface)",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background =
idx % 2 === 0 ? "transparent" : "var(--color-surface)";
}}
>
{/* Row number */}
<td
className="text-right px-2 py-1.5 border-b tabular-nums"
style={{
color: "var(--color-text-muted)",
borderColor: "var(--color-border)",
opacity: 0.4,
}}
>
{idx + 1}
</td>
{columns.map((col) => (
<td
key={col}
className="px-3 py-1.5 border-b whitespace-nowrap"
style={{ borderColor: "var(--color-border)", color: "var(--color-text)" }}
>
<CellContent value={row[col]} />
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
// --- Cell content renderer ---
function CellContent({ value }: { value: unknown }) {
if (value === null || value === undefined) {
return (
<span style={{ color: "var(--color-text-muted)", opacity: 0.4, fontStyle: "italic" }}>
null
</span>
);
}
if (typeof value === "boolean") {
return (
<span style={{ color: value ? "#22c55e" : "#f87171" }}>
{value ? "true" : "false"}
</span>
);
}
if (typeof value === "number") {
return <span className="tabular-nums">{value}</span>;
}
const str = String(value);
// Truncate very long values
if (str.length > 120) {
return (
<span title={str} className="cursor-help">
{str.slice(0, 120)}
<span style={{ color: "var(--color-text-muted)" }}>...</span>
</span>
);
}
return <span>{str}</span>;
}

View File

@ -5,7 +5,7 @@ import { useState, useCallback } from "react";
export type TreeNode = {
name: string;
path: string;
type: "object" | "document" | "folder" | "file";
type: "object" | "document" | "folder" | "file" | "database";
icon?: string;
defaultView?: "table" | "kanban";
children?: TreeNode[];
@ -57,6 +57,16 @@ function FileIcon() {
);
}
function DatabaseIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M3 5V19A9 3 0 0 0 21 19V5" />
<path d="M3 12A9 3 0 0 0 21 12" />
</svg>
);
}
function ChevronIcon({ open }: { open: boolean }) {
return (
<svg
@ -88,6 +98,8 @@ function NodeIcon({ node, open }: { node: TreeNode; open?: boolean }) {
return <DocumentIcon />;
case "folder":
return <FolderIcon open={open} />;
case "database":
return <DatabaseIcon />;
default:
return <FileIcon />;
}
@ -127,7 +139,9 @@ function TreeNodeItem({
? "var(--color-accent)"
: node.type === "document"
? "#60a5fa"
: "var(--color-text-muted)";
: node.type === "database"
? "#c084fc"
: "var(--color-text-muted)";
return (
<div>

View File

@ -8,6 +8,7 @@ import { ObjectTable } from "../components/workspace/object-table";
import { ObjectKanban } from "../components/workspace/object-kanban";
import { DocumentView } from "../components/workspace/document-view";
import { FileViewer } from "../components/workspace/file-viewer";
import { DatabaseViewer } from "../components/workspace/database-viewer";
import { Breadcrumbs } from "../components/workspace/breadcrumbs";
import { EmptyState } from "../components/workspace/empty-state";
@ -56,6 +57,7 @@ type ContentState =
| { kind: "object"; data: ObjectData }
| { kind: "document"; data: FileData; title: string }
| { kind: "file"; data: FileData; filename: string }
| { kind: "database"; dbPath: string; filename: string }
| { kind: "directory"; node: TreeNode };
// --- Helpers ---
@ -160,6 +162,9 @@ export default function WorkspacePage() {
data,
title: node.name.replace(/\.md$/, ""),
});
} else if (node.type === "database") {
// Database files are handled entirely by the DatabaseViewer component
setContent({ kind: "database", dbPath: node.path, filename: node.name });
} else if (node.type === "file") {
const res = await fetch(
`/api/workspace/file?path=${encodeURIComponent(node.path)}`,
@ -366,6 +371,14 @@ function ContentRenderer({
/>
);
case "database":
return (
<DatabaseViewer
dbPath={content.dbPath}
filename={content.filename}
/>
);
case "directory":
return (
<DirectoryListing
@ -441,13 +454,17 @@ function DirectoryListing({
? "rgba(232, 93, 58, 0.1)"
: child.type === "document"
? "rgba(96, 165, 250, 0.1)"
: "var(--color-surface-hover)",
: child.type === "database"
? "rgba(192, 132, 252, 0.1)"
: "var(--color-surface-hover)",
color:
child.type === "object"
? "var(--color-accent)"
: child.type === "document"
? "#60a5fa"
: "var(--color-text-muted)",
: child.type === "database"
? "#c084fc"
: "var(--color-text-muted)",
}}
>
<NodeTypeIcon type={child.type} />
@ -640,6 +657,14 @@ function NodeTypeIcon({ type }: { type: string }) {
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
</svg>
);
case "database":
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M3 5V19A9 3 0 0 0 21 19V5" />
<path d="M3 12A9 3 0 0 0 21 12" />
</svg>
);
default:
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">

View File

@ -91,6 +91,49 @@ export function duckdbQuery<T = Record<string, unknown>>(
}
}
/** 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.

View File

@ -29,6 +29,74 @@ dench/
WORKSPACE.md # Auto-generated schema summary
```
## .object.yaml Format
Every object directory MUST contain a `.object.yaml` file. This is a lightweight metadata projection that the sidebar reads. Generate it from DuckDB after creating or modifying any object.
Template:
```yaml
id: "<object_id from DuckDB>"
name: "<object_name>"
description: "<object_description>"
icon: "<lucide_icon_name>"
default_view: "<table|kanban>"
entry_count: <number>
fields:
- name: "Full Name"
type: text
required: true
- name: "Email Address"
type: email
required: true
- name: "Status"
type: enum
values: ["New", "Contacted", "Qualified", "Converted"]
- name: "Assigned To"
type: user
```
Generate by querying DuckDB then writing the file:
```bash
# 1. Query object + fields from DuckDB
duckdb dench/workspace.duckdb -json "
SELECT o.id, o.name, o.description, o.icon, o.default_view,
(SELECT COUNT(*) FROM entries WHERE object_id = o.id) as entry_count
FROM objects o WHERE o.name = 'lead'
"
duckdb dench/workspace.duckdb -json "
SELECT name, type, required, enum_values FROM fields
WHERE object_id = (SELECT id FROM objects WHERE name = 'lead')
ORDER BY sort_order
"
# 2. Write .object.yaml from the query results
mkdir -p dench/knowledge/lead
cat > dench/knowledge/lead/.object.yaml << 'YAML'
id: "AbCdEfGh..."
name: "lead"
description: "Sales leads tracking"
icon: "user-plus"
default_view: "table"
entry_count: 20
fields:
- name: "Full Name"
type: text
required: true
- name: "Email Address"
type: email
required: true
- name: "Status"
type: enum
values: ["New", "Contacted", "Qualified", "Converted"]
- name: "Score"
type: number
- name: "Notes"
type: richtext
YAML
```
## Startup
On every conversation:
@ -268,17 +336,19 @@ COPY (SELECT * FROM v_leads) TO 'dench/exports/leads.csv' (HEADER true);
## Full Workflow: Create CRM Structure in One Shot
Batch everything in a single exec call:
EVERY object creation MUST complete ALL THREE steps below. Never stop after the SQL.
**Step 1 — SQL: Create object + fields + view** (single exec call):
```sql
BEGIN TRANSACTION;
-- 1. Create object
-- 1a. Create object
INSERT INTO objects (name, description, icon, default_view)
VALUES ('lead', 'Sales leads tracking', 'user-plus', 'table')
ON CONFLICT (name) DO NOTHING;
-- 2. Create all fields
-- 1b. Create all fields
INSERT INTO fields (object_id, name, type, required, sort_order) VALUES
((SELECT id FROM objects WHERE name = 'lead'), 'Full Name', 'text', true, 0),
((SELECT id FROM objects WHERE name = 'lead'), 'Email Address', 'email', true, 1),
@ -295,7 +365,7 @@ INSERT INTO fields (object_id, name, type, enum_values, enum_colors, sort_order)
'["Website","Referral","Cold Call","Social"]'::JSON, NULL, 5)
ON CONFLICT (object_id, name) DO NOTHING;
-- 3. Auto-generate PIVOT view
-- 1c. MANDATORY: auto-generate PIVOT view
CREATE OR REPLACE VIEW v_lead AS
PIVOT (
SELECT e.id as entry_id, e.created_at, e.updated_at,
@ -309,11 +379,58 @@ PIVOT (
COMMIT;
```
Then project the filesystem: `mkdir -p dench/knowledge/lead` and write `.object.yaml`.
**Step 2 — Filesystem: Create object directory + .object.yaml** (exec call):
```bash
mkdir -p dench/knowledge/lead
# Query the object metadata from DuckDB to build .object.yaml
OBJ_ID=$(duckdb dench/workspace.duckdb -noheader -list "SELECT id FROM objects WHERE name = 'lead'")
ENTRY_COUNT=$(duckdb dench/workspace.duckdb -noheader -list "SELECT COUNT(*) FROM entries WHERE object_id = '$OBJ_ID'")
cat > dench/knowledge/lead/.object.yaml << 'YAML'
id: "<use actual $OBJ_ID>"
name: "lead"
description: "Sales leads tracking"
icon: "user-plus"
default_view: "table"
entry_count: <use actual $ENTRY_COUNT>
fields:
- name: "Full Name"
type: text
required: true
- name: "Email Address"
type: email
required: true
- name: "Phone Number"
type: phone
- name: "Status"
type: enum
values: ["New", "Contacted", "Qualified", "Converted"]
- name: "Score"
type: number
- name: "Source"
type: enum
values: ["Website", "Referral", "Cold Call", "Social"]
- name: "Notes"
type: richtext
YAML
```
**Step 3 — Verify**: Confirm both the view and filesystem exist:
```bash
# Verify view works
duckdb dench/workspace.duckdb "SELECT COUNT(*) FROM v_lead"
# Verify .object.yaml exists
cat dench/knowledge/lead/.object.yaml
```
## Kanban Boards
When creating task/board objects, use `default_view = 'kanban'` and auto-create Status + Assigned To fields:
When creating task/board objects, use `default_view = 'kanban'` and auto-create Status + Assigned To fields. Remember: ALL THREE STEPS are required.
**Step 1 — SQL:**
```sql
BEGIN TRANSACTION;
@ -353,6 +470,28 @@ PIVOT (
COMMIT;
```
**Step 2 — Filesystem (MANDATORY):**
```bash
mkdir -p dench/knowledge/task
cat > dench/knowledge/task/.object.yaml << 'YAML'
id: "<query from DuckDB>"
name: "task"
description: "Task tracking board"
icon: "check-square"
default_view: "kanban"
entry_count: 0
fields:
- name: "Status"
type: enum
values: ["In Queue", "In Progress", "Done"]
- name: "Assigned To"
type: user
YAML
```
**Step 3 — Verify:** `duckdb dench/workspace.duckdb "SELECT COUNT(*) FROM v_task"` and `cat dench/knowledge/task/.object.yaml`.
## Field Types Reference
| Type | Description | Storage | Query Cast |
@ -444,17 +583,41 @@ VALUES ('Roadmap', 'map', 'projects/roadmap.md', '<parent_doc_id>', 0);
- Field type change: warn user before changing type on field with existing data.
- Missing required fields: validate before INSERT, report which fields are missing.
## Post-Mutation Pipeline
## Post-Mutation Checklist (MANDATORY)
After any schema mutation (create/update/delete object, field, or document):
You MUST complete ALL steps below after ANY schema mutation (create/update/delete object, field, or entry). Do NOT skip any step. Do NOT consider the operation complete until all steps are done.
1. **Regenerate views**: `CREATE OR REPLACE VIEW v_{object}` for affected objects
2. **Project filesystem**: Sync `dench/knowledge/` directory structure from DuckDB (mkdir/rmdir for objects, write `.object.yaml`, move `.md` files if nesting changed)
3. **Regenerate WORKSPACE.md**: Human-readable summary of all objects, fields, entry counts
### After creating or modifying an OBJECT or its FIELDS:
- [ ] `CREATE OR REPLACE VIEW v_{object_name}` — regenerate the PIVOT view
- [ ] `mkdir -p dench/knowledge/{object_name}/` — create the object directory
- [ ] Write `dench/knowledge/{object_name}/.object.yaml` — metadata projection with id, name, description, icon, default_view, entry_count, and full field list
- [ ] If object has a `parent_document_id`, place directory inside the parent document's directory
- [ ] Update `WORKSPACE.md` if it exists
### After adding or updating ENTRIES:
- [ ] Update `entry_count` in the corresponding `.object.yaml`
- [ ] Verify the view returns correct data: `SELECT * FROM v_{object} LIMIT 5`
### After deleting an OBJECT:
- [ ] `DROP VIEW IF EXISTS v_{object_name}` — remove the view
- [ ] `rm -rf dench/knowledge/{object_name}/` — remove the directory (unless it contains nested documents that need relocating)
- [ ] Update `WORKSPACE.md`
### After creating or modifying a DOCUMENT:
- [ ] Write the `.md` file to the correct path in `dench/knowledge/`
- [ ] `INSERT INTO documents` — ensure metadata row exists with correct `file_path`, `parent_id`, or `parent_object_id`
These steps ensure the filesystem always mirrors DuckDB. The sidebar depends on `.object.yaml` files — if they are missing, objects will not appear.
## Critical Reminders
- Handle the ENTIRE CRM operation from analysis to SQL execution to summary
- Handle the ENTIRE CRM operation from analysis to SQL execution to filesystem projection to summary
- **NEVER SKIP FILESYSTEM PROJECTION**: After creating/modifying any object, you MUST create/update `dench/knowledge/{object}/.object.yaml` AND the `v_{object}` view. If you skip this, the object will be invisible in the sidebar. This is NOT optional.
- **THREE STEPS, EVERY TIME**: (1) SQL transaction, (2) filesystem projection (.object.yaml + directory), (3) verify. An operation is NOT complete until all three are done.
- Always check existing data before creating (`SELECT` before `INSERT`, or `ON CONFLICT`)
- Use views (`v_{object}`) for all reads — never write raw PIVOT queries for search
- Never assume field names — verify with `SELECT * FROM fields WHERE object_id = ?`
@ -468,3 +631,4 @@ After any schema mutation (create/update/delete object, field, or document):
- **ONE EXEC CALL**: Batch related SQL in a single transaction — this is the whole point
- **workspace_context.yaml**: READ-ONLY. Never modify. Data flows from Dench UI only.
- **Source of truth**: DuckDB for all structured data. Filesystem for document content and navigation tree. Never duplicate entry data to the filesystem.
- **ENTRY COUNT**: After adding entries, update `entry_count` in `.object.yaml`.