Skill: grantgochnauer/capacities-lookup (version 1.0.0)

--- name: capacities-lookup description: Search Capacities for likely object matches and return direct capacities:// links. Use when the user wants to find, open, or locate a Capacities note, meeting, project, person, reference, page, or other object by title/search term, or when a request sounds like it may refer to something stored in Capacities. Supports type-aware lookup using cached Capacities structures metadata, related-term expansion, and honest fallback messaging when a requested object type is not found. metadata: {"openclaw":{"homepage":"https://github.com/GrantGochnauer/OpenClaw-Capacities","requires":{"bins":["python3"],"env":["CAPACITIES_API_TOKEN"]},"primaryEnv":"CAPACITIES_API_TOKEN","emoji":"🧠"}} --- # Capacities Lookup Use this skill for **real-time Capacities object lookup**, not full-content retrieval. ## What this skill does - searches Capacities live via the public API - enriches results with object types from cached `/space-info` metadata - builds direct `capacities://` deep links - supports type-aware ranking for phrases like: - `find my notes on X` - `meeting with Y` - `people at Z` - may suggest Capacities proactively when a request sounds like a note/project/meeting/person lookup ## What this skill does NOT do - read full Capacities object bodies - traverse properties/relations on objects - answer org-membership or linked-object questions unless the title lookup itself returns the target object - mirror Capacities locally beyond light metadata cache ## Prerequisites - `CAPACITIES_API_TOKEN` must be available in the shell environment - one of these must define the target space id: 1. `CAPACITIES_SPACE_ID` env var 2. `config/capacities.json` with `mainSpaceId` If the user says the token is in `~/.zshrc`, source it in the same shell command before running the scripts. ## Files Skill scripts live in `scripts/`. Key commands: ```bash python3 skills/capacities-lookup/scripts/capacities_cli.py sync-structures python3 skills/capacities-lookup/scripts/capacities_cli.py verify-space python3 skills/capacities-lookup/scripts/capacities_cli.py lookup "recovery" python3 skills/capacities-lookup/scripts/capacities_cli.py lookup "Find my notes on recovery" --json ``` ## Recommended workflow ### 1) Make sure structure metadata exists If this is the first use or results seem type-blind, run: ```bash source ~/.zshrc >/dev/null 2>&1 || true python3 skills/capacities-lookup/scripts/capacities_cli.py sync-structures ``` ### 2) Run lookup Use natural language when helpful. Examples: ```bash source ~/.zshrc >/dev/null 2>&1 || true python3 skills/capacities-lookup/scripts/capacities_cli.py lookup "Find my notes on recovery" --json ``` ```bash source ~/.zshrc >/dev/null 2>&1 || true python3 skills/capacities-lookup/scripts/capacities_cli.py lookup "people associated with an organization" --json ``` ### 3) Interpret results correctly Prefer in chat: - title - type - match quality - matched-on term when useful - clickable markdown deep link Usually omit raw object ids unless debugging. ## Response rules ### If a strong match exists Return the best match directly with a clickable link. ### If multiple good matches exist Return 2–5 candidates. ### If the requested object type is not found Say so clearly. Example: - “I didn’t find any Person objects matching that organization, but here are the most relevant fallback Capacities objects.” ### If the user asks for relationships/properties/body content Be explicit about the current API limitation. Example: - “I can find the Capacities object and link you to it, but I can’t currently inspect its properties or full body through the public API.” ## Operational notes - This skill intentionally uses **live lookup + light cache**, not full sync. - Structures metadata is cached under workspace `data/capacities/`. - Lookup cache is convenience-only; live lookup remains authoritative. - Type-aware behavior depends on cached structures metadata, so run a structure sync before relying on custom object types.

path: scripts/capacities_cache.py
content: from __future__ import annotations

import json
import os
import tempfile
from pathlib import Path
from typing import Any


