feat: 路径约定体系 & 自优化管道落地

This commit is contained in:
oliver 2026-05-10 23:28:41 +08:00
parent 08df5d580a
commit df30b5902f
37 changed files with 239 additions and 324 deletions

View file

@ -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`.

View file

@ -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:

View file

@ -81,5 +81,7 @@ def run_cc_mini_cli(
env=merged,
check=False,
text=True,
encoding="utf-8",
errors="replace",
**kwargs,
)

View file

@ -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__)

View file

@ -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,
)

View file

@ -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"

View file

@ -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",
]

View file

@ -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"]

View file

@ -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"]

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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 "")

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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"]

View file

@ -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,
)

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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)),

View file

@ -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/` 以外的技能目录

View file

@ -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;

View file

@ -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<string, unknown> {
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;

View file

@ -23,7 +23,7 @@ Oclaw uses workspace-based prompt injection combined with event-driven hooks. Co
└── hooks/ # Custom hooks
└── <hook-name>/
├── HOOK.md
└── handler.ts
└── handler.py
```
## 快速配置

View file

@ -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

View file

@ -38,7 +38,7 @@ Set in OpenClaw config:
## Script Location
```bash
python3 skills/tavily/lib/tavily_search.py <command> "query" [options]
python3 skills/tavily-search-pro/lib/tavily_search.py <command> "query" [options]
```
---
@ -347,5 +347,5 @@ Sources:
## Install
```bash
bash skills/tavily/install.sh
bash skills/tavily-search-pro/install.sh
```

View file

@ -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&current_weather=true"
```
Tool fallback:
```text
web_fetch_clean url="https://api.open-meteo.com/v1/forecast?latitude=51.5&longitude=-0.12&current_weather=true"
```
Find coordinates for a city, then query. Returns JSON with temp, windspeed, weathercode.
Docs: https://open-meteo.com/en/docs

View file

@ -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:

View file

@ -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

View file

@ -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,

View file

@ -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