feat: implement reminder skill and enhance executor with natural language time parsing

This commit is contained in:
larchanka
2026-02-21 15:32:02 +01:00
committed by Mikhail Larchanka
parent 06951c7892
commit 07d0798eec
6 changed files with 283 additions and 177 deletions

View File

@@ -4,4 +4,5 @@
| --- | --- |
| weather | ALWAYS use this skill to get the current weather and forecasts. NEVER use any other tool for this purpose. |
| research | Deep web research using lynx. Use this for fact-checking, gathering news, or deep dives into specific topics. |
| apple-notes | ALWAYS use this skill when the user mentions "notes", "Apple Notes", "memo", or asks to "list", "search", or "create" a note. It utilizes the `memo` CLI to interact with the local Apple Notes database. |
| reminder | Set up one-time or recurring reminders (e.g., "remind me in 2 hours to drink water"). |
| apple-notes | ALWAYS use this skill when the user mentions "notes", "Apple Notes", or asks to "list", "search", or "create" a note. It utilizes the `memo` CLI to interact with the local Apple Notes database. |

41
skills/reminder/SKILL.md Normal file
View File

@@ -0,0 +1,41 @@
# Reminder Skill
Set up one-time or recurring reminders for the user.
## When to Use
**USE this skill when the user asks to:**
- "Remind me to [do something] [at/in/every/...] [time]"
- "Set a reminder for [time] to [do something]"
- "Don't forget to [do something] [time]"
- "Notify me [time] about [something]"
**DON'T use this skill when:**
- The user is asking to list current reminders (use core /reminders command instead).
- The user is asking to cancel a reminder.
- The user is asking to create a note (use apple-notes skill).
## Instructions
1. **Extract the Task**: Identify what the user wants to be reminded about.
2. **Extract the Time**: Identify the temporal expression (e.g., "in 2 hours", "every day at 8am").
3. **Schedule**: Call the `schedule_reminder` tool with the extracted time and message.
## Tool: schedule_reminder
**Arguments**:
- `time`: (string) Natural language time expression (e.g., "in 5 minutes", "tomorrow at 3pm", "every Monday").
- `message`: (string) The content of the reminder.
## Strategy
- Be precise with the `message`. If the user says "remind me to drink water", the message should be "Drink water".
- If the user provides a vague time, ask for clarification if necessary, or use your best judgment (e.g., "later today" could be "in 4 hours").
- The system will automatically handle parsing the natural language `time` string into a cron expression.
## Example Workflow
User Goal: "remind me to call Mom in 20 minutes"
1. Call `schedule_reminder(time="in 20 minutes", message="Call Mom")`.
2. Respond to the user: "Sure! I'll remind you to Call Mom in 20 minutes."

View File

@@ -26,6 +26,8 @@ Web research is a multi-step process. Do not stop at the first page of search re
4. **Recurse**: If a page contains a "References" section or promising links to deeper info, follow them.
5. **Summarize**: Gather all key findings and provide a comprehensive response.
**!!! IMPORTANT !!!** If you think that you dont have enough information to answer the user's request, you can generate new search queries and repeat the process.
## Commands
### 1. Perform a Search

View File