def _detect_workspace_root() -> Path:
    env_root = os.environ.get("CAPACITIES_WORKSPACE_ROOT", "").strip()
    if env_root:
        return Path(env_root).expanduser().resolve()

    candidates: list[Path] = []
    cwd = Path.cwd().resolve()
    candidates.extend([cwd, *cwd.parents])

    script_path = Path(__file__).resolve()
    candidates.extend(script_path.parents)

    seen: set[Path] = set()
    for candidate in candidates:
        if candidate in seen:
            continue
        seen.add(candidate)
        if (candidate / "AGENTS.md").exists():
            return candidate
        if (candidate / "config" / "capacities.json").exists():
            return candidate

    return Path(__file__).resolve().parents[3]


WORKSPACE_ROOT = _detect_workspace_root()
DATA_DIR = WORKSPACE_ROOT / "data" / "capacities"
STRUCTURES_PATH = DATA_DIR / "structures.json"
SPACES_PATH = DATA_DIR / "spaces.json"
LOOKUP_CACHE_PATH = DATA_DIR / "lookup-cache.json"
STATE_PATH = DATA_DIR / "state.json"


def ensure_data_dirs() -> None:
    DATA_DIR.mkdir(parents=True, exist_ok=True)


def read_json(path: Path, default: Any) -> Any:
    try:
        with path.open("r", encoding="utf-8") as f:
            return json.load(f)
    except FileNotFoundError:
        return default


def write_json_atomic(path: Path, data: Any) -> None:
    ensure_data_dirs()
    with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as tmp:
        json.dump(data, tmp, indent=2, sort_keys=True)
        tmp.write("\n")
        tmp_path = Path(tmp.name)
    os.replace(tmp_path, path)


def load_structures_cache() -> dict[str, Any]:
    return read_json(STRUCTURES_PATH, {})


def save_structures_cache(data: dict[str, Any]) -> None:
    write_json_atomic(STRUCTURES_PATH, data)


def load_spaces_cache() -> dict[str, Any]:
    return read_json(SPACES_PATH, {})


def save_spaces_cache(data: dict[str, Any]) -> None:
    write_json_atomic(SPACES_PATH, data)


def load_lookup_cache() -> dict[str, Any]:
    return read_json(LOOKUP_CACHE_PATH, {})


def save_lookup_cache(data: dict[str, Any]) -> None:
    write_json_atomic(LOOKUP_CACHE_PATH, data)


def load_state() -> dict[str, Any]:
    return read_json(STATE_PATH, {})


def save_state(state: dict[str, Any]) -> None:
    write_json_atomic(STATE_PATH, state)


path: scripts/capacities_cli.py
content: from __future__ import annotations

import json
import sys
from pathlib import Path

SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPT_DIR))

from capacities_cache import load_structures_cache
from capacities_lookup import lookup_with_metadata
from capacities_sync import sync_structures, verify_main_space


def _print_json(data: object) -> None:
    print(json.dumps(data, indent=2))


def _has_flag(argv: list[str], flag: str) -> bool:
    return flag in argv


def _lookup_search_term(argv: list[str]) -> str:
    terms = [arg for arg in argv[2:] if not arg.startswith("--")]
    return " ".join(terms).strip()


def _print_lookup_human(result: dict[str, object]) -> None:
    print(f"Search: {result.get('searchTerm')}")
    print(f"Space: {result.get('spaceId')}")
    print(f"Results: {result.get('resultCount')}")
    if result.get("requestedTypes"):
        print(f"Requested types: {', '.join(result.get('requestedTypes') or [])}")
        if result.get("requestedTypeFound") is False:
            print("Requested type matches: none")
    if result.get("expandRelated"):
        print(f"Query terms: {', '.join(result.get('queryTerms') or [])}")
    print()

    results = result.get("results", []) or []
    if not results:
        print("No matches found.")
        return

    for i, item in enumerate(results, start=1):
        if not isinstance(item, dict):
            continue
        matched_terms = item.get("allMatchedTerms") or [item.get("matchedOn")]
        matched_on_text = ", ".join([str(x) for x in matched_terms if x])
        print(f"{i}. {item.get('title')}")
        print(f"   Type: {item.get('structureLabel')}")
        print(f"   Match: {item.get('matchKind')} ({item.get('queryRole')})")
        if matched_on_text:
            print(f"   Matched on: {matched_on_text}")
        print(f"   Object ID: {item.get('id')}")
        print(f"   Link: {item.get('deepLink')}")
        print()

    fallback_results = result.get("fallbackResults") or []
    if result.get("requestedTypeFound") is False and fallback_results:
        print("Fallback suggestions:")
        for item in fallback_results:
            if not isinstance(item, dict):
                continue
            print(f"- {item.get('title')} [{item.get('structureLabel')}] -> {item.get('deepLink')}")


