[taskboard] migrate fleet console to nextjs

This commit is contained in:
2026-03-06 14:44:27 -08:00
parent 94e54dc144
commit a765b3d22f
48 changed files with 5483 additions and 790 deletions

268
lib/agents.ts Normal file
View File

@@ -0,0 +1,268 @@
import fs from "node:fs";
import path from "node:path";
import {
ARCHITECTURE_DOCUMENT,
OPENCLAW_AGENTS_DIR,
OPENCLAW_CONFIG_PATH,
ZEROCLAW_CONTROL_DIR,
ZEROCLAW_PRIMARY_DIR,
} from "@/lib/fleet-config";
import { all } from "@/lib/db";
import { normalizeTask } from "@/lib/tasks";
import type { AgentStatus, FleetAgent, TaskRecord } from "@/lib/types";
type OpenClawAgentConfig = {
id: string;
name?: string;
model?: { primary?: string };
identity?: { name?: string; emoji?: string; theme?: string };
subagents?: { allowAgents?: string[] };
};
type OpenClawConfigShape = {
agents?: { list?: OpenClawAgentConfig[] };
channels?: {
telegram?: {
groups?: Record<
string,
{
topics?: Record<string, { systemPrompt?: string }>;
}
>;
};
};
};
function readTextFile(filePath: string) {
if (!fs.existsSync(filePath)) {
return "";
}
return fs.readFileSync(filePath, "utf8");
}
function parseBulletValues(content: string) {
return content
.split("\n")
.map((line) => line.trim())
.filter((line) => line.startsWith("- "))
.map((line) => line.replace(/^- /, "").replace(/`/g, "").trim());
}
function parseRoleFromAgentsMd(content: string) {
const identityMatch = content.match(/- Scope:\s*(.+)/);
if (identityMatch) {
return identityMatch[1].trim();
}
return "Host-scoped agent";
}
function parseResponsibilities(content: string) {
const sectionMatch = content.match(/## Responsibilities([\s\S]*?)(##|$)/);
return sectionMatch ? parseBulletValues(sectionMatch[1]) : [];
}
function readWorkspaceAgent(agentRoot: string, fallbackName: string) {
const workspaceRoot = path.join(agentRoot, "workspace");
const agentsMd = readTextFile(path.join(workspaceRoot, "AGENTS.md"));
const toolsMd = readTextFile(path.join(workspaceRoot, "TOOLS.md"));
const identityMd = readTextFile(path.join(workspaceRoot, "IDENTITY.md"));
const heartbeatMd = readTextFile(path.join(workspaceRoot, "HEARTBEAT.md"));
const tools = parseBulletValues(toolsMd);
const capabilities = parseResponsibilities(agentsMd);
const currentTaskMatch = heartbeatMd.match(/Current Task:\s*(.+)/i);
return {
files: ["AGENTS.md", "TOOLS.md", "IDENTITY.md"].filter((fileName) =>
fs.existsSync(path.join(workspaceRoot, fileName)),
),
tools,
capabilities,
currentTask: currentTaskMatch ? currentTaskMatch[1].trim() : null,
role: parseRoleFromAgentsMd(agentsMd),
noteValues: parseBulletValues(identityMd),
workspaceRoot,
};
}
function readOpenClawConfig(): OpenClawConfigShape {
try {
return JSON.parse(fs.readFileSync(OPENCLAW_CONFIG_PATH, "utf8")) as OpenClawConfigShape;
} catch {
return {};
}
}
function getOpenClawChannels(agentId: string, config: OpenClawConfigShape) {
const summaries: { label: string; value: string }[] = [
{ label: "Family", value: "OpenClaw telegram + gateway" },
];
const topicGroups = config.channels?.telegram?.groups?.["-1003809447066"]?.topics;
if (!topicGroups) {
return summaries;
}
const topicEntries = Object.entries(topicGroups).filter(([, topic]) => {
const prompt = topic.systemPrompt || "";
return prompt.toLowerCase().includes(agentId.toLowerCase()) || agentId === "main";
});
if (topicEntries.length === 0 && agentId === "main") {
summaries.push({ label: "Forum", value: "Homelab HQ default route" });
return summaries;
}
topicEntries.forEach(([topicId]) => {
summaries.push({ label: "Topic", value: `Homelab HQ topic ${topicId}` });
});
return summaries;
}
async function fetchTaskBuckets(aliases: string[]) {
const placeholders = aliases.map(() => "?").join(", ");
const activeRows = await all<Omit<TaskRecord, "tags"> & { tags: string }>(
`SELECT * FROM tasks WHERE assignee IN (${placeholders}) AND status IN ('Todo', 'In Progress', 'Review')
ORDER BY priority DESC, created_at ASC`,
aliases,
);
const completedRows = await all<Omit<TaskRecord, "tags"> & { tags: string }>(
`SELECT * FROM tasks WHERE assignee IN (${placeholders}) AND status = 'Done'
ORDER BY completed_at DESC LIMIT 5`,
aliases,
);
return {
activeTasks: activeRows.map(normalizeTask),
completedTasks: completedRows.map(normalizeTask),
};
}
async function buildOpenClawAgents() {
const config = readOpenClawConfig();
const agents = config.agents?.list || [];
return Promise.all(
agents.map(async (agentConfig) => {
const agentRoot = path.join(OPENCLAW_AGENTS_DIR, agentConfig.id);
const workspace = readWorkspaceAgent(agentRoot, agentConfig.identity?.name || agentConfig.id);
const aliases = [
agentConfig.id,
agentConfig.identity?.name || agentConfig.name || agentConfig.id,
];
const taskBuckets = await fetchTaskBuckets(aliases);
return {
slug: agentConfig.id,
assignmentKey: agentConfig.id,
aliases,
family: "openclaw",
name: agentConfig.identity?.name || agentConfig.name || agentConfig.id,
host: "ubuntu",
role: agentConfig.identity?.theme || workspace.role,
runtimePath: workspace.workspaceRoot || OPENCLAW_AGENTS_DIR,
configPath: OPENCLAW_CONFIG_PATH,
model: agentConfig.model?.primary || null,
emoji: agentConfig.identity?.emoji || "🦞",
channels: getOpenClawChannels(agentConfig.id, config),
tools: workspace.tools,
capabilities: workspace.capabilities,
files: workspace.files,
status: deriveStatus(taskBuckets.activeTasks.length),
workload: taskBuckets.activeTasks.length,
activeTasks: taskBuckets.activeTasks,
completedTasks: taskBuckets.completedTasks,
currentTask: workspace.currentTask,
notes: workspace.noteValues,
} satisfies FleetAgent;
}),
);
}
async function buildZeroClawAgents() {
const configuredAgents = [
{
slug: "grizzley-zeroclaw",
assignmentKey: "grizzley-zeroclaw",
aliases: ["grizzley-zeroclaw", "ZeroClaw Grizzley", "grizzley"],
name: "ZeroClaw Grizzley",
host: "grizzley",
role: "Edge host operator for grizzley",
runtimePath: ZEROCLAW_PRIMARY_DIR,
configPath: path.join(ZEROCLAW_PRIMARY_DIR, "config.toml"),
model: "glm-4.7",
emoji: "🛰️",
channels: [
{ label: "Gateway", value: "HTTP gateway :3000" },
{ label: "Access", value: "paired remote gateway via ice" },
],
notes: ["Host-scoped runtime for Traefik, OpenCode, and local services."],
},
{
slug: "ice-zeroclaw",
assignmentKey: "ice-zeroclaw",
aliases: ["ice-zeroclaw", "ZeroClaw Ice", "ZeroClaw Admin", "ice"],
name: "ZeroClaw Ice",
host: "ice",
role: "Control-plane operator for ice",
runtimePath: ZEROCLAW_CONTROL_DIR,
configPath: path.join(ZEROCLAW_CONTROL_DIR, "config.toml"),
model: "glm-5",
emoji: "🧊",
channels: [
{ label: "Telegram", value: "Homelab-Ice topics 11-15" },
{ label: "Gateway", value: "paired webhook + status routing" },
],
notes: ["Control-plane runtime and topic router for remote host delegation."],
},
];
return Promise.all(
configuredAgents.map(async (configuredAgent) => {
const workspace = readWorkspaceAgent(configuredAgent.runtimePath, configuredAgent.name);
const taskBuckets = await fetchTaskBuckets(configuredAgent.aliases);
return {
...configuredAgent,
family: "zeroclaw" as const,
tools: workspace.tools,
capabilities: workspace.capabilities,
files: workspace.files,
status: deriveStatus(taskBuckets.activeTasks.length),
workload: taskBuckets.activeTasks.length,
activeTasks: taskBuckets.activeTasks,
completedTasks: taskBuckets.completedTasks,
currentTask: workspace.currentTask,
notes: [...configuredAgent.notes, ...workspace.noteValues],
};
}),
);
}
export async function listFleetAgents() {
const [openclawAgents, zeroclawAgents] = await Promise.all([
buildOpenClawAgents(),
buildZeroClawAgents(),
]);
return [...openclawAgents, ...zeroclawAgents];
}
export async function listArchitecture() {
const agents = await listFleetAgents();
return {
...ARCHITECTURE_DOCUMENT,
generatedAt: new Date().toISOString(),
sections: ARCHITECTURE_DOCUMENT.sections.map((section) => ({
...section,
configuredAgents: agents
.filter((agent) => agent.family === section.id)
.map((agent) => `${agent.name} (${agent.host})`),
})),
};
}
function deriveStatus(activeTaskCount: number): AgentStatus {
return activeTaskCount > 0 ? "busy" : "active";
}

86
lib/db.ts Normal file
View File

@@ -0,0 +1,86 @@
import fs from "node:fs";
import path from "node:path";
import sqlite3 from "sqlite3";
const DB_PATH = process.env.DB_PATH || path.join(process.cwd(), "data", "tasks.db");
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
let database: sqlite3.Database | null = null;
function getDatabase() {
if (database) {
return database;
}
database = new sqlite3.Database(DB_PATH);
database.serialize(() => {
database?.run(`
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT DEFAULT '',
assignee TEXT DEFAULT '',
priority TEXT NOT NULL DEFAULT 'Medium',
status TEXT NOT NULL DEFAULT 'Backlog',
tags TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
completed_at TEXT
)
`);
database?.run(`
CREATE TABLE IF NOT EXISTS usage_tracking (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent TEXT NOT NULL,
provider TEXT NOT NULL,
model TEXT NOT NULL,
request_type TEXT DEFAULT 'chat',
tokens_used INTEGER DEFAULT 0,
cost_estimate REAL DEFAULT 0,
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
});
return database;
}
export function all<T>(sql: string, params: unknown[] = []) {
const db = getDatabase();
return new Promise<T[]>((resolve, reject) => {
db.all(sql, params, (error, rows) => {
if (error) {
reject(error);
return;
}
resolve(rows as T[]);
});
});
}
export function get<T>(sql: string, params: unknown[] = []) {
const db = getDatabase();
return new Promise<T | undefined>((resolve, reject) => {
db.get(sql, params, (error, row) => {
if (error) {
reject(error);
return;
}
resolve(row as T | undefined);
});
});
}
export function run(sql: string, params: unknown[] = []) {
const db = getDatabase();
return new Promise<{ lastID: number; changes: number }>((resolve, reject) => {
db.run(sql, params, function onRun(error) {
if (error) {
reject(error);
return;
}
resolve({ lastID: this.lastID, changes: this.changes });
});
});
}

