From 1969e78d5477c03dda7b1309e37e048ec623e8ce Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 9 Dec 2025 02:25:37 +0100 Subject: [PATCH] feat: surface system presence for the agent --- src/auto-reply/command-reply.ts | 5 +-- src/auto-reply/reply.ts | 23 +++++++++++++ src/commands/agent.ts | 2 ++ src/commands/status.ts | 17 ++++++++++ src/infra/control-channel.ts | 15 +++++++++ src/infra/provider-summary.ts | 59 +++++++++++++++++++++++++++++++++ src/infra/system-events.ts | 33 ++++++++++++++++++ src/infra/system-presence.ts | 48 +++++++++++++++++++++++++++ src/web/session.ts | 2 +- src/webchat/server.ts | 1 + 10 files changed, 202 insertions(+), 3 deletions(-) create mode 100644 src/infra/provider-summary.ts create mode 100644 src/infra/system-events.ts create mode 100644 src/infra/system-presence.ts diff --git a/src/auto-reply/command-reply.ts b/src/auto-reply/command-reply.ts index ef119cbb2..49d952d43 100644 --- a/src/auto-reply/command-reply.ts +++ b/src/auto-reply/command-reply.ts @@ -625,9 +625,10 @@ export async function runCommandReply( toolCallId?: string; tool_call_id?: string; }; - const role = msg.role; + const role = + typeof msg.role === "string" ? msg.role.toLowerCase() : ""; const isToolResult = - role === "toolResult" || role === "tool_result"; + role === "toolresult" || role === "tool_result"; if (!isToolResult || !Array.isArray(msg.content)) { // not a tool result message we care about } else { diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 736694761..559537f58 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -1,4 +1,5 @@ import crypto from "node:crypto"; + import { lookupContextTokens } from "../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL } from "../agents/defaults.js"; import { resolveBundledPiBinary } from "../agents/pi-path.js"; @@ -13,7 +14,9 @@ import { saveSessionStore, } from "../config/sessions.js"; import { isVerbose, logVerbose } from "../globals.js"; +import { buildProviderSummary } from "../infra/provider-summary.js"; import { triggerWarelayRestart } from "../infra/restart.js"; +import { drainSystemEvents } from "../infra/system-events.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { defaultRuntime } from "../runtime.js"; import { resolveHeartbeatSeconds } from "../web/reconnect.js"; @@ -605,6 +608,26 @@ export async function getReplyFromConfig( ABORT_MEMORY.set(abortKey, false); } } + + // Prepend queued system events and (for new main sessions) a provider snapshot. + const isGroupSession = + typeof ctx.From === "string" && + (ctx.From.includes("@g.us") || ctx.From.startsWith("group:")); + const isMainSession = + !isGroupSession && sessionKey === (sessionCfg?.mainKey ?? "main"); + if (isMainSession) { + const systemLines: string[] = []; + const queued = drainSystemEvents(); + systemLines.push(...queued); + if (isNewSession) { + const summary = await buildProviderSummary(cfg); + if (summary) systemLines.unshift(summary); + } + if (systemLines.length > 0) { + const block = systemLines.map((l) => `System: ${l}`).join("\n"); + prefixedBodyBase = `${block}\n\n${prefixedBodyBase}`; + } + } if ( sessionCfg && sendSystemOnce && diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 9f4cd8afd..c80b0dfe9 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -37,6 +37,7 @@ type AgentCommandOpts = { json?: boolean; timeout?: string; deliver?: boolean; + surface?: string; }; type SessionResolution = { @@ -270,6 +271,7 @@ export async function agentCommand( From: opts.to, SessionId: sessionId, IsNewSession: isNewSession ? "true" : "false", + Surface: opts.surface, }; const sessionIntro = diff --git a/src/commands/status.ts b/src/commands/status.ts index fd2826bd2..a0ad646dc 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -1,6 +1,8 @@ import { loadConfig } from "../config/config.js"; import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; import { info } from "../globals.js"; +import { buildProviderSummary } from "../infra/provider-summary.js"; +import { peekSystemEvents } from "../infra/system-events.js"; import type { RuntimeEnv } from "../runtime.js"; import { resolveHeartbeatSeconds } from "../web/reconnect.js"; import { @@ -12,6 +14,8 @@ import { export type StatusSummary = { web: { linked: boolean; authAgeMs: number | null }; heartbeatSeconds: number; + providerSummary: string; + queuedSystemEvents: string[]; sessions: { path: string; count: number; @@ -28,6 +32,8 @@ export async function getStatusSummary(): Promise { const linked = await webAuthExists(); const authAgeMs = getWebAuthAgeMs(); const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined); + const providerSummary = await buildProviderSummary(cfg); + const queuedSystemEvents = peekSystemEvents(); const storePath = resolveStorePath(cfg.inbound?.reply?.session?.store); const store = loadSessionStore(storePath); @@ -44,6 +50,8 @@ export async function getStatusSummary(): Promise { return { web: { linked, authAgeMs }, heartbeatSeconds, + providerSummary, + queuedSystemEvents, sessions: { path: storePath, count: sessions.length, @@ -80,6 +88,15 @@ export async function statusCommand( if (summary.web.linked) { logWebSelfId(runtime, true); } + runtime.log(info(`System: ${summary.providerSummary}`)); + if (summary.queuedSystemEvents.length > 0) { + const preview = summary.queuedSystemEvents.slice(0, 3).join(" | "); + runtime.log( + info( + `Queued system events (${summary.queuedSystemEvents.length}): ${preview}`, + ), + ); + } runtime.log(info(`Heartbeat: ${summary.heartbeatSeconds}s`)); runtime.log(info(`Session store: ${summary.sessions.path}`)); runtime.log(info(`Active sessions: ${summary.sessions.count}`)); diff --git a/src/infra/control-channel.ts b/src/infra/control-channel.ts index ec8b1a049..87702215b 100644 --- a/src/infra/control-channel.ts +++ b/src/infra/control-channel.ts @@ -10,6 +10,8 @@ import { type HeartbeatEventPayload, onHeartbeatEvent, } from "./heartbeat-events.js"; +import { enqueueSystemEvent } from "./system-events.js"; +import { listSystemPresence, updateSystemPresence } from "./system-presence.js"; type ControlRequest = { type: "request"; @@ -158,6 +160,19 @@ export async function startControlChannel( respond({ ok: true }); break; } + case "system-event": { + const text = String(parsed.params?.text ?? "").trim(); + if (text) { + enqueueSystemEvent(text); + updateSystemPresence(text); + } + respond({ ok: true }); + break; + } + case "system-presence": { + respond(listSystemPresence()); + break; + } default: respond(undefined, false, `unknown method: ${parsed.method}`); break; diff --git a/src/infra/provider-summary.ts b/src/infra/provider-summary.ts new file mode 100644 index 000000000..83caa5ed6 --- /dev/null +++ b/src/infra/provider-summary.ts @@ -0,0 +1,59 @@ +import { loadConfig, type WarelayConfig } from "../config/config.js"; +import { normalizeE164 } from "../utils.js"; +import { + getWebAuthAgeMs, + readWebSelfId, + webAuthExists, +} from "../web/session.js"; + +const DEFAULT_WEBCHAT_PORT = 18788; + +export async function buildProviderSummary( + cfg?: WarelayConfig, +): Promise { + const effective = cfg ?? loadConfig(); + const parts: string[] = []; + + const webLinked = await webAuthExists(); + const authAgeMs = getWebAuthAgeMs(); + const authAge = authAgeMs === null ? "unknown" : formatAge(authAgeMs); + const { e164 } = readWebSelfId(); + parts.push( + webLinked + ? `WhatsApp web linked${e164 ? ` as ${e164}` : ""} (auth ${authAge})` + : "WhatsApp web not linked", + ); + + const telegramToken = + process.env.TELEGRAM_BOT_TOKEN ?? effective.telegram?.botToken; + parts.push( + telegramToken ? "Telegram bot configured" : "Telegram bot not configured", + ); + + if (effective.webchat?.enabled === false) { + parts.push("WebChat disabled"); + } else { + const port = effective.webchat?.port ?? DEFAULT_WEBCHAT_PORT; + parts.push(`WebChat enabled (port ${port})`); + } + + const allowFrom = effective.inbound?.allowFrom?.length + ? effective.inbound.allowFrom.map(normalizeE164).filter(Boolean) + : []; + if (allowFrom.length) { + parts.push(`AllowFrom: ${allowFrom.join(", ")}`); + } + + return `System status: ${parts.join("; ")}`; +} + +export function formatAge(ms: number): string { + if (ms < 0) return "unknown"; + const minutes = Math.round(ms / 60_000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.round(minutes / 60); + if (hours < 48) return `${hours}h ago`; + const days = Math.round(hours / 24); + return `${days}d ago`; +} diff --git a/src/infra/system-events.ts b/src/infra/system-events.ts new file mode 100644 index 000000000..652333f91 --- /dev/null +++ b/src/infra/system-events.ts @@ -0,0 +1,33 @@ +// Lightweight in-memory queue for human-readable system events that should be +// prefixed to the next main-session prompt/heartbeat. We intentionally avoid +// persistence to keep events ephemeral. + +type SystemEvent = { text: string; ts: number }; + +const MAX_EVENTS = 20; +const queue: SystemEvent[] = []; +let lastText: string | null = null; + +export function enqueueSystemEvent(text: string) { + const cleaned = text.trim(); + if (!cleaned) return; + if (lastText === cleaned) return; // skip consecutive duplicates + lastText = cleaned; + queue.push({ text: cleaned, ts: Date.now() }); + if (queue.length > MAX_EVENTS) queue.shift(); +} + +export function drainSystemEvents(): string[] { + const out = queue.map((e) => e.text); + queue.length = 0; + lastText = null; + return out; +} + +export function peekSystemEvents(): string[] { + return queue.map((e) => e.text); +} + +export function hasSystemEvents() { + return queue.length > 0; +} diff --git a/src/infra/system-presence.ts b/src/infra/system-presence.ts new file mode 100644 index 000000000..30fc1eace --- /dev/null +++ b/src/infra/system-presence.ts @@ -0,0 +1,48 @@ +export type SystemPresence = { + host?: string; + ip?: string; + version?: string; + lastInputSeconds?: number; + mode?: string; + reason?: string; + text: string; + ts: number; +}; + +const entries = new Map(); + +function parsePresence(text: string): SystemPresence { + const trimmed = text.trim(); + const pattern = + /Node:\s*([^ (]+)\s*\(([^)]+)\)\s*·\s*app\s*([^·]+?)\s*·\s*last input\s*([0-9]+)s ago\s*·\s*mode\s*([^·]+?)\s*·\s*reason\s*(.+)$/i; + const match = trimmed.match(pattern); + if (!match) { + return { text: trimmed, ts: Date.now() }; + } + const [, host, ip, version, lastInputStr, mode, reasonRaw] = match; + const lastInputSeconds = Number.parseInt(lastInputStr, 10); + const reason = reasonRaw.trim(); + return { + host: host.trim(), + ip: ip.trim(), + version: version.trim(), + lastInputSeconds: Number.isFinite(lastInputSeconds) + ? lastInputSeconds + : undefined, + mode: mode.trim(), + reason, + text: trimmed, + ts: Date.now(), + }; +} + +export function updateSystemPresence(text: string) { + const parsed = parsePresence(text); + const key = + parsed.host?.toLowerCase() || parsed.ip || parsed.text.slice(0, 64); + entries.set(key, parsed); +} + +export function listSystemPresence(): SystemPresence[] { + return [...entries.values()].sort((a, b) => b.ts - a.ts); +} diff --git a/src/web/session.ts b/src/web/session.ts index 0fe578a6a..3679ffd9c 100644 --- a/src/web/session.ts +++ b/src/web/session.ts @@ -158,7 +158,7 @@ export async function logoutWeb(runtime: RuntimeEnv = defaultRuntime) { return true; } -function readWebSelfId() { +export function readWebSelfId() { // Read the cached WhatsApp Web identity (jid + E.164) from disk if present. const credsPath = path.join(WA_WEB_AUTH_DIR, "creds.json"); try { diff --git a/src/webchat/server.ts b/src/webchat/server.ts index f2a1598bc..4be1648cf 100644 --- a/src/webchat/server.ts +++ b/src/webchat/server.ts @@ -262,6 +262,7 @@ async function handleRpc( deliver, to, json: true, + surface: "webchat", }, runtime, );