def cmd_sync_structures() -> int:
    result = sync_structures()
    _print_json(result)
    return 0


def cmd_verify_space() -> int:
    result = verify_main_space()
    _print_json(result)
    return 0


def cmd_list_structures() -> int:
    data = load_structures_cache()
    structures = data.get("structures", [])
    if not structures:
        print("No structures cache found. Run: sync-structures")
        return 1

    print(f"Space: {data.get('spaceId')}")
    print(f"Synced: {data.get('syncedAt')}")
    print()
    for item in structures:
        print(f"- {item.get('title')} ({item.get('id')})")
    return 0


def cmd_lookup(argv: list[str]) -> int:
    if len(argv) < 3:
        print('Usage: capacities_cli.py lookup "search phrase" [--json] [--include-structures] [--no-related]')
        return 1
    search_term = _lookup_search_term(argv)
    if not search_term:
        print('Usage: capacities_cli.py lookup "search phrase" [--json] [--include-structures] [--no-related]')
        return 1
    as_json = _has_flag(argv, "--json")
    include_structures = _has_flag(argv, "--include-structures")
    expand_related = not _has_flag(argv, "--no-related")
    result = lookup_with_metadata(search_term, include_structures=include_structures, expand_related=expand_related)
    if as_json:
        _print_json(result)
    else:
        _print_lookup_human(result)
    return 0


def main(argv: list[str]) -> int:
    if len(argv) < 2:
        print("Usage: capacities_cli.py <sync-structures|list-structures|verify-space|lookup>")
        return 1

    command = argv[1]
    if command == "sync-structures":
        return cmd_sync_structures()
    if command == "list-structures":
        return cmd_list_structures()
    if command == "verify-space":
        return cmd_verify_space()
    if command == "lookup":
        return cmd_lookup(argv)

    print(f"Unknown command: {command}")
    return 1


if __name__ == "__main__":
    raise SystemExit(main(sys.argv))


path: scripts/capacities_client.py
content: from __future__ import annotations

import json
import os
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any


class CapacitiesError(RuntimeError):
    pass


class CapacitiesConfigError(CapacitiesError):
    pass


class CapacitiesAuthError(CapacitiesError):
    pass


class CapacitiesRateLimitError(CapacitiesError):
    pass


class CapacitiesRequestError(CapacitiesError):
    pass


def _detect_workspace_root() -> Path:
    env_root = os.environ.get("CAPACITIES_WORKSPACE_ROOT", "").strip()
    if env_root:
        return Path(env_root).expanduser().resolve()

    candidates: list[Path] = []
    cwd = Path.cwd().resolve()
    candidates.extend([cwd, *cwd.parents])

    script_path = Path(__file__).resolve()
    candidates.extend(script_path.parents)

    seen: set[Path] = set()
    for candidate in candidates:
        if candidate in seen:
            continue
        seen.add(candidate)
        if (candidate / "AGENTS.md").exists():
            return candidate
        if (candidate / "config" / "capacities.json").exists():
            return candidate

    return Path(__file__).resolve().parents[3]


WORKSPACE_ROOT = _detect_workspace_root()
CONFIG_PATH = WORKSPACE_ROOT / "config" / "capacities.json"


