|
|
|
|
@@ -1,9 +1,48 @@
|
|
|
|
|
import { Injectable, Logger, Optional } from '@nestjs/common';
|
|
|
|
|
import {
|
|
|
|
|
BadRequestException,
|
|
|
|
|
Injectable,
|
|
|
|
|
Logger,
|
|
|
|
|
Optional,
|
|
|
|
|
} from '@nestjs/common';
|
|
|
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
|
|
|
import { SshService } from './ssh.service';
|
|
|
|
|
import { ConfigService } from '@nestjs/config';
|
|
|
|
|
import { ActivityService } from '../activity/activity.service';
|
|
|
|
|
|
|
|
|
|
export type RuntimeState =
|
|
|
|
|
| 'idle'
|
|
|
|
|
| 'thinking'
|
|
|
|
|
| 'tool_calling'
|
|
|
|
|
| 'speaking'
|
|
|
|
|
| 'error';
|
|
|
|
|
|
|
|
|
|
export interface RuntimeMessage {
|
|
|
|
|
id: string;
|
|
|
|
|
role: 'user' | 'assistant' | 'tool';
|
|
|
|
|
content: string;
|
|
|
|
|
ts: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface RuntimeSubagent {
|
|
|
|
|
name: string;
|
|
|
|
|
state: RuntimeState;
|
|
|
|
|
updatedAt: number | null;
|
|
|
|
|
currentTask: string | null;
|
|
|
|
|
sessionLabel: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface SisterRuntimeSnapshot {
|
|
|
|
|
name: string;
|
|
|
|
|
gatewayConnected: boolean;
|
|
|
|
|
mainState: RuntimeState;
|
|
|
|
|
currentTask: string | null;
|
|
|
|
|
activeSessionLabel: string | null;
|
|
|
|
|
activeSessionUpdatedAt: number | null;
|
|
|
|
|
controlSessionKey: string | null;
|
|
|
|
|
recentMessages: RuntimeMessage[];
|
|
|
|
|
subagents: RuntimeSubagent[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface SisterStatus {
|
|
|
|
|
id: number;
|
|
|
|
|
name: string;
|
|
|
|
|
@@ -13,6 +52,28 @@ export interface SisterStatus {
|
|
|
|
|
status: 'online' | 'offline' | 'working' | 'unknown';
|
|
|
|
|
lastSeen: Date | null;
|
|
|
|
|
currentTask: string | null;
|
|
|
|
|
liveState: RuntimeState;
|
|
|
|
|
activeSessionLabel: string | null;
|
|
|
|
|
gatewayConnected: boolean;
|
|
|
|
|
subagents: RuntimeSubagent[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface RuntimeProbeResult {
|
|
|
|
|
gatewayConnected?: boolean;
|
|
|
|
|
mainState?: RuntimeState;
|
|
|
|
|
currentTask?: string | null;
|
|
|
|
|
activeSessionLabel?: string | null;
|
|
|
|
|
activeSessionUpdatedAt?: number | null;
|
|
|
|
|
controlSessionKey?: string | null;
|
|
|
|
|
recentMessages?: RuntimeMessage[];
|
|
|
|
|
subagents?: RuntimeSubagent[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ChatSendResult {
|
|
|
|
|
ok: boolean;
|
|
|
|
|
status: string;
|
|
|
|
|
reply: string;
|
|
|
|
|
raw?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const SISTER_ROLES: Record<string, string> = {
|
|
|
|
|
@@ -22,6 +83,17 @@ const SISTER_ROLES: Record<string, string> = {
|
|
|
|
|
erang: 'Infra Manager',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function runtimeStateToStatus(
|
|
|
|
|
state: RuntimeState,
|
|
|
|
|
connected: boolean,
|
|
|
|
|
): 'online' | 'offline' | 'working' {
|
|
|
|
|
if (!connected || state === 'error') return 'offline';
|
|
|
|
|
if (state === 'thinking' || state === 'tool_calling' || state === 'speaking') {
|
|
|
|
|
return 'working';
|
|
|
|
|
}
|
|
|
|
|
return 'online';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
|
export class SistersService {
|
|
|
|
|
private readonly logger = new Logger(SistersService.name);
|
|
|
|
|
@@ -34,7 +106,7 @@ export class SistersService {
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
async getAllSistersStatus(): Promise<SisterStatus[]> {
|
|
|
|
|
const sisters = await this.prisma.sisterConfig.findMany();
|
|
|
|
|
const sisters = await this.prisma.sisterConfig.findMany({ orderBy: { id: 'asc' } });
|
|
|
|
|
const sshKeyPath = this.config.get<string>('SSH_KEY_PATH');
|
|
|
|
|
if (!sshKeyPath) {
|
|
|
|
|
throw new Error('SSH_KEY_PATH is not set. Check your .env file.');
|
|
|
|
|
@@ -49,19 +121,85 @@ export class SistersService {
|
|
|
|
|
return result.value;
|
|
|
|
|
}
|
|
|
|
|
const sister = sisters[index];
|
|
|
|
|
return {
|
|
|
|
|
id: sister.id,
|
|
|
|
|
name: sister.name,
|
|
|
|
|
user: sister.user,
|
|
|
|
|
lxcId: sister.lxcId,
|
|
|
|
|
role: SISTER_ROLES[sister.name] ?? 'Unknown',
|
|
|
|
|
status: 'offline' as const,
|
|
|
|
|
lastSeen: sister.lastSeen,
|
|
|
|
|
currentTask: null,
|
|
|
|
|
};
|
|
|
|
|
return this.buildOfflineStatus(sister);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getAllSistersRuntime(): Promise<SisterRuntimeSnapshot[]> {
|
|
|
|
|
const sisters = await this.prisma.sisterConfig.findMany({ orderBy: { id: 'asc' } });
|
|
|
|
|
const sshKeyPath = this.config.get<string>('SSH_KEY_PATH');
|
|
|
|
|
if (!sshKeyPath) {
|
|
|
|
|
throw new Error('SSH_KEY_PATH is not set. Check your .env file.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const results = await Promise.allSettled(
|
|
|
|
|
sisters.map(async (sister) => {
|
|
|
|
|
const runtime = await this.probeRuntime(sister, sshKeyPath, true);
|
|
|
|
|
return this.withRuntimeName(sister.name, runtime);
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return results.map((result, index) => {
|
|
|
|
|
if (result.status === 'fulfilled') return result.value;
|
|
|
|
|
return this.buildRuntimeFallback(sisters[index].name);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getSisterRuntime(name: string): Promise<SisterRuntimeSnapshot> {
|
|
|
|
|
const sister = await this.findByName(name);
|
|
|
|
|
if (!sister) throw new Error(`Sister ${name} not found`);
|
|
|
|
|
|
|
|
|
|
const sshKeyPath = this.config.get<string>('SSH_KEY_PATH');
|
|
|
|
|
if (!sshKeyPath) {
|
|
|
|
|
throw new Error('SSH_KEY_PATH is not set. Check your .env file.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const runtime = await this.probeRuntime(sister, sshKeyPath, true);
|
|
|
|
|
return this.withRuntimeName(sister.name, runtime);
|
|
|
|
|
} catch {
|
|
|
|
|
return this.buildRuntimeFallback(sister.name);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async sendChatMessage(name: string, message: string) {
|
|
|
|
|
const trimmed = message.trim();
|
|
|
|
|
if (!trimmed) {
|
|
|
|
|
throw new BadRequestException('message is required');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sister = await this.findByName(name);
|
|
|
|
|
if (!sister) throw new Error(`Sister ${name} not found`);
|
|
|
|
|
|
|
|
|
|
const sshKeyPath = this.config.get<string>('SSH_KEY_PATH');
|
|
|
|
|
if (!sshKeyPath) {
|
|
|
|
|
throw new Error('SSH_KEY_PATH is not set. Check your .env file.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sendResult = await this.ssh.executeCommand(
|
|
|
|
|
sister.ip,
|
|
|
|
|
sister.user,
|
|
|
|
|
sshKeyPath,
|
|
|
|
|
this.buildAgentSendCommand(trimmed),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const parsed = this.parseRemoteJson<ChatSendResult>(sendResult.stdout);
|
|
|
|
|
if (!parsed?.ok) {
|
|
|
|
|
throw new Error(parsed?.raw || 'Failed to send message to sister runtime');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const runtime = await this.getSisterRuntime(name).catch(() =>
|
|
|
|
|
this.buildRuntimeFallback(name),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
reply: parsed.reply,
|
|
|
|
|
status: parsed.status,
|
|
|
|
|
runtime,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async findByName(name: string) {
|
|
|
|
|
return this.prisma.sisterConfig.findUnique({ where: { name } });
|
|
|
|
|
}
|
|
|
|
|
@@ -138,21 +276,17 @@ export class SistersService {
|
|
|
|
|
sshKeyPath: string,
|
|
|
|
|
): Promise<SisterStatus> {
|
|
|
|
|
try {
|
|
|
|
|
const result = await this.ssh.executeCommand(
|
|
|
|
|
sister.ip,
|
|
|
|
|
sister.user,
|
|
|
|
|
sshKeyPath,
|
|
|
|
|
'if systemctl --user is-active hermes-agent >/dev/null 2>&1; then echo active; elif systemctl --user is-active hermes-gateway >/dev/null 2>&1; then echo active; elif pgrep -f "hermes.*gateway|hermes.*agent" >/dev/null 2>&1; then echo active; elif systemctl --user is-active openclaw-gateway >/dev/null 2>&1; then echo active; else echo inactive; fi',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const isActive = result.stdout.trim() === 'active';
|
|
|
|
|
const status: 'online' | 'offline' = isActive ? 'online' : 'offline';
|
|
|
|
|
const runtime = await this.probeRuntime(sister, sshKeyPath, false);
|
|
|
|
|
const gatewayConnected = Boolean(runtime.gatewayConnected);
|
|
|
|
|
const liveState = runtime.mainState ?? (gatewayConnected ? 'idle' : 'error');
|
|
|
|
|
const status = runtimeStateToStatus(liveState, gatewayConnected);
|
|
|
|
|
const prevStatus = sister.status;
|
|
|
|
|
const now = new Date();
|
|
|
|
|
const lastSeen = gatewayConnected ? now : sister.lastSeen;
|
|
|
|
|
|
|
|
|
|
await this.prisma.sisterConfig.update({
|
|
|
|
|
where: { id: sister.id },
|
|
|
|
|
data: { lastSeen: isActive ? now : sister.lastSeen, status },
|
|
|
|
|
data: { lastSeen, status },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (prevStatus !== status && this.activity) {
|
|
|
|
|
@@ -172,22 +306,448 @@ export class SistersService {
|
|
|
|
|
lxcId: sister.lxcId,
|
|
|
|
|
role: SISTER_ROLES[sister.name] ?? 'Unknown',
|
|
|
|
|
status,
|
|
|
|
|
lastSeen: isActive ? now : sister.lastSeen,
|
|
|
|
|
currentTask: null,
|
|
|
|
|
lastSeen,
|
|
|
|
|
currentTask: runtime.currentTask ?? null,
|
|
|
|
|
liveState,
|
|
|
|
|
activeSessionLabel: runtime.activeSessionLabel ?? null,
|
|
|
|
|
gatewayConnected,
|
|
|
|
|
subagents: runtime.subagents ?? [],
|
|
|
|
|
};
|
|
|
|
|
} catch {
|
|
|
|
|
this.logger.warn(`Failed to check status for ${sister.name}`);
|
|
|
|
|
return this.buildOfflineStatus(sister);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async probeRuntime(
|
|
|
|
|
sister: { name: string; ip: string; user: string },
|
|
|
|
|
sshKeyPath: string,
|
|
|
|
|
includeMessages: boolean,
|
|
|
|
|
): Promise<RuntimeProbeResult> {
|
|
|
|
|
const result = await this.ssh.executeCommand(
|
|
|
|
|
sister.ip,
|
|
|
|
|
sister.user,
|
|
|
|
|
sshKeyPath,
|
|
|
|
|
this.buildRuntimeProbeCommand(includeMessages),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const parsed = this.parseRemoteJson<RuntimeProbeResult>(result.stdout);
|
|
|
|
|
if (parsed) {
|
|
|
|
|
return parsed;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const legacy = this.parseLegacyRuntimeResult(result.stdout);
|
|
|
|
|
if (legacy) {
|
|
|
|
|
return legacy;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw new Error(`Runtime probe returned invalid JSON for ${sister.name}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private withRuntimeName(
|
|
|
|
|
name: string,
|
|
|
|
|
runtime: RuntimeProbeResult,
|
|
|
|
|
): SisterRuntimeSnapshot {
|
|
|
|
|
return {
|
|
|
|
|
name,
|
|
|
|
|
gatewayConnected: Boolean(runtime.gatewayConnected),
|
|
|
|
|
mainState: runtime.mainState ?? 'error',
|
|
|
|
|
currentTask: runtime.currentTask ?? null,
|
|
|
|
|
activeSessionLabel: runtime.activeSessionLabel ?? null,
|
|
|
|
|
activeSessionUpdatedAt: runtime.activeSessionUpdatedAt ?? null,
|
|
|
|
|
controlSessionKey: runtime.controlSessionKey ?? null,
|
|
|
|
|
recentMessages: runtime.recentMessages ?? [],
|
|
|
|
|
subagents: runtime.subagents ?? [],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private buildOfflineStatus(sister: {
|
|
|
|
|
id: number;
|
|
|
|
|
name: string;
|
|
|
|
|
user: string;
|
|
|
|
|
lxcId: number;
|
|
|
|
|
lastSeen: Date | null;
|
|
|
|
|
}): SisterStatus {
|
|
|
|
|
return {
|
|
|
|
|
id: sister.id,
|
|
|
|
|
name: sister.name,
|
|
|
|
|
user: sister.user,
|
|
|
|
|
lxcId: sister.lxcId,
|
|
|
|
|
role: SISTER_ROLES[sister.name] ?? 'Unknown',
|
|
|
|
|
status: 'offline',
|
|
|
|
|
lastSeen: sister.lastSeen,
|
|
|
|
|
currentTask: null,
|
|
|
|
|
liveState: 'error',
|
|
|
|
|
activeSessionLabel: null,
|
|
|
|
|
gatewayConnected: false,
|
|
|
|
|
subagents: [],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private buildRuntimeFallback(name: string): SisterRuntimeSnapshot {
|
|
|
|
|
return {
|
|
|
|
|
name,
|
|
|
|
|
gatewayConnected: false,
|
|
|
|
|
mainState: 'error',
|
|
|
|
|
currentTask: null,
|
|
|
|
|
activeSessionLabel: null,
|
|
|
|
|
activeSessionUpdatedAt: null,
|
|
|
|
|
controlSessionKey: null,
|
|
|
|
|
recentMessages: [],
|
|
|
|
|
subagents: [],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private parseRemoteJson<T>(stdout: string): T | null {
|
|
|
|
|
const trimmed = stdout.trim();
|
|
|
|
|
const start = trimmed.indexOf('{');
|
|
|
|
|
const end = trimmed.lastIndexOf('}');
|
|
|
|
|
if (start === -1 || end === -1 || end <= start) return null;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
return JSON.parse(trimmed.slice(start, end + 1)) as T;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private parseLegacyRuntimeResult(stdout: string): RuntimeProbeResult | null {
|
|
|
|
|
const trimmed = stdout.trim();
|
|
|
|
|
if (trimmed === 'active') {
|
|
|
|
|
return {
|
|
|
|
|
id: sister.id,
|
|
|
|
|
name: sister.name,
|
|
|
|
|
user: sister.user,
|
|
|
|
|
lxcId: sister.lxcId,
|
|
|
|
|
role: SISTER_ROLES[sister.name] ?? 'Unknown',
|
|
|
|
|
status: 'offline',
|
|
|
|
|
lastSeen: sister.lastSeen,
|
|
|
|
|
gatewayConnected: true,
|
|
|
|
|
mainState: 'idle',
|
|
|
|
|
currentTask: null,
|
|
|
|
|
activeSessionLabel: null,
|
|
|
|
|
activeSessionUpdatedAt: null,
|
|
|
|
|
controlSessionKey: null,
|
|
|
|
|
recentMessages: [],
|
|
|
|
|
subagents: [],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (trimmed === 'inactive') {
|
|
|
|
|
return {
|
|
|
|
|
gatewayConnected: false,
|
|
|
|
|
mainState: 'error',
|
|
|
|
|
currentTask: null,
|
|
|
|
|
activeSessionLabel: null,
|
|
|
|
|
activeSessionUpdatedAt: null,
|
|
|
|
|
controlSessionKey: null,
|
|
|
|
|
recentMessages: [],
|
|
|
|
|
subagents: [],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private buildRuntimeProbeCommand(includeMessages: boolean): string {
|
|
|
|
|
return `
|
|
|
|
|
GATEWAY_CONNECTED=0
|
|
|
|
|
if systemctl --user is-active hermes-agent >/dev/null 2>&1; then GATEWAY_CONNECTED=1; \
|
|
|
|
|
elif systemctl --user is-active hermes-gateway >/dev/null 2>&1; then GATEWAY_CONNECTED=1; \
|
|
|
|
|
elif pgrep -f "hermes.*gateway|hermes.*agent" >/dev/null 2>&1; then GATEWAY_CONNECTED=1; \
|
|
|
|
|
elif systemctl --user is-active openclaw-gateway >/dev/null 2>&1; then GATEWAY_CONNECTED=1; \
|
|
|
|
|
elif pgrep -f "openclaw.*gateway|openclaw.*agent" >/dev/null 2>&1; then GATEWAY_CONNECTED=1; fi
|
|
|
|
|
export GATEWAY_CONNECTED
|
|
|
|
|
if command -v python3 >/dev/null 2>&1; then
|
|
|
|
|
python3 - <<'PY'
|
|
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
INCLUDE_MESSAGES = ${includeMessages ? 'True' : 'False'}
|
|
|
|
|
MESSAGE_LIMIT = ${includeMessages ? '12' : '0'}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def pick_base():
|
|
|
|
|
for name in ('.hermes', '.openclaw'):
|
|
|
|
|
candidate = Path.home() / name
|
|
|
|
|
if candidate.exists():
|
|
|
|
|
return candidate
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_json(path: Path):
|
|
|
|
|
if not path.exists():
|
|
|
|
|
return {}
|
|
|
|
|
try:
|
|
|
|
|
return json.loads(path.read_text(errors='ignore'))
|
|
|
|
|
except Exception:
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def normalize_role(role):
|
|
|
|
|
if role in ('user', 'assistant'):
|
|
|
|
|
return role
|
|
|
|
|
if role in ('tool', 'toolResult'):
|
|
|
|
|
return 'tool'
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def extract_text(content):
|
|
|
|
|
if isinstance(content, str):
|
|
|
|
|
return content.strip()
|
|
|
|
|
parts = []
|
|
|
|
|
if isinstance(content, list):
|
|
|
|
|
for item in content:
|
|
|
|
|
if not isinstance(item, dict):
|
|
|
|
|
continue
|
|
|
|
|
item_type = item.get('type')
|
|
|
|
|
text = item.get('text') or item.get('input') or item.get('content') or ''
|
|
|
|
|
if item_type in ('text', 'input_text', 'output_text') and text:
|
|
|
|
|
parts.append(str(text).strip())
|
|
|
|
|
elif item_type == 'tool_call':
|
|
|
|
|
tool_name = item.get('name') or item.get('toolName') or 'tool'
|
|
|
|
|
parts.append(f'[{tool_name}]')
|
|
|
|
|
return ' '.join(part for part in parts if part).strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def compact(text, limit=160):
|
|
|
|
|
normalized = ' '.join((text or '').split())
|
|
|
|
|
if not normalized:
|
|
|
|
|
return ''
|
|
|
|
|
if normalized.startswith('A new session was started via /new or /reset.'):
|
|
|
|
|
return ''
|
|
|
|
|
if len(normalized) <= limit:
|
|
|
|
|
return normalized
|
|
|
|
|
return normalized[: limit - 1] + '…'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def read_recent_messages(session_file, limit):
|
|
|
|
|
if not session_file or limit <= 0:
|
|
|
|
|
return []
|
|
|
|
|
path = Path(session_file)
|
|
|
|
|
if not path.exists():
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
out = []
|
|
|
|
|
try:
|
|
|
|
|
lines = path.read_text(errors='ignore').splitlines()
|
|
|
|
|
except Exception:
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
for line in reversed(lines):
|
|
|
|
|
try:
|
|
|
|
|
payload = json.loads(line)
|
|
|
|
|
except Exception:
|
|
|
|
|
continue
|
|
|
|
|
if payload.get('type') != 'message':
|
|
|
|
|
continue
|
|
|
|
|
message = payload.get('message') or {}
|
|
|
|
|
role = normalize_role(message.get('role'))
|
|
|
|
|
if not role:
|
|
|
|
|
continue
|
|
|
|
|
content = compact(extract_text(message.get('content')))
|
|
|
|
|
if not content:
|
|
|
|
|
continue
|
|
|
|
|
out.append(
|
|
|
|
|
{
|
|
|
|
|
'id': str(payload.get('id') or ''),
|
|
|
|
|
'role': role,
|
|
|
|
|
'content': content,
|
|
|
|
|
'ts': payload.get('timestamp') or message.get('timestamp'),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
if len(out) >= limit:
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
out.reverse()
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def pick_session(index, prefer_key=None):
|
|
|
|
|
if not isinstance(index, dict) or not index:
|
|
|
|
|
return None, None
|
|
|
|
|
if prefer_key and prefer_key in index:
|
|
|
|
|
return prefer_key, index.get(prefer_key)
|
|
|
|
|
items = sorted(index.items(), key=lambda item: item[1].get('updatedAt') or 0, reverse=True)
|
|
|
|
|
return items[0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_label(entry):
|
|
|
|
|
if not isinstance(entry, dict):
|
|
|
|
|
return None
|
|
|
|
|
return entry.get('displayName') or (entry.get('origin') or {}).get('label') or entry.get('lastTo')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def latest_user_text(messages):
|
|
|
|
|
for item in reversed(messages):
|
|
|
|
|
if item.get('role') == 'user':
|
|
|
|
|
return item.get('content')
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def age_minutes(updated_at):
|
|
|
|
|
if not updated_at:
|
|
|
|
|
return 10 ** 9
|
|
|
|
|
return max(0, (int(__import__('time').time() * 1000) - int(updated_at)) // 60000)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def state_from(entry, messages, connected):
|
|
|
|
|
if not connected:
|
|
|
|
|
return 'error'
|
|
|
|
|
if not isinstance(entry, dict):
|
|
|
|
|
return 'idle'
|
|
|
|
|
|
|
|
|
|
status = entry.get('status') or ''
|
|
|
|
|
last_role = messages[-1]['role'] if messages else None
|
|
|
|
|
age_min = age_minutes(entry.get('updatedAt'))
|
|
|
|
|
|
|
|
|
|
if status == 'running':
|
|
|
|
|
if last_role == 'tool':
|
|
|
|
|
return 'tool_calling'
|
|
|
|
|
if last_role == 'assistant':
|
|
|
|
|
return 'speaking'
|
|
|
|
|
return 'thinking'
|
|
|
|
|
|
|
|
|
|
if age_min <= 2:
|
|
|
|
|
if last_role == 'assistant':
|
|
|
|
|
return 'speaking'
|
|
|
|
|
if last_role == 'tool':
|
|
|
|
|
return 'tool_calling'
|
|
|
|
|
if last_role == 'user':
|
|
|
|
|
return 'thinking'
|
|
|
|
|
|
|
|
|
|
if age_min <= 15 and last_role == 'assistant':
|
|
|
|
|
return 'speaking'
|
|
|
|
|
|
|
|
|
|
return 'idle'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
payload = {
|
|
|
|
|
'gatewayConnected': os.environ.get('GATEWAY_CONNECTED') == '1',
|
|
|
|
|
'mainState': 'error',
|
|
|
|
|
'currentTask': None,
|
|
|
|
|
'activeSessionLabel': None,
|
|
|
|
|
'activeSessionUpdatedAt': None,
|
|
|
|
|
'controlSessionKey': None,
|
|
|
|
|
'recentMessages': [],
|
|
|
|
|
'subagents': [],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
base = pick_base()
|
|
|
|
|
if not base:
|
|
|
|
|
print(json.dumps(payload, ensure_ascii=False))
|
|
|
|
|
raise SystemExit
|
|
|
|
|
|
|
|
|
|
agents_root = base / 'agents'
|
|
|
|
|
main_index = load_json(agents_root / 'main' / 'sessions' / 'sessions.json')
|
|
|
|
|
active_key, active_entry = pick_session(main_index)
|
|
|
|
|
control_key, control_entry = pick_session(main_index, 'agent:main:main')
|
|
|
|
|
active_messages = read_recent_messages((active_entry or {}).get('sessionFile'), 8)
|
|
|
|
|
control_messages = read_recent_messages(
|
|
|
|
|
(control_entry or active_entry or {}).get('sessionFile'),
|
|
|
|
|
MESSAGE_LIMIT if INCLUDE_MESSAGES else 0,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
payload['mainState'] = state_from(active_entry, active_messages, payload['gatewayConnected'])
|
|
|
|
|
payload['currentTask'] = latest_user_text(active_messages) or get_label(active_entry)
|
|
|
|
|
payload['activeSessionLabel'] = get_label(active_entry)
|
|
|
|
|
payload['activeSessionUpdatedAt'] = (active_entry or {}).get('updatedAt')
|
|
|
|
|
payload['controlSessionKey'] = control_key or active_key
|
|
|
|
|
payload['recentMessages'] = control_messages
|
|
|
|
|
|
|
|
|
|
subagent_names = set()
|
|
|
|
|
workspace_agents = base / 'workspace' / 'agents'
|
|
|
|
|
if workspace_agents.exists():
|
|
|
|
|
subagent_names.update(path.stem for path in workspace_agents.glob('*.md'))
|
|
|
|
|
if agents_root.exists():
|
|
|
|
|
subagent_names.update(
|
|
|
|
|
path.name for path in agents_root.iterdir() if path.is_dir() and path.name != 'main'
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
for subagent in sorted(subagent_names):
|
|
|
|
|
sub_index = load_json(agents_root / subagent / 'sessions' / 'sessions.json')
|
|
|
|
|
_, sub_entry = pick_session(sub_index)
|
|
|
|
|
sub_messages = read_recent_messages((sub_entry or {}).get('sessionFile'), 4)
|
|
|
|
|
payload['subagents'].append(
|
|
|
|
|
{
|
|
|
|
|
'name': subagent,
|
|
|
|
|
'state': state_from(sub_entry, sub_messages, payload['gatewayConnected']),
|
|
|
|
|
'updatedAt': (sub_entry or {}).get('updatedAt'),
|
|
|
|
|
'currentTask': latest_user_text(sub_messages) or get_label(sub_entry),
|
|
|
|
|
'sessionLabel': get_label(sub_entry),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
print(json.dumps(payload, ensure_ascii=False))
|
|
|
|
|
PY
|
|
|
|
|
else
|
|
|
|
|
printf '%s' '{"gatewayConnected":false,"mainState":"error","currentTask":null,"activeSessionLabel":null,"activeSessionUpdatedAt":null,"controlSessionKey":null,"recentMessages":[],"subagents":[]}'
|
|
|
|
|
fi`.trim();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private buildAgentSendCommand(message: string): string {
|
|
|
|
|
const messageB64 = Buffer.from(message, 'utf8').toString('base64');
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
export MSG_B64='${messageB64}'
|
|
|
|
|
if command -v python3 >/dev/null 2>&1 && command -v openclaw >/dev/null 2>&1; then
|
|
|
|
|
python3 - <<'PY'
|
|
|
|
|
import base64
|
|
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
import subprocess
|
|
|
|
|
|
|
|
|
|
message = base64.b64decode(os.environ['MSG_B64']).decode('utf-8')
|
|
|
|
|
proc = subprocess.run(
|
|
|
|
|
[
|
|
|
|
|
'openclaw',
|
|
|
|
|
'agent',
|
|
|
|
|
'--agent',
|
|
|
|
|
'main',
|
|
|
|
|
'--message',
|
|
|
|
|
message,
|
|
|
|
|
'--json',
|
|
|
|
|
'--timeout',
|
|
|
|
|
'120',
|
|
|
|
|
],
|
|
|
|
|
stdout=subprocess.PIPE,
|
|
|
|
|
stderr=subprocess.STDOUT,
|
|
|
|
|
text=True,
|
|
|
|
|
)
|
|
|
|
|
out = proc.stdout or ''
|
|
|
|
|
|
|
|
|
|
start = out.find('{')
|
|
|
|
|
end = out.rfind('}')
|
|
|
|
|
payload = {}
|
|
|
|
|
if start != -1 and end != -1 and end > start:
|
|
|
|
|
try:
|
|
|
|
|
payload = json.loads(out[start : end + 1])
|
|
|
|
|
except Exception:
|
|
|
|
|
payload = {}
|
|
|
|
|
|
|
|
|
|
result = payload.get('result') if isinstance(payload.get('result'), dict) else {}
|
|
|
|
|
payloads = result.get('payloads') if isinstance(result.get('payloads'), list) else []
|
|
|
|
|
texts = []
|
|
|
|
|
for item in payloads:
|
|
|
|
|
if not isinstance(item, dict):
|
|
|
|
|
continue
|
|
|
|
|
text = item.get('text') or item.get('message') or item.get('content')
|
|
|
|
|
if isinstance(text, str) and text.strip():
|
|
|
|
|
texts.append(text.strip())
|
|
|
|
|
|
|
|
|
|
reply = '\n\n'.join(texts).strip()
|
|
|
|
|
if not reply and proc.returncode == 0:
|
|
|
|
|
reply = str(payload.get('summary') or '응답 완료').strip()
|
|
|
|
|
|
|
|
|
|
print(
|
|
|
|
|
json.dumps(
|
|
|
|
|
{
|
|
|
|
|
'ok': proc.returncode == 0,
|
|
|
|
|
'status': payload.get('status') or ('completed' if proc.returncode == 0 else 'failed'),
|
|
|
|
|
'reply': reply,
|
|
|
|
|
'raw': out[-4000:],
|
|
|
|
|
},
|
|
|
|
|
ensure_ascii=False,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
PY
|
|
|
|
|
else
|
|
|
|
|
printf '%s' '{"ok":false,"status":"failed","reply":"","raw":"openclaw runtime unavailable"}'
|
|
|
|
|
fi`.trim();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private parseUptime(stdout: string) {
|
|
|
|
|
|