From a50813931b1b1935ba0254f42757aff03eb8d3e1 Mon Sep 17 00:00:00 2001 From: "Arjun (OpenClaw)" Date: Sat, 7 Feb 2026 19:07:02 +0000 Subject: [PATCH 01/12] fix(frontend): allow localhost dev origins Expands Next.js allowedDevOrigins to include localhost/127.0.0.1 to reduce dev proxy ECONNRESET when binding next dev to 127.0.0.1. --- frontend/next.config.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/next.config.ts b/frontend/next.config.ts index e594f65b..8c034bc8 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,7 +1,11 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - allowedDevOrigins: ["192.168.1.101"], + // In dev, Next may proxy requests based on the request origin/host. + // Allow common local origins so `next dev --hostname 127.0.0.1` works + // when users access via http://localhost:3000 or http://127.0.0.1:3000. + // Keep the LAN IP as well for dev on the local network. + allowedDevOrigins: ["192.168.1.101", "localhost", "127.0.0.1"], images: { remotePatterns: [ { From 4d898dbb596612450f6ae3566a47ba43b4a63740 Mon Sep 17 00:00:00 2001 From: "Arjun (OpenClaw)" Date: Sat, 7 Feb 2026 19:10:05 +0000 Subject: [PATCH 02/12] docs(frontend): clarify dev origin restrictions Update frontend README note to explain host/origin mismatch and allowedDevOrigins when seeing proxy ECONNRESET in next dev. --- frontend/README.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/frontend/README.md b/frontend/README.md index 52ddef78..e5d5820c 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -157,8 +157,6 @@ Clerk should be **off** unless you set a real `pk_test_...` or `pk_live_...` pub ### Dev server blocked by origin restrictions -`next.config.ts` sets: +`next.config.ts` sets `allowedDevOrigins` for dev proxy safety. -- `allowedDevOrigins: ["192.168.1.101"]` - -If you’re developing from a different hostname/IP, you may need to update `allowedDevOrigins` (or use `npm run dev` on localhost). \ No newline at end of file +If you see repeated proxy errors (often `ECONNRESET`), make sure your dev server hostname and browser URL match (e.g. `localhost` vs `127.0.0.1`), and that your origin is included in `allowedDevOrigins`. \ No newline at end of file From 91e4c069ccd3227123d63370c9ffbeac930585c0 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Sat, 7 Feb 2026 23:27:49 +0530 Subject: [PATCH 03/12] feat: enhance agent identity profile with purpose and personality attributes --- .gitignore | 2 +- backend/app/services/agent_provisioning.py | 3 ++ .../src/app/agents/[agentId]/edit/page.tsx | 32 +++++++++++++------ templates/HEARTBEAT_LEAD.md | 7 ++++ templates/IDENTITY.md | 8 +++++ templates/SELF.md | 6 ++++ 6 files changed, 47 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 354a7c84..addb7216 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,6 @@ node_modules/ # Accidental literal "~" directories (e.g. when a configured path contains "~" but isn't expanded) backend/~/ -backend/coverage.xml +backend/coverage.* backend/.coverage frontend/coverage \ No newline at end of file diff --git a/backend/app/services/agent_provisioning.py b/backend/app/services/agent_provisioning.py index b50d1f77..ab0ea972 100644 --- a/backend/app/services/agent_provisioning.py +++ b/backend/app/services/agent_provisioning.py @@ -34,6 +34,9 @@ EXTRA_IDENTITY_PROFILE_FIELDS = { "verbosity": "identity_verbosity", "output_format": "identity_output_format", "update_cadence": "identity_update_cadence", + # Per-agent charter (optional). Used to give agents a "purpose in life" and a distinct vibe. + "purpose": "identity_purpose", + "personality": "identity_personality", "custom_instructions": "identity_custom_instructions", } diff --git a/frontend/src/app/agents/[agentId]/edit/page.tsx b/frontend/src/app/agents/[agentId]/edit/page.tsx index 14af932f..cb0d0043 100644 --- a/frontend/src/app/agents/[agentId]/edit/page.tsx +++ b/frontend/src/app/agents/[agentId]/edit/page.tsx @@ -68,16 +68,27 @@ const getBoardOptions = (boards: BoardRead[]): SearchableSelectOption[] => label: board.name, })); -const normalizeIdentityProfile = ( - profile: IdentityProfile, -): IdentityProfile | null => { - const normalized: IdentityProfile = { - role: profile.role.trim(), - communication_style: profile.communication_style.trim(), - emoji: profile.emoji.trim(), +const mergeIdentityProfile = ( + existing: unknown, + patch: IdentityProfile, +): Record | null => { + const resolved: Record = + existing && typeof existing === "object" + ? { ...(existing as Record) } + : {}; + const updates: Record = { + role: patch.role.trim(), + communication_style: patch.communication_style.trim(), + emoji: patch.emoji.trim(), }; - const hasValue = Object.values(normalized).some((value) => value.length > 0); - return hasValue ? normalized : null; + for (const [key, value] of Object.entries(updates)) { + if (value) { + resolved[key] = value; + } else { + delete resolved[key]; + } + } + return Object.keys(resolved).length > 0 ? resolved : null; }; const withIdentityDefaults = ( @@ -241,7 +252,8 @@ export default function EditAgentPage() { every: resolvedHeartbeatEvery.trim() || "10m", target: resolvedHeartbeatTarget, } as unknown as Record, - identity_profile: normalizeIdentityProfile( + identity_profile: mergeIdentityProfile( + loadedAgent.identity_profile, resolvedIdentityProfile, ) as unknown as Record | null, soul_template: resolvedSoulTemplate.trim() || null, diff --git a/templates/HEARTBEAT_LEAD.md b/templates/HEARTBEAT_LEAD.md index 16851816..f52f1ce3 100644 --- a/templates/HEARTBEAT_LEAD.md +++ b/templates/HEARTBEAT_LEAD.md @@ -235,6 +235,11 @@ Body: {"depends_on_task_ids":["DEP_TASK_ID_1","DEP_TASK_ID_2"]} - When creating a new agent, always set `identity_profile.role` using real-world team roles so humans and other agents can coordinate quickly. - Use Title Case role nouns: `Researcher`, `Analyst 1`, `Analyst 2`, `Engineer 1`, `QA`, `Reviewer`, `Scribe`. - If you create multiple agents with the same base role, number them sequentially starting at 1 (pick the next unused number by scanning the current agent list). +- When creating a new agent, always give them a lightweight "charter" so they are not a generic interchangeable worker: + - The charter must be derived from the requirements of the work you plan to delegate next (tasks, constraints, success metrics, risks). If you cannot articulate it, do **not** create the agent yet. + - Set `identity_profile.purpose` (1-2 sentences): what outcomes they own, what artifacts they should produce, and how it advances the board objective. + - Set `identity_profile.personality` (short): a distinct working style that changes decisions and tradeoffs (e.g., speed vs correctness, skeptical vs optimistic, detail vs breadth). + - Optional: set `identity_profile.custom_instructions` when you need stronger guardrails (3-8 short bullets). Examples: "always cite sources", "always propose tests", "prefer smallest change", "ask clarifying questions before coding", "do not touch prod configs". Agent create (lead‑allowed): POST $BASE_URL/api/v1/agent/agents Body example: @@ -243,6 +248,8 @@ Body: {"depends_on_task_ids":["DEP_TASK_ID_1","DEP_TASK_ID_2"]} "board_id": "$BOARD_ID", "identity_profile": { "role": "Researcher", + "purpose": "Find authoritative sources on X and write a 10-bullet summary with links + key risks.", + "personality": "curious, skeptical, citation-happy, concise", "communication_style": "concise, structured", "emoji": ":brain:" } diff --git a/templates/IDENTITY.md b/templates/IDENTITY.md index 126ad844..c1e0d810 100644 --- a/templates/IDENTITY.md +++ b/templates/IDENTITY.md @@ -9,3 +9,11 @@ Creature: {{ identity_role }} Vibe: {{ identity_communication_style }} Emoji: {{ identity_emoji }} + +{% if identity_purpose %} +Purpose: {{ identity_purpose }} +{% endif %} + +{% if identity_personality %} +Personality: {{ identity_personality }} +{% endif %} diff --git a/templates/SELF.md b/templates/SELF.md index a170ab81..1823bf61 100644 --- a/templates/SELF.md +++ b/templates/SELF.md @@ -15,6 +15,12 @@ every message. - Role: {{ identity_role }} - Communication: {{ identity_communication_style }} - Emoji: {{ identity_emoji }} +{% if identity_purpose %} +- Purpose: {{ identity_purpose }} +{% endif %} +{% if identity_personality %} +- Personality: {{ identity_personality }} +{% endif %} {% if board_id is defined %} - Board: {{ board_name }} From 40b0be75409c482884fc8c0c8dca550db1aa4b6c Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Sat, 7 Feb 2026 23:36:28 +0530 Subject: [PATCH 04/12] feat: add board group integration to boards page --- frontend/src/app/boards/page.tsx | 60 ++++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/boards/page.tsx b/frontend/src/app/boards/page.tsx index ef597c6e..be6cc70b 100644 --- a/frontend/src/app/boards/page.tsx +++ b/frontend/src/app/boards/page.tsx @@ -21,7 +21,11 @@ import { useDeleteBoardApiV1BoardsBoardIdDelete, useListBoardsApiV1BoardsGet, } from "@/api/generated/boards/boards"; -import type { BoardRead } from "@/api/generated/model"; +import { + type listBoardGroupsApiV1BoardGroupsGetResponse, + useListBoardGroupsApiV1BoardGroupsGet, +} from "@/api/generated/board-groups/board-groups"; +import type { BoardGroupRead, BoardRead } from "@/api/generated/model"; import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { DashboardShell } from "@/components/templates/DashboardShell"; import { Button, buttonVariants } from "@/components/ui/button"; @@ -46,6 +50,9 @@ const formatTimestamp = (value?: string | null) => { }); }; +const compactId = (value: string) => + value.length > 8 ? `${value.slice(0, 8)}…` : value; + export default function BoardsPage() { const { isSignedIn } = useAuth(); const queryClient = useQueryClient(); @@ -63,6 +70,20 @@ export default function BoardsPage() { }, }); + const groupsQuery = useListBoardGroupsApiV1BoardGroupsGet< + listBoardGroupsApiV1BoardGroupsGetResponse, + ApiError + >( + { limit: 200 }, + { + query: { + enabled: Boolean(isSignedIn), + refetchInterval: 30_000, + refetchOnMount: "always", + }, + }, + ); + const boards = useMemo( () => boardsQuery.data?.status === 200 @@ -71,6 +92,19 @@ export default function BoardsPage() { [boardsQuery.data], ); + const groups = useMemo(() => { + if (groupsQuery.data?.status !== 200) return []; + return groupsQuery.data.data.items ?? []; + }, [groupsQuery.data]); + + const groupById = useMemo(() => { + const map = new Map(); + for (const group of groups) { + map.set(group.id, group); + } + return map; + }, [groups]); + const deleteMutation = useDeleteBoardApiV1BoardsBoardIdDelete< ApiError, { previous?: listBoardsApiV1BoardsGetResponse } @@ -136,6 +170,28 @@ export default function BoardsPage() { ), }, + { + id: "group", + header: "Group", + cell: ({ row }) => { + const groupId = row.original.board_group_id; + if (!groupId) { + return ; + } + const group = groupById.get(groupId); + const label = group?.name ?? compactId(groupId); + const title = group?.name ?? groupId; + return ( + + {label} + + ); + }, + }, { accessorKey: "updated_at", header: "Updated", @@ -167,7 +223,7 @@ export default function BoardsPage() { ), }, ], - [], + [groupById], ); // eslint-disable-next-line react-hooks/incompatible-library From fa5b7dd27118a0d30cb7c2d2862b4a562e6ccc0b Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Sun, 8 Feb 2026 00:18:15 +0530 Subject: [PATCH 05/12] feat: remove unused lead agent attributes from onboarding chat --- .../src/components/BoardOnboardingChat.tsx | 30 ------------------- 1 file changed, 30 deletions(-) diff --git a/frontend/src/components/BoardOnboardingChat.tsx b/frontend/src/components/BoardOnboardingChat.tsx index a29e984f..7b2db557 100644 --- a/frontend/src/components/BoardOnboardingChat.tsx +++ b/frontend/src/components/BoardOnboardingChat.tsx @@ -447,36 +447,6 @@ export function BoardOnboardingChat({ Emoji:{" "} {draft.lead_agent.identity_profile?.emoji || "—"}