def load_config() -> dict[str, Any]:
    config: dict[str, Any] = {
        "apiBaseUrl": os.environ.get("CAPACITIES_API_BASE_URL", "https://api.capacities.io"),
        "timeoutMs": int(os.environ.get("CAPACITIES_TIMEOUT_MS", "15000")),
        "lookupCacheTtlSeconds": int(os.environ.get("CAPACITIES_LOOKUP_CACHE_TTL_SECONDS", "86400")),
        "verifySpacesOnSync": True,
        "defaultResultLimit": int(os.environ.get("CAPACITIES_DEFAULT_RESULT_LIMIT", "10")),
        "cacheSchemaVersion": 1,
    }

    if CONFIG_PATH.exists():
        with CONFIG_PATH.open("r", encoding="utf-8") as f:
            file_config = json.load(f)
        config.update(file_config)

    env_space_id = os.environ.get("CAPACITIES_SPACE_ID", "").strip()
    if env_space_id:
        config["mainSpaceId"] = env_space_id

    required = ["mainSpaceId", "apiBaseUrl", "timeoutMs"]
    missing = [key for key in required if not config.get(key)]
    if missing:
        raise CapacitiesConfigError(
            "Missing required Capacities config. Set CAPACITIES_SPACE_ID or config/capacities.json with mainSpaceId. "
            f"Missing: {', '.join(missing)}"
        )
    return config


def get_token() -> str:
    [REDACTED_SECRET]"CAPACITIES_API_TOKEN", "").strip()
    if not token:
        raise CapacitiesAuthError("CAPACITIES_API_TOKEN is not set")
    return token


def _read_error_body(exc: urllib.error.HTTPError) -> str:
    try:
        body = exc.read().decode("utf-8", errors="replace")
    except Exception:
        body = ""
    return body[:500]


def request(method: str, path: str, json_body: dict[str, Any] | None = None, retries: int = 2) -> dict[str, Any]:
    config = load_config()
    [REDACTED_SECRET])
    url = config["apiBaseUrl"].rstrip("/") + path
    timeout_seconds = max(int(config["timeoutMs"]) / 1000, 1)

    body_bytes = None
    headers = {
        "Authorization": f"Bearer {token}",
        "Accept": "application/json",
    }
    if json_body is not None:
        body_bytes = json.dumps(json_body).encode("utf-8")
        headers["Content-Type"] = "application/json"

    last_error: Exception | None = None
    for attempt in range(retries + 1):
        req = urllib.request.Request(url, data=body_bytes, headers=headers, method=method.upper())
        try:
            with urllib.request.urlopen(req, timeout=timeout_seconds) as resp:
                raw = resp.read().decode("utf-8", errors="replace")
                if not raw.strip():
                    return {}
                return json.loads(raw)
        except urllib.error.HTTPError as exc:
            body = _read_error_body(exc)
            if exc.code == 401:
                raise CapacitiesAuthError("Capacities API returned 401 Unauthorized") from exc
            if exc.code == 429:
                reset = exc.headers.get("RateLimit-Reset") or exc.headers.get("Retry-After")
                if attempt < retries:
                    sleep_seconds = int(reset) if (reset and str(reset).isdigit()) else (attempt + 1)
                    time.sleep(max(sleep_seconds, 1))
                    continue
                raise CapacitiesRateLimitError(f"Capacities API rate limited request to {path}; reset={reset}") from exc
            if exc.code >= 500 and attempt < retries:
                time.sleep(attempt + 1)
                continue
            raise CapacitiesRequestError(f"Capacities API error {exc.code} for {path}: {body}") from exc
        except urllib.error.URLError as exc:
            last_error = exc
            if attempt < retries:
                time.sleep(attempt + 1)
                continue
            raise CapacitiesRequestError(f"Network error calling Capacities API {path}: {exc}") from exc
        except json.JSONDecodeError as exc:
            raise CapacitiesRequestError(f"Invalid JSON returned by Capacities API for {path}") from exc

    raise CapacitiesRequestError(f"Request failed for {path}: {last_error}")


