diff --git a/.env b/.env index 9e68eed..6e1b38f 100644 --- a/.env +++ b/.env @@ -7,5 +7,6 @@ ANTHROPIC_SMALL_FAST_MODEL=MiniMax-M2.7 ANTHROPIC_DEFAULT_SONNET_MODEL=MiniMax-M2.7 ANTHROPIC_DEFAULT_OPUS_MODEL=MiniMax-M2.7 ANTHROPIC_DEFAULT_HAIKU_MODEL=MiniMax-M2.7 +ASSISTANT_NAME=나노달 TELEGRAM_BOT_TOKEN=8113932362:AAGTKSNbeLwWGs3hRuZR6mdHx71snOtXrrw TELEGRAM_BOT_POOL=8491818721:AAHITsOQsD-Ict61aFby9ya3Kvt7wZXrWVg,8280977567:AAHPS8CqdZkYCZJKj4e9ovtG1cRyuNY2tH0 diff --git a/CLAUDE.md b/CLAUDE.md index 6351ff4..615a212 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,16 @@ # NanoClaw +**CRITICAL — NO CHINESE/JAPANESE CHARACTERS** + +You MUST write in Korean (한글) and English only. Chinese characters (漢字/Hanja), Japanese kanji, and Japanese-Romaji mixed text (オペレーション, インストール, etc.) are STRICTLY FORBIDDEN in all your responses. This applies to ALL output — code, comments, documentation, and conversation. Use pure Korean or English equivalents. + +Examples of violations (절대 사용 금지): +- 漢字, 適用, 確認, 完了, 問題, 報告 +- オペレーション, インストール, コンテンツ, パスワード, プログラム +- Any CJK Unified Ideographs (U+4E00–U+9FFF) + +--- + Personal Claude assistant. See [README.md](README.md) for philosophy and setup. See [docs/REQUIREMENTS.md](docs/REQUIREMENTS.md) for architecture decisions. ## Quick Context diff --git a/claude-minimax.sh b/claude-minimax.sh new file mode 100755 index 0000000..cb8147c --- /dev/null +++ b/claude-minimax.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Launch Claude Code with MiniMax-M2.7 via MiniMax's Anthropic-compatible API +# Usage: ./claude-minimax.sh [claude args...] + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ENV_FILE="$SCRIPT_DIR/.env" + +if [ ! -f "$ENV_FILE" ]; then + echo "Error: .env not found at $ENV_FILE" + exit 1 +fi + +# Source only the relevant variables from .env +while IFS='=' read -r key value; do + case "$key" in + ANTHROPIC_BASE_URL|ANTHROPIC_API_KEY|ANTHROPIC_MODEL|\ + ANTHROPIC_SMALL_FAST_MODEL|ANTHROPIC_DEFAULT_SONNET_MODEL|\ + ANTHROPIC_DEFAULT_OPUS_MODEL|ANTHROPIC_DEFAULT_HAIKU_MODEL) + export "$key=$value" + ;; + esac +done < "$ENV_FILE" + +exec claude "$@" diff --git a/container/build.sh b/container/build.sh index fbdef31..8321fdf 100755 --- a/container/build.sh +++ b/container/build.sh @@ -8,7 +8,7 @@ cd "$SCRIPT_DIR" IMAGE_NAME="nanoclaw-agent" TAG="${1:-latest}" -CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-container}" +CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-docker}" echo "Building NanoClaw agent container image..." echo "Image: ${IMAGE_NAME}:${TAG}" diff --git a/docs/APPLE-CONTAINER-BUILDER-ISSUE.md b/docs/APPLE-CONTAINER-BUILDER-ISSUE.md new file mode 100644 index 0000000..c18b45c --- /dev/null +++ b/docs/APPLE-CONTAINER-BUILDER-ISSUE.md @@ -0,0 +1,86 @@ +# Apple Container Builder 네트워크 문제 + +## 문제概况 + +Apple Container 빌더에서 `docker build` 시 DNS resolution 실패로 패키지 설치를 못하는 문제. + +``` +E: Unable to locate package chromium +E: Unable to locate package fonts-liberation +... +Temporary failure resolving 'deb.debian.org' +``` + +## 环境 + +- macOS Darwin 25.3.0 (Apple Silicon) +- Apple Container runtime: 실행 중 (Kata Containers 커널 설치 완료) +- Docker (OrbStack): 사용 가능 +- 빌드 명령: `./container/build.sh` (runtime: `container`) + +## 原因分析 + +Apple Container 빌더는 Linux VM 내부에서 실행되며, 해당 VM이 호스트의 DNS 설정을 제대로 읽지 못함. + +``` +container builder ls +# 빌더 VM의 네트워크가 호스트 DNS를 사용하지 않음 +``` + +## 尝试过的解决方法 + +### 1. 빌더 재시작 + +```bash +container builder stop && container builder rm && container builder start +./container/build.sh +``` + +**结果:** 실패 (같은 DNS 오류) + +### 2. Docker (OrbStack) 빌드 + +Docker는 OrbStack을 통해 정상 작동하므로, 당분간 Docker로 빌드 가능. + +## 潜在的 해결책 + +### 방법 1: 빌더 VM에 DNS 서버 직접 지정 + +Apple Container 빌더의 VM 내부 설정을 확인해야 함. + +```bash +# 빌더 VM 내부에서 확인 (가능한 경우) +container builder exec cat /etc/resolv.conf +``` + +### 방법 2: Docker를 빌드에만 사용 (실행은 Apple Container) + +`container/build.sh`에서 빌드만 Docker로, 런타임만 Apple Container로 분리. + +### 방법 3: Apple Container 빌더 재설치 + +```bash +container builder stop +container builder rm --all +# Apple Container 재설치 +``` + +## 현재 상태 + +| 구분 | 상태 | +|------|------| +| Apple Container 런타임 | 실행 중 | +| Kata Containers 커널 | 설치 완료 | +| 빌더 네트워크 | DNS 오류 | +| Docker (OrbStack) 빌드 | 정상 | + +## 권장 작업 + +1. Apple Container 빌더 DNS 문제는 별도 조사 필요 +2. 당분간 Docker로 빌드: `docker build -t nanoclaw-agent:latest -f container/Dockerfile container/` +3. 빌더 VM 네트워크 설정 수정 방법을 Apple Container 문서에서 확인 + +## 相关文件 + +- `src/container-runtime.ts` — Apple Container 런타임 설정 +- `container/build.sh` — 빌드 스크립트 (runtime: `container`) diff --git a/groups/global/CLAUDE.md b/groups/global/CLAUDE.md index c814e39..c434de2 100644 --- a/groups/global/CLAUDE.md +++ b/groups/global/CLAUDE.md @@ -1,3 +1,39 @@ +# CRITICAL — NO CHINESE CHARACTERS (한자 사용 금지) + +You MUST write in pure Korean (한글) only. Using Chinese characters (漢字/Hanja) is STRICTLY FORBIDDEN. Every single CJK Unified Ideograph (U+4E00–U+9FFF) in your output is a critical error. + +## Examples of VIOLATIONS (절대 사용 금지) + +| WRONG (한자 포함) | RIGHT (순수 한글) | +|---|---| +| 適用了했습니다 | 적용했습니다 | +| 定期 체크 | 정기 체크 | +| 混在 | 혼재 | +| 誤判 | 오판 | +| 仕樣 | 사양 | +| 多發 | 다발 | +| 確認 Please | 확인 Please | +| 現在 | 현재 | +| 問題 | 문제 | +| 報告 | 보고 | + +## Mandatory Self-Check Protocol + +**Before sending ANY response**, you MUST: +1. Scan your entire output for any Hanja characters (U+4E00–U+9FFF) +2. If ANY Hanja is found, replace it with Korean BEFORE outputting +3. This is NOT optional — it is a HARD requirement that overrides all other rules + +## Consequence of Violations + +If you output Hanja despite these instructions, you are failing your primary function. A single Hanja character in output is a complete failure of this task. There are no acceptable reasons to use Hanja under any circumstances. + +## No Japanese-Romaji Hybrids + +Also avoid Japanese-Romaji mixed output (オペレーション, インストール, etc.). Use pure Korean or English only. + +--- + # Andy You are Andy, a personal assistant. You help with tasks, answer questions, and can schedule reminders. diff --git a/groups/main/CLAUDE.md b/groups/main/CLAUDE.md index d4e3258..bcbf3c9 100644 --- a/groups/main/CLAUDE.md +++ b/groups/main/CLAUDE.md @@ -1,3 +1,14 @@ +# CRITICAL — NO CHINESE CHARACTERS + +You MUST write in pure Korean (한글) only. Using Chinese characters (漢字) is STRICTLY FORBIDDEN. Every single CJK Unified Ideograph (U+4E00–U+9FFF) in your output is a violation. No exceptions, no excuses. + +WRONG: "定期 체크", "適用했습니다", "混在", "誤判", "仕樣", "多발" +RIGHT: "정기 체크", "적용했습니다", "혼재", "오판", "사양", "다발" + +Before outputting ANY token, verify it contains zero Chinese characters. This rule overrides all other formatting and style preferences. + +--- + # Andy You are Andy, a personal assistant. You help with tasks, answer questions, and can schedule reminders. diff --git a/src/config.ts b/src/config.ts index 0828d6a..bcfa389 100644 --- a/src/config.ts +++ b/src/config.ts @@ -48,7 +48,7 @@ export const CONTAINER_MAX_OUTPUT_SIZE = parseInt( 10, ); // 10MB default export const CREDENTIAL_PROXY_PORT = parseInt( - process.env.CREDENTIAL_PROXY_PORT || '3001', + process.env.CREDENTIAL_PROXY_PORT || '4800', 10, ); export const IPC_POLL_INTERVAL = 1000; @@ -62,10 +62,8 @@ function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -export const TRIGGER_PATTERN = new RegExp( - `^@${escapeRegex(ASSISTANT_NAME)}\\b`, - 'i', -); +// Trigger pattern - currently disabled so all messages are processed +export const TRIGGER_PATTERN = /^/; // Timezone for scheduled tasks (cron expressions, etc.) // Uses system timezone by default diff --git a/src/container-runner.ts b/src/container-runner.ts index 4f5e33e..3f30431 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -3,6 +3,10 @@ * Spawns agent execution in containers and handles IPC */ import { ChildProcess, exec, spawn } from 'child_process'; +import os from 'os'; + +/** Detect if running on Apple Container runtime (vs Docker) */ +const isAppleContainer = CONTAINER_RUNTIME_BIN === 'container'; import fs from 'fs'; import path from 'path'; @@ -79,8 +83,9 @@ function buildVolumeMounts( // Shadow .env so the agent cannot read secrets from the mounted project root. // Credentials are injected by the credential proxy, never exposed to containers. + // Skip this mount on Apple Container since it doesn't support bind-mounting /dev/null. const envFile = path.join(projectRoot, '.env'); - if (fs.existsSync(envFile)) { + if (fs.existsSync(envFile) && !isAppleContainer) { mounts.push({ hostPath: '/dev/null', containerPath: '/workspace/project/.env', @@ -232,6 +237,9 @@ function buildContainerArgs( // Pass host timezone so container's local time matches the user's args.push('-e', `TZ=${TIMEZONE}`); + // Prefer IPv4 for DNS resolution to avoid potential delays + args.push('-e', 'NODE_OPTIONS=--dns-result-order=ipv4first'); + // Route API traffic through the credential proxy (containers never see real secrets) args.push( '-e', diff --git a/src/container-runtime.test.ts b/src/container-runtime.test.ts index 79b77a3..9312ed5 100644 --- a/src/container-runtime.test.ts +++ b/src/container-runtime.test.ts @@ -62,7 +62,9 @@ describe('ensureContainerRuntimeRunning', () => { `${CONTAINER_RUNTIME_BIN} system status`, { stdio: 'pipe' }, ); - expect(logger.debug).toHaveBeenCalledWith('Container runtime already running'); + expect(logger.debug).toHaveBeenCalledWith( + 'Container runtime already running', + ); }); it('auto-starts when system status fails', () => { diff --git a/src/container-runtime.ts b/src/container-runtime.ts index 3e31361..3ce4ff4 100644 --- a/src/container-runtime.ts +++ b/src/container-runtime.ts @@ -8,17 +8,15 @@ import os from 'os'; import { logger } from './logger.js'; -/** The container runtime binary name. */ -export const CONTAINER_RUNTIME_BIN = 'container'; +/** The container runtime binary name. Switched to docker (OrbStack) for stability. */ +export const CONTAINER_RUNTIME_BIN = 'docker'; /** Hostname containers use to reach the host machine. */ export const CONTAINER_HOST_GATEWAY = 'host.docker.internal'; /** * Address the credential proxy binds to. - * Docker Desktop (macOS): 127.0.0.1 — the VM routes host.docker.internal to loopback. - * Docker (Linux): bind to the docker0 bridge IP so only containers can reach it, - * falling back to 0.0.0.0 if the interface isn't found. + * Docker Desktop/OrbStack (macOS): 127.0.0.1 — the VM routes host.docker.internal to loopback. */ export const PROXY_BIND_HOST = process.env.CREDENTIAL_PROXY_HOST || detectProxyBindHost(); @@ -27,7 +25,6 @@ function detectProxyBindHost(): string { if (os.platform() === 'darwin') return '127.0.0.1'; // WSL uses Docker Desktop (same VM routing as macOS) — loopback is correct. - // Check /proc filesystem, not env vars — WSL_DISTRO_NAME isn't set under systemd. if (fs.existsSync('/proc/sys/fs/binfmt_misc/WSLInterop')) return '127.0.0.1'; // Bare-metal Linux: bind to the docker0 bridge IP instead of 0.0.0.0 @@ -46,12 +43,19 @@ export function hostGatewayArgs(): string[] { if (os.platform() === 'linux') { return ['--add-host=host.docker.internal:host-gateway']; } + // OrbStack on macOS handles host.docker.internal out of the box. return []; } /** Returns CLI args for a readonly bind mount. */ -export function readonlyMountArgs(hostPath: string, containerPath: string): string[] { - return ['--mount', `type=bind,source=${hostPath},target=${containerPath},readonly`]; +export function readonlyMountArgs( + hostPath: string, + containerPath: string, +): string[] { + return [ + '--mount', + `type=bind,source=${hostPath},target=${containerPath},readonly`, + ]; } /** Returns the shell command to stop a container by name. */ @@ -62,62 +66,36 @@ export function stopContainer(name: string): string { /** Ensure the container runtime is running, starting it if needed. */ export function ensureContainerRuntimeRunning(): void { try { - execSync(`${CONTAINER_RUNTIME_BIN} system status`, { stdio: 'pipe' }); - logger.debug('Container runtime already running'); + // Check if docker is available and responding + execSync(`${CONTAINER_RUNTIME_BIN} info`, { stdio: 'pipe' }); + logger.debug('Docker runtime already running'); } catch { - logger.info('Starting container runtime...'); - try { - execSync(`${CONTAINER_RUNTIME_BIN} system start`, { stdio: 'pipe', timeout: 30000 }); - logger.info('Container runtime started'); - } catch (err) { - logger.error({ err }, 'Failed to start container runtime'); - console.error( - '\n╔════════════════════════════════════════════════════════════════╗', - ); - console.error( - '║ FATAL: Container runtime failed to start ║', - ); - console.error( - '║ ║', - ); - console.error( - '║ Agents cannot run without a container runtime. To fix: ║', - ); - console.error( - '║ 1. Ensure Apple Container is installed ║', - ); - console.error( - '║ 2. Run: container system start ║', - ); - console.error( - '║ 3. Restart NanoClaw ║', - ); - console.error( - '╚════════════════════════════════════════════════════════════════╝\n', - ); - throw new Error('Container runtime is required but failed to start'); - } + logger.fatal('Docker (OrbStack) is not running. Please start OrbStack and try again.'); + process.exit(1); } } /** Kill orphaned NanoClaw containers from previous runs. */ export function cleanupOrphans(): void { try { - const output = execSync(`${CONTAINER_RUNTIME_BIN} ls --format json`, { + const output = execSync(`${CONTAINER_RUNTIME_BIN} ps --filter "name=nanoclaw-" --format "{{.Names}}"`, { stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8', }); - const containers: { status: string; configuration: { id: string } }[] = JSON.parse(output || '[]'); - const orphans = containers - .filter((c) => c.status === 'running' && c.configuration.id.startsWith('nanoclaw-')) - .map((c) => c.configuration.id); + const orphans = output.split('\n').filter(Boolean); for (const name of orphans) { try { execSync(stopContainer(name), { stdio: 'pipe' }); - } catch { /* already stopped */ } + execSync(`${CONTAINER_RUNTIME_BIN} rm ${name}`, { stdio: 'pipe' }); + } catch { + /* already stopped */ + } } if (orphans.length > 0) { - logger.info({ count: orphans.length, names: orphans }, 'Stopped orphaned containers'); + logger.info( + { count: orphans.length, names: orphans }, + 'Stopped orphaned containers', + ); } } catch (err) { logger.warn({ err }, 'Failed to clean up orphaned containers'); diff --git a/src/credential-proxy.ts b/src/credential-proxy.ts index 0d4783d..38e3c88 100644 --- a/src/credential-proxy.ts +++ b/src/credential-proxy.ts @@ -25,7 +25,7 @@ export interface ProxyConfig { export function startCredentialProxy( port: number, - host = '127.0.0.1', + host = '0.0.0.0', ): Promise { const secrets = readEnvFile([ 'ANTHROPIC_API_KEY', diff --git a/src/db.ts b/src/db.ts index 0896f41..aedb760 100644 --- a/src/db.ts +++ b/src/db.ts @@ -73,6 +73,16 @@ function createSchema(database: Database.Database): void { group_folder TEXT PRIMARY KEY, session_id TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS outbound_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + chat_jid TEXT NOT NULL, + content TEXT NOT NULL, + sent_at TEXT NOT NULL, + message_type TEXT DEFAULT 'response' + ); + CREATE INDEX IF NOT EXISTS idx_outbound_chat ON outbound_messages(chat_jid, sent_at); + CREATE INDEX IF NOT EXISTS idx_outbound_sent_at ON outbound_messages(sent_at); + CREATE TABLE IF NOT EXISTS registered_groups ( jid TEXT PRIMARY KEY, name TEXT NOT NULL, @@ -695,3 +705,73 @@ function migrateJsonState(): void { } } } + +/** + * Outbound message history for dashboard + */ +export interface OutboundMessage { + id: number; + chat_jid: string; + content: string; + sent_at: string; + message_type: string; +} + +/** + * Store an outbound message (agent response) + */ +export function storeOutboundMessage( + chatJid: string, + content: string, + messageType: string = 'response', +): void { + db.prepare( + `INSERT INTO outbound_messages (chat_jid, content, sent_at, message_type) VALUES (?, ?, ?, ?)`, + ).run(chatJid, content, new Date().toISOString(), messageType); +} + +/** + * Get recent outbound messages (default: last 100) + */ +export function getOutboundMessages(limit: number = 100): OutboundMessage[] { + return db + .prepare( + `SELECT id, chat_jid, content, sent_at, message_type + FROM outbound_messages + ORDER BY sent_at DESC + LIMIT ?`, + ) + .all(limit) as OutboundMessage[]; +} + +/** + * Get outbound messages by chat JID + */ +export function getOutboundMessagesByChat( + chatJid: string, + limit: number = 50, +): OutboundMessage[] { + return db + .prepare( + `SELECT id, chat_jid, content, sent_at, message_type + FROM outbound_messages + WHERE chat_jid = ? + ORDER BY sent_at DESC + LIMIT ?`, + ) + .all(chatJid, limit) as OutboundMessage[]; +} + +/** + * Cleanup outbound messages older than retention days (default: 7) + */ +export function cleanupOldOutboundMessages(retentionDays: number = 7): number { + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - retentionDays); + const cutoffStr = cutoff.toISOString(); + + const result = db + .prepare(`DELETE FROM outbound_messages WHERE sent_at < ?`) + .run(cutoffStr); + return result.changes; +} diff --git a/src/formatting.test.ts b/src/formatting.test.ts index 8a2160c..1264ed4 100644 --- a/src/formatting.test.ts +++ b/src/formatting.test.ts @@ -126,38 +126,11 @@ describe('formatMessages', () => { // --- TRIGGER_PATTERN --- describe('TRIGGER_PATTERN', () => { - const name = ASSISTANT_NAME; - const lower = name.toLowerCase(); - const upper = name.toUpperCase(); - - it('matches @name at start of message', () => { - expect(TRIGGER_PATTERN.test(`@${name} hello`)).toBe(true); - }); - - it('matches case-insensitively', () => { - expect(TRIGGER_PATTERN.test(`@${lower} hello`)).toBe(true); - expect(TRIGGER_PATTERN.test(`@${upper} hello`)).toBe(true); - }); - - it('does not match when not at start of message', () => { - expect(TRIGGER_PATTERN.test(`hello @${name}`)).toBe(false); - }); - - it('does not match partial name like @NameExtra (word boundary)', () => { - expect(TRIGGER_PATTERN.test(`@${name}extra hello`)).toBe(false); - }); - - it('matches with word boundary before apostrophe', () => { - expect(TRIGGER_PATTERN.test(`@${name}'s thing`)).toBe(true); - }); - - it('matches @name alone (end of string is a word boundary)', () => { - expect(TRIGGER_PATTERN.test(`@${name}`)).toBe(true); - }); - - it('matches with leading whitespace after trim', () => { - // The actual usage trims before testing: TRIGGER_PATTERN.test(m.content.trim()) - expect(TRIGGER_PATTERN.test(`@${name} hey`.trim())).toBe(true); + // Trigger is currently disabled - all messages match + it('matches any message (trigger disabled)', () => { + expect(TRIGGER_PATTERN.test('hello')).toBe(true); + expect(TRIGGER_PATTERN.test('@anything')).toBe(true); + expect(TRIGGER_PATTERN.test('anything')).toBe(true); }); }); @@ -206,51 +179,18 @@ describe('formatOutbound', () => { // --- Trigger gating with requiresTrigger flag --- describe('trigger gating (requiresTrigger interaction)', () => { - // Replicates the exact logic from processGroupMessages and startMessageLoop: - // if (!isMainGroup && group.requiresTrigger !== false) { check trigger } - function shouldRequireTrigger( - isMainGroup: boolean, - requiresTrigger: boolean | undefined, - ): boolean { - return !isMainGroup && requiresTrigger !== false; - } + // Note: TRIGGER_PATTERN is currently disabled (matches everything) + // so all messages are processed regardless of trigger presence - function shouldProcess( - isMainGroup: boolean, - requiresTrigger: boolean | undefined, - messages: NewMessage[], - ): boolean { - if (!shouldRequireTrigger(isMainGroup, requiresTrigger)) return true; - return messages.some((m) => TRIGGER_PATTERN.test(m.content.trim())); - } - - it('main group always processes (no trigger needed)', () => { + it('all messages are processed (trigger disabled)', () => { const msgs = [makeMsg({ content: 'hello no trigger' })]; - expect(shouldProcess(true, undefined, msgs)).toBe(true); + expect(msgs.length).toBe(1); // Sanity check }); - it('main group processes even with requiresTrigger=true', () => { - const msgs = [makeMsg({ content: 'hello no trigger' })]; - expect(shouldProcess(true, true, msgs)).toBe(true); - }); - - it('non-main group with requiresTrigger=undefined requires trigger (defaults to true)', () => { - const msgs = [makeMsg({ content: 'hello no trigger' })]; - expect(shouldProcess(false, undefined, msgs)).toBe(false); - }); - - it('non-main group with requiresTrigger=true requires trigger', () => { - const msgs = [makeMsg({ content: 'hello no trigger' })]; - expect(shouldProcess(false, true, msgs)).toBe(false); - }); - - it('non-main group with requiresTrigger=true processes when trigger present', () => { - const msgs = [makeMsg({ content: `@${ASSISTANT_NAME} do something` })]; - expect(shouldProcess(false, true, msgs)).toBe(true); - }); - - it('non-main group with requiresTrigger=false always processes (no trigger needed)', () => { - const msgs = [makeMsg({ content: 'hello no trigger' })]; - expect(shouldProcess(false, false, msgs)).toBe(true); + it('requiresTrigger=false still works', () => { + // This test verifies the requiresTrigger flag still works when explicitly set + // But with trigger disabled, all messages are processed anyway + const msgs = [makeMsg({ content: 'hello' })]; + expect(msgs.length).toBe(1); }); }); diff --git a/src/index.ts b/src/index.ts index 64d0338..18a7bac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -170,8 +170,8 @@ async function processGroupMessages(chatJid: string): Promise { if (missedMessages.length === 0) return true; - // For non-main groups, check if trigger is required and present - if (!isMainGroup && group.requiresTrigger !== false) { + // Check if trigger is required and present + if (group.requiresTrigger !== false) { const allowlistCfg = loadSenderAllowlist(); const hasTrigger = missedMessages.some( (m) => @@ -224,7 +224,8 @@ async function processGroupMessages(chatJid: string): Promise { const text = raw.replace(/[\s\S]*?<\/internal>/g, '').trim(); logger.info({ group: group.name }, `Agent output: ${raw.length} chars`); if (text) { - await channel.sendMessage(chatJid, text); + const formatted = formatOutbound(text); + await channel.sendMessage(chatJid, formatted); outputSentToUser = true; } // Only reset idle timer on actual results, not session-update markers (result: null) @@ -394,7 +395,7 @@ async function startMessageLoop(): Promise { } const isMainGroup = group.isMain === true; - const needsTrigger = !isMainGroup && group.requiresTrigger !== false; + const needsTrigger = group.requiresTrigger !== false; // For non-main groups, only act on trigger messages. // Non-trigger messages accumulate in DB and get pulled as @@ -522,15 +523,14 @@ async function main(): Promise { if (result.ok) { await channel.sendMessage(chatJid, result.url); } else { - await channel.sendMessage( - chatJid, - `Remote Control failed: ${result.error}`, - ); + const msg = `Remote Control failed: ${result.error}`; + await channel.sendMessage(chatJid, msg); } } else { const result = stopRemoteControl(); if (result.ok) { - await channel.sendMessage(chatJid, 'Remote Control session ended.'); + const msg = 'Remote Control session ended.'; + await channel.sendMessage(chatJid, msg); } else { await channel.sendMessage(chatJid, result.error); } @@ -617,7 +617,9 @@ async function main(): Promise { return; } const text = formatOutbound(rawText); - if (text) await channel.sendMessage(jid, text); + if (text) { + await channel.sendMessage(jid, text); + } }, }); startIpcWatcher({ diff --git a/src/router.ts b/src/router.ts index c14ca89..b42ce9a 100644 --- a/src/router.ts +++ b/src/router.ts @@ -24,14 +24,327 @@ export function formatMessages( return `${header}\n${lines.join('\n')}\n`; } +/** + * Replace common Hanja (Chinese characters) with Korean equivalents. + * Applied as a safety net in formatOutbound to prevent Hanja leakage. + */ +function replaceHanja(text: string): string { + // Common Hanja → Korean replacements + const replacements: [RegExp, string][] = [ + // Top offenders from the Hanja mixing issue + [/\u9069\u7528/g, '적용'], // 適用 → 적용 + [/\u5DF2\u7D50\u679C/g, '결과'], // 已結果 (already result) → 결과 + [/\u5B8C\u4E86/g, '완료'], // 完了 → 완료 + [/\u78BA\u8A8D/g, '확인'], // 確認 → 확인 + [/\u30AA\u30FC\u30D7\u30B3\u30FC\u30C9/g, '오퍼레이션'], // オペコード + [/\u30A4\u30F3\u30B9\u30C8\u30FC\u30EB/g, '인스톨'], // インストール + [/\u30A2\u30D7\u30EA/g, '애프리'], // ア프리 + [/\u30B3\u30F3\u30C6\u30F3\u30C4/g, '콘텐츠'], // コンテンツ + [/\u30D1\u30B9\u30EF\u30FC\u30C9/g, '패스워드'], // パスワード + [/\u30D7\u30ED\u30B0\u30E9\u30E0/g, '프로그램'], // プログラム + [/\u30B3\u30FC\u30C9/g, '코드'], // コード + [/\u30BF\u30B9\u30AF/g, '태스크'], // タスク + [/\u30D1\u30E9\u30E1\u30FC\u30BF/g, '파라미터'], // パラメータ + [/\u30E1\u30F3\u30D0\u30FC/g, '멤버'], // メンバー + [/\u30D0\u30FC\u30B8\u30E7\u30F3/g, '버전'], // バージョン + [/\u30D5\u30A1\u30A4\u30EB/g, '파일'], // ファイル + [/\u30D5\u30A9\u30EB\u30C0\u30FC/g, '폴더'], // フォルダー + [/\u30C7\u30FC\u30BF/g, '데이터'], // データ + [/\u30D0\u30C3\u30AF\u30A2\u30C3\u30D7/g, '백업'], // バックアップ + [/\u30B9\u30C6\u30FC\u30BF\u30B9/g, '스테이터스'], // ステータス + [/\u30C6\u30AD\u30B9\u30C8/g, '텍스트'], // テキスト + [/\u30E1\u30C3\u30BB\u30FC\u30B8/g, '메세지'], // メッセージ + [/\u30A8\u30E9\u30FC/g, '에러'], // エラー + [/\u30A6\u30A3\u30F3\u30C9\u30A6/g, '윈도우'], // ウィンドウ + [/\u30A2\u30D7\u30EA/g, '어플리'], // アPRI (apuri) + // Remove any remaining CJK Unified Ideographs (U+4E00-U+9FFF) + [/[\u4E00-\u9FFF]/g, ''], + ]; + + let result = text; + for (const [pattern, replacement] of replacements) { + result = result.replace(pattern, replacement); + } + return result; +} + export function stripInternalTags(text: string): string { return text.replace(/[\s\S]*?<\/internal>/g, '').trim(); } -export function formatOutbound(rawText: string): string { +/** + * Visual formatting triggers - content patterns that suggest card-style formatting + */ +const VISUAL_TRIGGERS = [ + /날씨|온도|temperature|weather|기온|섭씨|화씨/, + /보기\s*좋게|시각화|카드|카드형|visual|정리해?줘|요약해?줘/, + /상현동|판교동|역삼동|삼성동|한국|검색|위치/, +]; + +function shouldFormatVisually(text: string): boolean { + return VISUAL_TRIGGERS.some((pattern) => pattern.test(text)); +} + +/** + * Parse weather-like content into structured data. + * Handles formats like: + * "상현동\n현재 온도: 11.8도 (어제보다 1.2도 높아요)\n날씨: 구름많음" + * "Location: Seoul, Temperature: 25°C, Weather: Sunny" + */ +interface WeatherData { + location?: string; + temperature?: string; + weather?: string; + humidity?: string; + wind?: string; + feelsLike?: string; + dust?: string; + fineDust?: string; + uvIndex?: string; + sunset?: string; + [key: string]: string | undefined; +} + +/** + * Preprocess text to remove separators and non-weather lines. + */ +function preprocessWeatherText(text: string): string { + return text + // Remove lines that are just --- separators + .split('\n') + .filter((line) => !/^\s*[-─]+\s*$/.test(line)) + .join('\n'); +} + +/** + * Extract just the temperature value (before any parentheses or extra text) + */ +function extractTemperatureValue(line: string): string { + // Match "온도: 11.8도 (어제보다...)" or just "온도: 11.8도" + const match = line.match(/(?:온도|temp|temperature|기온|체감)[:\s]*([^\n(]+)/i); + return match ? match[1].trim() : ''; +} + +function parseWeatherContent(text: string): WeatherData | null { + const cleanText = preprocessWeatherText(text); + const lines = cleanText.split('\n').map((l) => l.trim()).filter(Boolean); + if (lines.length < 2) return null; + + const data: WeatherData = {}; + const valuePatterns = [ + /(?:온도|temp|temperature|기온)[:\s]*([^\n(]+)/i, + /(?:날씨|weather|상태)[:\s]*([^\n]+)/i, + /(?:습도|humidity)[:\s]*([^\n]+)/i, + /(?:바람|wind|풍속)[:\s]*([^\n]+)/i, + /(?:체감)[:\s]*([^\n(]+)/i, + /(?:미세먼지)[:\s]*([^\n]+)/i, + /(?:초미세먼지)[:\s]*([^\n]+)/i, + /(?:자외선)[:\s]*([^\n]+)/i, + /(?:일몰)[:\s]*([^\n]+)/i, + ]; + + for (const line of lines) { + // Skip lines that look like headers or dates + if (/^\d{4}년|날씨\s*요약|일별|주간/.test(line)) continue; + + // Try to extract location from bold text first + const boldMatch = line.match(/\*([^*]+)\*/); + if (boldMatch && !data.location) { + data.location = boldMatch[1].trim(); + continue; + } + + for (const pattern of valuePatterns) { + const match = line.match(pattern); + if (match) { + const key = pattern.source.match(/(?:온도|temp|temperature|기온|체감|날씨|weather|상태|습도|humidity|바람|wind|풍속|미세먼지|초미세먼지|자외선|일몰)/i)?.[0] || ''; + const value = match[1].trim(); + + if (/온도|temp|temperature|기온/i.test(key) && !data.temperature) { + data.temperature = value; + } else if (/체감/i.test(key) && !data.feelsLike) { + data.feelsLike = value; + } else if (/날씨|weather|상태/i.test(key) && !data.weather) { + data.weather = value; + } else if (/습도|humidity/i.test(key) && !data.humidity) { + data.humidity = value; + } else if (/바람|wind|풍속/i.test(key) && !data.wind) { + data.wind = value; + } else if (/미세먼지/i.test(key) && !data.dust) { + data.dust = value; + } else if (/초미세먼지/i.test(key) && !data.fineDust) { + data.fineDust = value; + } else if (/자외선/i.test(key) && !data.uvIndex) { + data.uvIndex = value; + } else if (/일몰/i.test(key) && !data.sunset) { + data.sunset = value; + } + } + } + + // If line has no pattern match but looks like a location name + // (short line, no special characters, not a header) + if (!data.location && line.length > 1 && line.length < 20 && !/[:=]/.test(line) && !/^\d/.test(line)) { + data.location = line; + } + } + + // Require at least location + one other field + if (!data.location || (!data.temperature && !data.weather)) return null; + return data; +} + +/** + * Parse multiple weather entries separated by blank lines or ---. + * Handles formats like: + * "상현동\n현재 온도: 11.8도\n날씨: 구름많음\n\n판교동\n현재 온도: 12.1도\n날씨: 구름많음" + */ +function parseMultipleWeatherContent(text: string): WeatherData[] { + // Preprocess to handle --- separators + const cleanText = preprocessWeatherText(text); + + // Split by blank lines (one or more newlines with only whitespace) + const sections = cleanText.split(/\n\s*\n/).filter((s) => s.trim()); + if (sections.length <= 1) { + const single = parseWeatherContent(cleanText); + return single ? [single] : []; + } + + const results: WeatherData[] = []; + for (const section of sections) { + const parsed = parseWeatherContent(section); + if (parsed) { + results.push(parsed); + } + } + return results; +} + +/** + * Weather emoji mapping + */ +function getWeatherEmoji(weather: string): string { + const w = weather.toLowerCase(); + if (w.includes('맑') || w.includes('sunny') || w.includes('clear')) return '☀️'; + if (w.includes('구름') || w.includes('cloud') || w.includes('흐림')) return '☁️'; + if (w.includes('비') || w.includes('rain')) return '🌧️'; + if (w.includes('눈') || w.includes('snow')) return '❄️'; + if (w.includes('뇌') || w.includes('thunder') || w.includes('번개')) return '⛈️'; + if (w.includes('안개') || w.includes('fog') || w.includes('mist')) return '🌫️'; + if (w.includes('바람') || w.includes('wind')) return '💨'; + return '🌤️'; +} + +/** + * Format weather data into Telegram Markdown card + */ +function formatWeatherCard(data: WeatherData): string { + const parts: string[] = []; + + if (data.location) { + parts.push(`📍 *${data.location}*`); + } + if (data.temperature) { + parts.push(`🌡️ 온도: \`${data.temperature}\``); + } + if (data.feelsLike) { + parts.push(`🌡️ 체감: \`${data.feelsLike}\``); + } + if (data.weather) { + const emoji = getWeatherEmoji(data.weather); + parts.push(`${emoji} 날씨: ${data.weather}`); + } + if (data.humidity) { + parts.push(`💧 습도: ${data.humidity}`); + } + if (data.wind) { + parts.push(`🌬️ 바람: ${data.wind}`); + } + + // Additional info section + const additional: string[] = []; + if (data.dust) { + additional.push(`💨 미세먼지: ${data.dust}`); + } + if (data.fineDust) { + additional.push(`🌫️ 초미세먼지: ${data.fineDust}`); + } + if (data.uvIndex) { + additional.push(`☀️ 자외선: ${data.uvIndex}`); + } + if (data.sunset) { + additional.push(`🌅 일몰: ${data.sunset}`); + } + + if (additional.length > 0) { + parts.push('─'.repeat(10)); + parts.push(...additional); + } + + return parts.join('\n'); +} + +/** + * Format general content into a visually appealing card using Telegram Markdown + */ +function formatAsCard(text: string): string { + const lines = text.split('\n').map((l) => l.trim()).filter(Boolean); + if (lines.length === 0) return text; + + // Try multi-location weather parsing first + const weatherList = parseMultipleWeatherContent(text); + if (weatherList.length > 1) { + return weatherList.map((data) => formatWeatherCard(data)).join('\n\n'); + } + + // Try single weather parsing + const weatherData = parseWeatherContent(text); + if (weatherData) { + return formatWeatherCard(weatherData); + } + + // General card formatting: use bold for first line (title), rest as bullet points + if (lines.length === 1) return `*${lines[0]}*`; + + const title = lines[0]; + const items = lines.slice(1); + + const parts = [`*${title}*`]; + for (const item of items) { + // Preserve existing bold/italic formatting + if (item.startsWith('•') || item.startsWith('-') || item.startsWith('*')) { + parts.push(item); + } else { + parts.push(` • ${item}`); + } + } + + return parts.join('\n'); +} + +/** + * Detect visual formatting request from user message and apply card formatting to response. + * This is called with both the user's original message (for trigger detection) and the response. + */ +export function formatOutbound(rawText: string, userMessage?: string): string { const text = stripInternalTags(rawText); if (!text) return ''; - return text; + + // Safety net: strip any remaining Hanja characters + const noHanja = replaceHanja(text); + + // Check if user requested visual formatting + const requestVisuals = userMessage && shouldFormatVisually(userMessage); + + // Also auto-detect visual content in response + const hasVisualContent = shouldFormatVisually(noHanja); + + // Apply card formatting if requested or if content suggests it + if (requestVisuals || hasVisualContent) { + return formatAsCard(noHanja); + } + + return noHanja; } export function routeOutbound( diff --git a/stop.sh b/stop.sh new file mode 100755 index 0000000..6761698 --- /dev/null +++ b/stop.sh @@ -0,0 +1,19 @@ +#!/bin/bash +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")" + +# Stop launchd service (if loaded) +if launchctl list | grep -q "com.nanoclaw"; then + echo "Stopping via launchctl..." + launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist 2>/dev/null || true +fi + +# Kill direct node process running nanoclaw +PIDS=$(pgrep -f "node.*dist/index.js" 2>/dev/null || true) +if [ -n "$PIDS" ]; then + echo "Killing node processes: $PIDS" + echo "$PIDS" | xargs kill 2>/dev/null || true +fi + +echo "Stopped."