@@ -10,7 +10,7 @@ const MAX_CONCURRENT_NODES = 5;
const MAX_REVISION_CYCLES = 3;
const MAX_SKILL_TURNS = 5;
const SKILL_TOOLS = [
const SKILL_TOOLS: any[] = [
{
type: "function",
function: {
@@ -39,6 +39,21 @@ const SKILL_TOOLS = [
required: ["url"]
}
}
},
{
type: "function",
function: {
name: "schedule_reminder",
description: "Schedule a reminder for the user. Use natural language for the 'time' parameter.",
parameters: {
type: "object",
properties: {
time: { type: "string", description: "When to remind (e.g., 'in 2 hours', 'every Monday at 9am', 'tomorrow at 3pm')" },
message: { type: "string", description: "The content of the reminder (what to remind about)" }
},
required: ["time", "message"]
}
}
}
];
@@ -55,6 +70,7 @@ import { PROTOCOL_VERSION } from "../shared/protocol.js";
import { responsePayloadSchema } from "../shared/protocol.js";
import { getConfig } from "../shared/config.js";
import { SkillManager } from "../services/skill-manager.js";
import { parseTimeExpression } from "../services/time-parser.js";
const PLAN_EXECUTE = "plan.execute";
const NODE_EXECUTE = "node.execute";
@@ -483,7 +499,7 @@ export class ExecutorAgent extends BaseProcess {
// Execute each tool call
for (const tc of result.tool_calls) {
const toolResult = await this.callTool(taskId, node.id, tc.function.name, tc.function.arguments);
const toolResult = await this.callTool(taskId, node.id, tc.function.name, tc.function.arguments, context);
let content = typeof toolResult === "string" ? toolResult : JSON.stringify(toolResult);
// Safety truncation for large web pages or shell outputs to stay within context limits
if (content.length > 30000) {
@@ -610,6 +626,19 @@ export class ExecutorAgent extends BaseProcess {
// Extract cronExpr from input or dependency output
let cronExpr = nodeInput.cronExpr as string | undefined;
// Support natural language 'time' input
if (!cronExpr) {
const time = (nodeInput.time as string | undefined) ?? (nodeInput.prompt as string | undefined);
if (time) {
try {
const result = await parseTimeExpression(time);
cronExpr = result.cronExpr;
} catch (err) {
// Fallback to extraction from deps if parsing fails
}
}
}
// Check if cronExpr is a placeholder string (should be ignored)
const isPlaceholder = cronExpr && (
cronExpr.includes("<output from") ||
@@ -698,6 +727,7 @@ export class ExecutorAgent extends BaseProcess {
const chatId = (nodeInput.chatId as number | string | undefined) ??
(context._chatId as number | string | undefined);
let reminderMessage = (nodeInput.reminderMessage as string | undefined) ??
(nodeInput.message as string | undefined) ??
(context.reminderMessage as string | undefined);
const userId = (nodeInput.userId as number | string | undefined) ??
(context._userId as number | string | undefined);
@@ -874,7 +904,16 @@ export class ExecutorAgent extends BaseProcess {
});
}
private callTool(taskId: string, nodeId: string, name: string, args: any): Promise<any> {
private callTool(taskId: string, nodeId: string, name: string, args: any, context: Record<string, unknown> = {}): Promise<any> {
if (name === "schedule_reminder") {
const node: CapabilityNode = {
id: nodeId,
type: "schedule_reminder",
service: "cron-manager",
input: args
};
return this.handleScheduleReminder(taskId, node, context);
}
return new Promise((resolve, reject) => {
const id = randomUUID();
this.send({

View File

@@ -126,6 +126,26 @@ User: "Deep dive into the current status of the RISC-V ecosystem."
{ "from": "research-eco", "to": "final-report" }
]
}
## Example: Reminder
User: "remind me to drink water in 2 hrs"
{
"taskId": "task-rem-01",
"complexity": "small",
"reflectionMode": "OFF",
"nodes": [
{
"id": "rem-node",
"type": "skill",
"service": "executor",
"input": {
"skillName": "reminder",
"task": "remind me to drink water in 2 hrs"
}
}
],
"edges": []
}
</examples>`;
export interface PlannerPromptOptions {

View File

@@ -186,6 +186,7 @@ const CSS = `
table {
width: 100%;
border-collapse: collapse;
table-layout: auto;
}
th {
@@ -193,14 +194,16 @@ const CSS = `
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
padding: 8px 0;
padding: 12px 10px;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
td {
padding: 12px 0;
padding: 12px 10px;
border-bottom: 1px solid var(--border);
font-size: 14px;
vertical-align: middle;
}
.tag {
@@ -298,183 +301,183 @@ const CSS = `
`;
export class DashboardService extends BaseProcess {
private readonly server: http.Server;
private readonly server: http.Server;
constructor() {
super({ processName: 'dashboard' });
this.server = http.createServer((req, res) => this.handleRequest(req, res));
}
constructor() {
super({ processName: 'dashboard' });
this.server = http.createServer((req, res) => this.handleRequest(req, res));
}
override start(): void {
super.start();
this.server.listen(PORT, () => {
this.logEvent('info', `Dashboard server started on port ${PORT}`);
});
override start(): void {
super.start();
this.server.listen(PORT, () => {
this.logEvent('info', `Dashboard server started on port ${PORT}`);
});
// Send initial log announcement to orchestrator
this.send({
id: randomUUID(),
timestamp: Date.now(),
from: 'dashboard',
to: 'logger',
type: 'event.system.dashboard_online',
version: '1.0',
payload: { port: PORT, status: 'online' }
});
}
// Send initial log announcement to orchestrator
this.send({
id: randomUUID(),
timestamp: Date.now(),
from: 'dashboard',
to: 'logger',
type: 'event.system.dashboard_online',
version: '1.0',
payload: { port: PORT, status: 'online' }
});
}
private logEvent(level: string, message: string) {
this.send({
id: randomUUID(),
timestamp: Date.now(),
from: 'dashboard',
to: 'logger',
type: `event.dashboard.${level}`,
version: '1.0',
payload: { message }
});
}
private logEvent(level: string, message: string) {
this.send({
id: randomUUID(),
timestamp: Date.now(),
from: 'dashboard',
to: 'logger',
type: `event.dashboard.${level}`,
version: '1.0',
payload: { message }
});
}
private handleRequest(req: http.IncomingMessage, res: http.ServerResponse) {
if (req.url === '/api/stats') {
const s = this.getStats();
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({
...s,
charts: {
taskDonut: this.generateDonutChart(s.tasks),
compBar: this.generateBarChart(Object.keys(s.complexity), Object.values(s.complexity))
}
}));
return;
private handleRequest(req: http.IncomingMessage, res: http.ServerResponse) {
if (req.url === '/api/stats') {
const s = this.getStats();
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({
...s,
charts: {
taskDonut: this.generateDonutChart(s.tasks),
compBar: this.generateBarChart(Object.keys(s.complexity), Object.values(s.complexity))
}
}));
return;
}
if (req.url === '/') {
res.setHeader('Content-Type', 'text/html');
res.end(this.getHTML());
return;
if (req.url === '/') {
res.setHeader('Content-Type', 'text/html');
res.end(this.getHTML());
return;
}
if (req.url?.startsWith('/api/fail-task')) {
const url = new URL(req.url, `http://${req.headers.host}`);
const taskId = url.searchParams.get('id');
if (taskId) {
try {
const tdb = new Database(path.join(ROOT_DIR, 'data/tasks.sqlite'));
tdb.prepare("UPDATE tasks SET status = 'failed', updated_at = ?, metadata = ? WHERE id = ?").run(Date.now(), JSON.stringify({ reason: 'Manually failed via dashboard' }), taskId);
tdb.close();
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ status: 'success' }));
return;
} catch (e) {
res.statusCode = 500;
res.end(JSON.stringify({ status: 'error', message: String(e) }));
return;
}
if (req.url?.startsWith('/api/fail-task')) {
const url = new URL(req.url, `http://${req.headers.host}`);
const taskId = url.searchParams.get('id');
if (taskId) {
try {
const tdb = new Database(path.join(ROOT_DIR, 'data/tasks.sqlite'));
tdb.prepare("UPDATE tasks SET status = 'failed', updated_at = ?, metadata = ? WHERE id = ?").run(Date.now(), JSON.stringify({ reason: 'Manually failed via dashboard' }), taskId);
tdb.close();
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ status: 'success' }));
return;
} catch (e) {
res.statusCode = 500;
res.end(JSON.stringify({ status: 'error', message: String(e) }));
return;
}
}
}
res.statusCode = 404;
res.end('Not Found');
}
}
private getStats() {
const stats: any = {
tasks: {},
complexity: { small: 0, medium: 0, large: 0, unknown: 0 },
rag: 0,
cron: 0,
logs: [],
maxNodes: 0,
timing: { first: '-', last: '-', avg: '-' },
models: {}
};
try {
const configPath = path.join(ROOT_DIR, 'config.json');
if (fs.existsSync(configPath)) {
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
stats.models = config.modelRouter || {};
}
} catch (e) { }
res.statusCode = 404;
res.end('Not Found');
}
try {
const tdb = new Database(path.join(ROOT_DIR, 'data/tasks.sqlite'), { readonly: true });
tdb.prepare('SELECT status, count(*) as c FROM tasks GROUP BY status').all().forEach((r: any) => stats.tasks[r.status] = r.c);
tdb.prepare('SELECT complexity, count(*) as c FROM tasks GROUP BY complexity').all().forEach((r: any) => {
const key = r.complexity ? r.complexity.toLowerCase() : 'unknown';
stats.complexity[key] = (stats.complexity[key] || 0) + r.c;
});
const peak = tdb.prepare('SELECT MAX(cnt) as m FROM (SELECT count(*) as cnt FROM task_nodes GROUP BY task_id)').get() as { m: number } | undefined;
stats.maxNodes = peak ? peak.m : 0;
private getStats() {
const stats: any = {
tasks: {},
complexity: { small: 0, medium: 0, large: 0, unknown: 0 },
rag: 0,
cron: 0,
logs: [],
maxNodes: 0,
timing: { first: '-', last: '-', avg: '-' },
models: {}
};
try {
const configPath = path.join(ROOT_DIR, 'config.json');
if (fs.existsSync(configPath)) {
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
stats.models = config.modelRouter || {};
}
} catch (e) { }
const times = tdb.prepare('SELECT MIN(created_at) as first, MAX(updated_at) as last FROM tasks').get() as { first: number, last: number } | undefined;
if (times?.first) {
stats.timing.first = new Date(times.first).toLocaleDateString() + ' ' + new Date(times.first).toLocaleTimeString();
stats.timing.last = new Date(times.last).toLocaleDateString() + ' ' + new Date(times.last).toLocaleTimeString();
}
try {
const tdb = new Database(path.join(ROOT_DIR, 'data/tasks.sqlite'), { readonly: true });
tdb.prepare('SELECT status, count(*) as c FROM tasks GROUP BY status').all().forEach((r: any) => stats.tasks[r.status] = r.c);
tdb.prepare('SELECT complexity, count(*) as c FROM tasks GROUP BY complexity').all().forEach((r: any) => {
const key = r.complexity ? r.complexity.toLowerCase() : 'unknown';
stats.complexity[key] = (stats.complexity[key] || 0) + r.c;
});
const peak = tdb.prepare('SELECT MAX(cnt) as m FROM (SELECT count(*) as cnt FROM task_nodes GROUP BY task_id)').get() as { m: number } | undefined;
stats.maxNodes = peak ? peak.m : 0;
const avg = tdb.prepare("SELECT AVG(updated_at - created_at) as a FROM tasks WHERE status = 'completed' AND updated_at > created_at").get() as { a: number } | undefined;
if (avg?.a) {
const sec = Math.round(avg.a / 1000);
stats.timing.avg = sec > 60 ? `${Math.floor(sec / 60)}m ${sec % 60}s` : `${sec}s`;
}
const times = tdb.prepare('SELECT MIN(created_at) as first, MAX(updated_at) as last FROM tasks').get() as { first: number, last: number } | undefined;
if (times?.first) {
stats.timing.first = new Date(times.first).toLocaleDateString() + ' ' + new Date(times.first).toLocaleTimeString();
stats.timing.last = new Date(times.last).toLocaleDateString() + ' ' + new Date(times.last).toLocaleTimeString();
}
stats.pendingTasks = tdb.prepare('SELECT id, goal, status, complexity, updated_at FROM tasks WHERE status IN (?, ?) ORDER BY updated_at DESC')
.all('pending', 'running');
const avg = tdb.prepare("SELECT AVG(updated_at - created_at) as a FROM tasks WHERE status = 'completed' AND updated_at > created_at").get() as { a: number } | undefined;
if (avg?.a) {
const sec = Math.round(avg.a / 1000);
stats.timing.avg = sec > 60 ? `${Math.floor(sec / 60)}m ${sec % 60}s` : `${sec}s`;
}
tdb.close();
} catch (e) { }
try {
const rdb = new Database(path.join(ROOT_DIR, 'data/rag.sqlite'), { readonly: true });
const row = rdb.prepare('SELECT count(*) as c FROM rag_documents').get() as { c: number } | undefined;
stats.rag = row ? row.c : 0;
rdb.close();
} catch (e) { }
try {
const cdb = new Database(path.join(ROOT_DIR, 'data/cron.sqlite'), { readonly: true });
const row = cdb.prepare('SELECT count(*) as c FROM cron_schedules WHERE enabled=1').get() as { c: number } | undefined;
stats.cron = row ? row.c : 0;
cdb.close();
} catch (e) { }
try {
const logPath = path.join(ROOT_DIR, 'logs/events.log');
if (fs.existsSync(logPath)) {
stats.logs = fs.readFileSync(logPath, 'utf8').trim().split('\n').slice(-20).map(line => {
try { return JSON.parse(line); } catch (e) { return { message: line }; }
}).reverse();
}
} catch (e) { }
return stats;
}
stats.pendingTasks = tdb.prepare('SELECT id, goal, status, complexity, updated_at FROM tasks WHERE status IN (?, ?) ORDER BY updated_at DESC')
.all('pending', 'running');
private generateDonutChart(data: any, size = 200) {
const total = Object.values(data).reduce((a: any, b: any) => a + b, 0) as number;
if (!total) return `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><text x="50%" y="50%" text-anchor="middle" fill="#8b8b8b">No Data</text></svg>`;
const radius = 75, circumference = 2 * Math.PI * radius;
let offset = 0;
const colors: any = { completed: '#0b6e4f', failed: '#df2a5f', pending: '#d9730d', running: '#2383e2' };
const slices = Object.entries(data).map(([k, v]: [string, any]) => {
const p = v / total, dash = (p * circumference) + ' ' + circumference, currentOffset = -offset;
offset += p * circumference;
return `<circle cx="100" cy="100" r="${radius}" fill="transparent" stroke="${colors[k] || '#2383e2'}" stroke-width="25" stroke-dasharray="${dash}" stroke-dashoffset="${currentOffset}" transform="rotate(-90 100 100)"></circle>`;
}).join('');
return `<svg width="100%" height="100%" viewBox="0 0 200 200" preserveAspectRatio="xMidYMid meet">${slices}<text x="50%" y="50%" text-anchor="middle" dy=".3em" fill="currentColor" font-size="28" font-weight="700">${total}</text></svg>`;
}
tdb.close();
} catch (e) { }
try {
const rdb = new Database(path.join(ROOT_DIR, 'data/rag.sqlite'), { readonly: true });
const row = rdb.prepare('SELECT count(*) as c FROM rag_documents').get() as { c: number } | undefined;
stats.rag = row ? row.c : 0;
rdb.close();
} catch (e) { }
try {
const cdb = new Database(path.join(ROOT_DIR, 'data/cron.sqlite'), { readonly: true });
const row = cdb.prepare('SELECT count(*) as c FROM cron_schedules WHERE enabled=1').get() as { c: number } | undefined;
stats.cron = row ? row.c : 0;
cdb.close();
} catch (e) { }
try {
const logPath = path.join(ROOT_DIR, 'logs/events.log');
if (fs.existsSync(logPath)) {
stats.logs = fs.readFileSync(logPath, 'utf8').trim().split('\n').slice(-20).map(line => {
try { return JSON.parse(line); } catch (e) { return { message: line }; }
}).reverse();
}
} catch (e) { }
return stats;
}
private generateBarChart(labels: string[], values: number[], width = 400, height = 200) {
const max = Math.max(...values, 1), barWidth = (width / labels.length) * 0.5, gap = (width / labels.length) * 0.5;
const bars = labels.map((l, i) => {
const val = values[i] ?? 0;
const h = (val / max) * 120, x = i * (barWidth + gap) + gap / 2;
return `<rect x="${x}" y="${160 - h}" width="${barWidth}" height="${h}" fill="#2ea7ff" rx="4"></rect>` +
`<text x="${x + barWidth / 2}" y="180" text-anchor="middle" fill="#8b8b8b" font-size="10">${l}</text>` +
`<text x="${x + barWidth / 2}" y="${150 - h}" text-anchor="middle" fill="currentColor" font-size="10">${val}</text>`;
}).join('');
return `<svg width="100%" height="100%" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMidYMid meet">${bars}</svg>`;
}
private generateDonutChart(data: any, size = 200) {
const total = Object.values(data).reduce((a: any, b: any) => a + b, 0) as number;
if (!total) return `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><text x="50%" y="50%" text-anchor="middle" fill="#8b8b8b">No Data</text></svg>`;
const radius = 75, circumference = 2 * Math.PI * radius;
let offset = 0;
const colors: any = { completed: '#0b6e4f', failed: '#df2a5f', pending: '#d9730d', running: '#2383e2' };
const slices = Object.entries(data).map(([k, v]: [string, any]) => {
const p = v / total, dash = (p * circumference) + ' ' + circumference, currentOffset = -offset;
offset += p * circumference;
return `<circle cx="100" cy="100" r="${radius}" fill="transparent" stroke="${colors[k] || '#2383e2'}" stroke-width="25" stroke-dasharray="${dash}" stroke-dashoffset="${currentOffset}" transform="rotate(-90 100 100)"></circle>`;
}).join('');
return `<svg width="100%" height="100%" viewBox="0 0 200 200" preserveAspectRatio="xMidYMid meet">${slices}<text x="50%" y="50%" text-anchor="middle" dy=".3em" fill="currentColor" font-size="28" font-weight="700">${total}</text></svg>`;
}
private getHTML() {
return `<!DOCTYPE html>
private generateBarChart(labels: string[], values: number[], width = 400, height = 200) {
const max = Math.max(...values, 1), barWidth = (width / labels.length) * 0.5, gap = (width / labels.length) * 0.5;
const bars = labels.map((l, i) => {
const val = values[i] ?? 0;
const h = (val / max) * 120, x = i * (barWidth + gap) + gap / 2;
return `<rect x="${x}" y="${160 - h}" width="${barWidth}" height="${h}" fill="#2ea7ff" rx="4"></rect>` +
`<text x="${x + barWidth / 2}" y="180" text-anchor="middle" fill="#8b8b8b" font-size="10">${l}</text>` +
`<text x="${x + barWidth / 2}" y="${150 - h}" text-anchor="middle" fill="currentColor" font-size="10">${val}</text>`;
}).join('');
return `<svg width="100%" height="100%" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMidYMid meet">${bars}</svg>`;
}
private getHTML() {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
@@ -547,11 +550,11 @@ export class DashboardService extends BaseProcess {
<table id="queue-table">
<thead>
<tr>
<th style="padding-left: 20px;">UPDATED</th>
<th>STATUS</th>
<th>COMPLEXITY</th>
<th style="padding-left: 20px; width: 100px;">UPDATED</th>
<th style="width: 100px;">STATUS</th>
<th style="width: 120px;">COMPLEXITY</th>
<th>GOAL</th>
<th style="padding-right: 20px; text-align: right;">ACTION</th>
<th style="padding-right: 20px; text-align: right; width: 80px;">ACTION</th>
</tr>
</thead>
<tbody id="qt"></tbody>
@@ -565,8 +568,8 @@ export class DashboardService extends BaseProcess {
<table id="log-table">
<thead>
<tr>
<th style="padding-left: 20px;">TIME</th>
<th>TYPE</th>
<th style="padding-left: 20px; width: 100px;">TIME</th>
<th style="width: 120px;">TYPE</th>
<th style="padding-right: 20px;">CONTENT</th>
</tr>
</thead>
@@ -627,10 +630,10 @@ export class DashboardService extends BaseProcess {
const tc = isRunning ? "running" : "warning";
const indicator = isRunning ? '<div class="pulse"></div>' : '';
return \`<tr>
<td style="padding-left: 20px; color: var(--text-muted);">\${new Date(t.updated_at).toLocaleTimeString()}</td>
<td><span class="tag \${tc}">\${indicator}\${t.status.toUpperCase()}</span></td>
<td><span class="tag complexity-\${t.complexity || 'unknown'}">\${(t.complexity || 'unknown').toUpperCase()}</span></td>
<td style="font-weight: 500;">\${t.goal}</td>
<td style="padding-left: 20px; color: var(--text-muted); white-space: nowrap;">\${new Date(t.updated_at).toLocaleTimeString()}</td>
<td style="white-space: nowrap;"><span class="tag \${tc}">\${indicator}\${t.status.toUpperCase()}</span></td>
<td style="white-space: nowrap;"><span class="tag complexity-\${t.complexity || 'unknown'}">\${(t.complexity || 'unknown').toUpperCase()}</span></td>
<td style="font-weight: 500; word-break: break-word;">\${t.goal}</td>
<td style="padding-right: 20px; text-align: right;">
<button onclick="failTask('\${t.id}')" style="cursor: pointer; font-size: 11px; padding: 2px 8px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--error);">FAIL</button>
</td>
@@ -665,9 +668,9 @@ export class DashboardService extends BaseProcess {
</script>
</body>
</html>`;
}
}
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
new DashboardService().start();
new DashboardService().start();
}