def get_spaces() -> dict[str, Any]:
    return request("GET", "/spaces")


def get_space_info(space_id: str) -> dict[str, Any]:
    return request("GET", f"/space-info?spaceid={space_id}")


def lookup(space_id: str, search_term: str) -> dict[str, Any]:
    return request("POST", "/lookup", {"spaceId": space_id, "searchTerm": search_term})


path: scripts/capacities_lookup.py
content: from __future__ import annotations

import time
from typing import Any

from capacities_cache import load_lookup_cache, load_structures_cache, save_lookup_cache
from capacities_client import load_config, lookup


RELATED_TERMS: dict[str, list[str]] = {
    "recovery": ["sleep", "hydration", "mobility", "protocol"],
    "protocol": ["recovery", "routine", "plan"],
    "meeting": ["1:1", "sync", "review"],
    "meetings": ["1:1", "sync", "review"],
    "project": ["projects", "roadmap", "plan"],
    "projects": ["project", "roadmap", "plan"],
}

MATCH_ORDER = {"exact": 0, "prefix": 1, "substring": 2, "fallback": 3}
LOOKUP_CACHE_VERSION = 4

INTENT_TYPE_PREFERENCES: dict[str, list[str]] = {
    "note": ["note", "reference", "page", "knowledge", "pdf", "file"],
    "meeting": ["meeting", "note", "reference"],
    "project": ["project", "page", "knowledge", "note", "reference"],
    "person": ["person"],
}

GENERIC_TYPE_ALIASES: dict[str, list[str]] = {
    "people": ["person"],
    "person": ["person"],
    "notes": ["note"],
    "note": ["note"],
    "meetings": ["meeting"],
    "meeting": ["meeting"],
    "projects": ["project"],
    "project": ["project"],
    "references": ["reference"],
    "reference": ["reference"],
    "pages": ["page"],
    "page": ["page"],
    "tags": ["tag"],
    "tag": ["tag"],
    "tasks": ["task"],
    "task": ["task"],
    "books": ["book"],
    "book": ["book"],
    "organizations": ["organization"],
    "organization": ["organization"],
    "emails": ["email"],
    "email": ["email"],
    "courses": ["course"],
    "course": ["course"],
    "recipes": ["recipe"],
    "recipe": ["recipe"],
}

STOPWORDS = {
    "find", "my", "on", "about", "for", "that", "the", "a", "an", "do", "does", "is",
    "are", "part", "of", "at", "in", "with", "who", "what", "give", "me", "show", "all",
    "any", "related", "from", "to", "named", "called",
}


def build_deep_link(space_id: str, object_id: str) -> str:
    return f"capacities://{space_id}/{object_id}"


def _normalize_text(value: str) -> str:
    return " ".join((value or "").strip().lower().split())


def _match_kind(search_term: str, title: str) -> str:
    s = _normalize_text(search_term)
    t = _normalize_text(title)
    if t == s:
        return "exact"
    if t.startswith(s) and s:
        return "prefix"
    if s and s in t:
        return "substring"
    return "fallback"


def _query_role(term: str, primary_term: str) -> str:
    return "primary" if _normalize_text(term) == _normalize_text(primary_term) else "related"


def _structure_penalty(structure_id: str | None) -> int:
    if structure_id == "RootSpace":
        return 3
    if structure_id == "RootStructure":
        return 2
    if structure_id == "RootTag":
        return 1
    return 0


def _baseline_type_penalty(structure_label: str | None) -> int:
    label = (structure_label or "").lower()
    if label in {"note", "reference", "meeting", "project", "page", "knowledge", "person", "pdf", "file", "organization", "email", "book", "task", "course", "recipe"}:
        return 0
    if label in {"tag", "query"}:
        return 2
    return 1


