Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 17 additions & 11 deletions plugins/openclaw/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,10 @@ This directory is a contributed reference for connecting
backend.

> **Reference status.** This is not one of the shared, plug-and-play
> `skillopt_sleep` wrappers. Several scripts and the sample config contain
> environment-specific absolute paths and assumptions from the original setup,
> and the contributed wrapper has unresolved Python 3.10 syntax and backend
> factory-signature gaps. The current checkout is not directly runnable; treat
> it as porting source material, not an installation.
> `skillopt_sleep` wrappers. Paths now follow OpenClaw's home/state/profile
> environment, but the contributed wrapper still has unresolved Python 3.10
> behavior and backend factory-signature gaps. The current checkout is not
> directly runnable; treat it as porting source material, not an installation.

## Included components

Expand Down Expand Up @@ -50,12 +49,19 @@ staged files before changing a live skill.
cd SkillOpt/plugins/openclaw
```

2. Inspect and replace the sample absolute paths in `run_sleep.py`,
`slash_sleep.py`, `run_sleep_cron.sh`, and `config.json`. Confirm the engine
checkout, OpenClaw workspace, state directory, skill directory, and task-file
paths all point to isolated test locations.

3. Review `config.json`. In particular, do not assume that values such as
2. Review the resolved locations before use. The scripts derive their own
plugin directory, the repository root, and task files from
`run_sleep_cron.sh`'s own path. OpenClaw locations follow
`OPENCLAW_HOME`, `OPENCLAW_STATE_DIR`, and `OPENCLAW_PROFILE`; the blank
`claude_home` and `invoked_project` values in `config.json` resolve to
`<OPENCLAW_STATE_DIR>/agents` and `<OPENCLAW_STATE_DIR>/workspace`.
Set `SKILLOPT_REPO` only when the SkillOpt package cannot be imported from
the checkout containing this plugin. `run_sleep.py` prefers an installed
config at `<OPENCLAW_STATE_DIR>/workspace/skills/skillopt-sleep/config.json`
and otherwise falls back to the sample beside the script.

3. Review `config.json`. Replace blank path values only when the OpenClaw
defaults are not appropriate. In particular, do not assume that values such as
`replay_mode` are enforced by this custom wrapper merely because they appear
in the example config. `max_tokens_per_night` is applied by the shared cycle,
but it is measured with the wrapper backend's rough `tokens_used()` estimate
Expand Down
15 changes: 8 additions & 7 deletions plugins/openclaw/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ OpenClaw integration. It illustrates one way to connect the shared
`skillopt_sleep` cycle to a custom DeepSeek Chat Completions backend and a set of
environment-specific task fixtures.

Do not run or schedule the files unchanged. Several scripts and the sample
configuration preserve assumptions from the contributor's original machine,
and parts of the wrapper have not yet been ported to the current shared-engine
interfaces. Start with the directory's [README.md](README.md), which is the
authoritative status and adaptation guide.
Do not run or schedule the files unchanged. Backend, transcript-conversion, and
adoption assumptions remain from the contributor's original setup, and parts
of the wrapper have not yet been ported to the current shared-engine interfaces.
Start with the directory's [README.md](README.md), which is the authoritative
status and adaptation guide.

## What is included

Expand All @@ -36,8 +36,9 @@ authoritative status and adaptation guide.

Before treating this as an integration, a maintainer must at least:

1. Replace every absolute workspace, repository, state, skill, log, and task
path with explicit user configuration.
1. Configure the OpenClaw home/state/profile and SkillOpt repository explicitly
when they differ from the defaults. The scripts derive their own plugin,
skill, and task locations; check all resolved paths before use.
2. Update the custom backend factory to the current `get_backend` call contract,
including the project directory, and update its backend methods and edit
records to the current protocol.
Expand Down
6 changes: 3 additions & 3 deletions plugins/openclaw/config.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"_comment": "OpenClaw adaptation of skillopt-sleep. Edit and run via run_sleep.py",
"_comment": "OpenClaw adaptation. Blank path fields follow OPENCLAW_HOME, OPENCLAW_STATE_DIR, and OPENCLAW_PROFILE; edit and run via run_sleep.py",

"claude_home": "/home/ethanclaw/.openclaw/agents",
"invoked_project": "/home/ethanclaw/.openclaw/workspace",
"claude_home": "",
"invoked_project": "",
"projects": "invoked",
"lookback_hours": 168,

Expand Down
81 changes: 81 additions & 0 deletions plugins/openclaw/openclaw_paths.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Resolve OpenClaw locations without assuming a specific OS user."""

from __future__ import annotations

import os
import re
from pathlib import Path
from typing import Any

_PROFILE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")


def _configured(name: str) -> str:
value = os.environ.get(name, "").strip()
if value.lower() in {"", "undefined", "null"}:
return ""
return value


def _system_home() -> Path:
configured = _configured("HOME")
return Path(configured) if configured else Path.home()


def _expand_user_path(value: str | Path) -> str:
text = str(value)
if text == "~":
return str(_system_home())
if text.startswith("~/") or text.startswith("~\\"):
return str(_system_home() / text[2:])
return text


def _absolute_path(value: str | Path) -> Path:
"""Match OpenClaw's lexical absolute paths without resolving symlinks."""
return Path(os.path.abspath(_expand_user_path(value)))


def resolve_openclaw_path(value: str | Path) -> Path:
"""Resolve a user path with OpenClaw's HOME fallback and no realpath."""
return _absolute_path(value)


def openclaw_home() -> Path:
"""Return the home used by OpenClaw path defaults."""
configured = _configured("OPENCLAW_HOME")
if configured:
return resolve_openclaw_path(configured)
home = _configured("HOME")
return resolve_openclaw_path(home) if home else resolve_openclaw_path(Path.home())


def openclaw_state_dir() -> Path:
"""Match OpenClaw's state-dir and named-profile conventions."""
configured = _configured("OPENCLAW_STATE_DIR")
if configured:
return resolve_openclaw_path(configured)
profile = _configured("OPENCLAW_PROFILE")
valid_profile = bool(_PROFILE_NAME_RE.fullmatch(profile))
if not valid_profile or profile.lower() == "default":
profile = ""
dirname = f".openclaw-{profile}" if profile else ".openclaw"
return openclaw_home() / dirname


def openclaw_agents_dir() -> Path:
return openclaw_state_dir() / "agents"


def openclaw_workspace_dir() -> Path:
return openclaw_state_dir() / "workspace"


def apply_openclaw_defaults(overrides: dict[str, Any]) -> dict[str, Any]:
"""Fill blank OpenClaw paths without overriding operator choices."""
resolved = dict(overrides)
if not resolved.get("claude_home"):
resolved["claude_home"] = str(openclaw_agents_dir())
if not resolved.get("invoked_project"):
resolved["invoked_project"] = str(openclaw_workspace_dir())
return resolved
13 changes: 10 additions & 3 deletions plugins/openclaw/run_sleep.py
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
3. replay tasks with current skill (baseline) + candidate skill (with proposed edit)
4. gate candidate vs baseline on held-out accuracy
5. stage the proposal in ~/.skillopt-sleep/staging/<night>/
6. leave adoption to Ethan (auto_adopt=false)
6. leave adoption to the operator (auto_adopt=false)

Usage:
python3 run_sleep.py # one cycle, default config
Expand All @@ -22,8 +22,14 @@
import sys
from pathlib import Path

from openclaw_paths import apply_openclaw_defaults, openclaw_workspace_dir, resolve_openclaw_path

# Ensure the skillopt_sleep package is importable (it lives in the cloned repo)
REPO = Path("/home/ethanclaw/.openclaw/workspace/SkillOpt")
PLUGIN_DIR = Path(__file__).resolve().parent
REPO_ROOT = os.environ.get("SKILLOPT_REPO", PLUGIN_DIR.parents[1])
REPO = resolve_openclaw_path(REPO_ROOT)
INSTALLED_CONFIG = openclaw_workspace_dir() / "skills" / "skillopt-sleep" / "config.json"
DEFAULT_CONFIG = INSTALLED_CONFIG if INSTALLED_CONFIG.is_file() else PLUGIN_DIR / "config.json"
sys.path.insert(0, str(REPO))

# Register our backend before importing cycle
Expand Down Expand Up @@ -72,7 +78,7 @@ def get_backend(
def main() -> int:
ap = argparse.ArgumentParser(description="OpenClaw SkillOpt-Sleep nightly cycle")
ap.add_argument("--dry-run", action="store_true", help="Compute but don't stage")
ap.add_argument("--config", default="/home/ethanclaw/.openclaw/workspace/skills/skillopt-sleep/config.json")
ap.add_argument("--config", default=str(DEFAULT_CONFIG))
ap.add_argument("--tasks", default=None, help="Path to pre-built tasks JSON")
ap.add_argument("--verbose", action="store_true")
args = ap.parse_args()
Expand All @@ -83,6 +89,7 @@ def main() -> int:
with open(args.config) as f:
overrides.update(json.load(f))
overrides.pop("_comment", None)
overrides = apply_openclaw_defaults(overrides)

cfg = load_config(**overrides)

Expand Down
45 changes: 39 additions & 6 deletions plugins/openclaw/run_sleep_cron.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,46 @@
# With args: run only on listed categories (research-cron, devops, wiki)
#
# Cron (3am MYT daily):
# 0 3 * * * cd /home/ethanclaw/.openclaw/workspace/skills/skillopt-sleep && bash run_sleep_cron.sh >> ~/.skillopt-sleep/nightly.log 2>&1
# 0 3 * * * cd /path/to/SkillOpt/plugins/openclaw && bash run_sleep_cron.sh >> ~/.skillopt-sleep/nightly.log 2>&1

set -euo pipefail

SKILL_DIR="/home/ethanclaw/.openclaw/workspace/skills/skillopt-sleep"
SKILL_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
TESTS_DIR="$SKILL_DIR/tests"
LOG_DIR="$HOME/.skillopt-sleep/logs"
OPENCLAW_HOME="${OPENCLAW_HOME:-$HOME}"
case "${OPENCLAW_HOME,,}" in
""|undefined|null) OPENCLAW_HOME="$HOME" ;;
esac
case "$OPENCLAW_HOME" in
"~") OPENCLAW_HOME="$HOME" ;;
"~/"*) OPENCLAW_HOME="$HOME/${OPENCLAW_HOME#\~/}" ;;
"~\\"*) OPENCLAW_HOME="$HOME/${OPENCLAW_HOME#\~\\}" ;;
esac
OPENCLAW_PROFILE="${OPENCLAW_PROFILE:-}"
if [[ "$OPENCLAW_PROFILE" =~ ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$ ]]; then
if [ "$(printf '%s' "$OPENCLAW_PROFILE" | tr '[:upper:]' '[:lower:]')" = "default" ]; then
OPENCLAW_PROFILE=""
fi
else
OPENCLAW_PROFILE=""
fi
OPENCLAW_STATE_DIR="${OPENCLAW_STATE_DIR:-}"
case "${OPENCLAW_STATE_DIR,,}" in
""|undefined|null)
if [ -n "$OPENCLAW_PROFILE" ]; then
OPENCLAW_STATE_DIR="$OPENCLAW_HOME/.openclaw-$OPENCLAW_PROFILE"
else
OPENCLAW_STATE_DIR="$OPENCLAW_HOME/.openclaw"
fi
;;
esac
case "$OPENCLAW_STATE_DIR" in
"~") OPENCLAW_STATE_DIR="$HOME" ;;
"~/"*) OPENCLAW_STATE_DIR="$HOME/${OPENCLAW_STATE_DIR#\~/}" ;;
"~\\"*) OPENCLAW_STATE_DIR="$HOME/${OPENCLAW_STATE_DIR#\~\\}" ;;
esac
SKILLOPT_STATE_DIR="${SKILLOPT_SLEEP_STATE_DIR:-$HOME/.skillopt-sleep}"
LOG_DIR="$SKILLOPT_STATE_DIR/logs"
mkdir -p "$LOG_DIR"

TIMESTAMP=$(date +%Y%m%d-%H%M%S)
Expand Down Expand Up @@ -40,8 +73,8 @@ fi
} | tee -a "$LOG_FILE"

# Pre-flight: check DeepSeek API key
if ! grep -q "DEEPSEEK_API_KEY=" "$HOME/.openclaw/.env" 2>/dev/null; then
echo "ERROR: DEEPSEEK_API_KEY not found in ~/.openclaw/.env" | tee -a "$LOG_FILE"
if ! grep -q "DEEPSEEK_API_KEY=" "$OPENCLAW_STATE_DIR/.env" 2>/dev/null; then
echo "ERROR: DEEPSEEK_API_KEY not found in $OPENCLAW_STATE_DIR/.env" | tee -a "$LOG_FILE"
exit 1
fi

Expand All @@ -57,7 +90,7 @@ for cat in "${CATS[@]}"; do
echo "--- [$cat] starting cycle ---" | tee -a "$LOG_FILE"

cd "$SKILL_DIR"
if python3 run_sleep.py --tasks "$tasks_file" 2>&1 | tee -a "$LOG_FILE"; then
if python3 "$SKILL_DIR/run_sleep.py" --tasks "$tasks_file" 2>&1 | tee -a "$LOG_FILE"; then
echo "--- [$cat] OK ---" | tee -a "$LOG_FILE"
else
EC=$?
Expand Down
18 changes: 9 additions & 9 deletions plugins/openclaw/slash_sleep.py
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -24,23 +24,23 @@
from pathlib import Path
from datetime import datetime

SKILL_DIR = Path("/home/ethanclaw/.openclaw/workspace/skills/skillopt-sleep")
STATE_DIR = Path(os.path.expanduser("~/.skillopt-sleep")) # default
STAGING_ROOT = STATE_DIR
from openclaw_paths import openclaw_state_dir, openclaw_workspace_dir

SKILL_DIR = Path(__file__).resolve().parent

def _resolve_state_dir():
"""Find the actual state dir.

Priority: scan in order:
1. ~/.skillopt-sleep/ (default)
2. /home/ethanclaw/.openclaw/workspace/.skillopt-sleep/ (when staging is there)
3. /home/ethanclaw/.openclaw/.skillopt-sleep/ (parent of overridden claude_home)
2. <OPENCLAW_STATE_DIR>/workspace/.skillopt-sleep/ (when staging is there)
3. <OPENCLAW_STATE_DIR>/.skillopt-sleep/ (parent of OpenClaw agents)
Pick the first one that has a state.json OR staging dir.
"""
candidates = [
Path(os.path.expanduser("~/.skillopt-sleep")),
Path("/home/ethanclaw/.openclaw/workspace/.skillopt-sleep"),
Path("/home/ethanclaw/.openclaw/.skillopt-sleep"),
openclaw_workspace_dir() / ".skillopt-sleep",
openclaw_state_dir() / ".skillopt-sleep",
]
# Prefer the one with state.json
for c in candidates:
Expand Down Expand Up @@ -161,8 +161,8 @@ def adopt(night: str = None) -> int:
m = json.load(f)
proposed = m.get("proposed_skill")
if proposed and Path(proposed).exists():
live = STATE_DIR / "live_skill.md"
backup = STATE_DIR / f"live_skill.md.bak-{target['night']}"
live = state_dir / "live_skill.md"
backup = state_dir / f"live_skill.md.bak-{target['night']}"
if live.exists():
shutil.copy2(live, backup)
print(f" backed up current live skill → {backup}")
Expand Down
Loading