diff --git a/docs/LOCAL_PUBLIC_TOOLS.md b/docs/LOCAL_PUBLIC_TOOLS.md index 666d7d98..c6c46e9a 100644 --- a/docs/LOCAL_PUBLIC_TOOLS.md +++ b/docs/LOCAL_PUBLIC_TOOLS.md @@ -43,5 +43,5 @@ This project exposes local atomic capabilities as shared `public` tools for all ## Path behavior defaults -- `run_command`: when `cwd` is omitted, it runs in `data/workspace`. +- `run_command`: when `cwd` is omitted, it runs in workspace root. - `write_file`: absolute path is used directly; relative path is written under `data/workspace`. diff --git a/platform/files/file_attachments.py b/platform/files/file_attachments.py index a9a8daeb..c7ce2031 100644 --- a/platform/files/file_attachments.py +++ b/platform/files/file_attachments.py @@ -54,7 +54,7 @@ def _sniff_image_mime(data: bytes) -> str | None: def _ffprobe_exists() -> bool: try: - p = subprocess.run(["ffprobe", "-version"], capture_output=True, text=True, timeout=3) + p = subprocess.run(["ffprobe", "-version"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=3) return p.returncode == 0 except Exception: return False @@ -115,6 +115,8 @@ def _ffprobe_video_meta(path: Path) -> dict[str, Any]: ["ffprobe", "-v", "error", "-print_format", "json", "-show_format", "-show_streams", str(path)], capture_output=True, text=True, + encoding="utf-8", + errors="replace", timeout=8, ) if p.returncode != 0: diff --git a/platform/integrations/cc_mini_vendor.py b/platform/integrations/cc_mini_vendor.py index 8da8ca8b..a7dd2d7b 100644 --- a/platform/integrations/cc_mini_vendor.py +++ b/platform/integrations/cc_mini_vendor.py @@ -81,5 +81,7 @@ def run_cc_mini_cli( env=merged, check=False, text=True, + encoding="utf-8", + errors="replace", **kwargs, ) diff --git a/runtime/gateway.py b/runtime/gateway.py index 2a64e932..0712d6b4 100644 --- a/runtime/gateway.py +++ b/runtime/gateway.py @@ -42,7 +42,7 @@ from oclaw.runtime.session_auto_title import ( finalize_auto_title, ) from oclaw.runtime.tools.base import ToolRegistry -from oclaw.runtime.tools.local_sdk import local_adapter_startup_self_check +from oclaw.runtime.tools.public.local_sdk import local_adapter_startup_self_check logger = logging.getLogger(__name__) diff --git a/runtime/skill_installer.py b/runtime/skill_installer.py index 3875d9b5..820a4fe6 100644 --- a/runtime/skill_installer.py +++ b/runtime/skill_installer.py @@ -154,6 +154,8 @@ def _run_dep_install(command: list[str], *, cwd: Path) -> tuple[bool, str]: cwd=str(cwd), capture_output=True, text=True, + encoding="utf-8", + errors="replace", timeout=600, check=False, ) diff --git a/runtime/system_prompt.py b/runtime/system_prompt.py index 19eacbb8..9f6c4a44 100644 --- a/runtime/system_prompt.py +++ b/runtime/system_prompt.py @@ -42,12 +42,13 @@ def _session_bootstrap_content_signature(*, skill_binding_role: str | None, work targets: list[Path] = [] # session-bootstrap static source files - targets.append((repo / "runtime" / "skills" / "session-bootstrap" / "SOUL.md").resolve()) - targets.append((repo / "runtime" / "skills" / "session-bootstrap" / "IDENTITY.md").resolve()) + targets.append((repo / "skills" / "session-bootstrap" / "SOUL.md").resolve()) + targets.append((repo / "skills" / "session-bootstrap" / "IDENTITY.md").resolve()) # wiki global memory/improvement sources targets.append((repo / "data" / "wiki" / "improvement" / "learnings.md").resolve()) targets.append((repo / "data" / "wiki" / "improvement" / "errors.md").resolve()) targets.append((repo / "data" / "wiki" / "improvement" / "feature-requests.md").resolve()) + targets.append((repo / "data" / "wiki" / "users" / "current.md").resolve()) core_dir = (repo / "data" / "wiki" / "core").resolve() if core_dir.exists() and core_dir.is_dir(): @@ -99,6 +100,7 @@ def _unified_skill_policy_guidance() -> str: # Global policy for all agents (including dynamic/ephemeral) — appended to base_system in build_executor_system_prompt. return ( "## 技能使用政策(Skill Usage Policy):\n" + "- 每次会话启动后,在进行文件/路径相关操作前,必须先读取 `skills/_workspace/public/path-convention/SKILL.md` 了解当前路径规范。\n" "- 如果用户问你有哪些技能(skill/技能),请直接根据已注入的技能目录及其 description 回答。\n" "- 不要为了“列出技能”而去读取 SKILL.md。只有在你确实需要某个技能的详细使用说明时,才读取对应 SKILL.md。\n" "- 当你需要技能细节时,请按目录中给出的 path 读取对应的 SKILL.md。\n" diff --git a/runtime/tools/experts/memory/__init__.py b/runtime/tools/experts/memory/__init__.py deleted file mode 100644 index 6f395a3b..00000000 --- a/runtime/tools/experts/memory/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -from __future__ import annotations - -# Canonical memory expert tools live under this directory. -from .wiki_tools import ( - memory_wiki_apply_tool, - memory_wiki_get_tool, - memory_wiki_lint_tool, - memory_wiki_search_tool, - memory_wiki_status_tool, -) - -__all__ = [ - "memory_wiki_apply_tool", - "memory_wiki_get_tool", - "memory_wiki_lint_tool", - "memory_wiki_search_tool", - "memory_wiki_status_tool", -] - diff --git a/runtime/tools/local_sdk/__init__.py b/runtime/tools/local_sdk/__init__.py deleted file mode 100644 index 0431cb54..00000000 --- a/runtime/tools/local_sdk/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from __future__ import annotations - -from .adapter import LocalAdapter, LocalAdapterError, get_local_adapter, local_adapter_startup_self_check - -__all__ = ["LocalAdapter", "LocalAdapterError", "get_local_adapter", "local_adapter_startup_self_check"] diff --git a/runtime/tools/public/cd_tool.py b/runtime/tools/public/cd_tool.py deleted file mode 100644 index a5c88660..00000000 --- a/runtime/tools/public/cd_tool.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from oclaw.runtime.tools.base import ToolSpec -from oclaw.runtime.tools.local_sdk import get_local_adapter - - -def cd_tool() -> ToolSpec: - def _handler(args: dict[str, Any]) -> dict[str, Any]: - cwd = str(args.get("cwd") or "").strip() - return get_local_adapter().cd(cwd=cwd) - - return ToolSpec( - name="cd", - description="Change current working directory for local tools (best-effort, per-process).", - parameters={ - "type": "object", - "properties": {"cwd": {"type": "string", "description": "Directory to change into."}}, - "required": ["cwd"], - "additionalProperties": False, - }, - handler=_handler, - tags=frozenset({"public", "local", "write"}), - risk_level="high", - timeout_s=5.0, - ) - - -__all__ = ["cd_tool"] diff --git a/runtime/tools/public/delete_file_tool.py b/runtime/tools/public/delete_file_tool.py index a6354264..a48c61e1 100644 --- a/runtime/tools/public/delete_file_tool.py +++ b/runtime/tools/public/delete_file_tool.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any from oclaw.runtime.tools.base import ToolSpec -from oclaw.runtime.tools.local_sdk import get_local_adapter +from oclaw.runtime.tools.public.local_sdk import get_local_adapter def delete_file_tool() -> ToolSpec: diff --git a/runtime/tools/public/edit_file_tool.py b/runtime/tools/public/edit_file_tool.py index 3f8d3a7a..9d5872f0 100644 --- a/runtime/tools/public/edit_file_tool.py +++ b/runtime/tools/public/edit_file_tool.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any from oclaw.runtime.tools.base import ToolSpec -from oclaw.runtime.tools.local_sdk import get_local_adapter +from oclaw.runtime.tools.public.local_sdk import get_local_adapter def edit_file_tool() -> ToolSpec: diff --git a/runtime/tools/public/get_cwd_tool.py b/runtime/tools/public/get_cwd_tool.py index 33dce1e6..dbdbae5a 100644 --- a/runtime/tools/public/get_cwd_tool.py +++ b/runtime/tools/public/get_cwd_tool.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any from oclaw.runtime.tools.base import ToolSpec -from oclaw.runtime.tools.local_sdk import get_local_adapter +from oclaw.runtime.tools.public.local_sdk import get_local_adapter def get_cwd_tool() -> ToolSpec: diff --git a/runtime/tools/public/get_env_tool.py b/runtime/tools/public/get_env_tool.py index 044e57b9..0c3b51b3 100644 --- a/runtime/tools/public/get_env_tool.py +++ b/runtime/tools/public/get_env_tool.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any from oclaw.runtime.tools.base import ToolSpec -from oclaw.runtime.tools.local_sdk import get_local_adapter +from oclaw.runtime.tools.public.local_sdk import get_local_adapter def get_env_tool() -> ToolSpec: diff --git a/runtime/tools/public/git_tools.py b/runtime/tools/public/git_tools.py index 83b2e7c7..ebb8f7d1 100644 --- a/runtime/tools/public/git_tools.py +++ b/runtime/tools/public/git_tools.py @@ -15,6 +15,8 @@ def _git(command: str, *, cwd: str) -> dict[str, Any]: shell=True, capture_output=True, text=True, + encoding="utf-8", + errors="replace", timeout=60.0, ) out = (cp.stdout or "") + (("\n" + cp.stderr) if cp.stderr else "") diff --git a/runtime/tools/public/kill_process_tool.py b/runtime/tools/public/kill_process_tool.py index 4f94e0e3..46a36beb 100644 --- a/runtime/tools/public/kill_process_tool.py +++ b/runtime/tools/public/kill_process_tool.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any from oclaw.runtime.tools.base import ToolSpec -from oclaw.runtime.tools.local_sdk import get_local_adapter +from oclaw.runtime.tools.public.local_sdk import get_local_adapter def kill_process_tool() -> ToolSpec: diff --git a/runtime/tools/public/list_directory_tool.py b/runtime/tools/public/list_directory_tool.py index 5a9ec6bf..6e90b182 100644 --- a/runtime/tools/public/list_directory_tool.py +++ b/runtime/tools/public/list_directory_tool.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any from oclaw.runtime.tools.base import ToolSpec -from oclaw.runtime.tools.local_sdk import get_local_adapter +from oclaw.runtime.tools.public.local_sdk import get_local_adapter def list_directory_tool() -> ToolSpec: diff --git a/runtime/tools/public/list_processes_tool.py b/runtime/tools/public/list_processes_tool.py index ca5db1b8..460b5d9d 100644 --- a/runtime/tools/public/list_processes_tool.py +++ b/runtime/tools/public/list_processes_tool.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any from oclaw.runtime.tools.base import ToolSpec -from oclaw.runtime.tools.local_sdk import get_local_adapter +from oclaw.runtime.tools.public.local_sdk import get_local_adapter def list_processes_tool() -> ToolSpec: diff --git a/runtime/tools/local_sdk/adapter.py b/runtime/tools/public/local_sdk.py similarity index 97% rename from runtime/tools/local_sdk/adapter.py rename to runtime/tools/public/local_sdk.py index 09c2c3ee..19bce43f 100644 --- a/runtime/tools/local_sdk/adapter.py +++ b/runtime/tools/public/local_sdk.py @@ -30,8 +30,8 @@ class LocalAdapter: def get_cwd(self) -> dict[str, Any]: try: - p = resolve_workspace_path(self._cwd or ".") - return {"ok": True, "cwd": str(p)} + root = resolve_workspace_path(".") + return {"ok": True, "cwd": str(root)} except Exception as exc: return {"ok": False, "error_code": "local_execution_error", "error": f"{type(exc).__name__}: {exc}"} @@ -105,8 +105,8 @@ class LocalAdapter: try: timeout_s = max(1, min(int(timeout or 300), 600)) # run_command never follows adapter cd state. - # It only uses explicit cwd; otherwise defaults to data/workspace. - workdir = str(resolve_workspace_path(cwd or "data/workspace")) + # It only uses explicit cwd; otherwise defaults to workspace root. + workdir = str(resolve_workspace_path(cwd or ".")) Path(workdir).mkdir(parents=True, exist_ok=True) # Use UTF-8 for decoded streams: Windows defaults (e.g. GBK) break on UTF-8-only bytes from # curl/wttr.in, git, ripgrep, etc.; subprocess may leave stdout None after UnicodeDecodeError. @@ -370,6 +370,8 @@ class LocalAdapter: shell=True, capture_output=True, text=True, + encoding="utf-8", + errors="replace", timeout=10.0, ) out = str(cp.stdout or "") @@ -393,6 +395,8 @@ class LocalAdapter: shell=True, capture_output=True, text=True, + encoding="utf-8", + errors="replace", timeout=10.0, ) for line in str(cp.stdout or "").splitlines(): @@ -418,7 +422,7 @@ class LocalAdapter: try: if os.name == "nt": cmd = f"taskkill /PID {p} " + ("/F" if force else "") - cp = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=10.0) + cp = subprocess.run(cmd, shell=True, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10.0) ok = cp.returncode == 0 return {"ok": ok, "pid": p, "stdout": str(cp.stdout or ""), "stderr": str(cp.stderr or ""), "exit_code": int(cp.returncode)} import signal @@ -462,5 +466,3 @@ def local_adapter_startup_self_check() -> dict[str, Any]: "error": f"{type(exc).__name__}: {exc}", } - -__all__ = ["LocalAdapter", "LocalAdapterError", "get_local_adapter", "local_adapter_startup_self_check"] diff --git a/runtime/tools/experts/memory/wiki_tools.py b/runtime/tools/public/memory_wiki_tools.py similarity index 92% rename from runtime/tools/experts/memory/wiki_tools.py rename to runtime/tools/public/memory_wiki_tools.py index a32603a8..e77510ee 100644 --- a/runtime/tools/experts/memory/wiki_tools.py +++ b/runtime/tools/public/memory_wiki_tools.py @@ -31,8 +31,6 @@ def _wiki_handlers() -> dict[str, Callable[[dict[str, Any]], dict[str, Any]]]: if spec is None or spec.loader is None: return {} mod = importlib.util.module_from_spec(spec) - # Register module before exec so decorators (e.g., @dataclass) can resolve - # cls.__module__ via sys.modules during import-time processing. sys.modules[spec.name] = mod spec.loader.exec_module(mod) # type: ignore[assignment] fn = getattr(mod, "build_wiki_tool_specs", None) @@ -67,7 +65,8 @@ def _status_tool(public_name: str, desc: str) -> ToolSpec: description=desc, parameters={"type": "object", "properties": {}, "required": [], "additionalProperties": False}, handler=lambda args: _delegate("wiki_status", args), - tags=frozenset({"memory", "wiki", "curator"}), + tags=frozenset({"wiki", "curator"}), + risk_level="low", read_only=True, ) @@ -87,7 +86,8 @@ def _get_tool(public_name: str, desc: str) -> ToolSpec: "additionalProperties": False, }, handler=lambda args: _delegate("wiki_get", args), - tags=frozenset({"memory", "wiki", "curator"}), + tags=frozenset({"wiki", "curator"}), + risk_level="low", read_only=True, ) @@ -108,7 +108,8 @@ def _search_tool(public_name: str, desc: str) -> ToolSpec: "additionalProperties": False, }, handler=lambda args: _delegate("wiki_search", args), - tags=frozenset({"memory", "wiki", "curator"}), + tags=frozenset({"wiki", "curator"}), + risk_level="low", read_only=True, ) @@ -124,7 +125,8 @@ def _lint_tool(public_name: str, desc: str) -> ToolSpec: "additionalProperties": False, }, handler=lambda args: _delegate("wiki_lint", args), - tags=frozenset({"memory", "wiki", "curator"}), + tags=frozenset({"wiki", "curator"}), + risk_level="low", read_only=True, ) @@ -144,8 +146,8 @@ def _apply_tool(public_name: str, desc: str) -> ToolSpec: "additionalProperties": False, }, handler=lambda args: _delegate("wiki_apply", args), - tags=frozenset({"memory", "wiki", "curator", "write"}), - risk_level="high", + tags=frozenset({"wiki", "curator", "write"}), + risk_level="low", read_only=False, ) diff --git a/runtime/tools/public/mkdir_tool.py b/runtime/tools/public/mkdir_tool.py index 2fb51b03..261060a2 100644 --- a/runtime/tools/public/mkdir_tool.py +++ b/runtime/tools/public/mkdir_tool.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any from oclaw.runtime.tools.base import ToolSpec -from oclaw.runtime.tools.local_sdk import get_local_adapter +from oclaw.runtime.tools.public.local_sdk import get_local_adapter def mkdir_tool() -> ToolSpec: diff --git a/runtime/tools/public/move_file_tool.py b/runtime/tools/public/move_file_tool.py index e1b0a0c3..d686412d 100644 --- a/runtime/tools/public/move_file_tool.py +++ b/runtime/tools/public/move_file_tool.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any from oclaw.runtime.tools.base import ToolSpec -from oclaw.runtime.tools.local_sdk import get_local_adapter +from oclaw.runtime.tools.public.local_sdk import get_local_adapter def move_file_tool() -> ToolSpec: diff --git a/runtime/tools/public/query_video_attachment_tool.py b/runtime/tools/public/query_video_attachment_tool.py index 7cbf5546..07fb0b6e 100644 --- a/runtime/tools/public/query_video_attachment_tool.py +++ b/runtime/tools/public/query_video_attachment_tool.py @@ -20,7 +20,7 @@ from oclaw.runtime.tools.base import ToolSpec def _ffmpeg_exists() -> bool: try: - p = subprocess.run(["ffmpeg", "-version"], capture_output=True, text=True, timeout=3) + p = subprocess.run(["ffmpeg", "-version"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=3) return p.returncode == 0 except Exception: return False @@ -41,6 +41,8 @@ def _ffprobe_json(path: Path) -> dict[str, Any] | None: ], capture_output=True, text=True, + encoding="utf-8", + errors="replace", timeout=8, ) if p.returncode != 0: @@ -179,6 +181,8 @@ def query_video_attachment_tool() -> ToolSpec: ["ffmpeg", "-y", "-i", str(p), "-vn", "-ac", "1", "-ar", "16000", str(wav)], capture_output=True, text=True, + encoding="utf-8", + errors="replace", timeout=60, ) if not wav.exists() or wav.stat().st_size <= 0: diff --git a/runtime/tools/public/run_command_tool.py b/runtime/tools/public/run_command_tool.py index add179ed..f4111f8e 100644 --- a/runtime/tools/public/run_command_tool.py +++ b/runtime/tools/public/run_command_tool.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any from oclaw.runtime.tools.base import ToolSpec -from oclaw.runtime.tools.local_sdk import get_local_adapter +from oclaw.runtime.tools.public.local_sdk import get_local_adapter def run_command_tool() -> ToolSpec: diff --git a/runtime/tools/public/search_files_tool.py b/runtime/tools/public/search_files_tool.py index 9f05bc8f..c6662f4b 100644 --- a/runtime/tools/public/search_files_tool.py +++ b/runtime/tools/public/search_files_tool.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any from oclaw.runtime.tools.base import ToolSpec -from oclaw.runtime.tools.local_sdk import get_local_adapter +from oclaw.runtime.tools.public.local_sdk import get_local_adapter def search_files_tool() -> ToolSpec: diff --git a/runtime/tools/public/set_env_tool.py b/runtime/tools/public/set_env_tool.py index 79188096..35a1bf8d 100644 --- a/runtime/tools/public/set_env_tool.py +++ b/runtime/tools/public/set_env_tool.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any from oclaw.runtime.tools.base import ToolSpec -from oclaw.runtime.tools.local_sdk import get_local_adapter +from oclaw.runtime.tools.public.local_sdk import get_local_adapter def set_env_tool() -> ToolSpec: diff --git a/runtime/tools/skills_runtime/subprocess_exec.py b/runtime/tools/skills_runtime/subprocess_exec.py index a0246709..8abc345a 100644 --- a/runtime/tools/skills_runtime/subprocess_exec.py +++ b/runtime/tools/skills_runtime/subprocess_exec.py @@ -156,6 +156,8 @@ def _run(argv: list[str], *, cwd: Path, stdin_json: dict[str, Any], timeout_s: f argv, input=json.dumps(stdin_json, ensure_ascii=False), text=True, + encoding="utf-8", + errors="replace", capture_output=True, cwd=str(cwd), timeout=max(1, int(timeout_s)), diff --git a/skills/_workspace/public/path-convention/SKILL.md b/skills/_workspace/public/path-convention/SKILL.md new file mode 100644 index 00000000..61453c90 --- /dev/null +++ b/skills/_workspace/public/path-convention/SKILL.md @@ -0,0 +1,31 @@ +--- +name: path-convention +description: "Oclaw 项目路径规范。定义了项目根目录、用户数据区、技能目录、Wiki 记忆四者之间的路径关系。适用于:新建文件、读取文件、运行脚本、创建目录等所有涉及文件路径的操作之前。" +--- + +# 路径规范 —— Path Convention + +## 根目录 + +项目根 = 当前仓库根目录(workspace root) + +所有 `read_file`、`write_file`、`delete_file`、`move_file`、`mkdir` 等工具的 `path` 参数支持两种写法: +- 相对路径:相对于**项目根目录**(workspace root)解析,例如 `data/workspace/a.txt` +- 绝对路径:按系统绝对路径解析(Windows 如 `D:\...`,Linux/macOS 如 `/...`),但仍受工作区路径访问策略约束 + +## 核心目录 + +| 用途 | 路径 | 说明 | +|------|------|------| +| **用户数据** | `data/workspace/` | 所有用户生成的文件放这里:脚本、文档、工作流、图片生成产物、测试文件等 | +| **技能文件** | `skills/` | 系统技能和用户安装的技能 | +| **Wiki 记忆** | `data/wiki/` | 长期记忆、Learnings、用户偏好、项目背景 | +| **临时文件** | `data/workspace/tmp/` | 临时中间文件 | + +## 注意事项 + +- `get_cwd()` 返回项目根目录(workspace root) +- `run_command` 未传 `cwd` 时默认在项目根目录执行;需要在子目录执行时请显式传 `cwd` +- 用户数据只放在 `data/workspace/` 下,不要直接散落在根目录 +- `data/wiki/` 仅存放结构化记忆,不要放脚本或工作流文件 +- 技能安装路径见 `skill_auto_install` 工具,勿手动写入 `skills/` 以外的技能目录 diff --git a/skills/self-improvement/hooks/runtime/handler.js b/skills/self-improvement/hooks/runtime/handler.js deleted file mode 100644 index 65f5a121..00000000 --- a/skills/self-improvement/hooks/runtime/handler.js +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Oclaw 自我改进 Hook - * - * 在 agent:bootstrap 阶段注入学习沉淀提醒。 - */ - -const REMINDER_NAME = 'SELF_IMPROVEMENT_REMINDER.md'; -const REMINDER_PATH = REMINDER_NAME; - -const REMINDER_CONTENT = ` -## 自我改进提醒 - -任务完成后,请评估是否产生可沉淀学习。 - -仅在当前仓库/工作区启用 self-improvement 技能时记录。 - -记录前: -- 使用 memory_wiki_* 工具写入 \`improvement/\` 下的 Wiki 笔记 -- 不记录密钥、令牌、私钥、环境变量或原始对话全文 -- 优先使用简短摘要或脱敏片段,避免完整命令输出 - -**以下情况应记录:** -- 用户纠正你 → \`improvement/learnings.md\` -- 命令/操作失败 → \`improvement/errors.md\` -- 用户提出缺失能力 → \`improvement/feature-requests.md\` -- 发现认知错误 → \`improvement/learnings.md\` -- 发现更优做法 → \`improvement/learnings.md\` - -**当模式被验证后进行提升:** -- 行为模式 → \`SOUL.md\` -- 工作流改进 → \`AGENTS.md\` -- 工具易错点 → \`TOOLS.md\` - -条目保持简洁:时间、标题、发生了什么、后续应如何做。 -`.trim(); - -function isObject(value) { - return !!value && typeof value === 'object'; -} - -function isInjectedReminderFile(value) { - if (!isObject(value) || value.path !== REMINDER_PATH) { - return false; - } - - return ( - value.virtual === true || - value.content === REMINDER_CONTENT - ); -} - -const handler = async (event) => { - // 事件结构安全检查 - if (!event || typeof event !== 'object') { - return; - } - - // 仅处理 agent:bootstrap 事件 - if (event.type !== 'agent' || event.action !== 'bootstrap') { - return; - } - - // context 安全检查 - if (!event.context || typeof event.context !== 'object') { - return; - } - - // 跳过子代理会话,避免引导污染 - const sessionKey = event.sessionKey || ''; - if (sessionKey.includes(':subagent:')) { - return; - } - - // 以虚拟 bootstrap 文件注入提醒 - if (Array.isArray(event.context.bootstrapFiles)) { - const occupiedByOtherFile = event.context.bootstrapFiles.some( - (file) => isObject(file) && file.path === REMINDER_PATH && !isInjectedReminderFile(file), - ); - if (occupiedByOtherFile) { - return; - } - - const cleanedBootstrapFiles = event.context.bootstrapFiles.filter( - (file, index, files) => - !isInjectedReminderFile(file) || - files.findIndex((candidate) => isInjectedReminderFile(candidate)) === index, - ); - - const reminderFile = { - name: REMINDER_NAME, - path: REMINDER_PATH, - content: REMINDER_CONTENT, - missing: false, - virtual: true, - }; - - const existingIndex = cleanedBootstrapFiles.findIndex((file) => isInjectedReminderFile(file)); - if (existingIndex === -1) { - cleanedBootstrapFiles.push(reminderFile); - } else { - cleanedBootstrapFiles[existingIndex] = reminderFile; - } - - event.context.bootstrapFiles = cleanedBootstrapFiles; - } -}; - -module.exports = handler; -module.exports.default = handler; diff --git a/skills/self-improvement/hooks/runtime/handler.ts b/skills/self-improvement/hooks/runtime/handler.ts deleted file mode 100644 index 42f3ab94..00000000 --- a/skills/self-improvement/hooks/runtime/handler.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Oclaw 自我改进 Hook - * - * 在 agent:bootstrap 阶段注入学习沉淀提醒。 - */ - -import type { HookHandler } from 'oclaw/hooks'; - -const REMINDER_NAME = 'SELF_IMPROVEMENT_REMINDER.md'; -const REMINDER_PATH = REMINDER_NAME; - -const REMINDER_CONTENT = `## 自我改进提醒 - -任务完成后,请评估是否产生可沉淀学习。 - -仅在当前仓库/工作区启用 self-improvement 技能时记录。 - -记录前: -- 使用 memory_wiki_* 工具写入 \`improvement/\` 下的 Wiki 笔记 -- 不记录密钥、令牌、私钥、环境变量或原始对话全文 -- 优先使用简短摘要或脱敏片段,避免完整命令输出 - -**以下情况应记录:** -- 用户纠正你 → \`improvement/learnings.md\` -- 命令/操作失败 → \`improvement/errors.md\` -- 用户提出缺失能力 → \`improvement/feature-requests.md\` -- 发现认知错误 → \`improvement/learnings.md\` -- 发现更优做法 → \`improvement/learnings.md\` - -**当模式被验证后进行提升:** -- 行为模式 → \`SOUL.md\` -- 工作流改进 → \`AGENTS.md\` -- 工具易错点 → \`TOOLS.md\` - -条目保持简洁:时间、标题、发生了什么、后续应如何做。`; - -function isObject(value: unknown): value is Record { - return !!value && typeof value === 'object'; -} - -function isInjectedReminderFile(value: unknown): boolean { - if (!isObject(value) || value.path !== REMINDER_PATH) { - return false; - } - - return ( - value.virtual === true || - value.content === REMINDER_CONTENT - ); -} - -const handler: HookHandler = async (event) => { - // 事件结构安全检查 - if (!event || typeof event !== 'object') { - return; - } - - // 仅处理 agent:bootstrap 事件 - if (event.type !== 'agent' || event.action !== 'bootstrap') { - return; - } - - // context 安全检查 - if (!event.context || typeof event.context !== 'object') { - return; - } - - // 跳过子代理会话,避免引导污染 - const sessionKey = event.sessionKey || ''; - if (sessionKey.includes(':subagent:')) { - return; - } - - // 以虚拟 bootstrap 文件注入提醒 - if (Array.isArray(event.context.bootstrapFiles)) { - const occupiedByOtherFile = event.context.bootstrapFiles.some( - (file) => isObject(file) && file.path === REMINDER_PATH && !isInjectedReminderFile(file), - ); - if (occupiedByOtherFile) { - return; - } - - const cleanedBootstrapFiles = event.context.bootstrapFiles.filter( - (file, index, files) => - !isInjectedReminderFile(file) || - files.findIndex((candidate) => isInjectedReminderFile(candidate)) === index, - ); - - const reminderFile = { - name: REMINDER_NAME, - path: REMINDER_PATH, - content: REMINDER_CONTENT, - missing: false, - virtual: true, - }; - - const existingIndex = cleanedBootstrapFiles.findIndex((file) => isInjectedReminderFile(file)); - if (existingIndex === -1) { - cleanedBootstrapFiles.push(reminderFile); - } else { - cleanedBootstrapFiles[existingIndex] = reminderFile; - } - - event.context.bootstrapFiles = cleanedBootstrapFiles; - } -}; - -export default handler; diff --git a/skills/self-improvement/references/oclaw-integration.md b/skills/self-improvement/references/oclaw-integration.md index 17763ace..bab225bd 100644 --- a/skills/self-improvement/references/oclaw-integration.md +++ b/skills/self-improvement/references/oclaw-integration.md @@ -23,7 +23,7 @@ Oclaw uses workspace-based prompt injection combined with event-driven hooks. Co └── hooks/ # Custom hooks └── / ├── HOOK.md - └── handler.ts + └── handler.py ``` ## 快速配置 diff --git a/skills/session-bootstrap/hooks/runtime/handler.py b/skills/session-bootstrap/hooks/runtime/handler.py index f6919208..ec98a778 100644 --- a/skills/session-bootstrap/hooks/runtime/handler.py +++ b/skills/session-bootstrap/hooks/runtime/handler.py @@ -128,13 +128,35 @@ def _extract_recent_topic(memory_text: str) -> str: return _last_nonempty_line(memory_text) +def _extract_developer_name(raw_text: str) -> str: + lines = [ln.strip() for ln in str(raw_text or "").splitlines() if ln.strip()] + for ln in lines: + low = ln.lower() + if low.startswith("#"): + continue + if low.startswith("name:") or low.startswith("姓名:"): + name = ln.split(":", 1)[1].strip() + if name: + return name[:60] + if low.startswith("- name:") or low.startswith("- 姓名:"): + name = ln.split(":", 1)[1].strip() + if name: + return name[:60] + if ln.startswith("- "): + val = ln[2:].strip() + if val: + return val[:60] + return ln[:60] + return "" + + def _build_bootstrap_content(event: Any) -> str: ws_dir = _workspace_dir(event) repo = _repo_root() wiki = _wiki_root(repo) - soul = repo / "runtime" / "skills" / "session-bootstrap" / "SOUL.md" - ident = repo / "runtime" / "skills" / "session-bootstrap" / "IDENTITY.md" + soul = repo / "skills" / "session-bootstrap" / "SOUL.md" + ident = repo / "skills" / "session-bootstrap" / "IDENTITY.md" mem_file = _latest_memory_file(ws_dir) role_id = _agent_role(event) @@ -158,10 +180,12 @@ def _build_bootstrap_content(event: Any) -> str: role_lines.append(f"### {p.name}\n{snap or '(缺失)'}") role_txt = "\n\n".join(role_lines).strip() lrn_txt = _safe_read(learnings, max_chars=900) + user_current = wiki / "users" / "current.md" + user_txt = _safe_read(user_current, max_chars=400) topic = _extract_recent_topic(mem_txt) or "近期项目上下文" learning = _extract_recent_learning(lrn_txt) or "待补充新的关键学习" - developer = "开发者" + developer = _extract_developer_name(user_txt) or "开发者" welcome = f"欢迎回来,{developer}。上次我们聊了{topic},我学到了{learning}。" mem_path = str(mem_file) if mem_file else "(无)" @@ -184,6 +208,7 @@ def _build_bootstrap_content(event: Any) -> str: f"- 当前角色: {role_id or '(未知)'}\n" f"- Wiki core 规则文件: {core_sources}\n" f"- Wiki 角色规则文件: {role_sources}\n" + f"- Wiki 当前用户: {user_current}\n" f"- Wiki 学习记录: {learnings}\n" f"- Wiki 错误记录: {errors}\n" f"- Wiki 需求记录: {feats}\n\n" @@ -223,6 +248,13 @@ def handle(event: object) -> None: return content = _build_bootstrap_content(event) + try: + repo = _repo_root() + soul_ok = bool((repo / "skills" / "session-bootstrap" / "SOUL.md").exists()) + ident_ok = bool((repo / "skills" / "session-bootstrap" / "IDENTITY.md").exists()) + ctx["sessionBootstrapDiag"] = {"soul_found": soul_ok, "identity_found": ident_ok} + except Exception: + pass occupied = any(_is_record(f) and str(f.get("path")) == BOOT_PATH and not _is_bootstrap_file(f) for f in files) if occupied: return diff --git a/skills/tavily-search-pro/SKILL.md b/skills/tavily-search-pro/SKILL.md index 68d64a33..35bcacb3 100644 --- a/skills/tavily-search-pro/SKILL.md +++ b/skills/tavily-search-pro/SKILL.md @@ -38,7 +38,7 @@ Set in OpenClaw config: ## Script Location ```bash -python3 skills/tavily/lib/tavily_search.py "query" [options] +python3 skills/tavily-search-pro/lib/tavily_search.py "query" [options] ``` --- @@ -347,5 +347,5 @@ Sources: ## Install ```bash -bash skills/tavily/install.sh +bash skills/tavily-search-pro/install.sh ``` diff --git a/skills/weather/SKILL.md b/skills/weather/SKILL.md index 2146580d..508ca135 100644 --- a/skills/weather/SKILL.md +++ b/skills/weather/SKILL.md @@ -2,7 +2,7 @@ name: weather description: Get current weather and forecasts (no API key required). homepage: https://wttr.in/:help -metadata: {"clawdbot":{"emoji":"🌤️","requires":{"bins":["curl"]}}} +metadata: {"clawdbot":{"emoji":"🌤️"}} --- # Weather @@ -17,6 +17,11 @@ curl -s "wttr.in/London?format=3" # Output: London: ⛅️ +8°C ``` +Tool fallback (no curl required): +```text +web_fetch_clean url="https://wttr.in/London?format=3" +``` + Compact format: ```bash curl -s "wttr.in/London?format=%l:+%c+%t+%h+%w" @@ -36,6 +41,7 @@ Tips: - Units: `?m` (metric) `?u` (USCS) - Today only: `?1` · Current only: `?0` - PNG: `curl -s "wttr.in/Berlin.png" -o /tmp/weather.png` +- If `curl` is unavailable, prefer `web_fetch_clean` for text responses ## Open-Meteo (fallback, JSON) @@ -44,6 +50,11 @@ Free, no key, good for programmatic use: curl -s "https://api.open-meteo.com/v1/forecast?latitude=51.5&longitude=-0.12¤t_weather=true" ``` +Tool fallback: +```text +web_fetch_clean url="https://api.open-meteo.com/v1/forecast?latitude=51.5&longitude=-0.12¤t_weather=true" +``` + Find coordinates for a city, then query. Returns JSON with temp, windspeed, weathercode. Docs: https://open-meteo.com/en/docs diff --git a/tests/test_local_public_tools.py b/tests/test_local_public_tools.py index b22bdca8..5745372e 100644 --- a/tests/test_local_public_tools.py +++ b/tests/test_local_public_tools.py @@ -4,7 +4,7 @@ import tempfile from pathlib import Path from oclaw.runtime.tools.catalog import default_registry -from oclaw.runtime.tools.local_sdk.adapter import LocalAdapter +from oclaw.runtime.tools.public.local_sdk import LocalAdapter from oclaw.runtime.tools.public.edit_file_tool import edit_file_tool from oclaw.runtime.tools.public.run_command_tool import run_command_tool from oclaw.runtime.tools.public.read_file_tool import read_file_tool @@ -13,7 +13,6 @@ from oclaw.runtime.tools.public_registry import clear_public_tool_cache from oclaw.runtime.tools.public.list_directory_tool import list_directory_tool from oclaw.runtime.tools.public.search_files_tool import search_files_tool from oclaw.runtime.tools.public.get_cwd_tool import get_cwd_tool -from oclaw.runtime.tools.public.cd_tool import cd_tool from oclaw.runtime.tools.public.get_env_tool import get_env_tool from oclaw.runtime.tools.public.set_env_tool import set_env_tool @@ -33,7 +32,6 @@ def test_local_public_read_tool_visible_by_default() -> None: assert "mkdir" not in names assert "delete_file" not in names assert "move_file" not in names - assert "cd" not in names assert "set_env" not in names assert "kill_process" not in names @@ -54,7 +52,6 @@ def test_local_public_high_risk_tools_visible_when_enabled(monkeypatch) -> None: assert "mkdir" in names assert "delete_file" in names assert "move_file" in names - assert "cd" in names assert "set_env" in names assert "kill_process" in names @@ -160,14 +157,10 @@ def test_p1_p2_read_tools_smoke(tmp_path: Path, monkeypatch) -> None: out_s = search_files_tool().handler({"pattern": "hell", "root": "d", "regex": False}) assert out_s.get("ok") is True assert (out_s.get("count") or 0) >= 1 - # get_cwd / cd + # get_cwd out_cwd0 = get_cwd_tool().handler({}) assert out_cwd0.get("ok") is True - out_cd = cd_tool().handler({"cwd": "d"}) - assert out_cd.get("ok") is True - out_cwd1 = get_cwd_tool().handler({}) - assert out_cwd1.get("ok") is True - assert str(out_cwd1.get("cwd") or "").replace("\\", "/").endswith("/d") + assert str(out_cwd0.get("cwd") or "").replace("\\", "/").rstrip("/") == str(tmp_path).replace("\\", "/").rstrip("/") # get_env / set_env out_get0 = get_env_tool().handler({"key": "LOCAL_PUBLIC_TOOLS_TEST_KEY", "default": "x"}) assert out_get0.get("ok") is True @@ -178,6 +171,34 @@ def test_p1_p2_read_tools_smoke(tmp_path: Path, monkeypatch) -> None: assert out_get1.get("value") == "y" +def test_get_cwd_returns_workspace_root_when_at_data_workspace(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("OPS_WORKSPACE_ROOT", str(tmp_path)) + (tmp_path / "data" / "workspace").mkdir(parents=True, exist_ok=True) + adapter = LocalAdapter() + + out_cd = adapter.cd(cwd="data/workspace") + assert out_cd.get("ok") is True + + out_cwd = adapter.get_cwd() + assert out_cwd.get("ok") is True + norm = str(out_cwd.get("cwd") or "").replace("\\", "/").rstrip("/") + assert norm == str(tmp_path).replace("\\", "/").rstrip("/") + + +def test_get_cwd_returns_workspace_root_even_after_cd(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("OPS_WORKSPACE_ROOT", str(tmp_path)) + (tmp_path / "subdir").mkdir(parents=True, exist_ok=True) + adapter = LocalAdapter() + + out_cd = adapter.cd(cwd="subdir") + assert out_cd.get("ok") is True + + out_cwd = adapter.get_cwd() + assert out_cwd.get("ok") is True + norm = str(out_cwd.get("cwd") or "").replace("\\", "/").rstrip("/") + assert norm == str(tmp_path).replace("\\", "/").rstrip("/") + + def test_run_command_does_not_follow_cd_state(tmp_path: Path, monkeypatch) -> None: monkeypatch.setenv("OPS_WORKSPACE_ROOT", str(tmp_path)) monkeypatch.setenv("AIA_ENABLE_RUN_COMMAND", "1") @@ -194,8 +215,8 @@ def test_run_command_does_not_follow_cd_state(tmp_path: Path, monkeypatch) -> No out_run = adapter.run_command(command='python -c "import os; print(os.path.basename(os.getcwd()))"', timeout=20) assert out_run.get("ok") is True, out_run - # If run_command follows cd state this would be "subdir"; default should be data/workspace. - assert str(out_run.get("cwd") or "").replace("\\", "/").rstrip("/").endswith("/data/workspace") + # If run_command follows cd state this would be "subdir"; default should be workspace root. + assert str(out_run.get("cwd") or "").replace("\\", "/").rstrip("/") == str(tmp_path).replace("\\", "/").rstrip("/") def test_run_command_reads_db_toggle_without_ops_db_env(tmp_path: Path, monkeypatch) -> None: diff --git a/tests/test_memory_tool_aliases.py b/tests/test_memory_tool_aliases.py index 8276fe86..81d23505 100644 --- a/tests/test_memory_tool_aliases.py +++ b/tests/test_memory_tool_aliases.py @@ -14,3 +14,13 @@ def test_memory_tools_only_expose_canonical_names() -> None: assert all(not str(x).startswith("memory_curator_") for x in names) +def test_generalist_also_exposes_memory_wiki_tools() -> None: + specs = materialize_tool_specs(expert="generalist") + names = {str(x.name or "") for x in specs} + assert "memory_wiki_status" in names + assert "memory_wiki_get" in names + assert "memory_wiki_search" in names + assert "memory_wiki_lint" in names + assert "memory_wiki_apply" in names + + diff --git a/tests/test_memory_wiki_tools_loader.py b/tests/test_memory_wiki_tools_loader.py index 25db6b80..039a4750 100644 --- a/tests/test_memory_wiki_tools_loader.py +++ b/tests/test_memory_wiki_tools_loader.py @@ -3,7 +3,7 @@ from __future__ import annotations from pathlib import Path from oclaw.platform.config.paths import PROJECT_ROOT -from oclaw.runtime.tools.experts.memory.wiki_tools import ( +from oclaw.runtime.tools.public.memory_wiki_tools import ( memory_wiki_apply_tool, memory_wiki_get_tool, memory_wiki_lint_tool, diff --git a/tests/test_session_bootstrap_handler.py b/tests/test_session_bootstrap_handler.py new file mode 100644 index 00000000..4b07c794 --- /dev/null +++ b/tests/test_session_bootstrap_handler.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +from types import SimpleNamespace + + +def _load_handler_module(): + module_path = ( + Path(__file__).resolve().parents[1] + / "skills" + / "session-bootstrap" + / "hooks" + / "runtime" + / "handler.py" + ) + spec = importlib.util.spec_from_file_location("test_session_bootstrap_handler", str(module_path)) + assert spec is not None and spec.loader is not None + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def test_build_bootstrap_reads_skills_root_and_user_name(tmp_path: Path) -> None: + mod = _load_handler_module() + repo = tmp_path / "repo" + ws = repo / "workspace" + (repo / "skills" / "session-bootstrap").mkdir(parents=True) + (repo / "data" / "wiki" / "users").mkdir(parents=True) + ws.mkdir(parents=True) + + (repo / "skills" / "session-bootstrap" / "SOUL.md").write_text("SOUL_OK", encoding="utf-8") + (repo / "skills" / "session-bootstrap" / "IDENTITY.md").write_text("IDENT_OK", encoding="utf-8") + (repo / "data" / "wiki" / "users" / "current.md").write_text("name: Alice", encoding="utf-8") + + mod._repo_root = lambda: repo # type: ignore[attr-defined] + event = SimpleNamespace(context={"workspaceDir": str(ws), "agentId": "generalist"}) + content = mod._build_bootstrap_content(event) # type: ignore[attr-defined] + assert "SOUL_OK" in content + assert "IDENT_OK" in content + assert "欢迎回来,Alice" in content + + +def test_handle_sets_bootstrap_diag(tmp_path: Path) -> None: + mod = _load_handler_module() + repo = tmp_path / "repo" + (repo / "skills" / "session-bootstrap").mkdir(parents=True) + (repo / "skills" / "session-bootstrap" / "SOUL.md").write_text("x", encoding="utf-8") + (repo / "skills" / "session-bootstrap" / "IDENTITY.md").write_text("y", encoding="utf-8") + mod._repo_root = lambda: repo # type: ignore[attr-defined] + event = SimpleNamespace( + type="agent", + action="bootstrap", + sessionKey="agent:main:main", + context={"workspaceDir": str(tmp_path / "ws"), "bootstrapFiles": [], "agentId": "generalist"}, + ) + mod.handle(event) + diag = event.context.get("sessionBootstrapDiag") + assert isinstance(diag, dict) + assert diag.get("soul_found") is True + assert diag.get("identity_found") is True