def _detect_intent(search_term: str) -> str | None:
    s = _normalize_text(search_term)
    if any(phrase in s for phrase in ["notes on", "note on", "my notes on", "find my notes", "find my note", "notes about", "note about"]):
        return "note"
    if any(phrase in s for phrase in ["meeting with", "meeting note", "meetings about", "1:1 with"]):
        return "meeting"
    if any(phrase in s for phrase in ["project", "projects", "roadmap"]):
        return "project"
    if any(phrase in s for phrase in ["people", "person", "who is", "who are"]):
        return "person"
    return None


def _extract_core_query(search_term: str) -> str:
    s = _normalize_text(search_term)
    prefixes = [
        "find my notes on ", "find my note on ", "my notes on ", "my note on ", "notes on ", "note on ",
        "find my notes about ", "find my note about ", "notes about ", "note about ",
        "find my notes for ", "find my note for ", "notes for ", "note for ",
        "find people at ", "find people in ", "find people from ", "find people that are part of ",
        "people at ", "people in ", "people from ", "people that are part of ",
        "who is at ", "who is in ", "who are at ", "who are in ",
    ]
    for prefix in prefixes:
        if s.startswith(prefix):
            return s[len(prefix):].strip()
    return search_term.strip()


def _build_type_phrase_map(structures_by_id: dict[str, Any]) -> dict[str, list[str]]:
    mapping: dict[str, list[str]] = {}
    for structure in structures_by_id.values():
        title = _normalize_text(structure.get("title") or "")
        plural = _normalize_text(structure.get("pluralName") or "")
        for phrase in [title, plural]:
            if not phrase:
                continue
            mapping.setdefault(phrase, [])
            if title and title not in mapping[phrase]:
                mapping[phrase].append(title)
    for alias, labels in GENERIC_TYPE_ALIASES.items():
        mapping.setdefault(alias, [])
        for label in labels:
            if label not in mapping[alias]:
                mapping[alias].append(label)
    return mapping


def _detect_requested_types(search_term: str, structures_by_id: dict[str, Any]) -> list[str]:
    normalized = _normalize_text(search_term)
    phrase_map = _build_type_phrase_map(structures_by_id)
    requested: list[str] = []
    for phrase in sorted(phrase_map.keys(), key=len, reverse=True):
        if not phrase:
            continue
        if phrase in normalized:
            for label in phrase_map[phrase]:
                if label not in requested:
                    requested.append(label)
    return requested


def _strip_requested_type_phrases(search_term: str, structures_by_id: dict[str, Any]) -> str:
    normalized = _normalize_text(search_term)
    phrase_map = _build_type_phrase_map(structures_by_id)
    for phrase in sorted(phrase_map.keys(), key=len, reverse=True):
        if not phrase:
            continue
        normalized = normalized.replace(phrase, " ")
    tokens = [t for t in normalized.split() if t not in STOPWORDS]
    return " ".join(tokens).strip()


def _type_penalty(structure_label: str | None, intent: str | None, requested_types: list[str]) -> int:
    label = (structure_label or "").lower()
    if requested_types:
        if label in requested_types:
            return requested_types.index(label)
        if label in {"tag", "query"}:
            return len(requested_types) + 4
        return len(requested_types) + 2
    if intent:
        preferred = INTENT_TYPE_PREFERENCES.get(intent, [])
        if label in preferred:
            return preferred.index(label)
        if label in {"tag", "query"}:
            return len(preferred) + 3
        return len(preferred) + 1
    return _baseline_type_penalty(structure_label)


def _expansion_terms(search_term: str) -> list[str]:
    normalized = _normalize_text(search_term)
    terms: list[str] = [search_term]

    if normalized in RELATED_TERMS:
        terms.extend(RELATED_TERMS[normalized])

    for token in normalized.split():
        if token in RELATED_TERMS:
            for term in RELATED_TERMS[token]:
                if term not in terms:
                    terms.append(term)

    seen: set[str] = set()
    unique_terms: list[str] = []
    for term in terms:
        key = _normalize_text(term)
        if not key or key in seen:
            continue
        seen.add(key)
        unique_terms.append(term)
    return unique_terms


