Merge branch 'main' into main

This commit is contained in:
Harry 2026-04-12 22:12:24 +08:00 committed by GitHub
commit 2dba6a2916
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
77 changed files with 7185 additions and 918 deletions

5
.gitignore vendored
View file

@ -28,9 +28,12 @@ build/
.env
!.env.example
# MCP files
# MCP config files
openspace/config/config_mcp.json
# Communication config files
openspace/config/config_communication.json
# Logs
logs/

View file

@ -15,10 +15,31 @@
[![WeChat](https://img.shields.io/badge/WeChat-Group-C5EAB4?style=flat&logo=wechat&logoColor=white)](./COMMUNICATION.md)
[![中文文档](https://img.shields.io/badge/文档-中文版-F5C6C6?style=flat)](./README_CN.md)
**One Command to Evolve All Your AI Agents**: OpenClaw, nanobot, Claude Code, Codex, Cursor and etc.
<img src="assets/cli-typing.gif" width="500px" alt="openspace --query your task">
</div>
---
## 📢 News
- **2026-04-09** 💬 Multi-channel **communication gateway**. OpenSpace can now receive and respond to messages from external platforms. Ships with **WhatsApp** (Baileys bridge + QR auth) and **Feishu** (HTTP webhook) adapters, session management, attachment caching, and allowlist-based access control. See [`openspace/config/README.md`](openspace/config/README.md) for setup.
- **2026-04-07** 🌐 OpenSpace MCP now supports standalone **SSE** and **streamable HTTP** startup, making it easier for remote hosts to connect over HTTP instead of stdio and bypass stdio-bound MCP server timeout bottlenecks. See the [host integration guide](openspace/host_skills/README.md) for setup details.
- **2026-04-06** 🛠️ Fixed multiple runtime issues across grounding, MCP serving, skill evolution, and persistence, improving execution stability and recovery in long-running workflows.
- **2026-04-05** 🧭 Cleaned up LLM credential resolution: centralized `.env` loading, improved host config auto-detection, and made provider-native env handling more consistent.
- **2026-04-03** 🚀 Released **v0.1.0** — Skill quality monitoring: structural patterns extracted from high-quality skills now evaluate every new submission daily. Faster, more relevant cloud search. Production-grade vertical skill clusters emerging organically from the community. Frontend now supports Chinese (zh) i18n.
- **2026-04-02** ⚡ Cloud search upgraded for higher relevance and lower latency.
- **2026-03-31** 🛡️ Security hardening: hardened zip extraction and `import_skill` against path traversal. CLI now respects `OPENSPACE_MODEL` and `OPENSPACE_LLM_*` env vars; MiniMax compatibility; workflow ID collision fixes.
- **2026-03-29** 🔒 Pinned litellm to <1.82.7 to avoid PYSEC-2026-2 supply-chain attack.
- **2026-03-28** 🔧 Idempotent skill registration — `register_skill_dir` now returns existing `SkillMeta` for already-registered skills. Updated OpenClaw setup docs.
- **2026-03-27** 🪟 Fixed stdio deadlock on Windows; improved evolver confirmation parsing with stem-style keyword matching.
- **2026-03-26** 🌱 Dynamic skill directory re-scanning on each call, lightweight local skill search, and streamlined documentation.
- **2026-03-25** 🎉 OpenSpace is now open source!
---
## The Problem with Today's AI Agents
Today's AI agents — [OpenClaw](https://github.com/openclaw/openclaw), [nanobot](https://github.com/HKUDS/nanobot), [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [Cursor](https://cursor.com), etc. — are powerful, but they have a critical weakness: they never **Learn**, **Adapt**, and **Evolve** from real-world experience — let alone **Share** with each other.
@ -140,6 +161,15 @@ pip install -e .
openspace-mcp --help # verify installation
```
> [!TIP]
> **Slow clone?** The `assets/` folder (~50 MB of images) makes the default clone large. Use this lightweight alternative to skip it:
> ```bash
> git clone --filter=blob:none --sparse https://github.com/HKUDS/OpenSpace.git
> cd OpenSpace
> git sparse-checkout set '/*' '!assets/'
> pip install -e .
> ```
**Choose your path:**
- **[Path A](#-path-a-for-your-agent)** — Plug OpenSpace into your agent
- **[Path B](#-path-b-as-your-co-worker)** — Use OpenSpace directly as your AI co-worker
@ -169,6 +199,18 @@ Works with any agent that supports skills (`SKILL.md`) — [Claude Code](https:/
> [!TIP]
> Credentials (API key, model) are **auto-detected** from your agent's config; you usually don't need to set them manually.
> [!NOTE]
> OpenSpace supports 3 launch modes:
> - **stdio**: keep `command: "openspace-mcp"` in the host config.
> - **SSE**: start `openspace-mcp --transport sse --host 127.0.0.1 --port 8080`.
> - **streamable HTTP**: start `openspace-mcp --transport streamable-http --host 127.0.0.1 --port 8081`.
>
> Common remote endpoints:
> - SSE endpoint: `http://127.0.0.1:8080/sse`
> - streamable HTTP endpoint: `http://127.0.0.1:8081/mcp`
>
> `stdio` is the simplest option. HTTP modes keep OpenSpace as a standalone server, but **host-specific registration syntax** and **host-side timeouts** still apply.
**② Copy skills** into your agent's skills directory:
```bash
@ -480,6 +522,14 @@ OpenSpace/
│ │ ├── auth.py # API key management
│ │ └── cli/ # CLI tools (download_skill, upload_skill)
│ │
│ ├── 💬 communication/ # Multi-Channel Communication Gateway
│ │ ├── gateway.py # Message routing, session management, reply dispatch
│ │ ├── adapters/ # Platform adapters (WhatsApp, Feishu)
│ │ ├── bridges/ # Non-Python runtimes (WhatsApp Baileys bridge)
│ │ ├── config.py # Communication config loader
│ │ ├── session_store.py # Per-channel session persistence
│ │ └── types.py # ChannelMessage, ChannelSource, SendResult
│ │
│ ├── 🔧 platform/ # Platform abstraction (system info, screenshots)
│ ├── 🔧 host_detection/ # Auto-detect nanobot / openclaw credentials
│ ├── 🔧 host_skills/ # SKILL.md definitions for agent integration
@ -531,7 +581,19 @@ OpenSpace builds upon the following open-source projects. We sincerely thank the
<div align="center">
**🌟 Star us if OpenSpace helps your agent!**
## ⭐ Star History
If you find OpenSpace helpful, please consider giving us a star! ⭐
<div align="center">
<a href="https://star-history.com/#HKUDS/OpenSpace&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=HKUDS/OpenSpace&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=HKUDS/OpenSpace&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=HKUDS/OpenSpace&type=Date" />
</picture>
</a>
</div>
**🧬 Make You Agent Self-Evolve · 🌐 A Community That Grows Together · 💰 Fewer Tokens, Smarter Agents**

View file

@ -15,10 +15,31 @@
[![Feishu](https://img.shields.io/badge/Feishu-Group-E9DBFC?style=flat&logo=larksuite&logoColor=white)](./COMMUNICATION.md)
[![WeChat](https://img.shields.io/badge/WeChat-Group-C5EAB4?style=flat&logo=wechat&logoColor=white)](./COMMUNICATION.md)
**一条命令,进化你所有的 AI Agent**OpenClaw、nanobot、Claude Code、Codex、Cursor 等
<img src="assets/cli-typing.gif" width="500px" alt="openspace --query your task">
</div>
---
## 📢 最新动态
- **2026-04-09** 💬 多渠道**通信网关**上线。OpenSpace 现可接收并回复外部平台消息。内置 **WhatsApp**Baileys bridge + 扫码认证)与**飞书**HTTP webhook适配器支持会话管理、附件缓存和白名单访问控制。配置方式见 [`openspace/config/README.md`](openspace/config/README.md)。
- **2026-04-07** 🌐 OpenSpace MCP 新增独立 **SSE****streamable HTTP** 启动方式,便于远端 host 通过 HTTP 接入,绕过基于 stdio 的 MCP server timeout 瓶颈。具体接入方式见 [host integration 文档](openspace/host_skills/README.md)。
- **2026-04-06** 🛠️ 修复多项运行时问题,覆盖 grounding、MCP 服务、skill 进化与持久化链路,长流程执行的稳定性与恢复能力进一步提升。
- **2026-04-05** 🧭 LLM 凭证解析清理完成:统一 `.env` 加载逻辑,改进宿主配置自动识别,并让 provider 原生环境变量处理更一致。
- **2026-04-03** 🚀 发布 **v0.1.0** — Skill 质量监控上线:从优质 Skill 中提取结构模式,每日自动评估所有新提交;云端搜索全面升级,匹配更准、响应更快;社区自发形成生产级垂直 Skill 集群。前端新增中文zh国际化支持。
- **2026-04-02** ⚡ 云端搜索升级,提升匹配质量、降低响应延迟。
- **2026-03-31** 🛡️ 安全加固zip 解压与 `import_skill` 新增路径穿越防护CLI 启动时读取 `OPENSPACE_MODEL``OPENSPACE_LLM_*` 环境变量;修复 MiniMax 兼容性问题与 workflow ID 冲突。
- **2026-03-29** 🔒 锁定 litellm 版本至 <1.82.7规避 PYSEC-2026-2 供应链投毒
- **2026-03-28** 🔧 Skill 注册幂等化——`register_skill_dir` 对已注册目录直接返回已有 `SkillMeta`,不再重复创建。同步更新 OpenClaw 部署文档。
- **2026-03-27** 🪟 修复 Windows 下 stdio 死锁evolver 确认解析改用词干匹配,消除误判。
- **2026-03-26** 🌱 Skill 目录支持每次调用时动态重扫描,本地搜索更轻量,文档同步精简。
- **2026-03-25** 🎉 OpenSpace 正式开源!
---
## 当前 AI Agent 面临的问题
如今的 AI Agent——[OpenClaw](https://github.com/openclaw/openclaw)、[nanobot](https://github.com/HKUDS/nanobot)、[Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[Codex](https://github.com/openai/codex)、[Cursor](https://cursor.com) 等——能力强大,但有一个致命弱点:它们从不从真实世界的经验中**学习**、**适应**和**进化**——更不用说相互之间的**共享**了。
@ -140,6 +161,15 @@ pip install -e .
openspace-mcp --help # 验证安装
```
> [!TIP]
> **Clone 太慢?** `assets/` 目录包含约 50 MB 的图片文件,导致仓库较大。使用以下轻量方式跳过它:
> ```bash
> git clone --filter=blob:none --sparse https://github.com/HKUDS/OpenSpace.git
> cd OpenSpace
> git sparse-checkout set '/*' '!assets/'
> pip install -e .
> ```
**选择你的路径:**
- **[路径 A](#-路径-a为你的-agent-接入)** — 将 OpenSpace 接入你的 Agent
- **[路径 B](#-路径-b作为你的-ai-协作者)** — 直接使用 OpenSpace 作为你的 AI 协作者
@ -169,6 +199,18 @@ openspace-mcp --help # 验证安装
> [!TIP]
> 凭证API 密钥、模型)会从你的 Agent 配置中**自动检测**,通常无需手动设置。
> [!NOTE]
> OpenSpace 支持 3 种启动方式:
> - **stdio**:在宿主配置里保留 `command: "openspace-mcp"`
> - **SSE**:先启动 `openspace-mcp --transport sse --host 127.0.0.1 --port 8080`
> - **streamable HTTP**:先启动 `openspace-mcp --transport streamable-http --host 127.0.0.1 --port 8081`
>
> 通用远端 endpoint
> - SSE: `http://127.0.0.1:8080/sse`
> - streamable HTTP: `http://127.0.0.1:8081/mcp`
>
> `stdio` 最简单。HTTP 模式会把 OpenSpace 作为独立服务常驻,但 **不同宿主的注册写法不同**,而且 **调用方自己的 timeout 仍然生效**
**② 将 Skill 复制**到你的 Agent Skill 目录:
```bash
@ -480,6 +522,14 @@ OpenSpace/
│ │ ├── auth.py # API 密钥管理
│ │ └── cli/ # CLI 工具download_skill、upload_skill
│ │
│ ├── 💬 communication/ # 多渠道通信网关
│ │ ├── gateway.py # 消息路由、会话管理、回复分发
│ │ ├── adapters/ # 平台适配器WhatsApp、飞书
│ │ ├── bridges/ # 非 Python 运行时WhatsApp Baileys bridge
│ │ ├── config.py # 通信配置加载
│ │ ├── session_store.py # 按频道的会话持久化
│ │ └── types.py # ChannelMessage, ChannelSource, SendResult
│ │
│ ├── 🔧 platform/ # 平台抽象(系统信息、截图)
│ ├── 🔧 host_detection/ # 自动检测 nanobot / openclaw 凭证
│ ├── 🔧 host_skills/ # 面向 Agent 集成的 SKILL.md 定义

BIN
assets/cli-typing.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

View file

@ -9,9 +9,11 @@
"version": "0.1.0",
"dependencies": {
"axios": "^1.7.9",
"i18next": "^26.0.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-force-graph-2d": "^1.25.4",
"react-i18next": "^17.0.2",
"react-router-dom": "^7.1.1"
},
"devDependencies": {
@ -272,6 +274,15 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@ -2297,6 +2308,46 @@
"node": ">= 0.4"
}
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/i18next": {
"version": "26.0.3",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.3.tgz",
"integrity": "sha512-1571kXINxHKY7LksWp8wP+zP0YqHSSpl/OW0Y0owFEf2H3s8gCAffWaZivcz14rMkOvn3R/psiQxVsR9t2Nafg==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2"
},
"peerDependencies": {
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/index-array-by": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.2.tgz",
@ -2916,6 +2967,33 @@
"react": "*"
}
},
"node_modules/react-i18next": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.2.tgz",
"integrity": "sha512-shBftH2vaTWK2Bsp7FiL+cevx3xFJlvFxmsDFQSrJc+6twHkP0tv/bGa01VVWzpreUVVwU+3Hev5iFqRg65RwA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 26.0.1",
"react": ">= 16.8.0",
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@ -3319,7 +3397,7 @@
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@ -3360,6 +3438,15 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@ -3473,6 +3560,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View file

@ -10,9 +10,11 @@
},
"dependencies": {
"axios": "^1.7.9",
"i18next": "^26.0.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-force-graph-2d": "^1.25.4",
"react-i18next": "^17.0.2",
"react-router-dom": "^7.1.1"
},
"devDependencies": {

View file

@ -1,4 +1,5 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import i18n from '../i18n';
interface Props {
children: ReactNode;
@ -21,7 +22,6 @@ export class ErrorBoundary extends Component<Props, State> {
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// Log error to reporting service
if (import.meta.env.DEV) {
console.error('Error caught by boundary:', error, errorInfo);
}
@ -29,25 +29,26 @@ export class ErrorBoundary extends Component<Props, State> {
render() {
if (this.state.hasError) {
const t = i18n.t.bind(i18n);
return (
this.props.fallback || (
<div className="min-h-screen flex items-center justify-center bg-[color:var(--color-bg-page)]">
<div className="text-center">
<h1 className="text-2xl font-bold text-[color:var(--color-danger)] mb-4">
Something went wrong
{t('errorBoundary.title')}
</h1>
<p className="text-[color:var(--color-muted)] mb-6">
An unexpected error occurred
{t('errorBoundary.message')}
</p>
<button
onClick={() => window.location.href = '/dashboard'}
className="btn-primary"
>
Go to Dashboard
{t('errorBoundary.goToDashboard')}
</button>
{import.meta.env.DEV && this.state.error && (
<details className="mt-4 text-left text-xs text-[color:var(--color-muted)]">
<summary>Error details</summary>
<summary>{t('errorBoundary.details')}</summary>
<pre className="mt-2 p-4 bg-[color:var(--color-surface)] overflow-auto">
{this.state.error.stack}
</pre>

View file

@ -1,4 +1,5 @@
import { useEffect, useMemo, useState, type KeyboardEvent } from 'react';
import { useTranslation } from 'react-i18next';
import type { DiffFile, DiffLine } from '../../utils/diffParser';
interface DiffViewerProps {
@ -124,6 +125,7 @@ function buildSplitRows(lines: DiffLine[], header: string): SplitDiffRow[] {
}
export default function DiffViewer({ files }: DiffViewerProps) {
const { t } = useTranslation();
const [selectedIndex, setSelectedIndex] = useState(0);
const renderableFiles = useMemo(
() => files.filter((file) => file.hunks.some((hunk) => hunk.lines.length > 0)),
@ -135,7 +137,7 @@ export default function DiffViewer({ files }: DiffViewerProps) {
}, [renderableFiles]);
if (renderableFiles.length === 0) {
return <p className="text-[color:var(--color-muted)] text-sm">No files in diff</p>;
return <p className="text-[color:var(--color-muted)] text-sm">{t('diffViewer.noFiles')}</p>;
}
const activeIndex = selectedIndex < renderableFiles.length ? selectedIndex : 0;
@ -216,8 +218,8 @@ export default function DiffViewer({ files }: DiffViewerProps) {
return (
<div key={`${activeFile.path}-hunk-${hunkIdx}`}>
<div className="sticky top-0 z-10 grid grid-cols-[1fr_1fr] border-y border-[color:var(--color-ink)] bg-[#CBCADB] px-3 py-1.5 text-[color:var(--color-muted)] select-none">
<div>Old</div>
<div>New</div>
<div>{t('diffViewer.old')}</div>
<div>{t('diffViewer.new')}</div>
</div>
<div className="sticky top-[29px] z-10 border-b border-[color:var(--color-border-dark)] bg-[color:var(--color-surface)] px-3 py-1 text-[color:var(--color-muted)] select-none">
{hunk.header}

View file

@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import ForceGraph2D from 'react-force-graph-2d';
import type { SkillGraphNode } from '../../hooks/useSkillEvolutionGraphData';
@ -25,6 +26,7 @@ export default function SkillEvolutionGraph({
onNodeClick,
onBackgroundClick,
}: SkillEvolutionGraphProps) {
const { t } = useTranslation();
const graphContainerRef = useRef<HTMLDivElement>(null);
const fgRef = useRef<any>(null);
const [graphDim, setGraphDim] = useState({ width: 0, height: 0 });
@ -180,7 +182,7 @@ export default function SkillEvolutionGraph({
}, []);
if (graphData.nodes.length === 0) {
return <div className="text-sm text-muted p-4">No lineage graph data.</div>;
return <div className="text-sm text-muted p-4">{t('graph.noGraphData')}</div>;
}
return (
@ -197,9 +199,9 @@ export default function SkillEvolutionGraph({
const graphNode = node as SkillGraphNode;
return [
graphNode.name,
`score: ${graphNode.score.toFixed(1)}`,
`generation: ${graphNode.generation}`,
`origin: ${graphNode.origin}`,
t('graph.tooltipScore', { value: graphNode.score.toFixed(1) }),
t('graph.tooltipGeneration', { value: graphNode.generation }),
t('graph.tooltipOrigin', { value: graphNode.origin }),
].join('\n');
}}
onNodeClick={(node) => {

View file

@ -1,6 +1,7 @@
import { useLayoutEffect, useMemo, useRef } from 'react';
import { createPortal } from 'react-dom';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import type { SkillDetail } from '../../api';
import { parseDiff } from '../../utils/diffParser';
import EmptyState from '../EmptyState';
@ -72,6 +73,7 @@ function lockScroll() {
}
export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVersionDrawerProps) {
const { t } = useTranslation();
const closeButtonRef = useRef<HTMLButtonElement>(null);
const rawDiff = skill?.lineage.content_diff ?? '';
@ -135,16 +137,16 @@ export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVers
<div className="drawer-scroll flex h-full w-full flex-col overflow-hidden overscroll-contain">
<header className="p-4 border-b-2 border-[color:var(--color-border)] flex items-start justify-between gap-3 shrink-0">
<div className="min-w-0">
<p className="text-xs uppercase tracking-wide text-muted">Skill Version</p>
<p className="text-xs uppercase tracking-wide text-muted">{t('drawer.skillVersion')}</p>
<h2 id="skill-version-drawer-title" className="font-bold text-lg truncate">{skill.name}</h2>
<p className="text-xs text-muted font-mono break-all">{skill.skill_id}</p>
</div>
<div className="flex items-center gap-2">
<Link to={`/skills/${encodeURIComponent(skill.skill_id)}`} className="btn-outline-ink text-sm">
Open as main
{t('drawer.openAsMain')}
</Link>
<button type="button" onClick={onClose} ref={closeButtonRef} className="btn-outline-ink text-sm">
Close
{t('common.close')}
</button>
</div>
</header>
@ -153,13 +155,13 @@ export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVers
<section className="rounded-[var(--radius)] border-2 border-[color:var(--color-border-dark)] bg-surface p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<div className="space-y-2 min-w-0">
<div className="text-xs uppercase tracking-[0.16em] text-muted">Version Summary</div>
<div className="text-sm text-muted">{skill.description || 'No description available for this version.'}</div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('drawer.versionSummary')}</div>
<div className="text-sm text-muted">{skill.description || t('drawer.noDescriptionAvailable')}</div>
<div className="flex flex-wrap gap-2 text-xs">
<span className="tag px-2 py-1">{skill.category}</span>
<span className="tag px-2 py-1">{skill.origin}</span>
<span className="tag px-2 py-1">gen {skill.generation}</span>
<span className="tag px-2 py-1">{skill.is_active ? 'active' : 'inactive'}</span>
<span className="tag px-2 py-1">{t('drawer.gen', { generation: skill.generation })}</span>
<span className="tag px-2 py-1">{skill.is_active ? t('common.active') : t('common.inactive')}</span>
{skill.tags.map((tag) => (
<span key={tag} className="tag px-2 py-1">{tag}</span>
))}
@ -167,67 +169,67 @@ export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVers
</div>
<div className="text-right shrink-0">
<div className="text-4xl font-bold font-serif leading-none">{skill.score.toFixed(1)}</div>
<div className="text-xs uppercase tracking-[0.16em] text-muted mt-2">version score</div>
<div className="text-xs uppercase tracking-[0.16em] text-muted mt-2">{t('drawer.versionScore')}</div>
</div>
</div>
</section>
<section className="rounded-[var(--radius)] border-2 border-[color:var(--color-border-dark)] bg-surface p-4 space-y-4">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">Metrics</div>
<h3 className="text-xl font-bold font-serif mt-1">Execution quality</h3>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('drawer.metrics')}</div>
<h3 className="text-xl font-bold font-serif mt-1">{t('drawer.executionQuality')}</h3>
</div>
<div className="space-y-4">
<ProgressBar label="Effective rate" value={skill.effective_rate} colorClass="bg-primary" />
<ProgressBar label="Completion rate" value={skill.completion_rate} colorClass="bg-accent" />
<ProgressBar label="Applied rate" value={skill.applied_rate} colorClass="bg-teal" />
<ProgressBar label="Fallback rate" value={skill.fallback_rate} colorClass="bg-danger" />
<ProgressBar label={t('drawer.effectiveRate')} value={skill.effective_rate} colorClass="bg-primary" />
<ProgressBar label={t('drawer.completionRate')} value={skill.completion_rate} colorClass="bg-accent" />
<ProgressBar label={t('drawer.appliedRate')} value={skill.applied_rate} colorClass="bg-teal" />
<ProgressBar label={t('drawer.fallbackRate')} value={skill.fallback_rate} colorClass="bg-danger" />
</div>
<div className="grid grid-cols-2 gap-3 text-sm text-muted">
<div><div className="font-bold text-ink">Selections</div><div>{skill.total_selections}</div></div>
<div><div className="font-bold text-ink">Applied</div><div>{skill.total_applied}</div></div>
<div><div className="font-bold text-ink">Completions</div><div>{skill.total_completions}</div></div>
<div><div className="font-bold text-ink">Fallbacks</div><div>{skill.total_fallbacks}</div></div>
<div><div className="font-bold text-ink">{t('drawer.selectionsLabel')}</div><div>{skill.total_selections}</div></div>
<div><div className="font-bold text-ink">{t('drawer.appliedLabel')}</div><div>{skill.total_applied}</div></div>
<div><div className="font-bold text-ink">{t('drawer.completionsLabel')}</div><div>{skill.total_completions}</div></div>
<div><div className="font-bold text-ink">{t('drawer.fallbacksLabel')}</div><div>{skill.total_fallbacks}</div></div>
</div>
</section>
<section className="rounded-[var(--radius)] border-2 border-[color:var(--color-border-dark)] bg-surface p-4 text-sm space-y-2">
<h3 className="font-bold">Version Metadata</h3>
<p><strong>Origin:</strong> {skill.origin}</p>
<p><strong>Generation:</strong> {skill.generation}</p>
<p><strong>Visibility:</strong> {skill.visibility}</p>
<p><strong>Created:</strong> {formatDate(skill.lineage.created_at)}</p>
<p><strong>First seen:</strong> {formatDate(skill.first_seen)}</p>
<p><strong>Last updated:</strong> {formatDate(skill.last_updated)}</p>
<p><strong>Skill path:</strong> <span className="break-all">{skill.path || 'Unavailable'}</span></p>
<p><strong>Skill dir:</strong> <span className="break-all">{skill.skill_dir || 'Unavailable'}</span></p>
<p><strong>Parent IDs:</strong> {skill.parent_skill_ids.length ? skill.parent_skill_ids.join(', ') : 'None'}</p>
<p><strong>Change summary:</strong> {skill.lineage.change_summary || 'None'}</p>
<p><strong>Effective score:</strong> {formatPercent(skill.effective_rate)}</p>
<h3 className="font-bold">{t('drawer.versionMetadata')}</h3>
<p><strong>{t('drawer.origin')}</strong> {skill.origin}</p>
<p><strong>{t('drawer.generation')}</strong> {skill.generation}</p>
<p><strong>{t('drawer.visibility')}</strong> {skill.visibility}</p>
<p><strong>{t('drawer.created')}</strong> {formatDate(skill.lineage.created_at)}</p>
<p><strong>{t('drawer.firstSeen')}</strong> {formatDate(skill.first_seen)}</p>
<p><strong>{t('drawer.lastUpdated')}</strong> {formatDate(skill.last_updated)}</p>
<p><strong>{t('drawer.skillPath')}</strong> <span className="break-all">{skill.path || t('common.unavailable')}</span></p>
<p><strong>{t('drawer.skillDir')}</strong> <span className="break-all">{skill.skill_dir || t('common.unavailable')}</span></p>
<p><strong>{t('drawer.parentIds')}</strong> {skill.parent_skill_ids.length ? skill.parent_skill_ids.join(', ') : t('common.none')}</p>
<p><strong>{t('drawer.changeSummary')}</strong> {skill.lineage.change_summary || t('common.none')}</p>
<p><strong>{t('drawer.effectiveScore')}</strong> {formatPercent(skill.effective_rate)}</p>
</section>
<section className="rounded-[var(--radius)] border-2 border-[color:var(--color-border-dark)] bg-surface p-4 space-y-4">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">Diff</div>
<h3 className="text-xl font-bold font-serif mt-1">Content diff</h3>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('drawer.diff')}</div>
<h3 className="text-xl font-bold font-serif mt-1">{t('drawer.contentDiff')}</h3>
</div>
{isOversizedDiff ? (
<EmptyState title="Diff too large" description="This version has a very large content diff, so the inline viewer is disabled." />
<EmptyState title={t('drawer.diffTooLarge')} description={t('drawer.diffTooLargeDesc')} />
) : canShowDiff ? (
diffFiles.length > 0 ? (
<DiffViewer files={diffFiles} />
) : (
<EmptyState title="Diff unavailable" description="This version has a diff payload, but it could not be parsed as a unified diff." />
<EmptyState title={t('drawer.diffUnavailable')} description={t('drawer.diffUnavailableDesc')} />
)
) : (
<EmptyState title="No content diff" description="This version does not have a stored content diff." />
<EmptyState title={t('drawer.noContentDiff')} description={t('drawer.noContentDiffDesc')} />
)}
</section>
<section className="rounded-[var(--radius)] border-2 border-[color:var(--color-border-dark)] bg-surface p-4 space-y-4">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">Source</div>
<h3 className="text-xl font-bold font-serif mt-1">SKILL.md preview</h3>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('drawer.source')}</div>
<h3 className="text-xl font-bold font-serif mt-1">{t('drawer.skillMdPreview')}</h3>
</div>
{sourcePreview ? (
<div className="space-y-3">
@ -235,14 +237,14 @@ export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVers
<pre className="field-surface p-4 text-xs overflow-auto max-h-[320px] whitespace-pre-wrap">{sourcePreview.content}</pre>
</div>
) : (
<EmptyState title="Source unavailable" description="This version points to a missing or unreadable SKILL.md path." />
<EmptyState title={t('drawer.sourceUnavailable')} description={t('drawer.sourceUnavailableDesc')} />
)}
</section>
<section className="rounded-[var(--radius)] border-2 border-[color:var(--color-border-dark)] bg-surface p-4 space-y-4">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">Analyses</div>
<h3 className="text-xl font-bold font-serif mt-1">Recent execution analyses</h3>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('drawer.analyses')}</div>
<h3 className="text-xl font-bold font-serif mt-1">{t('drawer.recentAnalyses')}</h3>
</div>
{skill.recent_analyses.length > 0 ? (
<div className="space-y-3">
@ -252,15 +254,19 @@ export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVers
<div className="font-bold truncate">{analysis.task_id}</div>
<div className="text-xs text-muted">{formatDate(analysis.timestamp)}</div>
</div>
<div className="text-sm text-muted">{truncate(analysis.execution_note || 'No execution note', 220)}</div>
<div className="text-sm text-muted">{truncate(analysis.execution_note || t('drawer.noExecutionNote'), 220)}</div>
<div className="text-xs text-muted">
completed: {analysis.task_completed ? 'yes' : 'no'} · tool issues: {analysis.tool_issues.length} · suggestions: {analysis.evolution_suggestions.length}
{t('drawer.analysisCompleted', {
value: analysis.task_completed ? t('common.yes') : t('common.no'),
toolIssues: analysis.tool_issues.length,
suggestions: analysis.evolution_suggestions.length,
})}
</div>
</div>
))}
</div>
) : (
<EmptyState title="No analyses yet" description="Execution analyses will appear after recorded task runs are persisted into SQLite." />
<EmptyState title={t('drawer.noAnalysesYet')} description={t('drawer.noAnalysesDesc')} />
)}
</section>
</main>

View file

@ -1,3 +1,5 @@
import { useTranslation } from 'react-i18next';
interface SkillVersionFilterBarProps {
originFilter: string;
onOriginFilterChange: (value: string) => void;
@ -15,16 +17,18 @@ export default function SkillVersionFilterBar({
allOrigins,
allTags,
}: SkillVersionFilterBarProps) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
<label className="text-sm font-medium">Origin:</label>
<label className="text-sm font-medium">{t('filter.origin')}</label>
<select
value={originFilter}
onChange={(event) => onOriginFilterChange(event.target.value)}
className="border border-[color:var(--color-ink)] bg-transparent px-2 py-1 text-sm"
>
<option value="all">All Origins</option>
<option value="all">{t('filter.allOrigins')}</option>
{allOrigins.map((origin) => (
<option key={origin} value={origin}>{origin}</option>
))}
@ -32,13 +36,13 @@ export default function SkillVersionFilterBar({
</div>
<div className="flex items-center gap-2">
<label className="text-sm font-medium">Tags:</label>
<label className="text-sm font-medium">{t('filter.tags')}</label>
<select
value={tagFilter}
onChange={(event) => onTagFilterChange(event.target.value)}
className="border border-[color:var(--color-ink)] bg-transparent px-2 py-1 text-sm"
>
<option value="all">All Tags</option>
<option value="all">{t('filter.allTags')}</option>
{allTags.map((tag) => (
<option key={tag} value={tag}>{tag}</option>
))}

253
frontend/src/i18n/en.json Normal file
View file

@ -0,0 +1,253 @@
{
"nav": {
"dashboard": "Dashboard",
"skills": "Skills",
"workflows": "Workflows"
},
"common": {
"loading": "Loading…",
"yes": "yes",
"no": "no",
"none": "None",
"unavailable": "Unavailable",
"unknown": "Unknown",
"close": "Close",
"score": "score",
"success": "success",
"active": "active",
"inactive": "inactive",
"noDescription": "No description",
"steps_one": "{{count}} step",
"steps_other": "{{count}} steps",
"agentActions_one": "{{count}} agent action",
"agentActions_other": "{{count}} agent actions",
"tags": "+{{count}} tags"
},
"langSwitch": {
"label": "Language"
},
"errorBoundary": {
"title": "Something went wrong",
"message": "An unexpected error occurred",
"goToDashboard": "Go to Dashboard",
"details": "Error details"
},
"dashboard": {
"title": "Dashboard",
"loadingDashboard": "Loading dashboard…",
"failedToLoad": "Failed to load overview",
"dashboardUnavailable": "Dashboard unavailable",
"totalSkills": "Total Skills",
"activeHint": "Active: {{count}}",
"avgSkillScore": "Average Skill Score",
"avgScoreHint": "Primary metric = effective rate × 100",
"workflowSessions": "Workflow Sessions",
"localRepo": "local repo",
"workspace": "workspace",
"recordedUnder": "Recorded under {{location}}",
"workflowSuccess": "Workflow Success",
"avgSuccessHint": "Average session success rate",
"health": "Health",
"runtimeSnapshot": "Runtime snapshot",
"status": "Status",
"dbPath": "DB Path",
"workflowCount": "Workflow Count",
"builtFrontend": "Built Frontend",
"skillsSection": "Skills",
"topScoredSkills": "Top scored skills",
"noSkillsYet": "No skills yet",
"noSkillsDesc": "Run OpenSpace tasks or sync skills into the local registry first.",
"effective": "effective {{value}}",
"applied": "applied {{value}}",
"selections": "selections {{count}}",
"workflowsSection": "Workflows",
"recentSessions": "Recent sessions",
"noWorkflowSessions": "No workflow sessions",
"noWorkflowDesc": "Recordings will appear after a task is executed with recording enabled."
},
"skills": {
"title": "Skill classes",
"searchPlaceholder": "Search by name, id, description, tag, or origin",
"sortByScore": "Sort by best score",
"sortByUpdated": "Sort by updated time",
"sortByName": "Sort by name",
"skillClasses": "Skill Classes",
"versionsHint": "Versions: {{count}}",
"activeVersions": "Active Versions",
"withActivity": "With activity: {{count}}",
"avgBestScore": "Average Best Score",
"bestNodeScoreHint": "Best node score per class",
"selections": "Selections",
"completionsHint": "Completions: {{count}}",
"loadingSkills": "Loading skills…",
"failedToLoad": "Failed to load skills",
"noSkillsMatch": "No skills match",
"noSkillsMatchDesc": "Try another keyword, or execute tasks so new skill telemetry lands in SQLite.",
"bestScore": "best score",
"noClassDescription": "No class description",
"versions": "{{count}} versions",
"active": "{{count}} active",
"selectionsCount": "{{count}} selections"
},
"skillDetail": {
"loadingDetail": "Loading skill detail…",
"failedToLoad": "Failed to load skill class",
"skillNotFound": "Skill not found",
"backToSkills": "← Back to Skills",
"anchoredOn": "Skill class anchored on {{id}}",
"skillClass": "Skill Class",
"evolutionOverview": "Evolution overview",
"activeTip": "active tip",
"inactiveAnchor": "inactive anchor",
"bestVersionScore": "best version score",
"skillDirectory": "Skill directory",
"latestVersionCreated": "Latest version created",
"representativeVersion": "Representative version",
"representativeUpdate": "Representative update",
"versions": "Versions",
"maxGeneration": "Max generation {{count}}",
"activeVersions": "Active Versions",
"originsCount": "Origins: {{count}}",
"averageScore": "Average Score",
"acrossAllVersions": "Across all versions in this lineage",
"selections": "Selections",
"representativeScore": "Representative score {{score}}",
"evolutionGraph": "Evolution Graph",
"versionLineage": "Version lineage",
"loadingDrawer": "Loading version drawer…",
"noLineageGraph": "No lineage graph",
"noLineageGraphDesc": "This skill does not yet have lineage data to visualize."
},
"workflows": {
"title": "Recorded sessions",
"searchPlaceholder": "Search by task name or instruction",
"workflowSessions": "Workflow Sessions",
"scannedFrom": "Scanned from logs/recordings and logs/trajectories",
"averageSuccess": "Average Success",
"meanSuccessRate": "Mean success rate across sessions",
"loadingWorkflows": "Loading workflows…",
"failedToLoad": "Failed to load workflows",
"noSessions": "No workflow sessions",
"noSessionsDesc": "Run `openspace` with recording enabled, then refresh this page.",
"more": "+{{count}} more"
},
"workflowDetail": {
"loadingDetail": "Loading workflow detail…",
"failedToLoad": "Failed to load workflow",
"workflowNotFound": "Workflow not found",
"backToWorkflows": "← Back to Workflows",
"workflowDetail": "Workflow detail",
"skillsSelected_one": "{{count}} skill selected",
"skillsSelected_other": "{{count}} skills selected",
"mergedEvent_one": "{{count}} merged event",
"mergedEvent_other": "{{count}} merged events",
"runDescription": "{{status}} run with {{iterations}} and {{actions}}.",
"started": "Started",
"duration": "Duration",
"stepsLabel": "Steps",
"latestEvent": "Latest event",
"successRate": "Success rate",
"successRateHint_one": "{{count}} successful iteration out of {{iterations}}",
"successRateHint_other": "{{count}} successful iterations out of {{iterations}}",
"iterations": "Iterations",
"totalRuntime": "{{duration}} total runtime",
"activeBackends": "Active backends",
"mostActive": "Most active {{backend}} · {{count}} events",
"noRecordedActivity": "No recorded tool activity",
"timelineEvents": "Timeline events",
"agentToolHint": "{{agentCount}} agent actions · {{toolCount}} tool events",
"noTimelineData": "No timeline data",
"noTimelineDesc": "This session does not yet contain trajectory or agent action records.",
"rawEventJson": "Raw event JSON",
"selection": "Selection",
"selectedSkills": "Selected skills",
"noSelectedSkills": "No selected skills",
"noSelectedSkillsDesc": "No skills were selected or recorded for this run.",
"session": "Session",
"overview": "Overview",
"taskId": "Task ID",
"runtime": "Runtime",
"window": "Window",
"ended": "Ended {{date}}",
"selectionMethod": "Selection method",
"notRecorded": "Not recorded",
"enabledBackends": "Enabled backends",
"backendActivity": "Backend activity",
"iteration_one": "{{count}} iteration",
"iteration_other": "{{count}} iterations",
"step_one": "{{count}} step",
"step_other": "{{count}} steps",
"agentAction_one": "{{count}} agent action",
"agentAction_other": "{{count}} agent actions",
"successfulIteration_one": "{{count}} successful iteration",
"successfulIteration_other": "{{count}} successful iterations"
},
"drawer": {
"skillVersion": "Skill Version",
"openAsMain": "Open as main",
"versionSummary": "Version Summary",
"noDescriptionAvailable": "No description available for this version.",
"gen": "gen {{generation}}",
"versionScore": "version score",
"metrics": "Metrics",
"executionQuality": "Execution quality",
"effectiveRate": "Effective rate",
"completionRate": "Completion rate",
"appliedRate": "Applied rate",
"fallbackRate": "Fallback rate",
"selectionsLabel": "Selections",
"appliedLabel": "Applied",
"completionsLabel": "Completions",
"fallbacksLabel": "Fallbacks",
"versionMetadata": "Version Metadata",
"origin": "Origin:",
"generation": "Generation:",
"visibility": "Visibility:",
"created": "Created:",
"firstSeen": "First seen:",
"lastUpdated": "Last updated:",
"skillPath": "Skill path:",
"skillDir": "Skill dir:",
"parentIds": "Parent IDs:",
"changeSummary": "Change summary:",
"effectiveScore": "Effective score:",
"diff": "Diff",
"contentDiff": "Content diff",
"diffTooLarge": "Diff too large",
"diffTooLargeDesc": "This version has a very large content diff, so the inline viewer is disabled.",
"diffUnavailable": "Diff unavailable",
"diffUnavailableDesc": "This version has a diff payload, but it could not be parsed as a unified diff.",
"noContentDiff": "No content diff",
"noContentDiffDesc": "This version does not have a stored content diff.",
"source": "Source",
"skillMdPreview": "SKILL.md preview",
"sourceUnavailable": "Source unavailable",
"sourceUnavailableDesc": "This version points to a missing or unreadable SKILL.md path.",
"analyses": "Analyses",
"recentAnalyses": "Recent execution analyses",
"noExecutionNote": "No execution note",
"analysisCompleted": "completed: {{value}} · tool issues: {{toolIssues}} · suggestions: {{suggestions}}",
"noAnalysesYet": "No analyses yet",
"noAnalysesDesc": "Execution analyses will appear after recorded task runs are persisted into SQLite."
},
"filter": {
"origin": "Origin:",
"allOrigins": "All Origins",
"tags": "Tags:",
"allTags": "All Tags"
},
"graph": {
"noGraphData": "No lineage graph data.",
"tooltipScore": "score: {{value}}",
"tooltipGeneration": "generation: {{value}}",
"tooltipOrigin": "origin: {{value}}"
},
"diffViewer": {
"noFiles": "No files in diff",
"old": "Old",
"new": "New"
},
"format": {
"noInstruction": "No instruction captured"
}
}

View file

@ -0,0 +1,34 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import en from './en.json';
import zh from './zh.json';
const STORAGE_KEY = 'openspace-lang';
function getSavedLanguage(): string {
try {
return localStorage.getItem(STORAGE_KEY) || 'en';
} catch {
return 'en';
}
}
i18n.use(initReactI18next).init({
resources: {
en: { translation: en },
zh: { translation: zh },
},
lng: getSavedLanguage(),
fallbackLng: 'en',
interpolation: { escapeValue: false },
});
i18n.on('languageChanged', (lng) => {
try {
localStorage.setItem(STORAGE_KEY, lng);
} catch {
// ignore
}
});
export default i18n;

253
frontend/src/i18n/zh.json Normal file
View file

@ -0,0 +1,253 @@
{
"nav": {
"dashboard": "仪表盘",
"skills": "Skills",
"workflows": "工作流"
},
"common": {
"loading": "加载中…",
"yes": "是",
"no": "否",
"none": "无",
"unavailable": "不可用",
"unknown": "未知",
"close": "关闭",
"score": "评分",
"success": "成功率",
"active": "活跃",
"inactive": "未激活",
"noDescription": "暂无描述",
"steps_one": "{{count}} 步",
"steps_other": "{{count}} 步",
"agentActions_one": "{{count}} 个 Agent 操作",
"agentActions_other": "{{count}} 个 Agent 操作",
"tags": "+{{count}} 个标签"
},
"langSwitch": {
"label": "语言"
},
"errorBoundary": {
"title": "出现了错误",
"message": "发生了意外错误",
"goToDashboard": "返回仪表盘",
"details": "错误详情"
},
"dashboard": {
"title": "仪表盘",
"loadingDashboard": "正在加载仪表盘…",
"failedToLoad": "加载概览失败",
"dashboardUnavailable": "仪表盘不可用",
"totalSkills": "Skills 总数",
"activeHint": "活跃:{{count}}",
"avgSkillScore": "Skill 平均评分",
"avgScoreHint": "主要指标 = 有效率 × 100",
"workflowSessions": "工作流会话",
"localRepo": "本地仓库",
"workspace": "工作区",
"recordedUnder": "记录于{{location}}",
"workflowSuccess": "工作流成功率",
"avgSuccessHint": "平均会话成功率",
"health": "健康状态",
"runtimeSnapshot": "运行时快照",
"status": "状态",
"dbPath": "数据库路径",
"workflowCount": "工作流数量",
"builtFrontend": "前端构建",
"skillsSection": "Skills",
"topScoredSkills": "评分最高的 Skills",
"noSkillsYet": "暂无 Skills",
"noSkillsDesc": "先运行 OpenSpace 任务或将 Skills 同步到本地注册表中。",
"effective": "有效率 {{value}}",
"applied": "应用率 {{value}}",
"selections": "选择次数 {{count}}",
"workflowsSection": "工作流",
"recentSessions": "近期会话",
"noWorkflowSessions": "暂无工作流会话",
"noWorkflowDesc": "启用录制后执行任务,录制数据将在此显示。"
},
"skills": {
"title": "Skill 类别",
"searchPlaceholder": "按名称、ID、描述、标签或来源搜索",
"sortByScore": "按最佳评分排序",
"sortByUpdated": "按更新时间排序",
"sortByName": "按名称排序",
"skillClasses": "Skill 类别",
"versionsHint": "版本数:{{count}}",
"activeVersions": "活跃版本",
"withActivity": "有活动记录:{{count}}",
"avgBestScore": "平均最佳评分",
"bestNodeScoreHint": "每个类别的最佳节点评分",
"selections": "选择次数",
"completionsHint": "完成次数:{{count}}",
"loadingSkills": "正在加载 Skills…",
"failedToLoad": "加载 Skills 失败",
"noSkillsMatch": "没有匹配的 Skills",
"noSkillsMatchDesc": "尝试其他关键词,或执行任务以将新的 Skill 遥测数据写入 SQLite。",
"bestScore": "最佳评分",
"noClassDescription": "暂无类别描述",
"versions": "{{count}} 个版本",
"active": "{{count}} 个活跃",
"selectionsCount": "{{count}} 次选择"
},
"skillDetail": {
"loadingDetail": "正在加载 Skill 详情…",
"failedToLoad": "加载 Skill 类别失败",
"skillNotFound": "未找到 Skill",
"backToSkills": "← 返回 Skills",
"anchoredOn": "Skill 类别锚定于 {{id}}",
"skillClass": "Skill 类别",
"evolutionOverview": "演化概览",
"activeTip": "活跃端点",
"inactiveAnchor": "未激活锚点",
"bestVersionScore": "最佳版本评分",
"skillDirectory": "Skill 目录",
"latestVersionCreated": "最新版本创建时间",
"representativeVersion": "代表版本",
"representativeUpdate": "代表版本更新时间",
"versions": "版本数",
"maxGeneration": "最大世代 {{count}}",
"activeVersions": "活跃版本",
"originsCount": "来源数:{{count}}",
"averageScore": "平均评分",
"acrossAllVersions": "该谱系中所有版本的平均值",
"selections": "选择次数",
"representativeScore": "代表评分 {{score}}",
"evolutionGraph": "演化图",
"versionLineage": "版本谱系",
"loadingDrawer": "正在加载版本抽屉…",
"noLineageGraph": "暂无谱系图",
"noLineageGraphDesc": "该 Skill 尚无可供可视化的谱系数据。"
},
"workflows": {
"title": "录制会话",
"searchPlaceholder": "按任务名称或指令搜索",
"workflowSessions": "工作流会话",
"scannedFrom": "从 logs/recordings 和 logs/trajectories 扫描",
"averageSuccess": "平均成功率",
"meanSuccessRate": "各会话的平均成功率",
"loadingWorkflows": "正在加载工作流…",
"failedToLoad": "加载工作流失败",
"noSessions": "暂无工作流会话",
"noSessionsDesc": "启用录制运行 `openspace` 后刷新此页面。",
"more": "+{{count}} 更多"
},
"workflowDetail": {
"loadingDetail": "正在加载工作流详情…",
"failedToLoad": "加载工作流失败",
"workflowNotFound": "未找到工作流",
"backToWorkflows": "← 返回工作流列表",
"workflowDetail": "工作流详情",
"skillsSelected_one": "已选择 {{count}} 个 Skill",
"skillsSelected_other": "已选择 {{count}} 个 Skills",
"mergedEvent_one": "{{count}} 个合并事件",
"mergedEvent_other": "{{count}} 个合并事件",
"runDescription": "{{status}}运行,包含 {{iterations}} 和 {{actions}}。",
"started": "开始时间",
"duration": "持续时间",
"stepsLabel": "步骤",
"latestEvent": "最新事件",
"successRate": "成功率",
"successRateHint_one": "{{iterations}}中 {{count}} 次成功迭代",
"successRateHint_other": "{{iterations}}中 {{count}} 次成功迭代",
"iterations": "迭代次数",
"totalRuntime": "总运行时间 {{duration}}",
"activeBackends": "活跃后端",
"mostActive": "最活跃 {{backend}} · {{count}} 个事件",
"noRecordedActivity": "无已记录的工具活动",
"timelineEvents": "时间线事件",
"agentToolHint": "{{agentCount}} 个 Agent 操作 · {{toolCount}} 个工具事件",
"noTimelineData": "暂无时间线数据",
"noTimelineDesc": "此会话尚未包含轨迹或 Agent 操作记录。",
"rawEventJson": "原始事件 JSON",
"selection": "Skill 选择",
"selectedSkills": "已选 Skills",
"noSelectedSkills": "未选择 Skills",
"noSelectedSkillsDesc": "此次运行未选择或记录任何 Skills。",
"session": "会话",
"overview": "概览",
"taskId": "任务 ID",
"runtime": "运行时",
"window": "时间窗口",
"ended": "结束于 {{date}}",
"selectionMethod": "选择方法",
"notRecorded": "未记录",
"enabledBackends": "已启用后端",
"backendActivity": "后端活动",
"iteration_one": "{{count}} 次迭代",
"iteration_other": "{{count}} 次迭代",
"step_one": "{{count}} 步",
"step_other": "{{count}} 步",
"agentAction_one": "{{count}} 个 Agent 操作",
"agentAction_other": "{{count}} 个 Agent 操作",
"successfulIteration_one": "{{count}} 次成功迭代",
"successfulIteration_other": "{{count}} 次成功迭代"
},
"drawer": {
"skillVersion": "Skill 版本",
"openAsMain": "作为主视图打开",
"versionSummary": "版本摘要",
"noDescriptionAvailable": "此版本暂无描述信息。",
"gen": "第 {{generation}} 代",
"versionScore": "版本评分",
"metrics": "指标",
"executionQuality": "执行质量",
"effectiveRate": "有效率",
"completionRate": "完成率",
"appliedRate": "应用率",
"fallbackRate": "回退率",
"selectionsLabel": "选择次数",
"appliedLabel": "应用次数",
"completionsLabel": "完成次数",
"fallbacksLabel": "回退次数",
"versionMetadata": "版本元数据",
"origin": "来源:",
"generation": "世代:",
"visibility": "可见性:",
"created": "创建时间:",
"firstSeen": "首次出现:",
"lastUpdated": "最后更新:",
"skillPath": "Skill 路径:",
"skillDir": "Skill 目录:",
"parentIds": "父级 ID",
"changeSummary": "变更摘要:",
"effectiveScore": "有效评分:",
"diff": "Diff",
"contentDiff": "内容 Diff",
"diffTooLarge": "Diff 过大",
"diffTooLargeDesc": "此版本的内容 Diff 过大,内联查看器已禁用。",
"diffUnavailable": "Diff 不可用",
"diffUnavailableDesc": "此版本有 Diff 数据,但无法解析为统一 Diff 格式。",
"noContentDiff": "无内容 Diff",
"noContentDiffDesc": "此版本没有存储的内容 Diff。",
"source": "源代码",
"skillMdPreview": "SKILL.md 预览",
"sourceUnavailable": "源代码不可用",
"sourceUnavailableDesc": "此版本指向的 SKILL.md 路径缺失或不可读。",
"analyses": "分析",
"recentAnalyses": "近期执行分析",
"noExecutionNote": "无执行备注",
"analysisCompleted": "已完成:{{value}} · 工具问题:{{toolIssues}} · 建议:{{suggestions}}",
"noAnalysesYet": "暂无分析",
"noAnalysesDesc": "执行分析将在已记录的任务运行持久化到 SQLite 后显示。"
},
"filter": {
"origin": "来源:",
"allOrigins": "所有来源",
"tags": "标签:",
"allTags": "所有标签"
},
"graph": {
"noGraphData": "暂无谱系图数据。",
"tooltipScore": "评分:{{value}}",
"tooltipGeneration": "Generation{{value}}",
"tooltipOrigin": "Origin{{value}}"
},
"diffViewer": {
"noFiles": "Diff 中无文件",
"old": "旧版",
"new": "新版"
},
"format": {
"noInstruction": "未捕获到指令"
}
}

View file

@ -32,7 +32,7 @@
--radius-chip: 999px;
--radius-card: 32px;
--radius-card-sm: 18px;
--font-serif: 'Neuton', Georgia, serif;
--font-serif: 'Neuton', 'Noto Serif CJK SC', 'Songti SC', 'SimSun', Georgia, serif;
--font-sans: 'Cabin', system-ui, -apple-system, sans-serif;
--font-mono: ui-monospace, 'SF Mono', Menlo, Monaco, 'Cascadia Code', 'Courier New', monospace;
--shadow-hard: 4px 4px 0px 0px rgba(74, 59, 42, 0.1);

View file

@ -1,4 +1,5 @@
import { NavLink, Outlet } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
const linkClass = ({ isActive }: { isActive: boolean }) =>
isActive
@ -6,6 +7,12 @@ const linkClass = ({ isActive }: { isActive: boolean }) =>
: 'hover:text-primary';
export default function MainLayout() {
const { t, i18n } = useTranslation();
const toggleLang = () => {
i18n.changeLanguage(i18n.language === 'zh' ? 'en' : 'zh');
};
return (
<div className="h-screen min-w-[1180px] relative flex flex-col overflow-x-auto overflow-y-hidden bg-bg-page text-ink">
<nav className="relative z-10 flex justify-between items-center px-4 py-3 border-b border-[color:var(--color-border)] bg-bg-page">
@ -13,17 +20,26 @@ export default function MainLayout() {
<div className="font-bold text-3xl tracking-tighter font-serif">OpenSpace</div>
<div className="flex gap-4 text-sm">
<NavLink to="/dashboard" className={linkClass}>
Dashboard
{t('nav.dashboard')}
</NavLink>
<NavLink to="/skills" className={linkClass}>
Skills
{t('nav.skills')}
</NavLink>
<NavLink to="/workflows" className={linkClass}>
Workflows
{t('nav.workflows')}
</NavLink>
</div>
</div>
<div className="text-xs text-muted">API: `localhost:7788` · Vite: `localhost:3888`</div>
<div className="flex items-center gap-4">
<button
type="button"
onClick={toggleLang}
className="px-2.5 py-1 text-xs border border-[color:var(--color-border-dark)] rounded hover:bg-[color:var(--color-surface)] transition-colors cursor-pointer bg-transparent text-ink"
>
{i18n.language === 'zh' ? 'EN' : '中文'}
</button>
<div className="text-xs text-muted">API: `localhost:7788` · Vite: `localhost:3888`</div>
</div>
</nav>
<main className="app-scroll-region relative z-10 min-h-0 flex-1 overflow-auto">

View file

@ -1,5 +1,6 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './i18n';
import './index.css';
import App from './App';
import { ErrorBoundary } from './components/ErrorBoundary';

View file

@ -1,11 +1,13 @@
import { Link } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { overviewApi, type OverviewResponse } from '../api';
import MetricCard from '../components/MetricCard';
import EmptyState from '../components/EmptyState';
import { formatDate, formatInstruction, formatPercent, truncate } from '../utils/format';
export default function DashboardPage() {
const { t } = useTranslation();
const [data, setData] = useState<OverviewResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -22,7 +24,7 @@ export default function DashboardPage() {
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load overview');
setError(err instanceof Error ? err.message : t('dashboard.failedToLoad'));
}
} finally {
if (!cancelled) {
@ -34,37 +36,37 @@ export default function DashboardPage() {
return () => {
cancelled = true;
};
}, []);
}, [t]);
if (loading) {
return <div className="p-6 text-sm text-muted">Loading dashboard</div>;
return <div className="p-6 text-sm text-muted">{t('dashboard.loadingDashboard')}</div>;
}
if (error || !data) {
return <div className="p-6 text-sm text-danger">{error ?? 'Dashboard unavailable'}</div>;
return <div className="p-6 text-sm text-danger">{error ?? t('dashboard.dashboardUnavailable')}</div>;
}
return (
<div className="p-6 space-y-6">
<h1 className="text-3xl font-bold font-serif">Dashboard</h1>
<h1 className="text-3xl font-bold font-serif">{t('dashboard.title')}</h1>
<section className="metrics-row">
<MetricCard label="Total Skills" value={data.skills.summary.total_skills_all} hint={`Active: ${data.skills.summary.total_skills}`} />
<MetricCard label="Average Skill Score" value={data.skills.average_score.toFixed(1)} hint="Primary metric = effective rate × 100" />
<MetricCard label="Workflow Sessions" value={data.workflows.total} hint={`Recorded under ${data.health.db_path.includes('.openspace') ? 'local repo' : 'workspace'}`} />
<MetricCard label="Workflow Success" value={`${data.workflows.average_success_rate.toFixed(1)}%`} hint="Average session success rate" />
<MetricCard label={t('dashboard.totalSkills')} value={data.skills.summary.total_skills_all} hint={t('dashboard.activeHint', { count: data.skills.summary.total_skills })} />
<MetricCard label={t('dashboard.avgSkillScore')} value={data.skills.average_score.toFixed(1)} hint={t('dashboard.avgScoreHint')} />
<MetricCard label={t('dashboard.workflowSessions')} value={data.workflows.total} hint={t('dashboard.recordedUnder', { location: data.health.db_path.includes('.openspace') ? t('dashboard.localRepo') : t('dashboard.workspace') })} />
<MetricCard label={t('dashboard.workflowSuccess')} value={`${data.workflows.average_success_rate.toFixed(1)}%`} hint={t('dashboard.avgSuccessHint')} />
</section>
<section>
<div className="panel-surface p-5 space-y-4">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">Health</div>
<h2 className="text-2xl font-bold font-serif mt-1">Runtime snapshot</h2>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('dashboard.health')}</div>
<h2 className="text-2xl font-bold font-serif mt-1">{t('dashboard.runtimeSnapshot')}</h2>
</div>
<div className="space-y-3 text-sm">
<div className="flex items-center justify-between"><span className="text-muted">Status</span><span>{data.health.status}</span></div>
<div className="flex items-center justify-between"><span className="text-muted">DB Path</span><span className="text-right break-all">{data.health.db_path}</span></div>
<div className="flex items-center justify-between"><span className="text-muted">Workflow Count</span><span>{data.health.workflow_count}</span></div>
<div className="flex items-center justify-between"><span className="text-muted">Built Frontend</span><span>{data.health.frontend_dist_exists ? 'yes' : 'no'}</span></div>
<div className="flex items-center justify-between"><span className="text-muted">{t('dashboard.status')}</span><span>{data.health.status}</span></div>
<div className="flex items-center justify-between"><span className="text-muted">{t('dashboard.dbPath')}</span><span className="text-right break-all">{data.health.db_path}</span></div>
<div className="flex items-center justify-between"><span className="text-muted">{t('dashboard.workflowCount')}</span><span>{data.health.workflow_count}</span></div>
<div className="flex items-center justify-between"><span className="text-muted">{t('dashboard.builtFrontend')}</span><span>{data.health.frontend_dist_exists ? t('common.yes') : t('common.no')}</span></div>
</div>
</div>
</section>
@ -72,11 +74,11 @@ export default function DashboardPage() {
<section className="grid grid-cols-2 gap-6">
<div className="panel-surface p-5 space-y-4">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">Skills</div>
<h2 className="text-2xl font-bold font-serif mt-1">Top scored skills</h2>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('dashboard.skillsSection')}</div>
<h2 className="text-2xl font-bold font-serif mt-1">{t('dashboard.topScoredSkills')}</h2>
</div>
{data.skills.top.length === 0 ? (
<EmptyState title="No skills yet" description="Run OpenSpace tasks or sync skills into the local registry first." />
<EmptyState title={t('dashboard.noSkillsYet')} description={t('dashboard.noSkillsDesc')} />
) : (
<div className="space-y-3">
{data.skills.top.map((skill) => (
@ -84,17 +86,17 @@ export default function DashboardPage() {
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 space-y-1">
<div className="font-bold truncate">{skill.name}</div>
<div className="text-sm text-muted">{truncate(skill.description || 'No description', 110)}</div>
<div className="text-sm text-muted">{truncate(skill.description || t('common.noDescription'), 110)}</div>
</div>
<div className="text-right shrink-0">
<div className="text-2xl font-bold font-serif">{skill.score.toFixed(1)}</div>
<div className="text-xs text-muted">score</div>
<div className="text-xs text-muted">{t('common.score')}</div>
</div>
</div>
<div className="mt-3 flex gap-3 text-xs text-muted">
<span>effective {formatPercent(skill.effective_rate)}</span>
<span>applied {formatPercent(skill.applied_rate)}</span>
<span>selections {skill.total_selections}</span>
<span>{t('dashboard.effective', { value: formatPercent(skill.effective_rate) })}</span>
<span>{t('dashboard.applied', { value: formatPercent(skill.applied_rate) })}</span>
<span>{t('dashboard.selections', { count: skill.total_selections })}</span>
</div>
</Link>
))}
@ -104,11 +106,11 @@ export default function DashboardPage() {
<div className="panel-surface p-5 space-y-4">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">Workflows</div>
<h2 className="text-2xl font-bold font-serif mt-1">Recent sessions</h2>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('dashboard.workflowsSection')}</div>
<h2 className="text-2xl font-bold font-serif mt-1">{t('dashboard.recentSessions')}</h2>
</div>
{data.workflows.recent.length === 0 ? (
<EmptyState title="No workflow sessions" description="Recordings will appear after a task is executed with recording enabled." />
<EmptyState title={t('dashboard.noWorkflowSessions')} description={t('dashboard.noWorkflowDesc')} />
) : (
<div className="space-y-3">
{data.workflows.recent.map((workflow) => (
@ -116,16 +118,16 @@ export default function DashboardPage() {
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 space-y-1">
<div className="font-bold truncate">{workflow.task_name}</div>
<div className="text-sm text-muted line-clamp-2">{formatInstruction(workflow.instruction, 160)}</div>
<div className="text-sm text-muted line-clamp-2">{formatInstruction(workflow.instruction, 160, t('format.noInstruction'))}</div>
</div>
<div className="text-right shrink-0">
<div className="text-lg font-bold font-serif">{(workflow.success_rate * 100).toFixed(1)}%</div>
<div className="text-xs text-muted">success</div>
<div className="text-xs text-muted">{t('common.success')}</div>
</div>
</div>
<div className="mt-3 flex gap-3 text-xs text-muted">
<span>{workflow.total_steps} steps</span>
<span>{workflow.agent_action_count} agent actions</span>
<span>{t('common.steps', { count: workflow.total_steps })}</span>
<span>{t('common.agentActions', { count: workflow.agent_action_count })}</span>
<span>{formatDate(workflow.start_time)}</span>
</div>
</Link>

View file

@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useSearchParams, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { skillsApi, type SkillDetail, type SkillLineage } from '../api';
import EmptyState from '../components/EmptyState';
import MetricCard from '../components/MetricCard';
@ -26,6 +27,7 @@ function resolveLineageGraph(skill: SkillDetail | null): SkillLineage | null {
const DRAWER_ANIMATION_DURATION_MS = 300;
export default function SkillDetailPage() {
const { t } = useTranslation();
const { skillId = '' } = useParams();
const [searchParams, setSearchParams] = useSearchParams();
const [skillClass, setSkillClass] = useState<SkillDetail | null>(null);
@ -52,7 +54,7 @@ export default function SkillDetailPage() {
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load skill class');
setError(err instanceof Error ? err.message : t('skillDetail.failedToLoad'));
}
} finally {
if (!cancelled) {
@ -68,7 +70,7 @@ export default function SkillDetailPage() {
return () => {
cancelled = true;
};
}, [skillId]);
}, [skillId, t]);
const lineageGraph = useMemo(() => resolveLineageGraph(skillClass), [skillClass]);
@ -97,7 +99,7 @@ export default function SkillDetailPage() {
} catch (err) {
if (!cancelled) {
setSelectedVersion(null);
setDrawerError(err instanceof Error ? err.message : 'Failed to load selected version');
setDrawerError(err instanceof Error ? err.message : t('skillDetail.failedToLoad'));
}
} finally {
if (!cancelled) {
@ -110,7 +112,7 @@ export default function SkillDetailPage() {
return () => {
cancelled = true;
};
}, [selectedVersionId, skillClass]);
}, [selectedVersionId, skillClass, t]);
useEffect(() => {
if (selectedVersion) {
@ -215,20 +217,20 @@ export default function SkillDetailPage() {
};
if (loading) {
return <div className="p-6 text-sm text-muted">Loading skill detail</div>;
return <div className="p-6 text-sm text-muted">{t('skillDetail.loadingDetail')}</div>;
}
if (error || !skillClass) {
return <div className="p-6 text-sm text-danger">{error ?? 'Skill not found'}</div>;
return <div className="p-6 text-sm text-danger">{error ?? t('skillDetail.skillNotFound')}</div>;
}
return (
<div className="p-6 space-y-6 relative">
<div className="flex items-center gap-4">
<Link to="/skills" className="chip text-sm transition-colors hover:border-[color:var(--color-border-dark)] hover:text-ink"> Back to Skills</Link>
<Link to="/skills" className="chip text-sm transition-colors hover:border-[color:var(--color-border-dark)] hover:text-ink">{t('skillDetail.backToSkills')}</Link>
<div className="min-w-0">
<h1 className="text-3xl font-bold font-serif truncate">{skillClass.name}</h1>
<div className="text-sm text-muted mt-1">Skill class anchored on {skillClass.skill_id}</div>
<div className="text-sm text-muted mt-1">{t('skillDetail.anchoredOn', { id: skillClass.skill_id })}</div>
</div>
</div>
@ -236,13 +238,13 @@ export default function SkillDetailPage() {
<div className="flex items-start justify-between gap-6">
<div className="space-y-3 min-w-0 flex-1">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">Skill Class</div>
<h2 className="text-2xl font-bold font-serif mt-1">Evolution overview</h2>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('skillDetail.skillClass')}</div>
<h2 className="text-2xl font-bold font-serif mt-1">{t('skillDetail.evolutionOverview')}</h2>
</div>
<div className="flex flex-wrap gap-2 text-xs">
<span className="tag px-2 py-1">{skillClass.category}</span>
<span className="tag px-2 py-1">{skillClass.visibility}</span>
<span className="tag px-2 py-1">{skillClass.is_active ? 'active tip' : 'inactive anchor'}</span>
<span className="tag px-2 py-1">{skillClass.is_active ? t('skillDetail.activeTip') : t('skillDetail.inactiveAnchor')}</span>
{classSummary.origins.map((origin) => (
<span key={origin} className="tag px-2 py-1">{origin}</span>
))}
@ -250,48 +252,48 @@ export default function SkillDetailPage() {
<span key={tag} className="tag px-2 py-1">{tag}</span>
))}
{classSummary.tags.length > 8 ? (
<span className="tag px-2 py-1">+{classSummary.tags.length - 8} tags</span>
<span className="tag px-2 py-1">{t('common.tags', { count: classSummary.tags.length - 8 })}</span>
) : null}
</div>
</div>
<div className="shrink-0 text-right">
<div className="text-5xl font-bold font-serif leading-none">{classSummary.bestScore.toFixed(1)}</div>
<div className="text-xs uppercase tracking-[0.16em] text-muted mt-2">best version score</div>
<div className="text-xs uppercase tracking-[0.16em] text-muted mt-2">{t('skillDetail.bestVersionScore')}</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4 text-sm text-muted">
<div>
<div className="font-bold text-ink">Skill directory</div>
<div className="break-all">{skillClass.skill_dir || 'Unavailable'}</div>
<div className="font-bold text-ink">{t('skillDetail.skillDirectory')}</div>
<div className="break-all">{skillClass.skill_dir || t('common.unavailable')}</div>
</div>
<div>
<div className="font-bold text-ink">Latest version created</div>
<div className="font-bold text-ink">{t('skillDetail.latestVersionCreated')}</div>
<div>{formatDate(classSummary.latestCreatedAt)}</div>
</div>
<div>
<div className="font-bold text-ink">Representative version</div>
<div className="font-bold text-ink">{t('skillDetail.representativeVersion')}</div>
<div className="break-all">{skillClass.skill_id}</div>
</div>
<div>
<div className="font-bold text-ink">Representative update</div>
<div className="font-bold text-ink">{t('skillDetail.representativeUpdate')}</div>
<div>{formatDate(skillClass.last_updated)}</div>
</div>
</div>
</section>
<section className="metrics-row">
<MetricCard label="Versions" value={classSummary.versionCount} hint={`Max generation ${classSummary.maxGeneration}`} />
<MetricCard label="Active Versions" value={classSummary.activeCount} hint={`Origins: ${classSummary.origins.length}`} />
<MetricCard label="Average Score" value={classSummary.averageScore.toFixed(1)} hint="Across all versions in this lineage" />
<MetricCard label="Selections" value={classSummary.totalSelections} hint={`Representative score ${skillClass.score.toFixed(1)}`} />
<MetricCard label={t('skillDetail.versions')} value={classSummary.versionCount} hint={t('skillDetail.maxGeneration', { count: classSummary.maxGeneration })} />
<MetricCard label={t('skillDetail.activeVersions')} value={classSummary.activeCount} hint={t('skillDetail.originsCount', { count: classSummary.origins.length })} />
<MetricCard label={t('skillDetail.averageScore')} value={classSummary.averageScore.toFixed(1)} hint={t('skillDetail.acrossAllVersions')} />
<MetricCard label={t('skillDetail.selections')} value={classSummary.totalSelections} hint={t('skillDetail.representativeScore', { score: skillClass.score.toFixed(1) })} />
</section>
<section className="panel-surface overflow-hidden relative min-h-[620px]">
<div className="px-5 py-4 border-b border-[color:var(--color-border)] bg-surface flex items-center justify-between gap-4 flex-wrap">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">Evolution Graph</div>
<h2 className="text-2xl font-bold font-serif mt-1">Version lineage</h2>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('skillDetail.evolutionGraph')}</div>
<h2 className="text-2xl font-bold font-serif mt-1">{t('skillDetail.versionLineage')}</h2>
</div>
<SkillVersionFilterBar
originFilter={originFilter}
@ -309,7 +311,7 @@ export default function SkillDetailPage() {
onBackgroundClick={closeDrawer}
/>
{drawerLoading ? (
<div className="absolute bottom-4 left-4 text-xs text-muted">Loading version drawer</div>
<div className="absolute bottom-4 left-4 text-xs text-muted">{t('skillDetail.loadingDrawer')}</div>
) : null}
{drawerError ? (
<div className="absolute bottom-4 left-4 text-xs text-danger">{drawerError}</div>
@ -317,7 +319,7 @@ export default function SkillDetailPage() {
</section>
{lineageGraph && lineageGraph.nodes.length === 0 ? (
<EmptyState title="No lineage graph" description="This skill does not yet have lineage data to visualize." />
<EmptyState title={t('skillDetail.noLineageGraph')} description={t('skillDetail.noLineageGraphDesc')} />
) : null}
<SkillVersionDrawer skill={drawerVersion} isOpen={Boolean(selectedVersion)} onClose={closeDrawer} />

View file

@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { skillsApi, type Skill, type SkillStats } from '../api';
import EmptyState from '../components/EmptyState';
import MetricCard from '../components/MetricCard';
@ -7,6 +8,7 @@ import { formatDate, truncate } from '../utils/format';
import { buildSkillClasses } from '../utils/skillClasses';
export default function SkillsPage() {
const { t } = useTranslation();
const [skills, setSkills] = useState<Skill[]>([]);
const [stats, setStats] = useState<SkillStats | null>(null);
const [loading, setLoading] = useState(true);
@ -30,7 +32,7 @@ export default function SkillsPage() {
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load skills');
setError(err instanceof Error ? err.message : t('skills.failedToLoad'));
}
} finally {
if (!cancelled) {
@ -42,7 +44,7 @@ export default function SkillsPage() {
return () => {
cancelled = true;
};
}, [sort]);
}, [sort, t]);
const skillClasses = useMemo(() => buildSkillClasses(skills), [skills]);
@ -89,37 +91,37 @@ export default function SkillsPage() {
<div className="p-6 space-y-6">
<div className="flex items-end justify-between gap-4">
<div>
<h1 className="text-3xl font-bold font-serif">Skill classes</h1>
<h1 className="text-3xl font-bold font-serif">{t('skills.title')}</h1>
</div>
<div className="flex gap-3 items-center">
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search by name, id, description, tag, or origin"
placeholder={t('skills.searchPlaceholder')}
className="px-3 py-2 min-w-[320px]"
/>
<select value={sort} onChange={(event) => setSort(event.target.value as typeof sort)} className="px-3 py-2">
<option value="score">Sort by best score</option>
<option value="updated">Sort by updated time</option>
<option value="name">Sort by name</option>
<option value="score">{t('skills.sortByScore')}</option>
<option value="updated">{t('skills.sortByUpdated')}</option>
<option value="name">{t('skills.sortByName')}</option>
</select>
</div>
</div>
{stats ? (
<section className="metrics-row">
<MetricCard label="Skill Classes" value={skillClasses.length} hint={`Versions: ${stats.total_skills_all}`} />
<MetricCard label="Active Versions" value={totalActiveVersions} hint={`With activity: ${stats.skills_with_activity}`} />
<MetricCard label="Average Best Score" value={averageBestScore.toFixed(1)} hint="Best node score per class" />
<MetricCard label="Selections" value={stats.total_selections} hint={`Completions: ${stats.total_completions}`} />
<MetricCard label={t('skills.skillClasses')} value={skillClasses.length} hint={t('skills.versionsHint', { count: stats.total_skills_all })} />
<MetricCard label={t('skills.activeVersions')} value={totalActiveVersions} hint={t('skills.withActivity', { count: stats.skills_with_activity })} />
<MetricCard label={t('skills.avgBestScore')} value={averageBestScore.toFixed(1)} hint={t('skills.bestNodeScoreHint')} />
<MetricCard label={t('skills.selections')} value={stats.total_selections} hint={t('skills.completionsHint', { count: stats.total_completions })} />
</section>
) : null}
{loading ? <div className="text-sm text-muted">Loading skills</div> : null}
{loading ? <div className="text-sm text-muted">{t('skills.loadingSkills')}</div> : null}
{error ? <div className="text-sm text-danger">{error}</div> : null}
{!loading && !error && filteredClasses.length === 0 ? (
<EmptyState title="No skills match" description="Try another keyword, or execute tasks so new skill telemetry lands in SQLite." />
<EmptyState title={t('skills.noSkillsMatch')} description={t('skills.noSkillsMatchDesc')} />
) : null}
{!loading && !error && filteredClasses.length > 0 ? (
@ -137,18 +139,18 @@ export default function SkillsPage() {
</div>
<div className="text-right shrink-0">
<div className="text-3xl font-bold font-serif leading-none">{skillClass.best_score.toFixed(1)}</div>
<div className="text-xs text-muted">best score</div>
<div className="text-xs text-muted">{t('skills.bestScore')}</div>
</div>
</div>
<div className="text-sm text-muted">
{truncate(skillClass.representative.description || 'No class description', 160)}
{truncate(skillClass.representative.description || t('skills.noClassDescription'), 160)}
</div>
<div className="grid grid-cols-4 gap-3 text-xs text-muted">
<div>{skillClass.version_count} versions</div>
<div>{skillClass.active_count} active</div>
<div>{skillClass.total_selections} selections</div>
<div>{t('skills.versions', { count: skillClass.version_count })}</div>
<div>{t('skills.active', { count: skillClass.active_count })}</div>
<div>{t('skills.selectionsCount', { count: skillClass.total_selections })}</div>
<div>{formatDate(skillClass.latest_updated)}</div>
</div>
@ -160,7 +162,7 @@ export default function SkillsPage() {
<span key={`${skillClass.class_id}-${tag}`} className="tag px-2 py-1">{tag}</span>
))}
{skillClass.tags.length > 5 ? (
<span className="tag px-2 py-1">+{skillClass.tags.length - 5} tags</span>
<span className="tag px-2 py-1">{t('common.tags', { count: skillClass.tags.length - 5 })}</span>
) : null}
</div>
</Link>

View file

@ -1,5 +1,6 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { Link, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { workflowsApi, type WorkflowDetail, type WorkflowTimelineEvent } from '../api';
import { formatDate, formatInstruction } from '../utils/format';
@ -48,10 +49,6 @@ function formatPercent(value: number): string {
return `${(value * 100).toFixed(1)}%`;
}
function pluralize(value: number, singular: string, plural = `${singular}s`): string {
return `${value} ${value === 1 ? singular : plural}`;
}
function getString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value : null;
}
@ -367,6 +364,7 @@ function describeTimelineEvent(event: WorkflowTimelineEvent): TimelinePresentati
}
export default function WorkflowDetailPage() {
const { t } = useTranslation();
const { workflowId = '' } = useParams();
const [workflow, setWorkflow] = useState<WorkflowDetail | null>(null);
const [loading, setLoading] = useState(true);
@ -385,7 +383,7 @@ export default function WorkflowDetailPage() {
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load workflow');
setError(err instanceof Error ? err.message : t('workflowDetail.failedToLoad'));
}
} finally {
if (!cancelled) {
@ -399,7 +397,7 @@ export default function WorkflowDetailPage() {
return () => {
cancelled = true;
};
}, [workflowId]);
}, [workflowId, t]);
const timeline = useMemo(() => {
const events = workflow?.timeline ?? [];
@ -464,7 +462,7 @@ export default function WorkflowDetailPage() {
return (
<div className="workflow-detail-page p-6">
<div className="mx-auto max-w-[1480px]">
<div className="workflow-panel p-6 text-sm text-muted">Loading workflow detail</div>
<div className="workflow-panel p-6 text-sm text-muted">{t('workflowDetail.loadingDetail')}</div>
</div>
</div>
);
@ -474,7 +472,7 @@ export default function WorkflowDetailPage() {
return (
<div className="workflow-detail-page p-6">
<div className="mx-auto max-w-[1480px]">
<div className="workflow-panel p-6 text-sm text-danger">{error ?? 'Workflow not found'}</div>
<div className="workflow-panel p-6 text-sm text-danger">{error ?? t('workflowDetail.workflowNotFound')}</div>
</div>
</div>
);
@ -487,16 +485,16 @@ export default function WorkflowDetailPage() {
const skillSelection = isRecord(metadata.skill_selection) ? metadata.skill_selection : null;
const selectionMethod = skillSelection && typeof skillSelection.method === 'string' ? skillSelection.method : null;
const executionDurationLabel = formatDurationSeconds(workflow.execution_time);
const selectedSkillLabel = `${pluralize(workflow.selected_skills.length, 'skill')} selected`;
const iterationsLabel = pluralize(workflow.iterations, 'iteration');
const totalStepLabel = pluralize(workflow.total_steps, 'step');
const actionCountLabel = pluralize(workflow.agent_action_count, 'agent action');
const selectedSkillLabel = t('workflowDetail.skillsSelected', { count: workflow.selected_skills.length });
const iterationsLabel = t('workflowDetail.iteration', { count: workflow.iterations });
const totalStepLabel = t('workflowDetail.step', { count: workflow.total_steps });
const actionCountLabel = t('workflowDetail.agentAction', { count: workflow.agent_action_count });
const agentActionCount = timelineSummary.byType.agent_action ?? 0;
const toolExecutionCount = timelineSummary.byType.tool_execution ?? 0;
const latestEventLabel = timelineSummary.lastTimestamp ? formatTimeLabel(timelineSummary.lastTimestamp) : '—';
const timelineEventLabel = pluralize(timelineSummary.total, 'merged event');
const timelineEventLabel = t('workflowDetail.mergedEvent', { count: timelineSummary.total });
const statusLabel = humanizeToken(workflow.status || 'unknown');
const selectionMethodLabel = selectionMethod ? humanizeToken(selectionMethod) : 'Not recorded';
const selectionMethodLabel = selectionMethod ? humanizeToken(selectionMethod) : t('workflowDetail.notRecorded');
const successRateLabel = formatPercent(workflow.success_rate);
return (
@ -510,7 +508,7 @@ export default function WorkflowDetailPage() {
to="/workflows"
className="workflow-chip text-sm transition-colors hover:border-[color:var(--color-border-dark)] hover:text-ink"
>
Back to Workflows
{t('workflowDetail.backToWorkflows')}
</Link>
<WorkflowChip className={getStatusChipClasses(workflow.status)}>{statusLabel}</WorkflowChip>
<WorkflowChip>{selectedSkillLabel}</WorkflowChip>
@ -518,36 +516,36 @@ export default function WorkflowDetailPage() {
</div>
<div className="space-y-3">
<div className="workflow-kicker">Workflow detail</div>
<div className="workflow-kicker">{t('workflowDetail.workflowDetail')}</div>
<h1 className="max-w-5xl text-4xl font-semibold leading-[1.05] tracking-[-0.05em] text-ink lg:text-5xl xl:text-[3.6rem]">
{workflow.task_name}
</h1>
<p className="workflow-copy max-w-4xl text-lg leading-8 text-muted line-clamp-4">
{formatInstruction(workflow.instruction, 480)}
{formatInstruction(workflow.instruction, 480, t('format.noInstruction'))}
</p>
</div>
</div>
<div className="workflow-soft-card w-full max-w-sm shrink-0 p-5 space-y-5">
<p className="workflow-copy text-base leading-7 text-muted">
{`${statusLabel} run with ${iterationsLabel} and ${actionCountLabel}.`}
{t('workflowDetail.runDescription', { status: statusLabel, iterations: iterationsLabel, actions: actionCountLabel })}
</p>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1">
<div className="workflow-kicker">Started</div>
<div className="workflow-kicker">{t('workflowDetail.started')}</div>
<div className="text-base font-medium text-ink">{formatDate(workflow.start_time)}</div>
</div>
<div className="space-y-1">
<div className="workflow-kicker">Duration</div>
<div className="workflow-kicker">{t('workflowDetail.duration')}</div>
<div className="text-base font-medium text-ink">{executionDurationLabel}</div>
</div>
<div className="space-y-1">
<div className="workflow-kicker">Steps</div>
<div className="workflow-kicker">{t('workflowDetail.stepsLabel')}</div>
<div className="text-base font-medium text-ink">{totalStepLabel}</div>
</div>
<div className="space-y-1">
<div className="workflow-kicker">Latest event</div>
<div className="workflow-kicker">{t('workflowDetail.latestEvent')}</div>
<div className="text-base font-medium text-ink">{latestEventLabel}</div>
</div>
</div>
@ -556,24 +554,24 @@ export default function WorkflowDetailPage() {
<section className="workflow-metrics-row">
<SummaryMetric
label="Success rate"
label={t('workflowDetail.successRate')}
value={successRateLabel}
hint={`${pluralize(workflow.success_count, 'successful iteration')} out of ${iterationsLabel}`}
hint={t('workflowDetail.successRateHint', { count: workflow.success_count, iterations: iterationsLabel })}
/>
<SummaryMetric
label="Iterations"
label={t('workflowDetail.iterations')}
value={workflow.iterations}
hint={`${executionDurationLabel} total runtime`}
hint={t('workflowDetail.totalRuntime', { duration: executionDurationLabel })}
/>
<SummaryMetric
label="Active backends"
label={t('workflowDetail.activeBackends')}
value={activityEntries.length}
hint={topBackendEntry ? `Most active ${humanizeToken(topBackendEntry[0])} · ${topBackendEntry[1]} events` : 'No recorded tool activity'}
hint={topBackendEntry ? t('workflowDetail.mostActive', { backend: humanizeToken(topBackendEntry[0]), count: topBackendEntry[1] }) : t('workflowDetail.noRecordedActivity')}
/>
<SummaryMetric
label="Timeline events"
label={t('workflowDetail.timelineEvents')}
value={timelineSummary.total}
hint={`${agentActionCount} agent actions · ${toolExecutionCount} tool events`}
hint={t('workflowDetail.agentToolHint', { agentCount: agentActionCount, toolCount: toolExecutionCount })}
/>
</section>
</section>
@ -582,8 +580,8 @@ export default function WorkflowDetailPage() {
<div className="workflow-panel p-5 space-y-4">
{timeline.length === 0 ? (
<QuietEmptyState
title="No timeline data"
description="This session does not yet contain trajectory or agent action records."
title={t('workflowDetail.noTimelineData')}
description={t('workflowDetail.noTimelineDesc')}
/>
) : (
<div role="list" aria-label="Workflow timeline events">
@ -677,7 +675,7 @@ export default function WorkflowDetailPage() {
) : null}
<div className="workflow-soft-card p-3.5">
<div className="text-[11px] uppercase tracking-[0.16em] text-muted">Raw event JSON</div>
<div className="text-[11px] uppercase tracking-[0.16em] text-muted">{t('workflowDetail.rawEventJson')}</div>
<pre className="workflow-json mt-3 whitespace-pre-wrap break-all text-xs leading-6 text-muted">
{stringify(event.details)}
</pre>
@ -697,8 +695,8 @@ export default function WorkflowDetailPage() {
<section className="workflow-panel p-5 space-y-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="workflow-kicker">Selection</div>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-ink">Selected skills</h2>
<div className="workflow-kicker">{t('workflowDetail.selection')}</div>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-ink">{t('workflowDetail.selectedSkills')}</h2>
</div>
{workflow.selected_skills.length > 0 ? <WorkflowChip>{workflow.selected_skills.length}</WorkflowChip> : null}
</div>
@ -718,42 +716,42 @@ export default function WorkflowDetailPage() {
</div>
) : (
<div className="space-y-2">
<div className="text-lg font-semibold tracking-[-0.02em] text-ink">No selected skills</div>
<p className="workflow-copy text-sm leading-6 text-muted">No skills were selected or recorded for this run.</p>
<div className="text-lg font-semibold tracking-[-0.02em] text-ink">{t('workflowDetail.noSelectedSkills')}</div>
<p className="workflow-copy text-sm leading-6 text-muted">{t('workflowDetail.noSelectedSkillsDesc')}</p>
</div>
)}
</section>
<section className="workflow-panel p-5 space-y-5">
<div>
<div className="workflow-kicker">Session</div>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-ink">Overview</h2>
<div className="workflow-kicker">{t('workflowDetail.session')}</div>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-ink">{t('workflowDetail.overview')}</h2>
</div>
<div className="space-y-5">
<SidebarRow label="Task ID">
<SidebarRow label={t('workflowDetail.taskId')}>
<div className="break-all">{workflow.task_id}</div>
</SidebarRow>
<SidebarRow label="Runtime">
<SidebarRow label={t('workflowDetail.runtime')}>
<div>{executionDurationLabel}</div>
<div className="text-xs leading-6 text-muted">
{iterationsLabel} · {totalStepLabel} · {actionCountLabel}
</div>
</SidebarRow>
<SidebarRow label="Window">
<SidebarRow label={t('workflowDetail.window')}>
<div>{formatDate(workflow.start_time)}</div>
<div className="text-xs leading-6 text-muted">Ended {formatDate(workflow.end_time)}</div>
<div className="text-xs leading-6 text-muted">{t('workflowDetail.ended', { date: formatDate(workflow.end_time) })}</div>
</SidebarRow>
<SidebarRow label="Selection method">
<SidebarRow label={t('workflowDetail.selectionMethod')}>
<div>{selectionMethodLabel}</div>
<div className="text-xs leading-6 text-muted">{selectedSkillLabel}</div>
</SidebarRow>
{enabledBackends.length > 0 ? (
<SidebarRow label="Enabled backends">
<SidebarRow label={t('workflowDetail.enabledBackends')}>
<div className="flex flex-wrap gap-2 text-xs">
{enabledBackends.map((backend) => (
<WorkflowChip key={backend}>{humanizeToken(backend)}</WorkflowChip>
@ -763,7 +761,7 @@ export default function WorkflowDetailPage() {
) : null}
{activityEntries.length > 0 ? (
<SidebarRow label="Backend activity">
<SidebarRow label={t('workflowDetail.backendActivity')}>
<div className="flex flex-wrap gap-2 text-xs">
{activityEntries.map(([backend, count]) => (
<WorkflowChip key={backend}>{`${humanizeToken(backend)} ${count}`}</WorkflowChip>

View file

@ -1,10 +1,12 @@
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { workflowsApi, type WorkflowSummary } from '../api';
import EmptyState from '../components/EmptyState';
import { formatDate, formatInstruction } from '../utils/format';
export default function WorkflowsPage() {
const { t } = useTranslation();
const [workflows, setWorkflows] = useState<WorkflowSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -22,7 +24,7 @@ export default function WorkflowsPage() {
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load workflows');
setError(err instanceof Error ? err.message : t('workflows.failedToLoad'));
}
} finally {
if (!cancelled) {
@ -34,7 +36,7 @@ export default function WorkflowsPage() {
return () => {
cancelled = true;
};
}, []);
}, [t]);
const filtered = useMemo(() => {
const normalized = query.trim().toLowerCase();
@ -56,34 +58,34 @@ export default function WorkflowsPage() {
<div className="p-6 space-y-6">
<div className="flex items-end justify-between gap-4">
<div>
<h1 className="text-3xl font-bold font-serif">Recorded sessions</h1>
<h1 className="text-3xl font-bold font-serif">{t('workflows.title')}</h1>
</div>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search by task name or instruction"
placeholder={t('workflows.searchPlaceholder')}
className="px-3 py-2 min-w-[320px]"
/>
</div>
<section className="grid grid-cols-2 gap-4">
<div className="p-4 space-y-2">
<div className="text-xs uppercase tracking-[0.16em] text-muted">Workflow Sessions</div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('workflows.workflowSessions')}</div>
<div className="text-3xl font-bold font-serif leading-none">{workflows.length}</div>
<div className="text-xs text-muted">Scanned from logs/recordings and logs/trajectories</div>
<div className="text-xs text-muted">{t('workflows.scannedFrom')}</div>
</div>
<div className="p-4 space-y-2">
<div className="text-xs uppercase tracking-[0.16em] text-muted">Average Success</div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('workflows.averageSuccess')}</div>
<div className="text-3xl font-bold font-serif leading-none">{averageSuccess}%</div>
<div className="text-xs text-muted">Mean success rate across sessions</div>
<div className="text-xs text-muted">{t('workflows.meanSuccessRate')}</div>
</div>
</section>
{loading ? <div className="text-sm text-muted">Loading workflows</div> : null}
{loading ? <div className="text-sm text-muted">{t('workflows.loadingWorkflows')}</div> : null}
{error ? <div className="text-sm text-danger">{error}</div> : null}
{!loading && !error && filtered.length === 0 ? (
<EmptyState title="No workflow sessions" description="Run `openspace` with recording enabled, then refresh this page." />
<EmptyState title={t('workflows.noSessions')} description={t('workflows.noSessionsDesc')} />
) : null}
{!loading && !error && filtered.length > 0 ? (
@ -96,13 +98,13 @@ export default function WorkflowsPage() {
</div>
<div className="text-right shrink-0">
<div className="text-lg font-bold font-serif">{(workflow.success_rate * 100).toFixed(1)}%</div>
<div className="text-xs text-muted">success</div>
<div className="text-xs text-muted">{t('common.success')}</div>
</div>
</div>
<div className="text-sm text-muted line-clamp-2">{formatInstruction(workflow.instruction, 220)}</div>
<div className="text-sm text-muted line-clamp-2">{formatInstruction(workflow.instruction, 220, t('format.noInstruction'))}</div>
<div className="grid grid-cols-3 gap-3 text-xs text-muted">
<div>{workflow.total_steps} steps</div>
<div>{workflow.agent_action_count} agent actions</div>
<div>{t('common.steps', { count: workflow.total_steps })}</div>
<div>{t('common.agentActions', { count: workflow.agent_action_count })}</div>
<div>{formatDate(workflow.start_time)}</div>
</div>
{workflow.selected_skills.length > 0 ? (
@ -114,7 +116,7 @@ export default function WorkflowsPage() {
))}
{workflow.selected_skills.length > 3 ? (
<span className="tag px-2 py-1 text-muted">
+{workflow.selected_skills.length - 3} more
{t('workflows.more', { count: workflow.selected_skills.length - 3 })}
</span>
) : null}
</div>

View file

@ -56,8 +56,9 @@ function shortenPaths(text: string, keep = 3): string {
export function formatInstruction(
raw: string | null | undefined,
maxLen?: number,
fallback = 'No instruction captured',
): string {
if (!raw) return 'No instruction captured';
if (!raw) return fallback;
let text = shortenPaths(raw);

View file

@ -3,10 +3,24 @@
# Copy this file to .env and fill in your keys
# ============================================
# ---- LLM API Keys ----
# At least one LLM API key is required for OpenSpace to function.
# OpenSpace uses LiteLLM for model routing, so the key you need depends on your chosen model.
# See https://docs.litellm.ai/docs/providers for supported providers.
# ── LLM Credentials ──────────────────────────────────────
#
# OpenSpace resolves LLM credentials in this order (first match wins):
#
# 1. OPENSPACE_LLM_* — explicit override, always highest priority
# 2. Provider-native vars — OPENROUTER_API_KEY, OPENAI_API_KEY, etc.
# 3. ~/.nanobot/config.json or ~/.openclaw/openclaw.json — fallback (only when no explicit or provider key found)
#
# For most users, setting ONE of the provider-native keys below is enough.
# LiteLLM reads them automatically. See https://docs.litellm.ai/docs/providers
#
# Full configuration guide: openspace/config/README.md
# --- Option A: Provider-native key (simplest) ---
# Set the key that matches your model's provider:
# OpenRouter (for openrouter/* models, e.g. openrouter/anthropic/claude-sonnet-4.5)
OPENROUTER_API_KEY=
# Anthropic (for anthropic/claude-* models)
# ANTHROPIC_API_KEY=
@ -14,8 +28,22 @@
# OpenAI (for openai/gpt-* models)
# OPENAI_API_KEY=
# OpenRouter (for openrouter/* models, e.g. openrouter/anthropic/claude-sonnet-4.5)
OPENROUTER_API_KEY=
# DeepSeek (for deepseek/* models)
# DEEPSEEK_API_KEY=
# --- Option B: Explicit OpenSpace override (takes priority over Option A) ---
# Use these when you need full control, e.g. custom API base or non-standard provider.
# OPENSPACE_MODEL=openrouter/anthropic/claude-sonnet-4.5
# OPENSPACE_LLM_API_KEY=sk-xxx
# OPENSPACE_LLM_API_BASE=https://openrouter.ai/api/v1
# --- Option C: Local Ollama ---
# For ollama/* models, set OPENSPACE_MODEL and the local Ollama endpoint.
#
# OPENSPACE_MODEL=ollama/qwen3-coder:30b
# OLLAMA_API_BASE=http://127.0.0.1:11434
# OLLAMA_API_KEY=ollama
# ── OpenSpace Cloud (optional) ──────────────────────────────
# Register at https://open-space.cloud to get your key.
@ -23,28 +51,25 @@ OPENROUTER_API_KEY=
OPENSPACE_API_KEY=sk_xxxxxxxxxxxxxxxx
# ---- GUI Backend (Anthropic Computer Use) ----
# Required only if using the GUI backend. Uses the same ANTHROPIC_API_KEY above.
# ── GUI Backend (optional) ──────────────────────────────────
# Required only if using the GUI backend (Anthropic Computer Use).
# Uses the same ANTHROPIC_API_KEY above.
# Optional backup key for rate limit fallback:
# ANTHROPIC_API_KEY_BACKUP=
# ---- Web Backend (Deep Research) ----
# Required only if using the Web backend for deep research.
# Uses OpenRouter API by default:
# OPENROUTER_API_KEY=
# ---- Embedding (Optional) ----
# ── Embedding (optional) ────────────────────────────────────
# For remote embedding API instead of local model.
# If not set, OpenSpace uses a local embedding model (BAAI/bge-small-en-v1.5).
# EMBEDDING_BASE_URL=
# EMBEDDING_API_KEY=
# EMBEDDING_MODEL= "openai/text-embedding-3-small"
# EMBEDDING_MODEL=openai/text-embedding-3-small
# ---- E2B Sandbox (Optional) ----
# ── E2B Sandbox (optional) ──────────────────────────────────
# Required only if sandbox mode is enabled in security config.
# E2B_API_KEY=
# ---- Local Server (Optional) ----
# ── Local Server (optional) ─────────────────────────────────
# Override the default local server URL (default: http://127.0.0.1:5000)
# Useful for remote VM integration (e.g., OSWorld).
# LOCAL_SERVER_URL=http://127.0.0.1:5000

View file

@ -158,6 +158,43 @@ def _create_argument_parser() -> argparse.ArgumentParser:
'--config', '-c', type=str,
help='MCP configuration file path'
)
communication_parser = subparsers.add_parser(
'communication',
help='Run the communication gateway'
)
communication_parser.add_argument(
'--config',
type=str,
dest='communication_config',
help='Communication configuration file path'
)
communication_subparsers = communication_parser.add_subparsers(
dest='communication_command',
help='Communication gateway commands'
)
communication_run_parser = communication_subparsers.add_parser(
'run',
help='Start the communication gateway'
)
communication_run_parser.add_argument(
'--config',
type=str,
dest='communication_config',
help='Communication configuration file path'
)
communication_health_parser = communication_subparsers.add_parser(
'health',
help='Check the communication gateway health endpoint'
)
communication_health_parser.add_argument(
'--config',
type=str,
dest='communication_config',
help='Communication configuration file path'
)
communication_health_parser.add_argument('--host', type=str, default=None)
communication_health_parser.add_argument('--port', type=int, default=None)
# Basic arguments (for run mode)
parser.add_argument('--config', '-c', type=str, help='Configuration file path (JSON format)')
@ -321,16 +358,47 @@ async def refresh_mcp_cache(config_path: Optional[str] = None):
def _load_config(args) -> OpenSpaceConfig:
"""Load configuration"""
import os
from openspace.host_detection import (
build_grounding_config_path,
build_llm_kwargs,
load_runtime_env,
)
load_runtime_env()
cli_overrides = {}
if args.model:
cli_overrides['llm_model'] = args.model
if args.max_iterations is not None:
cli_overrides['grounding_max_iterations'] = args.max_iterations
if args.timeout is not None:
cli_overrides['llm_timeout'] = args.timeout
if args.log_level:
cli_overrides['log_level'] = args.log_level
# Resolve LLM model & credentials
# CLI --model > OPENSPACE_MODEL env > host-agent auto-detect > default
env_model = args.model or os.environ.get("OPENSPACE_MODEL", "")
model, llm_kwargs = build_llm_kwargs(env_model)
cli_overrides['llm_model'] = model
cli_overrides['llm_kwargs'] = llm_kwargs
max_iter = int(os.environ.get("OPENSPACE_MAX_ITERATIONS", "20"))
enable_rec = os.environ.get("OPENSPACE_ENABLE_RECORDING", "true").lower() in ("true", "1", "yes")
backend_scope_raw = os.environ.get("OPENSPACE_BACKEND_SCOPE")
backend_scope = (
[b.strip() for b in backend_scope_raw.split(",") if b.strip()]
if backend_scope_raw else None
)
config_path = build_grounding_config_path()
if 'grounding_max_iterations' not in cli_overrides:
cli_overrides['grounding_max_iterations'] = max_iter
cli_overrides['enable_recording'] = enable_rec
if backend_scope is not None:
cli_overrides['backend_scope'] = backend_scope
if config_path:
cli_overrides['grounding_config_path'] = config_path
try:
# Load from config file if provided
if args.config:
@ -338,18 +406,17 @@ def _load_config(args) -> OpenSpaceConfig:
with open(args.config, 'r', encoding='utf-8') as f:
config_dict = json.load(f)
# Apply CLI overrides
# Apply CLI / env overrides
config_dict.update(cli_overrides)
config = OpenSpaceConfig(**config_dict)
print(f"✓ Loaded from config file: {args.config}")
else:
# Use default config + CLI overrides
config = OpenSpaceConfig(**cli_overrides)
print("✓ Using default configuration")
if cli_overrides:
print(f"✓ CLI overrides: {', '.join(cli_overrides.keys())}")
if args.model:
print(f"✓ CLI overrides: llm_model")
if args.log_level:
Logger.set_level(args.log_level)
@ -414,6 +481,20 @@ async def main():
if args.command == 'refresh-cache':
await refresh_mcp_cache(args.config)
return 0
if args.command == 'communication':
from openspace.communication.gateway import main as communication_main
communication_argv = []
if args.communication_config:
communication_argv.extend(['--config', args.communication_config])
if args.communication_command:
communication_argv.append(args.communication_command)
if args.communication_command == 'health':
if args.host:
communication_argv.extend(['--host', args.host])
if args.port is not None:
communication_argv.extend(['--port', str(args.port)])
return await communication_main(communication_argv)
# Load configuration
config = _load_config(args)
@ -470,4 +551,4 @@ def run_main():
if __name__ == "__main__":
run_main()
run_main()

View file

@ -5,8 +5,15 @@ import json
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from openspace.agents.base import BaseAgent
from openspace.agents.message_utils import (
ITERATION_GUIDANCE_PREFIX,
build_channel_context_message,
cap_message_content,
normalize_external_history,
truncate_messages,
)
from openspace.agents.visual_analyzer import VisualAnalyzer
from openspace.grounding.core.types import BackendType, ToolResult
from openspace.platform.screenshot import ScreenshotClient
from openspace.prompts import GroundingAgentPrompts
from openspace.utils.logging import Logger
@ -20,6 +27,7 @@ logger = Logger.get_logger(__name__)
class GroundingAgent(BaseAgent):
def __init__(
self,
name: str = "GroundingAgent",
@ -58,9 +66,12 @@ class GroundingAgent(BaseAgent):
self._system_prompt = system_prompt or self._default_system_prompt()
self._max_iterations = max_iterations
self._visual_analysis_timeout = visual_analysis_timeout
self._tool_retrieval_llm = tool_retrieval_llm
self._visual_analysis_model = visual_analysis_model
self._visual_analyzer = VisualAnalyzer(
llm_client=llm_client,
visual_analysis_model=visual_analysis_model,
visual_analysis_timeout=visual_analysis_timeout,
)
# Skill context injection (set externally before process())
self._skill_context: Optional[str] = None
@ -75,7 +86,7 @@ class GroundingAgent(BaseAgent):
logger.info(f"Grounding Agent initialized: {name}")
logger.info(f"Backend scope: {self._backend_scope}")
logger.info(f"Max iterations: {self._max_iterations}")
logger.info(f"Visual analysis timeout: {self._visual_analysis_timeout}s")
logger.info(f"Visual analysis timeout: {visual_analysis_timeout}s")
if tool_retrieval_llm:
logger.info(f"Tool retrieval model: {tool_retrieval_llm.model}")
if visual_analysis_model:
@ -119,82 +130,6 @@ class GroundingAgent(BaseAgent):
count = len(registry.list_skills())
logger.info(f"Skill registry attached ({count} skill(s) available for mid-iteration retrieval)")
_MAX_SINGLE_CONTENT_CHARS = 30_000
@classmethod
def _cap_message_content(cls, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Truncate oversized individual message contents in-place.
Targets tool-result messages and assistant messages that can
carry enormous file contents (read_file on large CSVs/scripts).
System messages and the first user instruction are never touched.
"""
cap = cls._MAX_SINGLE_CONTENT_CHARS
trimmed = 0
for msg in messages:
content = msg.get("content")
if not isinstance(content, str) or len(content) <= cap:
continue
if msg.get("role") == "system":
continue
original_len = len(content)
msg["content"] = (
content[: cap // 2]
+ f"\n\n... [truncated {original_len - cap:,} chars] ...\n\n"
+ content[-(cap // 2):]
)
trimmed += 1
if trimmed:
logger.info(f"Capped {trimmed} oversized message(s) to {cap:,} chars each")
return messages
def _truncate_messages(
self,
messages: List[Dict[str, Any]],
keep_recent: int = 8,
max_tokens_estimate: int = 120000
) -> List[Dict[str, Any]]:
# First: cap any single oversized message to prevent one huge
# tool-result from dominating the context window.
messages = self._cap_message_content(messages)
if len(messages) <= keep_recent + 2: # +2 for system and initial user
return messages
total_text = json.dumps(messages, ensure_ascii=False)
estimated_tokens = len(total_text) // 4
if estimated_tokens < max_tokens_estimate:
return messages
logger.info(f"Truncating message history: {len(messages)} messages, "
f"~{estimated_tokens:,} tokens -> keeping recent {keep_recent} rounds")
system_messages = []
user_instruction = None
conversation_messages = []
for msg in messages:
role = msg.get("role")
if role == "system":
system_messages.append(msg)
elif role == "user" and user_instruction is None:
user_instruction = msg
else:
conversation_messages.append(msg)
recent_messages = conversation_messages[-(keep_recent * 2):] if conversation_messages else []
truncated = system_messages.copy()
if user_instruction:
truncated.append(user_instruction)
truncated.extend(recent_messages)
logger.info(f"After truncation: {len(truncated)} messages, "
f"~{len(json.dumps(truncated, ensure_ascii=False))//4:,} tokens (estimated)")
return truncated
async def process(self, context: Dict[str, Any]) -> Dict[str, Any]:
"""
Process a task execution request with multi-round iteration control.
@ -276,6 +211,14 @@ class GroundingAgent(BaseAgent):
tools=tools,
)
async def _va_callback(
result: ToolResult, tool_name: str, tool_call: Dict, backend: str
) -> ToolResult:
return await self._visual_analyzer.analyze_tool_result(
result, tool_name, tool_call, backend,
task_description=instruction,
)
try:
while current_iteration < max_iterations:
current_iteration += 1
@ -295,15 +238,15 @@ class GroundingAgent(BaseAgent):
# Cap oversized individual messages every iteration to prevent
# a single huge tool result from ballooning all subsequent calls.
if current_iteration >= 2:
messages = self._cap_message_content(messages)
messages = cap_message_content(messages)
# Truncate message history to prevent context length issues
# Start truncating after 5 iterations to keep context manageable
if current_iteration >= 5:
messages = self._truncate_messages(
messages,
messages = truncate_messages(
messages,
keep_recent=8,
max_tokens_estimate=120000
max_tokens_estimate=120000,
)
messages_input_snapshot = copy.deepcopy(messages)
@ -325,7 +268,7 @@ class GroundingAgent(BaseAgent):
tools=tools if context.get("auto_execute", True) else None,
execute_tools=context.get("auto_execute", True),
summary_prompt=None, # Disabled
tool_result_callback=self._visual_analysis_callback
tool_result_callback=_va_callback,
)
# Update messages with LLM response
@ -349,7 +292,7 @@ class GroundingAgent(BaseAgent):
f"Tool results: {len(tool_results_this_iteration)}, "
f"Content length: {len(assistant_content)} chars")
if len(assistant_content) > 0:
if len(assistant_content.strip()) > 0:
logger.info(f"Iteration {current_iteration} - Assistant content preview: {repr(assistant_content[:300])}")
consecutive_empty_responses = 0 # Reset counter on valid response
else:
@ -414,13 +357,19 @@ class GroundingAgent(BaseAgent):
# Remove previous iteration guidance to avoid accumulation
messages = [
msg for msg in messages
if not (msg.get("role") == "system" and "Iteration" in msg.get("content", "") and "complete" in msg.get("content", ""))
msg for msg in messages
if not (
isinstance(msg.get("content"), str)
and msg.get("content", "").startswith(ITERATION_GUIDANCE_PREFIX)
)
]
# MiniMax rejects system messages injected mid-conversation,
# so runtime guidance is sent as an internal user note.
guidance_msg = {
"role": "system",
"content": f"Iteration {current_iteration} complete. "
"role": "user",
"content": f"{ITERATION_GUIDANCE_PREFIX}\n"
f"Iteration {current_iteration} complete. "
f"Check if task is finished - if yes, output {GroundingAgentPrompts.TASK_COMPLETE}. "
f"If not, continue with next action."
}
@ -516,7 +465,16 @@ class GroundingAgent(BaseAgent):
"role": "system",
"content": artifact_msg
})
channel_context_msg = build_channel_context_message(
context.get("channel_context")
)
if channel_context_msg:
messages.append({
"role": "system",
"content": channel_context_msg,
})
# Skill injection — only active (selected) skills, full content
if self._skill_context:
messages.append({
@ -524,10 +482,20 @@ class GroundingAgent(BaseAgent):
"content": self._skill_context
})
logger.info(f"Injected active skill context ({len(self._active_skill_ids)} skill(s))")
external_history = normalize_external_history(
context.get("conversation_history")
)
if external_history:
messages.extend(external_history)
logger.info(
"Injected %d external conversation message(s)",
len(external_history),
)
# User instruction
messages.append({"role": "user", "content": instruction})
return messages
async def _get_available_tools(self, task_description: Optional[str]) -> List:
@ -613,218 +581,6 @@ class GroundingAgent(BaseAgent):
)
return all_tools
async def _visual_analysis_callback(
self,
result: ToolResult,
tool_name: str,
tool_call: Dict,
backend: str
) -> ToolResult:
"""
Callback for LLMClient to handle visual analysis after tool execution.
"""
# 1. Check if LLM requested to skip visual analysis
skip_visual_analysis = False
try:
arguments = tool_call.function.arguments
if isinstance(arguments, str):
args = json.loads(arguments.strip() or "{}")
else:
args = arguments
if isinstance(args, dict) and args.get("skip_visual_analysis"):
skip_visual_analysis = True
logger.info(f"Visual analysis skipped for {tool_name} (meta-parameter set by LLM)")
except Exception as e:
logger.debug(f"Could not parse tool arguments: {e}")
# 2. If skip requested, return original result
if skip_visual_analysis:
return result
# 3. Check if this backend needs visual analysis
if backend != "gui":
return result
# 4. Check if tool has visual data
metadata = getattr(result, 'metadata', None)
has_screenshots = metadata and (metadata.get("screenshot") or metadata.get("screenshots"))
# 5. If no visual data, try to capture a screenshot
if not has_screenshots:
try:
logger.info(f"No visual data from {tool_name}, capturing screenshot...")
screenshot_client = ScreenshotClient()
screenshot_bytes = await screenshot_client.capture()
if screenshot_bytes:
# Add screenshot to result metadata
if metadata is None:
result.metadata = {}
metadata = result.metadata
metadata["screenshot"] = screenshot_bytes
has_screenshots = True
logger.info(f"Screenshot captured for visual analysis")
else:
logger.warning("Failed to capture screenshot")
except Exception as e:
logger.warning(f"Error capturing screenshot: {e}")
# 6. If still no screenshots, return original result
if not has_screenshots:
logger.debug(f"No visual data available for {tool_name}")
return result
# 7. Perform visual analysis
return await self._enhance_result_with_visual_context(result, tool_name)
async def _enhance_result_with_visual_context(
self,
result: ToolResult,
tool_name: str
) -> ToolResult:
"""
Enhance tool result with visual analysis for grounding agent workflows.
"""
import asyncio
import base64
import litellm
try:
metadata = getattr(result, 'metadata', None)
if not metadata:
return result
# Collect all screenshots
screenshots_bytes = []
# Check for multiple screenshots first
if metadata.get("screenshots"):
screenshots_list = metadata["screenshots"]
if isinstance(screenshots_list, list):
screenshots_bytes = [s for s in screenshots_list if s]
# Fall back to single screenshot
elif metadata.get("screenshot"):
screenshots_bytes = [metadata["screenshot"]]
if not screenshots_bytes:
return result
# Select key screenshots if there are too many
selected_screenshots = self._select_key_screenshots(screenshots_bytes, max_count=3)
# Convert to base64
visual_b64_list = []
for visual_data in selected_screenshots:
if isinstance(visual_data, bytes):
visual_b64_list.append(base64.b64encode(visual_data).decode('utf-8'))
else:
visual_b64_list.append(visual_data) # Already base64
# Build prompt based on number of screenshots
num_screenshots = len(visual_b64_list)
prompt = GroundingAgentPrompts.visual_analysis(
tool_name=tool_name,
num_screenshots=num_screenshots,
task_description=getattr(self, '_current_instruction', '')
)
# Build content with text prompt + all images
content = [{"type": "text", "text": prompt}]
for visual_b64 in visual_b64_list:
content.append({
"type": "image_url",
"image_url": {
"url": f"data:image/png;base64,{visual_b64}"
}
})
# Use dedicated visual analysis model if configured, otherwise use main LLM model
visual_model = self._visual_analysis_model or (self._llm_client.model if self._llm_client else "openrouter/anthropic/claude-sonnet-4.5")
response = await asyncio.wait_for(
litellm.acompletion(
model=visual_model,
messages=[{
"role": "user",
"content": content
}],
timeout=self._visual_analysis_timeout
),
timeout=self._visual_analysis_timeout + 5
)
analysis = response.choices[0].message.content.strip()
# Inject visual analysis into content
original_content = result.content or "(no text output)"
enhanced_content = f"{original_content}\n\n**Visual content**: {analysis}"
# Create enhanced result
enhanced_result = ToolResult(
status=result.status,
content=enhanced_content,
error=result.error,
metadata={**metadata, "visual_analyzed": True, "visual_analysis": analysis},
execution_time=result.execution_time
)
logger.info(f"Enhanced {tool_name} result with visual analysis ({num_screenshots} screenshot(s))")
return enhanced_result
except asyncio.TimeoutError:
logger.warning(f"Visual analysis timed out for {tool_name}, returning original result")
return result
except Exception as e:
logger.warning(f"Failed to analyze visual content for {tool_name}: {e}")
return result
def _select_key_screenshots(
self,
screenshots: List[bytes],
max_count: int = 3
) -> List[bytes]:
"""
Select key screenshots if there are too many.
"""
if len(screenshots) <= max_count:
return screenshots
selected_indices = set()
# Always include last (final state)
selected_indices.add(len(screenshots) - 1)
# If room, include first (initial state)
if max_count >= 2:
selected_indices.add(0)
# Fill remaining slots with evenly spaced middle screenshots
remaining_slots = max_count - len(selected_indices)
if remaining_slots > 0:
# Calculate spacing
available_indices = [
i for i in range(1, len(screenshots) - 1)
if i not in selected_indices
]
if available_indices:
step = max(1, len(available_indices) // (remaining_slots + 1))
for i in range(remaining_slots):
idx = min((i + 1) * step, len(available_indices) - 1)
if idx < len(available_indices):
selected_indices.add(available_indices[idx])
# Return screenshots in original order
selected = [screenshots[i] for i in sorted(selected_indices)]
logger.debug(
f"Selected {len(selected)} screenshots at indices {sorted(selected_indices)} "
f"from total of {len(screenshots)}"
)
return selected
def _get_workspace_path(self, context: Dict[str, Any]) -> Optional[str]:
"""
Get workspace directory path from context.
@ -1209,4 +965,4 @@ class GroundingAgent(BaseAgent):
"step": self.step,
"instruction": instruction,
}
)
)

View file

@ -0,0 +1,227 @@
from __future__ import annotations
import json
from typing import Any, Dict, List, Optional, Set
from openspace.prompts import GroundingAgentPrompts
from openspace.utils.logging import Logger
logger = Logger.get_logger(__name__)
SUPPORTED_EXTERNAL_HISTORY_ROLES: Set[str] = {"user", "assistant"}
MAX_SINGLE_CONTENT_CHARS: int = 30_000
ITERATION_GUIDANCE_PREFIX: str = "[INTERNAL ORCHESTRATION NOTE]"
def cap_message_content(
messages: List[Dict[str, Any]],
max_chars: int = MAX_SINGLE_CONTENT_CHARS,
) -> List[Dict[str, Any]]:
"""Truncate oversized individual message contents in-place.
Targets tool-result messages and assistant messages that can
carry enormous file contents (read_file on large CSVs/scripts).
System messages and the first user instruction are never touched.
"""
trimmed = 0
for msg in messages:
content = msg.get("content")
if not isinstance(content, str) or len(content) <= max_chars:
continue
if msg.get("role") == "system":
continue
original_len = len(content)
msg["content"] = (
content[: max_chars // 2]
+ f"\n\n... [truncated {original_len - max_chars:,} chars] ...\n\n"
+ content[-(max_chars // 2) :]
)
trimmed += 1
if trimmed:
logger.info(f"Capped {trimmed} oversized message(s) to {max_chars:,} chars each")
return messages
def truncate_messages(
messages: List[Dict[str, Any]],
keep_recent: int = 8,
max_tokens_estimate: int = 120_000,
guidance_prefix: str = ITERATION_GUIDANCE_PREFIX,
) -> List[Dict[str, Any]]:
"""Truncate conversation history to fit within token budget.
Preserves system messages and the first user instruction while
keeping only the most recent conversation turns.
"""
messages = cap_message_content(messages)
if len(messages) <= keep_recent + 2: # +2 for system and initial user
return messages
total_text = json.dumps(messages, ensure_ascii=False)
estimated_tokens = len(total_text) // 4
if estimated_tokens < max_tokens_estimate:
return messages
logger.info(
f"Truncating message history: {len(messages)} messages, "
f"~{estimated_tokens:,} tokens -> keeping recent {keep_recent} rounds"
)
system_messages: List[Dict[str, Any]] = []
user_instruction: Optional[Dict[str, Any]] = None
conversation_messages: List[Dict[str, Any]] = []
for msg in messages:
role = msg.get("role")
if role == "system":
system_messages.append(msg)
elif role == "user" and user_instruction is None:
user_instruction = msg
else:
conversation_messages.append(msg)
recent_messages = (
conversation_messages[-(keep_recent * 2) :] if conversation_messages else []
)
truncated = system_messages.copy()
dropped = len(conversation_messages) - len(recent_messages)
if dropped > 0:
truncated.append(
{
"role": "system",
"content": (
f"{guidance_prefix} {dropped} earlier messages were "
"truncated to save context. The original task instruction "
"is preserved below."
),
}
)
if user_instruction:
truncated.append(user_instruction)
truncated.extend(recent_messages)
logger.info(
f"After truncation: {len(truncated)} messages, "
f"~{len(json.dumps(truncated, ensure_ascii=False)) // 4:,} tokens (estimated)"
)
return truncated
def normalize_external_history(
conversation_history: Any,
supported_roles: Set[str] = SUPPORTED_EXTERNAL_HISTORY_ROLES,
) -> List[Dict[str, str]]:
"""Normalize external conversation history into ``{role, content}`` dicts."""
if not isinstance(conversation_history, list):
return []
normalized: List[Dict[str, str]] = []
for entry in conversation_history:
if not isinstance(entry, dict):
continue
role = str(entry.get("role", "")).strip().lower()
if role not in supported_roles:
continue
content = entry.get("content")
if isinstance(content, list):
parts: List[str] = []
for item in content:
if isinstance(item, dict):
text = item.get("text")
if isinstance(text, str) and text.strip():
parts.append(text.strip())
elif isinstance(item, str) and item.strip():
parts.append(item.strip())
content = "\n".join(parts).strip()
elif content is not None:
content = str(content).strip()
if not content:
continue
normalized.append({"role": role, "content": content})
return normalized
def build_channel_context_message(channel_context: Any) -> Optional[str]:
"""Build a system message describing the communication channel context."""
if not isinstance(channel_context, dict):
return None
lines = [
"## Channel Context",
]
platform = str(channel_context.get("platform", "")).strip()
chat_type = str(channel_context.get("chat_type", "")).strip()
chat_id = str(channel_context.get("chat_id", "")).strip()
chat_name = str(channel_context.get("chat_name", "")).strip()
thread_id = str(channel_context.get("thread_id", "")).strip()
user_name = str(channel_context.get("user_name", "")).strip()
user_id = str(channel_context.get("user_id", "")).strip()
session_key = str(channel_context.get("session_key", "")).strip()
message_id = str(channel_context.get("message_id", "")).strip()
reply_to_message_id = str(channel_context.get("reply_to_message_id", "")).strip()
reply_to_text = str(channel_context.get("reply_to_text", "")).strip()
if platform:
lines.append(f"- Platform: {platform}")
if chat_type:
lines.append(f"- Chat type: {chat_type}")
if chat_id:
lines.append(f"- Chat ID: {chat_id}")
if chat_name:
lines.append(f"- Chat name: {chat_name}")
if thread_id:
lines.append(f"- Thread ID: {thread_id}")
if user_name:
lines.append(f"- User: {user_name}")
elif user_id:
lines.append(f"- User ID: {user_id}")
if session_key:
lines.append(f"- Session key: {session_key}")
if message_id:
lines.append(f"- Message ID: {message_id}")
if reply_to_message_id:
lines.append(f"- Reply-to message ID: {reply_to_message_id}")
if reply_to_text:
lines.append(f"- Reply context: {reply_to_text[:500]}")
lines.extend(
[
"",
"## Chat Reply Policy",
"- If the user is making simple conversation, answer directly in natural language.",
"- Do not call tools for greetings, acknowledgements, thanks, or brief "
"clarifications that can be answered from the current context.",
f"- When you reply directly without tools, include "
f"`{GroundingAgentPrompts.TASK_COMPLETE}` at the end of your response.",
]
)
attachments = channel_context.get("attachments")
if isinstance(attachments, list) and attachments:
lines.append("- Attachments:")
for attachment in attachments:
if not isinstance(attachment, dict):
continue
path = str(attachment.get("path", "")).strip()
if not path:
continue
kind = str(attachment.get("kind", "file")).strip() or "file"
name = str(attachment.get("name", "")).strip()
label = f"{kind}: {path}"
if name:
label += f" ({name})"
lines.append(f" - {label}")
if len(lines) == 1:
return None
return "\n".join(lines)

View file

@ -0,0 +1,250 @@
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from openspace.grounding.core.types import ToolResult
from openspace.platforms.screenshot import ScreenshotClient
from openspace.prompts import GroundingAgentPrompts
from openspace.utils.logging import Logger
if TYPE_CHECKING:
from openspace.llm import LLMClient
logger = Logger.get_logger(__name__)
class VisualAnalyzer:
"""Handles screenshot capture and LLM-based visual analysis of tool results."""
def __init__(
self,
llm_client: Optional[LLMClient] = None,
visual_analysis_model: Optional[str] = None,
visual_analysis_timeout: float = 30.0,
) -> None:
self._llm_client = llm_client
self._visual_analysis_model = visual_analysis_model
self._visual_analysis_timeout = visual_analysis_timeout
async def analyze_tool_result(
self,
result: ToolResult,
tool_name: str,
tool_call: Dict,
backend: str,
task_description: str = "",
) -> ToolResult:
"""Callback for LLMClient to handle visual analysis after tool execution."""
skip_visual_analysis = False
try:
arguments = tool_call.function.arguments
if isinstance(arguments, str):
args = json.loads(arguments.strip() or "{}")
else:
args = arguments
if isinstance(args, dict) and args.get("skip_visual_analysis"):
skip_visual_analysis = True
logger.info(f"Visual analysis skipped for {tool_name} (meta-parameter set by LLM)")
except Exception as e:
logger.debug(f"Could not parse tool arguments: {e}")
if skip_visual_analysis:
return result
if backend != "gui":
return result
metadata = getattr(result, "metadata", None)
has_screenshots = metadata and (
metadata.get("screenshot") or metadata.get("screenshots")
)
if not has_screenshots:
try:
logger.info(f"No visual data from {tool_name}, capturing screenshot...")
screenshot_client = ScreenshotClient()
screenshot_bytes = await screenshot_client.capture()
if screenshot_bytes:
if metadata is None:
result.metadata = {}
metadata = result.metadata
metadata["screenshot"] = screenshot_bytes
has_screenshots = True
logger.info("Screenshot captured for visual analysis")
else:
logger.warning("Failed to capture screenshot")
except Exception as e:
logger.warning(f"Error capturing screenshot: {e}")
if not has_screenshots:
logger.debug(f"No visual data available for {tool_name}")
return result
return await self._enhance_result(result, tool_name, task_description)
async def _enhance_result(
self,
result: ToolResult,
tool_name: str,
task_description: str = "",
) -> ToolResult:
"""Enhance tool result with LLM-based visual analysis."""
import asyncio
import base64
import litellm
try:
metadata = getattr(result, "metadata", None)
if not metadata:
return result
screenshots_bytes: List[bytes] = []
if metadata.get("screenshots"):
screenshots_list = metadata["screenshots"]
if isinstance(screenshots_list, list):
screenshots_bytes = [s for s in screenshots_list if s]
elif metadata.get("screenshot"):
screenshots_bytes = [metadata["screenshot"]]
if not screenshots_bytes:
return result
selected_screenshots = self._select_key_screenshots(
screenshots_bytes, max_count=3
)
visual_b64_list = []
for visual_data in selected_screenshots:
if isinstance(visual_data, bytes):
visual_b64_list.append(
base64.b64encode(visual_data).decode("utf-8")
)
else:
visual_b64_list.append(visual_data)
num_screenshots = len(visual_b64_list)
prompt = GroundingAgentPrompts.visual_analysis(
tool_name=tool_name,
num_screenshots=num_screenshots,
task_description=task_description,
)
content: List[Dict[str, Any]] = [{"type": "text", "text": prompt}]
for visual_b64 in visual_b64_list:
content.append(
{
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{visual_b64}"},
}
)
visual_model = self._visual_analysis_model or (
self._llm_client.model
if self._llm_client
else "openrouter/anthropic/claude-sonnet-4.5"
)
_llm_extra: Dict[str, Any] = {}
if self._llm_client and visual_model == self._llm_client.model:
_llm_extra = (
getattr(self._llm_client, "litellm_kwargs", {}) or {}
)
elif self._visual_analysis_model:
try:
from openspace.host_detection import build_llm_kwargs
visual_model, _llm_extra = build_llm_kwargs(visual_model)
except Exception as e:
logger.debug(
f"Failed to resolve dedicated visual model credentials: {e}"
)
_llm_extra = {}
response = await asyncio.wait_for(
litellm.acompletion(
model=visual_model,
messages=[{"role": "user", "content": content}],
timeout=self._visual_analysis_timeout,
**_llm_extra,
),
timeout=self._visual_analysis_timeout + 5,
)
analysis = response.choices[0].message.content.strip()
original_content = result.content or "(no text output)"
enhanced_content = (
f"{original_content}\n\n**Visual content**: {analysis}"
)
enhanced_result = ToolResult(
status=result.status,
content=enhanced_content,
error=result.error,
metadata={
**metadata,
"visual_analyzed": True,
"visual_analysis": analysis,
},
execution_time=result.execution_time,
)
logger.info(
f"Enhanced {tool_name} result with visual analysis "
f"({num_screenshots} screenshot(s))"
)
return enhanced_result
except asyncio.TimeoutError:
logger.warning(
f"Visual analysis timed out for {tool_name}, returning original result"
)
return result
except Exception as e:
logger.warning(
f"Failed to analyze visual content for {tool_name}: {e}"
)
return result
@staticmethod
def _select_key_screenshots(
screenshots: List[bytes],
max_count: int = 3,
) -> List[bytes]:
"""Select key screenshots from a sequence, preferring first/last/evenly-spaced."""
if len(screenshots) <= max_count:
return screenshots
selected_indices: set[int] = set()
selected_indices.add(len(screenshots) - 1)
if max_count >= 2:
selected_indices.add(0)
remaining_slots = max_count - len(selected_indices)
if remaining_slots > 0:
available_indices = [
i
for i in range(1, len(screenshots) - 1)
if i not in selected_indices
]
if available_indices:
step = max(1, len(available_indices) // (remaining_slots + 1))
for i in range(remaining_slots):
idx = min((i + 1) * step, len(available_indices) - 1)
if idx < len(available_indices):
selected_indices.add(available_indices[idx])
selected = [screenshots[i] for i in sorted(selected_indices)]
logger.debug(
f"Selected {len(selected)} screenshots at indices "
f"{sorted(selected_indices)} from total of {len(screenshots)}"
)
return selected

View file

@ -5,6 +5,7 @@ All methods are **synchronous** (use ``urllib``). In async contexts
Provides both low-level HTTP operations and higher-level workflows:
- ``fetch_record`` / ``download_artifact`` / ``fetch_metadata``
- ``search_record_embeddings``
- ``stage_artifact`` / ``create_record``
- ``upload_skill`` (stage diff create full workflow)
- ``import_skill`` (fetch download extract full workflow)
@ -29,6 +30,7 @@ logger = logging.getLogger("openspace.cloud")
SKILL_FILENAME = "SKILL.md"
SKILL_ID_FILENAME = ".skill_id"
RECORD_EMBEDDING_SEARCH_MAX_LIMIT = 300
_TEXT_EXTENSIONS = frozenset({
".md", ".txt", ".yaml", ".yml", ".json", ".py", ".sh", ".toml",
@ -99,9 +101,26 @@ class OpenSpaceClient:
_, data = self._request("GET", path, timeout=timeout)
return json.loads(data.decode("utf-8"))
@staticmethod
def _normalize_visibility_value(value: Any) -> Any:
"""Treat legacy/group-shared non-public skills as private locally."""
if value == "group_only":
return "private"
return value
@classmethod
def _normalize_record_payload(cls, payload: Dict[str, Any]) -> Dict[str, Any]:
normalized = dict(payload)
if "visibility" in normalized:
normalized["visibility"] = cls._normalize_visibility_value(
normalized.get("visibility")
)
return normalized
def fetch_record(self, record_id: str) -> Dict[str, Any]:
"""GET /records/{record_id} — fetch record metadata."""
return self._get_json(f"/records/{urllib.parse.quote(record_id)}")
data = self._get_json(f"/records/{urllib.parse.quote(record_id)}")
return self._normalize_record_payload(data)
def download_artifact(self, record_id: str) -> bytes:
"""GET /records/{record_id}/download — download artifact zip bytes."""
@ -132,7 +151,10 @@ class OpenSpaceClient:
path = f"/records/metadata?{urllib.parse.urlencode(params)}"
data = self._get_json(path, timeout=15)
all_items.extend(data.get("items", []))
all_items.extend(
self._normalize_record_payload(item)
for item in data.get("items", [])
)
if not data.get("has_more"):
break
@ -142,6 +164,34 @@ class OpenSpaceClient:
return all_items
def search_record_embeddings(
self,
*,
query: str,
limit: int = RECORD_EMBEDDING_SEARCH_MAX_LIMIT,
level: Optional[str] = None,
tags: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
"""POST /records/embeddings/search — fetch server-ranked embedding rows."""
search_request_payload: Dict[str, Any] = {
"query": query,
"limit": limit,
}
if level:
search_request_payload["level"] = level
if tags:
search_request_payload["tags"] = tags
_, response_body = self._request(
"POST",
"/records/embeddings/search",
body=json.dumps(search_request_payload).encode("utf-8"),
extra_headers={"Content-Type": "application/json"},
timeout=30,
)
items = json.loads(response_body.decode("utf-8"))
return [self._normalize_record_payload(item) for item in items]
def stage_artifact(self, skill_dir: Path) -> tuple[str, int]:
"""POST /artifacts/stage — upload skill files.
@ -266,7 +316,7 @@ class OpenSpaceClient:
parents = parent_skill_ids or []
self._validate_origin_parents(origin, parents)
api_visibility = "group_only" if visibility == "private" else "public"
api_visibility = visibility
# Step 1: Stage
logger.info(f"upload_skill: staging files for '{name}'")
@ -340,7 +390,11 @@ class OpenSpaceClient:
record_data = self.fetch_record(skill_id)
skill_name = record_data.get("name", skill_id)
skill_dir = target_dir / skill_name
if "/" in skill_name or "\\" in skill_name or skill_name.startswith("."):
skill_name = skill_id
skill_dir = (target_dir / skill_name).resolve()
if not skill_dir.is_relative_to(target_dir.resolve()):
raise CloudError(f"Skill name {skill_name!r} escapes target directory")
# Check if already exists locally
if skill_dir.exists() and (skill_dir / SKILL_FILENAME).exists():
@ -401,6 +455,7 @@ class OpenSpaceClient:
def _extract_zip(zip_data: bytes, target_dir: Path) -> List[str]:
"""Extract zip bytes to target directory with path traversal protection."""
extracted: List[str] = []
resolved_target = target_dir.resolve()
try:
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
for info in zf.infolist():
@ -409,7 +464,9 @@ class OpenSpaceClient:
clean_name = Path(info.filename).as_posix()
if clean_name.startswith("..") or clean_name.startswith("/"):
continue
target_path = target_dir / clean_name
target_path = (target_dir / clean_name).resolve()
if not target_path.is_relative_to(resolved_target):
continue
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_bytes(zf.read(info))
extracted.append(clean_name)

View file

@ -18,6 +18,7 @@ import re
from typing import Any, Dict, List, Optional
logger = logging.getLogger("openspace.cloud")
CLOUD_EMBEDDING_SEARCH_MAX_LIMIT = 300
def _check_safety(text: str) -> list[str]:
@ -159,36 +160,43 @@ class SkillSearchEngine:
from openspace.cloud.embedding import cosine_similarity
scored = []
for c in candidates:
name = c.get("name", "")
slug = c.get("skill_id", name).split("__")[0].replace(":", "-")
for candidate in candidates:
candidate_name = candidate.get("name", "")
candidate_slug = candidate.get("skill_id", candidate_name).split("__")[0].replace(":", "-")
# Vector score
vector_score = 0.0
# Vector score. If client-side query embeddings are unavailable,
# reuse the server-side cloud rank so cloud results keep semantic signal.
vector_score: Optional[float] = None
ranking_signal_score = 0.0
if query_embedding:
skill_emb = c.get("_embedding")
if skill_emb and isinstance(skill_emb, list):
vector_score = cosine_similarity(query_embedding, skill_emb)
candidate_embedding = candidate.get("_embedding")
if candidate_embedding and isinstance(candidate_embedding, list):
vector_score = cosine_similarity(query_embedding, candidate_embedding)
ranking_signal_score = vector_score
elif isinstance(candidate.get("_search_rank"), (int, float)):
ranking_signal_score = float(candidate["_search_rank"])
# Lexical boost
lexical = _lexical_boost(query_tokens, name, slug)
lexical_boost = _lexical_boost(query_tokens, candidate_name, candidate_slug)
final_score = vector_score + lexical
final_score = ranking_signal_score + lexical_boost
entry: Dict[str, Any] = {
"skill_id": c.get("skill_id", ""),
"name": name,
"description": c.get("description", ""),
"source": c.get("source", ""),
result_entry: Dict[str, Any] = {
"skill_id": candidate.get("skill_id", ""),
"name": candidate_name,
"description": candidate.get("description", ""),
"source": candidate.get("source", ""),
"score": round(final_score, 4),
}
if vector_score > 0:
entry["vector_score"] = round(vector_score, 4)
if vector_score is not None and vector_score > 0:
result_entry["vector_score"] = round(vector_score, 4)
if isinstance(candidate.get("_search_rank"), (int, float)):
result_entry["server_search_rank"] = round(float(candidate["_search_rank"]), 4)
# Include optional fields
for key in ("path", "visibility", "created_by", "origin", "tags", "quality", "safety_flags"):
if c.get(key):
entry[key] = c[key]
scored.append(entry)
if candidate.get(key):
result_entry[key] = candidate[key]
scored.append(result_entry)
scored.sort(key=lambda x: -x["score"])
return scored
@ -275,47 +283,85 @@ def build_local_candidates(
def build_cloud_candidates(
items: List[Dict[str, Any]],
cloud_items: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Build search candidate dicts from cloud metadata items.
"""Build search candidate dicts from cloud metadata/search items.
Args:
items: Items from ``OpenSpaceClient.fetch_metadata()``.
cloud_items: Items from cloud metadata or embedding search endpoints.
Returns:
List of candidate dicts (with safety filtering applied).
"""
candidates: List[Dict[str, Any]] = []
for item in items:
name = item.get("name", "")
desc = item.get("description", "")
tags = item.get("tags", [])
safety_text = f"{name}\n{desc}\n{' '.join(tags)}"
for item in cloud_items:
candidate_name = item.get("name", "")
candidate_description = item.get("description", "")
candidate_tags = item.get("tags", [])
safety_text = f"{candidate_name}\n{candidate_description}\n{' '.join(candidate_tags)}"
flags = _check_safety(safety_text)
if not _is_safe(flags):
continue
c_entry: Dict[str, Any] = {
candidate_entry: Dict[str, Any] = {
"skill_id": item.get("record_id", ""),
"name": name,
"description": desc,
"name": candidate_name,
"description": candidate_description,
"source": "cloud",
"visibility": item.get("visibility", "public"),
"is_local": False,
"created_by": item.get("created_by", ""),
"origin": item.get("origin", ""),
"tags": tags,
"tags": candidate_tags,
"safety_flags": flags if flags else None,
}
# Carry pre-computed embedding
platform_emb = item.get("embedding")
if platform_emb and isinstance(platform_emb, list):
c_entry["_embedding"] = platform_emb
candidates.append(c_entry)
server_embedding = item.get("embedding")
if server_embedding and isinstance(server_embedding, list):
candidate_entry["_embedding"] = server_embedding
server_search_rank = item.get("search_rank")
if isinstance(server_search_rank, (int, float)):
candidate_entry["_search_rank"] = float(server_search_rank)
candidates.append(candidate_entry)
return candidates
def build_cloud_results(
cloud_search_items: List[Dict[str, Any]],
*,
limit: int,
) -> List[Dict[str, Any]]:
"""Map server-ranked cloud search rows to MCP search result shape."""
results: List[Dict[str, Any]] = []
seen_names: set[str] = set()
for candidate in build_cloud_candidates(cloud_search_items):
candidate_name = candidate.get("name", "")
dedupe_name = candidate_name or candidate.get("skill_id", "")
if dedupe_name in seen_names:
continue
seen_names.add(dedupe_name)
entry: Dict[str, Any] = {
"skill_id": candidate.get("skill_id", ""),
"name": candidate_name,
"description": candidate.get("description", ""),
"source": "cloud",
"score": round(float(candidate.get("_search_rank", 0.0)), 4),
}
if isinstance(candidate.get("_search_rank"), (int, float)):
entry["server_search_rank"] = round(float(candidate["_search_rank"]), 4)
for key in ("visibility", "created_by", "origin", "tags", "safety_flags"):
if candidate.get(key):
entry[key] = candidate[key]
results.append(entry)
if len(results) >= limit:
break
return results
async def hybrid_search_skills(
query: str,
local_skills: list = None,
@ -341,8 +387,8 @@ async def hybrid_search_skills(
"""
from openspace.cloud.embedding import generate_embedding
q = query.strip()
if not q:
normalized_query = query.strip()
if not normalized_query:
return []
candidates: List[Dict[str, Any]] = []
@ -357,16 +403,16 @@ async def hybrid_search_skills(
auth_headers, api_base = get_openspace_auth()
if auth_headers:
client = OpenSpaceClient(auth_headers, api_base)
try:
from openspace.cloud.embedding import resolve_embedding_api
has_emb = bool(resolve_embedding_api()[0])
except Exception:
has_emb = False
items = await asyncio.to_thread(
client.fetch_metadata, include_embedding=has_emb, limit=200,
cloud_client = OpenSpaceClient(auth_headers, api_base)
cloud_result_limit = limit if source == "cloud" else CLOUD_EMBEDDING_SEARCH_MAX_LIMIT
cloud_search_items = await asyncio.to_thread(
cloud_client.search_record_embeddings,
query=normalized_query,
limit=cloud_result_limit,
)
candidates.extend(build_cloud_candidates(items))
if source == "cloud":
return build_cloud_results(cloud_search_items, limit=limit)
candidates.extend(build_cloud_candidates(cloud_search_items))
except Exception as e:
logger.warning(f"hybrid_search_skills: cloud unavailable: {e}")
@ -376,18 +422,17 @@ async def hybrid_search_skills(
# query embedding (optional — key/URL resolved inside generate_embedding)
query_embedding: Optional[List[float]] = None
try:
query_embedding = await asyncio.to_thread(generate_embedding, q)
query_embedding = await asyncio.to_thread(generate_embedding, normalized_query)
if query_embedding:
for c in candidates:
if not c.get("_embedding") and c.get("_embedding_text"):
emb = await asyncio.to_thread(
generate_embedding, c["_embedding_text"],
for candidate in candidates:
if not candidate.get("_embedding") and candidate.get("_embedding_text"):
candidate_embedding = await asyncio.to_thread(
generate_embedding, candidate["_embedding_text"],
)
if emb:
c["_embedding"] = emb
if candidate_embedding:
candidate["_embedding"] = candidate_embedding
except Exception:
pass
engine = SkillSearchEngine()
return engine.search(q, candidates, query_embedding=query_embedding, limit=limit)
return engine.search(normalized_query, candidates, query_embedding=query_embedding, limit=limit)

View file

@ -0,0 +1,27 @@
from openspace.communication.config import CommunicationConfig, load_communication_config
from openspace.communication.session_store import SessionStore, build_session_key
from openspace.communication.types import (
AttachmentKind,
ChannelAttachment,
ChannelMessage,
ChannelPlatform,
ChannelReply,
ChannelSession,
ChannelSource,
SendResult,
)
__all__ = [
"AttachmentKind",
"ChannelAttachment",
"ChannelMessage",
"ChannelPlatform",
"ChannelReply",
"ChannelSession",
"ChannelSource",
"CommunicationConfig",
"SendResult",
"SessionStore",
"build_session_key",
"load_communication_config",
]

View file

@ -0,0 +1,9 @@
from openspace.communication.adapters.base import BaseChannelAdapter
from openspace.communication.adapters.feishu import FeishuAdapter
from openspace.communication.adapters.whatsapp import WhatsAppAdapter
__all__ = [
"BaseChannelAdapter",
"FeishuAdapter",
"WhatsAppAdapter",
]

View file

@ -0,0 +1,63 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any, Awaitable, Callable, Optional
from openspace.utils.logging import Logger
from openspace.communication.types import ChannelMessage, ChannelPlatform, SendResult
logger = Logger.get_logger(__name__)
MessageHandler = Callable[[ChannelMessage], Awaitable[None]]
class BaseChannelAdapter(ABC):
platform: ChannelPlatform
def __init__(self, platform: ChannelPlatform):
self.platform = platform
self._message_handler: Optional[MessageHandler] = None
self._connected = False
@property
def is_connected(self) -> bool:
return self._connected
def set_message_handler(self, handler: MessageHandler) -> None:
self._message_handler = handler
async def dispatch_message(self, message: ChannelMessage) -> None:
if self._message_handler is None:
logger.warning("Dropping %s message because no handler is attached", self.platform.value)
return
await self._message_handler(message)
def register_http_routes(self, app: Any) -> None:
"""Optional hook for adapters that need inbound HTTP routes."""
def validate_configuration(self) -> None:
"""Optional hook for adapter-specific startup validation."""
def get_lock_identity(self) -> Optional[tuple[str, str]]:
"""Return an optional (scope, identity) tuple for gateway-scoped locking."""
return None
@abstractmethod
async def connect(self) -> bool:
raise NotImplementedError
@abstractmethod
async def disconnect(self) -> None:
raise NotImplementedError
@abstractmethod
async def send_text(
self,
chat_id: str,
content: str,
*,
reply_to_message_id: Optional[str] = None,
metadata: Optional[dict[str, Any]] = None,
) -> SendResult:
raise NotImplementedError

View file

@ -0,0 +1,901 @@
from __future__ import annotations
import asyncio
import contextlib
import hashlib
import hmac
import json
import threading
import time
from collections import OrderedDict, deque
from pathlib import Path
from typing import Any, Optional
from aiohttp import web
from openspace.communication.adapters.base import BaseChannelAdapter
from openspace.communication.attachment_cache import AttachmentCache
from openspace.communication.config import FeishuConfig
from openspace.communication.policy import is_authorized
from openspace.communication.types import (
AttachmentKind,
ChannelMessage,
ChannelPlatform,
ChannelSource,
SendResult,
)
from openspace.utils.logging import Logger
logger = Logger.get_logger(__name__)
_FEISHU_WEBHOOK_MAX_BODY_BYTES = 1 * 1024 * 1024
_FEISHU_WEBHOOK_READ_TIMEOUT_SECONDS = 30
_FEISHU_WEBHOOK_RATE_WINDOW_SECONDS = 60
_FEISHU_WEBHOOK_RATE_LIMIT_MAX = 120
_FEISHU_WEBHOOK_RATE_MAX_KEYS = 4096
_FEISHU_WEBHOOK_ANOMALY_TTL_SECONDS = 6 * 60 * 60
_FEISHU_DEDUP_CACHE_SIZE = 2048
_FEISHU_DEDUP_TTL_SECONDS = 24 * 60 * 60
try:
import lark_oapi as lark
from lark_oapi.api.im.v1 import (
CreateMessageRequest,
CreateMessageRequestBody,
GetMessageRequest,
GetMessageResourceRequest,
ReplyMessageRequest,
ReplyMessageRequestBody,
)
from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
FEISHU_AVAILABLE = True
except ImportError:
FEISHU_AVAILABLE = False
lark = None # type: ignore[assignment]
CreateMessageRequest = None # type: ignore[assignment]
CreateMessageRequestBody = None # type: ignore[assignment]
GetMessageRequest = None # type: ignore[assignment]
GetMessageResourceRequest = None # type: ignore[assignment]
ReplyMessageRequest = None # type: ignore[assignment]
ReplyMessageRequestBody = None # type: ignore[assignment]
FEISHU_DOMAIN = None # type: ignore[assignment]
LARK_DOMAIN = None # type: ignore[assignment]
class FeishuAdapter(BaseChannelAdapter):
MAX_MESSAGE_LENGTH = 8000
_REPLY_CONTEXT_MAX_LEN = 200
def __init__(
self,
config: FeishuConfig,
attachment_cache: AttachmentCache,
*,
runtime_dir: Optional[Path] = None,
):
super().__init__(ChannelPlatform.FEISHU)
self.config = config
self.attachment_cache = attachment_cache
self.runtime_dir = (
Path(runtime_dir).expanduser().resolve()
if runtime_dir is not None
else attachment_cache.base_dir.parent.resolve()
)
self._client: Any = None
self._bot_open_id = config.bot_open_id
self._loop: Optional[asyncio.AbstractEventLoop] = None
self._ws_client: Any = None
self._ws_thread: Optional[threading.Thread] = None
self._running = False
self._dedup_state_path = self.runtime_dir / "feishu_seen_message_ids.json"
self._seen_message_ids: OrderedDict[str, float] = OrderedDict()
self._recent_sent_message_ids: OrderedDict[str, None] = OrderedDict()
self._rate_windows: dict[str, deque[float]] = {}
self._webhook_anomalies: dict[str, tuple[int, str, float]] = {}
self._dedup_dirty = False
self._load_seen_message_ids()
def register_http_routes(self, app: Any) -> None:
if self.config.connection_mode == "webhook":
app.router.add_post(self.config.webhook_path, self._handle_webhook)
def validate_configuration(self) -> None:
if self.config.connection_mode == "webhook" and not _optional_str(self.config.verification_token):
raise ValueError("Feishu webhook mode requires verification_token")
def get_lock_identity(self) -> Optional[tuple[str, str]]:
app_id = _optional_str(self.config.app_id)
if not app_id:
return None
return ("feishu-app", app_id)
async def connect(self) -> bool:
self.validate_configuration()
if not FEISHU_AVAILABLE:
logger.error("Feishu adapter requires lark-oapi")
return False
if not self.config.app_id or not self.config.app_secret:
logger.error("Feishu adapter missing app_id/app_secret")
return False
domain = FEISHU_DOMAIN if self.config.domain != "lark" else LARK_DOMAIN
self._client = (
lark.Client.builder()
.app_id(self.config.app_id)
.app_secret(self.config.app_secret)
.domain(domain)
.log_level(lark.LogLevel.WARNING)
.build()
)
self._running = True
self._loop = asyncio.get_running_loop()
if not self._bot_open_id:
self._bot_open_id = await asyncio.to_thread(self._fetch_bot_open_id)
if self.config.connection_mode == "websocket":
self._start_websocket_client()
self._connected = False
else:
self._connected = True
logger.info(
"Feishu adapter connected via %s mode",
self.config.connection_mode,
)
return True
async def disconnect(self) -> None:
self._running = False
self._connected = False
if self._ws_thread is not None and self._ws_thread.is_alive():
await asyncio.to_thread(self._ws_thread.join, 5)
self._ws_thread = None
self._persist_seen_message_ids()
self._client = None
async def send_text(
self,
chat_id: str,
content: str,
*,
reply_to_message_id: Optional[str] = None,
metadata: Optional[dict[str, Any]] = None,
) -> SendResult:
if not self._client:
return SendResult(success=False, error="Feishu client not initialized")
last_message_id: Optional[str] = None
for chunk in _split_text(content, self.MAX_MESSAGE_LENGTH):
payload = json.dumps({"text": chunk}, ensure_ascii=False)
if reply_to_message_id:
body = (
ReplyMessageRequestBody.builder()
.msg_type("text")
.content(payload)
.build()
)
request = (
ReplyMessageRequest.builder()
.message_id(reply_to_message_id)
.request_body(body)
.build()
)
response = await asyncio.to_thread(self._client.im.v1.message.reply, request)
else:
body = (
CreateMessageRequestBody.builder()
.receive_id(chat_id)
.msg_type("text")
.content(payload)
.build()
)
request = (
CreateMessageRequest.builder()
.receive_id_type("chat_id")
.request_body(body)
.build()
)
response = await asyncio.to_thread(self._client.im.v1.message.create, request)
if not response.success():
return SendResult(
success=False,
error=f"[{response.code}] {response.msg}",
raw_response=response,
)
last_message_id = getattr(getattr(response, "data", None), "message_id", None)
if last_message_id:
self._remember_sent_message_id(last_message_id)
return SendResult(success=True, message_id=last_message_id)
async def _handle_webhook(self, request: web.Request) -> web.Response:
remote_ip = _client_ip_from_request(request)
rate_key = f"{self.config.app_id}:{self.config.webhook_path}:{remote_ip}"
if not self._check_webhook_rate_limit(rate_key):
self._record_webhook_anomaly(remote_ip, "429")
return web.Response(status=429, text="Rate limit exceeded")
content_length = request.content_length or 0
if content_length > _FEISHU_WEBHOOK_MAX_BODY_BYTES:
self._record_webhook_anomaly(remote_ip, "413")
return web.Response(status=413, text="Payload too large")
try:
async with asyncio.timeout(_FEISHU_WEBHOOK_READ_TIMEOUT_SECONDS):
body_bytes = await request.read()
except TimeoutError:
self._record_webhook_anomaly(remote_ip, "408")
return web.Response(status=408, text="Request timeout")
if len(body_bytes) > _FEISHU_WEBHOOK_MAX_BODY_BYTES:
self._record_webhook_anomaly(remote_ip, "413")
return web.Response(status=413, text="Payload too large")
try:
payload = json.loads(body_bytes.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
self._record_webhook_anomaly(remote_ip, "400")
return web.json_response({"code": 400, "msg": "invalid json"}, status=400)
incoming_token = str((payload.get("header") or {}).get("token") or payload.get("token") or "")
if not incoming_token or not hmac.compare_digest(incoming_token, self.config.verification_token or ""):
self._record_webhook_anomaly(remote_ip, "401-token")
return web.Response(status=401, text="Invalid verification token")
if self.config.encrypt_key and not _is_webhook_signature_valid(
encrypt_key=self.config.encrypt_key,
headers=request.headers,
body_bytes=body_bytes,
):
self._record_webhook_anomaly(remote_ip, "401-signature")
return web.Response(status=401, text="Invalid signature")
if payload.get("encrypt"):
self._record_webhook_anomaly(remote_ip, "400-encrypted")
return web.json_response(
{"code": 400, "msg": "encrypted webhook payloads are not supported"},
status=400,
)
self._clear_webhook_anomaly(remote_ip)
if payload.get("type") == "url_verification":
return web.json_response({"challenge": payload.get("challenge", "")})
event_type = str((payload.get("header") or {}).get("event_type") or "")
if event_type == "im.message.receive_v1":
await self._handle_message_event(payload)
return web.json_response({"code": 0, "msg": "ok"})
def _start_websocket_client(self) -> None:
assert lark is not None
handler = (
lark.EventDispatcherHandler.builder(
self.config.encrypt_key or "",
self.config.verification_token or "",
)
.register_p2_im_message_receive_v1(self._on_message_sync)
.build()
)
self._ws_client = lark.ws.Client(
self.config.app_id,
self.config.app_secret,
event_handler=handler,
log_level=lark.LogLevel.INFO,
)
def _run_ws_forever() -> None:
import lark_oapi.ws.client as lark_ws_client
ws_loop = asyncio.new_event_loop()
asyncio.set_event_loop(ws_loop)
lark_ws_client.loop = ws_loop
try:
while self._running:
try:
self._ws_client.start()
except Exception as exc:
self._connected = False
logger.warning("Feishu WebSocket client error: %s", exc)
if self._running:
time.sleep(5)
finally:
self._connected = False
ws_loop.close()
self._ws_thread = threading.Thread(
target=_run_ws_forever,
daemon=True,
name="openspace-feishu-ws",
)
self._ws_thread.start()
def _on_message_sync(self, data: Any) -> None:
if not self._loop or not self._running:
return
self._connected = True
asyncio.run_coroutine_threadsafe(
self._handle_websocket_message(data),
self._loop,
)
async def _handle_message_event(self, payload: dict[str, Any]) -> None:
normalized = await self._normalize_webhook_payload(payload)
if normalized is not None:
await self.dispatch_message(normalized)
async def _handle_websocket_message(self, data: Any) -> None:
normalized = await self._normalize_websocket_event(data)
if normalized is not None:
await self.dispatch_message(normalized)
async def _normalize_webhook_payload(self, payload: dict[str, Any]) -> Optional[ChannelMessage]:
event = payload.get("event") or {}
message = event.get("message") or {}
sender = event.get("sender") or {}
sender_id = sender.get("sender_id") or {}
if str(sender.get("sender_type", "")).lower() == "bot":
return None
return await self._normalize_inbound_message(
message_id=_optional_str(message.get("message_id")),
chat_id=_optional_str(message.get("chat_id")),
chat_type=_optional_str(message.get("chat_type")) or "p2p",
sender_uid=(
_optional_str(sender_id.get("open_id"))
or _optional_str(sender_id.get("user_id"))
or _optional_str(sender_id.get("union_id"))
),
sender_name=(
_optional_str(sender_id.get("name"))
or _optional_str(sender.get("sender_name"))
),
thread_id=_optional_str(message.get("thread_id")),
message_type=_optional_str(message.get("message_type")) or "",
content=_safe_json_loads(message.get("content", "{}")),
mentions=list(message.get("mentions") or []),
reply_to_message_id=(
_optional_str(message.get("parent_id"))
or _optional_str(message.get("upper_message_id"))
),
metadata={"webhook_payload": payload},
resolve_mentions=False,
)
async def _normalize_websocket_event(self, data: Any) -> Optional[ChannelMessage]:
event = getattr(data, "event", None)
message = getattr(event, "message", None)
sender = getattr(event, "sender", None)
if message is None or sender is None:
return None
if str(getattr(sender, "sender_type", "")).lower() == "bot":
return None
sender_id = getattr(sender, "sender_id", None)
return await self._normalize_inbound_message(
message_id=_optional_str(getattr(message, "message_id", None)),
chat_id=_optional_str(getattr(message, "chat_id", None)),
chat_type=_optional_str(getattr(message, "chat_type", None)) or "p2p",
sender_uid=(
_optional_str(getattr(sender_id, "open_id", None))
or _optional_str(getattr(sender_id, "user_id", None))
or _optional_str(getattr(sender_id, "union_id", None))
),
sender_name=_optional_str(getattr(sender, "sender_name", None)),
thread_id=_optional_str(getattr(message, "thread_id", None)),
message_type=_optional_str(getattr(message, "message_type", None)) or "",
content=_safe_json_loads(getattr(message, "content", "{}")),
mentions=list(getattr(message, "mentions", None) or []),
reply_to_message_id=(
_optional_str(getattr(message, "parent_id", None))
or _optional_str(getattr(message, "upper_message_id", None))
),
metadata={"websocket_event": True},
resolve_mentions=True,
)
async def _normalize_inbound_message(
self,
*,
message_id: Optional[str],
chat_id: Optional[str],
chat_type: str,
sender_uid: Optional[str],
sender_name: Optional[str],
thread_id: Optional[str],
message_type: str,
content: dict[str, Any],
mentions: list[Any],
reply_to_message_id: Optional[str],
metadata: dict[str, Any],
resolve_mentions: bool,
) -> Optional[ChannelMessage]:
if not message_id or not chat_id:
return None
if self._is_message_seen(message_id):
logger.debug("Skipping duplicate Feishu message %s", message_id)
return None
source = ChannelSource(
platform=ChannelPlatform.FEISHU,
chat_id=chat_id,
chat_type="dm" if str(chat_type).lower() == "p2p" else "group",
user_id=sender_uid,
user_name=sender_name,
chat_name=chat_id,
thread_id=thread_id,
)
session_key = _build_session_key_hint(source)
normalized_type = str(message_type or "").strip().lower()
mentions_bot = self._mentions_bot(mentions)
text = ""
if normalized_type == "text":
text = str(content.get("text", "")).strip()
if resolve_mentions:
text = _resolve_mentions(text, mentions)
elif normalized_type == "post":
text = _extract_post_text(content)
prefilter_message = ChannelMessage(
source=source,
text=text,
message_id=message_id,
reply_to_message_id=reply_to_message_id,
mentions_bot=mentions_bot,
metadata=metadata,
)
if not self._passes_prefilter(prefilter_message):
self._remember_message_seen(message_id)
return None
attachments = []
if normalized_type == "image":
attachment = await self._download_attachment(
session_key=session_key,
message_id=message_id,
file_key=str(content.get("image_key", "")).strip(),
file_name=str(content.get("image_key", "image")).strip() + ".png",
kind=AttachmentKind.IMAGE,
resource_type="image",
)
if attachment is not None:
attachments.append(attachment)
elif normalized_type == "file":
attachment = await self._download_attachment(
session_key=session_key,
message_id=message_id,
file_key=str(content.get("file_key", "")).strip(),
file_name=str(content.get("file_name", "document")).strip(),
kind=AttachmentKind.DOCUMENT,
resource_type="file",
)
if attachment is not None:
attachments.append(attachment)
prefilter_message.attachments = attachments
prefilter_message.reply_to_text = await self._fetch_message_text(reply_to_message_id)
self._remember_message_seen(message_id)
return prefilter_message
def _passes_prefilter(self, message: ChannelMessage) -> bool:
if not is_authorized(message, self.config):
logger.info("Rejected Feishu message from unauthorized user %s", message.source.user_id)
return False
if message.source.chat_type == "dm":
return self.config.allow_dm
if not self.config.allow_groups:
return False
if self.config.group_policy == "disabled":
return False
if self.config.group_policy == "mention_only":
return message.mentions_bot
if self.config.group_policy == "reply_or_mention":
return message.mentions_bot or self._is_reply_to_recent_bot_message(
message.reply_to_message_id
)
return True
def _is_reply_to_recent_bot_message(self, message_id: Optional[str]) -> bool:
if not message_id:
return False
return message_id in self._recent_sent_message_ids
def _remember_sent_message_id(self, message_id: str) -> None:
self._recent_sent_message_ids.pop(message_id, None)
self._recent_sent_message_ids[message_id] = None
while len(self._recent_sent_message_ids) > _FEISHU_DEDUP_CACHE_SIZE:
self._recent_sent_message_ids.popitem(last=False)
async def _download_attachment(
self,
*,
session_key: str,
message_id: str,
file_key: str,
file_name: str,
kind: AttachmentKind,
resource_type: str,
):
if not self._client or not file_key:
return None
request = (
GetMessageResourceRequest.builder()
.message_id(message_id)
.file_key(file_key)
.type(resource_type)
.build()
)
response = await asyncio.to_thread(self._client.im.v1.message_resource.get, request)
if not response.success():
logger.warning(
"Failed to download Feishu attachment: code=%s msg=%s",
response.code,
response.msg,
)
return None
try:
file_data = await asyncio.to_thread(
_read_attachment_body,
response.file,
self.attachment_cache.max_attachment_bytes,
)
except ValueError as exc:
logger.warning(
"Rejected Feishu attachment for session %s: %s",
session_key,
exc,
)
return None
return self.attachment_cache.save_bytes(
session_key=session_key,
data=file_data,
filename=file_name,
kind=kind,
)
def _fetch_bot_open_id(self) -> Optional[str]:
if not self._client or not lark:
return None
try:
request = (
lark.BaseRequest.builder()
.http_method(lark.HttpMethod.GET)
.uri("/open-apis/bot/v3/info")
.token_types({lark.AccessTokenType.APP})
.build()
)
response = self._client.request(request)
if not response.success():
logger.warning(
"Failed to fetch Feishu bot info: code=%s msg=%s",
response.code,
response.msg,
)
return None
payload = json.loads(response.raw.content)
bot = (payload.get("data") or payload).get("bot") or {}
return _optional_str(bot.get("open_id"))
except Exception as exc:
logger.warning("Failed to resolve Feishu bot open_id: %s", exc)
return None
async def _fetch_message_text(self, message_id: Optional[str]) -> Optional[str]:
if not self._client or not message_id or GetMessageRequest is None:
return None
request = GetMessageRequest.builder().message_id(message_id).build()
try:
response = await asyncio.to_thread(self._client.im.v1.message.get, request)
except Exception as exc:
logger.debug("Failed to fetch Feishu parent message %s: %s", message_id, exc)
return None
if not response.success():
return None
data = getattr(response, "data", None)
message_obj = None
items = getattr(data, "items", None)
if items:
message_obj = items[0]
elif data is not None:
message_obj = getattr(data, "message", None) or data
if message_obj is None:
return None
body = getattr(message_obj, "body", None)
raw_content = getattr(body, "content", None) if body is not None else getattr(message_obj, "content", None)
message_type = (
getattr(message_obj, "msg_type", None)
or getattr(message_obj, "message_type", None)
or ""
)
content = _safe_json_loads(raw_content)
text = ""
if str(message_type).lower() == "text":
text = str(content.get("text", "")).strip()
elif str(message_type).lower() == "post":
text = _extract_post_text(content)
if not text:
return None
if len(text) > self._REPLY_CONTEXT_MAX_LEN:
text = text[: self._REPLY_CONTEXT_MAX_LEN] + "..."
return text
def _mentions_bot(self, mentions: list[Any]) -> bool:
if not mentions:
return False
if not self._bot_open_id:
return False
for mention in mentions:
mention_id = (mention.get("id") or {}) if isinstance(mention, dict) else getattr(mention, "id", None)
open_id = (
_optional_str(mention_id.get("open_id"))
if isinstance(mention_id, dict)
else _optional_str(getattr(mention_id, "open_id", None))
)
if open_id == self._bot_open_id:
return True
return False
def _load_seen_message_ids(self) -> None:
if not self._dedup_state_path.exists():
return
try:
payload = json.loads(self._dedup_state_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
logger.warning("Failed to load Feishu dedup cache from %s", self._dedup_state_path)
return
entries = payload.get("message_ids", {}) if isinstance(payload, dict) else {}
now = time.time()
valid: list[tuple[str, float]] = []
if isinstance(entries, dict):
for message_id, seen_at in entries.items():
normalized_id = _optional_str(message_id)
if not normalized_id:
continue
try:
timestamp = float(seen_at)
except (TypeError, ValueError):
continue
if now - timestamp <= _FEISHU_DEDUP_TTL_SECONDS:
valid.append((normalized_id, timestamp))
for message_id, seen_at in sorted(valid, key=lambda item: item[1])[-_FEISHU_DEDUP_CACHE_SIZE:]:
self._seen_message_ids[message_id] = seen_at
def _persist_seen_message_ids(self) -> None:
if not self._dedup_dirty:
return
self._dedup_state_path.parent.mkdir(parents=True, exist_ok=True)
payload = {"message_ids": dict(self._seen_message_ids)}
try:
self._dedup_state_path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
except OSError:
logger.warning("Failed to persist Feishu dedup cache to %s", self._dedup_state_path)
return
self._dedup_dirty = False
def _is_message_seen(self, message_id: str) -> bool:
now = time.time()
self._prune_seen_message_ids(now)
return message_id in self._seen_message_ids
def _remember_message_seen(self, message_id: str) -> None:
now = time.time()
self._prune_seen_message_ids(now)
if message_id in self._seen_message_ids:
self._seen_message_ids.move_to_end(message_id)
return
self._seen_message_ids[message_id] = now
self._seen_message_ids.move_to_end(message_id)
while len(self._seen_message_ids) > _FEISHU_DEDUP_CACHE_SIZE:
self._seen_message_ids.popitem(last=False)
self._dedup_dirty = True
self._persist_seen_message_ids()
def _mark_message_seen(self, message_id: str) -> bool:
if self._is_message_seen(message_id):
return True
self._remember_message_seen(message_id)
return False
def _prune_seen_message_ids(self, now: Optional[float] = None) -> None:
current = now or time.time()
stale = [
message_id
for message_id, seen_at in self._seen_message_ids.items()
if current - seen_at > _FEISHU_DEDUP_TTL_SECONDS
]
for message_id in stale:
self._seen_message_ids.pop(message_id, None)
self._dedup_dirty = True
def _check_webhook_rate_limit(self, rate_key: str) -> bool:
now = time.time()
window = self._rate_windows.get(rate_key)
if window is None:
if len(self._rate_windows) >= _FEISHU_WEBHOOK_RATE_MAX_KEYS:
stale_keys = [
key
for key, timestamps in self._rate_windows.items()
if not timestamps or now - timestamps[-1] > _FEISHU_WEBHOOK_RATE_WINDOW_SECONDS
]
for key in stale_keys:
self._rate_windows.pop(key, None)
if rate_key not in self._rate_windows and len(self._rate_windows) >= _FEISHU_WEBHOOK_RATE_MAX_KEYS:
return False
window = deque()
self._rate_windows[rate_key] = window
cutoff = now - _FEISHU_WEBHOOK_RATE_WINDOW_SECONDS
while window and window[0] < cutoff:
window.popleft()
if len(window) >= _FEISHU_WEBHOOK_RATE_LIMIT_MAX:
return False
window.append(now)
return True
def _record_webhook_anomaly(self, remote_ip: str, status: str) -> None:
now = time.time()
current = self._webhook_anomalies.get(remote_ip)
if current and now - current[2] < _FEISHU_WEBHOOK_ANOMALY_TTL_SECONDS:
self._webhook_anomalies[remote_ip] = (current[0] + 1, status, current[2])
return
self._webhook_anomalies[remote_ip] = (1, status, now)
def _clear_webhook_anomaly(self, remote_ip: str) -> None:
self._webhook_anomalies.pop(remote_ip, None)
def _safe_json_loads(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return value
try:
return json.loads(str(value or "{}"))
except json.JSONDecodeError:
return {}
def _optional_str(value: Any) -> Optional[str]:
if value is None:
return None
value = str(value).strip()
return value or None
def _client_ip_from_request(request: web.Request) -> str:
forwarded = str(request.headers.get("x-forwarded-for", "") or "").split(",")[0].strip()
if forwarded:
return forwarded
peer = request.transport.get_extra_info("peername") if request.transport else None
if isinstance(peer, tuple) and peer:
return str(peer[0])
return request.remote or "unknown"
def _resolve_mentions(text: str, mentions: list[Any]) -> str:
if not text or not mentions:
return text
resolved = text
for mention in mentions:
key = _optional_str(
mention.get("key") if isinstance(mention, dict) else getattr(mention, "key", None)
)
if not key or key not in resolved:
continue
name = _optional_str(
mention.get("name") if isinstance(mention, dict) else getattr(mention, "name", None)
) or "user"
resolved = resolved.replace(key, f"@{name}")
return resolved
def _split_text(content: str, limit: int) -> list[str]:
text = content.strip()
if not text:
return [""]
if len(text) <= limit:
return [text]
chunks = []
remaining = text
while remaining:
chunk = remaining[:limit]
if len(remaining) > limit:
split_at = chunk.rfind("\n")
if split_at < limit // 3:
split_at = chunk.rfind(" ")
if split_at >= limit // 3:
chunk = chunk[:split_at]
chunks.append(chunk.strip())
remaining = remaining[len(chunk):].lstrip()
return [chunk for chunk in chunks if chunk]
def _is_webhook_signature_valid(*, encrypt_key: str, headers: Any, body_bytes: bytes) -> bool:
timestamp = str(headers.get("x-lark-request-timestamp", "") or "")
nonce = str(headers.get("x-lark-request-nonce", "") or "")
signature = str(headers.get("x-lark-signature", "") or "")
if not timestamp or not nonce or not signature:
return False
content = f"{timestamp}{nonce}{encrypt_key}{body_bytes.decode('utf-8', errors='replace')}"
expected = hashlib.sha256(content.encode("utf-8")).hexdigest()
return hmac.compare_digest(signature, expected)
def _build_session_key_hint(source: ChannelSource) -> str:
parts = [source.platform.value, source.chat_id]
if source.thread_id:
parts.append(source.thread_id)
return "__".join(part.replace("/", "_") for part in parts if part)
def _extract_post_text(content: dict[str, Any]) -> str:
texts: list[str] = []
def _walk(value: Any) -> None:
if isinstance(value, dict):
title = _optional_str(value.get("title"))
if title:
texts.append(title)
tag = _optional_str(value.get("tag"))
if tag == "at":
user_name = _optional_str(value.get("user_name")) or "user"
texts.append(f"@{user_name}")
else:
text = _optional_str(value.get("text"))
if text:
texts.append(text)
for nested in value.values():
_walk(nested)
elif isinstance(value, list):
for item in value:
_walk(item)
_walk(content)
deduped: list[str] = []
for text in texts:
if text not in deduped:
deduped.append(text)
return "\n".join(deduped).strip()
def _read_attachment_body(raw_file: Any, max_bytes: int) -> bytes:
if raw_file is None:
return b""
if isinstance(raw_file, bytes):
data = raw_file
elif isinstance(raw_file, bytearray):
data = bytes(raw_file)
elif hasattr(raw_file, "read"):
chunks: list[bytes] = []
total = 0
try:
while True:
chunk = raw_file.read(65536)
if not chunk:
break
if isinstance(chunk, str):
chunk = chunk.encode("utf-8")
elif isinstance(chunk, bytearray):
chunk = bytes(chunk)
elif not isinstance(chunk, bytes):
chunk = bytes(chunk)
total += len(chunk)
if total > max_bytes:
raise ValueError(
f"attachment size {total} exceeds limit {max_bytes}"
)
chunks.append(chunk)
finally:
close = getattr(raw_file, "close", None)
if callable(close):
close()
data = b"".join(chunks)
else:
data = bytes(raw_file)
if len(data) > max_bytes:
raise ValueError(f"attachment size {len(data)} exceeds limit {max_bytes}")
return data

View file

@ -0,0 +1,462 @@
from __future__ import annotations
import asyncio
import contextlib
import json
import os
import re
import secrets
import shutil
import subprocess
import uuid
from pathlib import Path
from typing import Any, Optional
import aiohttp
from openspace.communication.adapters.base import BaseChannelAdapter
from openspace.communication.attachment_cache import AttachmentCache
from openspace.communication.config import WhatsAppConfig
from openspace.communication.types import (
AttachmentKind,
ChannelMessage,
ChannelPlatform,
ChannelSource,
SendResult,
)
from openspace.utils.logging import Logger
logger = Logger.get_logger(__name__)
class WhatsAppAdapter(BaseChannelAdapter):
def __init__(
self,
config: WhatsAppConfig,
attachment_cache: AttachmentCache,
*,
runtime_dir: Optional[Path] = None,
poll_interval_seconds: float = 1.0,
):
super().__init__(ChannelPlatform.WHATSAPP)
self.config = config
self.attachment_cache = attachment_cache
self.runtime_dir = (
Path(runtime_dir).expanduser().resolve()
if runtime_dir is not None
else attachment_cache.base_dir.parent.resolve()
)
self._poll_interval_seconds = poll_interval_seconds
self._http_session: Optional[aiohttp.ClientSession] = None
self._ws: Optional[aiohttp.ClientWebSocketResponse] = None
self._receiver_task: Optional[asyncio.Task] = None
self._bridge_process: Optional[subprocess.Popen] = None
self._pending_requests: dict[str, asyncio.Future[dict[str, Any]]] = {}
self._auth_event = asyncio.Event()
self._status_event = asyncio.Event()
self._bridge_state = "disconnected"
def validate_configuration(self) -> None:
if self.config.bridge.enforce_loopback and self.config.bridge.host not in {"127.0.0.1", "localhost"}:
raise ValueError("WhatsApp bridge host must stay on loopback")
def get_lock_identity(self) -> Optional[tuple[str, str]]:
return ("whatsapp-session", str(self._session_dir().resolve()))
async def connect(self) -> bool:
self.validate_configuration()
if self._http_session is None:
self._http_session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=20),
)
for attempt in range(2):
try:
await self._open_control_socket()
except Exception as exc:
logger.info("WhatsApp bridge connection attempt %s failed: %s", attempt + 1, exc)
if attempt == 0:
await self._start_bridge_process()
await asyncio.sleep(1)
continue
return False
break
for _ in range(20):
if self._bridge_state == "connected":
self._connected = True
return True
await asyncio.sleep(1)
logger.error("WhatsApp bridge control channel opened but WhatsApp session did not connect")
return False
async def disconnect(self) -> None:
self._connected = False
self._bridge_state = "disconnected"
self._status_event.clear()
self._auth_event.clear()
if self._receiver_task is not None:
self._receiver_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._receiver_task
self._receiver_task = None
if self._ws is not None:
await self._ws.close()
self._ws = None
for future in self._pending_requests.values():
if not future.done():
future.set_exception(RuntimeError("WhatsApp bridge disconnected"))
self._pending_requests.clear()
if self._http_session is not None:
await self._http_session.close()
self._http_session = None
if self._bridge_process is not None and self._bridge_process.poll() is None:
self._bridge_process.terminate()
try:
self._bridge_process.wait(timeout=10)
except subprocess.TimeoutExpired:
self._bridge_process.kill()
self._bridge_process = None
async def send_text(
self,
chat_id: str,
content: str,
*,
reply_to_message_id: Optional[str] = None,
metadata: Optional[dict[str, Any]] = None,
) -> SendResult:
if self._ws is None:
return SendResult(success=False, error="WhatsApp bridge not initialized")
last_message_id: Optional[str] = None
for chunk in _split_text(content, 60000):
try:
payload = await self._send_command(
{
"type": "send",
"to": chat_id,
"text": chunk,
"replyToMessageId": reply_to_message_id,
}
)
except Exception as exc:
return SendResult(success=False, error=str(exc))
last_message_id = _optional_str(payload.get("messageId")) or last_message_id
return SendResult(success=True, message_id=last_message_id)
async def send_media(
self,
chat_id: str,
*,
file_path: str,
mimetype: str,
caption: Optional[str] = None,
file_name: Optional[str] = None,
reply_to_message_id: Optional[str] = None,
) -> SendResult:
if self._ws is None:
return SendResult(success=False, error="WhatsApp bridge not initialized")
try:
payload = await self._send_command(
{
"type": "send_media",
"to": chat_id,
"filePath": file_path,
"mimetype": mimetype,
"caption": caption,
"fileName": file_name,
"replyToMessageId": reply_to_message_id,
}
)
except Exception as exc:
return SendResult(success=False, error=str(exc))
return SendResult(success=True, message_id=_optional_str(payload.get("messageId")))
async def _open_control_socket(self) -> None:
if self._http_session is None:
raise RuntimeError("WhatsApp bridge HTTP session is not initialized")
if self._receiver_task is not None:
self._receiver_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._receiver_task
self._receiver_task = None
if self._ws is not None:
await self._ws.close()
self._ws = None
self._auth_event.clear()
self._status_event.clear()
ws = await self._http_session.ws_connect(
self.config.bridge.ws_url,
heartbeat=20,
autoping=True,
max_msg_size=4 * 1024 * 1024,
)
self._ws = ws
self._receiver_task = asyncio.create_task(self._receive_loop(ws))
await self._send_ws_json({"type": "auth", "token": self._effective_bridge_token()})
await asyncio.wait_for(self._auth_event.wait(), timeout=5)
async def _receive_loop(self, ws: aiohttp.ClientWebSocketResponse) -> None:
try:
async for msg in ws:
if msg.type != aiohttp.WSMsgType.TEXT:
if msg.type in {
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.ERROR,
}:
break
continue
try:
payload = json.loads(msg.data)
except json.JSONDecodeError:
logger.warning("Ignoring invalid WhatsApp bridge JSON: %r", msg.data[:200])
continue
await self._handle_ws_payload(payload)
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("WhatsApp bridge receive loop stopped: %s", exc)
finally:
if self._ws is ws:
self._ws = None
self._connected = False
self._bridge_state = "disconnected"
self._status_event.clear()
for request_id, future in list(self._pending_requests.items()):
if not future.done():
future.set_exception(RuntimeError("WhatsApp bridge disconnected"))
self._pending_requests.pop(request_id, None)
async def _handle_ws_payload(self, payload: dict[str, Any]) -> None:
message_type = str(payload.get("type", "")).strip().lower()
if message_type == "auth_ok":
self._auth_event.set()
return
if message_type == "status":
self._bridge_state = str(payload.get("status", "")).strip().lower() or "disconnected"
self._connected = self._bridge_state == "connected"
self._status_event.set()
return
if message_type == "qr":
logger.info("WhatsApp bridge is waiting for QR scan")
return
if message_type == "ack":
request_id = _optional_str(payload.get("requestId"))
if request_id and request_id in self._pending_requests:
future = self._pending_requests.pop(request_id)
if not future.done():
future.set_result(payload)
return
if message_type == "error":
request_id = _optional_str(payload.get("requestId"))
error = _optional_str(payload.get("error")) or "Unknown bridge error"
if request_id and request_id in self._pending_requests:
future = self._pending_requests.pop(request_id)
if not future.done():
future.set_exception(RuntimeError(error))
else:
logger.warning("WhatsApp bridge error: %s", error)
return
if message_type == "message":
message = await self._normalize_event(payload)
if message is not None:
await self.dispatch_message(message)
async def _send_command(self, payload: dict[str, Any]) -> dict[str, Any]:
if self._ws is None:
raise RuntimeError("WhatsApp bridge control channel is not connected")
request_id = uuid.uuid4().hex[:12]
future: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future()
self._pending_requests[request_id] = future
try:
await self._send_ws_json({**payload, "requestId": request_id})
return await asyncio.wait_for(future, timeout=20)
finally:
self._pending_requests.pop(request_id, None)
async def _send_ws_json(self, payload: dict[str, Any]) -> None:
if self._ws is None:
raise RuntimeError("WhatsApp bridge control channel is not connected")
await self._ws.send_str(json.dumps(payload, ensure_ascii=False))
async def _normalize_event(self, event: dict[str, Any]) -> Optional[ChannelMessage]:
chat_id = str(event.get("chatId", "")).strip()
message_id = str(event.get("messageId", "")).strip()
sender_id = str(event.get("senderId", "")).strip()
if not chat_id or not message_id:
return None
normalized_sender_id = _normalize_whatsapp_identifier(sender_id)
source = ChannelSource(
platform=ChannelPlatform.WHATSAPP,
chat_id=chat_id,
chat_type="group" if event.get("isGroup") else "dm",
user_id=normalized_sender_id or sender_id or None,
user_name=_optional_str(event.get("senderName")),
chat_name=_optional_str(event.get("chatName")),
)
session_key = _build_session_key_hint(source)
attachments = []
media_type = str(event.get("mediaType", "")).strip().lower()
attachment_kind = AttachmentKind.IMAGE if media_type == "image" else AttachmentKind.DOCUMENT
for media_path in event.get("mediaUrls") or []:
attachment = self.attachment_cache.copy_local_file(
session_key=session_key,
source_path=str(media_path),
kind=attachment_kind,
)
if attachment is not None:
attachments.append(attachment)
body = str(event.get("body", "") or "").strip()
return ChannelMessage(
source=source,
text=body,
message_id=message_id,
attachments=attachments,
reply_to_message_id=_optional_str(event.get("replyToMessageId")),
mentions_bot=bool(event.get("mentionsBot")),
metadata={
"bridge_event": event,
"raw_user_id": sender_id or None,
"auth_candidates": [
candidate
for candidate in (
sender_id or None,
normalized_sender_id or None,
f"+{normalized_sender_id}" if normalized_sender_id else None,
)
if candidate
],
},
)
async def _start_bridge_process(self) -> None:
if self._bridge_process is not None and self._bridge_process.poll() is None:
return
bridge_script = self._resolve_bridge_script()
bridge_dir = bridge_script.parent
session_dir = self._session_dir()
session_dir.mkdir(parents=True, exist_ok=True)
self._outbound_media_root().mkdir(parents=True, exist_ok=True)
if self.config.bridge.auto_install_dependencies and not (bridge_dir / "node_modules").exists():
subprocess.run(
["npm", "install", "--silent"],
cwd=bridge_dir,
check=True,
)
env = os.environ.copy()
env["BRIDGE_TOKEN"] = self._effective_bridge_token()
env["BRIDGE_MEDIA_ROOT"] = str(self._outbound_media_root())
if self.config.allowed_users:
env["WHATSAPP_ALLOWED_USERS"] = ",".join(self.config.allowed_users)
if self.config.reply_prefix is not None:
env["WHATSAPP_REPLY_PREFIX"] = self.config.reply_prefix
self._bridge_process = subprocess.Popen(
[
"node",
str(bridge_script),
"--host",
self.config.bridge.host,
"--port",
str(self.config.bridge.port),
"--session",
str(session_dir),
"--mode",
self.config.bridge.mode,
],
cwd=str(bridge_dir),
env=env,
)
def _resolve_bridge_script(self) -> Path:
if self.config.bridge.script_path:
custom_path = Path(self.config.bridge.script_path).expanduser().resolve()
return custom_path / "bridge.js" if custom_path.is_dir() else custom_path
source_dir = Path(__file__).resolve().parent.parent / "bridges" / "whatsapp"
target_dir = self.runtime_dir / "whatsapp-bridge"
target_dir.mkdir(parents=True, exist_ok=True)
for filename in ("bridge.js", "allowlist.js", "package.json"):
shutil.copy2(source_dir / filename, target_dir / filename)
return target_dir / "bridge.js"
def _effective_bridge_token(self) -> str:
configured = _optional_str(self.config.bridge.token)
if configured:
return configured
token_path = self.runtime_dir / "bridge_tokens" / "whatsapp.token"
if token_path.exists():
token = token_path.read_text(encoding="utf-8").strip()
if token:
return token
token_path.parent.mkdir(parents=True, exist_ok=True)
token = secrets.token_urlsafe(32)
token_path.write_text(token, encoding="utf-8")
try:
token_path.chmod(0o600)
except OSError:
pass
return token
def _session_dir(self) -> Path:
if self.config.bridge.session_dir:
return Path(self.config.bridge.session_dir).expanduser().resolve()
return (self.runtime_dir / "whatsapp" / "session").resolve()
def _outbound_media_root(self) -> Path:
return (self.runtime_dir / "outbound_media").resolve()
def _split_text(content: str, limit: int) -> list[str]:
text = content.strip()
if not text:
return [""]
if len(text) <= limit:
return [text]
chunks = []
remaining = text
while remaining:
chunk = remaining[:limit]
if len(remaining) > limit:
split_at = chunk.rfind("\n")
if split_at < limit // 3:
split_at = chunk.rfind(" ")
if split_at >= limit // 3:
chunk = chunk[:split_at]
chunks.append(chunk.strip())
remaining = remaining[len(chunk):].lstrip()
return [chunk for chunk in chunks if chunk]
def _optional_str(value: Any) -> Optional[str]:
if value is None:
return None
value = str(value).strip()
return value or None
def _build_session_key_hint(source: ChannelSource) -> str:
parts = [source.platform.value, source.chat_id]
if source.thread_id:
parts.append(source.thread_id)
return "__".join(part.replace("/", "_") for part in parts if part)
def _normalize_whatsapp_identifier(value: Any) -> str:
normalized = re.sub(r":.*@", "@", str(value or "").strip())
normalized = re.sub(r"@.*", "", normalized)
return normalized.lstrip("+")

View file

@ -0,0 +1,123 @@
from __future__ import annotations
import shutil
import uuid
from pathlib import Path
from typing import Optional
from openspace.utils.logging import Logger
from .types import AttachmentKind, ChannelAttachment
logger = Logger.get_logger(__name__)
class AttachmentCache:
def __init__(
self,
base_dir: Path,
*,
max_attachment_bytes: int = 25 * 1024 * 1024,
max_session_attachment_bytes: int = 100 * 1024 * 1024,
):
self.base_dir = base_dir
self.max_attachment_bytes = max_attachment_bytes
self.max_session_attachment_bytes = max_session_attachment_bytes
self.base_dir.mkdir(parents=True, exist_ok=True)
def session_dir(self, session_key: str) -> Path:
directory = self.base_dir / session_key / "attachments"
directory.mkdir(parents=True, exist_ok=True)
return directory
def save_bytes(
self,
*,
session_key: str,
data: bytes,
filename: str,
kind: AttachmentKind,
mime_type: str = "",
) -> Optional[ChannelAttachment]:
data_size = len(data)
if not self._within_limits(session_key, data_size):
return None
directory = self.session_dir(session_key)
safe_name = _safe_name(filename)
target = directory / f"{uuid.uuid4().hex[:12]}_{safe_name}"
target.write_bytes(data)
return ChannelAttachment(
kind=kind,
path=str(target),
name=safe_name,
mime_type=mime_type,
size_bytes=len(data),
)
def copy_local_file(
self,
*,
session_key: str,
source_path: str,
kind: AttachmentKind,
preferred_name: Optional[str] = None,
mime_type: str = "",
) -> Optional[ChannelAttachment]:
source = Path(source_path).expanduser()
if not source.exists():
logger.warning("Attachment source does not exist: %s", source)
return None
source_size = source.stat().st_size
if not self._within_limits(session_key, source_size):
return None
directory = self.session_dir(session_key)
safe_name = _safe_name(preferred_name or source.name)
target = directory / f"{uuid.uuid4().hex[:12]}_{safe_name}"
shutil.copy2(source, target)
return ChannelAttachment(
kind=kind,
path=str(target),
name=safe_name,
mime_type=mime_type,
size_bytes=target.stat().st_size,
metadata={"source_path": str(source)},
)
def _within_limits(self, session_key: str, attachment_size: int) -> bool:
if attachment_size > self.max_attachment_bytes:
logger.warning(
"Rejecting attachment for session %s because %d bytes exceeds limit %d",
session_key,
attachment_size,
self.max_attachment_bytes,
)
return False
session_usage = self._session_usage_bytes(session_key)
if session_usage + attachment_size > self.max_session_attachment_bytes:
logger.warning(
"Rejecting attachment for session %s because session quota would exceed %d bytes",
session_key,
self.max_session_attachment_bytes,
)
return False
return True
def _session_usage_bytes(self, session_key: str) -> int:
directory = self.base_dir / session_key / "attachments"
if not directory.exists():
return 0
total = 0
for path in directory.iterdir():
if path.is_file():
total += path.stat().st_size
return total
def _safe_name(name: str) -> str:
value = (name or "attachment").replace("\x00", "").strip()
value = Path(value).name
return value or "attachment"

View file

@ -0,0 +1,71 @@
import path from 'path';
import { existsSync, readFileSync } from 'fs';
export function normalizeWhatsAppIdentifier(value) {
return String(value || '')
.trim()
.replace(/:.*@/, '@')
.replace(/@.*/, '')
.replace(/^\+/, '');
}
export function parseAllowedUsers(rawValue) {
return new Set(
String(rawValue || '')
.split(',')
.map((value) => normalizeWhatsAppIdentifier(value))
.filter(Boolean)
);
}
function readMappingFile(sessionDir, identifier, suffix = '') {
const filePath = path.join(sessionDir, `lid-mapping-${identifier}${suffix}.json`);
if (!existsSync(filePath)) {
return null;
}
try {
const parsed = JSON.parse(readFileSync(filePath, 'utf8'));
const normalized = normalizeWhatsAppIdentifier(parsed);
return normalized || null;
} catch {
return null;
}
}
export function expandWhatsAppIdentifiers(identifier, sessionDir) {
const normalized = normalizeWhatsAppIdentifier(identifier);
if (!normalized) {
return new Set();
}
const resolved = new Set();
const queue = [normalized];
while (queue.length > 0) {
const current = queue.shift();
if (!current || resolved.has(current)) {
continue;
}
resolved.add(current);
for (const suffix of ['', '_reverse']) {
const mapped = readMappingFile(sessionDir, current, suffix);
if (mapped && !resolved.has(mapped)) {
queue.push(mapped);
}
}
}
return resolved;
}
export function matchesAllowedUser(senderId, allowedUsers, sessionDir) {
if (!allowedUsers || allowedUsers.size === 0) {
return true;
}
const aliases = expandWhatsAppIdentifiers(senderId, sessionDir);
for (const alias of aliases) {
if (allowedUsers.has(alias)) {
return true;
}
}
return false;
}

View file

@ -0,0 +1,577 @@
#!/usr/bin/env node
import {
DisconnectReason,
downloadMediaMessage,
fetchLatestBaileysVersion,
makeWASocket,
useMultiFileAuthState,
} from '@whiskeysockets/baileys';
import { Boom } from '@hapi/boom';
import pino from 'pino';
import path from 'path';
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
realpathSync,
statSync,
writeFileSync,
} from 'fs';
import { randomBytes } from 'crypto';
import qrcode from 'qrcode-terminal';
import { WebSocketServer, WebSocket } from 'ws';
import {
matchesAllowedUser,
normalizeWhatsAppIdentifier,
parseAllowedUsers,
} from './allowlist.js';
const args = process.argv.slice(2);
function getArg(name, defaultValue) {
const index = args.indexOf(`--${name}`);
return index !== -1 && args[index + 1] ? args[index + 1] : defaultValue;
}
const PORT = parseInt(getArg('port', '3000'), 10);
const HOST = getArg('host', '127.0.0.1');
const BIND_HOST = HOST === 'localhost' ? '127.0.0.1' : HOST;
const SESSION_DIR = path.resolve(
getArg('session', path.join(process.env.HOME || '~', '.openspace', 'whatsapp', 'session'))
);
const WHATSAPP_MODE = getArg('mode', process.env.WHATSAPP_MODE || 'self-chat');
const BRIDGE_TOKEN = String(process.env.BRIDGE_TOKEN || '').trim();
const DEFAULT_REPLY_PREFIX = 'OpenSpace\n────────────\n';
const REPLY_PREFIX = process.env.WHATSAPP_REPLY_PREFIX === undefined
? DEFAULT_REPLY_PREFIX
: process.env.WHATSAPP_REPLY_PREFIX.replace(/\\n/g, '\n');
const ALLOWED_USERS = parseAllowedUsers(process.env.WHATSAPP_ALLOWED_USERS || '');
const IMAGE_CACHE_DIR = path.join(SESSION_DIR, '..', 'image_cache');
const DOCUMENT_CACHE_DIR = path.join(SESSION_DIR, '..', 'document_cache');
const AUDIO_CACHE_DIR = path.join(SESSION_DIR, '..', 'audio_cache');
const MAX_RECENT_SENT = 50;
const MAX_RECENT_INBOUND = 512;
const AUTH_TIMEOUT_MS = 5000;
if (!BRIDGE_TOKEN) {
console.error('BRIDGE_TOKEN is required');
process.exit(1);
}
if (!['127.0.0.1', 'localhost'].includes(HOST)) {
console.error(`Refusing to bind WhatsApp bridge to non-loopback host: ${HOST}`);
process.exit(1);
}
mkdirSync(SESSION_DIR, { recursive: true });
mkdirSync(IMAGE_CACHE_DIR, { recursive: true });
mkdirSync(DOCUMENT_CACHE_DIR, { recursive: true });
mkdirSync(AUDIO_CACHE_DIR, { recursive: true });
const logger = pino({ level: 'warn' });
const clients = new Set();
const recentlySentIds = new Set();
const recentInboundById = new Map();
let sock = null;
let connectionState = 'disconnected';
let reconnectTimer = null;
function broadcast(payload) {
const encoded = JSON.stringify(payload);
for (const ws of clients) {
if (ws.readyState === WebSocket.OPEN && ws._authed) {
ws.send(encoded);
}
}
}
function recordRecentOutbound(messageId) {
if (!messageId) {
return;
}
recentlySentIds.delete(messageId);
recentlySentIds.add(messageId);
while (recentlySentIds.size > MAX_RECENT_SENT) {
recentlySentIds.delete(recentlySentIds.values().next().value);
}
}
function recordRecentInbound(messageId, rawMessage) {
if (!messageId || !rawMessage) {
return;
}
recentInboundById.delete(messageId);
recentInboundById.set(messageId, rawMessage);
while (recentInboundById.size > MAX_RECENT_INBOUND) {
const firstKey = recentInboundById.keys().next().value;
recentInboundById.delete(firstKey);
}
}
function currentStatusPayload() {
return { type: 'status', status: connectionState };
}
function buildQuotedOptions(replyToMessageId) {
if (!replyToMessageId) {
return {};
}
const quoted = recentInboundById.get(String(replyToMessageId).trim());
return quoted ? { quoted } : {};
}
function formatOutgoingMessage(message) {
if (WHATSAPP_MODE !== 'self-chat') {
return message;
}
return REPLY_PREFIX ? `${REPLY_PREFIX}${message}` : message;
}
function buildLidMap() {
const mapping = {};
try {
for (const fileName of readdirSync(SESSION_DIR)) {
const match = fileName.match(/^lid-mapping-(.+)\.json$/);
if (!match) {
continue;
}
const value = JSON.parse(readFileSync(path.join(SESSION_DIR, fileName), 'utf8'));
const normalized = normalizeWhatsAppIdentifier(value);
if (normalized) {
mapping[normalized] = match[1];
mapping[match[1]] = normalized;
}
}
} catch {
return {};
}
return mapping;
}
let lidToPhone = buildLidMap();
function normalizeId(value) {
return normalizeWhatsAppIdentifier(value);
}
function getMyIdentifiers() {
const ids = new Set();
if (sock?.user?.id) {
ids.add(normalizeId(sock.user.id));
}
if (sock?.user?.lid) {
ids.add(normalizeId(sock.user.lid));
}
return ids;
}
function getMessageContainer(message) {
return message?.message || {};
}
function extractContextInfo(message) {
const container = getMessageContainer(message);
return (
container.extendedTextMessage?.contextInfo
|| container.imageMessage?.contextInfo
|| container.videoMessage?.contextInfo
|| container.documentMessage?.contextInfo
|| container.audioMessage?.contextInfo
|| container.conversation?.contextInfo
|| null
);
}
async function cacheMedia(rawMessage, mediaMessage, targetDir, prefix, fallbackExt) {
const buffer = await downloadMediaMessage(
rawMessage,
'buffer',
{},
{ logger, reuploadRequest: sock.updateMediaMessage }
);
mkdirSync(targetDir, { recursive: true });
const mime = mediaMessage.mimetype || '';
const ext = mime.includes('/') ? `.${mime.split('/')[1].split(';')[0]}` : fallbackExt;
const filePath = path.join(targetDir, `${prefix}_${randomBytes(6).toString('hex')}${ext || fallbackExt}`);
writeFileSync(filePath, buffer);
return filePath;
}
function resolveAllowedFilePath(filePath) {
const mediaRootRaw = String(process.env.BRIDGE_MEDIA_ROOT || '').trim();
if (!mediaRootRaw) {
throw new Error('BRIDGE_MEDIA_ROOT is not configured');
}
const mediaRoot = realpathSync(mediaRootRaw);
const resolvedPath = realpathSync(String(filePath || ''));
const relative = path.relative(mediaRoot, resolvedPath);
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(`File path escapes bridge media root: ${filePath}`);
}
const stat = statSync(resolvedPath);
if (!stat.isFile()) {
throw new Error(`File is not a regular file: ${filePath}`);
}
return resolvedPath;
}
async function sendText(to, text, replyToMessageId) {
if (!sock || connectionState !== 'connected') {
throw new Error('Not connected to WhatsApp');
}
const sent = await sock.sendMessage(
to,
{ text: formatOutgoingMessage(text) },
buildQuotedOptions(replyToMessageId)
);
recordRecentOutbound(sent?.key?.id);
return sent;
}
async function sendMedia(to, filePath, mimetype, caption, fileName, replyToMessageId) {
if (!sock || connectionState !== 'connected') {
throw new Error('Not connected to WhatsApp');
}
const resolvedPath = resolveAllowedFilePath(filePath);
if (!existsSync(resolvedPath)) {
throw new Error(`File not found: ${resolvedPath}`);
}
const buffer = readFileSync(resolvedPath);
const normalizedMime = String(mimetype || '').toLowerCase();
let payload;
if (normalizedMime.startsWith('image/')) {
payload = { image: buffer, caption: caption || undefined };
} else if (normalizedMime.startsWith('video/')) {
payload = { video: buffer, caption: caption || undefined };
} else if (normalizedMime.startsWith('audio/')) {
payload = {
audio: buffer,
mimetype: normalizedMime || 'audio/ogg; codecs=opus',
ptt: normalizedMime.includes('ogg') || normalizedMime.includes('opus'),
};
} else {
payload = {
document: buffer,
fileName: fileName || path.basename(resolvedPath),
caption: caption || undefined,
};
}
const sent = await sock.sendMessage(
to,
payload,
buildQuotedOptions(replyToMessageId)
);
recordRecentOutbound(sent?.key?.id);
return sent;
}
async function handleInboundMessage(rawMessage) {
if (!rawMessage?.message) {
return;
}
const chatId = rawMessage.key?.remoteJid || '';
const senderId = rawMessage.key?.participant || chatId;
const isGroup = chatId.endsWith('@g.us');
const senderNumber = senderId.replace(/@.*/, '');
if (rawMessage.key?.fromMe) {
if (isGroup || chatId.includes('status')) {
return;
}
if (WHATSAPP_MODE === 'bot') {
return;
}
const myIds = getMyIdentifiers();
const chatNumber = normalizeId(chatId);
if (!myIds.has(chatNumber)) {
return;
}
}
if (!rawMessage.key?.fromMe && !matchesAllowedUser(senderId, ALLOWED_USERS, SESSION_DIR)) {
return;
}
const container = getMessageContainer(rawMessage);
const contextInfo = extractContextInfo(rawMessage);
const mentionedIds = (contextInfo?.mentionedJid || []).map((value) => normalizeId(value));
const mentionsBot = mentionedIds.some((value) => getMyIdentifiers().has(value));
const replyToMessageId = contextInfo?.stanzaId || null;
let body = '';
let hasMedia = false;
let mediaType = '';
const mediaUrls = [];
if (container.conversation) {
body = container.conversation;
} else if (container.extendedTextMessage?.text) {
body = container.extendedTextMessage.text;
} else if (container.imageMessage) {
body = container.imageMessage.caption || '';
hasMedia = true;
mediaType = 'image';
try {
mediaUrls.push(await cacheMedia(rawMessage, container.imageMessage, IMAGE_CACHE_DIR, 'img', '.jpg'));
} catch (error) {
console.error('[bridge] Failed to download image:', error.message);
}
} else if (container.videoMessage) {
body = container.videoMessage.caption || '';
hasMedia = true;
mediaType = 'video';
try {
mediaUrls.push(await cacheMedia(rawMessage, container.videoMessage, DOCUMENT_CACHE_DIR, 'vid', '.mp4'));
} catch (error) {
console.error('[bridge] Failed to download video:', error.message);
}
} else if (container.audioMessage || container.pttMessage) {
hasMedia = true;
mediaType = container.pttMessage ? 'ptt' : 'audio';
try {
const audioMessage = container.pttMessage || container.audioMessage;
mediaUrls.push(await cacheMedia(rawMessage, audioMessage, AUDIO_CACHE_DIR, 'aud', '.ogg'));
} catch (error) {
console.error('[bridge] Failed to download audio:', error.message);
}
} else if (container.documentMessage) {
body = container.documentMessage.caption || '';
hasMedia = true;
mediaType = 'document';
try {
mediaUrls.push(
await cacheMedia(
rawMessage,
container.documentMessage,
DOCUMENT_CACHE_DIR,
'doc',
path.extname(container.documentMessage.fileName || '') || '.bin'
)
);
} catch (error) {
console.error('[bridge] Failed to download document:', error.message);
}
}
const messageId = rawMessage.key?.id;
if (messageId && recentlySentIds.has(messageId)) {
recentlySentIds.delete(messageId);
return;
}
if (messageId) {
recordRecentInbound(messageId, rawMessage);
}
const normalizedSenderId = lidToPhone[normalizeId(senderId)] || senderId;
const event = {
type: 'message',
messageId,
chatId,
senderId: normalizedSenderId,
senderName: rawMessage.pushName || senderNumber,
chatName: isGroup ? chatId.split('@')[0] : (rawMessage.pushName || senderNumber),
isGroup,
body,
hasMedia,
mediaType,
mediaUrls,
replyToMessageId,
mentionedIds,
mentionsBot,
timestamp: rawMessage.messageTimestamp,
};
broadcast(event);
}
async function startSocket() {
const { state, saveCreds } = await useMultiFileAuthState(SESSION_DIR);
const { version } = await fetchLatestBaileysVersion();
sock = makeWASocket({
version,
auth: state,
logger,
printQRInTerminal: false,
browser: ['OpenSpace', 'Chrome', '120.0'],
syncFullHistory: false,
markOnlineOnConnect: false,
getMessage: async () => ({ conversation: '' }),
});
sock.ev.on('creds.update', () => {
saveCreds();
lidToPhone = buildLidMap();
});
sock.ev.on('connection.update', (update) => {
const { connection, lastDisconnect, qr } = update;
if (qr) {
console.log('\nScan this QR code with WhatsApp on your phone:\n');
qrcode.generate(qr, { small: true });
console.log('\nWaiting for scan...\n');
broadcast({ type: 'qr', qr });
}
if (connection === 'close') {
const reason = new Boom(lastDisconnect?.error)?.output?.statusCode;
connectionState = 'disconnected';
broadcast(currentStatusPayload());
if (reason === DisconnectReason.loggedOut) {
console.log('Logged out. Delete session and restart to re-authenticate.');
process.exit(1);
}
clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(
() => startSocket().catch((error) => console.error('WhatsApp reconnect failed:', error)),
reason === 515 ? 1000 : 3000
);
} else if (connection === 'open') {
connectionState = 'connected';
console.log('WhatsApp connected');
broadcast(currentStatusPayload());
}
});
sock.ev.on('messages.upsert', async ({ messages, type }) => {
if (type !== 'notify' && type !== 'append') {
return;
}
for (const message of messages) {
try {
await handleInboundMessage(message);
} catch (error) {
console.error('[bridge] Failed to normalize inbound message:', error);
}
}
});
}
function startBridgeServer() {
const wss = new WebSocketServer({ host: BIND_HOST, port: PORT });
console.log(`OpenSpace WhatsApp bridge listening on ws://${BIND_HOST}:${PORT} (mode: ${WHATSAPP_MODE})`);
wss.on('connection', (ws, request) => {
if (request.headers.origin) {
ws.close(4003, 'Origin header is not allowed');
return;
}
ws._authed = false;
clients.add(ws);
const authTimeout = setTimeout(() => {
if (!ws._authed) {
ws.close(4001, 'Authentication timeout');
}
}, AUTH_TIMEOUT_MS);
ws.once('message', (raw) => {
try {
const payload = JSON.parse(raw.toString());
if (payload.type !== 'auth' || payload.token !== BRIDGE_TOKEN) {
ws.close(4003, 'Invalid bridge token');
return;
}
ws._authed = true;
clearTimeout(authTimeout);
ws.send(JSON.stringify({ type: 'auth_ok' }));
ws.send(JSON.stringify(currentStatusPayload()));
ws.on('message', async (commandRaw) => {
try {
const command = JSON.parse(commandRaw.toString());
const requestId = command.requestId || null;
let sent = null;
if (command.type === 'send') {
sent = await sendText(command.to, command.text || '', command.replyToMessageId);
} else if (command.type === 'send_media') {
sent = await sendMedia(
command.to,
command.filePath,
command.mimetype,
command.caption,
command.fileName,
command.replyToMessageId
);
} else {
throw new Error(`Unsupported bridge command: ${command.type}`);
}
ws.send(JSON.stringify({
type: 'ack',
requestId,
messageId: sent?.key?.id || null,
}));
} catch (error) {
ws.send(JSON.stringify({
type: 'error',
requestId: (() => {
try {
return JSON.parse(commandRaw.toString()).requestId || null;
} catch {
return null;
}
})(),
error: error?.message || String(error),
}));
}
});
} catch {
ws.close(4003, 'Invalid auth payload');
}
});
ws.on('close', () => {
clearTimeout(authTimeout);
clients.delete(ws);
});
ws.on('error', () => {
clearTimeout(authTimeout);
clients.delete(ws);
});
});
return wss;
}
async function shutdown(server) {
clearTimeout(reconnectTimer);
for (const ws of clients) {
try {
ws.close();
} catch {}
}
clients.clear();
if (server) {
await new Promise((resolve) => server.close(resolve));
}
if (sock) {
try {
sock.end(new Error('Bridge shutdown'));
} catch {}
sock = null;
}
}
const server = startBridgeServer();
startSocket().catch((error) => {
console.error('Failed to start WhatsApp socket:', error);
process.exit(1);
});
for (const signal of ['SIGINT', 'SIGTERM']) {
process.on(signal, async () => {
try {
await shutdown(server);
} finally {
process.exit(0);
}
});
}

View file

@ -0,0 +1,17 @@
{
"name": "openspace-whatsapp-bridge",
"version": "1.0.0",
"description": "WhatsApp bridge for OpenSpace using Baileys",
"private": true,
"type": "module",
"scripts": {
"start": "node bridge.js"
},
"dependencies": {
"@hapi/boom": "^10.0.1",
"@whiskeysockets/baileys": "7.0.0-rc.9",
"pino": "^9.0.0",
"qrcode-terminal": "^0.12.0",
"ws": "^8.18.0"
}
}

View file

@ -0,0 +1,375 @@
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, Field, field_validator, model_validator
from openspace.host_detection import load_runtime_env
from openspace.utils.logging import Logger
logger = Logger.get_logger(__name__)
class GatewayServerConfig(BaseModel):
host: str = "127.0.0.1"
port: int = Field(8765, ge=1, le=65535)
health_path: str = "/health"
@field_validator("health_path")
@classmethod
def validate_health_path(cls, value: str) -> str:
value = value.strip() or "/health"
if not value.startswith("/"):
value = "/" + value
return value
class AgentExecutionConfig(BaseModel):
max_iterations: int = Field(20, ge=1, le=200)
enable_recording: bool = True
recording_backends: List[str] = Field(default_factory=lambda: ["shell"])
backend_scope: Optional[List[str]] = None
grounding_config_path: Optional[str] = None
workspace_root: Optional[str] = None
llm_timeout: float = Field(120.0, ge=1.0, le=3600.0)
class SessionProcessingConfig(BaseModel):
history_max_turns: int = Field(12, ge=1, le=100)
max_parallel_sessions: int = Field(2, ge=1, le=64)
idle_ttl_seconds: int = Field(900, ge=30, le=86400)
per_session_queue_size: int = Field(32, ge=1, le=512)
whatsapp_poll_interval_seconds: float = Field(1.0, ge=0.1, le=60.0)
max_attachment_bytes: int = Field(25 * 1024 * 1024, ge=1, le=512 * 1024 * 1024)
max_session_attachment_bytes: int = Field(
100 * 1024 * 1024,
ge=1,
le=10 * 1024 * 1024 * 1024,
)
class ChannelAccessConfig(BaseModel):
enabled: bool = False
allow_all_users: bool = False
allowed_users: List[str] = Field(default_factory=list)
allow_dm: bool = True
allow_groups: bool = True
group_policy: Literal["disabled", "mention_only", "reply_or_mention", "all"] = "reply_or_mention"
class WhatsAppBridgeConfig(BaseModel):
host: str = "127.0.0.1"
port: int = Field(3000, ge=1, le=65535)
script_path: Optional[str] = None
session_dir: Optional[str] = None
mode: Literal["self-chat", "bot"] = "self-chat"
auto_install_dependencies: bool = True
token: Optional[str] = None
enforce_loopback: bool = True
@model_validator(mode="after")
def validate_loopback_constraints(self) -> "WhatsAppBridgeConfig":
host = self.host.strip().lower() or "127.0.0.1"
if self.enforce_loopback and host not in {"127.0.0.1", "localhost"}:
raise ValueError(
"WhatsApp bridge host must be loopback when enforce_loopback is enabled"
)
self.host = host
return self
@property
def base_url(self) -> str:
return f"http://{self.host}:{self.port}"
@property
def ws_url(self) -> str:
return f"ws://{self.listen_host}:{self.port}"
@property
def listen_host(self) -> str:
return "127.0.0.1" if self.host == "localhost" else self.host
class WhatsAppConfig(ChannelAccessConfig):
bridge: WhatsAppBridgeConfig = Field(default_factory=WhatsAppBridgeConfig)
reply_prefix: Optional[str] = None
class FeishuConfig(ChannelAccessConfig):
app_id: Optional[str] = None
app_secret: Optional[str] = None
domain: Literal["feishu", "lark"] = "feishu"
connection_mode: Literal["webhook", "websocket"] = "webhook"
verification_token: Optional[str] = None
encrypt_key: Optional[str] = None
bot_open_id: Optional[str] = None
webhook_path: str = "/feishu/webhook"
@model_validator(mode="after")
def validate_webhook_requirements(self) -> "FeishuConfig":
if self.enabled and self.connection_mode == "webhook" and not (self.verification_token or "").strip():
raise ValueError("Feishu webhook mode requires verification_token")
return self
@field_validator("webhook_path")
@classmethod
def validate_webhook_path(cls, value: str) -> str:
value = value.strip() or "/feishu/webhook"
if not value.startswith("/"):
value = "/" + value
return value
@model_validator(mode="after")
def validate_webhook_security(self) -> "FeishuConfig":
if self.enabled and self.connection_mode == "webhook":
token = (self.verification_token or "").strip()
if not token:
raise ValueError(
"Feishu webhook mode requires verification_token when enabled"
)
self.verification_token = token
if self.encrypt_key is not None:
self.encrypt_key = self.encrypt_key.strip() or None
if self.bot_open_id is not None:
self.bot_open_id = self.bot_open_id.strip() or None
return self
class CommunicationConfig(BaseModel):
data_dir: str = Field(
default_factory=lambda: str(
Path(__file__).resolve().parents[2] / "logs" / "communication"
)
)
server: GatewayServerConfig = Field(default_factory=GatewayServerConfig)
agent: AgentExecutionConfig = Field(default_factory=AgentExecutionConfig)
sessions: SessionProcessingConfig = Field(default_factory=SessionProcessingConfig)
whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig)
feishu: FeishuConfig = Field(default_factory=FeishuConfig)
@property
def openspace(self) -> AgentExecutionConfig:
return self.agent
@property
def runtime(self) -> SessionProcessingConfig:
return self.sessions
@property
def data_path(self) -> Path:
return Path(self.data_dir).expanduser().resolve()
@property
def sessions_dir(self) -> Path:
return self.data_path / "sessions"
@property
def bridge_assets_dir(self) -> Path:
return Path(__file__).resolve().parent / "bridges" / "whatsapp"
@property
def runtime_status_path(self) -> Path:
return self.data_path / "runtime_status.json"
@property
def locks_dir(self) -> Path:
return self.data_path / "locks"
@property
def bridge_tokens_dir(self) -> Path:
return self.data_path / "bridge_tokens"
@property
def whatsapp_bridge_token_path(self) -> Path:
return self.bridge_tokens_dir / "whatsapp.token"
@property
def outbound_media_dir(self) -> Path:
return self.data_path / "outbound_media"
@property
def feishu_seen_message_ids_path(self) -> Path:
return self.data_path / "feishu_seen_message_ids.json"
@property
def enabled_platforms(self) -> List[str]:
platforms: List[str] = []
if self.whatsapp.enabled:
platforms.append("whatsapp")
if self.feishu.enabled:
platforms.append("feishu")
return platforms
def load_communication_config(path: Optional[str] = None) -> CommunicationConfig:
load_runtime_env()
config_path = _resolve_config_path(path)
raw: Dict[str, Any] = {}
if config_path and config_path.is_file():
with open(config_path, "r", encoding="utf-8") as handle:
raw = json.load(handle) or {}
raw = _normalize_legacy_keys(raw)
logger.info("Loaded communication config: %s", config_path)
config = CommunicationConfig.model_validate(raw)
_apply_env_overrides(config)
return CommunicationConfig.model_validate(config.model_dump(mode="python"))
def _resolve_config_path(path: Optional[str]) -> Optional[Path]:
explicit_path = Path(path).expanduser() if path else None
if explicit_path is not None:
if not explicit_path.is_file():
raise FileNotFoundError(f"Communication config file not found: {explicit_path}")
return explicit_path
env_path = (
Path(os.environ["OPENSPACE_COMMUNICATION_CONFIG"]).expanduser()
if os.environ.get("OPENSPACE_COMMUNICATION_CONFIG")
else None
)
if env_path is not None:
if not env_path.is_file():
raise FileNotFoundError(f"Communication config file not found: {env_path}")
return env_path
default_path = Path(__file__).resolve().parents[1] / "config" / "config_communication.json"
return default_path if default_path.is_file() else None
def _apply_env_overrides(config: CommunicationConfig) -> None:
_maybe_set_bool(config.whatsapp, "enabled", os.getenv("WHATSAPP_ENABLED"))
_maybe_set_bool(config.whatsapp, "allow_all_users", os.getenv("WHATSAPP_ALLOW_ALL_USERS"))
_maybe_set_list(config.whatsapp, "allowed_users", os.getenv("WHATSAPP_ALLOWED_USERS"))
_maybe_set_bool(config.whatsapp, "allow_dm", os.getenv("WHATSAPP_ALLOW_DM"))
_maybe_set_bool(config.whatsapp, "allow_groups", os.getenv("WHATSAPP_ALLOW_GROUPS"))
_maybe_set_str(config.whatsapp, "group_policy", os.getenv("WHATSAPP_GROUP_POLICY"))
_maybe_set_str(config.whatsapp.bridge, "host", os.getenv("WHATSAPP_BRIDGE_HOST"))
_maybe_set_int(config.whatsapp.bridge, "port", os.getenv("WHATSAPP_BRIDGE_PORT"))
_maybe_set_str(config.whatsapp.bridge, "script_path", os.getenv("WHATSAPP_BRIDGE_SCRIPT"))
_maybe_set_str(config.whatsapp.bridge, "session_dir", os.getenv("WHATSAPP_SESSION_DIR"))
_maybe_set_str(config.whatsapp.bridge, "mode", os.getenv("WHATSAPP_MODE"))
_maybe_set_str(config.whatsapp.bridge, "token", os.getenv("WHATSAPP_BRIDGE_TOKEN"))
_maybe_set_bool(config.whatsapp.bridge, "enforce_loopback", os.getenv("WHATSAPP_BRIDGE_ENFORCE_LOOPBACK"))
_maybe_set_str(config.whatsapp, "reply_prefix", os.getenv("WHATSAPP_REPLY_PREFIX"))
_maybe_set_bool(config.feishu, "enabled", os.getenv("FEISHU_ENABLED"))
_maybe_set_bool(config.feishu, "allow_all_users", os.getenv("FEISHU_ALLOW_ALL_USERS"))
_maybe_set_list(config.feishu, "allowed_users", os.getenv("FEISHU_ALLOWED_USERS"))
_maybe_set_bool(config.feishu, "allow_dm", os.getenv("FEISHU_ALLOW_DM"))
_maybe_set_bool(config.feishu, "allow_groups", os.getenv("FEISHU_ALLOW_GROUPS"))
_maybe_set_str(config.feishu, "group_policy", os.getenv("FEISHU_GROUP_POLICY"))
_maybe_set_str(config.feishu, "app_id", os.getenv("FEISHU_APP_ID"))
_maybe_set_str(config.feishu, "app_secret", os.getenv("FEISHU_APP_SECRET"))
_maybe_set_str(config.feishu, "verification_token", os.getenv("FEISHU_VERIFICATION_TOKEN"))
_maybe_set_str(config.feishu, "encrypt_key", os.getenv("FEISHU_ENCRYPT_KEY"))
_maybe_set_str(config.feishu, "bot_open_id", os.getenv("FEISHU_BOT_OPEN_ID"))
_maybe_set_str(config.feishu, "domain", os.getenv("FEISHU_DOMAIN"))
_maybe_set_str(config.feishu, "connection_mode", os.getenv("FEISHU_CONNECTION_MODE"))
_maybe_set_str(config.feishu, "webhook_path", os.getenv("FEISHU_WEBHOOK_PATH"))
_maybe_set_str(config, "data_dir", os.getenv("OPENSPACE_COMMUNICATION_DATA_DIR"))
_maybe_set_str(config.server, "host", os.getenv("OPENSPACE_COMMUNICATION_HOST"))
_maybe_set_int(config.server, "port", os.getenv("OPENSPACE_COMMUNICATION_PORT"))
_maybe_set_int(
config.agent,
"max_iterations",
os.getenv("OPENSPACE_COMMUNICATION_MAX_ITERATIONS") or os.getenv("OPENSPACE_MAX_ITERATIONS"),
)
_maybe_set_bool(
config.agent,
"enable_recording",
os.getenv("OPENSPACE_COMMUNICATION_ENABLE_RECORDING") or os.getenv("OPENSPACE_ENABLE_RECORDING"),
)
_maybe_set_list(
config.agent,
"recording_backends",
os.getenv("OPENSPACE_COMMUNICATION_RECORDING_BACKENDS"),
)
_maybe_set_list(
config.agent,
"backend_scope",
os.getenv("OPENSPACE_COMMUNICATION_BACKEND_SCOPE") or os.getenv("OPENSPACE_BACKEND_SCOPE"),
)
_maybe_set_str(
config.agent,
"grounding_config_path",
os.getenv("OPENSPACE_COMMUNICATION_GROUNDING_CONFIG_PATH") or os.getenv("OPENSPACE_CONFIG_PATH"),
)
_maybe_set_str(config.agent, "workspace_root", os.getenv("OPENSPACE_COMMUNICATION_WORKSPACE_ROOT"))
_maybe_set_float(config.agent, "llm_timeout", os.getenv("OPENSPACE_COMMUNICATION_LLM_TIMEOUT"))
_maybe_set_int(config.sessions, "history_max_turns", os.getenv("OPENSPACE_COMMUNICATION_HISTORY_TURNS"))
_maybe_set_int(config.sessions, "max_parallel_sessions", os.getenv("OPENSPACE_COMMUNICATION_MAX_PARALLEL"))
_maybe_set_int(config.sessions, "idle_ttl_seconds", os.getenv("OPENSPACE_COMMUNICATION_IDLE_TTL"))
_maybe_set_int(config.sessions, "per_session_queue_size", os.getenv("OPENSPACE_COMMUNICATION_QUEUE_SIZE"))
_maybe_set_int(
config.sessions,
"max_attachment_bytes",
os.getenv("OPENSPACE_COMMUNICATION_MAX_ATTACHMENT_BYTES"),
)
_maybe_set_int(
config.sessions,
"max_session_attachment_bytes",
os.getenv("OPENSPACE_COMMUNICATION_MAX_SESSION_ATTACHMENT_BYTES"),
)
_maybe_set_float(
config.sessions,
"whatsapp_poll_interval_seconds",
os.getenv("OPENSPACE_COMMUNICATION_WHATSAPP_POLL_INTERVAL"),
)
def _normalize_legacy_keys(raw: Dict[str, Any]) -> Dict[str, Any]:
normalized = dict(raw)
if "agent" not in normalized and "openspace" in normalized:
normalized["agent"] = normalized["openspace"]
if "sessions" not in normalized and "runtime" in normalized:
normalized["sessions"] = normalized["runtime"]
return normalized
def _maybe_set_bool(target: Any, field_name: str, raw: Optional[str]) -> None:
if raw is None:
return
lowered = raw.strip().lower()
if lowered in {"true", "1", "yes", "on"}:
setattr(target, field_name, True)
elif lowered in {"false", "0", "no", "off"}:
setattr(target, field_name, False)
def _maybe_set_int(target: Any, field_name: str, raw: Optional[str]) -> None:
if raw is None or not raw.strip():
return
try:
setattr(target, field_name, int(raw))
except ValueError:
logger.warning("Invalid integer for %s: %r", field_name, raw)
def _maybe_set_list(target: Any, field_name: str, raw: Optional[str]) -> None:
if raw is None:
return
values = [item.strip() for item in raw.split(",") if item.strip()]
setattr(target, field_name, values)
def _maybe_set_float(target: Any, field_name: str, raw: Optional[str]) -> None:
if raw is None or not raw.strip():
return
try:
setattr(target, field_name, float(raw))
except ValueError:
logger.warning("Invalid float for %s: %r", field_name, raw)
def _maybe_set_str(target: Any, field_name: str, raw: Optional[str]) -> None:
if raw is None:
return
value = raw.strip()
if value:
setattr(target, field_name, value)

View file

@ -0,0 +1,577 @@
from __future__ import annotations
import argparse
import asyncio
import os
from pathlib import Path
from typing import Any, Dict, Optional
import requests
from aiohttp import web
from openspace.communication.adapters import FeishuAdapter, WhatsAppAdapter
from openspace.communication.adapters.base import BaseChannelAdapter
from openspace.communication.attachment_cache import AttachmentCache
from openspace.communication.config import CommunicationConfig, load_communication_config
from openspace.communication.gateway_runtime import RuntimeStatusStore, ScopedLock, ScopedLockManager
from openspace.communication.policy import (
build_attachment_instruction,
is_authorized,
should_accept_message,
)
from openspace.communication.runtime_manager import SessionRuntimeManager
from openspace.communication.session_store import SessionStore
from openspace.communication.types import ChannelMessage, ChannelPlatform, ChannelSession
from openspace.host_detection import build_grounding_config_path, build_llm_kwargs, load_runtime_env
from openspace.tool_layer import OpenSpace, OpenSpaceConfig
from openspace.utils.logging import Logger
logger = Logger.get_logger(__name__)
def _append_no_proxy_hosts(*hosts: str) -> None:
for env_name in ("NO_PROXY", "no_proxy"):
current = os.environ.get(env_name, "")
entries = [entry.strip() for entry in current.split(",") if entry.strip()]
updated = False
for host in hosts:
if host not in entries:
entries.append(host)
updated = True
if updated:
os.environ[env_name] = ",".join(entries)
def _configure_ollama_process_env(model: str) -> None:
if not model.lower().startswith("ollama/"):
return
_append_no_proxy_hosts("127.0.0.1", "localhost")
for env_name in (
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
):
if os.environ.get(env_name):
logger.info("Clearing %s for local Ollama access", env_name)
os.environ.pop(env_name, None)
class CommunicationGateway:
def __init__(self, config: CommunicationConfig):
self.config = config
workspace_root = (
Path(config.agent.workspace_root).expanduser().resolve()
if config.agent.workspace_root
else None
)
self.session_store = SessionStore(
config.sessions_dir,
workspace_root=workspace_root,
)
self.attachment_cache = AttachmentCache(
config.sessions_dir,
max_attachment_bytes=config.sessions.max_attachment_bytes,
max_session_attachment_bytes=config.sessions.max_session_attachment_bytes,
)
self.runtime_manager = SessionRuntimeManager(config, self._create_openspace_runtime)
self._session_queues: Dict[str, asyncio.Queue[ChannelMessage]] = {}
self._session_workers: Dict[str, asyncio.Task] = {}
self._adapters: Dict[ChannelPlatform, BaseChannelAdapter] = {}
self._web_app: Optional[web.Application] = None
self._web_runner: Optional[web.AppRunner] = None
self._web_site: Optional[web.TCPSite] = None
self._running = False
self._runtime_manager_started = False
self._runtime_status = RuntimeStatusStore(self._runtime_status_path)
self._lock_manager = ScopedLockManager(self._locks_dir)
self._acquired_locks: list[ScopedLock] = []
async def start(self) -> None:
if self._running:
return
self.config.data_path.mkdir(parents=True, exist_ok=True)
self._locks_dir.mkdir(parents=True, exist_ok=True)
self._bridge_tokens_dir.mkdir(parents=True, exist_ok=True)
self._outbound_media_dir.mkdir(parents=True, exist_ok=True)
try:
self._build_adapters()
for adapter in self._adapters.values():
validate_configuration = getattr(adapter, "validate_configuration", None)
if callable(validate_configuration):
validate_configuration()
self._acquire_adapter_locks()
self._write_runtime_status("starting")
await self.runtime_manager.start()
self._runtime_manager_started = True
self._web_app = web.Application()
self._web_app.router.add_get(self.config.server.health_path, self._handle_health)
for adapter in self._adapters.values():
adapter.register_http_routes(self._web_app)
self._web_runner = web.AppRunner(self._web_app)
await self._web_runner.setup()
self._web_site = web.TCPSite(
self._web_runner,
self.config.server.host,
self.config.server.port,
)
await self._web_site.start()
for adapter in self._adapters.values():
connected = await adapter.connect()
if not connected:
raise RuntimeError(
f"Communication adapter failed to connect: {adapter.platform.value}"
)
self._running = True
self._write_runtime_status("running")
logger.info(
"Communication gateway started on %s:%s for platforms=%s",
self.config.server.host,
self.config.server.port,
",".join(self.config.enabled_platforms) or "(none)",
)
except Exception as exc:
await self._rollback_start(exc)
raise
async def stop(self) -> None:
if not self._running and not self._has_live_resources():
return
self._write_runtime_status("stopping")
self._running = False
await self._stop_session_workers()
await self._disconnect_adapters()
await self._cleanup_web_runner()
await self._stop_runtime_manager()
self._release_locks()
self._write_runtime_status("stopped")
logger.info("Communication gateway stopped")
def _build_adapters(self) -> None:
adapters: Dict[ChannelPlatform, BaseChannelAdapter] = {}
if self.config.whatsapp.enabled:
adapter = self._instantiate_adapter(
WhatsAppAdapter,
self.config.whatsapp,
self.attachment_cache,
runtime_dir=self.config.data_path,
poll_interval_seconds=self.config.sessions.whatsapp_poll_interval_seconds,
)
adapter.set_message_handler(self.handle_message)
adapters[ChannelPlatform.WHATSAPP] = adapter
if self.config.feishu.enabled:
adapter = self._instantiate_adapter(
FeishuAdapter,
self.config.feishu,
self.attachment_cache,
runtime_dir=self.config.data_path,
)
adapter.set_message_handler(self.handle_message)
adapters[ChannelPlatform.FEISHU] = adapter
self._adapters = adapters
@staticmethod
def _instantiate_adapter(adapter_cls: Any, *args: Any, **kwargs: Any) -> BaseChannelAdapter:
try:
return adapter_cls(*args, **kwargs)
except TypeError as exc:
if "unexpected keyword argument" not in str(exc):
raise
compatibility_kwargs = dict(kwargs)
compatibility_kwargs.pop("runtime_dir", None)
return adapter_cls(*args, **compatibility_kwargs)
def _acquire_adapter_locks(self) -> None:
self._release_locks()
for adapter in self._adapters.values():
get_lock_identity = getattr(adapter, "get_lock_identity", None)
binding = get_lock_identity() if callable(get_lock_identity) else None
if binding is None:
continue
scope, identity = binding
lock = self._lock_manager.acquire(
scope=scope,
identity=identity,
metadata={"platform": adapter.platform.value},
)
self._acquired_locks.append(lock)
def _release_locks(self) -> None:
while self._acquired_locks:
self._lock_manager.release(self._acquired_locks.pop())
def _write_runtime_status(
self,
gateway_state: str,
*,
fatal_error: Optional[str] = None,
) -> None:
platform_states = {
adapter.platform.value: {"connected": adapter.is_connected}
for adapter in self._adapters.values()
}
self._runtime_status.write(
gateway_state=gateway_state,
platforms=platform_states,
config_path=str(self.config.data_path),
fatal_error=fatal_error,
)
async def _rollback_start(self, exc: Exception) -> None:
logger.error("Communication gateway startup failed: %s", exc, exc_info=True)
self._running = False
await self._disconnect_adapters()
await self._cleanup_web_runner()
try:
await self._stop_runtime_manager()
finally:
self._release_locks()
self._write_runtime_status("failed", fatal_error=str(exc))
async def _stop_session_workers(self) -> None:
worker_tasks = list(self._session_workers.values())
self._session_workers.clear()
for task in worker_tasks:
task.cancel()
if worker_tasks:
await asyncio.gather(*worker_tasks, return_exceptions=True)
self._session_queues.clear()
async def _disconnect_adapters(self) -> None:
adapters = list(self._adapters.values())
self._adapters.clear()
for adapter in adapters:
try:
await adapter.disconnect()
except Exception:
logger.warning(
"Failed to disconnect adapter during cleanup: %s",
getattr(adapter.platform, "value", "unknown"),
exc_info=True,
)
async def _cleanup_web_runner(self) -> None:
if self._web_runner is None:
return
try:
await self._web_runner.cleanup()
finally:
self._web_runner = None
self._web_site = None
self._web_app = None
async def _stop_runtime_manager(self) -> None:
if not self._runtime_manager_started:
return
try:
await self.runtime_manager.stop()
finally:
self._runtime_manager_started = False
def _has_live_resources(self) -> bool:
return any(
(
self._runtime_manager_started,
bool(self._adapters),
self._web_runner is not None,
bool(self._acquired_locks),
bool(self._session_workers),
bool(self._session_queues),
)
)
@property
def _runtime_status_path(self) -> Path:
return getattr(self.config, "runtime_status_path", self.config.data_path / "runtime_status.json")
@property
def _locks_dir(self) -> Path:
return getattr(self.config, "locks_dir", self.config.data_path / "locks")
@property
def _bridge_tokens_dir(self) -> Path:
return getattr(self.config, "bridge_tokens_dir", self.config.data_path / "bridge_tokens")
@property
def _outbound_media_dir(self) -> Path:
return getattr(self.config, "outbound_media_dir", self.config.data_path / "outbound_media")
async def handle_message(self, message: ChannelMessage) -> None:
session = self.session_store.get_or_create_session(message.source)
queue = self._session_queues.get(session.session_key)
if queue is None:
queue = asyncio.Queue(maxsize=self.config.sessions.per_session_queue_size)
self._session_queues[session.session_key] = queue
worker = self._session_workers.get(session.session_key)
if worker is None or worker.done():
self._session_workers[session.session_key] = asyncio.create_task(
self._session_worker(session, queue)
)
await queue.put(message)
async def _session_worker(
self,
session: ChannelSession,
queue: asyncio.Queue[ChannelMessage],
) -> None:
session_key = session.session_key
try:
while True:
try:
message = await asyncio.wait_for(
queue.get(),
timeout=self.config.sessions.idle_ttl_seconds,
)
except asyncio.TimeoutError:
if queue.empty():
logger.info("Retiring idle communication worker: %s", session_key)
return
continue
try:
await self._process_message(session, message)
except Exception as exc:
logger.error(
"Failed to process %s message for session %s: %s",
message.source.platform.value,
session.session_key,
exc,
exc_info=True,
)
adapter = self._adapters.get(message.source.platform)
if adapter:
await adapter.send_text(
message.source.chat_id,
f"OpenSpace communication error: {exc}",
)
finally:
queue.task_done()
finally:
current_task = asyncio.current_task()
if self._session_workers.get(session_key) is current_task:
self._session_workers.pop(session_key, None)
if queue.empty():
if (
self._session_queues.get(session_key) is queue
and session_key not in self._session_workers
):
self._session_queues.pop(session_key, None)
elif self._running and session_key not in self._session_workers:
self._session_workers[session_key] = asyncio.create_task(
self._session_worker(session, queue)
)
async def _process_message(self, session: ChannelSession, message: ChannelMessage) -> None:
platform_config = self._get_platform_config(message.source.platform)
if not is_authorized(message, platform_config):
logger.info(
"Rejected %s message from unauthorized user %s",
message.source.platform.value,
message.source.user_id,
)
return
reply_to_bot = self.session_store.is_reply_to_assistant(
session,
message.reply_to_message_id,
)
if not should_accept_message(message, platform_config, reply_to_bot):
logger.debug(
"Skipped %s group message that did not satisfy policy",
message.source.platform.value,
)
return
history = self.session_store.load_history(
session,
self.config.sessions.history_max_turns,
)
if not message.text.strip():
message.text = build_attachment_instruction(message)
self.session_store.append_user_message(session, message)
result = await self.runtime_manager.execute_turn(
session=session,
message=message,
conversation_history=history,
channel_context=message.to_channel_context(session.session_key),
)
response_text = self._extract_response_text(result)
adapter = self._adapters.get(message.source.platform)
if adapter is None:
raise RuntimeError(f"No adapter registered for {message.source.platform.value}")
send_result = await adapter.send_text(
message.source.chat_id,
response_text,
reply_to_message_id=message.message_id,
)
if not send_result.success:
logger.warning(
"Failed to send %s response for session %s: %s",
message.source.platform.value,
session.session_key,
send_result.error,
)
self.session_store.append_assistant_message(
session,
content=response_text,
platform_message_id=send_result.message_id,
metadata={
"task_id": result.get("task_id"),
"status": result.get("status"),
"send_success": send_result.success,
"send_error": send_result.error,
},
)
async def _handle_health(self, request: web.Request) -> web.Response:
runtime_status = await self.runtime_manager.status()
gateway_status = self._runtime_status.read() or {}
return web.json_response(
{
"status": "ok" if self._running else "starting",
"gateway": gateway_status,
"platforms": {
platform.value: {
"connected": adapter.is_connected,
}
for platform, adapter in self._adapters.items()
},
"runtime": runtime_status,
"sessions": len(self.session_store.list_sessions()),
}
)
async def _create_openspace_runtime(self, session: ChannelSession) -> OpenSpace:
load_runtime_env()
env_model = os.environ.get("OPENSPACE_MODEL", "")
model, llm_kwargs = build_llm_kwargs(env_model)
llm_kwargs = dict(llm_kwargs)
if model.lower().startswith("ollama/"):
llm_kwargs["api_base"] = os.environ.get("OLLAMA_API_BASE", "").strip() or "http://127.0.0.1:11434"
llm_kwargs["api_key"] = os.environ.get("OLLAMA_API_KEY", "").strip() or llm_kwargs.get("api_key") or "ollama"
llm_kwargs.pop("extra_headers", None)
backend_scope = self.config.agent.backend_scope
grounding_config_path = (
self.config.agent.grounding_config_path
or build_grounding_config_path()
)
recording_dir = self.config.data_path / "recordings"
openspace_config = OpenSpaceConfig(
llm_model=model,
llm_kwargs=llm_kwargs,
workspace_dir=session.workspace_dir,
grounding_max_iterations=self.config.agent.max_iterations,
enable_recording=self.config.agent.enable_recording,
recording_backends=self.config.agent.recording_backends,
recording_log_dir=str(recording_dir),
backend_scope=backend_scope,
grounding_config_path=grounding_config_path,
llm_timeout=self.config.agent.llm_timeout,
)
runtime = OpenSpace(openspace_config)
await runtime.initialize()
return runtime
def _get_platform_config(self, platform: ChannelPlatform) -> Any:
if platform == ChannelPlatform.WHATSAPP:
return self.config.whatsapp
if platform == ChannelPlatform.FEISHU:
return self.config.feishu
raise ValueError(f"Unsupported platform: {platform}")
@staticmethod
def _extract_response_text(result: Dict[str, Any]) -> str:
response = str(result.get("response", "")).strip()
if response:
return response
error = str(result.get("error", "")).strip()
if error:
return f"OpenSpace error: {error}"
return "OpenSpace completed the task but returned no response."
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="OpenSpace communication gateway",
)
parser.add_argument(
"--config",
type=str,
help="Path to the communication JSON config file",
)
subparsers = parser.add_subparsers(dest="command")
run_parser = subparsers.add_parser("run", help="Start the communication gateway")
run_parser.add_argument(
"--config",
type=str,
help="Path to the communication JSON config file",
)
health_parser = subparsers.add_parser("health", help="Check the running gateway health endpoint")
health_parser.add_argument(
"--config",
type=str,
help="Path to the communication JSON config file",
)
health_parser.add_argument("--host", type=str, default=None)
health_parser.add_argument("--port", type=int, default=None)
return parser
async def _run_gateway(config_path: Optional[str]) -> int:
config = load_communication_config(config_path)
_configure_ollama_process_env(os.environ.get("OPENSPACE_MODEL", ""))
gateway = CommunicationGateway(config)
try:
await gateway.start()
except Exception as exc:
logger.error("Failed to start communication gateway: %s", exc)
return 1
try:
while True:
await asyncio.sleep(3600)
except (asyncio.CancelledError, KeyboardInterrupt):
pass
finally:
await gateway.stop()
return 0
def _check_health(config_path: Optional[str], host: Optional[str], port: Optional[int]) -> int:
config = load_communication_config(config_path)
url = f"http://{host or config.server.host}:{port or config.server.port}{config.server.health_path}"
response = requests.get(url, timeout=5)
response.raise_for_status()
print(response.text)
return 0
async def main(argv: Optional[list[str]] = None) -> int:
parser = _build_parser()
args = parser.parse_args(argv)
command = args.command or "run"
if command == "health":
return _check_health(args.config, args.host, args.port)
return await _run_gateway(args.config)
def run_main() -> None:
raise SystemExit(asyncio.run(main()))
if __name__ == "__main__":
run_main()

View file

@ -0,0 +1,252 @@
from __future__ import annotations
import hashlib
import json
import os
import sys
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from openspace.utils.logging import Logger
logger = Logger.get_logger(__name__)
_GATEWAY_KIND = "openspace-communication-gateway"
def _utcnow_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _scope_hash(identity: str) -> str:
return hashlib.sha256(identity.encode("utf-8")).hexdigest()[:16]
def _process_start_time(pid: int) -> Optional[int]:
stat_path = Path(f"/proc/{pid}/stat")
try:
return int(stat_path.read_text(encoding="utf-8").split()[21])
except (FileNotFoundError, IndexError, PermissionError, ValueError, OSError):
return None
def _is_pid_alive(pid: int) -> bool:
if pid <= 0:
return False
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
else:
return True
def _build_process_record() -> dict[str, Any]:
pid = os.getpid()
return {
"pid": pid,
"kind": _GATEWAY_KIND,
"argv": list(sys.argv),
"start_time": _process_start_time(pid),
}
def _record_matches_live_process(record: dict[str, Any]) -> bool:
try:
pid = int(record["pid"])
except (KeyError, TypeError, ValueError):
return False
if not _is_pid_alive(pid):
return False
recorded_start_time = record.get("start_time")
live_start_time = _process_start_time(pid)
if recorded_start_time is None or live_start_time is None:
return True
return live_start_time == recorded_start_time
def _read_json(path: Path) -> Optional[dict[str, Any]]:
if not path.exists():
return None
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
return payload if isinstance(payload, dict) else None
def _write_json(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
class LockConflictError(RuntimeError):
def __init__(self, scope: str, identity: str, record: Optional[dict[str, Any]] = None):
super().__init__(f"Communication gateway lock is already held for {scope}:{identity}")
self.scope = scope
self.identity = identity
self.record = record or {}
@dataclass
class ScopedRuntimeLock:
path: Path
record: dict[str, Any]
released: bool = False
@classmethod
def acquire(
cls,
*,
locks_dir: Path,
scope: str,
identity: str,
metadata: Optional[dict[str, Any]] = None,
) -> "ScopedRuntimeLock":
locks_dir.mkdir(parents=True, exist_ok=True)
lock_path = locks_dir / f"{scope}-{_scope_hash(identity)}.lock"
record = {
**_build_process_record(),
"scope": scope,
"identity": identity,
"metadata": metadata or {},
"created_at": _utcnow_iso(),
"updated_at": _utcnow_iso(),
}
while True:
existing = _read_json(lock_path)
if existing is not None:
if _record_matches_live_process(existing):
raise LockConflictError(scope, identity, existing)
try:
lock_path.unlink()
except FileNotFoundError:
continue
except OSError as exc:
raise RuntimeError(f"Failed to remove stale gateway lock {lock_path}: {exc}") from exc
elif lock_path.exists():
try:
lock_path.unlink()
except FileNotFoundError:
continue
except OSError as exc:
raise RuntimeError(
f"Failed to remove malformed gateway lock {lock_path}: {exc}"
) from exc
continue
try:
fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
except FileExistsError:
continue
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(record, handle, ensure_ascii=False, indent=2)
except Exception:
try:
lock_path.unlink(missing_ok=True)
except OSError:
pass
raise
return cls(path=lock_path, record=record)
def release(self) -> None:
if self.released:
return
try:
current = _read_json(self.path)
if current and current.get("pid") == self.record.get("pid"):
self.path.unlink(missing_ok=True)
except OSError as exc:
logger.warning("Failed to release communication gateway lock %s: %s", self.path, exc)
finally:
self.released = True
class GatewayRuntimeTracker:
def __init__(self, status_path: Path):
self.status_path = status_path
def write_status(
self,
*,
gateway_state: str,
platforms: dict[str, dict[str, Any]],
fatal_error: Optional[str] = None,
exit_reason: Optional[str] = None,
config_path: Optional[str] = None,
sessions: Optional[int] = None,
) -> None:
payload = {
**_build_process_record(),
"gateway_state": gateway_state,
"fatal_error": fatal_error,
"exit_reason": exit_reason,
"config_path": config_path,
"platforms": platforms,
"sessions": sessions,
"updated_at": _utcnow_iso(),
}
_write_json(self.status_path, payload)
def read_status(self) -> Optional[dict[str, Any]]:
return _read_json(self.status_path)
class ScopedLockManager:
def __init__(self, locks_dir: Path):
self.locks_dir = locks_dir
def acquire(
self,
scope: str,
identity: str,
metadata: Optional[dict[str, Any]] = None,
) -> "ScopedLock":
return ScopedRuntimeLock.acquire(
locks_dir=self.locks_dir,
scope=scope,
identity=identity,
metadata=metadata,
)
@staticmethod
def release(lock: "ScopedLock") -> None:
lock.release()
class RuntimeStatusStore:
def __init__(self, status_path: Path):
self._tracker = GatewayRuntimeTracker(status_path)
def write(
self,
*,
gateway_state: str,
platforms: dict[str, dict[str, Any]],
fatal_error: Optional[str] = None,
exit_reason: Optional[str] = None,
config_path: Optional[str] = None,
sessions: Optional[int] = None,
) -> None:
self._tracker.write_status(
gateway_state=gateway_state,
platforms=platforms,
fatal_error=fatal_error,
exit_reason=exit_reason,
config_path=config_path,
sessions=sessions,
)
def read(self) -> Optional[dict[str, Any]]:
return self._tracker.read_status()
ScopedLock = ScopedRuntimeLock

View file

@ -0,0 +1,61 @@
from __future__ import annotations
from typing import Any
from openspace.communication.types import ChannelMessage
def build_attachment_instruction(message: ChannelMessage) -> str:
attachment_paths = ", ".join(attachment.path for attachment in message.attachments)
return (
"Please inspect the attached files and help the user based on their contents. "
f"Attachment paths: {attachment_paths}"
)
def is_authorized(message: ChannelMessage, platform_config: Any) -> bool:
if getattr(platform_config, "allow_all_users", False):
return True
allowed_users = {
entry.strip()
for entry in getattr(platform_config, "allowed_users", [])
if entry and entry.strip()
}
if not allowed_users:
return False
user_candidates = {
candidate.strip()
for candidate in (
message.source.user_id,
message.source.user_name,
message.metadata.get("raw_user_id") if isinstance(message.metadata, dict) else None,
)
if candidate and candidate.strip()
}
if isinstance(message.metadata, dict):
for candidate in message.metadata.get("auth_candidates", []) or []:
if isinstance(candidate, str) and candidate.strip():
user_candidates.add(candidate.strip())
return bool(user_candidates & allowed_users)
def should_accept_message(
message: ChannelMessage,
platform_config: Any,
reply_to_bot: bool,
) -> bool:
if message.source.chat_type == "dm":
return bool(getattr(platform_config, "allow_dm", True))
if not getattr(platform_config, "allow_groups", True):
return False
group_policy = str(getattr(platform_config, "group_policy", "reply_or_mention"))
if group_policy == "disabled":
return False
if group_policy == "all":
return True
if group_policy == "mention_only":
return message.mentions_bot
return message.mentions_bot or reply_to_bot

View file

@ -0,0 +1,149 @@
from __future__ import annotations
import asyncio
import contextlib
import uuid
from time import monotonic
from typing import Any, Awaitable, Callable, Dict, Optional
from openspace.tool_layer import OpenSpace
from openspace.utils.logging import Logger
from .config import CommunicationConfig
from .types import ChannelMessage, ChannelSession
logger = Logger.get_logger(__name__)
OpenSpaceFactory = Callable[[ChannelSession], Awaitable[OpenSpace]]
class SessionRuntime:
def __init__(self, session: ChannelSession, openspace_factory: OpenSpaceFactory):
self.session = session
self._openspace_factory = openspace_factory
self._openspace: Optional[OpenSpace] = None
self._lock = asyncio.Lock()
self.last_used_monotonic = monotonic()
@property
def openspace(self) -> Optional[OpenSpace]:
return self._openspace
async def ensure_initialized(self) -> OpenSpace:
if self._openspace is None:
self._openspace = await self._openspace_factory(self.session)
self.last_used_monotonic = monotonic()
return self._openspace
async def execute_turn(
self,
*,
message: ChannelMessage,
conversation_history: list[dict[str, str]],
channel_context: dict[str, Any],
max_iterations: Optional[int] = None,
) -> Dict[str, Any]:
async with self._lock:
openspace = await self.ensure_initialized()
self.last_used_monotonic = monotonic()
task_id = f"comm_{self.session.session_key}_{uuid.uuid4().hex[:10]}"
result = await openspace.execute(
task=message.text,
context={
"conversation_history": conversation_history,
"channel_context": channel_context,
"session_key": self.session.session_key,
},
workspace_dir=self.session.workspace_dir,
max_iterations=max_iterations,
task_id=task_id,
)
self.last_used_monotonic = monotonic()
return result
async def close(self) -> None:
if self._openspace is not None:
await self._openspace.cleanup()
self._openspace = None
def is_idle(self, idle_ttl_seconds: int) -> bool:
if self._lock.locked():
return False
return (monotonic() - self.last_used_monotonic) >= idle_ttl_seconds
class SessionRuntimeManager:
def __init__(
self,
config: CommunicationConfig,
openspace_factory: OpenSpaceFactory,
):
self.config = config
self._openspace_factory = openspace_factory
self._runtimes: Dict[str, SessionRuntime] = {}
self._lock = asyncio.Lock()
self._semaphore = asyncio.Semaphore(config.sessions.max_parallel_sessions)
self._eviction_task: Optional[asyncio.Task] = None
async def start(self) -> None:
if self._eviction_task is None:
self._eviction_task = asyncio.create_task(self._evict_idle_loop())
async def stop(self) -> None:
if self._eviction_task is not None:
self._eviction_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._eviction_task
self._eviction_task = None
async with self._lock:
runtimes = list(self._runtimes.values())
self._runtimes.clear()
for runtime in runtimes:
await runtime.close()
async def execute_turn(
self,
*,
session: ChannelSession,
message: ChannelMessage,
conversation_history: list[dict[str, str]],
channel_context: dict[str, Any],
) -> Dict[str, Any]:
runtime = await self._get_or_create_runtime(session)
async with self._semaphore:
return await runtime.execute_turn(
message=message,
conversation_history=conversation_history,
channel_context=channel_context,
max_iterations=self.config.agent.max_iterations,
)
async def status(self) -> Dict[str, Any]:
async with self._lock:
return {
"active_runtimes": len(self._runtimes),
"session_keys": sorted(self._runtimes.keys()),
}
async def _get_or_create_runtime(self, session: ChannelSession) -> SessionRuntime:
async with self._lock:
runtime = self._runtimes.get(session.session_key)
if runtime is None:
runtime = SessionRuntime(session, self._openspace_factory)
self._runtimes[session.session_key] = runtime
return runtime
async def _evict_idle_loop(self) -> None:
while True:
await asyncio.sleep(30)
stale_keys: list[str] = []
async with self._lock:
for session_key, runtime in self._runtimes.items():
if runtime.is_idle(self.config.sessions.idle_ttl_seconds):
stale_keys.append(session_key)
runtimes = [self._runtimes.pop(key) for key in stale_keys]
for runtime in runtimes:
logger.info("Evicting idle communication runtime: %s", runtime.session.session_key)
await runtime.close()

View file

@ -0,0 +1,199 @@
from __future__ import annotations
import json
import re
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
from openspace.utils.logging import Logger
from .types import ChannelAttachment, ChannelMessage, ChannelSession, ChannelSource
logger = Logger.get_logger(__name__)
class SessionStore:
def __init__(
self,
sessions_dir: Path,
*,
workspace_root: Optional[Path] = None,
):
self.sessions_dir = sessions_dir
self.workspace_root = workspace_root
self.sessions_dir.mkdir(parents=True, exist_ok=True)
if self.workspace_root is not None:
self.workspace_root.mkdir(parents=True, exist_ok=True)
def get_or_create_session(self, source: ChannelSource) -> ChannelSession:
session_key = build_session_key(source)
session_dir = self.sessions_dir / session_key
session_dir.mkdir(parents=True, exist_ok=True)
metadata_path = session_dir / "session.json"
transcript_path = session_dir / "transcript.jsonl"
attachments_dir = session_dir / "attachments"
workspace_dir = (
self.workspace_root / session_key
if self.workspace_root is not None
else session_dir / "workspace"
)
attachments_dir.mkdir(parents=True, exist_ok=True)
workspace_dir.mkdir(parents=True, exist_ok=True)
now = _utcnow_iso()
if metadata_path.exists():
with open(metadata_path, "r", encoding="utf-8") as handle:
data = json.load(handle)
session = ChannelSession.from_dict(data)
session.source = source
session.updated_at = now
else:
session = ChannelSession(
session_key=session_key,
source=source,
session_dir=str(session_dir),
workspace_dir=str(workspace_dir),
attachments_dir=str(attachments_dir),
transcript_path=str(transcript_path),
metadata_path=str(metadata_path),
created_at=now,
updated_at=now,
)
self._write_session_metadata(session)
return session
def append_user_message(self, session: ChannelSession, message: ChannelMessage) -> None:
self._append_transcript_entry(
session,
{
"entry_id": uuid.uuid4().hex,
"role": "user",
"content": message.text,
"platform_message_id": message.message_id,
"reply_to_message_id": message.reply_to_message_id,
"reply_to_text": message.reply_to_text,
"mentions_bot": message.mentions_bot,
"attachments": [attachment.to_context_dict() for attachment in message.attachments],
"source": message.source.to_dict(),
"metadata": message.metadata,
"timestamp": message.received_at.isoformat(),
},
)
def append_assistant_message(
self,
session: ChannelSession,
*,
content: str,
platform_message_id: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
self._append_transcript_entry(
session,
{
"entry_id": uuid.uuid4().hex,
"role": "assistant",
"content": content,
"platform_message_id": platform_message_id,
"metadata": metadata or {},
"timestamp": _utcnow_iso(),
},
)
def load_history(self, session: ChannelSession, max_turns: int) -> List[Dict[str, str]]:
entries = self._read_transcript_entries(session)
if not entries:
return []
selected: List[Dict[str, str]] = []
user_messages = 0
for entry in reversed(entries):
role = entry.get("role")
if role not in {"user", "assistant"}:
continue
if role == "assistant" and not _assistant_entry_visible_in_history(entry):
continue
content = str(entry.get("content", "")).strip()
if not content:
continue
selected.append({"role": role, "content": content})
if role == "user":
user_messages += 1
if user_messages >= max_turns:
break
selected.reverse()
return selected
def is_reply_to_assistant(self, session: ChannelSession, message_id: Optional[str]) -> bool:
if not message_id:
return False
for entry in reversed(self._read_transcript_entries(session)):
if entry.get("platform_message_id") == message_id:
return entry.get("role") == "assistant" and _assistant_entry_visible_in_history(entry)
return False
def list_sessions(self) -> List[ChannelSession]:
sessions: List[ChannelSession] = []
for metadata_path in sorted(self.sessions_dir.glob("*/session.json")):
try:
with open(metadata_path, "r", encoding="utf-8") as handle:
sessions.append(ChannelSession.from_dict(json.load(handle)))
except Exception as exc:
logger.warning("Failed to load session metadata %s: %s", metadata_path, exc)
return sessions
def _append_transcript_entry(self, session: ChannelSession, entry: Dict[str, Any]) -> None:
transcript_path = Path(session.transcript_path)
transcript_path.parent.mkdir(parents=True, exist_ok=True)
with open(transcript_path, "a", encoding="utf-8") as handle:
handle.write(json.dumps(entry, ensure_ascii=False) + "\n")
session.updated_at = _utcnow_iso()
self._write_session_metadata(session)
def _write_session_metadata(self, session: ChannelSession) -> None:
with open(session.metadata_path, "w", encoding="utf-8") as handle:
json.dump(session.to_dict(), handle, ensure_ascii=False, indent=2)
def _read_transcript_entries(self, session: ChannelSession) -> List[Dict[str, Any]]:
transcript_path = Path(session.transcript_path)
if not transcript_path.exists():
return []
entries: List[Dict[str, Any]] = []
with open(transcript_path, "r", encoding="utf-8") as handle:
for line in handle:
line = line.strip()
if not line:
continue
try:
entries.append(json.loads(line))
except json.JSONDecodeError:
logger.warning("Skipping malformed transcript line in %s", transcript_path)
return entries
def build_session_key(source: ChannelSource) -> str:
parts = [source.platform.value, _sanitize(source.chat_id)]
if source.thread_id:
parts.append(_sanitize(source.thread_id))
return "__".join(part for part in parts if part)
def _sanitize(value: str) -> str:
value = re.sub(r"[^a-zA-Z0-9._-]+", "-", str(value).strip())
value = value.strip("-._")
return value or "unknown"
def _utcnow_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _assistant_entry_visible_in_history(entry: Dict[str, Any]) -> bool:
metadata = entry.get("metadata")
if not isinstance(metadata, dict):
return True
return metadata.get("send_success") is not False

View file

@ -0,0 +1,163 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Any, Dict, List, Optional
class ChannelPlatform(str, Enum):
WHATSAPP = "whatsapp"
FEISHU = "feishu"
class AttachmentKind(str, Enum):
IMAGE = "image"
DOCUMENT = "document"
FILE = "file"
@dataclass(slots=True)
class ChannelAttachment:
kind: AttachmentKind
path: str
name: str = ""
mime_type: str = ""
size_bytes: Optional[int] = None
source_url: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
def to_context_dict(self) -> Dict[str, Any]:
return {
"kind": self.kind.value,
"path": self.path,
"name": self.name,
"mime_type": self.mime_type,
"size_bytes": self.size_bytes,
"source_url": self.source_url,
"metadata": self.metadata,
}
@dataclass(slots=True)
class ChannelSource:
platform: ChannelPlatform
chat_id: str
chat_type: str = "dm"
user_id: Optional[str] = None
user_name: Optional[str] = None
chat_name: Optional[str] = None
thread_id: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
return {
"platform": self.platform.value,
"chat_id": self.chat_id,
"chat_type": self.chat_type,
"user_id": self.user_id,
"user_name": self.user_name,
"chat_name": self.chat_name,
"thread_id": self.thread_id,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "ChannelSource":
return cls(
platform=ChannelPlatform(str(data["platform"])),
chat_id=str(data["chat_id"]),
chat_type=str(data.get("chat_type", "dm")),
user_id=_optional_str(data.get("user_id")),
user_name=_optional_str(data.get("user_name")),
chat_name=_optional_str(data.get("chat_name")),
thread_id=_optional_str(data.get("thread_id")),
)
@dataclass(slots=True)
class ChannelMessage:
source: ChannelSource
text: str
message_id: str
attachments: List[ChannelAttachment] = field(default_factory=list)
reply_to_message_id: Optional[str] = None
reply_to_text: Optional[str] = None
mentions_bot: bool = False
metadata: Dict[str, Any] = field(default_factory=dict)
received_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
def to_channel_context(self, session_key: str) -> Dict[str, Any]:
return {
"platform": self.source.platform.value,
"chat_id": self.source.chat_id,
"chat_type": self.source.chat_type,
"chat_name": self.source.chat_name,
"thread_id": self.source.thread_id,
"user_id": self.source.user_id,
"user_name": self.source.user_name,
"session_key": session_key,
"message_id": self.message_id,
"reply_to_message_id": self.reply_to_message_id,
"reply_to_text": self.reply_to_text,
"attachments": [attachment.to_context_dict() for attachment in self.attachments],
}
@dataclass(slots=True)
class ChannelReply:
content: str
metadata: Dict[str, Any] = field(default_factory=dict)
@dataclass(slots=True)
class SendResult:
success: bool
message_id: Optional[str] = None
error: Optional[str] = None
raw_response: Any = None
@dataclass(slots=True)
class ChannelSession:
session_key: str
source: ChannelSource
session_dir: str
workspace_dir: str
attachments_dir: str
transcript_path: str
metadata_path: str
created_at: str
updated_at: str
def to_dict(self) -> Dict[str, Any]:
return {
"session_key": self.session_key,
"source": self.source.to_dict(),
"session_dir": self.session_dir,
"workspace_dir": self.workspace_dir,
"attachments_dir": self.attachments_dir,
"transcript_path": self.transcript_path,
"metadata_path": self.metadata_path,
"created_at": self.created_at,
"updated_at": self.updated_at,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "ChannelSession":
return cls(
session_key=str(data["session_key"]),
source=ChannelSource.from_dict(data["source"]),
session_dir=str(data["session_dir"]),
workspace_dir=str(data["workspace_dir"]),
attachments_dir=str(data["attachments_dir"]),
transcript_path=str(data["transcript_path"]),
metadata_path=str(data["metadata_path"]),
created_at=str(data["created_at"]),
updated_at=str(data["updated_at"]),
)
def _optional_str(value: Any) -> Optional[str]:
if value is None:
return None
value = str(value).strip()
return value or None

View file

@ -1,40 +1,53 @@
# 🔧 Configuration Guide
All configuration applies to both Path A (host agent) and Path B (standalone). Configure once before the first run.
## 1. API Keys (`.env`)
## 1. LLM Credentials (`.env`)
> [!NOTE]
> Create a `.env` file and add your API keys (refer to [`.env.example`](../../.env.example)). When used via host agent (Path A), LLM keys are auto-detected from your agent's config — `.env` is mainly needed for standalone mode.
> Create `openspace/.env` from [`.env.example`](../../.env.example) and set at least one LLM API key.
Resolution priority (first match wins):
| Priority | Source | Example |
|----------|--------|---------|
| **Tier 1** | `OPENSPACE_LLM_*` env vars | `OPENSPACE_LLM_API_KEY=sk-xxx` |
| **Tier 2** | Provider-native env vars | `OPENROUTER_API_KEY=sk-or-xxx` |
| **Tier 3** | Host agent config | `~/.nanobot/config.json` / `~/.openclaw/openclaw.json` |
> [!IMPORTANT]
> Tier 2 blocks Tier 3 — if `.env` has a provider key, host agent config is skipped.
```bash
# Provider-native — litellm reads automatically
OPENROUTER_API_KEY=sk-or-v1-xxx
# Or: OpenSpace-native — higher priority, same effect
OPENSPACE_LLM_API_KEY=sk-or-v1-xxx
```
## 2. Environment Variables
Set via `.env`, MCP config `env` block, or system environment. OpenSpace reads these at startup.
Set via `.env`, MCP config `env` block, or system environment.
| Variable | Required | Description |
|----------|----------|-------------|
| `OPENSPACE_HOST_SKILL_DIRS` | Path A only | Your agent's skill directories (comma-separated). Auto-registered on startup. |
| `OPENSPACE_WORKSPACE` | Recommended | OpenSpace project root. Used for recording logs and workspace resolution. |
| `OPENSPACE_API_KEY` | No | Cloud API key (`sk-xxx`). Register at https://open-space.cloud. |
| `OPENSPACE_MODEL` | No | LLM model override (default: auto-detected or `openrouter/anthropic/claude-sonnet-4.5`). |
| `OPENSPACE_MAX_ITERATIONS` | No | Max agent iterations per task (default: `20`). |
| `OPENSPACE_BACKEND_SCOPE` | No | Enabled backends, comma-separated (default: all — `shell,gui,mcp,web,system`). |
### Advanced env overrides (rarely needed)
| Variable | Description |
|----------|-------------|
| `OPENSPACE_LLM_API_KEY` | LLM API key (auto-detected from host agent in Path A) |
| `OPENSPACE_LLM_API_BASE` | LLM API base URL |
| `OPENSPACE_LLM_EXTRA_HEADERS` | Extra HTTP headers for LLM requests (JSON string) |
| `OPENSPACE_LLM_CONFIG` | Arbitrary litellm kwargs (JSON string) |
| `OPENSPACE_API_BASE` | Cloud API base URL (default `https://open-space.cloud/api/v1`) |
| `OPENSPACE_CONFIG_PATH` | Custom grounding config JSON (deep-merged with defaults) |
| `OPENSPACE_SHELL_CONDA_ENV` | Conda environment for shell backend |
| `OPENSPACE_SHELL_WORKING_DIR` | Working directory for shell backend |
| `OPENSPACE_MCP_SERVERS_JSON` | MCP server definitions (JSON string, merged into `mcpServers`) |
| `OPENSPACE_ENABLE_RECORDING` | Record execution traces (default: `true`) |
| `OPENSPACE_LOG_LEVEL` | `DEBUG` / `INFO` / `WARNING` / `ERROR` |
| Variable | Description | Default |
|----------|-------------|---------|
| `OPENSPACE_MODEL` | LLM model | `openrouter/anthropic/claude-sonnet-4.5` |
| `OPENSPACE_LLM_API_KEY` | LLM API key (Tier 1 override) | — |
| `OPENSPACE_LLM_API_BASE` | LLM API base URL | — |
| `OLLAMA_API_BASE` | Local Ollama endpoint for `ollama/*` models | `http://127.0.0.1:11434` |
| `OLLAMA_API_KEY` | Placeholder key for Ollama-compatible clients | `ollama` |
| `OPENSPACE_LLM_EXTRA_HEADERS` | Extra LLM headers (JSON) | — |
| `OPENSPACE_LLM_CONFIG` | Arbitrary litellm kwargs (JSON) | — |
| `OPENSPACE_API_KEY` | Cloud API key ([open-space.cloud](https://open-space.cloud)) | — |
| `OPENSPACE_MAX_ITERATIONS` | Max agent iterations per task | `20` |
| `OPENSPACE_BACKEND_SCOPE` | Enabled backends (comma-separated) | `shell,gui,mcp,web,system` |
| `OPENSPACE_HOST_SKILL_DIRS` | Agent skill directories (comma-separated) | — |
| `OPENSPACE_WORKSPACE` | Project root for logs/workspace | — |
| `OPENSPACE_SHELL_CONDA_ENV` | Conda env for shell backend | — |
| `OPENSPACE_SHELL_WORKING_DIR` | Working dir for shell backend | — |
| `OPENSPACE_CONFIG_PATH` | Custom grounding config JSON | — |
| `OPENSPACE_MCP_SERVERS_JSON` | MCP server definitions (JSON) | — |
| `OPENSPACE_ENABLE_RECORDING` | Record execution traces | `true` |
| `OPENSPACE_LOG_LEVEL` | Log level | `INFO` |
## 3. MCP Servers (`config_mcp.json`)
@ -67,7 +80,7 @@ Shell and GUI backends support two execution modes, set via `"mode"` in `config_
| **How** | `asyncio.subprocess` in-process | HTTP → Flask → subprocess |
> [!TIP]
> **Use local mode** for most use cases. For server mode setup (how to enable, platform-specific deps, remote VM control), see [`../local_server/README.md`](../local_server/README.md).
> **Use local mode** for most use cases. For server mode setup, see [`../local_server/README.md`](../local_server/README.md).
## 5. Config Files (`openspace/config/`)
@ -80,6 +93,7 @@ Layered system — later files override earlier ones:
| `config_mcp.json` | MCP servers OpenSpace connects to as a client |
| `config_security.json` | Security policies, blocked commands, sandboxing |
| `config_dev.json` | Dev overrides — copy from `config_dev.json.example` (highest priority) |
| `config_communication.json` | Communication gateway settings for WhatsApp and Feishu. Use `agent` for per-message OpenSpace execution and `sessions` for queue/history limits. LLM model stays in `openspace/.env`. |
### Agent config (`config_agents.json`)
@ -113,3 +127,40 @@ Layered system — later files override earlier ones:
| `sandbox_enabled` | Enable sandboxing for all operations | `false` |
| Per-backend overrides | Shell, MCP, GUI, Web each have independent security policies | Inherit global |
## 6. Communication Gateway
The tracked communication config is safe-by-default: loopback-only, channels disabled, and deny-by-default access control. Copy the example config, fill in credentials and `allowed_users`, then explicitly enable the channels you want. The gateway model is not configured here; it inherits `OPENSPACE_MODEL` from `openspace/.env`.
```bash
cp openspace/config/config_communication.json.example openspace/config/config_communication.json
```
Install the Feishu SDK extra when you need Feishu support:
```bash
pip install -e '.[communication]'
```
Start the gateway with either entrypoint:
```bash
openspace communication run --config openspace/config/config_communication.json
openspace-gateway --config openspace/config/config_communication.json
```
Check health:
```bash
openspace communication health --config openspace/config/config_communication.json
```
Notes:
- The tracked `config_communication.json` now stays local-only and deny-by-default. Keep credentials out of git and populate them from a private working copy or environment variables.
- Set `server.host` to `0.0.0.0` only when Feishu needs to reach the webhook from outside the machine, and pair that with a populated allowlist plus webhook verification secrets.
- Feishu now supports both `webhook` and `websocket` modes. `websocket` matches nanobot's long-connection setup and does not require a public webhook URL.
- WhatsApp requires Node.js and npm. The bundled bridge installs its dependencies on first start when `auto_install_dependencies` is enabled.
- Set `feishu.bot_open_id` if you want strict group mention gating and automatic bot identity discovery is unavailable in your deployment.
- Group chats are gated by `group_policy`. `reply_or_mention` is the default and only accepts messages that mention the bot or reply to a prior assistant message.
- `allowed_users` is enforced when `allow_all_users` is `false`. The secure default is deny-by-default until you populate the allowlist.
- Attachment caching is limited by `sessions.max_attachment_bytes` and `sessions.max_session_attachment_bytes` to bound disk usage per file and per session.

View file

@ -0,0 +1,65 @@
{
"data_dir": "./logs/communication",
"server": {
"host": "127.0.0.1",
"port": 8765,
"health_path": "/health"
},
"agent": {
"max_iterations": 20,
"enable_recording": true,
"recording_backends": [
"shell"
],
"backend_scope": null,
"grounding_config_path": null,
"workspace_root": null,
"llm_timeout": 120.0
},
"sessions": {
"history_max_turns": 12,
"max_parallel_sessions": 2,
"idle_ttl_seconds": 900,
"per_session_queue_size": 32,
"whatsapp_poll_interval_seconds": 1.0,
"max_attachment_bytes": 26214400,
"max_session_attachment_bytes": 104857600
},
"whatsapp": {
"enabled": false,
"allow_all_users": false,
"allowed_users": [
"15551234567"
],
"allow_dm": true,
"allow_groups": true,
"group_policy": "reply_or_mention",
"reply_prefix": "OpenSpace\n────────────\n",
"bridge": {
"host": "127.0.0.1",
"port": 3000,
"script_path": null,
"session_dir": null,
"mode": "self-chat",
"auto_install_dependencies": true
}
},
"feishu": {
"enabled": false,
"allow_all_users": false,
"allowed_users": [
"ou_xxxxxxxxxxxxx"
],
"allow_dm": true,
"allow_groups": true,
"group_policy": "reply_or_mention",
"app_id": "cli_xxxxxxxxxxxxx",
"app_secret": "xxxxxxxxxxxxx",
"domain": "feishu",
"connection_mode": "webhook",
"verification_token": "",
"encrypt_key": "",
"bot_open_id": "",
"webhook_path": "/feishu/webhook"
}
}

View file

@ -419,6 +419,18 @@ def _build_lineage_payload(skill_id: str, store: SkillStore) -> Dict[str, Any]:
}
def _workflow_id(workflow_dir: Path) -> str:
"""Stable short ID for a workflow directory, unique across roots.
Uses a hash suffix derived from the resolved path to avoid collisions
when directory names contain the separator character.
"""
import hashlib
resolved = str(workflow_dir.resolve())
path_hash = hashlib.sha256(resolved.encode()).hexdigest()[:8]
return f"{workflow_dir.name}_{path_hash}"
def _discover_workflow_dirs() -> List[Path]:
discovered: Dict[str, Path] = {}
for root in WORKFLOW_ROOTS:
@ -439,14 +451,14 @@ def _scan_workflow_tree(directory: Path, discovered: Dict[str, Path], *, _depth:
if not child.is_dir():
continue
if (child / "metadata.json").exists() or (child / "traj.jsonl").exists():
discovered.setdefault(child.name, child)
discovered.setdefault(str(child.resolve()), child)
else:
_scan_workflow_tree(child, discovered, _depth=_depth + 1, _max_depth=_max_depth)
def _get_workflow_dir(workflow_id: str) -> Optional[Path]:
for path in _discover_workflow_dirs():
if path.name == workflow_id:
if _workflow_id(path) == workflow_id:
return path
return None
@ -464,7 +476,7 @@ def _build_workflow_summary(workflow_dir: Path) -> Dict[str, Any]:
for candidate in video_candidates:
if candidate.exists():
rel = candidate.relative_to(workflow_dir).as_posix()
video_url = url_for("workflow_artifact", workflow_id=workflow_dir.name, artifact_path=rel)
video_url = url_for("workflow_artifact", workflow_id=_workflow_id(workflow_dir), artifact_path=rel)
break
outcome = metadata.get("execution_outcome") or {}
@ -514,7 +526,7 @@ def _build_workflow_summary(workflow_dir: Path) -> Dict[str, Any]:
iterations = len(trajectory)
return {
"id": workflow_dir.name,
"id": _workflow_id(workflow_dir),
"path": str(workflow_dir),
"task_id": metadata.get("task_id") or metadata.get("task_name") or workflow_dir.name,
"task_name": metadata.get("task_name") or metadata.get("task_id") or workflow_dir.name,

View file

@ -4,7 +4,7 @@ from openspace.grounding.core.provider import Provider
from openspace.grounding.core.session import BaseSession
from openspace.config import get_config
from openspace.config.utils import get_config_value
from openspace.platform import get_local_server_config
from openspace.platforms import get_local_server_config
from openspace.utils.logging import Logger
from .transport.connector import GUIConnector
from .transport.local_connector import LocalGUIConnector

View file

@ -30,6 +30,14 @@ from openspace.grounding.backends.mcp.transport.connectors.base import MCPBaseCo
logger = Logger.get_logger(__name__)
def _build_sse_candidate_urls(base_url: str) -> list[str]:
"""Try the common FastMCP `/sse` endpoint before the raw base URL."""
normalized = base_url.rstrip("/")
if normalized.endswith("/sse"):
return [normalized]
return [f"{normalized}/sse", normalized]
class HttpConnector(MCPBaseConnector):
"""Connector for MCP implementations using HTTP transport.
@ -210,69 +218,72 @@ class HttpConnector(MCPBaseConnector):
except (asyncio.TimeoutError, Exception):
pass
# Try SSE fallback
try:
logger.debug(f"Attempting SSE fallback connection to: {self.base_url}")
connection_manager = SseConnectionManager(
self.base_url, self.headers, self.timeout, self.sse_read_timeout
)
# Test the connection by starting it with built-in timeout
read_stream, write_stream = await connection_manager.start(timeout=self.timeout)
# Create and verify ClientSession
test_client = ClientSession(read_stream, write_stream, sampling_callback=None)
# Add timeout to __aenter__ - use asyncio.wait_for instead of anyio.fail_after
# to avoid cancel scope conflicts with background tasks
# Try SSE fallback. FastMCP commonly exposes legacy SSE on `/sse`,
# but some callers may already pass the full endpoint.
for sse_url in _build_sse_candidate_urls(self.base_url):
connection_manager = None
try:
await asyncio.wait_for(test_client.__aenter__(), timeout=self.timeout)
except asyncio.TimeoutError:
raise TimeoutError(f"ClientSession enter timed out after {self.timeout}s")
logger.debug(f"Attempting SSE fallback connection to: {sse_url}")
connection_manager = SseConnectionManager(
sse_url, self.headers, self.timeout, self.sse_read_timeout
)
try:
# Test the connection by starting it with built-in timeout
read_stream, write_stream = await connection_manager.start(timeout=self.timeout)
# Create and verify ClientSession
test_client = ClientSession(read_stream, write_stream, sampling_callback=None)
# Add timeout to __aenter__ - use asyncio.wait_for instead of anyio.fail_after
# to avoid cancel scope conflicts with background tasks
try:
await asyncio.wait_for(test_client.initialize(), timeout=self.timeout)
await asyncio.wait_for(test_client.__aenter__(), timeout=self.timeout)
except asyncio.TimeoutError:
raise TimeoutError(f"initialize() timed out after {self.timeout}s")
try:
await asyncio.wait_for(test_client.list_tools(), timeout=self.timeout)
except asyncio.TimeoutError:
raise TimeoutError(f"list_tools() timed out after {self.timeout}s")
# SUCCESS! Keep the client session (don't close it, closing destroys the streams)
# Store it directly as the client_session for later use
self.transport_type = "SSE"
self._connection_manager = connection_manager
self._connection = connection_manager.get_streams()
self.client_session = test_client # Reuse the working session
logger.debug("SSE transport selected")
return
except TimeoutError:
try:
await asyncio.wait_for(test_client.__aexit__(None, None, None), timeout=2)
except (asyncio.TimeoutError, Exception):
pass
raise
except Exception as init_error:
# Clean up the test client only on error
try:
await asyncio.wait_for(test_client.__aexit__(None, None, None), timeout=2)
except (asyncio.TimeoutError, Exception):
pass
raise init_error
raise TimeoutError(f"ClientSession enter timed out after {self.timeout}s")
except Exception as e:
sse_error = e
logger.debug(f"SSE failed: {e}")
# Clean up the failed connection manager
if connection_manager:
try:
await asyncio.wait_for(connection_manager.stop(), timeout=2)
except (asyncio.TimeoutError, Exception):
pass
try:
await asyncio.wait_for(test_client.initialize(), timeout=self.timeout)
except asyncio.TimeoutError:
raise TimeoutError(f"initialize() timed out after {self.timeout}s")
try:
await asyncio.wait_for(test_client.list_tools(), timeout=self.timeout)
except asyncio.TimeoutError:
raise TimeoutError(f"list_tools() timed out after {self.timeout}s")
# SUCCESS! Keep the client session (don't close it, closing destroys the streams)
# Store it directly as the client_session for later use
self.transport_type = "SSE"
self._connection_manager = connection_manager
self._connection = connection_manager.get_streams()
self.client_session = test_client # Reuse the working session
logger.debug("SSE transport selected")
return
except TimeoutError:
try:
await asyncio.wait_for(test_client.__aexit__(None, None, None), timeout=2)
except (asyncio.TimeoutError, Exception):
pass
raise
except Exception as init_error:
# Clean up the test client only on error
try:
await asyncio.wait_for(test_client.__aexit__(None, None, None), timeout=2)
except (asyncio.TimeoutError, Exception):
pass
raise init_error
except Exception as e:
sse_error = e
logger.debug(f"SSE failed for {sse_url}: {e}")
# Clean up the failed connection manager
if connection_manager:
try:
await asyncio.wait_for(connection_manager.stop(), timeout=2)
except (asyncio.TimeoutError, Exception):
pass
# Both MCP transports failed, try simple JSON-RPC HTTP as last resort
# This is useful for custom MCP servers that don't implement proper MCP transports

View file

@ -5,7 +5,7 @@ from .transport.connector import ShellConnector
from .transport.local_connector import LocalShellConnector
from openspace.config import get_config
from openspace.config.utils import get_config_value
from openspace.platform.config import get_local_server_config
from openspace.platforms.config import get_local_server_config
from openspace.utils.logging import Logger
logger = Logger.get_logger(__name__)

View file

@ -375,7 +375,7 @@ If you already have the exact command/script to run, use run_shell instead."""
if base_url is not None:
try:
from openspace.platform import SystemInfoClient
from openspace.platforms import SystemInfoClient
async with SystemInfoClient(base_url=base_url, timeout=5) as client:
info = await client.get_system_info(use_cache=False)

View file

@ -337,13 +337,13 @@ class GroundingClient:
def get_session_info(self, name: str) -> SessionInfo:
"""Get session monitoring info"""
if name not in self._session_info:
raise ErrorCode.SESSION_NOT_FOUND(name)
raise GroundingError(f"Session not found: {name}", code=ErrorCode.SESSION_NOT_FOUND)
return self._session_info[name]
def get_session(self, name: str) -> BaseSession:
"""Get session"""
if name not in self._sessions:
raise ErrorCode.SESSION_NOT_FOUND(name)
raise GroundingError(f"Session not found: {name}", code=ErrorCode.SESSION_NOT_FOUND)
return self._sessions[name]
@ -479,7 +479,7 @@ class GroundingClient:
# Session-level
if session_name:
if session_name not in self._sessions:
raise ErrorCode.SESSION_NOT_FOUND(session_name)
raise GroundingError(f"Session not found: {session_name}", code=ErrorCode.SESSION_NOT_FOUND)
backend_type = self._session_info[session_name].backend_type
return await self._fetch_tools(
backend_type,
@ -531,7 +531,7 @@ class GroundingClient:
use_cache: bool = False
) -> list[BaseTool]:
if session_name not in self._session_info:
raise ErrorCode.SESSION_NOT_FOUND(session_name)
raise GroundingError(f"Session not found: {session_name}", code=ErrorCode.SESSION_NOT_FOUND)
backend = self._session_info[session_name].backend_type
return await self.list_tools(backend, session_name, use_cache)
@ -838,7 +838,7 @@ class GroundingClient:
runtime_backend = backend
else:
if runtime_session not in self._session_info:
raise ErrorCode.SESSION_NOT_FOUND(runtime_session)
raise GroundingError(f"Session not found: {runtime_session}", code=ErrorCode.SESSION_NOT_FOUND)
runtime_backend = self._session_info[
runtime_session
].backend_type

View file

@ -21,7 +21,11 @@ Supported host agents:
import logging
from typing import Dict, Optional
from openspace.host_detection.resolver import build_llm_kwargs, build_grounding_config_path
from openspace.host_detection.resolver import (
build_grounding_config_path,
build_llm_kwargs,
load_runtime_env,
)
from openspace.host_detection.nanobot import (
get_openai_api_key as _nanobot_get_openai_api_key,
read_nanobot_mcp_env,
@ -31,6 +35,7 @@ from openspace.host_detection.openclaw import (
get_openclaw_openai_api_key as _openclaw_get_openai_api_key,
is_openclaw_host,
read_openclaw_skill_env,
try_read_openclaw_config,
)
logger = logging.getLogger("openspace.host_detection")
@ -80,12 +85,12 @@ def get_openai_api_key() -> Optional[str]:
__all__ = [
"build_llm_kwargs",
"build_grounding_config_path",
"load_runtime_env",
"get_openai_api_key",
"read_host_mcp_env",
# legacy re-exports
"read_nanobot_mcp_env",
"try_read_nanobot_config",
# openclaw-specific (for direct use if needed)
"is_openclaw_host",
"read_openclaw_skill_env",
"try_read_openclaw_config",
]

View file

@ -12,6 +12,7 @@ from __future__ import annotations
import json
import logging
import os
from pathlib import Path
from typing import Any, Dict, List, Optional
@ -31,23 +32,35 @@ PROVIDER_REGISTRY: List[tuple] = [
("zhipu", ("zhipu", "glm", "zai"), ""),
("dashscope", ("qwen", "dashscope"), ""),
("moonshot", ("moonshot", "kimi"), "https://api.moonshot.ai/v1"),
("minimax", ("minimax",), "https://api.minimax.io/v1"),
("minimax", ("minimax",), "https://api.minimaxi.com/v1"),
("groq", ("groq",), ""),
]
NANOBOT_CONFIG_PATH = Path.home() / ".nanobot" / "config.json"
def _resolve_nanobot_config_path() -> Path:
"""Resolve the nanobot config path from env overrides or defaults."""
explicit = os.environ.get("NANOBOT_CONFIG_PATH", "").strip()
if explicit:
return Path(explicit).expanduser()
state_dir = os.environ.get("NANOBOT_STATE_DIR", "").strip()
if state_dir:
return Path(state_dir).expanduser() / "config.json"
return Path.home() / ".nanobot" / "config.json"
def _load_nanobot_config() -> Optional[Dict[str, Any]]:
"""Load and parse ``~/.nanobot/config.json``. Returns None on failure."""
if not NANOBOT_CONFIG_PATH.is_file():
"""Load and parse nanobot config.json. Returns None on failure."""
config_path = _resolve_nanobot_config_path()
if not config_path.is_file():
return None
try:
with open(NANOBOT_CONFIG_PATH, encoding="utf-8") as f:
with open(config_path, encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, dict) else None
except (json.JSONDecodeError, OSError) as e:
logger.warning("Failed to read nanobot config %s: %s", NANOBOT_CONFIG_PATH, e)
logger.warning("Failed to read nanobot config %s: %s", config_path, e)
return None
@ -154,10 +167,11 @@ def try_read_nanobot_config(model: str) -> Optional[Dict[str, Any]]:
result["_forced_provider"] = forced_provider
if result:
config_path = _resolve_nanobot_config_path()
logger.info(
"Auto-detected LLM credentials from nanobot config (%s), "
"provider matched for model=%r",
NANOBOT_CONFIG_PATH, match_model,
config_path, match_model,
)
return result

View file

@ -1,7 +1,8 @@
"""OpenClaw host-agent config reader.
Reads ``~/.openclaw/openclaw.json`` to auto-detect:
- LLM provider credentials (via ``auth-profiles`` not yet implemented)
- LLM provider credentials from env-style config blocks
(``skills.entries.openspace.env`` and ``env.vars``)
- Skill-level env block (``skills.entries.openspace.env``)
- OpenAI API key for embedding generation
@ -17,20 +18,74 @@ from __future__ import annotations
import json
import logging
import os
from pathlib import Path
from typing import Any, Dict, Optional
from openspace.host_detection.nanobot import PROVIDER_REGISTRY
logger = logging.getLogger("openspace.host_detection")
_STATE_DIRNAMES = [".openclaw", ".clawdbot", ".moldbot", ".moltbot"]
_CONFIG_FILENAMES = ["openclaw.json", "clawdbot.json", "moldbot.json", "moltbot.json"]
_PROVIDER_ENV_VARS: Dict[str, Dict[str, tuple[str, ...]]] = {
"openrouter": {
"api_key": ("OPENROUTER_API_KEY", "OR_API_KEY"),
"api_base": ("OPENROUTER_API_BASE",),
},
"aihubmix": {
"api_key": ("AIHUBMIX_API_KEY",),
"api_base": ("AIHUBMIX_API_BASE",),
},
"siliconflow": {
"api_key": ("SILICONFLOW_API_KEY",),
"api_base": ("SILICONFLOW_API_BASE",),
},
"volcengine": {
"api_key": ("VOLCENGINE_API_KEY", "ARK_API_KEY"),
"api_base": ("VOLCENGINE_API_BASE", "ARK_API_BASE"),
},
"anthropic": {
"api_key": ("ANTHROPIC_API_KEY",),
"api_base": ("ANTHROPIC_API_BASE",),
},
"openai": {
"api_key": ("OPENAI_API_KEY",),
"api_base": ("OPENAI_BASE_URL", "OPENAI_API_BASE"),
},
"deepseek": {
"api_key": ("DEEPSEEK_API_KEY",),
"api_base": ("DEEPSEEK_API_BASE",),
},
"gemini": {
"api_key": ("GEMINI_API_KEY", "GOOGLE_API_KEY"),
"api_base": ("GEMINI_API_BASE", "GOOGLE_API_BASE"),
},
"zhipu": {
"api_key": ("ZHIPU_API_KEY",),
"api_base": ("ZHIPU_API_BASE",),
},
"dashscope": {
"api_key": ("DASHSCOPE_API_KEY",),
"api_base": ("DASHSCOPE_API_BASE",),
},
"moonshot": {
"api_key": ("MOONSHOT_API_KEY",),
"api_base": ("MOONSHOT_API_BASE",),
},
"minimax": {
"api_key": ("MINIMAX_API_KEY",),
"api_base": ("MINIMAX_API_BASE",),
},
"groq": {
"api_key": ("GROQ_API_KEY",),
"api_base": ("GROQ_API_BASE",),
},
}
def _resolve_openclaw_config_path() -> Optional[Path]:
"""Find the OpenClaw config file on disk."""
import os
# 1. Explicit env override
explicit = os.environ.get("OPENCLAW_CONFIG_PATH", "").strip()
if explicit:
p = Path(explicit).expanduser()
@ -38,7 +93,6 @@ def _resolve_openclaw_config_path() -> Optional[Path]:
return p
return None
# 2. State dir override
state_dir = os.environ.get("OPENCLAW_STATE_DIR", "").strip()
if state_dir:
for fname in _CONFIG_FILENAMES:
@ -46,7 +100,6 @@ def _resolve_openclaw_config_path() -> Optional[Path]:
if p.is_file():
return p
# 3. Default locations
home = Path.home()
for dirname in _STATE_DIRNAMES:
for fname in _CONFIG_FILENAMES:
@ -71,6 +124,119 @@ def _load_openclaw_config() -> Optional[Dict[str, Any]]:
return None
def _coerce_env_value(value: Any) -> str:
if value is None:
return ""
return str(value).strip()
def _pick_env(env_block: Dict[str, Any], names: tuple[str, ...]) -> str:
for name in names:
value = _coerce_env_value(env_block.get(name))
if value:
return value
return ""
def _get_openclaw_env(skill_name: str = "openspace") -> Dict[str, Any]:
"""Merge OpenClaw top-level env vars with skill-level env overrides."""
merged: Dict[str, Any] = {}
data = _load_openclaw_config()
if data and isinstance(data, dict):
env_section = data.get("env", {})
if isinstance(env_section, dict):
vars_block = env_section.get("vars", {})
if isinstance(vars_block, dict):
merged.update(vars_block)
merged.update(read_openclaw_skill_env(skill_name))
return merged
def _extract_explicit_llm_kwargs(env_block: Dict[str, Any]) -> Dict[str, Any]:
"""Read OpenSpace-native LLM overrides from an env-like dict."""
result: Dict[str, Any] = {}
api_key = _coerce_env_value(env_block.get("OPENSPACE_LLM_API_KEY"))
if api_key:
result["api_key"] = api_key
api_base = _coerce_env_value(env_block.get("OPENSPACE_LLM_API_BASE"))
if api_base:
result["api_base"] = api_base
extra_headers_raw = _coerce_env_value(env_block.get("OPENSPACE_LLM_EXTRA_HEADERS"))
if extra_headers_raw:
try:
headers = json.loads(extra_headers_raw)
if isinstance(headers, dict):
result["extra_headers"] = headers
except json.JSONDecodeError:
logger.warning(
"Invalid JSON in OpenClaw OPENSPACE_LLM_EXTRA_HEADERS: %r",
extra_headers_raw,
)
llm_config_raw = _coerce_env_value(env_block.get("OPENSPACE_LLM_CONFIG"))
if llm_config_raw:
try:
llm_config = json.loads(llm_config_raw)
if isinstance(llm_config, dict):
result.update(llm_config)
except json.JSONDecodeError:
logger.warning(
"Invalid JSON in OpenClaw OPENSPACE_LLM_CONFIG: %r",
llm_config_raw,
)
return result
def _extract_provider_env(
env_block: Dict[str, Any],
provider: str,
default_base: str = "",
) -> Optional[Dict[str, Any]]:
spec = _PROVIDER_ENV_VARS.get(provider)
if not spec:
return None
api_key = _pick_env(env_block, spec["api_key"])
if not api_key:
return None
result: Dict[str, Any] = {"api_key": api_key}
api_base = _pick_env(env_block, spec.get("api_base", ())) or default_base
if api_base:
result["api_base"] = api_base
return result
def _match_provider_env(model: str, env_block: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Resolve provider-native env vars from OpenClaw config for a model."""
model_lower = model.lower()
model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else ""
normalized_prefix = model_prefix.replace("-", "_")
for name, _keywords, default_base in PROVIDER_REGISTRY:
if model_prefix and normalized_prefix == name:
result = _extract_provider_env(env_block, name, default_base)
if result:
return result
for name, keywords, default_base in PROVIDER_REGISTRY:
if any(keyword in model_lower for keyword in keywords):
result = _extract_provider_env(env_block, name, default_base)
if result:
return result
for name, _keywords, default_base in PROVIDER_REGISTRY:
result = _extract_provider_env(env_block, name, default_base)
if result:
return result
return None
def read_openclaw_skill_env(skill_name: str = "openspace") -> Dict[str, str]:
"""Read ``skills.entries.<skill_name>.env`` from OpenClaw config.
@ -104,34 +270,46 @@ def get_openclaw_openai_api_key() -> Optional[str]:
Returns the key string, or None.
"""
# Try skill-level env
env = read_openclaw_skill_env("openspace")
key = env.get("OPENAI_API_KEY", "").strip()
env = _get_openclaw_env("openspace")
key = _coerce_env_value(env.get("OPENAI_API_KEY"))
if key:
logger.debug("Using OpenAI API key from OpenClaw skill env config")
return key
# Try top-level config env.vars
data = _load_openclaw_config()
if data:
env_section = data.get("env", {})
if isinstance(env_section, dict):
vars_block = env_section.get("vars", {})
if isinstance(vars_block, dict):
key = vars_block.get("OPENAI_API_KEY", "").strip()
if key:
logger.debug("Using OpenAI API key from OpenClaw env.vars config")
return key
return None
def is_openclaw_host() -> bool:
"""Detect if the current environment is running under OpenClaw."""
import os
# Check OpenClaw-specific env vars
if os.environ.get("OPENCLAW_STATE_DIR") or os.environ.get("OPENCLAW_CONFIG_PATH"):
return True
# Check if config exists
return _resolve_openclaw_config_path() is not None
def try_read_openclaw_config(model: str) -> Optional[Dict[str, Any]]:
"""Read LLM credentials from OpenClaw's env-style config blocks."""
env_block = _get_openclaw_env("openspace")
if not env_block:
return None
explicit_kwargs = _extract_explicit_llm_kwargs(env_block)
provider_kwargs = _match_provider_env(model or "", env_block)
if not explicit_kwargs and not provider_kwargs:
return None
result: Dict[str, Any] = {}
if provider_kwargs:
result.update(provider_kwargs)
if explicit_kwargs:
result.update(explicit_kwargs)
config_path = _resolve_openclaw_config_path()
logger.info(
"Auto-detected LLM credentials from OpenClaw config (%s), provider matched for model=%r",
config_path,
model,
)
return result

View file

@ -10,10 +10,120 @@ import json
import logging
import os
import tempfile
from pathlib import Path
from typing import Any, Dict, Optional
logger = logging.getLogger("openspace.host_detection")
_DEFAULT_MODEL = "openrouter/anthropic/claude-sonnet-4.5"
_PROVIDER_NATIVE_ENV_VARS: Dict[str, tuple[str, ...]] = {
"openrouter": ("OPENROUTER_API_KEY", "OR_API_KEY"),
"aihubmix": ("AIHUBMIX_API_KEY",),
"siliconflow": ("SILICONFLOW_API_KEY",),
"volcengine": ("VOLCENGINE_API_KEY", "ARK_API_KEY"),
"anthropic": ("ANTHROPIC_API_KEY",),
"openai": ("OPENAI_API_KEY",),
"deepseek": ("DEEPSEEK_API_KEY",),
"gemini": ("GEMINI_API_KEY", "GOOGLE_API_KEY"),
"zhipu": ("ZHIPU_API_KEY",),
"dashscope": ("DASHSCOPE_API_KEY",),
"moonshot": ("MOONSHOT_API_KEY",),
"minimax": ("MINIMAX_API_KEY",),
"groq": ("GROQ_API_KEY",),
}
_env_loaded = False
def _load_env_once() -> None:
"""Load .env files once per process.
Search order (first-loaded wins for each key):
1. ``openspace/.env`` (package root works regardless of CWD)
2. ``CWD/.env`` (project-level fallback)
Uses ``override=False`` so env vars already in the process (e.g. set
by the host agent or the shell) are never overwritten.
"""
global _env_loaded
if _env_loaded:
return
_env_loaded = True
try:
from dotenv import load_dotenv
except ImportError:
return
pkg_env = Path(__file__).resolve().parent.parent / ".env"
if pkg_env.is_file():
load_dotenv(pkg_env)
load_dotenv()
def load_runtime_env() -> None:
"""Public wrapper for one-time runtime .env loading."""
_load_env_once()
def _pick_first_env(names: tuple[str, ...]) -> str:
for name in names:
value = os.environ.get(name, "").strip()
if value:
return value
return ""
def _ensure_local_no_proxy() -> None:
required_hosts = ("127.0.0.1", "localhost")
for env_name in ("NO_PROXY", "no_proxy"):
current = os.environ.get(env_name, "")
entries = [entry.strip() for entry in current.split(",") if entry.strip()]
updated = False
for host in required_hosts:
if host not in entries:
entries.append(host)
updated = True
if updated:
os.environ[env_name] = ",".join(entries)
def _infer_provider_name(model: str) -> Optional[str]:
"""Infer the provider name from a model string using PROVIDER_REGISTRY."""
from openspace.host_detection.nanobot import PROVIDER_REGISTRY
model_lower = (model or "").lower()
model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else ""
normalized_prefix = model_prefix.replace("-", "_")
for name, _keywords, _default_base in PROVIDER_REGISTRY:
if model_prefix and normalized_prefix == name:
return name
for name, keywords, _default_base in PROVIDER_REGISTRY:
if any(keyword in model_lower for keyword in keywords):
return name
return None
def _has_provider_native_env(model: str) -> bool:
"""Check if a provider-native API key (e.g. OPENROUTER_API_KEY) exists.
When True, the key from .env or the process environment is sufficient
for litellm to authenticate no need to read nanobot / host config.
"""
provider = _infer_provider_name(model)
if not provider:
return False
env_names = _PROVIDER_NATIVE_ENV_VARS.get(provider)
if not env_names:
return False
return bool(_pick_first_env(env_names))
def build_llm_kwargs(model: str) -> tuple[str, Dict[str, Any]]:
"""Build litellm kwargs and resolve model for OpenSpace's LLM client.
@ -27,39 +137,63 @@ def build_llm_kwargs(model: str) -> tuple[str, Dict[str, Any]]:
OPENSPACE_LLM_EXTRA_HEADERS litellm ``extra_headers`` (JSON string)
OPENSPACE_LLM_CONFIG arbitrary litellm kwargs (JSON string)
Tier 2 Auto-detect from host agent config file::
Tier 2 Provider-native env vars already present in the process
(including values loaded from ``openspace/.env``)::
~/.nanobot/config.json providers.{matched}.apiKey / apiBase
OPENROUTER_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY / ...
Tier 3 Provider-native env vars inherited from the parent process
(e.g. ``OPENROUTER_API_KEY``). Read by litellm automatically.
These take precedence over host-agent config so local/standalone
launches are not hijacked by unrelated host config files.
Tier 3 Host-agent config file fallback (only when Tier 1+2 absent)::
nanobot ``~/.nanobot/config.json``
openclaw ``~/.openclaw/openclaw.json``
Returns:
``(resolved_model, llm_kwargs_dict)``
"""
from openspace.host_detection.nanobot import try_read_nanobot_config
_load_env_once()
kwargs: Dict[str, Any] = {}
resolved_model = model
source = "inherited env"
# --- Tier 2: auto-detect from host config (filled first, may be overridden) ---
host_config = try_read_nanobot_config(model)
has_explicit_llm_override = bool(
os.environ.get("OPENSPACE_LLM_API_BASE")
or os.environ.get("OPENSPACE_LLM_API_KEY")
)
provider_native_env_used = _has_provider_native_env(
resolved_model or _DEFAULT_MODEL
)
# --- Tier 3: host config fallback (only when no local keys) ---
host_config = None
host_source = None
if not has_explicit_llm_override and not provider_native_env_used:
from openspace.host_detection.nanobot import try_read_nanobot_config
host_config = try_read_nanobot_config(model)
if host_config:
host_source = "nanobot config"
else:
from openspace.host_detection.openclaw import try_read_openclaw_config
host_config = try_read_openclaw_config(model)
if host_config:
host_source = "openclaw config"
if host_config:
host_model = host_config.pop("_model", None)
forced_provider = host_config.pop("_forced_provider", None)
if not resolved_model and host_model:
resolved_model = host_model
# If the host config forces a gateway provider (e.g. openrouter)
# and the model name doesn't already carry that prefix, prepend
# it so that litellm uses the correct request format (OpenAI-
# compatible for gateways vs native for direct providers).
_GATEWAY_PROVIDERS = {"openrouter", "aihubmix", "siliconflow"}
if (
forced_provider
and forced_provider in _GATEWAY_PROVIDERS
and resolved_model
and not resolved_model.lower().startswith(f"{forced_provider}/")
and not (model and has_explicit_llm_override)
):
resolved_model = f"{forced_provider}/{resolved_model}"
logger.info(
@ -67,7 +201,7 @@ def build_llm_kwargs(model: str) -> tuple[str, Dict[str, Any]]:
resolved_model, forced_provider,
)
kwargs.update(host_config)
source = "nanobot config"
source = host_source or "host config"
# --- Tier 1: explicit env vars override everything ---
api_key = os.environ.get("OPENSPACE_LLM_API_KEY")
@ -100,7 +234,38 @@ def build_llm_kwargs(model: str) -> tuple[str, Dict[str, Any]]:
# Default model fallback
if not resolved_model:
resolved_model = "openrouter/anthropic/claude-sonnet-4.5"
resolved_model = _DEFAULT_MODEL
# Ollama models must use the Ollama-native API base, even when unrelated
# OPENSPACE_LLM_* env vars are present for a different provider.
if resolved_model.lower().startswith("ollama/"):
ollama_base = os.environ.get("OLLAMA_API_BASE", "").strip() or "http://127.0.0.1:11434"
_ensure_local_no_proxy()
kwargs["api_base"] = ollama_base.rstrip("/")
kwargs["api_key"] = os.environ.get("OLLAMA_API_KEY", "").strip() or kwargs.get("api_key") or "ollama"
kwargs.pop("extra_headers", None)
source = "ollama runtime"
# Provider-specific adjustments for litellm routing
if resolved_model and "minimax" in resolved_model.lower():
final_key = kwargs.get("api_key")
final_base = kwargs.get("api_base", "")
if final_key:
os.environ.setdefault("MINIMAX_API_KEY", final_key)
if final_base:
os.environ.setdefault("MINIMAX_API_BASE", final_base)
if (
resolved_model.lower().startswith("minimax/")
and "minimaxi.com" in final_base
):
original = resolved_model
resolved_model = "openai/" + resolved_model.split("/", 1)[1]
logger.info(
"Switched model prefix for minimaxi.com compat: %s -> %s",
original, resolved_model,
)
if kwargs:
safe = {
@ -108,6 +273,11 @@ def build_llm_kwargs(model: str) -> tuple[str, Dict[str, Any]]:
for k, v in kwargs.items()
}
logger.info("LLM kwargs resolved (source=%s): %s", source, safe)
elif provider_native_env_used:
logger.info(
"LLM credentials resolved from provider-native env for model=%r",
resolved_model,
)
return resolved_model, kwargs
@ -125,6 +295,8 @@ def build_grounding_config_path() -> Optional[str]:
Returns:
Path to the resolved config file, or None.
"""
_load_env_once()
config_json_raw = os.environ.get("OPENSPACE_CONFIG_JSON", "").strip()
overrides: Dict[str, Any] = {}
if config_json_raw:
@ -180,4 +352,3 @@ def build_grounding_config_path() -> Optional[str]:
logger.warning("Failed to write config overrides: %s", e)
return os.environ.get("OPENSPACE_CONFIG_PATH")

View file

@ -2,6 +2,17 @@
This guide covers **agent-specific setup** for integrating OpenSpace. For installation and general concepts, see the [main README](../../README.md#-quick-start).
**Quick recommendation:**
- Use **stdio** if you want the simplest setup.
- For **nanobot**, prefer **SSE** if you want OpenSpace to run as a standalone server.
- For **openclaw**, prefer **streamable-http** for remote HTTP transport.
**Common remote endpoints:**
- Start `openspace-mcp --transport sse --host 127.0.0.1 --port 8080` and use `http://127.0.0.1:8080/sse`
- Start `openspace-mcp --transport streamable-http --host 127.0.0.1 --port 8081` and use `http://127.0.0.1:8081/mcp`
The endpoint is common; the **host config syntax is not**. nanobot uses `tools.mcpServers`, while openclaw uses `openclaw mcp set`.
**Pick your agent:**
| Agent | Setup Guide |
@ -21,7 +32,7 @@ cp -r host_skills/skill-discovery/ /path/to/nanobot/nanobot/skills/
cp -r host_skills/delegate-task/ /path/to/nanobot/nanobot/skills/
```
### 2. Add MCP server to `~/.nanobot/config.json`
### 2. Option A: stdio (simplest)
```json
{
@ -44,44 +55,75 @@ cp -r host_skills/delegate-task/ /path/to/nanobot/nanobot/skills/
> [!TIP]
> LLM credentials are auto-detected from nanobot's `providers.*` config — no need to set `OPENSPACE_LLM_API_KEY`.
---
## Setup for openclaw
openclaw ships with a built-in `skills/openspace/` skill — **no need to copy host_skills**.
> [!NOTE]
> openclaw's built-in skill merges skill-discovery + delegate-task into a single SKILL.md with scenario sub-pages. It uses `mcporter call openspace.<tool>` syntax. The underlying MCP tools are identical.
### 1. Register MCP server
openclaw uses [mcporter](https://github.com/steipete/mcporter) as its MCP runtime:
```bash
mcporter config add openspace --command "openspace-mcp"
```
### 2. Configure env vars
Set in `~/.openclaw/openclaw.json`:
### 3. Option B: remote HTTP transport
```json
{
"skills": {
"entries": {
"tools": {
"mcpServers": {
"openspace": {
"env": {
"OPENSPACE_HOST_SKILL_DIRS": "/path/to/openclaw/skills",
"OPENSPACE_WORKSPACE": "/path/to/OpenSpace",
"OPENSPACE_API_KEY": "sk-xxx"
}
"type": "sse",
"url": "http://127.0.0.1:8080/sse",
"toolTimeout": 1200
}
}
}
}
```
Or set as system env vars (e.g. `~/.openclaw/.env`).
Or:
```json
{
"tools": {
"mcpServers": {
"openspace": {
"type": "streamableHttp",
"url": "http://127.0.0.1:8081/mcp",
"toolTimeout": 1200
}
}
}
}
```
`toolTimeout` still matters here. Changing transport to `sse` or `streamableHttp` does **not** remove nanobot's per-call timeout for slow MCP tools.
---
## Setup for openclaw
### 1. Copy host skills
```bash
cp -r host_skills/skill-discovery/ /path/to/openclaw/skills/
cp -r host_skills/delegate-task/ /path/to/openclaw/skills/
```
### 2. Option A: stdio via mcporter
openclaw uses [mcporter](https://github.com/steipete/mcporter) as its MCP runtime. Register the server and pass env vars in one command:
```bash
mcporter config add openspace --command "openspace-mcp" \
--env OPENSPACE_HOST_SKILL_DIRS=/path/to/openclaw/skills \
--env OPENSPACE_WORKSPACE=/path/to/OpenSpace \
--env OPENSPACE_API_KEY=sk-xxx
```
### 3. Option B: remote HTTP transport
```bash
openclaw mcp set openspace '{"url":"http://127.0.0.1:8081/mcp","transport":"streamable-http","connectionTimeoutMs":10000}'
```
If you specifically want legacy SSE instead, OpenClaw also supports:
```bash
openclaw mcp set openspace '{"url":"http://127.0.0.1:8080","connectionTimeoutMs":10000}'
```
`connectionTimeoutMs` controls connection establishment for the remote server. It does **not** guarantee unlimited runtime for a long-running MCP tool call.
---
@ -110,7 +152,7 @@ All tools default to `"all"` (local + cloud) and **automatically fall back** to
```
Your Agent (nanobot / openclaw / ...)
│ MCP protocol (stdio)
│ MCP protocol (stdio | HTTP/SSE | streamable-http)
openspace-mcp ← 4 tools exposed
├── execute_task ← multi-step grounding agent loop
@ -129,4 +171,4 @@ The two host skills teach the agent **when and how** to call these tools:
Skills auto-evolve inside `execute_task` (**FIX** / **DERIVED** / **CAPTURED**). After every call, your agent reports results to the user via its messaging tool.
> [!NOTE]
> For full parameter tables, examples, and decision trees, see each skill's SKILL.md directly.
> For full parameter tables, examples, and decision trees, see each skill's SKILL.md directly.

View file

@ -5,7 +5,7 @@ description: Delegate tasks to OpenSpace — a full-stack autonomous worker for
# Delegate Tasks to OpenSpace
OpenSpace is connected as an MCP server. You have 4 tools available: `execute_task`, `search_skills`, `fix_skill`, `upload_skill`.
OpenSpace is connected as an MCP server. Whether the host uses `stdio`, `sse`, or `streamable-http`, you have the same 4 tools available: `execute_task`, `search_skills`, `fix_skill`, `upload_skill`.
## When to use
@ -127,5 +127,6 @@ upload_skill(
## Notes
- `execute_task` may take minutes — this is expected for multi-step tasks.
- If `execute_task` times out, first check the host's MCP timeout settings. Changing from `stdio` to HTTP (`sse` or `streamable-http`) does not remove host-side per-call time limits.
- `upload_skill` requires a cloud API key; if it fails, the evolved skill is still saved locally.
- After every OpenSpace call, **tell the user** what happened: task result, any evolved skills, and your upload decision.

View file

@ -2,21 +2,16 @@ import litellm
import json
import asyncio
import time
from pathlib import Path
from typing import List, Sequence, Union, Dict, Optional
from dotenv import load_dotenv
from openai.types.chat import ChatCompletionToolParam
from openspace.grounding.core.types import ToolSchema, ToolResult, ToolStatus
from openspace.grounding.core.tool import BaseTool
from openspace.utils.logging import Logger
# Load .env from openspace package root (works regardless of CWD),
# then fall back to CWD/.env. override=False (default) means first-loaded wins.
_PKG_ENV = Path(__file__).resolve().parent.parent / ".env" # openspace/.env
if _PKG_ENV.is_file():
load_dotenv(_PKG_ENV)
load_dotenv() # also try CWD/.env for any remaining vars
# .env loading is centralized in host_detection.resolver.load_runtime_env().
# CLI/MCP entrypoints call it before reading startup env vars, and the
# resolver helpers also call it defensively.
# Disable LiteLLM verbose logging to prevent stdout blocking with large tool schemas
litellm.set_verbose = False
@ -175,9 +170,10 @@ def _infer_backend_from_tool_name(tool_name: str) -> Optional[str]:
if not tool_name or not isinstance(tool_name, str):
return None
name = tool_name.strip()
# Dedup format: "server__toolname" -> use suffix
# Dedup format: "server__toolname" -> use suffix.
# Use rsplit to handle server names that themselves contain "__".
if "__" in name:
name = name.split("__", 1)[-1]
name = name.rsplit("__", 1)[-1]
shell_tools = {"shell_agent", "read_file", "write_file", "list_dir", "run_shell"}
if name in shell_tools:
return "shell"
@ -190,6 +186,37 @@ def _infer_backend_from_tool_name(tool_name: str) -> Optional[str]:
return None
def _resolve_tool_call_target(
tool_name: str,
tool_map: Dict[str, BaseTool],
) -> tuple[Optional[BaseTool], List[str]]:
"""Resolve a returned tool name to a concrete tool object.
The LLM is expected to return the deduped tool key from ``tool_map``.
Some providers occasionally return the short schema name instead. In that
case we only recover when exactly one tool shares that schema name; if
multiple tools match, the call is ambiguous and should not be executed.
"""
tool_obj = tool_map.get(tool_name)
if tool_obj is not None or not tool_name:
return tool_obj, []
fallback_matches = [
(llm_name, tool)
for llm_name, tool in tool_map.items()
if getattr(getattr(tool, "schema", None), "name", None) == tool_name
]
if len(fallback_matches) == 1:
resolved_name, resolved_tool = fallback_matches[0]
logger.info(
f"[TOOL_FALLBACK] Resolved short tool name '{tool_name}' to '{resolved_name}'"
)
return resolved_tool, []
if len(fallback_matches) > 1:
return None, [llm_name for llm_name, _tool in fallback_matches]
return None, []
DEFAULT_SUMMARIZE_THRESHOLD_CHARS = 200000 # ~50K tokens, lowered from 400K to prevent context overflow
MAX_TOOL_RESULT_CHARS = 200000 # Fallback truncation limit when summarization fails (~50K tokens)
@ -198,7 +225,8 @@ async def _summarize_tool_result(
tool_name: str,
task: str = "",
model: str = "openrouter/anthropic/claude-sonnet-4.5",
timeout: float = 120.0
timeout: float = 120.0,
litellm_kwargs: Optional[Dict] = None,
) -> str:
"""Use LLM to summarize large tool results."""
try:
@ -234,11 +262,13 @@ Content:
Concise summary:"""
_extra = litellm_kwargs or {}
response = await asyncio.wait_for(
litellm.acompletion(
model=model,
messages=[{"role": "user", "content": prompt}],
timeout=timeout
timeout=timeout,
**_extra,
),
timeout=timeout + 5
)
@ -265,7 +295,8 @@ async def _tool_result_to_message_async(
task: str = "",
summarize_threshold: int = DEFAULT_SUMMARIZE_THRESHOLD_CHARS,
summarize_model: str = "openrouter/anthropic/claude-sonnet-4.5",
enable_summarization: bool = True
enable_summarization: bool = True,
litellm_kwargs: Optional[Dict] = None,
) -> Dict:
"""Convert ToolResult to LLMClient usable message format with LLM summarization for large results.
@ -294,7 +325,7 @@ async def _tool_result_to_message_async(
# Use LLM summarization if content exceeds threshold
if original_len > summarize_threshold and enable_summarization:
summary = await _summarize_tool_result(text_content, tool_name, task, summarize_model)
summary = await _summarize_tool_result(text_content, tool_name, task, summarize_model, litellm_kwargs=litellm_kwargs)
if summary:
text_content = summary
elif original_len > MAX_TOOL_RESULT_CHARS:
@ -406,6 +437,95 @@ class LLMClient:
self._logger = Logger.get_logger(__name__)
self._last_call_time = 0.0
@staticmethod
def _merge_consecutive_system_messages(messages: List[Dict]) -> List[Dict]:
"""Merge consecutive system messages into one.
Providers like MiniMax reject requests that contain multiple consecutive
messages with the same role (error 2013 "invalid chat setting").
Merging is safe for all providers it simply concatenates the content.
"""
if not messages:
return messages
merged: List[Dict] = []
for msg in messages:
if (
merged
and msg.get("role") == "system"
and merged[-1].get("role") == "system"
):
merged[-1] = {
"role": "system",
"content": merged[-1].get("content", "") + "\n\n" + msg.get("content", ""),
}
else:
merged.append(msg.copy())
return merged
@staticmethod
def _is_minimax_model(model: str) -> bool:
return isinstance(model, str) and "minimax" in model.lower()
@classmethod
def _rewrite_nonleading_system_messages_for_minimax(
cls,
messages: List[Dict],
) -> List[Dict]:
"""Rewrite non-leading system messages into internal user notes for MiniMax."""
rewritten: List[Dict] = []
rewritten_count = 0
for msg in messages:
msg_copy = msg.copy()
if msg_copy.get("role") == "system" and rewritten:
content = msg_copy.get("content", "")
if isinstance(content, str):
msg_copy["content"] = (
"[INTERNAL ORCHESTRATION NOTE]\n"
"This note was originally injected as a system message by the "
"agent runtime. Treat it as workflow guidance, not as a new "
"end-user request.\n\n"
f"{content}"
)
msg_copy["role"] = "user"
rewritten_count += 1
rewritten.append(msg_copy)
if rewritten_count:
logger.info(
"Rewrote %d non-leading system message(s) for MiniMax compatibility",
rewritten_count,
)
return rewritten
@classmethod
def _normalize_messages_for_model(cls, messages: List[Dict], model: str) -> List[Dict]:
"""Normalize message history only when a provider requires it."""
if not cls._is_minimax_model(model):
return messages
minimized_system_history = cls._merge_consecutive_system_messages(messages)
return cls._rewrite_nonleading_system_messages_for_minimax(
minimized_system_history
)
@staticmethod
def _serialize_response_field(value):
"""Convert provider response fields into plain Python containers."""
if hasattr(value, "model_dump"):
return value.model_dump(exclude_none=True)
if isinstance(value, list):
return [LLMClient._serialize_response_field(item) for item in value]
if isinstance(value, tuple):
return [LLMClient._serialize_response_field(item) for item in value]
if isinstance(value, dict):
return {
key: LLMClient._serialize_response_field(item)
for key, item in value.items()
}
return value
async def _rate_limit(self):
"""Apply rate limiting by adding delay between API calls"""
if self.rate_limit_delay > 0:
@ -539,6 +659,7 @@ class LLMClient:
"model": kwargs.get("model", self.model),
**self.litellm_kwargs,
}
request_model = completion_kwargs["model"]
# Add thinking/reasoning_effort only if explicitly enabled and not using tools
enable_thinking = kwargs.get("enable_thinking", self.enable_thinking)
@ -561,10 +682,16 @@ class LLMClient:
if enable_thinking:
completion_kwargs["reasoning_effort"] = kwargs.get("reasoning_effort", "medium")
# 4. Apply rate limiting
# 4. Normalize messages for providers with stricter role constraints.
current_messages = self._normalize_messages_for_model(
current_messages,
request_model,
)
# 5. Apply rate limiting
await self._rate_limit()
# 5. Call LLM with retry (single round)
# 6. Call LLM with retry (single round)
completion_kwargs["messages"] = current_messages
response = await self._call_with_retry(**completion_kwargs)
@ -578,6 +705,11 @@ class LLMClient:
"role": "assistant",
"content": response_message.content or "",
}
for field_name in ("reasoning_details", "reasoning_content", "name"):
field_value = getattr(response_message, field_name, None)
if field_value:
assistant_message[field_name] = self._serialize_response_field(field_value)
tool_calls = getattr(response_message, 'tool_calls', None)
if tool_calls:
@ -604,14 +736,9 @@ class LLMClient:
for tool_call in tool_calls:
tool_name = tool_call.function.name
# Resolve tool instance: key might differ from model response (e.g. API returns
# "read_file" while we stored "server__read_file" for dedup), so fallback by schema.name
tool_obj = tool_map.get(tool_name)
if tool_obj is None and tool_name:
for _k, _t in tool_map.items():
if getattr(getattr(_t, "schema", None), "name", None) == tool_name:
tool_obj = _t
break
# Resolve tool instance: some providers return the short schema
# name instead of the deduped LLM-visible tool key.
tool_obj, ambiguous_tool_names = _resolve_tool_call_target(tool_name, tool_map)
backend = None
server_name = None
@ -653,15 +780,24 @@ class LLMClient:
except (json.JSONDecodeError, ValueError, TypeError) as e:
self._logger.debug(f"Failed to parse tool arguments for {tool_name}: {e}")
if tool_name not in tool_map:
result = ToolResult(
status=ToolStatus.ERROR,
error=f"Tool '{tool_name}' not found"
)
if tool_obj is None:
if ambiguous_tool_names:
result = ToolResult(
status=ToolStatus.ERROR,
error=(
f"Tool '{tool_name}' is ambiguous; matches: "
f"{', '.join(ambiguous_tool_names)}"
)
)
else:
result = ToolResult(
status=ToolStatus.ERROR,
error=f"Tool '{tool_name}' not found"
)
else:
try:
result = await _execute_tool_call(
tool=tool_map[tool_name],
tool=tool_obj,
openai_tool_call={
"id": tool_call.id,
"type": "function",
@ -697,7 +833,8 @@ class LLMClient:
task=user_task,
summarize_threshold=self.summarize_threshold_chars,
summarize_model=self.model,
enable_summarization=self.enable_tool_result_summarization
enable_summarization=self.enable_tool_result_summarization,
litellm_kwargs=self.litellm_kwargs,
)
current_messages.append(tool_message)
@ -722,6 +859,10 @@ class LLMClient:
"content": summary_prompt
}
current_messages.append(summary_message)
current_messages = self._normalize_messages_for_model(
current_messages,
request_model,
)
# Apply rate limiting before summary call
await self._rate_limit()
@ -729,7 +870,7 @@ class LLMClient:
# Call LLM to generate summary (without tools)
summary_kwargs = {
**self.litellm_kwargs,
"model": self.model,
"model": request_model,
"messages": current_messages,
"tools": [],
"tool_choice": "none",

View file

@ -7,8 +7,9 @@ Exposes the following tools to MCP clients:
upload_skill Upload a local skill to cloud (pre-saved metadata, bot decides visibility)
Usage:
python -m openspace.mcp_server # stdio (default)
python -m openspace.mcp_server # auto (TTY -> SSE, MCP host -> stdio)
python -m openspace.mcp_server --transport sse # SSE on port 8080
python -m openspace.mcp_server --transport streamable-http # Streamable HTTP on port 8080
python -m openspace.mcp_server --port 9090 # SSE on custom port
Environment variables: see ``openspace/host_detection/`` and ``openspace/cloud/auth.py``.
@ -22,7 +23,6 @@ import json
import logging
import os
import sys
import traceback
from pathlib import Path
from typing import Any, Dict, List, Optional
@ -75,12 +75,27 @@ class _MCPSafeStdout:
def seekable(self):
return False
_real_stdout = sys.stdout
sys.stdout = _MCPSafeStdout(_real_stdout, sys.stderr)
def __getattr__(self, name):
return getattr(self._stderr, name)
_LOG_DIR = Path(__file__).resolve().parent.parent / "logs"
_LOG_DIR.mkdir(parents=True, exist_ok=True)
_real_stdout = sys.stdout
# Windows pipe buffers are small. When using stdio MCP transport,
# the parent process only reads stdout for MCP messages and does NOT
# drain stderr. Heavy log/print output during execute_task fills the stderr
# pipe buffer, blocking this process on write() → deadlock → timeout.
# Redirect stderr to a log file on Windows to prevent this.
if os.name == "nt":
_stderr_file = open(
_LOG_DIR / "mcp_stderr.log", "a", encoding="utf-8", buffering=1
)
sys.stderr = _stderr_file
sys.stdout = _MCPSafeStdout(_real_stdout, sys.stderr)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
@ -123,7 +138,13 @@ async def _get_openspace():
logger.info("Initializing OpenSpace engine ...")
from openspace.tool_layer import OpenSpace, OpenSpaceConfig
from openspace.host_detection import build_llm_kwargs, build_grounding_config_path
from openspace.host_detection import (
build_grounding_config_path,
build_llm_kwargs,
load_runtime_env,
)
load_runtime_env()
env_model = os.environ.get("OPENSPACE_MODEL", "")
workspace = os.environ.get("OPENSPACE_WORKSPACE")
@ -183,6 +204,61 @@ def _get_store():
return _standalone_store
def _get_local_skill_registry():
"""Build a lightweight SkillRegistry for local-only skill search.
This avoids initializing the full OpenSpace engine when callers only
want to inspect local skills. It mirrors the skill directory discovery
order used by the full engine, but skips LLM / provider startup.
The registry is rebuilt per call so later local searches can see
newly added skills without requiring a process restart.
"""
from openspace.config import get_config
from openspace.skill_engine import SkillRegistry
skill_paths: List[Path] = []
host_dirs_raw = os.environ.get("OPENSPACE_HOST_SKILL_DIRS", "")
if host_dirs_raw:
for d in host_dirs_raw.split(","):
d = d.strip()
if not d:
continue
p = Path(d)
if p.exists():
skill_paths.append(p)
else:
logger.warning("Host skill dir does not exist: %s", d)
try:
skill_cfg = get_config().skills
except Exception as e:
logger.warning("Failed to load local skill config: %s", e)
skill_cfg = None
if skill_cfg and skill_cfg.skill_dirs:
for d in skill_cfg.skill_dirs:
p = Path(d)
if p in skill_paths:
continue
if p.exists():
skill_paths.append(p)
else:
logger.warning("Configured skill dir does not exist: %s", d)
builtin_skills = Path(__file__).resolve().parent / "skills"
if builtin_skills.exists():
skill_paths.append(builtin_skills)
if not skill_paths:
logger.debug("No local skill directories found")
return None
registry = SkillRegistry(skill_dirs=skill_paths)
registry.discover()
return registry
def _get_cloud_client():
"""Get a OpenSpaceClient instance (raises CloudError if not configured)."""
from openspace.cloud.auth import get_openspace_auth
@ -259,16 +335,13 @@ def _read_upload_meta(skill_dir: Path) -> Dict[str, Any]:
async def _auto_register_skill_dirs(skill_dirs: List[str]) -> int:
"""Register bot skill directories into OpenSpace's SkillRegistry + DB.
Called automatically by ``execute_task`` when ``skill_dirs`` is provided.
Already-registered directories are skipped (idempotent within a session).
Called automatically by ``execute_task`` on every invocation. Directories
are re-scanned each time so that skills created by the host bot since the last call are discovered immediately.
"""
global _registered_skill_dirs
new_dirs = [
Path(d) for d in skill_dirs
if d not in _registered_skill_dirs and Path(d).is_dir()
]
if not new_dirs:
valid_dirs = [Path(d) for d in skill_dirs if Path(d).is_dir()]
if not valid_dirs:
return 0
openspace = await _get_openspace()
@ -277,19 +350,21 @@ async def _auto_register_skill_dirs(skill_dirs: List[str]) -> int:
logger.warning("_auto_register_skill_dirs: SkillRegistry not initialized")
return 0
added = registry.discover_from_dirs(new_dirs)
added = registry.discover_from_dirs(valid_dirs)
db_created = 0
if added:
store = _get_store()
db_created = await store.sync_from_registry(added)
is_first = any(d not in _registered_skill_dirs for d in skill_dirs)
for d in skill_dirs:
_registered_skill_dirs.add(d)
if added:
action = "Auto-registered" if is_first else "Re-scanned & found"
logger.info(
f"Auto-registered {len(added)} skill(s) from {len(new_dirs)} dir(s), "
f"{action} {len(added)} skill(s) from {len(valid_dirs)} dir(s), "
f"{db_created} new DB record(s)"
)
return len(added)
@ -299,63 +374,48 @@ async def _cloud_search_and_import(task: str, limit: int = 8) -> List[Dict[str,
"""Search cloud for skills relevant to *task* and auto-import top hits.
This is **stage 1** of a two-stage pipeline:
Stage 1 (here): cloud BM25+embedding pick top-N to import locally.
Stage 1 (here): server-side embedding search pick top-N to import locally.
Stage 2 (tool_layer): local BM25 + LLM select from ALL local skills
(including ones just imported) for injection.
Stage 1 intentionally imports more than will be used (default: 8) so
that stage 2 has a larger pool to choose from. The two BM25 passes
are NOT redundant stage 1 filters thousands of cloud candidates down
that stage 2 has a larger pool to choose from. Stage 1 relies on the
server's embedding search to filter thousands of cloud candidates down
to a manageable import set; stage 2 makes the final task-specific choice.
"""
try:
from openspace.cloud.search import (
SkillSearchEngine, build_cloud_candidates,
)
from openspace.cloud.embedding import generate_embedding, resolve_embedding_api
client = _get_cloud_client()
embedding_api_key, _ = resolve_embedding_api()
has_embedding = bool(embedding_api_key)
items = await asyncio.to_thread(
client.fetch_metadata, include_embedding=has_embedding, limit=200,
)
if not items:
normalized_task_query = task.strip()
if not normalized_task_query:
return []
candidates = build_cloud_candidates(items)
if not candidates:
cloud_client = _get_cloud_client()
cloud_search_results = await asyncio.to_thread(
cloud_client.search_record_embeddings,
query=normalized_task_query,
limit=min(limit * 2, 300),
)
if not cloud_search_results:
return []
query_embedding: Optional[List[float]] = None
if has_embedding:
query_embedding = await asyncio.to_thread(
generate_embedding, task,
)
engine = SkillSearchEngine()
results = engine.search(task, candidates, query_embedding=query_embedding, limit=limit * 2)
cloud_hits = [
r for r in results
if r.get("source") == "cloud"
and r.get("visibility", "public") == "public"
and r.get("skill_id")
public_cloud_hits = [
cloud_result for cloud_result in cloud_search_results
if cloud_result.get("visibility", "public") == "public"
and cloud_result.get("record_id")
][:limit]
import_results: List[Dict[str, Any]] = []
for hit in cloud_hits:
for cloud_hit in public_cloud_hits:
try:
imp = await _do_import_cloud_skill(skill_id=hit["skill_id"])
skill_id = cloud_hit["record_id"]
imp = await _do_import_cloud_skill(skill_id=skill_id)
import_results.append({
"skill_id": hit["skill_id"],
"name": hit.get("name", ""),
"skill_id": skill_id,
"name": cloud_hit.get("name", ""),
"import_status": imp.get("status", "error"),
"local_path": imp.get("local_path", ""),
})
except Exception as e:
logger.warning(f"Cloud import failed for {hit['skill_id']}: {e}")
logger.warning(f"Cloud import failed for {skill_id}: {e}")
if import_results:
logger.info(f"Cloud search imported {len(import_results)} skill(s)")
@ -495,8 +555,9 @@ async def execute_task(
workspace_dir: Working directory. Defaults to OPENSPACE_WORKSPACE env.
max_iterations: Max agent iterations (default: 20).
skill_dirs: Bot's skill directories to auto-register so OpenSpace
can select and track them. Already-registered dirs are
silently skipped.
can select and track them. Directories are re-scanned
on every call to discover skills created since the last
invocation.
search_scope: Skill search scope before execution.
"all" (default) local + cloud; falls back to local
if no API key is configured.
@ -505,10 +566,32 @@ async def execute_task(
try:
openspace = await _get_openspace()
# Auto-register bot skill directories
# Re-scan host skill directories (from env) to pick up skills
# created by the host bot since the last call.
host_skill_dirs_raw = os.environ.get("OPENSPACE_HOST_SKILL_DIRS", "")
if host_skill_dirs_raw:
env_dirs = [d.strip() for d in host_skill_dirs_raw.split(",") if d.strip()]
if env_dirs:
await _auto_register_skill_dirs(env_dirs)
# Auto-register bot skill directories (from call parameter)
if skill_dirs:
await _auto_register_skill_dirs(skill_dirs)
# Determine where CAPTURED skills should be written.
# Prefer the explicit skill_dirs parameter (= calling host agent's dir),
# then fall back to the first env-based host skill dir.
capture_skill_dir: str | None = None
if skill_dirs:
capture_skill_dir = skill_dirs[0]
elif host_skill_dirs_raw:
first_env = next(
(d.strip() for d in host_skill_dirs_raw.split(",") if d.strip()),
None,
)
if first_env:
capture_skill_dir = first_env
# Cloud search + import (if requested)
imported_skills: List[Dict[str, Any]] = []
if search_scope == "all":
@ -519,6 +602,7 @@ async def execute_task(
task=task,
workspace_dir=workspace_dir,
max_iterations=max_iterations,
capture_skill_dir=capture_skill_dir,
)
# Write .upload_meta.json for each evolved skill
@ -534,7 +618,7 @@ async def execute_task(
except Exception as e:
logger.error(f"execute_task failed: {e}", exc_info=True)
return _json_error(e, status="error", traceback=traceback.format_exc(limit=5))
return _json_error(e, status="error")
@mcp.tool()
@ -571,11 +655,22 @@ async def search_skills(
if not q:
return _json_ok({"results": [], "count": 0})
# Resolve local skills + store
# Re-scan host skill directories so newly created skills are searchable.
local_skills = None
store = None
if source in ("all", "local"):
if source == "local":
registry = _get_local_skill_registry()
if registry:
local_skills = registry.list_skills()
elif source == "all":
openspace = await _get_openspace()
host_skill_dirs_raw = os.environ.get("OPENSPACE_HOST_SKILL_DIRS", "")
if host_skill_dirs_raw:
env_dirs = [d.strip() for d in host_skill_dirs_raw.split(",") if d.strip()]
if env_dirs:
await _auto_register_skill_dirs(env_dirs)
registry = openspace._skill_registry
if registry:
local_skills = registry.list_skills()
@ -744,7 +839,7 @@ async def fix_skill(
except Exception as e:
logger.error(f"fix_skill failed: {e}", exc_info=True)
return _json_error(e, status="error", traceback=traceback.format_exc(limit=5))
return _json_error(e, status="error")
@mcp.tool()
@ -815,20 +910,83 @@ async def upload_skill(
except Exception as e:
logger.error(f"upload_skill failed: {e}", exc_info=True)
return _json_error(e, status="error", traceback=traceback.format_exc(limit=5))
return _json_error(e, status="error")
def run_mcp_server() -> None:
"""Console-script entry point for ``openspace-mcp``."""
import argparse
parser = argparse.ArgumentParser(description="OpenSpace MCP Server")
parser.add_argument("--transport", choices=["stdio", "sse"], default="stdio")
parser.add_argument("--port", type=int, default=8080)
args = parser.parse_args()
def _port_flag_was_set(argv: list[str]) -> bool:
return any(arg == "--port" or arg.startswith("--port=") for arg in argv)
if args.transport == "sse":
mcp.run(transport="sse", sse_params={"port": args.port})
def _parse_port_from_env(default: int = 8080) -> int:
raw_port = os.environ.get("OPENSPACE_MCP_PORT", "").strip()
if not raw_port:
return default
try:
return int(raw_port)
except ValueError:
logger.warning(
"Ignoring invalid OPENSPACE_MCP_PORT=%r; falling back to %d.",
raw_port,
default,
)
return default
def _parse_host_from_env(default: str = "127.0.0.1") -> str:
return os.environ.get("OPENSPACE_MCP_HOST", "").strip() or default
def _resolve_transport(requested_transport: str, argv: list[str]) -> str:
if requested_transport in ("stdio", "sse", "streamable-http"):
return requested_transport
env_transport = os.environ.get("OPENSPACE_MCP_TRANSPORT", "").strip().lower()
if env_transport:
if env_transport in ("stdio", "sse", "streamable-http"):
return env_transport
logger.warning(
"Ignoring invalid OPENSPACE_MCP_TRANSPORT=%r; expected 'stdio', 'sse', or 'streamable-http'.",
env_transport,
)
# Treat an explicit port override as an HTTP/SSE intent. This keeps the
# CLI behavior aligned with the usage examples above.
if _port_flag_was_set(argv):
return "sse"
stdin_is_tty = hasattr(sys.stdin, "isatty") and sys.stdin.isatty()
stdout_is_tty = _real_stdout.isatty()
return "sse" if stdin_is_tty and stdout_is_tty else "stdio"
argv = sys.argv[1:]
parser = argparse.ArgumentParser(description="OpenSpace MCP Server")
parser.add_argument(
"--transport",
choices=["auto", "stdio", "sse", "streamable-http"],
default="auto",
)
parser.add_argument("--host", default=_parse_host_from_env())
parser.add_argument("--port", type=int, default=_parse_port_from_env())
args = parser.parse_args(argv)
transport = _resolve_transport(args.transport, argv)
if transport == "sse":
mcp.settings.host = args.host
mcp.settings.port = args.port
logger.info("Starting OpenSpace MCP server with SSE transport on port %s", args.port)
mcp.run(transport="sse")
elif transport == "streamable-http":
mcp.settings.host = args.host
mcp.settings.port = args.port
logger.info(
"Starting OpenSpace MCP server with streamable HTTP transport on %s:%s",
args.host,
args.port,
)
mcp.run(transport="streamable-http")
else:
logger.info("Starting OpenSpace MCP server with stdio transport")
mcp.run(transport="stdio")

View file

@ -500,7 +500,7 @@ class RecordingManager:
# create video client (internal management)
if self.enable_video:
from openspace.platform import RecordingClient
from openspace.platforms import RecordingClient
self._recording_client = RecordingClient(base_url=self.server_url)
success = await self._recording_client.start_recording()
if success:
@ -510,7 +510,7 @@ class RecordingManager:
# create screenshot client (internal management)
if self.enable_screenshot:
from openspace.platform import ScreenshotClient
from openspace.platforms import ScreenshotClient
self._screenshot_client = ScreenshotClient(base_url=self.server_url)
logger.debug("Screenshot client ready")
@ -539,7 +539,7 @@ class RecordingManager:
async def _check_server_availability(self):
"""Check if local server is available"""
try:
from openspace.platform import SystemInfoClient
from openspace.platforms import SystemInfoClient
# Use context manager to ensure aiohttp session is closed, avoiding warning of unclosed session
async with SystemInfoClient(base_url=self.server_url) as client:
@ -668,8 +668,9 @@ class RecordingManager:
if not tool_name or not isinstance(tool_name, str):
return None
name = tool_name.strip()
# Use rsplit to handle server names that themselves contain "__".
if "__" in name:
name = name.split("__", 1)[-1]
name = name.rsplit("__", 1)[-1]
shell_tools = {"shell_agent", "read_file", "write_file", "list_dir", "run_shell"}
if name in shell_tools:
return "shell"

View file

@ -23,7 +23,7 @@ class TrajectoryRecorder:
Args:
task_name: task name (optional, will be saved in metadata)
log_dir: log directory
enable_screenshot: whether to save screenshots (through platform.ScreenshotClient)
enable_screenshot: whether to save screenshots (through platforms.ScreenshotClient)
enable_video: whether to enable video recording (through platform.RecordingClient)
server_url: local_server address (None = read from config/environment variables)
"""
@ -91,7 +91,7 @@ class TrajectoryRecorder:
parameters: tool parameters
screenshot: screenshot bytes (if provided)
extra: extra information (e.g. server field for MCP)
auto_screenshot: whether to automatically capture screenshot (through platform.ScreenshotClient)
auto_screenshot: whether to automatically capture screenshot (through platforms.ScreenshotClient)
"""
self.step_counter += 1
step_num = self.step_counter
@ -145,9 +145,9 @@ class TrajectoryRecorder:
return step_info
async def _capture_screenshot(self) -> Optional[bytes]:
"""Capture screenshot automatically through platform.ScreenshotClient"""
"""Capture screenshot automatically through platforms.ScreenshotClient"""
try:
from openspace.platform import ScreenshotClient
from openspace.platforms import ScreenshotClient
# Lazy initialization screenshot client
if not hasattr(self, '_screenshot_client'):

View file

@ -1,7 +1,7 @@
"""
Video Recorder
Communicates with local_server through platform.RecordingClient
Communicates with local_server through platforms.RecordingClient
Supports local and remote recording (through configuration LOCAL_SERVER_URL)
"""
@ -9,7 +9,7 @@ from pathlib import Path
from typing import Optional
from openspace.utils.logging import Logger
from openspace.platform import RecordingClient
from openspace.platforms import RecordingClient
logger = Logger.get_logger(__name__)

View file

@ -84,13 +84,17 @@ def _correct_skill_ids(
if prefix and k.split("__")[0] == prefix
]
best, best_dist = None, 4 # threshold: edit distance ≤ 3
# Adaptive threshold: tighten when many candidates share the prefix
max_dist = 2 if len(candidates) > 20 else 4 # ≤1 or ≤3
best, best_dist, ambiguous = None, max_dist, False
for cand in candidates:
d = _edit_distance(raw_id, cand)
if d < best_dist:
best, best_dist = cand, d
best, best_dist, ambiguous = cand, d, False
elif d == best_dist and cand != best:
ambiguous = True # multiple candidates at same distance
if best is not None:
if best is not None and not ambiguous:
logger.info(
f"Corrected LLM skill ID: {raw_id!r}{best!r} "
f"(edit_distance={best_dist})"

View file

@ -145,6 +145,11 @@ class EvolutionContext:
# Available tools for agent loop (read_file, web_search, shell, MCP, etc.)
available_tools: List["BaseTool"] = field(default_factory=list)
# For CAPTURED: preferred directory to write the new skill.
# Set from the calling host agent's skill directory so captured skills
# are written back to the correct host, not always to _skill_dirs[0].
capture_dir: Optional[Path] = None
class SkillEvolver:
"""Execute skill evolution actions.
@ -201,6 +206,7 @@ class SkillEvolver:
# evolved for each degraded tool. Keyed by tool_key.
# Pruned when a tool leaves the problematic list (= recovered).
self._addressed_degradations: Dict[str, Set[str]] = {}
self._degradation_lock = asyncio.Lock()
# Track background tasks so they can be awaited on shutdown.
self._background_tasks: Set[asyncio.Task] = set()
@ -219,7 +225,10 @@ class SkillEvolver:
f"Waiting for {len(self._background_tasks)} background "
f"evolution task(s) to finish..."
)
await asyncio.gather(*self._background_tasks, return_exceptions=True)
results = await asyncio.gather(*self._background_tasks, return_exceptions=True)
for r in results:
if isinstance(r, BaseException):
logger.warning(f"Background evolution task failed during shutdown: {r}")
self._background_tasks.clear()
async def evolve(self, ctx: EvolutionContext) -> Optional[SkillRecord]:
@ -255,13 +264,20 @@ class SkillEvolver:
# Trigger 1: post-analysis
async def process_analysis(
self, analysis: ExecutionAnalysis,
self,
analysis: ExecutionAnalysis,
capture_dir: Optional[Path] = None,
) -> List[SkillRecord]:
"""Process all evolution suggestions from a completed analysis.
Called immediately after ``ExecutionAnalyzer.analyze_execution()``.
Each suggestion becomes one evolution action, executed in parallel
(throttled by semaphore).
Args:
analysis: The completed execution analysis.
capture_dir: Preferred directory for CAPTURED skills (host agent's
skill dir). Falls back to ``registry._skill_dirs[0]`` when None.
"""
if not analysis.candidate_for_evolution:
return []
@ -269,7 +285,9 @@ class SkillEvolver:
# Build contexts first (cheap, no LLM calls)
contexts: List[EvolutionContext] = []
for suggestion in analysis.evolution_suggestions:
ctx = self._build_context_from_analysis(analysis, suggestion)
ctx = self._build_context_from_analysis(
analysis, suggestion, capture_dir=capture_dir,
)
if ctx is not None:
contexts.append(ctx)
@ -306,6 +324,13 @@ class SkillEvolver:
if not problematic_tools:
return []
async with self._degradation_lock:
return await self._process_tool_degradation_locked(problematic_tools)
async def _process_tool_degradation_locked(
self, problematic_tools: List,
) -> List[SkillRecord]:
"""Inner body of process_tool_degradation, called under _degradation_lock."""
# Prune recovered tools: if a tool_key used to be tracked but is
# no longer in the current problematic list, it recovered — clear
# its addressed set so future re-degradation gets a fresh pass.
@ -652,10 +677,21 @@ class SkillEvolver:
return bool(data.get("proceed", False))
except (json.JSONDecodeError, ValueError):
pass
# Fallback: look for keywords
if any(w in response for w in ("\"proceed\": true", "proceed: true", "yes", "confirm")):
# Fallback: look for keywords.
# - yes/no use strict word boundaries to avoid false positives
# (e.g. "know" matching "no").
# - confirm/reject/skip use stem-style matching so that common
# LLM variants like "confirmed", "rejected", "skipping" still
# parse correctly.
_wb = re.search # shorthand
if any(w in response for w in ("\"proceed\": true", "proceed: true")) \
or _wb(r"\byes\b", response) \
or _wb(r"\bconfirm\w*\b", response):
return True
if any(w in response for w in ("\"proceed\": false", "proceed: false", "no", "reject", "skip")):
if any(w in response for w in ("\"proceed\": false", "proceed: false")) \
or _wb(r"\bno\b", response) \
or _wb(r"\breject\w*\b", response) \
or _wb(r"\bskip\w*\b", response):
return False
# Default: skip — ambiguous response should not trigger costly evolution
logger.debug("LLM confirmation response was ambiguous, defaulting to skip")
@ -926,13 +962,23 @@ class SkillEvolver:
new_content = _set_frontmatter_field(new_content, "name", new_name)
# Create new skill directory via create_skill (handles multi-file FULL)
skill_dirs = self._registry._skill_dirs
if not skill_dirs:
logger.warning("CAPTURED: no skill directories configured")
return None
# Priority chain for choosing the target skill root:
# 1. ctx.capture_dir — explicitly set from host agent's skill_dirs param
# 2. Infer from analysis — if this task used a skill from dir B,
# captured skills belong alongside it (same host agent context)
# 3. registry._skill_dirs[0] — ultimate fallback
base_dir: Optional[Path] = None
if ctx.capture_dir and ctx.capture_dir.is_dir():
base_dir = ctx.capture_dir
else:
base_dir = self._infer_capture_dir_from_analysis(ctx)
# Directory name always matches the skill name
base_dir = skill_dirs[0] # Primary user skill directory
if base_dir is None:
skill_dirs = self._registry._skill_dirs
if not skill_dirs:
logger.warning("CAPTURED: no skill directories configured")
return None
base_dir = skill_dirs[0]
target_dir = base_dir / new_name
if target_dir.exists():
new_name = f"{new_name}-{uuid.uuid4().hex[:6]}"
@ -994,6 +1040,45 @@ class SkillEvolver:
logger.info(f"CAPTURED: {new_name} [{new_id}]")
return new_record
def _infer_capture_dir_from_analysis(
self, ctx: EvolutionContext,
) -> Optional[Path]:
"""Infer the best skill root for a CAPTURED skill from analysis context.
When ``capture_dir`` is not explicitly set (no ``skill_dirs`` param
from the host agent), we look at which skills were used during the
task that triggered the capture. If a used skill lives under one
of the registered skill roots, that root is a reasonable home for
the new captured skill (same host agent context).
"""
if not ctx.recent_analyses:
return None
registry_roots = self._registry._skill_dirs
if not registry_roots:
return None
for analysis in ctx.recent_analyses:
for judgment in analysis.skill_judgments:
if not judgment.skill_applied:
continue
rec = self._store.load_record(judgment.skill_id)
if not rec or not rec.path:
continue
skill_path = Path(rec.path).parent # e.g. /A/foo/
for root in registry_roots:
try:
skill_path.relative_to(root)
logger.debug(
"CAPTURED: inferred capture dir %s from "
"applied skill %s", root, judgment.skill_id,
)
return root
except ValueError:
continue
return None
async def _run_evolution_loop(
self,
prompt: str,
@ -1340,13 +1425,15 @@ class SkillEvolver:
self,
analysis: ExecutionAnalysis,
suggestion: EvolutionSuggestion,
capture_dir: Optional[Path] = None,
) -> Optional[EvolutionContext]:
"""Build EvolutionContext from a single analysis suggestion.
Loads all target skills referenced by ``suggestion.target_skill_ids``.
For FIX: exactly 1 parent required.
For DERIVED: 1+ parents (multi-parent = merge).
For CAPTURED: parents list is empty.
For CAPTURED: parents list is empty; ``capture_dir`` controls where
the new skill is written (defaults to registry's first skill root).
"""
records: List[SkillRecord] = []
contents: List[str] = []
@ -1390,6 +1477,7 @@ class SkillEvolver:
source_task_id=analysis.task_id,
recent_analyses=[analysis],
available_tools=self._available_tools,
capture_dir=capture_dir,
)
def _load_skill_content(self, record: SkillRecord) -> str:

View file

@ -299,8 +299,8 @@ class SkillRegistry:
skill_dir: Path to a directory containing ``SKILL.md``.
Returns:
:class:`SkillMeta` if newly registered, ``None`` if already
present, the directory is invalid, or the skill fails safety checks.
:class:`SkillMeta` if newly registered or already present,
``None`` if the directory is invalid or the skill fails safety checks.
"""
skill_file = skill_dir / "SKILL.md"
if not skill_file.exists():
@ -321,7 +321,7 @@ class SkillRegistry:
meta = self._parse_skill(skill_dir.name, skill_dir, skill_file, content)
if meta.skill_id in self._skills:
logger.debug(f"register_skill_dir: {meta.skill_id} already exists")
return None
return self._skills[meta.skill_id]
self._skills[meta.skill_id] = meta
self._content_cache[meta.skill_id] = content
logger.info(f"Hot-registered skill: {meta.skill_id}")

View file

@ -104,7 +104,6 @@ class OpenSpace:
return
logger.info("Initializing OpenSpace...")
try:
self._llm_client = LLMClient(
model=self.config.llm_model,
@ -307,18 +306,25 @@ class OpenSpace:
workspace_dir: Optional[str] = None,
max_iterations: Optional[int] = None,
task_id: Optional[str] = None,
capture_skill_dir: Optional[str] = None,
) -> Dict[str, Any]:
"""
Execute a task with OpenSpace.
Args:
task: Task instruction
context: Additional context
context: Additional context. Communication callers may pass:
- conversation_history: prior user/assistant turns
- channel_context: platform/chat metadata and attachments
- session_key: stable external session identifier
workspace_dir: Working directory
max_iterations: Max iterations override
task_id: External task ID for recording/logging. If None, generates a random one.
This allows external callers (e.g., OSWorld) to specify their own task ID
so recordings can be easily matched with benchmark results.
capture_skill_dir: Preferred directory for CAPTURED skills. In multi-host-agent
scenarios, this should be the calling host agent's skill directory so
newly captured skills are written to the correct location.
"""
if not self._initialized:
raise RuntimeError(
@ -350,6 +356,9 @@ class OpenSpace:
self._task_done.clear()
self._last_evolved_skills = [] # Reset per-execution tracking
start_time = asyncio.get_running_loop().time()
self._capture_skill_dir = capture_skill_dir
start_time = asyncio.get_event_loop().time()
# Use external task_id if provided, otherwise generate one
if task_id is None:
task_id = f"task_{uuid.uuid4().hex[:12]}"
@ -357,9 +366,11 @@ class OpenSpace:
# Populated inside the try block; used by finally for analysis
result: Dict[str, Any] = {}
execution_time = 0.0
cancelled_exc: Optional[asyncio.CancelledError] = None
try:
execution_context = context or {}
execution_context = dict(context) if context else {}
execution_context["task_id"] = task_id
execution_context["instruction"] = task
@ -507,6 +518,7 @@ class OpenSpace:
f"Executing with GroundingAgent "
f"(max {max_iterations} iterations, no skills)..."
)
execution_context["max_iterations"] = max_iterations
result = await self._grounding_agent.process(execution_context)
execution_time = asyncio.get_event_loop().time() - start_time
@ -530,6 +542,20 @@ class OpenSpace:
logger.error(f"Task failed: {result.get('error', 'Unknown error')}")
logger.info("="*60)
except asyncio.CancelledError as exc:
execution_time = asyncio.get_event_loop().time() - start_time
logger.warning("Task execution cancelled")
result = {
"status": "cancelled",
"error": "Task execution cancelled",
"response": "",
"execution_time": execution_time,
"task_id": task_id,
"iterations": 0,
"tool_executions": [],
}
cancelled_exc = exc
except Exception as e:
execution_time = asyncio.get_event_loop().time() - start_time
tb = traceback.format_exc(limit=10)
@ -568,14 +594,15 @@ class OpenSpace:
except Exception as e:
logger.warning(f"Failed to stop recording: {e}")
# Run execution analysis + evolution BEFORE building the return
# value, so evolved_skills is populated.
await self._maybe_analyze_execution(
task_id, recording_dir, result
)
if cancelled_exc is None:
# Run execution analysis + evolution BEFORE building the return
# value, so evolved_skills is populated.
await self._maybe_analyze_execution(
task_id, recording_dir, result
)
# Trigger quality evolution periodically
await self._maybe_evolve_quality()
# Trigger quality evolution periodically
await self._maybe_evolve_quality()
final_result = {
**result,
@ -587,8 +614,10 @@ class OpenSpace:
self._running = False
self._task_done.set()
return final_result
if cancelled_exc is not None:
raise cancelled_exc
return final_result
# Skills helpers
def _init_skill_registry(self) -> Optional[SkillRegistry]:
@ -784,7 +813,17 @@ class OpenSpace:
for s in analysis.evolution_suggestions
)
logger.info(f"[Skill Evolution] Suggestions: {evo_summary}")
evolved_records = await self._skill_evolver.process_analysis(analysis)
capture_dir = None
if getattr(self, "_capture_skill_dir", None):
from pathlib import Path as _P
_cd = _P(self._capture_skill_dir)
if _cd.is_dir():
capture_dir = _cd
evolved_records = await self._skill_evolver.process_analysis(
analysis, capture_dir=capture_dir,
)
# Track evolved skills for the caller
for rec in evolved_records:

View file

@ -233,6 +233,23 @@ class Logger:
cls._configured = True
@classmethod
def set_level(cls, level: str) -> None:
"""Set log level by name (e.g. ``"DEBUG"``, ``"INFO"``, ``"WARNING"``)."""
resolved = getattr(logging, level.upper(), None)
if resolved is None or not isinstance(resolved, int):
raise ValueError(f"Unknown log level: {level!r}")
if not cls._configured:
cls.configure(level=resolved, attach_to_root=True)
return
root_logger = logging.getLogger()
root_logger.setLevel(resolved)
for handler in root_logger.handlers:
handler.setLevel(resolved)
cls._update_level(resolved)
@classmethod
def set_debug(cls, debug_level: int = 2) -> None:
"""Dynamically switch debug level: 0 = WARNING, 1 = INFO, 2 = DEBUG."""
@ -309,4 +326,4 @@ Logger.configure(attach_to_root=True)
# Get openspace logger for internal logging
logger = Logger.get_logger()
logger.debug("OpenSpace logging initialized")
logger.debug("OpenSpace logging initialized")

View file

@ -14,7 +14,7 @@ authors = [
]
dependencies = [
"litellm>=1.70.0",
"litellm>=1.70.0,<1.82.7", # pinned to avoid PYSEC-2026-2 supply-chain compromise (1.82.7/1.82.8 were malicious)
"python-dotenv>=1.0.0",
"openai>=1.0.0",
"jsonschema>=4.25.0",
@ -26,6 +26,7 @@ dependencies = [
"flask>=3.1.0",
"pyautogui>=0.9.54",
"pydantic>=2.12.0",
"aiohttp>=3.10.0",
"requests>=2.32.0",
]
@ -57,8 +58,15 @@ dev = [
"mypy>=1.0.0",
]
communication = [
"lark-oapi>=1.4.20",
]
all = [
"openspace[macos,linux,windows,dev]",
"openspace[macos]; sys_platform == 'darwin'",
"openspace[linux]; sys_platform == 'linux'",
"openspace[windows]; sys_platform == 'win32'",
"openspace[communication,dev]",
]
[project.urls]
@ -72,6 +80,7 @@ openspace-mcp = "openspace.mcp_server:run_mcp_server"
openspace-download-skill = "openspace.cloud.cli.download_skill:main"
openspace-upload-skill = "openspace.cloud.cli.upload_skill:main"
openspace-dashboard = "openspace.dashboard_server:main"
openspace-gateway = "openspace.communication.gateway:run_main"
[tool.setuptools]
packages = {find = {where = ["."], include = ["openspace*"]}}
@ -80,6 +89,7 @@ packages = {find = {where = ["."], include = ["openspace*"]}}
openspace = [
"config/*.json",
"config/*.json.example",
"communication/bridges/whatsapp/*",
"local_server/config.json",
"local_server/README.md",
]

View file

@ -1,5 +1,5 @@
# OpenSpace core dependencies
litellm>=1.70.0
litellm>=1.70.0,<1.82.7 # pinned to avoid PYSEC-2026-2 supply-chain compromise (1.82.7/1.82.8 were malicious)
python-dotenv>=1.0.0
openai>=1.0.0
jsonschema>=4.25.0