124
lib/fleet-config.ts Normal file
View File

@@ -0,0 +1,124 @@
import type { ArchitectureDocument } from "@/lib/types";
export const OPENCLAW_RUNTIME_ROOT =
process.env.OPENCLAW_RUNTIME_ROOT || "/home/bear/.openclaw";
export const OPENCLAW_AGENTS_DIR =
process.env.AGENTS_DIR || "/home/bear/.openclaw/agents";
export const OPENCLAW_CONFIG_PATH =
process.env.OPENCLAW_CONFIG || "/home/bear/.openclaw/openclaw.json";
export const ZEROCLAW_PRIMARY_DIR =
process.env.ZEROCLAW_PRIMARY_DIR || "/app/zeroclaw/grizzley";
export const ZEROCLAW_CONTROL_DIR =
process.env.ZEROCLAW_CONTROL_DIR || "/app/zeroclaw/ice";
export const ARCHITECTURE_DOCUMENT: ArchitectureDocument = {
generatedAt: new Date().toISOString(),
title: "Claw Fleet Architecture",
overview: [
"OpenClaw is the ubuntu-local orchestration layer and Telegram HQ entrypoint.",
"ZeroClaw provides host-scoped remote administration on grizzley and ice.",
"Task assignment is shared across both families in a single board.",
],
topologyDiagram: String.raw`
Telegram / Forum Topics
|
+----------------+----------------+
| |
v v
OpenClaw gateway ZeroClaw control
ubuntu :18789 ice zeroclaw-admin
local swarm topic router / paired gateway
| |
+------------+--------------------+
|
v
shared taskboard UI
|
+-----------+-----------+
| |
v v
OpenClaw agents ZeroClaw runtimes
ubuntu-local swarm grizzley / ice
`,
sections: [
{
id: "openclaw",
title: "OpenClaw",
summary:
"Primary orchestration family on ubuntu. Owns local swarm execution, HQ Telegram bindings, and ubuntu-host workflows.",
runtime: [
{ label: "Host", value: "ubuntu (192.168.50.61)" },
{ label: "Service", value: "openclaw.service" },
{ label: "Runtime", value: "/srv/state/openclaw/current" },
{ label: "Config", value: "/home/bear/.openclaw/openclaw.json" },
],
channels: [
{ label: "Telegram DM", value: "allowlist: tg:5512934365" },
{ label: "Forum Group", value: "Homelab HQ (-1003809447066)" },
{ label: "Gateway", value: "LAN bind :18789 with token auth" },
],
configuredAgents: [
"main",
"ubuntu",
"docs",
"gitea-admin",
"planner",
"builder",
"reviewer",
],
diagram: String.raw`
OpenClaw HQ topics
topic 2 -> ubuntu
topic 3 -> docs
topic 4 -> gitea-admin
topics 5-9 -> main, then delegate to host-scoped ZeroClaw paths
main
|- ubuntu
|- docs
|- gitea-admin
|- planner
|- builder
\- reviewer
`,
notes: [
"Remote host personas were removed from OpenClaw.",
"OpenClaw remains gateway-only on ubuntu.",
],
},
{
id: "zeroclaw",
title: "ZeroClaw",
summary:
"Host-scoped runtime family for remote administration. Grizzley is the primary active gateway. Ice is the control-plane runtime and topic router.",
runtime: [
{ label: "Primary", value: "/srv/state/zeroclaw/current on grizzley" },
{ label: "Control", value: "/home/bear/.zeroclaw-admin on ice" },
{ label: "Primary Service", value: "zeroclaw.service" },
{ label: "Control Service", value: "zeroclaw-admin.service" },
],
channels: [
{ label: "Grizzley Gateway", value: "HTTP gateway :3000, pairing required" },
{ label: "Ice Telegram", value: "Homelab-Ice (-1003728617160)" },
{ label: "Remote Routing", value: "paired status/webhook to grizzley and pve" },
],
configuredAgents: ["grizzley-zeroclaw", "ice-zeroclaw"],
diagram: String.raw`
Homelab-Ice topics
11 -> local ice operations
12 -> grizzley paired gateway
13 -> pve paired gateway
14 -> truenas blocker message
15 -> panda rollout pending
ice zeroclaw-admin
-> zeroclaw-remote-gateway.sh status grizzley|pve
-> zeroclaw-remote-gateway.sh webhook grizzley|pve "<message>"
`,
notes: [
"Grizzley is host-scoped and should not proxy other hosts directly.",
"Ice still uses host-local secret/encryption state under /home/bear/.zeroclaw-admin.",
],
},
],
};