-

- Autonomy:{" "} - {draft.lead_agent.autonomy_level || "—"} -

-

- Verbosity:{" "} - {draft.lead_agent.verbosity || "—"} -

-

- - Output format: - {" "} - {draft.lead_agent.output_format || "—"} -

-

- - Update cadence: - {" "} - {draft.lead_agent.update_cadence || "—"} -

- {draft.lead_agent.custom_instructions ? ( - <> -

- Custom instructions -

-
-                      {draft.lead_agent.custom_instructions}
-                    
- - ) : null} ) : null} From 1dfff391403a0fbd12272e20e9cac5b7bf37c163 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Sun, 8 Feb 2026 00:28:59 +0530 Subject: [PATCH 06/12] feat: add node wrapper script and update Makefile for frontend tooling --- Makefile | 41 +++++++----- scripts/with_node.sh | 155 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+), 16 deletions(-) create mode 100644 scripts/with_node.sh diff --git a/Makefile b/Makefile index da1a24d1..6441b6d6 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,8 @@ SHELL := /usr/bin/env bash BACKEND_DIR := backend FRONTEND_DIR := frontend +NODE_WRAP := bash scripts/with_node.sh + .PHONY: help help: ## Show available targets @grep -E '^[a-zA-Z0-9_.-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*## "}; {printf " %-26s %s\n", $$1, $$2}' @@ -13,13 +15,20 @@ help: ## Show available targets .PHONY: setup setup: backend-sync frontend-sync ## Install/sync backend + frontend deps +.PHONY: all +all: setup format check ## Run everything (deps + format + CI-equivalent checks) + .PHONY: backend-sync backend-sync: ## uv sync backend deps (includes dev extra) cd $(BACKEND_DIR) && uv sync --extra dev +.PHONY: frontend-tooling +frontend-tooling: ## Verify frontend toolchain (node + npm) + @$(NODE_WRAP) --check + .PHONY: frontend-sync -frontend-sync: ## npm install frontend deps - cd $(FRONTEND_DIR) && npm install +frontend-sync: frontend-tooling ## npm install frontend deps + $(NODE_WRAP) --cwd $(FRONTEND_DIR) npm install .PHONY: format format: backend-format frontend-format ## Format backend + frontend @@ -30,8 +39,8 @@ backend-format: ## Format backend (isort + black) cd $(BACKEND_DIR) && uv run black . .PHONY: frontend-format -frontend-format: ## Format frontend (prettier) - cd $(FRONTEND_DIR) && npx prettier --write "src/**/*.{ts,tsx,js,jsx,json,css,md}" "*.{ts,js,json,md,mdx}" +frontend-format: frontend-tooling ## Format frontend (prettier) + $(NODE_WRAP) --cwd $(FRONTEND_DIR) npx prettier --write "src/**/*.{ts,tsx,js,jsx,json,css,md}" "*.{ts,js,json,md,mdx}" .PHONY: format-check format-check: backend-format-check frontend-format-check ## Check formatting (no changes) @@ -42,8 +51,8 @@ backend-format-check: ## Check backend formatting (isort + black) cd $(BACKEND_DIR) && uv run black . --check --diff .PHONY: frontend-format-check -frontend-format-check: ## Check frontend formatting (prettier) - cd $(FRONTEND_DIR) && npx prettier --check "src/**/*.{ts,tsx,js,jsx,json,css,md}" "*.{ts,js,json,md,mdx}" +frontend-format-check: frontend-tooling ## Check frontend formatting (prettier) + $(NODE_WRAP) --cwd $(FRONTEND_DIR) npx prettier --check "src/**/*.{ts,tsx,js,jsx,json,css,md}" "*.{ts,js,json,md,mdx}" .PHONY: lint lint: backend-lint frontend-lint ## Lint backend + frontend @@ -53,8 +62,8 @@ backend-lint: ## Lint backend (flake8) cd $(BACKEND_DIR) && uv run flake8 --config .flake8 .PHONY: frontend-lint -frontend-lint: ## Lint frontend (eslint) - cd $(FRONTEND_DIR) && npm run lint +frontend-lint: frontend-tooling ## Lint frontend (eslint) + $(NODE_WRAP) --cwd $(FRONTEND_DIR) npm run lint .PHONY: typecheck typecheck: backend-typecheck frontend-typecheck ## Typecheck backend + frontend @@ -64,8 +73,8 @@ backend-typecheck: ## Typecheck backend (mypy --strict) cd $(BACKEND_DIR) && uv run mypy .PHONY: frontend-typecheck -frontend-typecheck: ## Typecheck frontend (tsc) - cd $(FRONTEND_DIR) && npx tsc -p tsconfig.json --noEmit +frontend-typecheck: frontend-tooling ## Typecheck frontend (tsc) + $(NODE_WRAP) --cwd $(FRONTEND_DIR) npx tsc -p tsconfig.json --noEmit .PHONY: test test: backend-test frontend-test ## Run tests @@ -88,8 +97,8 @@ backend-coverage: ## Backend tests with coverage gate (scoped 100% stmt+branch o --cov-fail-under=100 .PHONY: frontend-test -frontend-test: ## Frontend tests (vitest) - cd $(FRONTEND_DIR) && npm run test +frontend-test: frontend-tooling ## Frontend tests (vitest) + $(NODE_WRAP) --cwd $(FRONTEND_DIR) npm run test .PHONY: backend-migrate backend-migrate: ## Apply backend DB migrations (alembic upgrade head) @@ -99,12 +108,12 @@ backend-migrate: ## Apply backend DB migrations (alembic upgrade head) build: frontend-build ## Build artifacts .PHONY: frontend-build -frontend-build: ## Build frontend (next build) - cd $(FRONTEND_DIR) && npm run build +frontend-build: frontend-tooling ## Build frontend (next build) + $(NODE_WRAP) --cwd $(FRONTEND_DIR) npm run build .PHONY: api-gen -api-gen: ## Regenerate TS API client (requires backend running at 127.0.0.1:8000) - cd $(FRONTEND_DIR) && npm run api:gen +api-gen: frontend-tooling ## Regenerate TS API client (requires backend running at 127.0.0.1:8000) + $(NODE_WRAP) --cwd $(FRONTEND_DIR) npm run api:gen .PHONY: backend-templates-sync backend-templates-sync: ## Sync templates to existing gateway agents (usage: make backend-templates-sync GATEWAY_ID= SYNC_ARGS="--reset-sessions") diff --git a/scripts/with_node.sh b/scripts/with_node.sh new file mode 100644 index 00000000..4974d23e --- /dev/null +++ b/scripts/with_node.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + with_node.sh [--check] [--cwd DIR] [--] [args...] + +Ensures node/npm/npx are available (optionally via nvm) before running a command. + +Options: + --check Only verify node/npm/npx are available (no command required). + --cwd DIR Change to DIR before running. + -h, --help Show help. +EOF +} + +CHECK_ONLY="false" +CWD="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --check) + CHECK_ONLY="true" + shift + ;; + --cwd) + CWD="${2:-}" + shift 2 + ;; + --) + shift + break + ;; + -h|--help) + usage + exit 0 + ;; + *) + break + ;; + esac +done + +if [[ -n "$CWD" ]]; then + : # handled after we resolve repo root from this script's location +fi + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd -P)" + +if [[ -n "$CWD" ]]; then + cd "$CWD" +fi + +read_nvmrc() { + local path="$1" + if [[ -f "$path" ]]; then + command tr -d ' \t\r\n' <"$path" || true + fi +} + +version_greater() { + # Returns 0 (true) if $1 > $2 for simple semver-ish values like "v22.21.1". + local v1="${1#v}" + local v2="${2#v}" + local a1 b1 c1 a2 b2 c2 + IFS=. read -r a1 b1 c1 <<<"$v1" + IFS=. read -r a2 b2 c2 <<<"$v2" + a1="${a1:-0}"; b1="${b1:-0}"; c1="${c1:-0}" + a2="${a2:-0}"; b2="${b2:-0}"; c2="${c2:-0}" + if ((a1 != a2)); then ((a1 > a2)); return; fi + if ((b1 != b2)); then ((b1 > b2)); return; fi + ((c1 > c2)) +} + +bootstrap_nvm_if_needed() { + if command -v node >/dev/null 2>&1 && command -v npm >/dev/null 2>&1 && command -v npx >/dev/null 2>&1; then + return 0 + fi + + local nvm_dir="${NVM_DIR:-$HOME/.nvm}" + if [[ ! -s "$nvm_dir/nvm.sh" ]]; then + return 0 + fi + + # nvm is not guaranteed to be safe under `set -u`. + set +u + # shellcheck disable=SC1090 + . "$nvm_dir/nvm.sh" + + local version="" + version="$(read_nvmrc "$REPO_ROOT/.nvmrc")" + if [[ -z "$version" ]]; then + version="$(read_nvmrc "$REPO_ROOT/frontend/.nvmrc")" + fi + + if [[ -n "$version" ]]; then + nvm use --silent "$version" >/dev/null 2>&1 || true + else + # Prefer a user-defined nvm default, otherwise pick the newest installed version. + nvm use --silent default >/dev/null 2>&1 || true + if ! command -v node >/dev/null 2>&1; then + local versions_dir="$nvm_dir/versions/node" + if [[ -d "$versions_dir" ]]; then + local latest="" + local candidate="" + for candidate in "$versions_dir"/*; do + [[ -d "$candidate" ]] || continue + candidate="$(basename "$candidate")" + [[ "$candidate" =~ ^v?[0-9]+(\\.[0-9]+){0,2}$ ]] || continue + if [[ -z "$latest" ]] || version_greater "$candidate" "$latest"; then + latest="$candidate" + fi + done + [[ -n "$latest" ]] && nvm use --silent "$latest" >/dev/null 2>&1 || true + fi + fi + fi + + set -u +} + +bootstrap_nvm_if_needed + +if ! command -v node >/dev/null 2>&1; then + echo "ERROR: node is required to run frontend targets." >&2 + echo "Install Node.js or make it available via nvm (set NVM_DIR and ensure \$NVM_DIR/nvm.sh exists)." >&2 + echo "Tip: add a project .nvmrc or set an nvm default alias (e.g. 'nvm alias default ')." >&2 + exit 127 +fi + +if ! command -v npm >/dev/null 2>&1; then + echo "ERROR: npm is required to run frontend targets." >&2 + echo "Install Node.js (includes npm/npx) or ensure your nvm-selected Node provides npm." >&2 + exit 127 +fi + +if ! command -v npx >/dev/null 2>&1; then + echo "ERROR: npx is required to run frontend targets (usually installed with npm)." >&2 + echo "Install Node.js (includes npm/npx) or ensure your npm install includes npx." >&2 + exit 127 +fi + +if [[ "$CHECK_ONLY" == "true" ]]; then + exit 0 +fi + +if [[ $# -lt 1 ]]; then + usage >&2 + exit 2 +fi + +exec "$@" From 527cc13c63a592b14b09bccd5843e203b5a9877d Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Sun, 8 Feb 2026 00:29:14 +0530 Subject: [PATCH 07/12] refactor: improve code formatting and readability across multiple files --- backend/tests/test_error_handling.py | 8 +++- backend/tests/test_request_id_middleware.py | 8 +++- frontend/README.md | 6 +-- frontend/src/app/activity/page.test.tsx | 16 ++++++-- .../src/app/board-groups/[groupId]/page.tsx | 38 ++++++++++--------- frontend/src/auth/clerkKey.ts | 4 +- frontend/src/proxy.ts | 4 +- 7 files changed, 54 insertions(+), 30 deletions(-) diff --git a/backend/tests/test_error_handling.py b/backend/tests/test_error_handling.py index 00d09476..4125d13c 100644 --- a/backend/tests/test_error_handling.py +++ b/backend/tests/test_error_handling.py @@ -3,10 +3,14 @@ from __future__ import annotations from fastapi import FastAPI, HTTPException from fastapi.testclient import TestClient from pydantic import BaseModel, Field - from starlette.requests import Request -from app.core.error_handling import REQUEST_ID_HEADER, _error_payload, _get_request_id, install_error_handling +from app.core.error_handling import ( + REQUEST_ID_HEADER, + _error_payload, + _get_request_id, + install_error_handling, +) def test_request_validation_error_includes_request_id(): diff --git a/backend/tests/test_request_id_middleware.py b/backend/tests/test_request_id_middleware.py index 1f2bb998..08b83bd0 100644 --- a/backend/tests/test_request_id_middleware.py +++ b/backend/tests/test_request_id_middleware.py @@ -46,7 +46,9 @@ async def test_request_id_middleware_ignores_blank_client_header_and_generates_o assert isinstance(captured_request_id, str) and captured_request_id # Header should reflect the generated id, not the blank one. - values = [v for k, v in response_headers if k.lower() == REQUEST_ID_HEADER.lower().encode("latin-1")] + values = [ + v for k, v in response_headers if k.lower() == REQUEST_ID_HEADER.lower().encode("latin-1") + ] assert values == [captured_request_id.encode("latin-1")] @@ -81,5 +83,7 @@ async def test_request_id_middleware_does_not_duplicate_existing_header() -> Non assert start_headers is not None # Ensure the middleware did not append a second copy. - values = [v for k, v in start_headers if k.lower() == REQUEST_ID_HEADER.lower().encode("latin-1")] + values = [ + v for k, v in start_headers if k.lower() == REQUEST_ID_HEADER.lower().encode("latin-1") + ] assert values == [b"already"] diff --git a/frontend/README.md b/frontend/README.md index 52ddef78..09e5074a 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -4,7 +4,7 @@ This package is the **Next.js** web UI for OpenClaw Mission Control. - Talks to the Mission Control **backend** over HTTP (typically `http://localhost:8000`). - Uses **React Query** for data fetching. -- Can optionally enable **Clerk** authentication (disabled by default unless you provide a *real* Clerk publishable key). +- Can optionally enable **Clerk** authentication (disabled by default unless you provide a _real_ Clerk publishable key). ## Prerequisites @@ -73,7 +73,7 @@ Implementation detail: we gate on a conservative regex (`pk_test_...` / `pk_live - `NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL` **Important:** `frontend/.env.example` contains placeholder values like `YOUR_PUBLISHABLE_KEY`. -Those placeholders are *not* valid keys and are intentionally treated as “Clerk disabled”. +Those placeholders are _not_ valid keys and are intentionally treated as “Clerk disabled”. ## How the frontend talks to the backend @@ -161,4 +161,4 @@ Clerk should be **off** unless you set a real `pk_test_...` or `pk_live_...` pub - `allowedDevOrigins: ["192.168.1.101"]` -If you’re developing from a different hostname/IP, you may need to update `allowedDevOrigins` (or use `npm run dev` on localhost). \ No newline at end of file +If you’re developing from a different hostname/IP, you may need to update `allowedDevOrigins` (or use `npm run dev` on localhost). diff --git a/frontend/src/app/activity/page.test.tsx b/frontend/src/app/activity/page.test.tsx index 7f4bc766..94b5ccf9 100644 --- a/frontend/src/app/activity/page.test.tsx +++ b/frontend/src/app/activity/page.test.tsx @@ -26,15 +26,23 @@ vi.mock("next/link", () => { // wrappers still render from @clerk/nextjs (which crashes in real builds). vi.mock("@clerk/nextjs", () => { return { - ClerkProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + ClerkProvider: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), SignedIn: () => { - throw new Error("@clerk/nextjs SignedIn rendered (unexpected in secretless mode)"); + throw new Error( + "@clerk/nextjs SignedIn rendered (unexpected in secretless mode)", + ); }, SignedOut: () => { throw new Error("@clerk/nextjs SignedOut rendered without ClerkProvider"); }, - SignInButton: ({ children }: { children: React.ReactNode }) => <>{children}, - SignOutButton: ({ children }: { children: React.ReactNode }) => <>{children}, + SignInButton: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), + SignOutButton: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), useAuth: () => ({ isLoaded: true, isSignedIn: false }), useUser: () => ({ isLoaded: true, isSignedIn: false, user: null }), }; diff --git a/frontend/src/app/board-groups/[groupId]/page.tsx b/frontend/src/app/board-groups/[groupId]/page.tsx index ef9606f9..cf59731a 100644 --- a/frontend/src/app/board-groups/[groupId]/page.tsx +++ b/frontend/src/app/board-groups/[groupId]/page.tsx @@ -135,7 +135,11 @@ const SSE_RECONNECT_BACKOFF = { type HeartbeatUnit = "s" | "m" | "h" | "d"; -const HEARTBEAT_PRESETS: Array<{ label: string; amount: number; unit: HeartbeatUnit }> = [ +const HEARTBEAT_PRESETS: Array<{ + label: string; + amount: number; + unit: HeartbeatUnit; +}> = [ { label: "30s", amount: 30, unit: "s" }, { label: "1m", amount: 1, unit: "m" }, { label: "2m", amount: 2, unit: "m" }, @@ -781,22 +785,22 @@ export default function BoardGroupDetailPage() { {HEARTBEAT_PRESETS.map((preset) => { const value = `${preset.amount}${preset.unit}`; return ( - + ); })} diff --git a/frontend/src/auth/clerkKey.ts b/frontend/src/auth/clerkKey.ts index 3e361862..3c0ed17c 100644 --- a/frontend/src/auth/clerkKey.ts +++ b/frontend/src/auth/clerkKey.ts @@ -3,7 +3,9 @@ // IMPORTANT: keep this file dependency-free (no `"use client"`, no React, no Clerk imports) // so it can be used from both client and server/edge entrypoints. -export function isLikelyValidClerkPublishableKey(key: string | undefined): key is string { +export function isLikelyValidClerkPublishableKey( + key: string | undefined, +): key is string { if (!key) return false; // Clerk publishable keys look like: pk_test_... or pk_live_... diff --git a/frontend/src/proxy.ts b/frontend/src/proxy.ts index 429d9ae8..c147ef4a 100644 --- a/frontend/src/proxy.ts +++ b/frontend/src/proxy.ts @@ -4,7 +4,9 @@ import { clerkMiddleware } from "@clerk/nextjs/server"; import { isLikelyValidClerkPublishableKey } from "@/auth/clerkKey"; const isClerkEnabled = () => - isLikelyValidClerkPublishableKey(process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY); + isLikelyValidClerkPublishableKey( + process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, + ); export default isClerkEnabled() ? clerkMiddleware() : () => NextResponse.next(); From 460d4adddfd9d5b376c6108f55ca4831e103f787 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Sun, 8 Feb 2026 00:46:10 +0530 Subject: [PATCH 08/12] feat: add souls directory integration with search and fetch functionality --- backend/app/api/agent.py | 109 +++++++++++++++++++- backend/app/api/souls_directory.py | 74 ++++++++++++++ backend/app/main.py | 2 + backend/app/schemas/__init__.py | 8 ++ backend/app/schemas/souls_directory.py | 21 ++++ backend/app/services/souls_directory.py | 129 ++++++++++++++++++++++++ backend/tests/test_souls_directory.py | 29 ++++++ templates/HEARTBEAT_LEAD.md | 25 +++++ 8 files changed, 395 insertions(+), 2 deletions(-) create mode 100644 backend/app/api/souls_directory.py create mode 100644 backend/app/schemas/souls_directory.py create mode 100644 backend/app/services/souls_directory.py create mode 100644 backend/tests/test_souls_directory.py diff --git a/backend/app/api/agent.py b/backend/app/api/agent.py index 0ea12bab..1c4ed53c 100644 --- a/backend/app/api/agent.py +++ b/backend/app/api/agent.py @@ -1,11 +1,12 @@ from __future__ import annotations +import re from collections.abc import Sequence from typing import Any, cast from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, status -from sqlmodel import col, select +from sqlmodel import SQLModel, col, select from sqlmodel.ext.asyncio.session import AsyncSession from app.api import agents as agents_api @@ -19,7 +20,7 @@ from app.core.config import settings from app.db.pagination import paginate from app.db.session import get_session from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig -from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message +from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, openclaw_call, send_message from app.models.activity_events import ActivityEvent from app.models.agents import Agent from app.models.approvals import Approval @@ -62,6 +63,26 @@ from app.services.task_dependencies import ( router = APIRouter(prefix="/agent", tags=["agent"]) +_AGENT_SESSION_PREFIX = "agent:" + + +def _gateway_agent_id(agent: Agent) -> str: + session_key = agent.openclaw_session_id or "" + if session_key.startswith(_AGENT_SESSION_PREFIX): + parts = session_key.split(":") + if len(parts) >= 2 and parts[1]: + return parts[1] + # Fall back to a stable slug derived from name (matches provisioning behavior). + value = agent.name.lower().strip() + value = re.sub(r"[^a-z0-9]+", "-", value).strip("-") + return value or str(agent.id) + + +class SoulUpdateRequest(SQLModel): + content: str + source_url: str | None = None + reason: str | None = None + def _actor(agent_ctx: AgentAuthContext) -> ActorContext: return ActorContext(actor_type="agent", agent=agent_ctx.agent) @@ -492,6 +513,90 @@ async def agent_heartbeat( ) +@router.get("/boards/{board_id}/agents/{agent_id}/soul", response_model=str) +async def get_agent_soul( + agent_id: str, + board: Board = Depends(get_board_or_404), + session: AsyncSession = Depends(get_session), + agent_ctx: AgentAuthContext = Depends(get_agent_auth_context), +) -> str: + _guard_board_access(agent_ctx, board) + if not agent_ctx.agent.is_board_lead and str(agent_ctx.agent.id) != agent_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + target = await session.get(Agent, agent_id) + if target is None or (target.board_id and target.board_id != board.id): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + config = await _gateway_config(session, board) + gateway_id = _gateway_agent_id(target) + try: + payload = await openclaw_call( + "agents.files.get", + {"agentId": gateway_id, "name": "SOUL.md"}, + config=config, + ) + except OpenClawGatewayError as exc: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc + if isinstance(payload, str): + return payload + if isinstance(payload, dict): + content = payload.get("content") + if isinstance(content, str): + return content + file_obj = payload.get("file") + if isinstance(file_obj, dict): + nested = file_obj.get("content") + if isinstance(nested, str): + return nested + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Invalid gateway response") + + +@router.put("/boards/{board_id}/agents/{agent_id}/soul", response_model=OkResponse) +async def update_agent_soul( + agent_id: str, + payload: SoulUpdateRequest, + board: Board = Depends(get_board_or_404), + session: AsyncSession = Depends(get_session), + agent_ctx: AgentAuthContext = Depends(get_agent_auth_context), +) -> OkResponse: + _guard_board_access(agent_ctx, board) + if not agent_ctx.agent.is_board_lead: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + target = await session.get(Agent, agent_id) + if target is None or (target.board_id and target.board_id != board.id): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + config = await _gateway_config(session, board) + gateway_id = _gateway_agent_id(target) + content = payload.content.strip() + if not content: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="content is required", + ) + try: + await openclaw_call( + "agents.files.set", + {"agentId": gateway_id, "name": "SOUL.md", "content": content}, + config=config, + ) + except OpenClawGatewayError as exc: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc + reason = (payload.reason or "").strip() + source_url = (payload.source_url or "").strip() + note = f"SOUL.md updated for {target.name}." + if reason: + note = f"{note} Reason: {reason}" + if source_url: + note = f"{note} Source: {source_url}" + record_activity( + session, + event_type="agent.soul.updated", + message=note, + agent_id=agent_ctx.agent.id, + ) + await session.commit() + return OkResponse() + + @router.post( "/boards/{board_id}/gateway/main/ask-user", response_model=GatewayMainAskUserResponse, diff --git a/backend/app/api/souls_directory.py b/backend/app/api/souls_directory.py new file mode 100644 index 00000000..c66ce8de --- /dev/null +++ b/backend/app/api/souls_directory.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import re + +from fastapi import APIRouter, Depends, HTTPException, Query, status + +from app.api.deps import ActorContext, require_admin_or_agent +from app.schemas.souls_directory import ( + SoulsDirectoryMarkdownResponse, + SoulsDirectorySearchResponse, + SoulsDirectorySoulRef, +) +from app.services import souls_directory + +router = APIRouter(prefix="/souls-directory", tags=["souls-directory"]) + +_SAFE_SEGMENT_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$") +_SAFE_SLUG_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$") + + +def _validate_segment(value: str, *, field: str) -> str: + cleaned = value.strip().strip("/") + if not cleaned: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"{field} is required", + ) + if field == "handle": + ok = bool(_SAFE_SEGMENT_RE.match(cleaned)) + else: + ok = bool(_SAFE_SLUG_RE.match(cleaned)) + if not ok: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"{field} contains unsupported characters", + ) + return cleaned + + +@router.get("/search", response_model=SoulsDirectorySearchResponse) +async def search( + q: str = Query(default="", min_length=0), + limit: int = Query(default=20, ge=1, le=100), + _actor: ActorContext = Depends(require_admin_or_agent), +) -> SoulsDirectorySearchResponse: + refs = await souls_directory.list_souls_directory_refs() + matches = souls_directory.search_souls(refs, query=q, limit=limit) + items = [ + SoulsDirectorySoulRef( + handle=ref.handle, + slug=ref.slug, + page_url=ref.page_url, + raw_md_url=ref.raw_md_url, + ) + for ref in matches + ] + return SoulsDirectorySearchResponse(items=items) + + +@router.get("/{handle}/{slug}.md", response_model=SoulsDirectoryMarkdownResponse) +@router.get("/{handle}/{slug}", response_model=SoulsDirectoryMarkdownResponse) +async def get_markdown( + handle: str, + slug: str, + _actor: ActorContext = Depends(require_admin_or_agent), +) -> SoulsDirectoryMarkdownResponse: + safe_handle = _validate_segment(handle, field="handle") + safe_slug = _validate_segment(slug.removesuffix(".md"), field="slug") + try: + content = await souls_directory.fetch_soul_markdown(handle=safe_handle, slug=safe_slug) + except Exception as exc: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc + return SoulsDirectoryMarkdownResponse(handle=safe_handle, slug=safe_slug, content=content) + diff --git a/backend/app/main.py b/backend/app/main.py index 22723710..5808801a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -20,6 +20,7 @@ from app.api.boards import router as boards_router from app.api.gateway import router as gateway_router from app.api.gateways import router as gateways_router from app.api.metrics import router as metrics_router +from app.api.souls_directory import router as souls_directory_router from app.api.tasks import router as tasks_router from app.api.users import router as users_router from app.core.config import settings @@ -74,6 +75,7 @@ api_v1.include_router(activity_router) api_v1.include_router(gateway_router) api_v1.include_router(gateways_router) api_v1.include_router(metrics_router) +api_v1.include_router(souls_directory_router) api_v1.include_router(board_groups_router) api_v1.include_router(board_group_memory_router) api_v1.include_router(boards_router) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index c5fd12a0..06687fe6 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -12,6 +12,11 @@ from app.schemas.board_onboarding import ( from app.schemas.boards import BoardCreate, BoardRead, BoardUpdate from app.schemas.gateways import GatewayCreate, GatewayRead, GatewayUpdate from app.schemas.metrics import DashboardMetrics +from app.schemas.souls_directory import ( + SoulsDirectoryMarkdownResponse, + SoulsDirectorySearchResponse, + SoulsDirectorySoulRef, +) from app.schemas.tasks import TaskCreate, TaskRead, TaskUpdate from app.schemas.users import UserCreate, UserRead, UserUpdate @@ -38,6 +43,9 @@ __all__ = [ "GatewayRead", "GatewayUpdate", "DashboardMetrics", + "SoulsDirectoryMarkdownResponse", + "SoulsDirectorySearchResponse", + "SoulsDirectorySoulRef", "TaskCreate", "TaskRead", "TaskUpdate", diff --git a/backend/app/schemas/souls_directory.py b/backend/app/schemas/souls_directory.py new file mode 100644 index 00000000..1902e739 --- /dev/null +++ b/backend/app/schemas/souls_directory.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from pydantic import BaseModel + + +class SoulsDirectorySoulRef(BaseModel): + handle: str + slug: str + page_url: str + raw_md_url: str + + +class SoulsDirectorySearchResponse(BaseModel): + items: list[SoulsDirectorySoulRef] + + +class SoulsDirectoryMarkdownResponse(BaseModel): + handle: str + slug: str + content: str + diff --git a/backend/app/services/souls_directory.py b/backend/app/services/souls_directory.py new file mode 100644 index 00000000..190c8d73 --- /dev/null +++ b/backend/app/services/souls_directory.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import time +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from typing import Final + +import httpx + +SOULS_DIRECTORY_BASE_URL: Final[str] = "https://souls.directory" +SOULS_DIRECTORY_SITEMAP_URL: Final[str] = f"{SOULS_DIRECTORY_BASE_URL}/sitemap.xml" + +_SITEMAP_TTL_SECONDS: Final[int] = 60 * 60 + + +@dataclass(frozen=True, slots=True) +class SoulRef: + handle: str + slug: str + + @property + def page_url(self) -> str: + return f"{SOULS_DIRECTORY_BASE_URL}/souls/{self.handle}/{self.slug}" + + @property + def raw_md_url(self) -> str: + return f"{SOULS_DIRECTORY_BASE_URL}/api/souls/{self.handle}/{self.slug}.md" + + +def _parse_sitemap_soul_refs(sitemap_xml: str) -> list[SoulRef]: + try: + root = ET.fromstring(sitemap_xml) + except ET.ParseError: + return [] + + # Handle both namespaced and non-namespaced sitemap XML. + urls: list[str] = [] + for loc in root.iter(): + if loc.tag.endswith("loc") and loc.text: + urls.append(loc.text.strip()) + + refs: list[SoulRef] = [] + for url in urls: + if not url.startswith(f"{SOULS_DIRECTORY_BASE_URL}/souls/"): + continue + # Expected: https://souls.directory/souls/{handle}/{slug} + parts = url.split("/") + if len(parts) < 6: + continue + handle = parts[4].strip() + slug = parts[5].strip() + if not handle or not slug: + continue + refs.append(SoulRef(handle=handle, slug=slug)) + return refs + + +_sitemap_cache: dict[str, object] = { + "loaded_at": 0.0, + "refs": [], +} + + +async def list_souls_directory_refs(*, client: httpx.AsyncClient | None = None) -> list[SoulRef]: + now = time.time() + loaded_raw = _sitemap_cache.get("loaded_at") + loaded_at = loaded_raw if isinstance(loaded_raw, (int, float)) else 0.0 + cached = _sitemap_cache.get("refs") + if cached and isinstance(cached, list) and now - loaded_at < _SITEMAP_TTL_SECONDS: + return cached + + owns_client = client is None + if client is None: + client = httpx.AsyncClient( + timeout=httpx.Timeout(10.0, connect=5.0), + headers={"User-Agent": "openclaw-mission-control/1.0"}, + ) + try: + resp = await client.get(SOULS_DIRECTORY_SITEMAP_URL) + resp.raise_for_status() + refs = _parse_sitemap_soul_refs(resp.text) + _sitemap_cache["loaded_at"] = now + _sitemap_cache["refs"] = refs + return refs + finally: + if owns_client: + await client.aclose() + + +async def fetch_soul_markdown( + *, + handle: str, + slug: str, + client: httpx.AsyncClient | None = None, +) -> str: + normalized_handle = handle.strip().strip("/") + normalized_slug = slug.strip().strip("/") + if normalized_slug.endswith(".md"): + normalized_slug = normalized_slug[: -len(".md")] + url = f"{SOULS_DIRECTORY_BASE_URL}/api/souls/{normalized_handle}/{normalized_slug}.md" + + owns_client = client is None + if client is None: + client = httpx.AsyncClient( + timeout=httpx.Timeout(15.0, connect=5.0), + headers={"User-Agent": "openclaw-mission-control/1.0"}, + ) + try: + resp = await client.get(url) + resp.raise_for_status() + return resp.text + finally: + if owns_client: + await client.aclose() + + +def search_souls(refs: list[SoulRef], *, query: str, limit: int = 20) -> list[SoulRef]: + q = query.strip().lower() + if not q: + return refs[: max(0, min(limit, len(refs)))] + + matches: list[SoulRef] = [] + for ref in refs: + hay = f"{ref.handle}/{ref.slug}".lower() + if q in hay: + matches.append(ref) + if len(matches) >= limit: + break + return matches diff --git a/backend/tests/test_souls_directory.py b/backend/tests/test_souls_directory.py new file mode 100644 index 00000000..cb427285 --- /dev/null +++ b/backend/tests/test_souls_directory.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from app.services.souls_directory import SoulRef, _parse_sitemap_soul_refs, search_souls + + +def test_parse_sitemap_extracts_soul_refs() -> None: + xml = """ + + https://souls.directory + https://souls.directory/souls/thedaviddias/code-reviewer + https://souls.directory/souls/someone/technical-writer + +""" + refs = _parse_sitemap_soul_refs(xml) + assert refs == [ + SoulRef(handle="thedaviddias", slug="code-reviewer"), + SoulRef(handle="someone", slug="technical-writer"), + ] + + +def test_search_souls_matches_handle_or_slug() -> None: + refs = [ + SoulRef(handle="thedaviddias", slug="code-reviewer"), + SoulRef(handle="thedaviddias", slug="technical-writer"), + SoulRef(handle="someone", slug="pirate-captain"), + ] + assert search_souls(refs, query="writer", limit=20) == [refs[1]] + assert search_souls(refs, query="thedaviddias", limit=20) == [refs[0], refs[1]] + diff --git a/templates/HEARTBEAT_LEAD.md b/templates/HEARTBEAT_LEAD.md index f52f1ce3..bb9da903 100644 --- a/templates/HEARTBEAT_LEAD.md +++ b/templates/HEARTBEAT_LEAD.md @@ -297,6 +297,31 @@ Body: {"depends_on_task_ids":["DEP_TASK_ID_1","DEP_TASK_ID_2"]} 9) Post a brief status update in board memory (1-3 bullets). +## Soul Inspiration (Optional) + +Sometimes it's useful to improve your `SOUL.md` (or an agent's `SOUL.md`) to better match the work, constraints, and desired collaboration style. + +Rules: +- Use external SOUL templates (e.g. souls.directory) as inspiration only. Do not copy-paste large sections verbatim. +- Prefer small, reversible edits. Keep `SOUL.md` stable; put fast-evolving preferences in `SELF.md`. +- When proposing a change, include: + - The source page URL(s) you looked at. + - A short summary of the principles you are borrowing. + - A minimal diff-like description of what would change. + - A rollback note (how to revert). +- Do not apply changes silently. Create a board approval first if the change is non-trivial. + +Tools: +- Search souls directory: + GET $BASE_URL/api/v1/souls-directory/search?q=&limit=10 +- Fetch a soul markdown: + GET $BASE_URL/api/v1/souls-directory// +- Read an agent's current SOUL.md (lead-only for other agents; self allowed): + GET $BASE_URL/api/v1/agent/boards/$BOARD_ID/agents//soul +- Update an agent's SOUL.md (lead-only): + PUT $BASE_URL/api/v1/agent/boards/$BOARD_ID/agents//soul + Body: {"content":"","source_url":"","reason":""} + ## Memory Maintenance (every 2-3 days) Lightweight consolidation (modeled on human "sleep consolidation"): 1) Read recent `memory/YYYY-MM-DD.md` files (since last consolidation, or last 2-3 days). From da1559fc981c0631732eddf7ac00276d005c3995 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Sun, 8 Feb 2026 00:47:46 +0530 Subject: [PATCH 09/12] feat: persist agent's soul_template to prevent overwriting on reprovision --- backend/app/api/agent.py | 7 +++++++ templates/HEARTBEAT_LEAD.md | 1 + 2 files changed, 8 insertions(+) diff --git a/backend/app/api/agent.py b/backend/app/api/agent.py index 1c4ed53c..88921a01 100644 --- a/backend/app/api/agent.py +++ b/backend/app/api/agent.py @@ -17,6 +17,7 @@ from app.api import tasks as tasks_api from app.api.deps import ActorContext, get_board_or_404, get_task_or_404 from app.core.agent_auth import AgentAuthContext, get_agent_auth_context from app.core.config import settings +from app.core.time import utcnow from app.db.pagination import paginate from app.db.session import get_session from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig @@ -572,6 +573,12 @@ async def update_agent_soul( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="content is required", ) + + # Persist the SOUL in the DB so future reprovision/update doesn't overwrite it. + target.soul_template = content + target.updated_at = utcnow() + session.add(target) + await session.commit() try: await openclaw_call( "agents.files.set", diff --git a/templates/HEARTBEAT_LEAD.md b/templates/HEARTBEAT_LEAD.md index bb9da903..95f56d8b 100644 --- a/templates/HEARTBEAT_LEAD.md +++ b/templates/HEARTBEAT_LEAD.md @@ -321,6 +321,7 @@ Tools: - Update an agent's SOUL.md (lead-only): PUT $BASE_URL/api/v1/agent/boards/$BOARD_ID/agents//soul Body: {"content":"","source_url":"","reason":""} + Notes: this persists as the agent's `soul_template` so future reprovision won't overwrite it. ## Memory Maintenance (every 2-3 days) Lightweight consolidation (modeled on human "sleep consolidation"): From 1ccc4c478f70c8337549b056b312556cd5a2a514 Mon Sep 17 00:00:00 2001 From: "Arjun (OpenClaw)" Date: Sat, 7 Feb 2026 19:19:40 +0000 Subject: [PATCH 10/12] fix: flake8 trailing blank lines Remove trailing blank line at EOF to satisfy flake8 W391. --- backend/app/api/souls_directory.py | 1 - backend/app/schemas/souls_directory.py | 1 - backend/tests/test_souls_directory.py | 1 - 3 files changed, 3 deletions(-) diff --git a/backend/app/api/souls_directory.py b/backend/app/api/souls_directory.py index c66ce8de..73565a96 100644 --- a/backend/app/api/souls_directory.py +++ b/backend/app/api/souls_directory.py @@ -71,4 +71,3 @@ async def get_markdown( except Exception as exc: raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc return SoulsDirectoryMarkdownResponse(handle=safe_handle, slug=safe_slug, content=content) - diff --git a/backend/app/schemas/souls_directory.py b/backend/app/schemas/souls_directory.py index 1902e739..aff2192b 100644 --- a/backend/app/schemas/souls_directory.py +++ b/backend/app/schemas/souls_directory.py @@ -18,4 +18,3 @@ class SoulsDirectoryMarkdownResponse(BaseModel): handle: str slug: str content: str - diff --git a/backend/tests/test_souls_directory.py b/backend/tests/test_souls_directory.py index cb427285..f318a7be 100644 --- a/backend/tests/test_souls_directory.py +++ b/backend/tests/test_souls_directory.py @@ -26,4 +26,3 @@ def test_search_souls_matches_handle_or_slug() -> None: ] assert search_souls(refs, query="writer", limit=20) == [refs[1]] assert search_souls(refs, query="thedaviddias", limit=20) == [refs[0], refs[1]] - From 1d0c11b69c767bfadb68fb9ffd983984e350dc81 Mon Sep 17 00:00:00 2001 From: abhi1693 Date: Sat, 7 Feb 2026 19:20:04 +0000 Subject: [PATCH 11/12] lint: fix W391 trailing blank line --- backend/app/api/souls_directory.py | 1 - backend/app/schemas/souls_directory.py | 1 - backend/tests/test_souls_directory.py | 1 - 3 files changed, 3 deletions(-) diff --git a/backend/app/api/souls_directory.py b/backend/app/api/souls_directory.py index c66ce8de..73565a96 100644 --- a/backend/app/api/souls_directory.py +++ b/backend/app/api/souls_directory.py @@ -71,4 +71,3 @@ async def get_markdown( except Exception as exc: raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc return SoulsDirectoryMarkdownResponse(handle=safe_handle, slug=safe_slug, content=content) - diff --git a/backend/app/schemas/souls_directory.py b/backend/app/schemas/souls_directory.py index 1902e739..aff2192b 100644 --- a/backend/app/schemas/souls_directory.py +++ b/backend/app/schemas/souls_directory.py @@ -18,4 +18,3 @@ class SoulsDirectoryMarkdownResponse(BaseModel): handle: str slug: str content: str - diff --git a/backend/tests/test_souls_directory.py b/backend/tests/test_souls_directory.py index cb427285..f318a7be 100644 --- a/backend/tests/test_souls_directory.py +++ b/backend/tests/test_souls_directory.py @@ -26,4 +26,3 @@ def test_search_souls_matches_handle_or_slug() -> None: ] assert search_souls(refs, query="writer", limit=20) == [refs[1]] assert search_souls(refs, query="thedaviddias", limit=20) == [refs[0], refs[1]] - From e8600420b286b07e8cdc10549ce47efc1b4d8fc6 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Sun, 8 Feb 2026 00:51:57 +0530 Subject: [PATCH 12/12] refactor: clean up imports and remove trailing whitespace in multiple files --- backend/app/api/agent.py | 7 ++++++- backend/app/api/souls_directory.py | 1 - backend/app/schemas/souls_directory.py | 1 - backend/tests/test_souls_directory.py | 1 - 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/backend/app/api/agent.py b/backend/app/api/agent.py index 88921a01..5c6d5b09 100644 --- a/backend/app/api/agent.py +++ b/backend/app/api/agent.py @@ -21,7 +21,12 @@ from app.core.time import utcnow from app.db.pagination import paginate from app.db.session import get_session from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig -from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, openclaw_call, send_message +from app.integrations.openclaw_gateway import ( + OpenClawGatewayError, + ensure_session, + openclaw_call, + send_message, +) from app.models.activity_events import ActivityEvent from app.models.agents import Agent from app.models.approvals import Approval diff --git a/backend/app/api/souls_directory.py b/backend/app/api/souls_directory.py index c66ce8de..73565a96 100644 --- a/backend/app/api/souls_directory.py +++ b/backend/app/api/souls_directory.py @@ -71,4 +71,3 @@ async def get_markdown( except Exception as exc: raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc return SoulsDirectoryMarkdownResponse(handle=safe_handle, slug=safe_slug, content=content) - diff --git a/backend/app/schemas/souls_directory.py b/backend/app/schemas/souls_directory.py index 1902e739..aff2192b 100644 --- a/backend/app/schemas/souls_directory.py +++ b/backend/app/schemas/souls_directory.py @@ -18,4 +18,3 @@ class SoulsDirectoryMarkdownResponse(BaseModel): handle: str slug: str content: str - diff --git a/backend/tests/test_souls_directory.py b/backend/tests/test_souls_directory.py index cb427285..f318a7be 100644 --- a/backend/tests/test_souls_directory.py +++ b/backend/tests/test_souls_directory.py @@ -26,4 +26,3 @@ def test_search_souls_matches_handle_or_slug() -> None: ] assert search_souls(refs, query="writer", limit=20) == [refs[1]] assert search_souls(refs, query="thedaviddias", limit=20) == [refs[0], refs[1]] -