def normalize_lookup_results(
    raw_results: list[dict[str, Any]],
    structures_by_id: dict[str, Any],
    space_id: str,
    matched_on: str,
    primary_search_term: str,
    intent: str | None,
    requested_types: list[str],
) -> list[dict[str, Any]]:
    results: list[dict[str, Any]] = []
    for item in raw_results:
        structure_id = item.get("structureId")
        structure = structures_by_id.get(structure_id, {})
        structure_label = _normalize_text(structure.get("title") or structure_id or "unknown")
        object_id = item.get("id")
        title = item.get("title")
        results.append(
            {
                "id": object_id,
                "title": title,
                "structureId": structure_id,
                "structureLabel": structure.get("title") or structure_id or "Unknown",
                "deepLink": build_deep_link(space_id, object_id),
                "matchedOn": matched_on,
                "queryRole": _query_role(matched_on, primary_search_term),
                "matchKind": _match_kind(matched_on, title or ""),
                "intent": intent,
                "requestedTypes": requested_types,
                "structurePenalty": _structure_penalty(structure_id),
                "preferredTypePenalty": _type_penalty(structure_label, intent, requested_types),
            }
        )
    return results


def _ranking_key(item: dict[str, Any]) -> tuple[Any, ...]:
    return (
        item.get("preferredTypePenalty", 0),
        0 if item.get("queryRole") == "primary" else 1,
        MATCH_ORDER.get(item.get("matchKind"), 99),
        item.get("structurePenalty", 0),
        len(item.get("title") or ""),
        (item.get("title") or "").lower(),
    )


def rank_lookup_results(results: list[dict[str, Any]]) -> list[dict[str, Any]]:
    deduped: dict[str, dict[str, Any]] = {}
    for item in results:
        object_id = item.get("id")
        if not object_id:
            continue
        existing = deduped.get(object_id)
        if existing is None or _ranking_key(item) < _ranking_key(existing):
            deduped[object_id] = item
        else:
            matched_terms = set(existing.get("allMatchedTerms", [existing.get("matchedOn")]))
            if item.get("matchedOn"):
                matched_terms.add(item.get("matchedOn"))
            existing["allMatchedTerms"] = sorted(matched_terms)

    ranked = list(deduped.values())
    for item in ranked:
        if "allMatchedTerms" not in item:
            item["allMatchedTerms"] = [item.get("matchedOn")]
    ranked.sort(key=_ranking_key)
    return ranked


def _cache_key(space_id: str, search_term: str, include_structures: bool, expand_related: bool) -> str:
    return f"v={LOOKUP_CACHE_VERSION}::{space_id}::{_normalize_text(search_term)}::include_structures={include_structures}::expand_related={expand_related}"