162
lib/tasks.ts Normal file
View File

@@ -0,0 +1,162 @@
import fs from "node:fs";
import path from "node:path";
import { all, get, run } from "@/lib/db";
import type { TaskPriority, TaskRecord, TaskStatus } from "@/lib/types";
const VALID_STATUSES: TaskStatus[] = [
"Backlog",
"Todo",
"In Progress",
"Review",
"Done",
];
const VALID_PRIORITIES: TaskPriority[] = ["Low", "Medium", "High", "Critical"];
const WIKI_DIR = process.env.WIKI_DIR || path.join(process.cwd(), "wiki");
fs.mkdirSync(WIKI_DIR, { recursive: true });
type DatabaseTaskRow = Omit<TaskRecord, "tags"> & { tags: string };
export function normalizeTask(row: DatabaseTaskRow): TaskRecord {
let tags: string[] = [];
try {
tags = JSON.parse(row.tags || "[]");
} catch {
tags = [];
}
return {
...row,
tags,
};
}
export async function listTasks() {
const rows = await all<DatabaseTaskRow>("SELECT * FROM tasks ORDER BY id DESC");
return rows.map(normalizeTask);
}
export async function findTask(id: number) {
const row = await get<DatabaseTaskRow>("SELECT * FROM tasks WHERE id = ?", [id]);
return row ? normalizeTask(row) : null;
}
export function validateTaskPayload(
payload: Partial<TaskRecord> & { tags?: unknown },
partial = false,
) {
const errors: string[] = [];
if (!partial || payload.title !== undefined) {
if (typeof payload.title !== "string" || payload.title.trim().length === 0) {
errors.push("title is required");
}
}
if (payload.status !== undefined && !VALID_STATUSES.includes(payload.status)) {
errors.push(`status must be one of: ${VALID_STATUSES.join(", ")}`);
}
if (payload.priority !== undefined && !VALID_PRIORITIES.includes(payload.priority)) {
errors.push(`priority must be one of: ${VALID_PRIORITIES.join(", ")}`);
}
if (payload.tags !== undefined && !Array.isArray(payload.tags)) {
errors.push("tags must be an array of strings");
}
return errors;
}
function buildWikiMarkdown(task: TaskRecord) {
const renderedTags = task.tags.length ? task.tags.join(", ") : "None";
return `# ${task.title}
- Task ID: ${task.id}
- Assignee: ${task.assignee || "Unassigned"}
- Priority: ${task.priority}
- Status: ${task.status}
- Tags: ${renderedTags}
- Created: ${task.created_at}
- Completed: ${task.completed_at || new Date().toISOString()}
## Description
${task.description || "No description provided."}
`;
}
async function writeWikiForTask(task: TaskRecord) {
const safeTitle = task.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
const fileName = `${new Date().toISOString().slice(0, 10)}-task-${task.id}-${safeTitle || `task-${task.id}`}.md`;
fs.writeFileSync(path.join(WIKI_DIR, fileName), buildWikiMarkdown(task), "utf8");
}
export async function createTask(input: Partial<TaskRecord>) {
const result = await run(
`INSERT INTO tasks (title, description, assignee, priority, status, tags)
VALUES (?, ?, ?, ?, ?, ?)`,
[
input.title?.trim() || "",
input.description || "",
input.assignee || "",
input.priority || "Medium",
input.status || "Backlog",
JSON.stringify(Array.isArray(input.tags) ? input.tags.filter((tag) => typeof tag === "string") : []),
],
);
const task = await findTask(result.lastID);
if (!task) {
throw new Error("failed_to_fetch_created_task");
}
return task;
}
export async function updateTask(id: number, input: Partial<TaskRecord>) {
const existing = await findTask(id);
if (!existing) {
return null;
}
const nextStatus = input.status ?? existing.status;
const completedAt =
nextStatus === "Done"
? existing.completed_at || new Date().toISOString()
: null;
await run(
`UPDATE tasks
SET title = ?, description = ?, assignee = ?, priority = ?, status = ?, tags = ?,
completed_at = ?, updated_at = datetime('now')
WHERE id = ?`,
[
input.title?.trim() || existing.title,
input.description ?? existing.description,
input.assignee ?? existing.assignee,
input.priority ?? existing.priority,
nextStatus,
JSON.stringify(input.tags ?? existing.tags),
completedAt,
id,
],
);
const updated = await findTask(id);
if (!updated) {
throw new Error("failed_to_fetch_updated_task");
}
if (nextStatus === "Done" && existing.status !== "Done") {
await writeWikiForTask(updated);
}
return updated;
}

