Compare commits

...
8 Commits
Author SHA1 Message Date
MervinPraison 61e53754d4 Release v4.6.16 2026-04-19 01:38:41 +01:00
MervinPraison bbcdf65b5a feat(cli): introduce configurable bot and environment paths
- Updated bot command to resolve configuration paths from environment variables.
- Added new path utility functions for clarity and maintainability.
- Updated version to 1.6.16 for package consistency.
- Enhanced help messages for commands to reflect new default path behavior.
2026-04-19 01:37:53 +01:00
MervinPraison dd03c817b7 feat(tutorial): enhance animation instructions in example script
Updated the motion graphics agent factory example with detailed steps
for creating a title-card animation, including necessary function
calls and content requirements. Added a new installation command
to the README for easier setup.
2026-04-19 01:34:10 +01:00
MervinPraison 3ff99487d6 Release v4.6.15 2026-04-19 01:25:00 +01:00
MervinPraison c47f572655 docs: auto-update feature parity trackers [skip ci] 2026-04-19 00:24:29 +00:00
MervinPraison 9c970df301 feat(onboard): persist GATEWAY_AUTH_TOKEN for stable gateway access
Added functionality to read and persist the GATEWAY_AUTH_TOKEN across daemon restarts. This ensures the dashboard link remains consistent, improving user experience. Updated version to 1.6.15 and made necessary adjustments to related files.
2026-04-19 01:24:08 +01:00
MervinPraison 6f1d55c912 Release v4.6.14 2026-04-19 01:10:52 +01:00
MervinPraison 3967dd164f feat(video): add motion graphics examples and tools
Introduced several Python scripts demonstrating motion graphics rendering
and GitTools safety features. Includes examples for basic rendering,
Git repository validation, and agent factories. These enhancements aim
to provide a comprehensive toolkit for creating and managing motion
graphics projects efficiently.
2026-04-19 01:09:50 +01:00
28 changed files with 587 additions and 55 deletions

No files matched your search

