Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Clawe Environment Configuration
# Copy this file to .env and fill in your values
# This is the SINGLE SOURCE OF TRUTH - app .env files reference these values

# =============================================================================
# REQUIRED
Expand All @@ -12,19 +13,30 @@ ANTHROPIC_API_KEY=sk-ant-...
OPENCLAW_TOKEN=your-secure-token-here

# Convex deployment URL (from your Convex dashboard)
# Used by CLI, watcher, and mapped to NEXT_PUBLIC_CONVEX_URL in Docker
# Apps reference this as NEXT_PUBLIC_CONVEX_URL
CONVEX_URL=https://your-deployment.convex.cloud

# =============================================================================
# OPTIONAL
# =============================================================================

# OpenAI API key (for image generation, optional)
OPENAI_API_KEY=sk-...
# OPENAI_API_KEY=sk-...

# Environment: dev or prod
ENVIRONMENT=dev

# OpenClaw gateway URL
# Development: http://localhost:18789 (Docker exposed on host)
# Production: http://openclaw:18789 (Docker internal network)
OPENCLAW_URL=http://localhost:18789

# =============================================================================
# ADVANCED (usually don't need to change)
# =============================================================================

# OpenClaw gateway port (default: 18789)
# OPENCLAW_PORT=18789

# OpenClaw state directory (for Docker)
# OPENCLAW_STATE_DIR=./.openclaw/config
8 changes: 8 additions & 0 deletions apps/watcher/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Watcher Environment
# Values are loaded from root .env via dotenv-cli
# No need to create a local .env file - all values come from root

# Required variables (from root .env):
# - CONVEX_URL
# - OPENCLAW_URL
# - OPENCLAW_TOKEN
33 changes: 33 additions & 0 deletions apps/watcher/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Dependencies
node_modules

# Build output
dist

# Turbo
.turbo

# Environment files
.env
.env.local
.env.development
.env.development.local
.env.production
.env.production.local

# Keep example
!.env.example

# Testing
coverage

# TypeScript
*.tsbuildinfo

# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# Misc
.DS_Store
91 changes: 77 additions & 14 deletions apps/watcher/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,78 @@ const AGENTS = [
const HEARTBEAT_MESSAGE =
"Read HEARTBEAT.md and follow it strictly. Check for notifications with 'clawe check'. If nothing needs attention, reply HEARTBEAT_OK.";

const STARTUP_RETRY_ATTEMPTS = 10;
const STARTUP_RETRY_DELAY_MS = 3000;

/**
* Sleep helper
*/
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

/**
* Retry a function with exponential backoff
*/
async function withRetry<T>(
fn: () => Promise<T>,
label: string,
maxAttempts = STARTUP_RETRY_ATTEMPTS,
baseDelayMs = STARTUP_RETRY_DELAY_MS,
): Promise<T> {
let lastError: Error | undefined;

for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));

if (attempt === maxAttempts) {
console.error(
`[watcher] ${label} failed after ${maxAttempts} attempts:`,
lastError.message,
);
throw lastError;
}

const delayMs = baseDelayMs * attempt;
console.log(
`[watcher] ${label} failed (attempt ${attempt}/${maxAttempts}), retrying in ${delayMs / 1000}s...`,
);
await sleep(delayMs);
}
}

throw lastError;
}