def lookup_with_metadata(
    search_term: str,
    use_cache: bool = True,
    include_structures: bool = False,
    expand_related: bool = True,
) -> dict[str, Any]:
    config = load_config()
    space_id = config["mainSpaceId"]
    cache_ttl = int(config.get("lookupCacheTtlSeconds", 86400))
    cache = load_lookup_cache()
    key = _cache_key(space_id, search_term, include_structures, expand_related)
    now = int(time.time())

    if use_cache:
        entry = cache.get(key)
        if entry and int(entry.get("expiresAt", 0)) > now:
            return entry["value"]

    structures_cache = load_structures_cache()
    structures_by_id = structures_cache.get("structuresById", {})

    intent = _detect_intent(search_term)
    extracted_core = _extract_core_query(search_term)
    requested_types = _detect_requested_types(search_term, structures_by_id)
    stripped_core = _strip_requested_type_phrases(extracted_core, structures_by_id)
    core_query = stripped_core or extracted_core or search_term.strip()

    query_terms = [core_query]
    if expand_related:
        query_terms = _expansion_terms(core_query)

    combined_results: list[dict[str, Any]] = []
    for term in query_terms:
        raw = lookup(space_id, term)
        combined_results.extend(
            normalize_lookup_results(
                raw.get("results", []),
                structures_by_id,
                space_id,
                matched_on=term,
                primary_search_term=core_query,
                intent=intent,
                requested_types=requested_types,
            )
        )

    ranked = rank_lookup_results(combined_results)

    if not include_structures:
        ranked = [r for r in ranked if r.get("structureId") not in {"RootStructure", "RootSpace"}]

    requested_type_results = []
    fallback_results = []
    if requested_types:
        requested_type_results = [r for r in ranked if (r.get("structureLabel") or "").lower() in requested_types]
        fallback_results = [r for r in ranked if (r.get("structureLabel") or "").lower() not in requested_types]

    limit = int(config.get("defaultResultLimit", 10))
    result = {
        "searchTerm": search_term,
        "coreQuery": core_query,
        "intent": intent,
        "requestedTypes": requested_types,
        "spaceId": space_id,
        "resultCount": len(ranked),
        "includeStructures": include_structures,
        "expandRelated": expand_related,
        "queryTerms": query_terms,
        "requestedTypeFound": (len(requested_type_results) > 0) if requested_types else None,
        "requestedTypeResultCount": len(requested_type_results) if requested_types else None,
        "fallbackResultCount": len(fallback_results) if requested_types else None,
        "results": ranked[:limit],
        "fallbackResults": fallback_results[: min(5, limit)] if requested_types and not requested_type_results else [],
    }

    cache[key] = {
        "cachedAt": now,
        "expiresAt": now + cache_ttl,
        "value": result,
    }
    save_lookup_cache(cache)
    return result


path: scripts/capacities_sync.py
content: from __future__ import annotations

from datetime import datetime, timezone
from typing import Any

from capacities_cache import load_state, save_spaces_cache, save_state, save_structures_cache
from capacities_client import get_space_info, get_spaces, load_config


def _now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


def normalize_space_info(raw: dict[str, Any], space_id: str) -> dict[str, Any]:
    structures = raw.get("structures", [])
    normalized_structures: list[dict[str, Any]] = []
    structures_by_id: dict[str, dict[str, Any]] = {}

    for item in structures:
        structure = {
            "id": item.get("id"),
            "title": item.get("title"),
            "pluralName": item.get("pluralName"),
            "labelColor": item.get("labelColor"),
            "propertyDefinitions": item.get("propertyDefinitions", []),
            "collections": item.get("collections", []),
        }
        normalized_structures.append(structure)
        if structure["id"]:
            structures_by_id[structure["id"]] = structure

    return {
        "spaceId": space_id,
        "syncedAt": _now_iso(),
        "structureCount": len(normalized_structures),
        "structures": normalized_structures,
        "structuresById": structures_by_id,
    }


def verify_main_space() -> dict[str, Any]:
    config = load_config()
    target_space_id = config["mainSpaceId"]
    spaces = get_spaces()
    save_spaces_cache(spaces)
    for space in spaces.get("spaces", []):
        if space.get("id") == target_space_id:
            return {"verified": True, "space": space}
    return {"verified": False, "space": None}


def sync_structures() -> dict[str, Any]:
    config = load_config()
    state = load_state()
    target_space_id = config["mainSpaceId"]

    try:
        verification = None
        if config.get("verifySpacesOnSync", True):
            verification = verify_main_space()

        raw = get_space_info(target_space_id)
        normalized = normalize_space_info(raw, target_space_id)
        save_structures_cache(normalized)

        state.update(
            {
                "cacheSchemaVersion": config.get("cacheSchemaVersion", 1),
                "lastStructureSyncAt": normalized["syncedAt"],
                "lastStructureSyncOk": True,
                "lastError": None,
                "spaceVerification": verification,
            }
        )
        save_state(state)
        return {
            "ok": True,
            "spaceId": target_space_id,
            "structureCount": normalized["structureCount"],
            "verification": verification,
        }
    except Exception as exc:
        state.update(
            {
                "lastStructureSyncOk": False,
                "lastError": str(exc),
                "lastStructureSyncAttemptAt": _now_iso(),
            }
        )
        save_state(state)
        raise


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)