+4
View File
@@ -25,6 +25,10 @@
PraisonAI 🦞 — **Hire a 24/7 AI Workforce.** Stop writing boilerplate and start shipping autonomous agents that research, plan, and execute tasks across your apps. From one agent to an entire organization, deployed in 5 lines of code.
```bash
curl -fsSL https://praison.ai/install.sh | bash
```
<div align="center">
<br>
<a href="https://x.com/elonmusk/status/1893870468249141688" target="_blank">
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.13" \
"praisonai>=4.6.16" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.13" \
"praisonai>=4.6.16" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.13" \
"praisonai>=4.6.16" \
"praisonai[ui]" \
"praisonai[crewai]"
@@ -0,0 +1,85 @@
"""Motion Graphics — Basic HTML/GSAP → MP4 render (no LLM required).
This example drives the `HtmlRenderBackend` directly with a hand-authored
HTML/GSAP composition. It renders to a real MP4 via headless Chromium +
bundled ffmpeg, with no API keys needed.
Requirements:
pip install praisonai-tools[video-motion]
playwright install chromium
Verified end-to-end: produces 1920x1080 @ 30fps H.264 MP4 in ~5–10 seconds.
"""
import asyncio
from pathlib import Path
from praisonai_tools.video.motion_graphics import HtmlRenderBackend, RenderOpts
HTML = """
<!DOCTYPE html>
<html>
<head>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
<style>
body { margin:0; padding:0; background:#0f172a; overflow:hidden;
font-family: -apple-system, Arial, sans-serif; }
#stage { width:1920px; height:1080px; position:relative; }
.title { font-size:96px; font-weight:800; color:#f8fafc;
position:absolute; top:50%; left:50%;
transform:translate(-50%,-50%); opacity:0; }
.subtitle { font-size:44px; color:#94a3b8;
position:absolute; top:62%; left:50%;
transform:translate(-50%,-50%); opacity:0; }
.dot { width:32px; height:32px; border-radius:50%; background:#38bdf8;
position:absolute; top:78%; left:50%;
transform:translate(-50%,-50%); opacity:0; }
</style>
</head>
<body>
<div id="stage" data-duration="4.0">
<div class="title">PraisonAI</div>
<div class="subtitle">Motion Graphics Pipeline</div>
<div class="dot"></div>
</div>
<script>
const tl = gsap.timeline({ paused: true });
tl.to(".title", { duration: 0.8, opacity: 1, y: -30, ease: "power3.out" })
.to(".subtitle", { duration: 0.7, opacity: 1, y: -20, ease: "power3.out" }, "-=0.3")
.to(".dot", { duration: 0.5, opacity: 1, scale: 2, ease: "back.out(2)" }, "-=0.2")
.to(".dot", { duration: 0.4, x: 200, ease: "power2.inOut" })
.to(".dot", { duration: 0.4, x: -200, ease: "power2.inOut" })
.to(".dot", { duration: 0.4, x: 0, ease: "power2.inOut" })
.to([".title",".subtitle",".dot"],
{ duration: 0.5, opacity: 0, ease: "power2.in" });
// Required: export timeline(s) for the render backend to drive.
window.__timelines = [tl];
</script>
</body>
</html>
"""
async def main() -> None:
out = Path("/tmp/motion_graphics_demo")
out.mkdir(exist_ok=True)
(out / "index.html").write_text(HTML)
backend = HtmlRenderBackend(base_dir=out)
lint = await backend.lint(out)
print(f"Lint: ok={lint.ok} messages={lint.messages}")
opts = RenderOpts(output_name="praisonai_demo.mp4", fps=30, quality="standard")
print("Render: ...", flush=True)
result = await backend.render(out, opts)
if result.ok:
print(f"Render: OK path={result.output_path} size={result.size_kb} KB")
else:
print(f"Render: FAIL stderr={result.stderr}")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,52 @@
"""Motion Graphics — GitTools safety demo (no network required for this demo).
GitTools is the read-only toolkit used by the code-exploration specialist in
`motion_graphics_team`. It clones repos on demand, validates paths against
traversal, and restricts to GitHub URLs or `owner/repo` format.
This example exercises the pure-Python safety helpers (URL parsing + file
path validation). Cloning is network-bound and demonstrated separately.
Requirements:
pip install praisonai-tools
"""
from praisonai_tools.tools.git_tools import GitTools
def main() -> None:
tools = GitTools(base_dir="/tmp/praison_git_repos_demo")
print("URL parsing")
print("-----------")
for repo_input in [
"octocat/Hello-World", # owner/repo
"https://github.com/octocat/Hello-World.git", # https URL
"[email protected]:octocat/Hello-World.git", # ssh URL
]:
try:
url, name = tools._parse_repo_input(repo_input)
print(f" {repo_input:<50} -> name={name}")
except ValueError as exc:
print(f" {repo_input:<50} -> REJECTED: {exc}")
print("\nFile-path validation")
print("--------------------")
for path in [
"README.md", # safe
"src/main.py", # safe
"../etc/passwd", # traversal
"../../secret.txt", # traversal
"/etc/passwd", # absolute
]:
try:
safe = tools._validate_file_path(path)
print(f" {path:<30} -> SAFE: {safe}")
except ValueError as exc:
print(f" {path:<30} -> REJECT: {exc}")
print("\nListing any previously cloned repos:", tools.list_repos())
if __name__ == "__main__":
main()
@@ -0,0 +1,64 @@
"""Motion Graphics — single-agent factory (requires praisonaiagents + LLM key).
Creates a single motion-graphics authoring agent that writes HTML/GSAP and
renders to MP4 via the HTML backend. Good for quick, one-shot prompts.
Requirements:
pip install praisonai-tools[video-motion] praisonaiagents[llm]
playwright install chromium
export ANTHROPIC_API_KEY=... # or OPENAI_API_KEY, etc.
Set a working model via env (LiteLLM routing):
export MOTION_LLM=anthropic/claude-sonnet-4-20250514
"""
import os
from pathlib import Path
from praisonai_tools.video.motion_graphics import create_motion_graphics_agent
def main() -> None:
workspace = Path("/tmp/motion_graphics_agent_demo")
workspace.mkdir(exist_ok=True)
llm = os.getenv("MOTION_LLM", "anthropic/claude-sonnet-4-20250514")
agent = create_motion_graphics_agent(
backend="html",
workspace=workspace,
max_retries=3,
llm=llm,
)
print(f"Agent: {agent.name}")
print(f"Backend: {agent._motion_graphics_backend.__class__.__name__}")
print(f"Workspace: {agent._motion_graphics_workspace}")
print(f"Retries: {agent._motion_graphics_max_retries}")
print(f"LLM: {llm}")
print()
print("Starting agent...\n")
agent.start(
"Create a short title-card animation.\n"
"Steps you MUST follow, in order:\n"
" 1. Call write_file with filepath='index.html' and content containing "
" a complete HTML document with GSAP 3.12 from cdnjs, a #stage div "
" with data-duration='3.0', visible text 'Hello Motion' that fades "
" in, and a cyan underline that draws across. The file MUST set "
" window.__timelines = [tl] at the end of its <script> block, and "
" the timeline MUST be created with { paused: true }.\n"
" 2. Call lint_composition() and verify ok=True.\n"
" 3. Call render_composition(output_name='intro.mp4', fps=30, "
" quality='standard') and report the resulting output_path.\n"
"Return the concrete output_path of the MP4 or the exact stderr."
)
print("\nArtifacts in workspace:")
for p in sorted(workspace.rglob("*")):
if p.is_file():
print(f" {p.relative_to(workspace)} ({p.stat().st_size} bytes)")
if __name__ == "__main__":
main()
@@ -0,0 +1,56 @@
"""Motion Graphics — full team pipeline (requires praisonaiagents + LLM key).
Runs the coordinator + animator team (optionally with researcher and
code-explorer specialists) under a hierarchical process, so the coordinator
validates outputs before marking a render as successful.
Requirements:
pip install praisonai-tools[video-motion] praisonaiagents[llm]
playwright install chromium
export ANTHROPIC_API_KEY=...
export MOTION_LLM=anthropic/claude-sonnet-4-20250514
"""
import os
import time
from pathlib import Path
from praisonai_tools.video.motion_graphics import motion_graphics_team
def main() -> None:
workspace = Path("/tmp/motion_graphics_team_demo")
workspace.mkdir(exist_ok=True)
llm = os.getenv("MOTION_LLM", "anthropic/claude-sonnet-4-20250514")
print(f"[setup] workspace={workspace} llm={llm}")
team = motion_graphics_team(
research=False, # set True to add a web-search researcher
code_exploration=False, # set True to add a GitTools code-explorer
workspace=workspace,
llm=llm,
)
print(f"[setup] {len(team.agents)} agents: {[a.name for a in team.agents]}")
prompt = "Animate Dijkstra's algorithm on a small weighted graph, 30s."
print(f"[run] prompt: {prompt}")
t0 = time.time()
result = team.start(prompt)
elapsed = time.time() - t0
print(f"[done] elapsed={elapsed:.1f}s")
print(f"[result] {result!r}")
print("\n[artifacts]")
for p in sorted(workspace.rglob("*.mp4")):
print(f" mp4 {p} ({p.stat().st_size} bytes)")
for p in sorted(workspace.rglob("*.html")):
print(f" html {p} ({p.stat().st_size} bytes)")
if __name__ == "__main__":
main()
@@ -1270,7 +1270,7 @@ Your Goal: {self.goal}"""
task_description=task_description,
task_id=task_id,
execute_tool_fn=self.execute_tool,
parallel_tool_calls=getattr(self.execution, "parallel_tool_calls", False),
parallel_tool_calls=getattr(getattr(self, "execution", None), "parallel_tool_calls", False),
reasoning_steps=reasoning_steps,
stream=stream
)
@@ -1740,7 +1740,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
task_description=task_description,
task_id=task_id,
execute_tool_fn=self.execute_tool_async,
parallel_tool_calls=getattr(self.execution, "parallel_tool_calls", False),
parallel_tool_calls=getattr(getattr(self, "execution", None), "parallel_tool_calls", False),
reasoning_steps=reasoning_steps,
stream=stream
)
@@ -2271,7 +2271,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
task_description=kwargs.get('task_description'),
task_id=kwargs.get('task_id'),
execute_tool_fn=self.execute_tool,
parallel_tool_calls=getattr(self.execution, "parallel_tool_calls", False)
parallel_tool_calls=getattr(getattr(self, "execution", None), "parallel_tool_calls", False)
):
response_content += chunk
yield chunk
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.6.13"
version = "1.6.16"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
+1 -1
View File
@@ -2951,7 +2951,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.6.13"
version = "1.6.16"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -1,6 +1,6 @@
{
"version": "1.5.87",
"lastUpdated": "2026-04-18",
"lastUpdated": "2026-04-19",
"generatedBy": "praisonai._dev.parity.generator",
"sourceOfTruth": "Python SDK (praisonaiagents)",
"status": "PARITY_ACHIEVED",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"version": "1.5.87",
"lastUpdated": "2026-04-18",
"lastUpdated": "2026-04-19",
"generatedBy": "praisonai._dev.parity.generator",
"sourceOfTruth": "Python SDK (praisonaiagents)",
"summary": {
+1 -1
View File
@@ -1,6 +1,6 @@
# Feature Parity Tracker
> **Version:** 1.5.87 | **Last Updated:** 2026-04-18
> **Version:** 1.5.87 | **Last Updated:** 2026-04-19
> **Source of Truth:** Python SDK (praisonaiagents)
## Summary
+4
View File
@@ -25,6 +25,10 @@
PraisonAI 🦞 — **Hire a 24/7 AI Workforce.** Stop writing boilerplate and start shipping autonomous agents that research, plan, and execute tasks across your apps. From one agent to an entire organization, deployed in 5 lines of code.
```bash
curl -fsSL https://praison.ai/install.sh | bash
```
<div align="center">
<br>
<a href="https://x.com/elonmusk/status/1893870468249141688" target="_blank">
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.13.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.13.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.16.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.16.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
+88
View File
@@ -0,0 +1,88 @@
"""Canonical filesystem paths for PraisonAI user state.
Mirrors the conventions used by ``hermes-agent`` (``~/.hermes``) and
``openclaw`` (``~/.openclaw``): one user-home directory per tool, with
configurable overrides via environment variables, so ``bot.yaml`` and
``.env`` don't pollute whatever working directory the user happened to
run ``praisonai onboard`` from.
Layout::
~/.praisonai/
├── .env # secrets (chmod 600) — managed by onboard
├── bot.yaml # default bot / gateway config
└── logs/ # daemon stdout / stderr (created by launchd helper)
Overrides (first match wins for each key):
* ``PRAISONAI_HOME`` — full path for the directory (e.g. Nix store)
* ``PRAISONAI_ENV_FILE`` — full path for the ``.env`` file
* ``PRAISONAI_BOT_CONFIG`` — full path for the default ``bot.yaml``
"""
from __future__ import annotations
import os
from pathlib import Path
__all__ = [
"praison_home",
"default_env_file",
"default_bot_config_path",
"resolve_bot_config_path",
]
def praison_home() -> Path:
"""Return the canonical PraisonAI home directory.
Honours ``PRAISONAI_HOME`` so packaging systems (Nix, Docker) can
relocate state. Defaults to ``~/.praisonai``.
"""
override = os.environ.get("PRAISONAI_HOME")
return Path(override).expanduser() if override else Path.home() / ".praisonai"
def default_env_file() -> Path:
"""Return the path to the canonical ``.env`` file."""
override = os.environ.get("PRAISONAI_ENV_FILE")
return Path(override).expanduser() if override else praison_home() / ".env"
def default_bot_config_path() -> Path:
"""Return the path where ``praisonai onboard`` writes ``bot.yaml``.
Users can force a custom path with ``PRAISONAI_BOT_CONFIG``.
"""
override = os.environ.get("PRAISONAI_BOT_CONFIG")
return Path(override).expanduser() if override else praison_home() / "bot.yaml"
def resolve_bot_config_path(cli_value: str = "bot.yaml") -> str:
"""Resolve the bot config path for CLI commands with sensible fallback.
Precedence:
1. ``cli_value`` if it is not the sentinel ``"bot.yaml"`` (i.e. user
passed an explicit ``--config X``).
2. ``./bot.yaml`` if it exists in the current working directory
(back-compat for projects that already keep bot.yaml checked in).
3. ``~/.praisonai/bot.yaml`` (or ``PRAISONAI_BOT_CONFIG``) — the
canonical location written by ``praisonai onboard``.
4. Fall back to ``cli_value`` unchanged so existing error messages
("config not found at bot.yaml") keep working.
"""
# (1) explicit override from the caller
if cli_value and cli_value != "bot.yaml":
return cli_value
# (2) honour an existing cwd file (back-compat)
cwd_path = Path.cwd() / "bot.yaml"
if cwd_path.is_file():
return str(cwd_path)
# (3) canonical home location
home_path = default_bot_config_path()
if home_path.is_file():
return str(home_path)
# (4) give up — return the caller's value so their own error handling
# surfaces a useful "not found" message.
return cli_value
+20 -7
View File
@@ -22,20 +22,28 @@ def _common_options():
@app.command("start")
def bot_start(
config: str = typer.Option(..., "--config", "-c", help="Path to bot YAML config file"),
config: str = typer.Option(
"bot.yaml", "--config", "-c",
help="Path to bot YAML config file (defaults to ./bot.yaml if present, else ~/.praisonai/bot.yaml)",
),
):
"""Start a bot from a single YAML config file (zero-code).
The config file contains platform, token, and agent settings all in one place.
Resolution order when ``--config`` is not given:
``./bot.yaml`` (back-compat) → ``~/.praisonai/bot.yaml`` (canonical).
Examples:
praisonai bot start --config bot.yaml
praisonai bot start # auto-detect
praisonai bot start --config ~/.praisonai/bot.yaml
praisonai bot start -c my-telegram-bot.yaml
"""
from .._paths import resolve_bot_config_path
from ..features.bots_cli import BotHandler
resolved = resolve_bot_config_path(config)
handler = BotHandler()
handler.start_from_config(config)
handler.start_from_config(resolved)
@app.command("telegram")
@@ -440,7 +448,10 @@ def bot_agentmail(
@app.command("install-daemon")
def bot_install_daemon(
config: str = typer.Option("bot.yaml", "--config", help="Path to bot.yaml"),
config: str = typer.Option(
"bot.yaml", "--config",
help="Path to bot.yaml (defaults to ./bot.yaml → ~/.praisonai/bot.yaml)",
),
start: bool = typer.Option(True, "--start/--no-start", help="Start after install"),
):
"""Install bot as OS daemon service (alias for 'praisonai gateway install').
@@ -450,9 +461,11 @@ def bot_install_daemon(
praisonai bot install-daemon --config my-bot.yaml --no-start
"""
from praisonai.daemon import install_daemon
from .._paths import resolve_bot_config_path
from ..output.console import get_output_controller
output = get_output_controller()
config = resolve_bot_config_path(config)
try:
result = install_daemon(config_path=config)
@@ -149,7 +149,10 @@ def gateway_channels(
@app.command("install")
def gateway_install(
config: str = typer.Option("bot.yaml", "--config", help="Path to bot.yaml"),
config: str = typer.Option(
"bot.yaml", "--config",
help="Path to bot.yaml (defaults to ./bot.yaml → ~/.praisonai/bot.yaml)",
),
start: bool = typer.Option(True, "--start/--no-start", help="Start after install"),
):
"""Install the gateway as an OS daemon (LaunchAgent / systemd).
@@ -159,9 +162,11 @@ def gateway_install(
praisonai gateway install --config my-bot.yaml --no-start
"""
from praisonai.daemon import install_daemon
from .._paths import resolve_bot_config_path
from ..output.console import get_output_controller
output = get_output_controller()
config = resolve_bot_config_path(config)
try:
result = install_daemon(config_path=config)
@@ -23,7 +23,8 @@ from ..registry import register_check
)
def check_bot_tokens(config: DoctorConfig) -> CheckResult:
"""Check bot token environment variables."""
config_path = getattr(config, 'config_file', None) or "bot.yaml"
from praisonai.cli._paths import resolve_bot_config_path
config_path = getattr(config, 'config_file', None) or resolve_bot_config_path("bot.yaml")
token_vars = {
"TELEGRAM_BOT_TOKEN": "Telegram",
"DISCORD_BOT_TOKEN": "Discord",
@@ -63,7 +64,8 @@ def check_bot_tokens(config: DoctorConfig) -> CheckResult:
def check_bot_config(config: DoctorConfig) -> CheckResult:
"""Check bot.yaml exists and is valid."""
start = time.time()
config_path = getattr(config, 'config_file', None) or "bot.yaml"
from praisonai.cli._paths import resolve_bot_config_path
config_path = getattr(config, 'config_file', None) or resolve_bot_config_path("bot.yaml")
if not os.path.exists(config_path):
return CheckResult(
id="bot_config",
@@ -107,7 +109,8 @@ def check_bot_config(config: DoctorConfig) -> CheckResult:
def check_bot_security(config: DoctorConfig) -> CheckResult:
"""Check bot security configuration for safe defaults."""
start = time.time()
config_path = getattr(config, 'config_file', None) or "bot.yaml"
from praisonai.cli._paths import resolve_bot_config_path
config_path = getattr(config, 'config_file', None) or resolve_bot_config_path("bot.yaml")
channel_warnings = []
global_warnings = []
@@ -8,11 +8,55 @@ from __future__ import annotations
import asyncio
import logging
from typing import Optional
import os
from pathlib import Path
from typing import Dict, Optional
logger = logging.getLogger(__name__)
def _load_praisonai_env_file() -> Dict[str, str]:
"""Load ``~/.praisonai/.env`` into ``os.environ`` (without overwriting).
Daemons launched by ``launchd`` / ``systemd`` don't inherit the user's
shell env and don't auto-source dotfiles, so secrets written by
``praisonai onboard`` (e.g. ``TELEGRAM_BOT_TOKEN``) are missing when
the gateway starts in the background. We load them here so the
YAML ``${VAR}`` substitution in ``GatewayServer.load_gateway_config``
resolves correctly.
Existing ``os.environ`` values take precedence (so user-set shell
vars always win). Returns the dict of keys we loaded (for logging).
"""
env_path = Path(os.environ.get("PRAISONAI_ENV_FILE")
or (Path.home() / ".praisonai" / ".env"))
loaded: Dict[str, str] = {}
if not env_path.exists():
return loaded
try:
for raw in env_path.read_text().splitlines():
s = raw.strip()
if not s or s.startswith("#") or "=" not in s:
continue
k, v = s.split("=", 1)
k = k.strip()
v = v.strip().strip('"').strip("'")
if not k:
continue
if k in os.environ:
continue # don't clobber existing env
os.environ[k] = v
loaded[k] = v
except OSError as exc:
logger.warning("Could not read %s: %s", env_path, exc)
if loaded:
logger.info(
"Loaded %d env var(s) from %s: %s",
len(loaded), env_path, ", ".join(sorted(loaded.keys())),
)
return loaded
class GatewayHandler:
"""Handler for gateway CLI commands."""
@@ -34,6 +78,28 @@ class GatewayHandler:
agent_file: Optional path to agent configuration file
config_file: Optional path to gateway.yaml for multi-bot mode
"""
# Ensure INFO-level logs surface to bot-stdout.log / bot-stderr.log
# when running under launchd / systemd. Many key lifecycle events
# (bot start, channel routing, scheduler tick, retries) are already
# emitted via `logger.info()` — they just weren't visible with the
# default WARNING root level. Only configure if nothing is set yet,
# so users/embedders keep control.
_root = logging.getLogger()
if not _root.handlers:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(name)s %(levelname)s %(message)s",
)
if _root.level > logging.INFO or _root.level == logging.NOTSET:
_root.setLevel(logging.INFO)
# Load ~/.praisonai/.env BEFORE any config parsing or ${VAR}
# substitution — daemons don't inherit shell env.
_load_praisonai_env_file()
logger.info(
"Gateway starting (host=%s port=%s config=%s agents=%s)",
host, port, config_file or "-", agent_file or "-",
)
try:
from praisonai.gateway import WebSocketGateway
from praisonaiagents.gateway import GatewayConfig
@@ -127,6 +127,24 @@ def _save_env_vars(env_vars: Dict[str, Optional[str]]) -> Optional[Path]:
return env_file
def _read_env_value(key: str) -> Optional[str]:
"""Read a single value from ``~/.praisonai/.env`` (if present)."""
env_file = _praison_home() / ".env"
if not env_file.exists():
return None
try:
for line in env_file.read_text().splitlines():
s = line.strip()
if not s or s.startswith("#") or "=" not in s:
continue
k, v = s.split("=", 1)
if k.strip() == key:
return v.strip().strip('"').strip("'") or None
except OSError:
return None
return None
def _generate_bot_yaml(platforms: List[str], agent_name: str = "assistant", agent_instructions: str = "") -> str:
"""Generate bot.yaml content compatible with BOTH ``praisonai bot start``
and ``praisonai gateway start``.
@@ -437,6 +455,20 @@ class OnboardWizard:
default="You are a helpful AI assistant.",
)
# Step 4b: Ensure a stable GATEWAY_AUTH_TOKEN is persisted. Without
# this the gateway server auto-generates a random token on every
# start (see server.py _check_auth), which means the dashboard link
# we show in the Done panel would rotate each restart. Persisting
# one value to ~/.praisonai/.env keeps clicks working across
# daemon restarts.
existing_gateway_token = _read_env_value("GATEWAY_AUTH_TOKEN")
if not existing_gateway_token:
import secrets as _secrets
self._gateway_token = _secrets.token_hex(16)
_save_env_vars({"GATEWAY_AUTH_TOKEN": self._gateway_token})
else:
self._gateway_token = existing_gateway_token
# Step 5: Generate config
console.print("\n[bold]Step 5: Generate configuration[/bold]\n")
yaml_content = _generate_bot_yaml(
@@ -445,7 +477,11 @@ class OnboardWizard:
agent_instructions=self.agent_instructions,
)
self.config_path = Prompt.ask("Config file path", default="bot.yaml")
from praisonai.cli._paths import default_bot_config_path
default_cfg = str(default_bot_config_path())
self.config_path = Prompt.ask("Config file path", default=default_cfg)
# Ensure parent dir exists (e.g. ~/.praisonai/) before writing.
os.makedirs(os.path.dirname(os.path.abspath(self.config_path)) or ".", exist_ok=True)
if os.path.exists(self.config_path):
if not Confirm.ask(f" {self.config_path} exists. Overwrite?", default=False):
@@ -486,13 +522,23 @@ class OnboardWizard:
console.print(f" [red]✗[/red] {str(e)[:200]}")
# Done — commands referenced here must exist in `praisonai --help`.
_host = "127.0.0.1"
_port = 8765
_tok = getattr(self, "_gateway_token", "")
_masked = (_tok[:4] + "…" + _tok[-4:]) if len(_tok) >= 10 else "(set)"
_dashboard_url = f"http://{_host}:{_port}/info?token={_tok}" if _tok else f"http://{_host}:{_port}/info"
_health_url = f"http://{_host}:{_port}/health"
console.print(Panel(
f"[bold green]Setup complete![/bold green]\n\n"
f"Start your bot:\n"
f"[bold]Dashboard link (with token):[/bold]\n"
f" [cyan]{_dashboard_url}[/cyan]\n"
f" [dim]Token {_masked} stored in ~/.praisonai/.env as GATEWAY_AUTH_TOKEN[/dim]\n\n"
f"[bold]Public health (no token):[/bold]\n"
f" [cyan]{_health_url}[/cyan]\n\n"
f"[bold]Start your bot (foreground):[/bold]\n"
f" [cyan]praisonai bot start --config {self.config_path}[/cyan]\n\n"
f"Check health:\n"
f" [cyan]praisonai doctor[/cyan]\n\n"
f"View gateway status:\n"
f"[bold]Check everything:[/bold]\n"
f" [cyan]praisonai doctor[/cyan]\n"
f" [cyan]praisonai gateway status[/cyan]",
title="✅ Done",
border_style="green",
@@ -549,12 +595,15 @@ class OnboardWizard:
env_file = _save_env_vars(env_to_save)
yaml_content = _generate_bot_yaml(self.selected_platforms)
with open("bot.yaml", "w") as f:
from praisonai.cli._paths import default_bot_config_path
cfg_path = default_bot_config_path()
os.makedirs(cfg_path.parent, exist_ok=True)
with open(cfg_path, "w") as f:
f.write(yaml_content)
print("\n✓ Written to bot.yaml")
print(f"\n✓ Written to {cfg_path}")
if env_file:
print(f"✓ Secrets saved to {env_file} (chmod 600)")
print("Start with: praisonai bot start --config bot.yaml")
print(f"Start with: praisonai bot start --config {cfg_path}")
def run_onboard() -> None:
+1 -1
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==4.6.13 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==4.6.16 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
+42 -12
View File
@@ -203,12 +203,21 @@ class WebSocketGateway:
"""
self.config = config or GatewayConfig(host=host, port=port)
if hasattr(self.config, 'auth_token') and not self.config.auth_token:
import secrets
self.config.auth_token = secrets.token_hex(16)
logger.warning(
f"No auth_token provided for Gateway server. Generated temporary token: {self.config.auth_token}. "
"For production, set GATEWAY_AUTH_TOKEN."
)
# Prefer a user-configured token (persisted by `praisonai onboard`
# to ~/.praisonai/.env as GATEWAY_AUTH_TOKEN) so the dashboard
# URL stays stable across daemon restarts. Only fall back to
# generating a random ephemeral token if nothing is set.
env_tok = os.environ.get("GATEWAY_AUTH_TOKEN", "").strip()
if env_tok:
self.config.auth_token = env_tok
logger.info("Gateway using GATEWAY_AUTH_TOKEN from environment")
else:
import secrets
self.config.auth_token = secrets.token_hex(16)
logger.warning(
f"No auth_token provided for Gateway server. Generated temporary token: {self.config.auth_token}. "
"For production, set GATEWAY_AUTH_TOKEN."
)
self._host = self.config.host
self._port = self.config.port
@@ -267,17 +276,27 @@ class WebSocketGateway:
return JSONResponse(self.health())
def _check_auth(request) -> Optional[JSONResponse]:
"""Validate auth token if configured. Returns error response or None."""
"""Validate auth token if configured. Returns error response or None.
Accepts either:
- ``Authorization: Bearer <token>`` header (preferred for APIs)
- ``?token=<token>`` query parameter (so the dashboard URL from
``praisonai onboard`` is clickable in a browser)
"""
if not self.config.auth_token:
return None
auth_header = request.headers.get("authorization", "")
if not auth_header.startswith("Bearer "):
token: str = ""
if auth_header.startswith("Bearer "):
token = auth_header[7:]
else:
token = request.query_params.get("token", "")
if not token:
return JSONResponse(
{"error": "Authentication required"},
status_code=401,
headers={"WWW-Authenticate": "Bearer"},
)
token = auth_header[7:]
if not secrets.compare_digest(token, self.config.auth_token):
return JSONResponse(
{"error": "Invalid authentication token"},
@@ -1030,15 +1049,26 @@ class WebSocketGateway:
@staticmethod
def _substitute_env_vars(value: str) -> str:
"""Replace ${VAR_NAME} patterns with environment variable values."""
"""Replace ``${VAR_NAME}`` patterns with environment variable values.
Previously, missing env vars returned the literal ``${VAR_NAME}`` which
caused downstream APIs (e.g. Telegram) to receive a broken token
string and fail with opaque 404s. We now substitute missing vars with
an **empty string** so the schema validator's required-field checks
(e.g. ``token`` must be truthy) trip cleanly instead.
"""
if not isinstance(value, str):
return value
def _replacer(match):
var_name = match.group(1)
env_val = os.environ.get(var_name)
if env_val is None:
logger.warning(f"Environment variable {var_name} not set")
return match.group(0)
logger.warning(
f"Environment variable {var_name} not set "
f"— substituting empty string. "
f"Run `praisonai onboard` or set it in ~/.praisonai/.env."
)
return ""
return env_val
return re.sub(r'\$\{([^}]+)\}', _replacer, value)
+19 -6
View File
@@ -222,13 +222,26 @@ class ScheduledAgentExecutor:
if delivery else None
)
# Build chat kwargs
# Build chat kwargs — only pass session_id if the agent
# actually accepts it (Core SDK's Agent.chat signature does
# not currently expose session_id; older assumption caused a
# TypeError on every scheduled job).
import inspect as _inspect
chat_kwargs: Dict[str, Any] = {}
if session_target == "main" and session_id:
chat_kwargs["session_id"] = session_id
else:
# Isolated mode: use a cron-specific session
chat_kwargs["session_id"] = f"cron_{job.id}"
try:
chat_params = _inspect.signature(agent.chat).parameters
except (TypeError, ValueError): # C-callable or builtin
chat_params = {}
supports_session_id = (
"session_id" in chat_params
or any(p.kind == _inspect.Parameter.VAR_KEYWORD
for p in chat_params.values())
)
if supports_session_id:
if session_target == "main" and session_id:
chat_kwargs["session_id"] = session_id
else:
chat_kwargs["session_id"] = f"cron_{job.id}"
result = await asyncio.to_thread(
agent.chat, message, **chat_kwargs,
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.6.13"
__version__ = "4.6.16"
+1 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"rich>=13.7",
"markdown>=3.5",
"pyparsing>=3.0.0",
"praisonaiagents>=1.6.13",
"praisonaiagents>=1.6.16",
"python-dotenv>=0.19.0",
"litellm>=1.81.0,<=1.82.6",
"PyYAML>=6.0",
+1 -1
View File
@@ -4808,7 +4808,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.6.13"
version = "1.6.16"
source = { directory = "../praisonai-agents" }
dependencies = [
{ name = "aiohttp" },