84
lib/types.ts Normal file
View File

@@ -0,0 +1,84 @@
export type TaskStatus = "Backlog" | "Todo" | "In Progress" | "Review" | "Done";
export type TaskPriority = "Low" | "Medium" | "High" | "Critical";
export type AgentFamily = "openclaw" | "zeroclaw";
export type AgentStatus = "active" | "busy" | "idle";
export type TaskRecord = {
id: number;
title: string;
description: string;
assignee: string;
priority: TaskPriority;
status: TaskStatus;
tags: string[];
created_at: string;
updated_at: string;
completed_at: string | null;
};
export type WikiPageSummary = {
filename: string;
title: string;
created: string;
modified: string;
tags: string[];
};
export type WikiPage = {
filename: string;
content: string;
metadata: {
title: string;
created: string;
modified: string;
tags: string[];
};
};
export type AgentRouteSummary = {
label: string;
value: string;
};
export type FleetAgent = {
slug: string;
assignmentKey: string;
aliases: string[];
family: AgentFamily;
name: string;
host: string;
role: string;
runtimePath: string;
configPath: string | null;
model: string | null;
emoji: string;
channels: AgentRouteSummary[];
tools: string[];
capabilities: string[];
files: string[];
status: AgentStatus;
workload: number;
activeTasks: TaskRecord[];
completedTasks: TaskRecord[];
currentTask: string | null;
notes: string[];
};
export type FleetSection = {
id: AgentFamily;
title: string;
summary: string;
runtime: AgentRouteSummary[];
channels: AgentRouteSummary[];
configuredAgents: string[];
diagram: string;
notes: string[];
};
export type ArchitectureDocument = {
generatedAt: string;
title: string;
overview: string[];
sections: FleetSection[];
topologyDiagram: string;
};

