chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:32 +08:00
commit 8d239a9913
214 changed files with 39453 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/Users/bingsen/clawd/openclaw-sansheng-liubu/scripts/agentrec_advisor.py
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""应用 data/pending_model_changes.json → openclaw.json,并重启 Gateway"""
import json, pathlib, subprocess, datetime, shutil, logging, glob
from file_lock import atomic_json_write, atomic_json_read
from utils import get_openclaw_home
log = logging.getLogger('model_change')
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(name)s] %(message)s', datefmt='%H:%M:%S')
BASE = pathlib.Path(__file__).parent.parent
DATA = BASE / 'data'
OPENCLAW_HOME = get_openclaw_home()
OPENCLAW_CFG = OPENCLAW_HOME / 'openclaw.json'
PENDING = DATA / 'pending_model_changes.json'
CHANGE_LOG = DATA / 'model_change_log.json'
MAX_BACKUPS = 10
def rj(path, default):
try:
return json.loads(path.read_text())
except Exception:
return default
def cleanup_backups():
"""只保留最近 MAX_BACKUPS 个备份"""
pattern = str(OPENCLAW_CFG.parent / 'openclaw.json.bak.model-*')
baks = sorted(glob.glob(pattern))
for old in baks[:-MAX_BACKUPS]:
try:
pathlib.Path(old).unlink()
except OSError:
pass
def main():
if not PENDING.exists():
return
pending = rj(PENDING, [])
if not pending:
return
cfg = rj(OPENCLAW_CFG, {})
agents_list = cfg.get('agents', {}).get('list', [])
default_model = cfg.get('agents', {}).get('defaults', {}).get('model', {}).get('primary', '')
applied, errors = [], []
for change in pending:
ag_id = change.get('agentId', '').strip()
new_model = change.get('model', '').strip()
if not ag_id or not new_model:
errors.append({'change': change, 'error': 'missing fields'})
continue
found = False
for ag in agents_list:
if ag.get('id') == ag_id:
old = ag.get('model', default_model)
if new_model == default_model:
ag.pop('model', None)
else:
ag['model'] = new_model
applied.append({'at': datetime.datetime.now().isoformat(), 'agentId': ag_id, 'oldModel': old, 'newModel': new_model})
found = True
break
if not found:
errors.append({'change': change, 'error': f'agent {ag_id} not found'})
if applied:
# 只有内容真正变化时才备份和写入
new_cfg = dict(cfg)
new_cfg['agents'] = dict(cfg.get('agents', {}))
new_cfg['agents']['list'] = agents_list
old_text = json.dumps(cfg, ensure_ascii=False, sort_keys=True)
new_text = json.dumps(new_cfg, ensure_ascii=False, sort_keys=True)
if old_text != new_text:
bak = OPENCLAW_CFG.parent / f'openclaw.json.bak.model-{datetime.datetime.now().strftime("%Y%m%d-%H%M%S")}'
shutil.copy2(OPENCLAW_CFG, bak)
cleanup_backups()
atomic_json_write(OPENCLAW_CFG, new_cfg)
cfg = new_cfg
log_data = rj(CHANGE_LOG, [])
if not isinstance(log_data, list):
log_data = []
log_data.extend(applied)
if len(log_data) > 200:
log_data = log_data[-200:]
atomic_json_write(CHANGE_LOG, log_data)
for e in applied:
log.info(f'{e["agentId"]}: {e["oldModel"]}{e["newModel"]}')
restart_ok = False
rollback = False
try:
r = subprocess.run(['openclaw', 'gateway', 'restart'], capture_output=True, text=True, timeout=30)
restart_ok = r.returncode == 0
log.info(f'gateway restart rc={r.returncode}')
except Exception as e:
log.error(f'gateway restart failed: {e}')
# 回滚配置
if bak.exists():
shutil.copy2(bak, OPENCLAW_CFG)
log.warning('rolled back openclaw.json from backup')
rollback = True
for a in applied:
a['rolledBack'] = True
atomic_json_write(PENDING, [])
atomic_json_write(DATA / 'last_model_change_result.json', {
'at': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'applied': applied, 'errors': errors,
'gatewayRestarted': restart_ok, 'rolledBack': rollback,
})
elif errors:
log.warning(f'{len(errors)} changes failed, 0 applied')
atomic_json_write(PENDING, [])
if __name__ == '__main__':
main()
+237
View File
@@ -0,0 +1,237 @@
#!/usr/bin/env python3
"""
早朝简报采集脚本
每日 06:00 自动运行,抓取全球新闻 RSS → data/morning_brief_YYYYMMDD.json
覆盖: 政治 | 军事 | 经济 | AI大模型
"""
import json, pathlib, datetime, subprocess, re, sys, os, logging
from xml.etree import ElementTree as ET
from file_lock import atomic_json_write
from utils import validate_url
log = logging.getLogger('朝报')
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(name)s] %(message)s', datefmt='%H:%M:%S')
DATA = pathlib.Path(__file__).resolve().parent.parent / 'data'
# ── RSS 源配置 ──────────────────────────────────────────────────────────
FEEDS = {
'政治': [
('BBC World', 'https://feeds.bbci.co.uk/news/world/rss.xml'),
('Reuters World', 'https://feeds.reuters.com/reuters/worldNews'),
('AP Top News', 'https://rsshub.app/apnews/topics/ap-top-news'),
],
'军事': [
('Defense News', 'https://www.defensenews.com/rss/'),
('BBC World', 'https://feeds.bbci.co.uk/news/world/rss.xml'),
('Reuters', 'https://feeds.reuters.com/reuters/worldNews'),
],
'经济': [
('Reuters Business', 'https://feeds.reuters.com/reuters/businessNews'),
('BBC Business', 'https://feeds.bbci.co.uk/news/business/rss.xml'),
('CNBC', 'https://search.cnbc.com/rs/search/combinedcms/view.xml?partnerId=wrss01&id=100003114'),
],
'AI大模型': [
('Hacker News', 'https://hnrss.org/newest?q=AI+LLM+model&points=50'),
('VentureBeat AI', 'https://venturebeat.com/category/ai/feed/'),
('MIT Tech Review', 'https://www.technologyreview.com/feed/'),
],
}
CATEGORY_KEYWORDS = {
'军事': ['war', 'military', 'troops', 'attack', 'missile', 'army', 'navy', 'weapons',
'', '', '导弹', '士兵', 'ukraine', 'russia', 'china sea', 'nato'],
'AI大模型': ['ai', 'llm', 'gpt', 'claude', 'gemini', 'openai', 'anthropic', 'deepseek',
'machine learning', 'neural', 'model', '大模型', '人工智能', 'chatgpt'],
}
def curl_rss(url, timeout=10):
"""用 curl 抓取 RSS"""
try:
from urllib.request import Request, urlopen
req = Request(url, headers={'User-Agent': 'Mozilla/5.0 (compatible; MorningBrief/1.0)'})
response = urlopen(req, timeout=timeout)
return response.read().decode('utf-8', errors='ignore')
except Exception:
return ''
def _safe_parse_xml(xml_text, max_size=5*1024*1024):
"""安全解析 XML:限制大小,禁用外部实体(防 XXE)。"""
if len(xml_text) > max_size:
log.warning(f'XML 内容过大 ({len(xml_text)} bytes),跳过')
return None
# 剥离 DOCTYPE / ENTITY 声明以防 XXE
cleaned = re.sub(r'<!DOCTYPE[^>]*>', '', xml_text, flags=re.IGNORECASE)
cleaned = re.sub(r'<!ENTITY[^>]*>', '', cleaned, flags=re.IGNORECASE)
try:
return ET.fromstring(cleaned)
except ET.ParseError:
return None
def parse_rss(xml_text):
"""解析 RSS XML → list of {title, desc, link, pub_date, image}"""
items = []
try:
root = _safe_parse_xml(xml_text)
if root is None:
return items
# RSS 2.0
ns = {'media': 'http://search.yahoo.com/mrss/'}
for item in root.findall('.//item')[:8]:
def get(tag):
el = item.find(tag)
return (el.text or '').strip() if el is not None else ''
title = get('title')
desc = re.sub(r'<[^>]+>', '', get('description'))[:200]
link = get('link')
pub = get('pubDate')
# 图片
img = ''
enc = item.find('enclosure')
if enc is not None and 'image' in (enc.get('type') or ''):
img = enc.get('url', '')
media = item.find('media:thumbnail', ns) or item.find('media:content', ns)
if media is not None:
img = media.get('url', img)
items.append({'title': title, 'desc': desc, 'link': link,
'pub_date': pub, 'image': img})
except Exception:
pass
return items
def match_category(item, category):
"""判断新闻是否属于该分类(用于军事/AI过滤)"""
kws = CATEGORY_KEYWORDS.get(category, [])
if not kws:
return True
text = (item['title'] + ' ' + item['desc']).lower()
return any(k in text for k in kws)
def fetch_category(category, feeds, max_items=5):
"""抓取一个分类的新闻"""
seen_urls = set()
results = []
for source_name, url in feeds:
if len(results) >= max_items:
break
xml = curl_rss(url)
if not xml:
continue
items = parse_rss(xml)
for item in items:
if not item['title']:
continue
if item['link'] in seen_urls:
continue
# 军事和AI分类需要关键词过滤
if category in CATEGORY_KEYWORDS and not match_category(item, category):
continue
seen_urls.add(item['link'])
results.append({
'title': item['title'],
'summary': item['desc'] or item['title'],
'link': item['link'],
'pub_date': item['pub_date'],
'image': item['image'],
'source': source_name,
})
if len(results) >= max_items:
break
return results
def main():
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('--force', action='store_true', help='强制采集,忽略幂等锁')
args = parser.parse_args()
# 幂等锁:防重复执行
today = datetime.date.today().strftime('%Y%m%d')
lock_file = DATA / f'morning_brief_{today}.lock'
if lock_file.exists() and not args.force:
age = datetime.datetime.now().timestamp() - lock_file.stat().st_mtime
if age < 3600: # 1小时内不重复
log.info(f'今日已采集({today}),跳过(使用 --force 强制采集)')
return
# 注意:lock 放到采集成功后再 touch,防止失败也锁定
# 读取用户配置
config_file = DATA / 'morning_brief_config.json'
config = {}
try:
config = json.loads(config_file.read_text())
except Exception:
pass
# 已启用的分类
enabled_cats = set()
if config.get('categories'):
for c in config['categories']:
if c.get('enabled', True):
enabled_cats.add(c['name'])
else:
enabled_cats = set(FEEDS.keys())
# 用户自定义关键词(全局加权)
user_keywords = [kw.lower() for kw in config.get('keywords', [])]
# 合并自定义 RSS 源
custom_feeds = config.get('custom_feeds', [])
merged_feeds = {}
for cat, feeds in FEEDS.items():
if cat in enabled_cats:
merged_feeds[cat] = list(feeds)
for cf in custom_feeds:
cat = cf.get('category', '')
feed_url = cf.get('url', '')
if cat in enabled_cats and feed_url:
# 校验自定义源 URL(SSRF 防护)
if validate_url(feed_url):
merged_feeds.setdefault(cat, []).append((cf.get('name', '自定义'), feed_url))
else:
log.warning(f'自定义源 URL 不合法,跳过: {feed_url}')
log.info(f'开始采集 {today}...')
log.info(f' 启用分类: {", ".join(enabled_cats)}')
if user_keywords:
log.info(f' 关注词: {", ".join(user_keywords)}')
if custom_feeds:
log.info(f' 自定义源: {len(custom_feeds)}')
result = {
'date': today,
'generated_at': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'categories': {}
}
for category, feeds in merged_feeds.items():
log.info(f' 采集 {category}...')
items = fetch_category(category, feeds)
# Boost items matching user keywords
if user_keywords:
for item in items:
text = (item.get('title', '') + ' ' + item.get('summary', '')).lower()
item['_kw_hits'] = sum(1 for kw in user_keywords if kw in text)
items.sort(key=lambda x: x.get('_kw_hits', 0), reverse=True)
for item in items:
item.pop('_kw_hits', None)
result['categories'][category] = items
log.info(f' {category}: {len(items)}')
# 写入今日文件
today_file = DATA / f'morning_brief_{today}.json'
atomic_json_write(today_file, result)
# 覆写 latest(看板读这个)
latest_file = DATA / 'morning_brief.json'
atomic_json_write(latest_file, result)
total = sum(len(v) for v in result['categories'].values())
log.info(f'✅ 完成:共 {total} 条新闻 → {today_file.name}')
# 采集成功后才写入幂等锁
lock_file.touch()
if __name__ == '__main__':
main()
+139
View File
@@ -0,0 +1,139 @@
"""
文件锁工具 — 防止多进程并发读写 JSON 文件导致数据丢失。
用法:
from file_lock import atomic_json_update, atomic_json_read
# 原子读取
data = atomic_json_read(path, default=[])
# 原子更新(读 → 修改 → 写回,全程持锁)
def modifier(tasks):
tasks.append(new_task)
return tasks
atomic_json_update(path, modifier, default=[])
"""
import json
import os
import pathlib
import tempfile
from typing import Any, Callable
_IS_WINDOWS = os.name == 'nt'
if _IS_WINDOWS:
import msvcrt
else:
import fcntl
# ── 平台抽象:文件锁 ────────────────────────────────────────────
def _lock_shared(fd: int) -> None:
"""获取共享锁(读锁)。"""
if _IS_WINDOWS:
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
else:
fcntl.flock(fd, fcntl.LOCK_SH)
def _lock_exclusive(fd: int) -> None:
"""获取排他锁(写锁)。"""
if _IS_WINDOWS:
msvcrt.locking(fd, msvcrt.LK_LOCK, 1)
else:
fcntl.flock(fd, fcntl.LOCK_EX)
def _unlock(fd: int) -> None:
"""释放锁。"""
if _IS_WINDOWS:
try:
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
except OSError:
pass
else:
fcntl.flock(fd, fcntl.LOCK_UN)
def _lock_path(path: pathlib.Path) -> pathlib.Path:
return path.parent / (path.name + '.lock')
def atomic_json_read(path: pathlib.Path, default: Any = None) -> Any:
"""持锁读取 JSON 文件。"""
lock_file = _lock_path(path)
lock_file.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(lock_file), os.O_CREAT | os.O_RDWR)
try:
_lock_shared(fd)
try:
return json.loads(path.read_text(encoding='utf-8')) if path.exists() else default
except Exception:
return default
finally:
_unlock(fd)
os.close(fd)
def atomic_json_update(
path: pathlib.Path,
modifier: Callable[[Any], Any],
default: Any = None,
) -> Any:
"""
原子地读取 → 修改 → 写回 JSON 文件。
modifier(data) 应返回修改后的数据。
使用临时文件 + rename 保证写入原子性。
"""
lock_file = _lock_path(path)
lock_file.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(lock_file), os.O_CREAT | os.O_RDWR)
try:
_lock_exclusive(fd)
# Read
try:
data = json.loads(path.read_text(encoding='utf-8')) if path.exists() else default
except Exception:
data = default
# Modify
result = modifier(data)
# Atomic write via temp file + rename
tmp_fd, tmp_path = tempfile.mkstemp(
dir=str(path.parent), suffix='.tmp', prefix=path.stem + '_'
)
try:
with os.fdopen(tmp_fd, 'w', encoding='utf-8') as f:
json.dump(result, f, ensure_ascii=False, indent=2)
os.replace(tmp_path, str(path))
except Exception:
os.unlink(tmp_path)
raise
return result
finally:
_unlock(fd)
os.close(fd)
def atomic_json_write(path: pathlib.Path, data: Any) -> None:
"""原子写入 JSON 文件(持排他锁 + tmpfile rename)。
直接写入,不读取现有内容(避免 atomic_json_update 的多余读开销)。
"""
lock_file = _lock_path(path)
lock_file.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(lock_file), os.O_CREAT | os.O_RDWR)
try:
_lock_exclusive(fd)
tmp_fd, tmp_path = tempfile.mkstemp(
dir=str(path.parent), suffix='.tmp', prefix=path.stem + '_'
)
try:
with os.fdopen(tmp_fd, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
os.replace(tmp_path, str(path))
except Exception:
os.unlink(tmp_path)
raise
finally:
_unlock(fd)
os.close(fd)
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
/Users/bingsen/clawd/openclaw-sansheng-liubu/scripts/linucb_router.py
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Record a demo video of the dashboard and convert to GIF."""
from playwright.sync_api import sync_playwright
import subprocess, os, time
ROOT = os.path.join(os.path.dirname(__file__), '..')
VIDEO_DIR = os.path.join(ROOT, 'docs', '_video_tmp')
OUTPUT_GIF = os.path.join(ROOT, 'docs', 'demo.gif')
URL = 'http://localhost:7891'
def main():
os.makedirs(VIDEO_DIR, exist_ok=True)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
ctx = browser.new_context(
viewport={'width': 1280, 'height': 720},
device_scale_factor=2,
color_scheme='dark',
record_video_dir=VIDEO_DIR,
record_video_size={'width': 1280, 'height': 720},
)
page = ctx.new_page()
# === Scene 1: Ceremony (3s) ===
print('🎬 Scene 1: Ceremony...')
page.goto(URL)
page.wait_for_timeout(500)
page.evaluate("localStorage.removeItem('openclaw_court_date')")
page.reload()
page.wait_for_timeout(3500)
# === Scene 2: Kanban overview (3s) ===
print('📋 Scene 2: Kanban...')
# Ceremony should have auto-dismissed by now, or skip it
page.evaluate("localStorage.setItem('openclaw_court_date', new Date().toISOString().substring(0,10))")
page.reload()
page.wait_for_load_state('networkidle')
page.wait_for_timeout(2000)
# Slow scroll down to show tasks
page.mouse.wheel(0, 300)
page.wait_for_timeout(1500)
page.mouse.wheel(0, -300)
page.wait_for_timeout(500)
# === Scene 3: Click a task (3s) ===
print('📜 Scene 3: Task detail...')
cards = page.locator('.edict-card')
if cards.count() > 0:
cards.first.click()
page.wait_for_timeout(2500)
page.keyboard.press('Escape')
page.wait_for_timeout(500)
# === Scene 4: Monitor (2s) ===
print('🔭 Scene 4: Monitor...')
page.click('[data-tab="monitor"]')
page.wait_for_timeout(2000)
# === Scene 5: Memorials (2s) ===
print('📜 Scene 5: Memorials...')
page.click('[data-tab="memorials"]')
page.wait_for_timeout(2000)
# === Scene 6: Templates (2s) ===
print('📜 Scene 6: Templates...')
page.click('[data-tab="templates"]')
page.wait_for_timeout(2000)
# === Scene 7: Officials (2s) ===
print('👥 Scene 7: Officials...')
page.click('[data-tab="officials"]')
page.wait_for_timeout(2000)
# === Scene 8: Models (1.5s) ===
print('⚙️ Scene 8: Models...')
page.click('[data-tab="models"]')
page.wait_for_timeout(1500)
# === Scene 9: Back to Kanban (1s) ===
print('📋 Scene 9: Back to kanban...')
page.click('[data-tab="edicts"]')
page.wait_for_timeout(1500)
# Close context to finalize video
page.close()
ctx.close()
browser.close()
# Find the recorded video
videos = [f for f in os.listdir(VIDEO_DIR) if f.endswith('.webm')]
if not videos:
print('❌ No video recorded!')
return
video_path = os.path.join(VIDEO_DIR, videos[0])
print(f'🎥 Video: {video_path} ({os.path.getsize(video_path) / 1024 / 1024:.1f} MB)')
# Convert to GIF using ffmpeg
# Two-pass: generate palette first for quality, then apply
palette_path = os.path.join(VIDEO_DIR, 'palette.png')
print('🎨 Generating palette...')
subprocess.run([
'ffmpeg', '-y', '-i', video_path,
'-vf', 'fps=12,scale=800:-1:flags=lanczos,palettegen=max_colors=128',
palette_path
], capture_output=True)
print('🖼️ Converting to GIF...')
subprocess.run([
'ffmpeg', '-y', '-i', video_path, '-i', palette_path,
'-lavfi', 'fps=12,scale=800:-1:flags=lanczos [x]; [x][1:v] paletteuse=dither=bayer:bayer_scale=3',
OUTPUT_GIF
], capture_output=True)
size_mb = os.path.getsize(OUTPUT_GIF) / 1024 / 1024
print(f'✅ GIF saved: {OUTPUT_GIF} ({size_mb:.1f} MB)')
if size_mb > 5:
print('⚠️ GIF is over 5MB, re-encoding with lower quality...')
subprocess.run([
'ffmpeg', '-y', '-i', video_path,
'-vf', 'fps=10,scale=640:-1:flags=lanczos,palettegen=max_colors=64',
palette_path
], capture_output=True)
subprocess.run([
'ffmpeg', '-y', '-i', video_path, '-i', palette_path,
'-lavfi', 'fps=10,scale=640:-1:flags=lanczos [x]; [x][1:v] paletteuse=dither=bayer:bayer_scale=5',
OUTPUT_GIF
], capture_output=True)
size_mb = os.path.getsize(OUTPUT_GIF) / 1024 / 1024
print(f'✅ Re-encoded GIF: {size_mb:.1f} MB')
# Cleanup
import shutil
shutil.rmtree(VIDEO_DIR, ignore_errors=True)
print('🧹 Cleaned up temp files')
if __name__ == '__main__':
main()
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
import json, pathlib, datetime, logging
from file_lock import atomic_json_write, atomic_json_read
from utils import read_json
log = logging.getLogger('refresh')
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(name)s] %(message)s', datefmt='%H:%M:%S')
BASE = pathlib.Path(__file__).parent.parent
DATA = BASE / 'data'
def output_meta(path):
p = pathlib.Path(path)
if not p.exists():
return {"exists": False, "lastModified": None}
ts = datetime.datetime.fromtimestamp(p.stat().st_mtime).strftime('%Y-%m-%d %H:%M:%S')
return {"exists": True, "lastModified": ts}
def main():
# 使用 officials_stats.json(与 sync_officials_stats.py 统一)
officials_data = read_json(DATA / 'officials_stats.json', {})
officials = officials_data.get('officials', []) if isinstance(officials_data, dict) else officials_data
# 任务源优先:tasks_source.json(可对接外部系统同步写入)
tasks = atomic_json_read(DATA / 'tasks_source.json', [])
if not tasks:
tasks = read_json(DATA / 'tasks.json', [])
sync_status = read_json(DATA / 'sync_status.json', {})
org_map = {}
for o in officials:
label = o.get('label', o.get('name', ''))
if label:
org_map[label] = label
now_ts = datetime.datetime.now(datetime.timezone.utc)
for t in tasks:
t['org'] = t.get('org') or org_map.get(t.get('official', ''), '')
t['outputMeta'] = output_meta(t.get('output', ''))
# 心跳时效检测:对 Doing/Assigned 状态的任务标注活跃度
if t.get('state') in ('Doing', 'Assigned', 'Review'):
updated_raw = t.get('updatedAt') or t.get('sourceMeta', {}).get('updatedAt')
age_sec = None
if updated_raw:
try:
if isinstance(updated_raw, (int, float)):
updated_dt = datetime.datetime.fromtimestamp(updated_raw / 1000, tz=datetime.timezone.utc)
else:
updated_dt = datetime.datetime.fromisoformat(str(updated_raw).replace('Z', '+00:00'))
age_sec = (now_ts - updated_dt).total_seconds()
except Exception:
pass
if age_sec is None:
t['heartbeat'] = {'status': 'unknown', 'label': '⚪ 未知', 'ageSec': None}
elif age_sec < 300:
t['heartbeat'] = {'status': 'active', 'label': f'🟢 活跃 {int(age_sec//60)}分钟前', 'ageSec': int(age_sec)}
elif age_sec < 900:
t['heartbeat'] = {'status': 'warn', 'label': f'🟡 可能停滞 {int(age_sec//60)}分钟前', 'ageSec': int(age_sec)}
else:
t['heartbeat'] = {'status': 'stalled', 'label': f'🔴 已停滞 {int(age_sec//60)}分钟', 'ageSec': int(age_sec)}
else:
t['heartbeat'] = None
today_str = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d')
def _is_today_done(t):
if t.get('state') != 'Done':
return False
ua = t.get('updatedAt', '')
if isinstance(ua, str) and ua[:10] == today_str:
return True
# fallback: outputMeta lastModified
lm = t.get('outputMeta', {}).get('lastModified', '')
if isinstance(lm, str) and lm[:10] == today_str:
return True
return False
today_done = sum(1 for t in tasks if _is_today_done(t))
total_done = sum(1 for t in tasks if t.get('state') == 'Done')
in_progress = sum(1 for t in tasks if t.get('state') in ['Doing', 'Review', 'Next', 'Blocked'])
blocked = sum(1 for t in tasks if t.get('state') == 'Blocked')
history = []
for t in tasks:
if t.get('state') == 'Done':
lm = t.get('outputMeta', {}).get('lastModified')
history.append({
'at': lm or '未知',
'official': t.get('official'),
'task': t.get('title'),
'out': t.get('output'),
'qa': '通过' if t.get('outputMeta', {}).get('exists') else '待补成果'
})
payload = {
'generatedAt': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'taskSource': 'tasks_source.json' if (DATA / 'tasks_source.json').exists() else 'tasks.json',
'officials': officials,
'tasks': tasks,
'history': history,
'metrics': {
'officialCount': len(officials),
'todayDone': today_done,
'totalDone': total_done,
'inProgress': in_progress,
'blocked': blocked
},
'syncStatus': sync_status,
'health': {
'syncOk': bool(sync_status.get('ok', False)),
'syncLatencyMs': sync_status.get('durationMs'),
'missingFieldCount': len(sync_status.get('missingFields', {})),
}
}
atomic_json_write(DATA / 'live_status.json', payload)
log.info(f'updated live_status.json ({len(tasks)} tasks)')
if __name__ == '__main__':
main()
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Refresh Watcher — 常驻进程,监控信号文件,debounce 后执行 refresh_live_data.py。
替代 kanban_update.py 中每次操作都 fork 子进程的方式。
多 Agent 并发时,200 次 touch → 合并为 1 次 refresh。
运行方式:
python3 scripts/refresh_watcher.py
部署方式:
- systemd: 参见 edict.service
- docker-compose: 参见 edict/docker-compose.yml
- 手动前台: python3 scripts/refresh_watcher.py
"""
import logging
import os
import pathlib
import signal
import subprocess
import sys
import time
_BASE = pathlib.Path(os.environ.get('EDICT_HOME', '')).resolve() if os.environ.get('EDICT_HOME') else pathlib.Path(__file__).resolve().parent.parent
SIGNAL_FILE = _BASE / 'data' / '.refresh_pending'
PID_FILE = _BASE / 'data' / '.refresh_watcher_pid'
REFRESH_SCRIPT = _BASE / 'scripts' / 'refresh_live_data.py'
DEBOUNCE_SEC = 2 # 信号文件稳定 2 秒后才执行
POLL_INTERVAL = 0.5 # 检查间隔
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [refresh_watcher] %(message)s',
datefmt='%H:%M:%S',
)
log = logging.getLogger('refresh_watcher')
_running = True
def _shutdown(signum, frame):
global _running
_running = False
log.info(f'收到信号 {signum},准备退出')
def main():
# 写 PID 文件,让 kanban_update.py 知道 watcher 在运行
PID_FILE.parent.mkdir(parents=True, exist_ok=True)
PID_FILE.write_text(str(os.getpid()))
log.info(f'Refresh watcher started (pid={os.getpid()}, debounce={DEBOUNCE_SEC}s)')
signal.signal(signal.SIGTERM, _shutdown)
signal.signal(signal.SIGINT, _shutdown)
last_seen_mtime = 0.0
refresh_count = 0
try:
while _running:
try:
if SIGNAL_FILE.exists():
mtime = SIGNAL_FILE.stat().st_mtime
now = time.time()
# 信号文件存在且已稳定 DEBOUNCE_SEC 秒
if mtime > last_seen_mtime and (now - mtime) >= DEBOUNCE_SEC:
last_seen_mtime = mtime
# 删除信号文件(在执行前删,避免执行期间的新 touch 被吞)
try:
SIGNAL_FILE.unlink()
except FileNotFoundError:
pass
refresh_count += 1
log.info(f'🔄 执行 refresh #{refresh_count}')
try:
subprocess.run(
[sys.executable, str(REFRESH_SCRIPT)],
capture_output=True,
timeout=30,
)
except subprocess.TimeoutExpired:
log.warning('refresh_live_data.py 超时 (30s)')
except Exception as e:
log.error(f'refresh 执行失败: {e}')
except Exception as e:
log.error(f'Watcher loop error: {e}')
time.sleep(POLL_INTERVAL)
finally:
# 清理 PID 文件
try:
PID_FILE.unlink()
except Exception:
pass
log.info(f'Refresh watcher stopped (total refreshes: {refresh_count})')
if __name__ == '__main__':
main()
+119
View File
@@ -0,0 +1,119 @@
<#
.SYNOPSIS
三省六部数据刷新循环 (Windows PowerShell 版本)
.DESCRIPTION
run_loop.sh 的 Windows 等效脚本。
用法: .\run_loop.ps1 [-Interval 15] [-ScanInterval 120]
.NOTES
源自 GitHub Issue #245 (感谢 @Vip4pt 贡献)
#>
param(
[int]$Interval = 15,
[int]$ScanInterval = 120
)
# ── 基础配置 ──
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
if (-not $env:OPENCLAW_HOME) {
$env:OPENCLAW_HOME = Split-Path -Parent $ScriptDir
}
$Log = "$env:TEMP\sansheng_liubu_refresh.log"
$PidFile = "$env:TEMP\sansheng_liubu_refresh.pid"
$MaxLogSize = 10MB
$ScriptTimeout = 30
$DashboardPort = $env:EDICT_DASHBOARD_PORT
if (-not $DashboardPort) { $DashboardPort = 7891 }
# ── 单实例保护 ──
if (Test-Path $PidFile) {
$OldPid = Get-Content $PidFile -ErrorAction SilentlyContinue
if ($OldPid -and (Get-Process -Id $OldPid -ErrorAction SilentlyContinue)) {
Write-Host "Already running (PID=$OldPid), exiting."
exit 1
}
Remove-Item $PidFile -Force -ErrorAction SilentlyContinue
}
$PID | Out-File $PidFile
# ── 优雅退出 ──
$cleanup = {
"$(Get-Date -Format HH:mm:ss) [loop] Shutting down..." | Out-File $Log -Append
Remove-Item $PidFile -Force -ErrorAction SilentlyContinue
exit
}
Register-EngineEvent PowerShell.Exiting -Action $cleanup | Out-Null
# ── 日志轮转 ──
function Rotate-Log {
if (Test-Path $Log) {
$size = (Get-Item $Log).Length
if ($size -gt $MaxLogSize) {
Move-Item $Log "$Log.1" -Force
"$(Get-Date -Format HH:mm:ss) [loop] Log rotated" | Out-File $Log
}
}
}
# ── 安全执行(带超时)──
function Safe-Run($script) {
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = "python"
$psi.Arguments = "`"$script`""
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.UseShellExecute = $false
$process = New-Object System.Diagnostics.Process
$process.StartInfo = $psi
$process.Start() | Out-Null
if (-not $process.WaitForExit($ScriptTimeout * 1000)) {
try {
$process.Kill()
"$(Get-Date -Format HH:mm:ss) [loop] Script timeout (${ScriptTimeout}s): $script" | Out-File $Log -Append
} catch {}
}
$stdout = $process.StandardOutput.ReadToEnd()
$stderr = $process.StandardError.ReadToEnd()
if ($stdout) { $stdout | Out-File $Log -Append }
if ($stderr) { $stderr | Out-File $Log -Append }
}
# ── 启动信息 ──
Write-Host "Data refresh loop started (PID=$PID)"
Write-Host " Script dir: $ScriptDir"
Write-Host " Interval: ${Interval}s Scan: ${ScanInterval}s Timeout: ${ScriptTimeout}s"
Write-Host " Log: $Log"
Write-Host " Ctrl+C to stop"
$ScanCounter = 0
# ── 主循环 ──
while ($true) {
Rotate-Log
Safe-Run "$ScriptDir\sync_from_openclaw_runtime.py"
Safe-Run "$ScriptDir\sync_agent_config.py"
Safe-Run "$ScriptDir\apply_model_changes.py"
Safe-Run "$ScriptDir\sync_officials_stats.py"
Safe-Run "$ScriptDir\refresh_live_data.py"
# ── 巡检任务 ──
$ScanCounter += $Interval
if ($ScanCounter -ge $ScanInterval) {
$ScanCounter = 0
try {
Invoke-RestMethod -Uri "http://127.0.0.1:$DashboardPort/api/scheduler-scan" `
-Method POST `
-ContentType "application/json" `
-Body '{"thresholdSec":180}' | Out-Null
} catch {
$_ | Out-File $Log -Append
}
}
Start-Sleep -Seconds $Interval
}
+90
View File
@@ -0,0 +1,90 @@
#!/bin/bash
# 三省六部 · 数据刷新循环
# 用法: ./run_loop.sh [间隔秒数 [巡检间隔秒数]]
# 间隔秒数:数据刷新频率,默认 15 秒
# 巡检间隔秒数:自动重试卡住任务的频率,默认 120 秒
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export EDICT_HOME="${EDICT_HOME:-$(dirname "$SCRIPT_DIR")}"
PYTHON_BIN="${EDICT_PYTHON:-python3}"
INTERVAL="${1:-15}"
LOG="/tmp/sansheng_liubu_refresh.log"
PIDFILE="/tmp/sansheng_liubu_refresh.pid"
MAX_LOG_SIZE=$((10 * 1024 * 1024)) # 10MB
# ── 单实例保护 ──
if [[ -f "$PIDFILE" ]]; then
OLD_PID=$(cat "$PIDFILE" 2>/dev/null)
if kill -0 "$OLD_PID" 2>/dev/null; then
echo "❌ 已有实例运行中 (PID=$OLD_PID),退出"
exit 1
fi
rm -f "$PIDFILE"
fi
echo $$ > "$PIDFILE"
# ── 优雅退出 ──
cleanup() {
echo "$(date '+%H:%M:%S') [loop] 收到退出信号,清理中..." >> "$LOG"
rm -f "$PIDFILE"
exit 0
}
trap cleanup SIGINT SIGTERM EXIT
# ── 日志轮转 ──
rotate_log() {
if [[ -f "$LOG" ]] && (( $(stat -f%z "$LOG" 2>/dev/null || stat -c%s "$LOG" 2>/dev/null || echo 0) > MAX_LOG_SIZE )); then
mv "$LOG" "${LOG}.1"
echo "$(date '+%H:%M:%S') [loop] 日志已轮转" > "$LOG"
fi
}
SCAN_INTERVAL="${2:-120}" # 巡检间隔(秒), 默认 120
SCAN_COUNTER=0
SCRIPT_TIMEOUT=30 # 单个脚本最大执行时间(秒)
DASHBOARD_PORT="${EDICT_DASHBOARD_PORT:-7891}" # 看板端口,可通过环境变量覆盖
echo "🏛️ 三省六部数据刷新循环启动 (PID=$$)"
echo " 脚本目录: $SCRIPT_DIR"
echo " 间隔: ${INTERVAL}s"
echo " 巡检间隔: ${SCAN_INTERVAL}s"
echo " 脚本超时: ${SCRIPT_TIMEOUT}s"
echo " 日志: $LOG"
echo " PID文件: $PIDFILE"
echo " 按 Ctrl+C 停止"
# ── 安全执行(带超时保护)──
safe_run() {
local script="$1"
if command -v timeout &>/dev/null; then
timeout "$SCRIPT_TIMEOUT" "$PYTHON_BIN" "$script" >> "$LOG" 2>&1 || {
local rc=$?
if [[ $rc -eq 124 ]]; then
echo "$(date '+%H:%M:%S') [loop] ⚠️ 脚本超时(${SCRIPT_TIMEOUT}s): $script" >> "$LOG"
fi
}
else
"$PYTHON_BIN" "$script" >> "$LOG" 2>&1 || true
fi
}
while true; do
rotate_log
safe_run "$SCRIPT_DIR/sync_from_openclaw_runtime.py"
safe_run "$SCRIPT_DIR/sync_agent_config.py"
safe_run "$SCRIPT_DIR/apply_model_changes.py"
safe_run "$SCRIPT_DIR/sync_officials_stats.py"
safe_run "$SCRIPT_DIR/refresh_live_data.py"
# 定期巡检:检测卡住的任务并自动重试
SCAN_COUNTER=$((SCAN_COUNTER + INTERVAL))
if (( SCAN_COUNTER >= SCAN_INTERVAL )); then
SCAN_COUNTER=0
curl -s -X POST "http://127.0.0.1:${DASHBOARD_PORT}/api/scheduler-scan" \
-H 'Content-Type: application/json' -d '{"thresholdSec":180}' >> "$LOG" 2>&1 || true
fi
sleep "$INTERVAL"
done
+379
View File
@@ -0,0 +1,379 @@
#!/usr/bin/env python3
"""
三省六部 · Skill 管理工具
支持从本地或远程 URL 添加、更新、查看和移除 skills
Usage:
python3 scripts/skill_manager.py add-remote --agent zhongshu --name code_review \\
--source https://raw.githubusercontent.com/org/skills/main/code_review/SKILL.md \\
--description "代码审查"
python3 scripts/skill_manager.py list-remote
python3 scripts/skill_manager.py update-remote --agent zhongshu --name code_review
python3 scripts/skill_manager.py remove-remote --agent zhongshu --name code_review
python3 scripts/skill_manager.py import-official-hub --agents menxia,shangshu
"""
import sys
import json
import pathlib
import argparse
import os
import urllib.request
import urllib.error
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from utils import get_openclaw_home, now_iso, safe_name, read_json
OCLAW_HOME = get_openclaw_home()
def _download_file(url: str, timeout: int = 30, retries: int = 3) -> str:
"""从 URL 下载文件内容(文本格式),支持重试"""
last_error = None
for attempt in range(1, retries + 1):
try:
req = urllib.request.Request(url, headers={'User-Agent': 'OpenClaw-SkillManager/1.0'})
with urllib.request.urlopen(req, timeout=timeout) as resp:
content = resp.read(10 * 1024 * 1024) # 最多 10MB
return content.decode('utf-8')
except urllib.error.HTTPError as e:
last_error = f'HTTP {e.code}: {e.reason}'
if e.code in (404, 403):
break # 不重试 4xx
except urllib.error.URLError as e:
last_error = f'网络错误: {e.reason}'
except Exception as e:
last_error = f'{type(e).__name__}: {e}'
if attempt < retries:
import time
wait = attempt * 3 # 3s, 6s
print(f' ⚠️ 第 {attempt} 次下载失败({last_error}){wait}秒后重试...')
time.sleep(wait)
# 所有重试失败
hint = ''
if 'timed out' in str(last_error).lower() or '超时' in str(last_error):
hint = '\n 💡 提示: 如果在中国大陆,请设置代理 export https_proxy=http://proxy:port'
elif '404' in str(last_error):
hint = '\n 💡 提示: 远程 skill URL 不存在,请检查 URL 或自定义 Skills Hub 配置'
raise Exception(f'{last_error} (已重试 {retries} 次){hint}')
def _compute_checksum(content: str) -> str:
"""计算内容的简单校验和"""
import hashlib
return hashlib.sha256(content.encode()).hexdigest()[:16]
def add_remote(agent_id: str, name: str, source_url: str, description: str = '') -> bool:
"""从远程 URL 为 Agent 添加 skill"""
if not safe_name(agent_id) or not safe_name(name):
print(f'❌ 错误:agent_id 或 skill 名称含非法字符')
return False
# 设置 workspace
workspace = OCLAW_HOME / f'workspace-{agent_id}' / 'skills' / name
workspace.mkdir(parents=True, exist_ok=True)
skill_md = workspace / 'SKILL.md'
# 下载文件
print(f'⏳ 正在从 {source_url} 下载...')
try:
content = _download_file(source_url)
except Exception as e:
print(f'❌ 下载失败:{e}')
print(f' URL: {source_url}')
return False
# 基础验证(放宽检查:有些 skill 不以 --- 开头)
if len(content.strip()) < 10:
print(f'❌ 文件内容过短或为空')
return False
# 保存 SKILL.md
skill_md.write_text(content)
# 保存源信息
source_info = {
'skillName': name,
'sourceUrl': source_url,
'description': description,
'addedAt': now_iso(),
'lastUpdated': now_iso(),
'checksum': _compute_checksum(content),
'status': 'valid',
}
source_json = workspace / '.source.json'
source_json.write_text(json.dumps(source_info, ensure_ascii=False, indent=2))
print(f'✅ 技能 {name} 已添加到 {agent_id}')
print(f' 路径: {skill_md}')
print(f' 大小: {len(content)} 字节')
return True
def list_remote() -> bool:
"""列出所有已添加的远程 skills"""
if not OCLAW_HOME.exists():
print('❌ OCLAW_HOME 不存在')
return False
remote_skills = []
for ws_dir in OCLAW_HOME.glob('workspace-*'):
agent_id = ws_dir.name.replace('workspace-', '')
skills_dir = ws_dir / 'skills'
if not skills_dir.exists():
continue
for skill_dir in skills_dir.iterdir():
if not skill_dir.is_dir():
continue
skill_name = skill_dir.name
source_json = skill_dir / '.source.json'
if not source_json.exists():
continue
try:
source_info = json.loads(source_json.read_text())
remote_skills.append({
'agent': agent_id,
'skill': skill_name,
'source': source_info.get('sourceUrl', 'N/A'),
'desc': source_info.get('description', ''),
'added': source_info.get('addedAt', 'N/A'),
})
except Exception:
pass
if not remote_skills:
print('📭 暂无远程 skills')
return True
print(f'📋 共 {len(remote_skills)} 个远程 skills\n')
print(f'{"Agent":<12} | {"Skill 名称":<20} | {"描述":<30} | 添加时间')
print('-' * 100)
for sk in remote_skills:
desc = (sk['desc'] or sk['source'])[:30].ljust(30)
print(f"{sk['agent']:<12} | {sk['skill']:<20} | {desc} | {sk['added'][:10]}")
print()
return True
def update_remote(agent_id: str, name: str) -> bool:
"""更新远程 skill 为最新版本"""
if not safe_name(agent_id) or not safe_name(name):
print(f'❌ 错误:agent_id 或 skill 名称含非法字符')
return False
workspace = OCLAW_HOME / f'workspace-{agent_id}' / 'skills' / name
source_json = workspace / '.source.json'
if not source_json.exists():
print(f'❌ 技能不存在或不是远程 skill: {name}')
return False
try:
source_info = json.loads(source_json.read_text())
source_url = source_info.get('sourceUrl')
if not source_url:
print(f'❌ 无效的源 URL')
return False
# 重新下载
return add_remote(agent_id, name, source_url, source_info.get('description', ''))
except Exception as e:
print(f'❌ 更新失败:{e}')
return False
def remove_remote(agent_id: str, name: str) -> bool:
"""移除远程 skill"""
if not safe_name(agent_id) or not safe_name(name):
print(f'❌ 错误:agent_id 或 skill 名称含非法字符')
return False
workspace = OCLAW_HOME / f'workspace-{agent_id}' / 'skills' / name
source_json = workspace / '.source.json'
if not source_json.exists():
print(f'❌ 技能不存在或不是远程 skill: {name}')
return False
try:
import shutil
shutil.rmtree(workspace)
print(f'✅ 技能 {name} 已从 {agent_id} 移除')
return True
except Exception as e:
print(f'❌ 移除失败:{e}')
return False
# 支持通过环境变量或本地配置指定自定义 Hub 地址
_HUB_BASE_ENV = 'OPENCLAW_SKILLS_HUB_BASE'
def _get_configured_hub_base():
"""Return a user-provided skills hub base URL, if configured."""
env_base = os.environ.get(_HUB_BASE_ENV)
if env_base:
return env_base
hub_url_file = OCLAW_HOME / 'skills-hub-url'
return hub_url_file.read_text().strip() if hub_url_file.exists() else None
def _get_hub_url(base, skill_name):
"""获取 skill 的 Hub URL,支持环境变量/本地配置覆盖"""
return f'{base.rstrip("/")}/{skill_name}/SKILL.md'
OFFICIAL_SKILLS_HUB = {
'mmx_cli': 'https://raw.githubusercontent.com/MiniMax-AI/cli/main/skill/SKILL.md',
}
_HUB_SKILL_NAMES = (
'code_review',
'api_design',
'security_audit',
'data_analysis',
'doc_generation',
'test_framework',
)
_configured_hub_base = _get_configured_hub_base()
if _configured_hub_base:
OFFICIAL_SKILLS_HUB.update({
skill_name: _get_hub_url(_configured_hub_base, skill_name)
for skill_name in _HUB_SKILL_NAMES
})
SKILL_AGENT_MAPPING = {
'code_review': ('bingbu', 'xingbu', 'menxia'),
'api_design': ('bingbu', 'gongbu', 'menxia'),
'security_audit': ('xingbu', 'menxia'),
'data_analysis': ('hubu', 'menxia'),
'doc_generation': ('libu', 'menxia'),
'test_framework': ('gongbu', 'xingbu', 'menxia'),
'mmx_cli': ('menxia', 'shangshu'),
}
def import_official_hub(agent_ids: list) -> bool:
"""从默认 Skills 源或自定义 Hub 导入 skills 到指定 agents。
如果未指定 agents,使用该 skill 的推荐 agents。
"""
requested_agents = list(agent_ids)
if not requested_agents:
print('ℹ️ 未指定 agent,使用推荐配置...\n')
total = 0
success = 0
failed = []
for skill_name, url in OFFICIAL_SKILLS_HUB.items():
# 确定目标 agents
target_agents = requested_agents or list(SKILL_AGENT_MAPPING.get(skill_name, ['menxia']))
print(f'\n📥 正在导入 skill: {skill_name}')
print(f' 目标 agents: {", ".join(target_agents)}')
for agent_id in target_agents:
total += 1
ok = add_remote(agent_id, skill_name, url, f'默认 skill{skill_name}')
if ok:
success += 1
else:
failed.append(f'{agent_id}/{skill_name}')
print(f'\n📊 导入完成:{success}/{total} 个 skills 成功')
if failed:
print(f'\n❌ 失败列表:')
for f in failed:
print(f' - {f}')
print(f'\n💡 排查建议:')
print(f' 1. 检查网络: curl -I <skill-url>')
print(f' 2. 设置代理: export https_proxy=http://your-proxy:port')
print(f' 3. 自定义 Hub: export {_HUB_BASE_ENV}=https://your-hub/raw-base')
print(f' 4. 自定义源: echo "https://your-hub/raw-base" > {OCLAW_HOME / "skills-hub-url"}')
print(f' 5. 单独重试: python3 scripts/skill_manager.py add-remote --agent <agent> --name <skill> --source <url>')
return success == total
def main():
parser = argparse.ArgumentParser(description='三省六部 Skill 管理工具',
formatter_class=argparse.RawDescriptionHelpFormatter)
subparsers = parser.add_subparsers(dest='cmd', help='命令')
# add-remote
add_parser = subparsers.add_parser('add-remote', help='从远程 URL 添加 skill')
add_parser.add_argument('--agent', required=True, help='目标 Agent ID')
add_parser.add_argument('--name', required=True, help='Skill 内部名称')
add_parser.add_argument('--source', required=True, help='远程 URL 或本地路径')
add_parser.add_argument('--description', default='', help='Skill 描述')
# list-remote
subparsers.add_parser('list-remote', help='列出所有远程 skills')
# update-remote
update_parser = subparsers.add_parser('update-remote', help='更新远程 skill')
update_parser.add_argument('--agent', required=True, help='Agent ID')
update_parser.add_argument('--name', required=True, help='Skill 名称')
# remove-remote
remove_parser = subparsers.add_parser('remove-remote', help='移除远程 skill')
remove_parser.add_argument('--agent', required=True, help='Agent ID')
remove_parser.add_argument('--name', required=True, help='Skill 名称')
# import-official-hub
import_parser = subparsers.add_parser('import-official-hub', help='从默认 Skills 源或自定义 Hub 导入 skills')
import_parser.add_argument('--agents', default='', help='逗号分隔的 Agent IDs(可选)')
# check-updates
check_parser = subparsers.add_parser('check-updates', help='检查更新(未来功能)')
check_parser.add_argument('--interval', default='weekly',
help='检查间隔 (weekly/daily/monthly)')
args = parser.parse_args()
if not args.cmd:
parser.print_help()
return
if args.cmd == 'add-remote':
success = add_remote(args.agent, args.name, args.source, args.description)
sys.exit(0 if success else 1)
elif args.cmd == 'list-remote':
success = list_remote()
sys.exit(0 if success else 1)
elif args.cmd == 'update-remote':
success = update_remote(args.agent, args.name)
sys.exit(0 if success else 1)
elif args.cmd == 'remove-remote':
success = remove_remote(args.agent, args.name)
sys.exit(0 if success else 1)
elif args.cmd == 'import-official-hub':
agent_list = [a.strip() for a in args.agents.split(',') if a.strip()] if args.agents else []
success = import_official_hub(agent_list)
sys.exit(0 if success else 1)
elif args.cmd == 'check-updates':
print(f'⏳ 检查更新功能(间隔: {args.interval})尚未实现')
print(f' 敬请期待...')
if __name__ == '__main__':
main()
+334
View File
@@ -0,0 +1,334 @@
#!/usr/bin/env python3
"""
同步 openclaw.json 中的 agent 配置 → data/agent_config.json
支持自动发现 agent workspace 下的 Skills 目录
"""
import json, os, pathlib, datetime, logging
from file_lock import atomic_json_write
from utils import get_openclaw_home
log = logging.getLogger('sync_agent_config')
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(name)s] %(message)s', datefmt='%H:%M:%S')
# Auto-detect project root (parent of scripts/)
BASE = pathlib.Path(__file__).parent.parent
DATA = BASE / 'data'
OPENCLAW_HOME = get_openclaw_home()
OPENCLAW_CFG = OPENCLAW_HOME / 'openclaw.json'
ID_LABEL = {
'taizi': {'label': '太子', 'role': '太子', 'duty': '飞书消息分拣与回奏', 'emoji': '🤴'},
'main': {'label': '太子', 'role': '太子', 'duty': '飞书消息分拣与回奏', 'emoji': '🤴'}, # 兼容旧配置
'zhongshu': {'label': '中书省', 'role': '中书令', 'duty': '起草任务令与优先级', 'emoji': '📜'},
'menxia': {'label': '门下省', 'role': '侍中', 'duty': '审议与退回机制', 'emoji': '🔍'},
'shangshu': {'label': '尚书省', 'role': '尚书令', 'duty': '派单与升级裁决', 'emoji': '📮'},
'libu': {'label': '礼部', 'role': '礼部尚书', 'duty': '文档/汇报/规范', 'emoji': '📝'},
'hubu': {'label': '户部', 'role': '户部尚书', 'duty': '资源/预算/成本', 'emoji': '💰'},
'bingbu': {'label': '兵部', 'role': '兵部尚书', 'duty': '工程实现与架构设计', 'emoji': '⚔️'},
'xingbu': {'label': '刑部', 'role': '刑部尚书', 'duty': '合规/审计/红线', 'emoji': '⚖️'},
'gongbu': {'label': '工部', 'role': '工部尚书', 'duty': '基础设施与部署运维', 'emoji': '🔧'},
'libu_hr': {'label': '吏部', 'role': '吏部尚书', 'duty': '人事/培训/Agent管理', 'emoji': '👔'},
'zaochao': {'label': '钦天监', 'role': '朝报官', 'duty': '每日新闻采集与简报', 'emoji': '📰'},
}
KNOWN_MODELS = [
{'id': 'anthropic/claude-sonnet-4-6', 'label': 'Claude Sonnet 4.6', 'provider': 'Anthropic'},
{'id': 'anthropic/claude-opus-4-5', 'label': 'Claude Opus 4.5', 'provider': 'Anthropic'},
{'id': 'anthropic/claude-haiku-3-5', 'label': 'Claude Haiku 3.5', 'provider': 'Anthropic'},
{'id': 'openai/gpt-4o', 'label': 'GPT-4o', 'provider': 'OpenAI'},
{'id': 'openai/gpt-4o-mini', 'label': 'GPT-4o Mini', 'provider': 'OpenAI'},
{'id': 'openai-codex/gpt-5.3-codex', 'label': 'GPT-5.3 Codex', 'provider': 'OpenAI Codex'},
{'id': 'google/gemini-2.0-flash', 'label': 'Gemini 2.0 Flash', 'provider': 'Google'},
{'id': 'google/gemini-2.5-pro', 'label': 'Gemini 2.5 Pro', 'provider': 'Google'},
{'id': 'copilot/claude-sonnet-4', 'label': 'Claude Sonnet 4', 'provider': 'Copilot'},
{'id': 'copilot/claude-opus-4.5', 'label': 'Claude Opus 4.5', 'provider': 'Copilot'},
{'id': 'github-copilot/claude-opus-4.6', 'label': 'Claude Opus 4.6', 'provider': 'GitHub Copilot'},
{'id': 'copilot/gpt-4o', 'label': 'GPT-4o', 'provider': 'Copilot'},
{'id': 'copilot/gemini-2.5-pro', 'label': 'Gemini 2.5 Pro', 'provider': 'Copilot'},
{'id': 'copilot/o3-mini', 'label': 'o3-mini', 'provider': 'Copilot'},
]
def normalize_model(model_value, fallback='unknown'):
if isinstance(model_value, str) and model_value:
return model_value
if isinstance(model_value, dict):
return model_value.get('primary') or model_value.get('id') or fallback
return fallback
def get_skills(workspace: str):
skills_dir = pathlib.Path(workspace) / 'skills'
skills = []
try:
if skills_dir.exists():
for d in sorted(skills_dir.iterdir()):
if d.is_dir():
md = d / 'SKILL.md'
desc = ''
if md.exists():
try:
for line in md.read_text(encoding='utf-8', errors='ignore').splitlines():
line = line.strip()
if line and not line.startswith('#') and not line.startswith('---'):
desc = line[:100]
break
except Exception:
desc = '(读取失败)'
skills.append({'name': d.name, 'path': str(md), 'exists': md.exists(), 'description': desc})
except PermissionError as e:
log.warning(f'Skills 目录访问受限: {e}')
return skills
def _collect_openclaw_models(cfg):
"""从 openclaw.json 中收集所有已配置的 model id,与 KNOWN_MODELS 合并去重。
解决 #127: 自定义 provider 的 model 不在下拉列表中。
"""
known_ids = {m['id'] for m in KNOWN_MODELS}
extra = []
agents_cfg = cfg.get('agents', {})
# 收集 defaults.model
dm = normalize_model(agents_cfg.get('defaults', {}).get('model', {}), '')
if dm and dm not in known_ids:
extra.append({'id': dm, 'label': dm, 'provider': 'OpenClaw'})
known_ids.add(dm)
# 收集 defaults.models 中的所有模型(OpenClaw 默认启用的模型列表)
defaults_models = agents_cfg.get('defaults', {}).get('models', {})
if isinstance(defaults_models, dict):
for model_id in defaults_models.keys():
if model_id and model_id not in known_ids:
provider = 'OpenClaw'
if '/' in model_id:
provider = model_id.split('/')[0]
extra.append({'id': model_id, 'label': model_id, 'provider': provider})
known_ids.add(model_id)
# 收集每个 agent 的 model
for ag in agents_cfg.get('list', []):
m = normalize_model(ag.get('model', ''), '')
if m and m not in known_ids:
extra.append({'id': m, 'label': m, 'provider': 'OpenClaw'})
known_ids.add(m)
# 收集 providers 中的 model id(如 copilot-proxy、anthropic 等)
for pname, pcfg in cfg.get('providers', {}).items():
for mid in (pcfg.get('models') or []):
mid_str = mid if isinstance(mid, str) else (mid.get('id') or mid.get('name') or '')
if mid_str and mid_str not in known_ids:
extra.append({'id': mid_str, 'label': mid_str, 'provider': pname})
known_ids.add(mid_str)
return KNOWN_MODELS + extra
def main():
cfg = {}
try:
cfg = json.loads(OPENCLAW_CFG.read_text(encoding='utf-8'))
except Exception as e:
log.warning(f'cannot read openclaw.json: {e}')
return
agents_cfg = cfg.get('agents', {})
default_model = normalize_model(agents_cfg.get('defaults', {}).get('model', {}), 'unknown')
agents_list = agents_cfg.get('list', [])
merged_models = _collect_openclaw_models(cfg)
result = []
seen_ids = set()
for ag in agents_list:
ag_id = ag.get('id', '')
if ag_id not in ID_LABEL:
continue
meta = ID_LABEL[ag_id]
workspace = ag.get('workspace', str(OPENCLAW_HOME / f'workspace-{ag_id}'))
if 'allowAgents' in ag:
allow_agents = ag.get('allowAgents', []) or []
else:
allow_agents = ag.get('subagents', {}).get('allowAgents', [])
result.append({
'id': ag_id,
'label': meta['label'], 'role': meta['role'], 'duty': meta['duty'], 'emoji': meta['emoji'],
'model': normalize_model(ag.get('model', default_model), default_model),
'defaultModel': default_model,
'workspace': workspace,
'skills': get_skills(workspace),
'allowAgents': allow_agents,
})
seen_ids.add(ag_id)
# 补充不在 openclaw.json agents list 中的 agent(兼容旧版 main
EXTRA_AGENTS = {
'taizi': {'model': default_model, 'workspace': str(OPENCLAW_HOME / 'workspace-taizi'),
'allowAgents': ['zhongshu']},
'main': {'model': default_model, 'workspace': str(OPENCLAW_HOME / 'workspace-main'),
'allowAgents': ['zhongshu','menxia','shangshu','hubu','libu','bingbu','xingbu','gongbu','libu_hr']},
'zaochao': {'model': default_model, 'workspace': str(OPENCLAW_HOME / 'workspace-zaochao'),
'allowAgents': []},
'libu_hr': {'model': default_model, 'workspace': str(OPENCLAW_HOME / 'workspace-libu_hr'),
'allowAgents': ['shangshu']},
}
for ag_id, extra in EXTRA_AGENTS.items():
if ag_id in seen_ids or ag_id not in ID_LABEL:
continue
meta = ID_LABEL[ag_id]
result.append({
'id': ag_id,
'label': meta['label'], 'role': meta['role'], 'duty': meta['duty'], 'emoji': meta['emoji'],
'model': extra['model'],
'defaultModel': default_model,
'workspace': extra['workspace'],
'skills': get_skills(extra['workspace']),
'allowAgents': extra['allowAgents'],
'isDefaultModel': True,
})
# 保留已有的 dispatchChannel 配置 (Fix #139)
existing_cfg = {}
cfg_path = DATA / 'agent_config.json'
if cfg_path.exists():
try:
existing_cfg = json.loads(cfg_path.read_text(encoding='utf-8'))
except Exception:
pass
payload = {
'generatedAt': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'defaultModel': default_model,
'knownModels': merged_models,
'dispatchChannel': existing_cfg.get('dispatchChannel') or os.getenv('DEFAULT_DISPATCH_CHANNEL', ''),
'agents': result,
}
DATA.mkdir(exist_ok=True)
atomic_json_write(DATA / 'agent_config.json', payload)
log.info(f'{len(result)} agents synced')
# 自动部署 SOUL.md 到 workspace(如果项目里有更新)
deploy_soul_files()
# 同步 scripts/ 到各 workspace(保持 kanban_update.py 等最新)
sync_scripts_to_workspaces()
# 项目 agents/ 目录名 → 运行时 agent_id 映射
_SOUL_DEPLOY_MAP = {
'taizi': 'taizi',
'zhongshu': 'zhongshu',
'menxia': 'menxia',
'shangshu': 'shangshu',
'libu': 'libu',
'hubu': 'hubu',
'bingbu': 'bingbu',
'xingbu': 'xingbu',
'gongbu': 'gongbu',
'libu_hr': 'libu_hr',
'zaochao': 'zaochao',
}
def _sync_script_symlink(src_file: pathlib.Path, dst_file: pathlib.Path) -> bool:
"""Create a symlink dst_file → src_file (resolved).
Using symlinks instead of physical copies ensures that ``__file__`` in
each script always resolves back to the project ``scripts/`` directory,
so relative-path computations like ``Path(__file__).resolve().parent.parent``
point to the correct project root regardless of which workspace runs the
script. (Fixes #56 — kanban data-path split)
Returns True if the link was (re-)created, False if already up-to-date.
"""
src_resolved = src_file.resolve()
# Guard: skip if dst resolves to the same real path as src.
# This happens when ws_scripts is itself a directory-level symlink pointing
# to the project scripts/ dir (created by install.sh link_resources).
# Without this check the function would unlink the real source file and
# then create a self-referential symlink (foo.py -> foo.py).
try:
dst_resolved = dst_file.resolve()
except OSError:
dst_resolved = None
if dst_resolved == src_resolved:
return False
# Already a correct symlink?
if dst_file.is_symlink() and dst_resolved == src_resolved:
return False
# Remove stale file / old physical copy / broken symlink
if dst_file.exists() or dst_file.is_symlink():
dst_file.unlink()
os.symlink(src_resolved, dst_file)
return True
def sync_scripts_to_workspaces():
"""将项目 scripts/ 目录同步到各 agent workspace(保持 kanban_update.py 等最新)
Uses symlinks so that ``__file__`` in workspace copies resolves to the
project ``scripts/`` directory, keeping path-derived constants like
``TASKS_FILE`` pointing to the canonical ``data/`` folder.
"""
scripts_src = BASE / 'scripts'
if not scripts_src.is_dir():
return
synced = 0
for proj_name, runtime_id in _SOUL_DEPLOY_MAP.items():
ws_scripts = OPENCLAW_HOME / f'workspace-{runtime_id}' / 'scripts'
ws_scripts.mkdir(parents=True, exist_ok=True)
for src_file in scripts_src.iterdir():
if src_file.suffix not in ('.py', '.sh') or src_file.stem.startswith('__'):
continue
dst_file = ws_scripts / src_file.name
try:
if _sync_script_symlink(src_file, dst_file):
synced += 1
except Exception:
continue
# also sync to workspace-main for legacy compatibility
ws_main_scripts = OPENCLAW_HOME / 'workspace-main' / 'scripts'
ws_main_scripts.mkdir(parents=True, exist_ok=True)
for src_file in scripts_src.iterdir():
if src_file.suffix not in ('.py', '.sh') or src_file.stem.startswith('__'):
continue
dst_file = ws_main_scripts / src_file.name
try:
if _sync_script_symlink(src_file, dst_file):
synced += 1
except Exception:
pass
if synced:
log.info(f'{synced} script symlinks synced to workspaces')
def deploy_soul_files():
"""将项目 agents/xxx/SOUL.md 部署到 ~/.openclaw/workspace-xxx/SOUL.md"""
agents_dir = BASE / 'agents'
deployed = 0
for proj_name, runtime_id in _SOUL_DEPLOY_MAP.items():
src = agents_dir / proj_name / 'SOUL.md'
if not src.exists():
continue
ws_dst = OPENCLAW_HOME / f'workspace-{runtime_id}' / 'SOUL.md'
ws_dst.parent.mkdir(parents=True, exist_ok=True)
# 只在内容不同时更新(避免不必要的写入)
src_text = src.read_text(encoding='utf-8', errors='ignore')
try:
dst_text = ws_dst.read_text(encoding='utf-8', errors='ignore')
except FileNotFoundError:
dst_text = ''
if src_text != dst_text:
ws_dst.write_text(src_text, encoding='utf-8')
deployed += 1
# 太子兼容:同步一份到 legacy main agent 目录
if runtime_id == 'taizi':
ag_dst = OPENCLAW_HOME / 'agents' / 'main' / 'SOUL.md'
ag_dst.parent.mkdir(parents=True, exist_ok=True)
try:
ag_text = ag_dst.read_text(encoding='utf-8', errors='ignore')
except FileNotFoundError:
ag_text = ''
if src_text != ag_text:
ag_dst.write_text(src_text, encoding='utf-8')
# 确保 sessions 目录存在
sess_dir = OPENCLAW_HOME / 'agents' / runtime_id / 'sessions'
sess_dir.mkdir(parents=True, exist_ok=True)
if deployed:
log.info(f'{deployed} SOUL.md files deployed')
if __name__ == '__main__':
main()
+353
View File
@@ -0,0 +1,353 @@
#!/usr/bin/env python3
import json
import pathlib
import time
import datetime
import traceback
import logging
from file_lock import atomic_json_write, atomic_json_read
from utils import get_openclaw_home
log = logging.getLogger('sync_runtime')
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(name)s] %(message)s', datefmt='%H:%M:%S')
BASE = pathlib.Path(__file__).resolve().parent.parent
DATA = BASE / 'data'
DATA.mkdir(exist_ok=True)
SYNC_STATUS = DATA / 'sync_status.json'
SESSIONS_ROOT = get_openclaw_home() / 'agents'
def write_status(**kwargs):
atomic_json_write(SYNC_STATUS, kwargs)
def ms_to_str(ts_ms):
if not ts_ms:
return '-'
try:
return datetime.datetime.fromtimestamp(ts_ms / 1000).strftime('%Y-%m-%d %H:%M:%S')
except Exception:
return '-'
def state_from_session(age_ms, aborted):
if aborted:
return 'Blocked'
if age_ms <= 2 * 60 * 1000:
return 'Doing'
if age_ms <= 60 * 60 * 1000:
return 'Review'
return 'Next'
def detect_official(agent_id):
mapping = {
'main': ('储君', '太子'), # legacy id for taizi
'taizi': ('储君', '太子'),
'zhongshu': ('中书令', '中书省'),
'menxia': ('侍中', '门下省'),
'shangshu': ('尚书令', '尚书省'),
'hubu': ('户部尚书', '户部'),
'libu': ('礼部尚书', '礼部'),
'bingbu': ('兵部尚书', '兵部'),
'xingbu': ('刑部尚书', '刑部'),
'gongbu': ('工部尚书', '工部'),
'libu_hr': ('吏部尚书', '吏部'),
'zaochao': ('钦天监', '钦天监'),
}
return mapping.get(agent_id, ('尚书令', '尚书省'))
def load_activity(session_file, limit=12):
p = pathlib.Path(session_file or '')
if not p.exists():
return []
rows = []
try:
lines = p.read_text(errors='ignore').splitlines()
except Exception:
return []
# Read all valid JSON lines first
events = []
for ln in lines:
try:
item = json.loads(ln)
events.append(item)
except:
continue
# Process events to extract meaningful activity
# We want to show what the agent is *thinking* or *doing*
for item in reversed(events):
msg = item.get('message') or {}
role = msg.get('role')
ts = item.get('timestamp') or ''
if role == 'toolResult':
tool = msg.get('toolName', '-')
details = msg.get('details') or {}
# If tool output is short, show it
content = msg.get('content', [{'text': ''}])[0].get('text', '')
if len(content) < 50:
text = f"Tool '{tool}' returned: {content}"
else:
text = f"Tool '{tool}' finished"
rows.append({'at': ts, 'kind': 'tool', 'text': text})
elif role == 'assistant':
text = ''
for c in msg.get('content', []):
if c.get('type') == 'text' and c.get('text'):
raw_text = c.get('text').strip()
# Clean up common prefixes
clean_text = raw_text.replace('[[reply_to_current]]', '').strip()
if clean_text:
text = clean_text
break
if text:
# Prioritize showing the "thought" - usually the first few sentences
summary = text.split('\n')[0]
if len(summary) > 200:
summary = summary[:200] + '...'
rows.append({'at': ts, 'kind': 'assistant', 'text': summary})
elif role == 'user':
# Also show what user asked, can be context relevant
text = ''
for c in msg.get('content', []):
if c.get('type') == 'text':
text = c.get('text', '')[:100]
if text:
rows.append({'at': ts, 'kind': 'user', 'text': f"User: {text}..."})
if len(rows) >= limit:
break
# Re-order to chronological for display if needed, but the caller usually takes the first (latest)
return rows
def build_task(agent_id, session_key, row, now_ms):
session_id = row.get('sessionId') or session_key
updated_at = row.get('updatedAt') or 0
age_ms = max(0, now_ms - updated_at) if updated_at else 99 * 24 * 3600 * 1000
aborted = bool(row.get('abortedLastRun'))
state = state_from_session(age_ms, aborted)
official, org = detect_official(agent_id)
channel = row.get('lastChannel') or (row.get('origin') or {}).get('channel') or '-'
session_file = row.get('sessionFile', '')
# 尝试从 activity 获取更有意义的当前状态描述
latest_act = '等待指令'
acts = load_activity(session_file, limit=5)
# If the absolute latest is a tool result, look for the preceding assistant thought
# because that explains *why* the tool was called.
if acts:
first_act = acts[0]
if first_act['kind'] == 'tool' and len(acts) > 1:
# Look for next assistant message (which is actually previous in time)
for next_act in acts[1:]:
if next_act['kind'] == 'assistant':
latest_act = f"正在执行: {next_act['text'][:80]}"
break
else:
latest_act = first_act['text'][:60]
elif first_act['kind'] == 'assistant':
latest_act = f"思考中: {first_act['text'][:80]}"
else:
latest_act = acts[0]['text'][:60]
title_label = (row.get('origin') or {}).get('label') or session_key
# 清洗会话标题:agent:xxx:cron:uuid → 定时任务, agent:xxx:subagent:uuid → 子任务
import re
if re.match(r'agent:\w+:cron:', title_label):
title = f"{org}定时任务"
elif re.match(r'agent:\w+:subagent:', title_label):
title = f"{org}子任务"
elif title_label == session_key or len(title_label) > 40:
title = f"{org}会话"
else:
title = f"{title_label}"
return {
'id': f"OC-{agent_id}-{str(session_id)[:8]}",
'title': title,
'official': official,
'org': org,
'state': state,
'now': latest_act,
'eta': ms_to_str(updated_at),
'block': '上次运行中断' if aborted else '',
'output': session_file,
'flow': {
'draft': f"agent={agent_id}",
'review': f"updatedAt={ms_to_str(updated_at)}",
'dispatch': f"sessionKey={session_key}",
},
'ac': '来自 OpenClaw runtime sessions 的实时映射',
'activity': load_activity(session_file, limit=10),
'sourceMeta': {
'agentId': agent_id,
'sessionKey': session_key,
'sessionId': session_id,
'updatedAt': updated_at,
'ageMs': age_ms,
'systemSent': bool(row.get('systemSent')),
'abortedLastRun': aborted,
'inputTokens': row.get('inputTokens'),
'outputTokens': row.get('outputTokens'),
'totalTokens': row.get('totalTokens'),
}
}
def main():
start = time.time()
now = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
now_ms = int(time.time() * 1000)
try:
tasks = []
scan_files = 0
if SESSIONS_ROOT.exists():
for agent_dir in sorted(SESSIONS_ROOT.iterdir()):
if not agent_dir.is_dir():
continue
agent_id = agent_dir.name
sessions_file = agent_dir / 'sessions' / 'sessions.json'
if not sessions_file.exists():
continue
scan_files += 1
try:
raw = json.loads(sessions_file.read_text())
except Exception:
continue
if not isinstance(raw, dict):
continue
for session_key, row in raw.items():
if not isinstance(row, dict):
continue
tasks.append(build_task(agent_id, session_key, row, now_ms))
# merge mission control tasks (最小接入)
mc_tasks_file = DATA / 'mission_control_tasks.json'
if mc_tasks_file.exists():
try:
mc_tasks = json.loads(mc_tasks_file.read_text())
if isinstance(mc_tasks, list):
tasks.extend(mc_tasks)
except Exception:
pass
# merge manual parallel tasks (用于军机处并行看板展示)
manual_tasks_file = DATA / 'manual_parallel_tasks.json'
if manual_tasks_file.exists():
try:
manual_tasks = json.loads(manual_tasks_file.read_text())
if isinstance(manual_tasks, list):
tasks.extend(manual_tasks)
except Exception:
pass
tasks.sort(key=lambda x: x.get('sourceMeta', {}).get('updatedAt', 0), reverse=True)
# 去重(同一 id 只保留第一个=最新的)
seen_ids = set()
deduped = []
for t in tasks:
if t['id'] not in seen_ids:
seen_ids.add(t['id'])
deduped.append(t)
tasks = deduped
# ── 过滤掉非 JJC 且非活跃的系统会话,防止看板噪音 ──
# 规则: 仅保留 24小时内更新的活跃会话,且排除 cron/subagent 等纯后台任务
filtered_tasks = []
one_day_ago = now_ms - 24 * 3600 * 1000
for t in tasks:
# 始终保留 JJC 任务(如果有的话,虽然这里主要是 OC 任务,但以防万一)
if str(t['id']).startswith('JJC'):
filtered_tasks.append(t)
continue
# OC 任务过滤
updated = t.get('sourceMeta', {}).get('updatedAt', 0)
title = t.get('title', '')
# 1. 排除太旧的 (超过24小时)
if updated < one_day_ago:
continue
# 2. 排除纯后台 cron / subagent 任务,除非它们正在报错
if '定时任务' in title or '子任务' in title:
# 只有当它 block 或者 error 时才显示,否则视为噪音
if t.get('state') != 'Blocked':
continue
# 3. 排除已冷却的 OC 会话,避免污染看板
# 保留 Doing<2min)、Review<60min)、Blocked(报错)
# 仅过滤掉 Next>60min 无响应)等已结束/闲置的会话
state = t.get('state')
if state not in ('Doing', 'Review', 'Blocked'):
continue
filtered_tasks.append(t)
tasks = filtered_tasks
# ── 保留已有的 JJC-* 旨意任务(不覆盖皇上下旨记录)──
# JJC 任务的 now 字段由 Agent 自己通过 kanban_update.py progress 命令主动上报,
# 不再从会话日志中被动抓取。这里只做合并,不做 activity 映射。
existing_tasks_file = DATA / 'tasks_source.json'
if existing_tasks_file.exists():
try:
existing = json.loads(existing_tasks_file.read_text())
jjc_existing = [t for t in existing if str(t.get('id', '')).startswith('JJC')]
# 去掉 tasks 里已有的 JJC(以防重复),再把旨意放到最前面
tasks = [t for t in tasks if not str(t.get('id', '')).startswith('JJC')]
tasks = jjc_existing + tasks
except Exception as e:
log.error(f'merge existing JJC tasks failed: {e}')
pass
atomic_json_write(DATA / 'tasks_source.json', tasks)
duration_ms = int((time.time() - start) * 1000)
write_status(
ok=True,
lastSyncAt=now,
durationMs=duration_ms,
source='openclaw_runtime_sessions',
recordCount=len(tasks),
scannedSessionFiles=scan_files,
missingFields={},
error=None,
)
log.info(f'synced {len(tasks)} tasks from openclaw runtime in {duration_ms}ms')
except Exception as e:
duration_ms = int((time.time() - start) * 1000)
write_status(
ok=False,
lastSyncAt=now,
durationMs=duration_ms,
source='openclaw_runtime_sessions',
recordCount=0,
missingFields={},
error=f'{type(e).__name__}: {e}',
traceback=traceback.format_exc(limit=3),
)
raise
if __name__ == '__main__':
main()
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env python3
"""同步各官员统计数据 → data/officials_stats.json"""
import json, pathlib, datetime, logging
from file_lock import atomic_json_write
from utils import get_openclaw_home
log = logging.getLogger('officials')
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(name)s] %(message)s', datefmt='%H:%M:%S')
BASE = pathlib.Path(__file__).resolve().parent.parent
DATA = BASE / 'data'
OPENCLAW_HOME = get_openclaw_home()
AGENTS_ROOT = OPENCLAW_HOME / 'agents'
OPENCLAW_CFG = OPENCLAW_HOME / 'openclaw.json'
# Anthropic 定价(每1M token,美元)
MODEL_PRICING = {
'anthropic/claude-sonnet-4-6': {'in':3.0, 'out':15.0, 'cr':0.30, 'cw':3.75},
'anthropic/claude-opus-4-5': {'in':15.0,'out':75.0, 'cr':1.50, 'cw':18.75},
'anthropic/claude-haiku-3-5': {'in':0.8, 'out':4.0, 'cr':0.08, 'cw':1.0},
'openai/gpt-4o': {'in':2.5, 'out':10.0, 'cr':1.25, 'cw':0},
'openai/gpt-4o-mini': {'in':0.15,'out':0.6, 'cr':0.075,'cw':0},
'google/gemini-2.0-flash': {'in':0.075,'out':0.3, 'cr':0, 'cw':0},
'google/gemini-2.5-pro': {'in':1.25,'out':10.0, 'cr':0, 'cw':0},
}
OFFICIALS = [
{'id':'taizi', 'label':'太子', 'role':'太子', 'emoji':'🤴','rank':'储君'},
{'id':'zhongshu','label':'中书省','role':'中书令', 'emoji':'📜','rank':'正一品'},
{'id':'menxia', 'label':'门下省','role':'侍中', 'emoji':'🔍','rank':'正一品'},
{'id':'shangshu','label':'尚书省','role':'尚书令', 'emoji':'📮','rank':'正一品'},
{'id':'libu', 'label':'礼部', 'role':'礼部尚书','emoji':'📝','rank':'正二品'},
{'id':'hubu', 'label':'户部', 'role':'户部尚书','emoji':'💰','rank':'正二品'},
{'id':'bingbu', 'label':'兵部', 'role':'兵部尚书','emoji':'⚔️','rank':'正二品'},
{'id':'xingbu', 'label':'刑部', 'role':'刑部尚书','emoji':'⚖️','rank':'正二品'},
{'id':'gongbu', 'label':'工部', 'role':'工部尚书','emoji':'🔧','rank':'正二品'},
{'id':'libu_hr', 'label':'吏部', 'role':'吏部尚书','emoji':'👔','rank':'正二品'},
{'id':'zaochao', 'label':'钦天监','role':'朝报官', 'emoji':'📰','rank':'正三品'},
]
def rj(p, d):
try:
return json.loads(pathlib.Path(p).read_text(encoding='utf-8'))
except Exception:
return d
# Pre-load openclaw config once (avoid re-reading per agent)
_OPENCLAW_CACHE = None
def _load_openclaw_cfg():
global _OPENCLAW_CACHE
if _OPENCLAW_CACHE is None:
_OPENCLAW_CACHE = rj(OPENCLAW_CFG, {})
return _OPENCLAW_CACHE
def normalize_model(model_value, fallback='anthropic/claude-sonnet-4-6'):
if isinstance(model_value, str) and model_value:
return model_value
if isinstance(model_value, dict):
return model_value.get('primary') or model_value.get('id') or fallback
return fallback
def get_model(agent_id):
cfg = _load_openclaw_cfg()
default = normalize_model(cfg.get('agents',{}).get('defaults',{}).get('model',{}), 'anthropic/claude-sonnet-4-6')
for a in cfg.get('agents',{}).get('list',[]):
if a.get('id') == agent_id:
return normalize_model(a.get('model', default), default)
# 兼容历史:太子曾使用 main 作为运行时 id
if agent_id == 'taizi':
for a in cfg.get('agents',{}).get('list',[]):
if a.get('id') == 'main':
return normalize_model(a.get('model', default), default)
return default
def scan_agent(agent_id):
"""从 sessions.json 读取 token 统计(累计所有 session"""
sj = AGENTS_ROOT / agent_id / 'sessions' / 'sessions.json'
if not sj.exists() and agent_id == 'taizi':
sj = AGENTS_ROOT / 'main' / 'sessions' / 'sessions.json'
if not sj.exists():
return {'tokens_in':0,'tokens_out':0,'cache_read':0,'cache_write':0,'sessions':0,'last_active':None,'messages':0}
data = rj(sj, {})
tin = tout = cr = cw = 0
last_ts = None
for sid, v in data.items():
tin += v.get('inputTokens', 0) or 0
tout += v.get('outputTokens', 0) or 0
cr += v.get('cacheRead', 0) or 0
cw += v.get('cacheWrite', 0) or 0
ts = v.get('updatedAt')
if ts:
try:
t = datetime.datetime.fromtimestamp(ts/1000) if isinstance(ts,int) else datetime.datetime.fromisoformat(ts.replace('Z','+00:00'))
if last_ts is None or t > last_ts: last_ts = t
except Exception: pass
# Estimate message count from most recent session JSONL
msg_count = 0
if data:
try:
sf_key = max(data.keys(), key=lambda k: data[k].get('updatedAt',0) or 0, default=None)
except Exception:
sf_key = None
else:
sf_key = None
if sf_key and data[sf_key].get('sessionFile'):
sf = AGENTS_ROOT / agent_id / 'sessions' / pathlib.Path(data[sf_key]['sessionFile']).name
try:
lines = sf.read_text(errors='ignore').splitlines()
for ln in lines:
try:
e = json.loads(ln)
if e.get('type') == 'message' and e.get('message',{}).get('role') == 'assistant':
msg_count += 1
except Exception: pass
except Exception: pass
return {
'tokens_in': tin, 'tokens_out': tout,
'cache_read': cr, 'cache_write': cw,
'sessions': len(data),
'last_active': last_ts.strftime('%Y-%m-%d %H:%M') if last_ts else None,
'messages': msg_count,
}
def calc_cost(s, model):
p = MODEL_PRICING.get(model, MODEL_PRICING['anthropic/claude-sonnet-4-6'])
usd = (s['tokens_in']/1e6*p['in'] + s['tokens_out']/1e6*p['out']
+ s['cache_read']/1e6*p['cr'] + s['cache_write']/1e6*p['cw'])
return round(usd, 4)
def get_task_stats(org_label, tasks):
done = [t for t in tasks if t.get('state')=='Done' and t.get('org')==org_label]
active = [t for t in tasks if t.get('state') in ('Doing','Review','Assigned') and t.get('org')==org_label]
fl = sum(1 for t in tasks for f in t.get('flow_log',[])
if f.get('from')==org_label or f.get('to')==org_label)
# 参与的旨意(JJC)列表
participated = []
for t in tasks:
if not t['id'].startswith('JJC'): continue
for f in t.get('flow_log',[]):
if f.get('from')==org_label or f.get('to')==org_label:
if t['id'] not in [x['id'] for x in participated]:
participated.append({'id':t['id'],'title':t.get('title',''),'state':t.get('state','')})
break
return {'tasks_done':len(done),'tasks_active':len(active),
'flow_participations':fl,'participated_edicts':participated}
def get_hb(agent_id, live_tasks):
for t in live_tasks:
if t.get('sourceMeta',{}).get('agentId')==agent_id and t.get('heartbeat'):
return t['heartbeat']
return {'status':'idle','label':'⚪ 待命','ageSec':None}
def main():
tasks = rj(DATA/'tasks_source.json', [])
live = rj(DATA/'live_status.json', {})
live_tasks = live.get('tasks', [])
result = []
for off in OFFICIALS:
model = get_model(off['id'])
ss = scan_agent(off['id'])
ts = get_task_stats(off['label'], tasks)
hb = get_hb(off['id'], live_tasks)
cost_usd = calc_cost(ss, model)
result.append({
**off,
'model': model,
'model_short': model.split('/')[-1] if isinstance(model, str) and '/' in model else str(model),
'sessions': ss['sessions'],
'tokens_in': ss['tokens_in'],
'tokens_out': ss['tokens_out'],
'cache_read': ss['cache_read'],
'cache_write': ss['cache_write'],
'tokens_total': ss['tokens_in'] + ss['tokens_out'],
'messages': ss['messages'],
'cost_usd': cost_usd,
'cost_cny': round(cost_usd * 7.25, 2),
'last_active': ss['last_active'],
'heartbeat': hb,
'tasks_done': ts['tasks_done'],
'tasks_active': ts['tasks_active'],
'flow_participations': ts['flow_participations'],
'participated_edicts': ts['participated_edicts'],
'merit_score': ts['tasks_done']*10 + ts['flow_participations']*2 + min(ss['sessions'],20),
})
result.sort(key=lambda x: x['merit_score'], reverse=True)
for i, r in enumerate(result): r['merit_rank'] = i+1
totals = {
'tokens_total': sum(r['tokens_total'] for r in result),
'cache_total': sum(r['cache_read']+r['cache_write'] for r in result),
'cost_usd': round(sum(r['cost_usd'] for r in result), 2),
'cost_cny': round(sum(r['cost_cny'] for r in result), 2),
'tasks_done': sum(r['tasks_done'] for r in result),
}
top = max(result, key=lambda x: x['merit_score'], default={})
payload = {
'generatedAt': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'officials': result,
'totals': totals,
'top_official': top.get('label',''),
}
atomic_json_write(DATA/'officials_stats.json', payload)
log.info(f'{len(result)} officials | cost=¥{totals["cost_cny"]} | top={top.get("label","")}')
if __name__ == '__main__':
main()
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Take all dashboard screenshots for the README using Playwright."""
from playwright.sync_api import sync_playwright
import time, os
SHOTS = os.path.join(os.path.dirname(__file__), '..', 'docs', 'screenshots')
URL = 'http://localhost:7891'
def main():
os.makedirs(SHOTS, exist_ok=True)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
ctx = browser.new_context(
viewport={'width': 1920, 'height': 1080},
device_scale_factor=2,
color_scheme='dark',
)
page = ctx.new_page()
# ── Clear ceremony localStorage so it doesn't show on every load
page.goto(URL)
page.evaluate("localStorage.setItem('openclaw_court_date', new Date().toISOString().substring(0,10))")
page.reload()
page.wait_for_load_state('networkidle')
page.wait_for_timeout(2000)
# 1. Kanban main (default tab = edicts)
print('📋 01 kanban...')
page.screenshot(path=os.path.join(SHOTS, '01-kanban-main.png'), full_page=False)
# 2. Monitor (省部调度)
print('🔭 02 monitor...')
page.click('[data-tab="monitor"]')
page.wait_for_timeout(800)
page.screenshot(path=os.path.join(SHOTS, '02-monitor.png'), full_page=False)
# 3. Task detail - click first task card
print('📜 03 task detail...')
page.click('[data-tab="edicts"]')
page.wait_for_timeout(500)
cards = page.locator('.edict-card')
if cards.count() > 0:
cards.first.click()
page.wait_for_timeout(800)
page.screenshot(path=os.path.join(SHOTS, '03-task-detail.png'), full_page=False)
# Close modal
page.keyboard.press('Escape')
page.wait_for_timeout(300)
# 4. Model config
print('⚙️ 04 models...')
page.click('[data-tab="models"]')
page.wait_for_load_state('networkidle')
page.wait_for_timeout(1000)
page.screenshot(path=os.path.join(SHOTS, '04-model-config.png'), full_page=False)
# 5. Skills config
print('🛠️ 05 skills...')
page.click('[data-tab="skills"]')
page.wait_for_load_state('networkidle')
page.wait_for_timeout(1000)
page.screenshot(path=os.path.join(SHOTS, '05-skills-config.png'), full_page=False)
# 6. Officials overview
print('👥 06 officials...')
page.click('[data-tab="officials"]')
page.wait_for_timeout(1000)
page.screenshot(path=os.path.join(SHOTS, '06-official-overview.png'), full_page=False)
# 7. Sessions
print('💬 07 sessions...')
page.click('[data-tab="sessions"]')
page.wait_for_timeout(800)
page.screenshot(path=os.path.join(SHOTS, '07-sessions.png'), full_page=False)
# 8. Memorials
print('📜 08 memorials...')
page.click('[data-tab="memorials"]')
page.wait_for_timeout(800)
page.screenshot(path=os.path.join(SHOTS, '08-memorials.png'), full_page=False)
# 9. Templates
print('📜 09 templates...')
page.click('[data-tab="templates"]')
page.wait_for_timeout(800)
page.screenshot(path=os.path.join(SHOTS, '09-templates.png'), full_page=False)
# 10. Morning briefing
print('📰 10 morning...')
page.click('[data-tab="morning"]')
page.wait_for_timeout(1000)
page.screenshot(path=os.path.join(SHOTS, '10-morning-briefing.png'), full_page=False)
# 11. Ceremony - clear date then reload
print('🎬 11 ceremony...')
page.evaluate("localStorage.removeItem('openclaw_court_date')")
page.reload()
page.wait_for_timeout(2500)
page.screenshot(path=os.path.join(SHOTS, '11-ceremony.png'), full_page=False)
browser.close()
print('✅ All screenshots saved to', SHOTS)
if __name__ == '__main__':
main()
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""
三省六部 · 公共工具函数
避免 read_json / now_iso 等基础函数在多个脚本中重复定义
"""
import os
import sys
import json, pathlib, datetime, shutil
def read_json(path, default=None):
"""安全读取 JSON 文件,失败返回 default"""
try:
return json.loads(pathlib.Path(path).read_text(encoding='utf-8'))
except Exception:
return default if default is not None else {}
def get_openclaw_home() -> pathlib.Path:
"""Return OpenClaw home directory, respecting OPENCLAW_HOME env var."""
env = os.environ.get('OPENCLAW_HOME')
if env:
return pathlib.Path(env).expanduser()
return pathlib.Path.home() / '.openclaw'
def now_iso():
"""返回 UTC ISO 8601 时间字符串(末尾 Z)"""
return datetime.datetime.now(datetime.timezone.utc).isoformat().replace('+00:00', 'Z')
def today_str(fmt='%Y%m%d'):
"""返回今天日期字符串,默认 YYYYMMDD"""
return datetime.date.today().strftime(fmt)
def safe_name(s: str) -> bool:
"""检查名称是否只含安全字符(字母、数字、下划线、连字符、中文)"""
import re
return bool(re.match(r'^[a-zA-Z0-9_\-\u4e00-\u9fff]+$', s))
def python_bin() -> str:
"""返回当前 Python 解释器路径,兼容 Windows(无 python3 命令)"""
return sys.executable or shutil.which('python3') or shutil.which('python') or 'python3'
def validate_url(url: str, allowed_schemes=('https',), allowed_domains=None) -> bool:
"""校验 URL 合法性,防 SSRF"""
from urllib.parse import urlparse
try:
parsed = urlparse(url)
if parsed.scheme not in allowed_schemes:
return False
if allowed_domains and parsed.hostname not in allowed_domains:
return False
if not parsed.hostname:
return False
# 禁止内网地址
import ipaddress
try:
ip = ipaddress.ip_address(parsed.hostname)
if ip.is_private or ip.is_loopback or ip.is_reserved:
return False
except ValueError:
pass # hostname 不是 IP,放行
return True
except Exception:
return False