From eccca8727ce4f5818f84fd44baf3458a6debb790 Mon Sep 17 00:00:00 2001 From: heaoxiang Date: Sat, 9 May 2026 14:39:20 +0800 Subject: [PATCH 01/11] feat: add ov dream skill --- examples/skills/ov_dream/SKILL.md | 63 +++ examples/skills/ov_dream/__init__.py | 1 + examples/skills/ov_dream/scripts/__init__.py | 1 + examples/skills/ov_dream/scripts/dream.py | 388 ++++++++++++++++++ .../skills/ov_dream/tests/test_dream_cli.py | 66 +++ 5 files changed, 519 insertions(+) create mode 100644 examples/skills/ov_dream/SKILL.md create mode 100644 examples/skills/ov_dream/__init__.py create mode 100644 examples/skills/ov_dream/scripts/__init__.py create mode 100644 examples/skills/ov_dream/scripts/dream.py create mode 100644 examples/skills/ov_dream/tests/test_dream_cli.py diff --git a/examples/skills/ov_dream/SKILL.md b/examples/skills/ov_dream/SKILL.md new file mode 100644 index 000000000..e1c6e74db --- /dev/null +++ b/examples/skills/ov_dream/SKILL.md @@ -0,0 +1,63 @@ +--- +name: ov_dream +description: Use when the user explicitly types `ov dream` or `ov recall ` and the request should be routed to the local OpenViking sync/recall CLI instead of handled as normal chat. +--- + +# OV Dream + +Use this skill only for manual OpenViking validation without occupying the OpenClaw `contextEngine` slot. + +## When To Use + +Use this skill when the user message begins with one of these exact prefixes: + +- `ov dream` +- `ov recall ` + +Do not treat those messages as normal conversation. They are explicit operator commands. + +## Commands + +- `ov dream` + Manual sync. Read the active OpenClaw session, upload new `user` and `assistant` messages to OpenViking, then commit when new messages exist. + +- `ov recall ` + Manual recall. Search OpenViking memories under `viking://user/memories`. + +## Mode 3: Recall + +Trigger when the user message starts with `ov recall `. + +This is a hard routing rule for this skill: + +- If the user says `ov recall `, do not answer from general reasoning. +- Do not summarize what recall would do. +- Do not ask whether recall should be run. +- Immediately execute the local recall command. + +Execution flow: + +1. Extract everything after `ov recall` as the recall query. +2. Run: + + ```bash + python3 scripts/dream.py recall "" + ``` + +3. Return the relevant memory rows to the user. +4. If no memories are found, return `No memories found.` + +Rules: + +- Treat `ov recall ...` as a manual recall request, not a normal conversation turn. +- Treat the command text after `ov recall` as the exact recall query. +- Run the recall command from the skill directory so `scripts/dream.py` resolves correctly. +- Do not auto-inject retrieved memories into prompt context. +- Do not trigger `ov dream` unless the user separately asks for sync. +- If the query is empty, ask the user for the recall query instead of guessing. + +## Notes + +- This skill is manual-only in the first version. +- It does not auto-inject recall into prompts. +- It does not replace the OpenViking context-engine plugin. diff --git a/examples/skills/ov_dream/__init__.py b/examples/skills/ov_dream/__init__.py new file mode 100644 index 000000000..948c96cac --- /dev/null +++ b/examples/skills/ov_dream/__init__.py @@ -0,0 +1 @@ +"""OV Dream skill package.""" diff --git a/examples/skills/ov_dream/scripts/__init__.py b/examples/skills/ov_dream/scripts/__init__.py new file mode 100644 index 000000000..41d020e9e --- /dev/null +++ b/examples/skills/ov_dream/scripts/__init__.py @@ -0,0 +1 @@ +"""Scripts for the OV Dream skill.""" diff --git a/examples/skills/ov_dream/scripts/dream.py b/examples/skills/ov_dream/scripts/dream.py new file mode 100644 index 000000000..43c0e8744 --- /dev/null +++ b/examples/skills/ov_dream/scripts/dream.py @@ -0,0 +1,388 @@ +from __future__ import annotations + +import argparse +import json +import os +import sys +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable +from urllib.error import HTTPError +from urllib.request import Request, urlopen + + +DEFAULT_BASE_URL = "http://127.0.0.1:1933" +DEFAULT_TARGET_URI = "viking://user/memories" + + +@dataclass +class Message: + role: str + content: str + timestamp: str + + +@dataclass +class Session: + session_id: str + cwd: str + created_at: str + + +class OpenVikingClient: + def __init__(self, base_url: str, api_key: str | None = None, timeout: int = 30) -> None: + self.base_url = base_url.rstrip("/") + self.api_key = api_key or os.environ.get("OPENVIKING_API_KEY", "") + self.timeout = timeout + + def _headers(self) -> dict[str, str]: + headers = { + "Content-Type": "application/json", + "X-OpenViking-Account": os.environ.get("OPENVIKING_ACCOUNT", "default"), + "X-OpenViking-User": os.environ.get("OPENVIKING_USER", "default"), + "X-OpenViking-Agent": os.environ.get("OPENVIKING_AGENT", "default"), + } + if self.api_key: + headers["X-API-Key"] = self.api_key + return headers + + def _resolve_target_uri(self, target_uri: str) -> str: + normalized = target_uri.rstrip("/") + if normalized == DEFAULT_TARGET_URI: + user_space = self._headers().get("X-OpenViking-User", "default") or "default" + return f"viking://user/{user_space}/memories/" + return target_uri + + def _request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: + data = None if payload is None else json.dumps(payload).encode("utf-8") + request = Request( + f"{self.base_url}{path}", + data=data, + headers=self._headers(), + method=method, + ) + try: + with urlopen(request, timeout=self.timeout) as response: + body = json.loads(response.read().decode("utf-8")) + except HTTPError as exc: + raw = exc.read().decode("utf-8", errors="replace") + try: + body = json.loads(raw) + except json.JSONDecodeError as decode_exc: + raise RuntimeError(f"HTTP {exc.code}: {raw}") from decode_exc + error = body.get("error") or {} + detail = body.get("detail") + message = error.get("message") or detail or f"HTTP {exc.code}" + raise RuntimeError(message) from exc + if body.get("status") == "error": + message = body.get("error", {}).get("message", "unknown error") + raise RuntimeError(message) + return body.get("result", body) + + def add_session_message(self, session_id: str, role: str, content: str) -> dict[str, Any]: + return self._request( + "POST", + f"/api/v1/sessions/{session_id}/messages", + {"role": role, "content": content}, + ) + + def commit_session(self, session_id: str, wait: bool = True) -> dict[str, Any]: + suffix = "?wait=true" if wait else "" + return self._request("POST", f"/api/v1/sessions/{session_id}/commit{suffix}", {}) + + def recall(self, query: str, limit: int = 5, target_uri: str = DEFAULT_TARGET_URI) -> dict[str, Any]: + return self._request( + "POST", + "/api/v1/search/find", + { + "query": query, + "limit": limit, + "target_uri": self._resolve_target_uri(target_uri), + }, + ) + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") + + +def load_sync_state(state_root: Path) -> dict[str, Any]: + path = state_root / "ov_dream_sync.json" + if not path.exists(): + return {"sessions": {}} + return json.loads(path.read_text(encoding="utf-8")) + + +def save_sync_state(state_root: Path, state: dict[str, Any]) -> None: + state_root.mkdir(parents=True, exist_ok=True) + path = state_root / "ov_dream_sync.json" + path.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8") + + +def get_session_path(sessions_root: Path, session_id: str) -> Path: + return sessions_root / f"{session_id}.jsonl" + + +def get_active_session(openclaw_root: Path) -> Session | None: + sessions_root = openclaw_root / "agents" / "main" / "sessions" + if not sessions_root.exists(): + return None + + indexed = _get_indexed_active_session(sessions_root) + if indexed is not None: + return indexed + + files = [ + path + for path in sessions_root.glob("*.jsonl") + if ".reset." not in path.name and ".checkpoint." not in path.name + ] + if not files: + return None + + latest = max(files, key=lambda path: path.stat().st_mtime) + lines = latest.read_text(encoding="utf-8").splitlines() + if not lines: + return None + first = json.loads(lines[0]) + return Session( + session_id=first["id"], + cwd=first.get("cwd", ""), + created_at=first.get("timestamp", ""), + ) + + +def _get_indexed_active_session(sessions_root: Path) -> Session | None: + index_path = sessions_root / "sessions.json" + if not index_path.exists(): + return None + try: + index = json.loads(index_path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return None + if not isinstance(index, dict): + return None + + active = index.get("agent:main:main") + if not isinstance(active, dict): + return None + + session_id = active.get("sessionId") + if not isinstance(session_id, str) or not session_id: + return None + + session_file = active.get("sessionFile") + path = Path(session_file) if isinstance(session_file, str) and session_file else get_session_path(sessions_root, session_id) + if not path.exists(): + return None + + lines = path.read_text(encoding="utf-8").splitlines() + if not lines: + return None + try: + first = json.loads(lines[0]) + except json.JSONDecodeError: + return None + return Session( + session_id=session_id, + cwd=first.get("cwd", ""), + created_at=first.get("timestamp", ""), + ) + + +def parse_messages(sessions_root: Path, session_id: str, after_timestamp: str | None) -> Iterable[Message]: + path = get_session_path(sessions_root, session_id) + if not path.exists(): + return [] + + messages: list[Message] = [] + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + row = json.loads(line) + if row.get("type") != "message": + continue + timestamp = row.get("timestamp", "") + if after_timestamp and timestamp <= after_timestamp: + continue + message = row.get("message", {}) + role = message.get("role") + if role not in {"user", "assistant"}: + continue + blocks = message.get("content", []) + text_parts = [ + block.get("text", "").strip() + for block in blocks + if block.get("type") == "text" + ] + content = "\n".join(part for part in text_parts if part) + if not content: + continue + messages.append(Message(role=role, content=content, timestamp=timestamp)) + return messages + + +def sync_active_session(client: OpenVikingClient, openclaw_root: Path, state_root: Path) -> dict[str, Any]: + sessions_root = openclaw_root / "agents" / "main" / "sessions" + session = get_active_session(openclaw_root) + if session is None: + raise RuntimeError("No active OpenClaw session found.") + + state = load_sync_state(state_root) + sessions = state.setdefault("sessions", {}) + session_state = sessions.get(session.session_id) + if not isinstance(session_state, dict): + session_state = {} + + last_synced_timestamp = session_state.get("last_synced_timestamp") + messages = [ + message + for message in parse_messages(sessions_root, session.session_id, last_synced_timestamp) + if message.timestamp and (last_synced_timestamp is None or message.timestamp > last_synced_timestamp) + ] + messages.sort(key=lambda message: message.timestamp) + + synced_count = 0 + committed = False + now = _utc_now_iso() + try: + for message in messages: + client.add_session_message(session.session_id, message.role, message.content) + synced_count += 1 + + session_state["last_status"] = "ok" + session_state["last_synced_count"] = synced_count + session_state["last_sync_at"] = now + session_state["committed"] = False + + if synced_count: + client.commit_session(session.session_id, wait=True) + committed = True + session_state["committed"] = True + session_state["last_commit_at"] = now + last_synced_timestamp = messages[-1].timestamp + session_state["last_synced_timestamp"] = last_synced_timestamp + except Exception: + session_state["last_status"] = "error" + session_state["last_synced_count"] = synced_count + session_state["last_sync_at"] = now + session_state["committed"] = False + sessions[session.session_id] = session_state + save_sync_state(state_root, state) + raise + + sessions[session.session_id] = session_state + save_sync_state(state_root, state) + return { + "session_id": session.session_id, + "synced_count": synced_count, + "committed": committed, + "last_synced_timestamp": last_synced_timestamp, + } + + +def _normalize_ov_command(argv: list[str] | None) -> list[str] | None: + if argv is None: + return None + if not argv: + return argv + if len(argv) >= 2 and argv[0] == "ov": + if argv[1] == "dream": + return ["dream"] + if argv[1] == "recall": + query = " ".join(argv[2:]).strip() + return ["recall", query] if query else ["recall", ""] + if len(argv) == 1: + raw = argv[0].strip() + if raw == "ov dream": + return ["dream"] + if raw.startswith("ov recall "): + return ["recall", raw[len("ov recall ") :].strip()] + return argv + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="ov dream") + parser.add_argument("--base-url", default=os.environ.get("OPENVIKING_BASE_URL", DEFAULT_BASE_URL)) + parser.add_argument("--api-key", default=None) + parser.add_argument("--openclaw-root", default=str(Path.home() / ".openclaw")) + parser.add_argument("--state-root", default=str(Path.home() / ".openclaw" / "memory")) + + subparsers = parser.add_subparsers(dest="command", required=True) + subparsers.add_parser("dream", help="Sync the active OpenClaw session to OpenViking.") + + recall = subparsers.add_parser("recall", help="Recall memories from OpenViking.") + recall.add_argument("query") + recall.add_argument("--limit", type=int, default=5) + + return parser + + +def _iter_memories(result: Any) -> Iterable[dict[str, Any]]: + if not isinstance(result, dict): + return [] + memories = result.get("memories") + if not isinstance(memories, list): + return [] + return [item for item in memories if isinstance(item, dict)] + + +def _print_sync_summary(summary: dict[str, Any]) -> None: + print( + "session_id={session_id} synced_count={synced_count} committed={committed} last_synced_timestamp={last_synced_timestamp}".format( + session_id=summary.get("session_id", ""), + synced_count=summary.get("synced_count", 0), + committed=str(summary.get("committed", False)).lower(), + last_synced_timestamp=summary.get("last_synced_timestamp", ""), + ) + ) + + +def _print_recall_results(result: Any) -> None: + memories = list(_iter_memories(result)) + if not memories: + print("No memories found.") + return + + for item in memories: + uri = item.get("uri", "") + score = item.get("score", "") + summary = item.get("abstract") or item.get("overview") or "" + print(f"{uri}|{score}|{summary}") + + +def run_dream(args: argparse.Namespace) -> int: + client = OpenVikingClient(base_url=args.base_url, api_key=args.api_key) + summary = sync_active_session( + client=client, + openclaw_root=Path(args.openclaw_root), + state_root=Path(args.state_root), + ) + _print_sync_summary(summary) + return 0 + + +def run_recall(args: argparse.Namespace) -> int: + client = OpenVikingClient(base_url=args.base_url, api_key=args.api_key) + result = client.recall(query=args.query, limit=args.limit) + _print_recall_results(result) + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = _build_parser() + try: + args = parser.parse_args(_normalize_ov_command(argv)) + if args.command == "dream": + return run_dream(args) + if args.command == "recall": + return run_recall(args) + raise RuntimeError(f"Unsupported command: {args.command}") + except Exception as exc: + print(str(exc), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/skills/ov_dream/tests/test_dream_cli.py b/examples/skills/ov_dream/tests/test_dream_cli.py new file mode 100644 index 000000000..fd789846d --- /dev/null +++ b/examples/skills/ov_dream/tests/test_dream_cli.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +import sys + + +def _load_dream_module(): + module_path = Path("examples/skills/ov_dream/scripts/dream.py").resolve() + spec = importlib.util.spec_from_file_location("ov_dream_cli", module_path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +dream = _load_dream_module() + + +def test_normalize_raw_ov_recall_phrase() -> None: + assert dream._normalize_ov_command(["ov recall 小明的信息"]) == ["recall", "小明的信息"] + + +def test_recall_expands_user_memories_alias_to_explicit_user_space(monkeypatch) -> None: + monkeypatch.setenv("OPENVIKING_USER", "default") + client = dream.OpenVikingClient(base_url="http://127.0.0.1:1933") + + assert client._resolve_target_uri("viking://user/memories") == "viking://user/default/memories/" + assert client._resolve_target_uri("viking://user/memories/") == "viking://user/default/memories/" + assert client._resolve_target_uri("viking://user/default/memories/") == "viking://user/default/memories/" + + +def test_get_active_session_prefers_sessions_index(tmp_path: Path) -> None: + openclaw_root = tmp_path / ".openclaw" + sessions_root = openclaw_root / "agents" / "main" / "sessions" + sessions_root.mkdir(parents=True) + + indexed_session = sessions_root / "indexed.jsonl" + indexed_session.write_text( + json.dumps({"id": "indexed", "timestamp": "2026-04-20T00:00:00Z", "cwd": "/tmp"}) + "\n", + encoding="utf-8", + ) + newer_fallback = sessions_root / "newer.jsonl" + newer_fallback.write_text( + json.dumps({"id": "newer", "timestamp": "2026-04-20T00:00:01Z", "cwd": "/tmp"}) + "\n", + encoding="utf-8", + ) + + (sessions_root / "sessions.json").write_text( + json.dumps( + { + "agent:main:main": { + "sessionId": "indexed", + "sessionFile": str(indexed_session), + } + } + ), + encoding="utf-8", + ) + + session = dream.get_active_session(openclaw_root) + + assert session is not None + assert session.session_id == "indexed" From d1b6e696d522d05cbba1e62629ef8aec78fc3345 Mon Sep 17 00:00:00 2001 From: heaoxiang Date: Mon, 18 May 2026 11:22:38 +0800 Subject: [PATCH 02/11] feat: ov dream --- examples/skills/ov_dream/SKILL.md | 1 + examples/skills/ov_dream/scripts/dream.py | 100 +++++++++++++++--- .../skills/ov_dream/tests/test_dream_cli.py | 45 ++++++++ 3 files changed, 129 insertions(+), 17 deletions(-) diff --git a/examples/skills/ov_dream/SKILL.md b/examples/skills/ov_dream/SKILL.md index e1c6e74db..2c54c3565 100644 --- a/examples/skills/ov_dream/SKILL.md +++ b/examples/skills/ov_dream/SKILL.md @@ -61,3 +61,4 @@ Rules: - This skill is manual-only in the first version. - It does not auto-inject recall into prompts. - It does not replace the OpenViking context-engine plugin. +- For OpenViking serverless, configure `OPENVIKING_BASE_URL`, `OPENVIKING_API_KEY`, and `OPENVIKING_AGENT_ID`; the CLI will use Bearer auth and the serverless session message format automatically. diff --git a/examples/skills/ov_dream/scripts/dream.py b/examples/skills/ov_dream/scripts/dream.py index 43c0e8744..14cd80c1d 100644 --- a/examples/skills/ov_dream/scripts/dream.py +++ b/examples/skills/ov_dream/scripts/dream.py @@ -14,6 +14,7 @@ DEFAULT_BASE_URL = "http://127.0.0.1:1933" DEFAULT_TARGET_URI = "viking://user/memories" +SERVERLESS_BASE_URL = "https://api.vikingdb.cn-beijing.volces.com/openviking" @dataclass @@ -31,24 +32,51 @@ class Session: class OpenVikingClient: - def __init__(self, base_url: str, api_key: str | None = None, timeout: int = 30) -> None: + def __init__( + self, + base_url: str, + api_key: str | None = None, + agent_id: str | None = None, + auth_mode: str = "auto", + timeout: int = 30, + ) -> None: self.base_url = base_url.rstrip("/") self.api_key = api_key or os.environ.get("OPENVIKING_API_KEY", "") + self.agent_id = agent_id or os.environ.get("OPENVIKING_AGENT_ID") or os.environ.get("OPENVIKING_AGENT", "default") + self.auth_mode = self._resolve_auth_mode(auth_mode) self.timeout = timeout + def _resolve_auth_mode(self, auth_mode: str) -> str: + if auth_mode not in {"auto", "local", "serverless"}: + raise ValueError("auth_mode must be one of: auto, local, serverless") + if auth_mode != "auto": + return auth_mode + if "api.vikingdb" in self.base_url or self.base_url.endswith("/openviking"): + return "serverless" + return "local" + def _headers(self) -> dict[str, str]: - headers = { - "Content-Type": "application/json", - "X-OpenViking-Account": os.environ.get("OPENVIKING_ACCOUNT", "default"), - "X-OpenViking-User": os.environ.get("OPENVIKING_USER", "default"), - "X-OpenViking-Agent": os.environ.get("OPENVIKING_AGENT", "default"), - } - if self.api_key: - headers["X-API-Key"] = self.api_key + headers = {"Content-Type": "application/json"} + if self.auth_mode == "serverless": + headers["X-OpenViking-Agent"] = self.agent_id + if self.api_key: + headers["Authorization"] = "Bearer " + self.api_key + else: + headers.update( + { + "X-OpenViking-Account": os.environ.get("OPENVIKING_ACCOUNT", "default"), + "X-OpenViking-User": os.environ.get("OPENVIKING_USER", "default"), + "X-OpenViking-Agent": self.agent_id, + } + ) + if self.api_key: + headers["X-API-Key"] = self.api_key return headers def _resolve_target_uri(self, target_uri: str) -> str: normalized = target_uri.rstrip("/") + if self.auth_mode == "serverless": + return target_uri if normalized == DEFAULT_TARGET_URI: user_space = self._headers().get("X-OpenViking-User", "default") or "default" return f"viking://user/{user_space}/memories/" @@ -80,16 +108,36 @@ def _request(self, method: str, path: str, payload: dict[str, Any] | None = None raise RuntimeError(message) return body.get("result", body) + def create_session(self) -> str: + result = self._request("POST", "/api/v1/sessions", {}) + session_id = result.get("session_id") + if not isinstance(session_id, str) or not session_id: + raise RuntimeError("Create session response missing session_id.") + return session_id + + def _sync_session_id(self, source_session_id: str) -> str: + if self.auth_mode == "serverless": + return self.create_session() + return source_session_id + def add_session_message(self, session_id: str, role: str, content: str) -> dict[str, Any]: + if self.auth_mode == "serverless": + payload = { + "role": role, + "parts": [{"type": "text", "text": content}], + } + else: + payload = {"role": role, "content": content} return self._request( "POST", f"/api/v1/sessions/{session_id}/messages", - {"role": role, "content": content}, + payload, ) def commit_session(self, session_id: str, wait: bool = True) -> dict[str, Any]: - suffix = "?wait=true" if wait else "" - return self._request("POST", f"/api/v1/sessions/{session_id}/commit{suffix}", {}) + payload = {"telemetry": False} if self.auth_mode == "serverless" else {} + suffix = "" if self.auth_mode == "serverless" else "?wait=true" if wait else "" + return self._request("POST", f"/api/v1/sessions/{session_id}/commit{suffix}", payload) def recall(self, query: str, limit: int = 5, target_uri: str = DEFAULT_TARGET_URI) -> dict[str, Any]: return self._request( @@ -247,17 +295,21 @@ def sync_active_session(client: OpenVikingClient, openclaw_root: Path, state_roo committed = False now = _utc_now_iso() try: + openviking_session_id = None + if messages: + openviking_session_id = client._sync_session_id(session.session_id) for message in messages: - client.add_session_message(session.session_id, message.role, message.content) + client.add_session_message(openviking_session_id or session.session_id, message.role, message.content) synced_count += 1 session_state["last_status"] = "ok" session_state["last_synced_count"] = synced_count session_state["last_sync_at"] = now session_state["committed"] = False + session_state["openviking_session_id"] = openviking_session_id if synced_count: - client.commit_session(session.session_id, wait=True) + client.commit_session(openviking_session_id or session.session_id, wait=True) committed = True session_state["committed"] = True session_state["last_commit_at"] = now @@ -276,6 +328,7 @@ def sync_active_session(client: OpenVikingClient, openclaw_root: Path, state_roo save_sync_state(state_root, state) return { "session_id": session.session_id, + "openviking_session_id": session_state.get("openviking_session_id"), "synced_count": synced_count, "committed": committed, "last_synced_timestamp": last_synced_timestamp, @@ -306,6 +359,8 @@ def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="ov dream") parser.add_argument("--base-url", default=os.environ.get("OPENVIKING_BASE_URL", DEFAULT_BASE_URL)) parser.add_argument("--api-key", default=None) + parser.add_argument("--agent-id", default=None) + parser.add_argument("--auth-mode", choices=["auto", "local", "serverless"], default=os.environ.get("OPENVIKING_AUTH_MODE", "auto")) parser.add_argument("--openclaw-root", default=str(Path.home() / ".openclaw")) parser.add_argument("--state-root", default=str(Path.home() / ".openclaw" / "memory")) @@ -330,8 +385,9 @@ def _iter_memories(result: Any) -> Iterable[dict[str, Any]]: def _print_sync_summary(summary: dict[str, Any]) -> None: print( - "session_id={session_id} synced_count={synced_count} committed={committed} last_synced_timestamp={last_synced_timestamp}".format( + "session_id={session_id} openviking_session_id={openviking_session_id} synced_count={synced_count} committed={committed} last_synced_timestamp={last_synced_timestamp}".format( session_id=summary.get("session_id", ""), + openviking_session_id=summary.get("openviking_session_id") or "", synced_count=summary.get("synced_count", 0), committed=str(summary.get("committed", False)).lower(), last_synced_timestamp=summary.get("last_synced_timestamp", ""), @@ -353,7 +409,12 @@ def _print_recall_results(result: Any) -> None: def run_dream(args: argparse.Namespace) -> int: - client = OpenVikingClient(base_url=args.base_url, api_key=args.api_key) + client = OpenVikingClient( + base_url=args.base_url, + api_key=args.api_key, + agent_id=args.agent_id, + auth_mode=args.auth_mode, + ) summary = sync_active_session( client=client, openclaw_root=Path(args.openclaw_root), @@ -364,7 +425,12 @@ def run_dream(args: argparse.Namespace) -> int: def run_recall(args: argparse.Namespace) -> int: - client = OpenVikingClient(base_url=args.base_url, api_key=args.api_key) + client = OpenVikingClient( + base_url=args.base_url, + api_key=args.api_key, + agent_id=args.agent_id, + auth_mode=args.auth_mode, + ) result = client.recall(query=args.query, limit=args.limit) _print_recall_results(result) return 0 diff --git a/examples/skills/ov_dream/tests/test_dream_cli.py b/examples/skills/ov_dream/tests/test_dream_cli.py index fd789846d..7c9df48e6 100644 --- a/examples/skills/ov_dream/tests/test_dream_cli.py +++ b/examples/skills/ov_dream/tests/test_dream_cli.py @@ -32,6 +32,51 @@ def test_recall_expands_user_memories_alias_to_explicit_user_space(monkeypatch) assert client._resolve_target_uri("viking://user/default/memories/") == "viking://user/default/memories/" +def test_serverless_headers_use_bearer_and_agent_id() -> None: + client = dream.OpenVikingClient( + base_url=dream.SERVERLESS_BASE_URL, + api_key="test-key", + agent_id="test-agent", + ) + + assert client.auth_mode == "serverless" + assert client._headers()["Authorization"] == "Bearer test-key" + assert client._headers()["X-OpenViking-Agent"] == "test-agent" + assert "X-API-Key" not in client._headers() + assert "X-OpenViking-User" not in client._headers() + + +def test_serverless_session_api_uses_parts_and_telemetry_payload() -> None: + calls = [] + + class RecordingClient(dream.OpenVikingClient): + def _request(self, method, path, payload=None): + calls.append((method, path, payload)) + if path == "/api/v1/sessions": + return {"session_id": "ov-session"} + return {} + + client = RecordingClient( + base_url=dream.SERVERLESS_BASE_URL, + api_key="test-key", + agent_id="test-agent", + ) + + assert client._sync_session_id("source-session") == "ov-session" + client.add_session_message("ov-session", "user", "hello") + client.commit_session("ov-session") + + assert calls == [ + ("POST", "/api/v1/sessions", {}), + ( + "POST", + "/api/v1/sessions/ov-session/messages", + {"role": "user", "parts": [{"type": "text", "text": "hello"}]}, + ), + ("POST", "/api/v1/sessions/ov-session/commit", {"telemetry": False}), + ] + + def test_get_active_session_prefers_sessions_index(tmp_path: Path) -> None: openclaw_root = tmp_path / ".openclaw" sessions_root = openclaw_root / "agents" / "main" / "sessions" From aab884650db2f693657860422a64085988806774 Mon Sep 17 00:00:00 2001 From: heaoxiang Date: Mon, 18 May 2026 16:24:06 +0800 Subject: [PATCH 03/11] feat: ov dream --- examples/skills/ov_dream/scripts/dream.py | 264 ++++++++++++------ .../skills/ov_dream/tests/test_dream_cli.py | 138 +++++++++ 2 files changed, 319 insertions(+), 83 deletions(-) diff --git a/examples/skills/ov_dream/scripts/dream.py b/examples/skills/ov_dream/scripts/dream.py index 14cd80c1d..cbbc3a536 100644 --- a/examples/skills/ov_dream/scripts/dream.py +++ b/examples/skills/ov_dream/scripts/dream.py @@ -29,6 +29,8 @@ class Session: session_id: str cwd: str created_at: str + session_key: str = "" + session_file: str = "" class OpenVikingClient: @@ -172,75 +174,132 @@ def get_session_path(sessions_root: Path, session_id: str) -> Path: return sessions_root / f"{session_id}.jsonl" -def get_active_session(openclaw_root: Path) -> Session | None: - sessions_root = openclaw_root / "agents" / "main" / "sessions" - if not sessions_root.exists(): - return None +def get_session_file_path(sessions_root: Path, session: Session) -> Path: + if not session.session_file: + return get_session_path(sessions_root, session.session_id) + path = Path(session.session_file) + return path if path.is_absolute() else sessions_root / path + + +def is_chat_session_key(key: str) -> bool: + blocked = (":cron:", ":heartbeat", ":subagent:", ":acp:", ":hook:") + if not key.startswith("agent:main:"): + return False + if any(part in key for part in blocked): + return False + return key.endswith(":main") or any( + marker in key + for marker in ( + ":direct:", + ":channel:", + ":group:", + ":room:", + ) + ) - indexed = _get_indexed_active_session(sessions_root) - if indexed is not None: - return indexed - files = [ - path - for path in sessions_root.glob("*.jsonl") - if ".reset." not in path.name and ".checkpoint." not in path.name - ] - if not files: +def _session_from_file(path: Path, session_key: str = "") -> Session | None: + if not path.exists(): return None - - latest = max(files, key=lambda path: path.stat().st_mtime) - lines = latest.read_text(encoding="utf-8").splitlines() + lines = path.read_text(encoding="utf-8").splitlines() if not lines: return None - first = json.loads(lines[0]) + try: + first = json.loads(lines[0]) + except json.JSONDecodeError: + return None + session_id = first.get("id") + if not isinstance(session_id, str) or not session_id: + session_id = path.stem return Session( - session_id=first["id"], + session_id=session_id, cwd=first.get("cwd", ""), created_at=first.get("timestamp", ""), + session_key=session_key, + session_file=str(path), ) -def _get_indexed_active_session(sessions_root: Path) -> Session | None: +def _session_from_index_entry(sessions_root: Path, session_key: str, entry: Any) -> Session | None: + if not isinstance(entry, dict): + return None + + session_id = entry.get("sessionId") + if not isinstance(session_id, str) or not session_id: + return None + + session_file = entry.get("sessionFile") + if isinstance(session_file, str) and session_file: + raw_path = Path(session_file) + path = raw_path if raw_path.is_absolute() else sessions_root / raw_path + else: + path = get_session_path(sessions_root, session_id) + session = _session_from_file(path, session_key=session_key) + if session is None: + return None + if session.session_id != session_id: + session.session_id = session_id + return session + + +def _get_indexed_chat_sessions(sessions_root: Path) -> list[Session]: index_path = sessions_root / "sessions.json" if not index_path.exists(): - return None + return [] try: index = json.loads(index_path.read_text(encoding="utf-8")) except json.JSONDecodeError: - return None + return [] if not isinstance(index, dict): - return None + return [] - active = index.get("agent:main:main") - if not isinstance(active, dict): - return None + sessions: list[Session] = [] + seen: set[str] = set() + for session_key, entry in sorted(index.items()): + if not isinstance(session_key, str) or not is_chat_session_key(session_key): + continue + session = _session_from_index_entry(sessions_root, session_key, entry) + if session is None or session.session_id in seen: + continue + seen.add(session.session_id) + sessions.append(session) + return sessions - session_id = active.get("sessionId") - if not isinstance(session_id, str) or not session_id: - return None - session_file = active.get("sessionFile") - path = Path(session_file) if isinstance(session_file, str) and session_file else get_session_path(sessions_root, session_id) - if not path.exists(): - return None +def get_active_sessions(openclaw_root: Path) -> list[Session]: + sessions_root = openclaw_root / "agents" / "main" / "sessions" + if not sessions_root.exists(): + return [] - lines = path.read_text(encoding="utf-8").splitlines() - if not lines: - return None - try: - first = json.loads(lines[0]) - except json.JSONDecodeError: - return None - return Session( - session_id=session_id, - cwd=first.get("cwd", ""), - created_at=first.get("timestamp", ""), - ) + indexed = _get_indexed_chat_sessions(sessions_root) + if indexed: + return indexed + + files = [ + path + for path in sessions_root.glob("*.jsonl") + if ".reset." not in path.name and ".checkpoint." not in path.name + ] + if not files: + return [] + + latest = max(files, key=lambda path: path.stat().st_mtime) + session = _session_from_file(latest) + return [session] if session is not None else [] + + +def get_active_session(openclaw_root: Path) -> Session | None: + sessions = get_active_sessions(openclaw_root) + return sessions[0] if sessions else None -def parse_messages(sessions_root: Path, session_id: str, after_timestamp: str | None) -> Iterable[Message]: - path = get_session_path(sessions_root, session_id) +def _get_indexed_active_session(sessions_root: Path) -> Session | None: + sessions = _get_indexed_chat_sessions(sessions_root) + return sessions[0] if sessions else None + + +def parse_messages(sessions_root: Path, session: Session, after_timestamp: str | None) -> Iterable[Message]: + path = get_session_file_path(sessions_root, session) if not path.exists(): return [] @@ -271,13 +330,12 @@ def parse_messages(sessions_root: Path, session_id: str, after_timestamp: str | return messages -def sync_active_session(client: OpenVikingClient, openclaw_root: Path, state_root: Path) -> dict[str, Any]: - sessions_root = openclaw_root / "agents" / "main" / "sessions" - session = get_active_session(openclaw_root) - if session is None: - raise RuntimeError("No active OpenClaw session found.") - - state = load_sync_state(state_root) +def sync_session( + client: OpenVikingClient, + sessions_root: Path, + state: dict[str, Any], + session: Session, +) -> dict[str, Any]: sessions = state.setdefault("sessions", {}) session_state = sessions.get(session.session_id) if not isinstance(session_state, dict): @@ -286,7 +344,7 @@ def sync_active_session(client: OpenVikingClient, openclaw_root: Path, state_roo last_synced_timestamp = session_state.get("last_synced_timestamp") messages = [ message - for message in parse_messages(sessions_root, session.session_id, last_synced_timestamp) + for message in parse_messages(sessions_root, session, last_synced_timestamp) if message.timestamp and (last_synced_timestamp is None or message.timestamp > last_synced_timestamp) ] messages.sort(key=lambda message: message.timestamp) @@ -294,39 +352,33 @@ def sync_active_session(client: OpenVikingClient, openclaw_root: Path, state_roo synced_count = 0 committed = False now = _utc_now_iso() - try: - openviking_session_id = None - if messages: - openviking_session_id = client._sync_session_id(session.session_id) - for message in messages: - client.add_session_message(openviking_session_id or session.session_id, message.role, message.content) - synced_count += 1 - - session_state["last_status"] = "ok" - session_state["last_synced_count"] = synced_count - session_state["last_sync_at"] = now - session_state["committed"] = False + openviking_session_id = None + if messages: + openviking_session_id = client._sync_session_id(session.session_id) + for message in messages: + client.add_session_message(openviking_session_id or session.session_id, message.role, message.content) + synced_count += 1 + + session_state["last_status"] = "ok" + session_state["last_synced_count"] = synced_count + session_state["last_sync_at"] = now + session_state["committed"] = False + session_state["session_key"] = session.session_key + session_state["session_file"] = session.session_file + if openviking_session_id is not None: session_state["openviking_session_id"] = openviking_session_id - if synced_count: - client.commit_session(openviking_session_id or session.session_id, wait=True) - committed = True - session_state["committed"] = True - session_state["last_commit_at"] = now - last_synced_timestamp = messages[-1].timestamp - session_state["last_synced_timestamp"] = last_synced_timestamp - except Exception: - session_state["last_status"] = "error" - session_state["last_synced_count"] = synced_count - session_state["last_sync_at"] = now - session_state["committed"] = False - sessions[session.session_id] = session_state - save_sync_state(state_root, state) - raise + if synced_count: + client.commit_session(openviking_session_id or session.session_id, wait=True) + committed = True + session_state["committed"] = True + session_state["last_commit_at"] = now + last_synced_timestamp = messages[-1].timestamp + session_state["last_synced_timestamp"] = last_synced_timestamp sessions[session.session_id] = session_state - save_sync_state(state_root, state) return { + "session_key": session.session_key, "session_id": session.session_id, "openviking_session_id": session_state.get("openviking_session_id"), "synced_count": synced_count, @@ -335,6 +387,36 @@ def sync_active_session(client: OpenVikingClient, openclaw_root: Path, state_roo } +def sync_active_session(client: OpenVikingClient, openclaw_root: Path, state_root: Path) -> dict[str, Any]: + sessions_root = openclaw_root / "agents" / "main" / "sessions" + active_sessions = get_active_sessions(openclaw_root) + if not active_sessions: + raise RuntimeError("No active OpenClaw chat sessions found.") + + state = load_sync_state(state_root) + summaries: list[dict[str, Any]] = [] + try: + for session in active_sessions: + summaries.append(sync_session(client, sessions_root, state, session)) + except Exception: + save_sync_state(state_root, state) + raise + + save_sync_state(state_root, state) + last_timestamps = [ + str(summary.get("last_synced_timestamp", "")) + for summary in summaries + if summary.get("last_synced_timestamp") + ] + return { + "session_count": len(summaries), + "sessions": summaries, + "synced_count": sum(int(summary.get("synced_count", 0) or 0) for summary in summaries), + "committed": any(bool(summary.get("committed")) for summary in summaries), + "last_synced_timestamp": max(last_timestamps) if last_timestamps else None, + } + + def _normalize_ov_command(argv: list[str] | None) -> list[str] | None: if argv is None: return None @@ -384,8 +466,24 @@ def _iter_memories(result: Any) -> Iterable[dict[str, Any]]: def _print_sync_summary(summary: dict[str, Any]) -> None: + child_summaries = summary.get("sessions") + if isinstance(child_summaries, list): + print( + "session_count={session_count} synced_count={synced_count} committed={committed} last_synced_timestamp={last_synced_timestamp}".format( + session_count=summary.get("session_count", len(child_summaries)), + synced_count=summary.get("synced_count", 0), + committed=str(summary.get("committed", False)).lower(), + last_synced_timestamp=summary.get("last_synced_timestamp", ""), + ) + ) + for child in child_summaries: + if isinstance(child, dict): + _print_sync_summary(child) + return + print( - "session_id={session_id} openviking_session_id={openviking_session_id} synced_count={synced_count} committed={committed} last_synced_timestamp={last_synced_timestamp}".format( + "session_key={session_key} session_id={session_id} openviking_session_id={openviking_session_id} synced_count={synced_count} committed={committed} last_synced_timestamp={last_synced_timestamp}".format( + session_key=summary.get("session_key", ""), session_id=summary.get("session_id", ""), openviking_session_id=summary.get("openviking_session_id") or "", synced_count=summary.get("synced_count", 0), diff --git a/examples/skills/ov_dream/tests/test_dream_cli.py b/examples/skills/ov_dream/tests/test_dream_cli.py index 7c9df48e6..ee73965e2 100644 --- a/examples/skills/ov_dream/tests/test_dream_cli.py +++ b/examples/skills/ov_dream/tests/test_dream_cli.py @@ -19,6 +19,24 @@ def _load_dream_module(): dream = _load_dream_module() +def _write_session(path: Path, session_id: str, messages: list[tuple[str, str, str]] | None = None) -> None: + rows = [ + {"id": session_id, "timestamp": "2026-04-20T00:00:00Z", "cwd": "/tmp"}, + ] + for role, text, timestamp in messages or []: + rows.append( + { + "type": "message", + "timestamp": timestamp, + "message": { + "role": role, + "content": [{"type": "text", "text": text}], + }, + } + ) + path.write_text("\n".join(json.dumps(row, ensure_ascii=False) for row in rows) + "\n", encoding="utf-8") + + def test_normalize_raw_ov_recall_phrase() -> None: assert dream._normalize_ov_command(["ov recall 小明的信息"]) == ["recall", "小明的信息"] @@ -109,3 +127,123 @@ def test_get_active_session_prefers_sessions_index(tmp_path: Path) -> None: assert session is not None assert session.session_id == "indexed" + + +def test_is_chat_session_key_filters_non_chat_openclaw_sessions() -> None: + assert dream.is_chat_session_key("agent:main:main") + assert dream.is_chat_session_key("agent:main:telegram:direct:123") + assert dream.is_chat_session_key("agent:main:discord:channel:456") + assert dream.is_chat_session_key("agent:main:chat:group:789") + assert dream.is_chat_session_key("agent:main:chat:room:abc") + + assert not dream.is_chat_session_key("agent:main:cron:daily") + assert not dream.is_chat_session_key("agent:main:heartbeat") + assert not dream.is_chat_session_key("agent:main:subagent:child") + assert not dream.is_chat_session_key("agent:main:acp:tool") + assert not dream.is_chat_session_key("agent:main:hook:event") + assert not dream.is_chat_session_key("agent:other:main") + assert not dream.is_chat_session_key("plain:main") + + +def test_get_active_sessions_filters_index_entries(tmp_path: Path) -> None: + openclaw_root = tmp_path / ".openclaw" + sessions_root = openclaw_root / "agents" / "main" / "sessions" + sessions_root.mkdir(parents=True) + + kept_main = sessions_root / "main.jsonl" + kept_direct = sessions_root / "direct.jsonl" + skipped_cron = sessions_root / "cron.jsonl" + skipped_subagent = sessions_root / "subagent.jsonl" + _write_session(kept_main, "main") + _write_session(kept_direct, "direct") + _write_session(skipped_cron, "cron") + _write_session(skipped_subagent, "subagent") + + (sessions_root / "sessions.json").write_text( + json.dumps( + { + "agent:main:main": {"sessionId": "main", "sessionFile": "main.jsonl"}, + "agent:main:telegram:direct:123": {"sessionId": "direct", "sessionFile": str(kept_direct)}, + "agent:main:cron:daily": {"sessionId": "cron", "sessionFile": str(skipped_cron)}, + "agent:main:subagent:child": {"sessionId": "subagent", "sessionFile": str(skipped_subagent)}, + } + ), + encoding="utf-8", + ) + + sessions = dream.get_active_sessions(openclaw_root) + + assert {session.session_id for session in sessions} == {"main", "direct"} + assert {session.session_key for session in sessions} == { + "agent:main:main", + "agent:main:telegram:direct:123", + } + + +def test_sync_active_session_syncs_chat_sessions_with_independent_cursors(tmp_path: Path) -> None: + openclaw_root = tmp_path / ".openclaw" + sessions_root = openclaw_root / "agents" / "main" / "sessions" + state_root = openclaw_root / "memory" + sessions_root.mkdir(parents=True) + + main_file = sessions_root / "main.jsonl" + direct_file = sessions_root / "direct.jsonl" + cron_file = sessions_root / "cron.jsonl" + _write_session( + main_file, + "main", + [("user", "hello from main", "2026-04-20T00:01:00Z")], + ) + _write_session( + direct_file, + "direct", + [("assistant", "hello from direct", "2026-04-20T00:02:00Z")], + ) + _write_session( + cron_file, + "cron", + [("user", "cron should not sync", "2026-04-20T00:03:00Z")], + ) + (sessions_root / "sessions.json").write_text( + json.dumps( + { + "agent:main:main": {"sessionId": "main", "sessionFile": str(main_file)}, + "agent:main:telegram:direct:123": {"sessionId": "direct", "sessionFile": str(direct_file)}, + "agent:main:cron:daily": {"sessionId": "cron", "sessionFile": str(cron_file)}, + } + ), + encoding="utf-8", + ) + + class RecordingClient: + def __init__(self) -> None: + self.messages = [] + self.commits = [] + + def _sync_session_id(self, source_session_id): + return source_session_id + + def add_session_message(self, session_id, role, content): + self.messages.append((session_id, role, content)) + + def commit_session(self, session_id, wait=True): + self.commits.append((session_id, wait)) + + client = RecordingClient() + + summary = dream.sync_active_session(client, openclaw_root, state_root) + + assert summary["session_count"] == 2 + assert summary["synced_count"] == 2 + assert client.messages == [ + ("main", "user", "hello from main"), + ("direct", "assistant", "hello from direct"), + ] + assert client.commits == [("main", True), ("direct", True)] + + state = json.loads((state_root / "ov_dream_sync.json").read_text(encoding="utf-8")) + assert state["sessions"]["main"]["session_key"] == "agent:main:main" + assert state["sessions"]["main"]["last_synced_timestamp"] == "2026-04-20T00:01:00Z" + assert state["sessions"]["direct"]["session_key"] == "agent:main:telegram:direct:123" + assert state["sessions"]["direct"]["last_synced_timestamp"] == "2026-04-20T00:02:00Z" + assert "cron" not in state["sessions"] From 1cd1da312dd242978338cc47f490bcd93140a890 Mon Sep 17 00:00:00 2001 From: heaoxiang Date: Mon, 18 May 2026 16:43:33 +0800 Subject: [PATCH 04/11] fix: bug --- examples/skills/ov_dream/SKILL.md | 33 +++++++++++++++---- examples/skills/ov_dream/scripts/dream.py | 6 +++- .../skills/ov_dream/tests/test_dream_cli.py | 29 +++++++++++++++- 3 files changed, 60 insertions(+), 8 deletions(-) diff --git a/examples/skills/ov_dream/SKILL.md b/examples/skills/ov_dream/SKILL.md index 2c54c3565..efec01bea 100644 --- a/examples/skills/ov_dream/SKILL.md +++ b/examples/skills/ov_dream/SKILL.md @@ -1,11 +1,11 @@ --- name: ov_dream -description: Use when the user explicitly types `ov dream` or `ov recall ` and the request should be routed to the local OpenViking sync/recall CLI instead of handled as normal chat. +description: Use when the user explicitly types `ov dream` or `ov recall ` and the request should be routed to the OpenViking sync/recall CLI instead of handled as normal chat. --- # OV Dream -Use this skill only for manual OpenViking validation without occupying the OpenClaw `contextEngine` slot. +Use this skill for manual OpenViking sync and recall without occupying the OpenClaw `contextEngine` slot. ## When To Use @@ -19,12 +19,32 @@ Do not treat those messages as normal conversation. They are explicit operator c ## Commands - `ov dream` - Manual sync. Read the active OpenClaw session, upload new `user` and `assistant` messages to OpenViking, then commit when new messages exist. + Manual sync. Read OpenClaw's `sessions.json`, sync eligible chat transcripts to OpenViking, then commit each session when new messages exist. - `ov recall ` - Manual recall. Search OpenViking memories under `viking://user/memories`. + Manual recall. Search OpenViking under the default user root URI, `viking://user/default`. -## Mode 3: Recall +## Sync Behavior + +Trigger when the user message is exactly `ov dream`. + +Execution flow: + +1. Run: + + ```bash + python3 scripts/dream.py dream + ``` + +2. Return the sync summary. + +The sync command reads OpenClaw session metadata from `~/.openclaw/agents/main/sessions/sessions.json` when available. It syncs chat-like session keys such as `agent:main:main`, `:direct:`, `:channel:`, `:group:`, and `:room:`. + +It must not sync explicitly non-chat sessions, including keys containing `:cron:`, `:heartbeat`, `:subagent:`, `:acp:`, or `:hook:`. + +Each source session keeps an independent sync cursor in `~/.openclaw/memory/ov_dream_sync.json`. + +## Recall Behavior Trigger when the user message starts with `ov recall `. @@ -61,4 +81,5 @@ Rules: - This skill is manual-only in the first version. - It does not auto-inject recall into prompts. - It does not replace the OpenViking context-engine plugin. -- For OpenViking serverless, configure `OPENVIKING_BASE_URL`, `OPENVIKING_API_KEY`, and `OPENVIKING_AGENT_ID`; the CLI will use Bearer auth and the serverless session message format automatically. +- Disk-based sync is for recently recorded chat transcripts. It is not a precise "currently running sessions" detector. +- For OpenViking serverless, configure `OPENVIKING_BASE_URL`, `OPENVIKING_API_KEY`, `OPENVIKING_AGENT_ID`, and optionally `OPENVIKING_AUTH_MODE=serverless`. The CLI will use Bearer auth and the serverless session message format automatically. diff --git a/examples/skills/ov_dream/scripts/dream.py b/examples/skills/ov_dream/scripts/dream.py index cbbc3a536..4485f950f 100644 --- a/examples/skills/ov_dream/scripts/dream.py +++ b/examples/skills/ov_dream/scripts/dream.py @@ -13,7 +13,8 @@ DEFAULT_BASE_URL = "http://127.0.0.1:1933" -DEFAULT_TARGET_URI = "viking://user/memories" +DEFAULT_TARGET_URI = "viking://user/default" +LEGACY_TARGET_URI = "viking://user/memories" SERVERLESS_BASE_URL = "https://api.vikingdb.cn-beijing.volces.com/openviking" @@ -80,6 +81,9 @@ def _resolve_target_uri(self, target_uri: str) -> str: if self.auth_mode == "serverless": return target_uri if normalized == DEFAULT_TARGET_URI: + user_space = self._headers().get("X-OpenViking-User", "default") or "default" + return f"viking://user/{user_space}" + if normalized == LEGACY_TARGET_URI: user_space = self._headers().get("X-OpenViking-User", "default") or "default" return f"viking://user/{user_space}/memories/" return target_uri diff --git a/examples/skills/ov_dream/tests/test_dream_cli.py b/examples/skills/ov_dream/tests/test_dream_cli.py index ee73965e2..5f68b0b7d 100644 --- a/examples/skills/ov_dream/tests/test_dream_cli.py +++ b/examples/skills/ov_dream/tests/test_dream_cli.py @@ -41,15 +41,42 @@ def test_normalize_raw_ov_recall_phrase() -> None: assert dream._normalize_ov_command(["ov recall 小明的信息"]) == ["recall", "小明的信息"] -def test_recall_expands_user_memories_alias_to_explicit_user_space(monkeypatch) -> None: +def test_recall_expands_default_user_root_to_explicit_user_space(monkeypatch) -> None: monkeypatch.setenv("OPENVIKING_USER", "default") client = dream.OpenVikingClient(base_url="http://127.0.0.1:1933") + assert client._resolve_target_uri("viking://user/default") == "viking://user/default" + assert client._resolve_target_uri("viking://user/default/") == "viking://user/default" assert client._resolve_target_uri("viking://user/memories") == "viking://user/default/memories/" assert client._resolve_target_uri("viking://user/memories/") == "viking://user/default/memories/" assert client._resolve_target_uri("viking://user/default/memories/") == "viking://user/default/memories/" +def test_recall_default_target_uri_is_user_root() -> None: + calls = [] + + class RecordingClient(dream.OpenVikingClient): + def _request(self, method, path, payload=None): + calls.append((method, path, payload)) + return {"memories": []} + + client = RecordingClient(base_url=dream.SERVERLESS_BASE_URL, api_key="test-key", agent_id="test-agent") + + client.recall("hello") + + assert calls == [ + ( + "POST", + "/api/v1/search/find", + { + "query": "hello", + "limit": 5, + "target_uri": "viking://user/default", + }, + ) + ] + + def test_serverless_headers_use_bearer_and_agent_id() -> None: client = dream.OpenVikingClient( base_url=dream.SERVERLESS_BASE_URL, From be872d76765fce0ad574120053ec0c21afa3206c Mon Sep 17 00:00:00 2001 From: heaoxiang Date: Mon, 18 May 2026 19:56:18 +0800 Subject: [PATCH 05/11] fix --- examples/skills/ov_dream/scripts/dream.py | 49 +++---------------- .../skills/ov_dream/tests/test_dream_cli.py | 43 +++++++++++----- 2 files changed, 38 insertions(+), 54 deletions(-) diff --git a/examples/skills/ov_dream/scripts/dream.py b/examples/skills/ov_dream/scripts/dream.py index 4485f950f..6d64f794d 100644 --- a/examples/skills/ov_dream/scripts/dream.py +++ b/examples/skills/ov_dream/scripts/dream.py @@ -114,18 +114,6 @@ def _request(self, method: str, path: str, payload: dict[str, Any] | None = None raise RuntimeError(message) return body.get("result", body) - def create_session(self) -> str: - result = self._request("POST", "/api/v1/sessions", {}) - session_id = result.get("session_id") - if not isinstance(session_id, str) or not session_id: - raise RuntimeError("Create session response missing session_id.") - return session_id - - def _sync_session_id(self, source_session_id: str) -> str: - if self.auth_mode == "serverless": - return self.create_session() - return source_session_id - def add_session_message(self, session_id: str, role: str, content: str) -> dict[str, Any]: if self.auth_mode == "serverless": payload = { @@ -186,6 +174,7 @@ def get_session_file_path(sessions_root: Path, session: Session) -> Path: def is_chat_session_key(key: str) -> bool: + # OpenClaw session keys include cron/subagent/tool routes; only chat routes should be synced. blocked = (":cron:", ":heartbeat", ":subagent:", ":acp:", ":hook:") if not key.startswith("agent:main:"): return False @@ -275,21 +264,8 @@ def get_active_sessions(openclaw_root: Path) -> list[Session]: if not sessions_root.exists(): return [] - indexed = _get_indexed_chat_sessions(sessions_root) - if indexed: - return indexed - - files = [ - path - for path in sessions_root.glob("*.jsonl") - if ".reset." not in path.name and ".checkpoint." not in path.name - ] - if not files: - return [] - - latest = max(files, key=lambda path: path.stat().st_mtime) - session = _session_from_file(latest) - return [session] if session is not None else [] + # Only trust OpenClaw's session index; raw jsonl fallback can accidentally sync cron/subagent transcripts. + return _get_indexed_chat_sessions(sessions_root) def get_active_session(openclaw_root: Path) -> Session | None: @@ -297,11 +273,6 @@ def get_active_session(openclaw_root: Path) -> Session | None: return sessions[0] if sessions else None -def _get_indexed_active_session(sessions_root: Path) -> Session | None: - sessions = _get_indexed_chat_sessions(sessions_root) - return sessions[0] if sessions else None - - def parse_messages(sessions_root: Path, session: Session, after_timestamp: str | None) -> Iterable[Message]: path = get_session_file_path(sessions_root, session) if not path.exists(): @@ -345,6 +316,7 @@ def sync_session( if not isinstance(session_state, dict): session_state = {} + # Cursor is tracked per source session so cron syncs only upload newly appended messages. last_synced_timestamp = session_state.get("last_synced_timestamp") messages = [ message @@ -356,11 +328,8 @@ def sync_session( synced_count = 0 committed = False now = _utc_now_iso() - openviking_session_id = None - if messages: - openviking_session_id = client._sync_session_id(session.session_id) for message in messages: - client.add_session_message(openviking_session_id or session.session_id, message.role, message.content) + client.add_session_message(session.session_id, message.role, message.content) synced_count += 1 session_state["last_status"] = "ok" @@ -369,11 +338,9 @@ def sync_session( session_state["committed"] = False session_state["session_key"] = session.session_key session_state["session_file"] = session.session_file - if openviking_session_id is not None: - session_state["openviking_session_id"] = openviking_session_id if synced_count: - client.commit_session(openviking_session_id or session.session_id, wait=True) + client.commit_session(session.session_id, wait=True) committed = True session_state["committed"] = True session_state["last_commit_at"] = now @@ -384,7 +351,6 @@ def sync_session( return { "session_key": session.session_key, "session_id": session.session_id, - "openviking_session_id": session_state.get("openviking_session_id"), "synced_count": synced_count, "committed": committed, "last_synced_timestamp": last_synced_timestamp, @@ -486,10 +452,9 @@ def _print_sync_summary(summary: dict[str, Any]) -> None: return print( - "session_key={session_key} session_id={session_id} openviking_session_id={openviking_session_id} synced_count={synced_count} committed={committed} last_synced_timestamp={last_synced_timestamp}".format( + "session_key={session_key} session_id={session_id} synced_count={synced_count} committed={committed} last_synced_timestamp={last_synced_timestamp}".format( session_key=summary.get("session_key", ""), session_id=summary.get("session_id", ""), - openviking_session_id=summary.get("openviking_session_id") or "", synced_count=summary.get("synced_count", 0), committed=str(summary.get("committed", False)).lower(), last_synced_timestamp=summary.get("last_synced_timestamp", ""), diff --git a/examples/skills/ov_dream/tests/test_dream_cli.py b/examples/skills/ov_dream/tests/test_dream_cli.py index 5f68b0b7d..76fe1164c 100644 --- a/examples/skills/ov_dream/tests/test_dream_cli.py +++ b/examples/skills/ov_dream/tests/test_dream_cli.py @@ -91,14 +91,12 @@ def test_serverless_headers_use_bearer_and_agent_id() -> None: assert "X-OpenViking-User" not in client._headers() -def test_serverless_session_api_uses_parts_and_telemetry_payload() -> None: +def test_serverless_sync_reuses_source_session_id_and_uses_parts_payload() -> None: calls = [] class RecordingClient(dream.OpenVikingClient): def _request(self, method, path, payload=None): calls.append((method, path, payload)) - if path == "/api/v1/sessions": - return {"session_id": "ov-session"} return {} client = RecordingClient( @@ -107,18 +105,16 @@ def _request(self, method, path, payload=None): agent_id="test-agent", ) - assert client._sync_session_id("source-session") == "ov-session" - client.add_session_message("ov-session", "user", "hello") - client.commit_session("ov-session") + client.add_session_message("source-session", "user", "hello") + client.commit_session("source-session") assert calls == [ - ("POST", "/api/v1/sessions", {}), ( "POST", - "/api/v1/sessions/ov-session/messages", + "/api/v1/sessions/source-session/messages", {"role": "user", "parts": [{"type": "text", "text": "hello"}]}, ), - ("POST", "/api/v1/sessions/ov-session/commit", {"telemetry": False}), + ("POST", "/api/v1/sessions/source-session/commit", {"telemetry": False}), ] @@ -156,6 +152,32 @@ def test_get_active_session_prefers_sessions_index(tmp_path: Path) -> None: assert session.session_id == "indexed" +def test_get_active_sessions_does_not_fallback_to_raw_jsonl(tmp_path: Path) -> None: + openclaw_root = tmp_path / ".openclaw" + sessions_root = openclaw_root / "agents" / "main" / "sessions" + sessions_root.mkdir(parents=True) + + cron_session = sessions_root / "cron.jsonl" + _write_session(cron_session, "cron") + latest_unindexed = sessions_root / "latest.jsonl" + _write_session(latest_unindexed, "latest") + + (sessions_root / "sessions.json").write_text( + json.dumps( + { + "agent:main:cron:daily": { + "sessionId": "cron", + "sessionFile": str(cron_session), + } + } + ), + encoding="utf-8", + ) + + assert dream.get_active_sessions(openclaw_root) == [] + assert dream.get_active_session(openclaw_root) is None + + def test_is_chat_session_key_filters_non_chat_openclaw_sessions() -> None: assert dream.is_chat_session_key("agent:main:main") assert dream.is_chat_session_key("agent:main:telegram:direct:123") @@ -247,9 +269,6 @@ def __init__(self) -> None: self.messages = [] self.commits = [] - def _sync_session_id(self, source_session_id): - return source_session_id - def add_session_message(self, session_id, role, content): self.messages.append((session_id, role, content)) From 1671161cc3cf28473397108973cd33052a531ba0 Mon Sep 17 00:00:00 2001 From: heaoxiang Date: Tue, 19 May 2026 11:30:27 +0800 Subject: [PATCH 06/11] fix --- examples/skills/ov_dream/scripts/dream.py | 16 ++-------------- examples/skills/ov_dream/tests/test_dream_cli.py | 6 ++++-- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/examples/skills/ov_dream/scripts/dream.py b/examples/skills/ov_dream/scripts/dream.py index 6d64f794d..ede30c98e 100644 --- a/examples/skills/ov_dream/scripts/dream.py +++ b/examples/skills/ov_dream/scripts/dream.py @@ -174,21 +174,9 @@ def get_session_file_path(sessions_root: Path, session: Session) -> Path: def is_chat_session_key(key: str) -> bool: - # OpenClaw session keys include cron/subagent/tool routes; only chat routes should be synced. + # OpenClaw chat session keys can vary by channel, so only filter known non-chat routes. blocked = (":cron:", ":heartbeat", ":subagent:", ":acp:", ":hook:") - if not key.startswith("agent:main:"): - return False - if any(part in key for part in blocked): - return False - return key.endswith(":main") or any( - marker in key - for marker in ( - ":direct:", - ":channel:", - ":group:", - ":room:", - ) - ) + return bool(key) and not any(part in key for part in blocked) def _session_from_file(path: Path, session_key: str = "") -> Session | None: diff --git a/examples/skills/ov_dream/tests/test_dream_cli.py b/examples/skills/ov_dream/tests/test_dream_cli.py index 76fe1164c..e2f145d1a 100644 --- a/examples/skills/ov_dream/tests/test_dream_cli.py +++ b/examples/skills/ov_dream/tests/test_dream_cli.py @@ -180,18 +180,20 @@ def test_get_active_sessions_does_not_fallback_to_raw_jsonl(tmp_path: Path) -> N def test_is_chat_session_key_filters_non_chat_openclaw_sessions() -> None: assert dream.is_chat_session_key("agent:main:main") + assert dream.is_chat_session_key("agent:main:web-abc") assert dream.is_chat_session_key("agent:main:telegram:direct:123") assert dream.is_chat_session_key("agent:main:discord:channel:456") assert dream.is_chat_session_key("agent:main:chat:group:789") assert dream.is_chat_session_key("agent:main:chat:room:abc") + assert dream.is_chat_session_key("agent:other:main") + assert dream.is_chat_session_key("plain:main") assert not dream.is_chat_session_key("agent:main:cron:daily") assert not dream.is_chat_session_key("agent:main:heartbeat") assert not dream.is_chat_session_key("agent:main:subagent:child") assert not dream.is_chat_session_key("agent:main:acp:tool") assert not dream.is_chat_session_key("agent:main:hook:event") - assert not dream.is_chat_session_key("agent:other:main") - assert not dream.is_chat_session_key("plain:main") + assert not dream.is_chat_session_key("") def test_get_active_sessions_filters_index_entries(tmp_path: Path) -> None: From f7a974e82857f6e0ef2e1905a4ac00e25cfab4b5 Mon Sep 17 00:00:00 2001 From: heaoxiang Date: Wed, 20 May 2026 11:43:36 +0800 Subject: [PATCH 07/11] docs: add ov dream openclaw install prompt --- .../ov_dream/OPENCLAW_INSTALL_PROMPT.md | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 examples/skills/ov_dream/OPENCLAW_INSTALL_PROMPT.md diff --git a/examples/skills/ov_dream/OPENCLAW_INSTALL_PROMPT.md b/examples/skills/ov_dream/OPENCLAW_INSTALL_PROMPT.md new file mode 100644 index 000000000..a9ce796a2 --- /dev/null +++ b/examples/skills/ov_dream/OPENCLAW_INSTALL_PROMPT.md @@ -0,0 +1,89 @@ +# OpenClaw ov_dream Install Prompt + +Use this prompt when asking a cloud OpenClaw agent to install or update the `ov_dream` skill. + +```text +Please install or update the ov_dream skill for the current cloud OpenClaw environment, and configure it to sync memories through OpenViking serverless. Do not install the OpenViking contextEngine plugin and do not consume a plugin slot. + +Important: this skill is not in the OpenViking main branch yet. Use the upstream pull request source: + +https://github.com/volcengine/OpenViking/pull/2136 + +Download files from: + +https://raw.githubusercontent.com/volcengine/OpenViking/refs/pull/2136/head + +If I have not provided real values, stop and ask me for: +- OPENVIKING_API_KEY +- OPENVIKING_AGENT_ID + +Do not print the API key in logs or replies. + +Run these steps: + +1. Create the skill directory: + +mkdir -p ~/.openclaw/skills/ov_dream/scripts + +2. Download and overwrite the skill files from the pull request source. Do not download from main: + +curl -fsSL https://raw.githubusercontent.com/volcengine/OpenViking/refs/pull/2136/head/examples/skills/ov_dream/SKILL.md -o ~/.openclaw/skills/ov_dream/SKILL.md +curl -fsSL https://raw.githubusercontent.com/volcengine/OpenViking/refs/pull/2136/head/examples/skills/ov_dream/scripts/dream.py -o ~/.openclaw/skills/ov_dream/scripts/dream.py +touch ~/.openclaw/skills/ov_dream/__init__.py +touch ~/.openclaw/skills/ov_dream/scripts/__init__.py + +If curl returns 404 or fails, stop immediately and tell me the PR source is unavailable. + +3. Verify the downloaded dream.py contains the latest capabilities: + +grep -q 'SERVERLESS_BASE_URL' ~/.openclaw/skills/ov_dream/scripts/dream.py +grep -q 'OPENVIKING_AUTH_MODE' ~/.openclaw/skills/ov_dream/scripts/dream.py +grep -q 'viking://user/default' ~/.openclaw/skills/ov_dream/scripts/dream.py +grep -q 'is_chat_session_key' ~/.openclaw/skills/ov_dream/scripts/dream.py +grep -q 'raw jsonl fallback can accidentally sync cron/subagent transcripts' ~/.openclaw/skills/ov_dream/scripts/dream.py +grep -q 'client.add_session_message(session.session_id' ~/.openclaw/skills/ov_dream/scripts/dream.py + +If any check fails, stop immediately and tell me the PR source is not the latest version. Do not configure cron. + +4. Create or update the environment file. If ~/.openclaw/ov_dream.env already exists and contains real values, do not overwrite them with placeholders. If real values are missing, stop and ask me for OPENVIKING_API_KEY and OPENVIKING_AGENT_ID. + +If the file does not exist, create it with: + +cat > ~/.openclaw/ov_dream.env <<'EOF' +OPENVIKING_BASE_URL=https://api.vikingdb.cn-beijing.volces.com/openviking +OPENVIKING_API_KEY= +OPENVIKING_AGENT_ID= +OPENVIKING_AUTH_MODE=serverless +EOF +chmod 600 ~/.openclaw/ov_dream.env + +5. Verify sync: + +cd ~/.openclaw/skills/ov_dream && set -a && . ~/.openclaw/ov_dream.env && set +a && python3 scripts/dream.py dream + +6. Verify recall: + +cd ~/.openclaw/skills/ov_dream && set -a && . ~/.openclaw/ov_dream.env && set +a && python3 scripts/dream.py recall "最近我在聊什么" + +7. Add or update the OpenClaw cronjob to sync every 5 minutes: + +openclaw cron add ov-dream-sync --schedule "*/5 * * * *" --command 'cd ~/.openclaw/skills/ov_dream && set -a && . ~/.openclaw/ov_dream.env && set +a && python3 scripts/dream.py dream' + +8. After installation, when I type: + +ov recall + +run: + +cd ~/.openclaw/skills/ov_dream && set -a && . ~/.openclaw/ov_dream.env && set +a && python3 scripts/dream.py recall "" + +Notes: +- Do not use local machine paths such as /Users/bytedance/OpenViking. +- Do not install the OpenViking contextEngine plugin. +- Do not consume a plugin slot. +- Do not download from main. +- Do not print the API key in logs or replies. +- ov_dream only reads chat sessions from ~/.openclaw/agents/main/sessions/sessions.json and no longer falls back to scanning latest jsonl files. +- ov_dream filters non-chat sessions containing :cron:, :heartbeat:, :subagent:, :acp:, or :hook:. +- ov_dream reuses the OpenClaw session_id when writing to OpenViking serverless and no longer creates a separate serverless session id. +``` From c3ad6134e732a691e10f2d0437e6e30df06cbd07 Mon Sep 17 00:00:00 2001 From: heaoxiang Date: Wed, 20 May 2026 11:50:42 +0800 Subject: [PATCH 08/11] docs: rename ov dream install guide --- .../{OPENCLAW_INSTALL_PROMPT.md => OV_DREAM_INSTALL.md} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename examples/skills/ov_dream/{OPENCLAW_INSTALL_PROMPT.md => OV_DREAM_INSTALL.md} (96%) diff --git a/examples/skills/ov_dream/OPENCLAW_INSTALL_PROMPT.md b/examples/skills/ov_dream/OV_DREAM_INSTALL.md similarity index 96% rename from examples/skills/ov_dream/OPENCLAW_INSTALL_PROMPT.md rename to examples/skills/ov_dream/OV_DREAM_INSTALL.md index a9ce796a2..f80bf67e4 100644 --- a/examples/skills/ov_dream/OPENCLAW_INSTALL_PROMPT.md +++ b/examples/skills/ov_dream/OV_DREAM_INSTALL.md @@ -1,6 +1,6 @@ -# OpenClaw ov_dream Install Prompt +# OV Dream Install -Use this prompt when asking a cloud OpenClaw agent to install or update the `ov_dream` skill. +Use this install prompt to ask a cloud OpenClaw agent to install or update the `ov_dream` skill. ```text Please install or update the ov_dream skill for the current cloud OpenClaw environment, and configure it to sync memories through OpenViking serverless. Do not install the OpenViking contextEngine plugin and do not consume a plugin slot. From c8fddd71d2370d48085fee5ad5275dbd673a25ed Mon Sep 17 00:00:00 2001 From: heaoxiang Date: Wed, 20 May 2026 11:53:40 +0800 Subject: [PATCH 09/11] docs: rename ov lite install guide --- .../{OV_DREAM_INSTALL.md => OV_LITE_INSTALL.md} | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) rename examples/skills/ov_dream/{OV_DREAM_INSTALL.md => OV_LITE_INSTALL.md} (83%) diff --git a/examples/skills/ov_dream/OV_DREAM_INSTALL.md b/examples/skills/ov_dream/OV_LITE_INSTALL.md similarity index 83% rename from examples/skills/ov_dream/OV_DREAM_INSTALL.md rename to examples/skills/ov_dream/OV_LITE_INSTALL.md index f80bf67e4..bbaf249cb 100644 --- a/examples/skills/ov_dream/OV_DREAM_INSTALL.md +++ b/examples/skills/ov_dream/OV_LITE_INSTALL.md @@ -1,9 +1,9 @@ -# OV Dream Install +# OV Lite Install -Use this install prompt to ask a cloud OpenClaw agent to install or update the `ov_dream` skill. +Use this install prompt to ask a cloud OpenClaw agent to install or update OV Lite through the `ov_dream` skill. ```text -Please install or update the ov_dream skill for the current cloud OpenClaw environment, and configure it to sync memories through OpenViking serverless. Do not install the OpenViking contextEngine plugin and do not consume a plugin slot. +Please install or update OV Lite for the current cloud OpenClaw environment through the ov_dream skill, and configure it to sync memories through OpenViking serverless. Do not install the OpenViking contextEngine plugin and do not consume a plugin slot. Important: this skill is not in the OpenViking main branch yet. Use the upstream pull request source: @@ -83,7 +83,7 @@ Notes: - Do not consume a plugin slot. - Do not download from main. - Do not print the API key in logs or replies. -- ov_dream only reads chat sessions from ~/.openclaw/agents/main/sessions/sessions.json and no longer falls back to scanning latest jsonl files. -- ov_dream filters non-chat sessions containing :cron:, :heartbeat:, :subagent:, :acp:, or :hook:. -- ov_dream reuses the OpenClaw session_id when writing to OpenViking serverless and no longer creates a separate serverless session id. +- OV Lite only reads chat sessions from ~/.openclaw/agents/main/sessions/sessions.json and no longer falls back to scanning latest jsonl files. +- OV Lite filters non-chat sessions containing :cron:, :heartbeat:, :subagent:, :acp:, or :hook:. +- OV Lite reuses the OpenClaw session_id when writing to OpenViking serverless and no longer creates a separate serverless session id. ``` From a591793d2d1cd3521fd516cf62d76af027a6d192 Mon Sep 17 00:00:00 2001 From: heaoxiang Date: Wed, 20 May 2026 11:58:49 +0800 Subject: [PATCH 10/11] docs: clarify ov lite install source --- examples/skills/ov_dream/OV_LITE_INSTALL.md | 29 +++++++++++++-------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/examples/skills/ov_dream/OV_LITE_INSTALL.md b/examples/skills/ov_dream/OV_LITE_INSTALL.md index bbaf249cb..77d7f2ba4 100644 --- a/examples/skills/ov_dream/OV_LITE_INSTALL.md +++ b/examples/skills/ov_dream/OV_LITE_INSTALL.md @@ -5,13 +5,19 @@ Use this install prompt to ask a cloud OpenClaw agent to install or update OV Li ```text Please install or update OV Lite for the current cloud OpenClaw environment through the ov_dream skill, and configure it to sync memories through OpenViking serverless. Do not install the OpenViking contextEngine plugin and do not consume a plugin slot. -Important: this skill is not in the OpenViking main branch yet. Use the upstream pull request source: +Important: install from an explicit OpenViking source ref, not from a local machine path. If this change has not been merged yet, use the pull-request raw source. After merge, use the main-branch raw source. + +Current pre-merge source: https://github.com/volcengine/OpenViking/pull/2136 -Download files from: +Raw source base: + +SOURCE_BASE=https://raw.githubusercontent.com/volcengine/OpenViking/refs/pull/2136/head + +After this PR is merged, replace it with: -https://raw.githubusercontent.com/volcengine/OpenViking/refs/pull/2136/head +SOURCE_BASE=https://raw.githubusercontent.com/volcengine/OpenViking/main If I have not provided real values, stop and ask me for: - OPENVIKING_API_KEY @@ -25,14 +31,15 @@ Run these steps: mkdir -p ~/.openclaw/skills/ov_dream/scripts -2. Download and overwrite the skill files from the pull request source. Do not download from main: +2. Download and overwrite the skill files from SOURCE_BASE: -curl -fsSL https://raw.githubusercontent.com/volcengine/OpenViking/refs/pull/2136/head/examples/skills/ov_dream/SKILL.md -o ~/.openclaw/skills/ov_dream/SKILL.md -curl -fsSL https://raw.githubusercontent.com/volcengine/OpenViking/refs/pull/2136/head/examples/skills/ov_dream/scripts/dream.py -o ~/.openclaw/skills/ov_dream/scripts/dream.py +SOURCE_BASE=https://raw.githubusercontent.com/volcengine/OpenViking/refs/pull/2136/head +curl -fsSL "$SOURCE_BASE/examples/skills/ov_dream/SKILL.md" -o ~/.openclaw/skills/ov_dream/SKILL.md +curl -fsSL "$SOURCE_BASE/examples/skills/ov_dream/scripts/dream.py" -o ~/.openclaw/skills/ov_dream/scripts/dream.py touch ~/.openclaw/skills/ov_dream/__init__.py touch ~/.openclaw/skills/ov_dream/scripts/__init__.py -If curl returns 404 or fails, stop immediately and tell me the PR source is unavailable. +If curl returns 404 or fails, stop immediately and tell me SOURCE_BASE is unavailable or outdated. 3. Verify the downloaded dream.py contains the latest capabilities: @@ -43,9 +50,9 @@ grep -q 'is_chat_session_key' ~/.openclaw/skills/ov_dream/scripts/dream.py grep -q 'raw jsonl fallback can accidentally sync cron/subagent transcripts' ~/.openclaw/skills/ov_dream/scripts/dream.py grep -q 'client.add_session_message(session.session_id' ~/.openclaw/skills/ov_dream/scripts/dream.py -If any check fails, stop immediately and tell me the PR source is not the latest version. Do not configure cron. +If any check fails, stop immediately and tell me SOURCE_BASE is not the latest expected version. Do not configure cron. -4. Create or update the environment file. If ~/.openclaw/ov_dream.env already exists and contains real values, do not overwrite them with placeholders. If real values are missing, stop and ask me for OPENVIKING_API_KEY and OPENVIKING_AGENT_ID. +4. Create or update the environment file. If ~/.openclaw/ov_dream.env already exists and contains real values, keep the existing values and only add missing non-secret defaults. Do not overwrite a real API key or agent id with placeholders. If real values are missing, stop and ask me for OPENVIKING_API_KEY and OPENVIKING_AGENT_ID. If the file does not exist, create it with: @@ -65,7 +72,7 @@ cd ~/.openclaw/skills/ov_dream && set -a && . ~/.openclaw/ov_dream.env && set +a cd ~/.openclaw/skills/ov_dream && set -a && . ~/.openclaw/ov_dream.env && set +a && python3 scripts/dream.py recall "最近我在聊什么" -7. Add or update the OpenClaw cronjob to sync every 5 minutes: +7. Add or update the OpenClaw cronjob to sync every 5 minutes. If a job named ov-dream-sync already exists, update or replace that job instead of creating a duplicate: openclaw cron add ov-dream-sync --schedule "*/5 * * * *" --command 'cd ~/.openclaw/skills/ov_dream && set -a && . ~/.openclaw/ov_dream.env && set +a && python3 scripts/dream.py dream' @@ -81,7 +88,7 @@ Notes: - Do not use local machine paths such as /Users/bytedance/OpenViking. - Do not install the OpenViking contextEngine plugin. - Do not consume a plugin slot. -- Do not download from main. +- Do not download from an implicit default branch. Always use the explicit SOURCE_BASE above. - Do not print the API key in logs or replies. - OV Lite only reads chat sessions from ~/.openclaw/agents/main/sessions/sessions.json and no longer falls back to scanning latest jsonl files. - OV Lite filters non-chat sessions containing :cron:, :heartbeat:, :subagent:, :acp:, or :hook:. From cec5932bcb61d5c2930d87b4523a4666ce175aaf Mon Sep 17 00:00:00 2001 From: heaoxiang Date: Wed, 20 May 2026 12:03:32 +0800 Subject: [PATCH 11/11] docs: make ov lite install guide stable --- examples/skills/ov_dream/OV_LITE_INSTALL.md | 107 ++++++++++---------- 1 file changed, 55 insertions(+), 52 deletions(-) diff --git a/examples/skills/ov_dream/OV_LITE_INSTALL.md b/examples/skills/ov_dream/OV_LITE_INSTALL.md index 77d7f2ba4..f24befde8 100644 --- a/examples/skills/ov_dream/OV_LITE_INSTALL.md +++ b/examples/skills/ov_dream/OV_LITE_INSTALL.md @@ -1,96 +1,99 @@ # OV Lite Install -Use this install prompt to ask a cloud OpenClaw agent to install or update OV Lite through the `ov_dream` skill. +This guide installs OV Lite for OpenClaw through the `ov_dream` skill. It syncs OpenClaw chat sessions to OpenViking serverless without installing the OpenViking `contextEngine` plugin or consuming a plugin slot. -```text -Please install or update OV Lite for the current cloud OpenClaw environment through the ov_dream skill, and configure it to sync memories through OpenViking serverless. Do not install the OpenViking contextEngine plugin and do not consume a plugin slot. +## Prerequisites -Important: install from an explicit OpenViking source ref, not from a local machine path. If this change has not been merged yet, use the pull-request raw source. After merge, use the main-branch raw source. +Set these values before running sync or recall: -Current pre-merge source: +- `OPENVIKING_API_KEY`: OpenViking serverless API key +- `OPENVIKING_AGENT_ID`: OpenViking serverless agent ID -https://github.com/volcengine/OpenViking/pull/2136 +Do not print API keys in logs, shell history snippets, or replies. -Raw source base: +## Install Or Update -SOURCE_BASE=https://raw.githubusercontent.com/volcengine/OpenViking/refs/pull/2136/head - -After this PR is merged, replace it with: +Choose the OpenViking source ref explicitly. Use `main` after this guide has been merged, or replace `SOURCE_BASE` with another trusted raw source when testing an unmerged change. +```bash SOURCE_BASE=https://raw.githubusercontent.com/volcengine/OpenViking/main -If I have not provided real values, stop and ask me for: -- OPENVIKING_API_KEY -- OPENVIKING_AGENT_ID - -Do not print the API key in logs or replies. - -Run these steps: - -1. Create the skill directory: - mkdir -p ~/.openclaw/skills/ov_dream/scripts -2. Download and overwrite the skill files from SOURCE_BASE: +curl -fsSL "$SOURCE_BASE/examples/skills/ov_dream/SKILL.md" \ + -o ~/.openclaw/skills/ov_dream/SKILL.md +curl -fsSL "$SOURCE_BASE/examples/skills/ov_dream/scripts/dream.py" \ + -o ~/.openclaw/skills/ov_dream/scripts/dream.py -SOURCE_BASE=https://raw.githubusercontent.com/volcengine/OpenViking/refs/pull/2136/head -curl -fsSL "$SOURCE_BASE/examples/skills/ov_dream/SKILL.md" -o ~/.openclaw/skills/ov_dream/SKILL.md -curl -fsSL "$SOURCE_BASE/examples/skills/ov_dream/scripts/dream.py" -o ~/.openclaw/skills/ov_dream/scripts/dream.py touch ~/.openclaw/skills/ov_dream/__init__.py touch ~/.openclaw/skills/ov_dream/scripts/__init__.py +``` -If curl returns 404 or fails, stop immediately and tell me SOURCE_BASE is unavailable or outdated. +If any download fails, stop and verify `SOURCE_BASE`. -3. Verify the downloaded dream.py contains the latest capabilities: +## Verify Files +```bash grep -q 'SERVERLESS_BASE_URL' ~/.openclaw/skills/ov_dream/scripts/dream.py grep -q 'OPENVIKING_AUTH_MODE' ~/.openclaw/skills/ov_dream/scripts/dream.py grep -q 'viking://user/default' ~/.openclaw/skills/ov_dream/scripts/dream.py grep -q 'is_chat_session_key' ~/.openclaw/skills/ov_dream/scripts/dream.py grep -q 'raw jsonl fallback can accidentally sync cron/subagent transcripts' ~/.openclaw/skills/ov_dream/scripts/dream.py grep -q 'client.add_session_message(session.session_id' ~/.openclaw/skills/ov_dream/scripts/dream.py +``` -If any check fails, stop immediately and tell me SOURCE_BASE is not the latest expected version. Do not configure cron. +If any check fails, the downloaded `dream.py` is not the expected OV Lite version. -4. Create or update the environment file. If ~/.openclaw/ov_dream.env already exists and contains real values, keep the existing values and only add missing non-secret defaults. Do not overwrite a real API key or agent id with placeholders. If real values are missing, stop and ask me for OPENVIKING_API_KEY and OPENVIKING_AGENT_ID. +## Configure Serverless Auth -If the file does not exist, create it with: +Create `~/.openclaw/ov_dream.env` if it does not exist. If it already exists, keep real `OPENVIKING_API_KEY` and `OPENVIKING_AGENT_ID` values and only add missing non-secret defaults. +```bash cat > ~/.openclaw/ov_dream.env <<'EOF' OPENVIKING_BASE_URL=https://api.vikingdb.cn-beijing.volces.com/openviking -OPENVIKING_API_KEY= -OPENVIKING_AGENT_ID= +OPENVIKING_API_KEY= +OPENVIKING_AGENT_ID= OPENVIKING_AUTH_MODE=serverless EOF chmod 600 ~/.openclaw/ov_dream.env +``` -5. Verify sync: - -cd ~/.openclaw/skills/ov_dream && set -a && . ~/.openclaw/ov_dream.env && set +a && python3 scripts/dream.py dream +## Verify Sync And Recall -6. Verify recall: +```bash +cd ~/.openclaw/skills/ov_dream +set -a +. ~/.openclaw/ov_dream.env +set +a +python3 scripts/dream.py dream +python3 scripts/dream.py recall "最近我在聊什么" +``` -cd ~/.openclaw/skills/ov_dream && set -a && . ~/.openclaw/ov_dream.env && set +a && python3 scripts/dream.py recall "最近我在聊什么" +## Schedule Sync -7. Add or update the OpenClaw cronjob to sync every 5 minutes. If a job named ov-dream-sync already exists, update or replace that job instead of creating a duplicate: +Add or update an OpenClaw cronjob to sync every 5 minutes. If `ov-dream-sync` already exists, update or replace it instead of creating a duplicate. -openclaw cron add ov-dream-sync --schedule "*/5 * * * *" --command 'cd ~/.openclaw/skills/ov_dream && set -a && . ~/.openclaw/ov_dream.env && set +a && python3 scripts/dream.py dream' +```bash +openclaw cron add ov-dream-sync \ + --schedule "*/5 * * * *" \ + --command 'cd ~/.openclaw/skills/ov_dream && set -a && . ~/.openclaw/ov_dream.env && set +a && python3 scripts/dream.py dream' +``` -8. After installation, when I type: +## Recall Command -ov recall +When the user asks for `ov recall `, run: -run: +```bash +cd ~/.openclaw/skills/ov_dream +set -a +. ~/.openclaw/ov_dream.env +set +a +python3 scripts/dream.py recall "" +``` -cd ~/.openclaw/skills/ov_dream && set -a && . ~/.openclaw/ov_dream.env && set +a && python3 scripts/dream.py recall "" +## Behavior Notes -Notes: -- Do not use local machine paths such as /Users/bytedance/OpenViking. -- Do not install the OpenViking contextEngine plugin. -- Do not consume a plugin slot. -- Do not download from an implicit default branch. Always use the explicit SOURCE_BASE above. -- Do not print the API key in logs or replies. -- OV Lite only reads chat sessions from ~/.openclaw/agents/main/sessions/sessions.json and no longer falls back to scanning latest jsonl files. -- OV Lite filters non-chat sessions containing :cron:, :heartbeat:, :subagent:, :acp:, or :hook:. -- OV Lite reuses the OpenClaw session_id when writing to OpenViking serverless and no longer creates a separate serverless session id. -``` +- OV Lite reads chat sessions from `~/.openclaw/agents/main/sessions/sessions.json`. +- OV Lite does not fall back to scanning latest raw jsonl files. +- OV Lite filters non-chat sessions containing `:cron:`, `:heartbeat:`, `:subagent:`, `:acp:`, or `:hook:`. +- OV Lite reuses the OpenClaw `session_id` when writing to OpenViking serverless.