/**
* Register all agents in Convex (upsert - creates or updates)
*/
async function registerAgents(): Promise<void> {
console.log("[watcher] Registering agents in Convex...");
console.log("[watcher] CONVEX_URL:", config.convexUrl);

for (const agent of AGENTS) {
// Try to register first agent with retry (waits for Convex to be ready)
const firstAgent = AGENTS[0];
if (firstAgent) {
await withRetry(async () => {
const sessionKey = `agent:${firstAgent.id}:main`;
await convex.mutation(api.agents.upsert, {
name: firstAgent.name,
role: firstAgent.role,
sessionKey,
emoji: firstAgent.emoji,
});
console.log(
`[watcher] ✓ ${firstAgent.name} ${firstAgent.emoji} registered (${sessionKey})`,
);
}, "Convex connection");
}

// Register remaining agents (Convex is now ready)
for (const agent of AGENTS.slice(1)) {
const sessionKey = `agent:${agent.id}:main`;

try {
Expand Down Expand Up @@ -97,13 +162,18 @@ async function registerAgents(): Promise<void> {
async function setupCrons(): Promise<void> {
console.log("[watcher] Checking heartbeat crons...");

const result = await cronList();
if (!result.ok) {
console.error("[watcher] Failed to list crons:", result.error?.message);
return;
}
// Retry getting cron list (waits for OpenClaw to be ready)
const result = await withRetry(async () => {
const res = await cronList();
if (!res.ok) {
throw new Error(res.error?.message ?? "Failed to list crons");
}
return res;
}, "OpenClaw connection");

const existingNames = new Set(result.result.jobs.map((j: CronJob) => j.name));
const existingNames = new Set(
result.result.details.jobs.map((j: CronJob) => j.name),
);

for (const agent of AGENTS) {
const cronName = `${agent.id}-heartbeat`;
Expand Down Expand Up @@ -172,13 +242,6 @@ function formatNotification(notification: {
return parts.join("\n");
}

/**
* Sleep helper
*/
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

/**
* Deliver notifications to a single agent
*/
Expand Down
123 changes: 108 additions & 15 deletions apps/web/src/hooks/use-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,27 @@ import type {

const REQUEST_TIMEOUT_MS = 30000;

/**
* Internal system response patterns that should be filtered from chat.
* These are automated cron/heartbeat responses, not real conversation.
*/
const SYSTEM_MESSAGE_PATTERNS = [
// Exact matches (case-insensitive) - short system responses
/^NO_REPLY$/i,
/^REPLY_SKIP$/i,
/^HEARTBEAT_OK$/i,
/^OK$/i,
// Cron heartbeat instruction (the full cron trigger message)
/Read HEARTBEAT\.md.*follow it strictly/i,
/Check for notifications with ['"]clawe check['"]/i,
/If nothing needs attention.*reply HEARTBEAT_OK/i,
// System-prefixed messages
/^System:\s*\[\d{4}-\d{2}-\d{2}/i,
/^Cron:/i,
// Heartbeat status reports (contains HEARTBEAT_OK in the message)
/HEARTBEAT_OK/i,
];

/**
* Extract text content from a message object.
*/
Expand Down Expand Up @@ -73,6 +94,13 @@ function isInternalContent(text: string): boolean {
return true;
}

// Check against system message patterns (heartbeat/cron)
for (const pattern of SYSTEM_MESSAGE_PATTERNS) {
if (pattern.test(trimmed)) {
return true;
}
}

return false;
}

Expand Down Expand Up @@ -148,27 +176,75 @@ function parseHistoryMessage(msg: unknown, index: number): ChatMessage {
}

/**
* Collapse consecutive assistant messages, keeping only the last one before each user message.
* Check if text looks like a JSON status/error message that should be hidden.
*/
function isJsonStatusMessage(text: string): boolean {
const trimmed = text.trim();
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
try {
const parsed = JSON.parse(trimmed);
// Filter JSON messages with status, tool, error, or result keys
if (parsed.status || parsed.tool || parsed.error || parsed.result) {
return true;
}
} catch {
// Not valid JSON, don't filter
}
}
return false;
}

/**
* Check if a message is empty (no meaningful content).
*/
function collapseAssistantMessages(messages: ChatMessage[]): ChatMessage[] {
const result: ChatMessage[] = [];
function isEmptyMessage(message: ChatMessage): boolean {
for (const block of message.content) {
if (block.type === "text" && block.text && block.text.trim().length > 0) {
return false;
}
if (block.type === "image") {
return false;
}
}
return true;
}

for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
if (!msg) continue;
/**
* Check if a message should be filtered out entirely (heartbeat/cron/system messages).
*/
function isSystemCronMessage(message: ChatMessage): boolean {
// Filter empty messages
if (isEmptyMessage(message)) {
return true;
}

// Check all text content in the message
for (const block of message.content) {
if (block.type === "text" && block.text) {
const text = block.text.trim();

const nextMsg = messages[i + 1];
// Filter JSON status/error messages
if (isJsonStatusMessage(text)) {
return true;
}

if (msg.role === "user") {
result.push(msg);
} else if (msg.role === "assistant") {
if (!nextMsg || nextMsg.role === "user") {
result.push(msg);
// Check against all system message patterns
for (const pattern of SYSTEM_MESSAGE_PATTERNS) {
if (pattern.test(text)) {
return true;
}
}
}
}

return result;
return false;
}

/**
* Filter out system/cron messages from the message list.
*/
function filterSystemMessages(messages: ChatMessage[]): ChatMessage[] {
return messages.filter((msg) => !isSystemCronMessage(msg));
}

/**
Expand Down Expand Up @@ -205,10 +281,27 @@ export function useChat({
}

const data = await response.json();

// Debug: log raw messages from API
console.log("[chat] Raw messages from API:", data.messages);

const historyMessages = (data.messages || []).map(parseHistoryMessage);
const collapsedMessages = collapseAssistantMessages(historyMessages);

setMessages(collapsedMessages);
// Debug: log filtered messages
const beforeCount = historyMessages.length;
const filteredMessages = filterSystemMessages(historyMessages);
const afterCount = filteredMessages.length;
if (beforeCount !== afterCount) {
console.log(
`[chat] Filtered ${beforeCount - afterCount} system messages`,
historyMessages.filter((m: ChatMessage) => isSystemCronMessage(m)),
);
}

// Debug: log final messages
console.log("[chat] Final messages:", filteredMessages);

setMessages(filteredMessages);
setStatus("idle");
} catch (err) {
const error = err instanceof Error ? err : new Error("Unknown error");
Expand Down
10 changes: 8 additions & 2 deletions apps/web/src/lib/openclaw/actions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ describe("OpenClaw Actions", () => {
});
vi.mocked(saveTelegramBotTokenClient).mockResolvedValueOnce({
ok: true,
result: { success: true, hash: "abc" },
result: {
content: [{ type: "text", text: "Config updated" }],
details: { success: true, hash: "abc" },
},
});

const result = await saveTelegramBotToken("123456:ABC-DEF");
Expand Down Expand Up @@ -76,7 +79,10 @@ describe("OpenClaw Actions", () => {
it("returns health status", async () => {
vi.mocked(checkHealth).mockResolvedValueOnce({
ok: true,
result: { config: { channels: {} }, hash: "abc123" },
result: {
content: [{ type: "text", text: "Config retrieved" }],
details: { config: { channels: {} }, hash: "abc123" },
},
});

const result = await checkOpenClawHealth();
Expand Down
Loading