29
lib/utils.ts Normal file
View File

@@ -0,0 +1,29 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDateTime(value: string | null | undefined) {
if (!value) {
return "Unknown";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return new Intl.DateTimeFormat("en-US", {
dateStyle: "medium",
timeStyle: "short",
}).format(date);
}
export function slugify(value: string) {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}

130
lib/wiki.ts Normal file
View File

@@ -0,0 +1,130 @@
import fs from "node:fs";
import path from "node:path";
import type { WikiPage, WikiPageSummary } from "@/lib/types";
const WIKI_DIR = process.env.WIKI_DIR || path.join(process.cwd(), "wiki");
fs.mkdirSync(WIKI_DIR, { recursive: true });
function assertSafeFilename(filename: string) {
if (filename.includes("..") || filename.includes("/") || filename.includes("\\")) {
throw new Error("invalid_filename");
}
}
function extractMetadata(content: string) {
const metadata = {
title: "",
created: "",
modified: "",
tags: [] as string[],
};
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (frontmatterMatch) {
const frontmatter = frontmatterMatch[1];
const titleMatch = frontmatter.match(/title:\s*(.+)/i);
const tagsMatch = frontmatter.match(/tags:\s*\[(.+)\]/i);
if (titleMatch) {
metadata.title = titleMatch[1].trim();
}
if (tagsMatch) {
metadata.tags = tagsMatch[1].split(",").map((tag) => tag.trim()).filter(Boolean);
}
}
if (!metadata.title) {
const headingMatch = content.match(/^#\s+(.+)$/m);
if (headingMatch) {
metadata.title = headingMatch[1].trim();
}
}
return metadata;
}
export function listWikiPages(): WikiPageSummary[] {
if (!fs.existsSync(WIKI_DIR)) {
return [];
}
return fs
.readdirSync(WIKI_DIR)
.filter((fileName) => fileName.endsWith(".md"))
.map((filename) => {
const filePath = path.join(WIKI_DIR, filename);
const stats = fs.statSync(filePath);
const content = fs.readFileSync(filePath, "utf8");
const metadata = extractMetadata(content);
return {
filename,
title: metadata.title || filename.replace(".md", "").replace(/-/g, " "),
created: stats.birthtime.toISOString(),
modified: stats.mtime.toISOString(),
tags: metadata.tags,
};
})
.sort((left, right) => new Date(right.modified).getTime() - new Date(left.modified).getTime());
}
export function readWikiPage(filename: string): WikiPage | null {
assertSafeFilename(filename);
const filePath = path.join(WIKI_DIR, filename);
if (!fs.existsSync(filePath)) {
return null;
}
const content = fs.readFileSync(filePath, "utf8");
const stats = fs.statSync(filePath);
const metadata = extractMetadata(content);
return {
filename,
content,
metadata: {
title: metadata.title || filename.replace(".md", ""),
created: stats.birthtime.toISOString(),
modified: stats.mtime.toISOString(),
tags: metadata.tags,
},
};
}
export function createWikiPage(title: string) {
const safeTitle = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
const timestamp = new Date().toISOString().slice(0, 10);
let filename = `${timestamp}-${safeTitle}.md`;
let counter = 1;
while (fs.existsSync(path.join(WIKI_DIR, filename))) {
filename = `${timestamp}-${safeTitle}-${counter}.md`;
counter += 1;
}
const content = `# ${title}
## Summary
Document the architecture, deployment notes, or runbook here.
`;
fs.writeFileSync(path.join(WIKI_DIR, filename), content, "utf8");
return filename;
}
export function updateWikiPage(filename: string, content: string) {
assertSafeFilename(filename);
fs.writeFileSync(path.join(WIKI_DIR, filename), content, "utf8");
}
export function deleteWikiPage(filename: string) {
assertSafeFilename(filename);
fs.unlinkSync(path.join(WIKI_DIR, filename));
}