Skill: etherstrings/openclaw-capture (version 0.1.0)

--- name: openclaw-capture description: Wrap a local openclaw_capture_workflow checkout as an OpenClaw/ClawHub skill that captures links, text, images, and videos, routes STT by platform, and fans results out to Telegram and Feishu. --- # OpenClaw Capture Use this skill when the user wants to send a link, pasted text, image, or video into the local `openclaw_capture_workflow` backend without modifying that repo, while choosing STT and notification modules by environment. ## Behavior 1. Normalize the request into the legacy payload contract: - `chat_id` - `reply_to_message_id` - `request_id` - `source_kind` - `source_url` - `raw_text` - `image_refs` - `platform_hint` - `requested_output_lang` 2. Immediately tell the user: `已收到，开始处理。` 3. Dispatch the payload through the wrapper runtime: ```bash python3 scripts/dispatch_capture.py --payload-file /path/to/payload.json ``` You may also pipe JSON through stdin: ```bash python3 scripts/dispatch_capture.py <<'JSON' {"chat_id":"-1001","source_kind":"url","source_url":"https://example.com"} JSON ``` ## Routing Rules - Keep the payload contract unchanged from the legacy workflow. - For `mixed`, preserve URL, pasted text, and images together. - STT profile resolves as: - macOS -> `mac_local_first` - non-macOS with `OPENCLAW_CAPTURE_LOCAL_STT_COMMAND` -> `local_cli_then_remote` - otherwise -> `remote_only` - Output modules resolve from `OPENCLAW_CAPTURE_OUTPUTS`: - `telegram` - `feishu` ## Required Environment - `OPENCLAW_CAPTURE_LEGACY_PROJECT_ROOT` should point to the local `openclaw_capture_workflow` checkout when this skill is not being run from the source repo. - `OPENCLAW_CAPTURE_BACKEND_MODE=library|http` - `OPENCLAW_CAPTURE_BACKEND_URL` when `BACKEND_MODE=http` - `OPENCLAW_CAPTURE_STT_PROFILE=mac_local_first|local_cli_then_remote|remote_only` to override the default routing - `OPENCLAW_CAPTURE_LOCAL_STT_COMMAND` for non-mac local CLI transcription fallback - `OPENCLAW_CAPTURE_MODEL_PROFILE=openai_direct|aihubmix_gateway` - `OPENCLAW_CAPTURE_MODEL_API_BASE_URL` - `OPENCLAW_CAPTURE_MODEL_API_KEY` - `OPENCLAW_CAPTURE_OUTPUTS=telegram,feishu` - `OPENCLAW_CAPTURE_TELEGRAM_BOT_TOKEN` - `OPENCLAW_CAPTURE_FEISHU_WEBHOOK` ## References - Runtime profiles and environment matrix: [references/runtime-profiles.md](references/runtime-profiles.md) - Module behavior and output fanout: [references/module-matrix.md](references/module-matrix.md) - Legacy payload contract and mixed-input rules: [references/payload-contract.md](references/payload-contract.md) Do not manually summarize after dispatch succeeds unless the user explicitly asks for an inline summary.

path: agents/openai.yaml
content: interface:
  display_name: "OpenClaw Capture"
  short_description: "Dispatch local capture jobs with modular STT and multi-channel results."
  default_prompt: "Use $openclaw-capture to send a capture payload into the local workflow with the right STT and output modules."

policy:
  allow_implicit_invocation: true



path: references/module-matrix.md
content: # Module Matrix

## Core

- Always required.
- Normalizes the legacy ingest payload.
- Chooses backend mode.
- Builds one shared result envelope for every output channel.

## `mac-local`

- Enabled automatically when the host platform is macOS and no explicit STT profile overrides it.
- Relies on the legacy Apple local speech path plus local OCR already provided by `openclaw_capture_workflow`.

## `cloud-stt`

- Enabled automatically for non-macOS paths.
- If `OPENCLAW_CAPTURE_LOCAL_STT_COMMAND` is set, the wrapper tries local CLI transcription first.
- Otherwise it uses the legacy remote transcription path.

## `telegram`

- Enabled when `telegram` is present in `OPENCLAW_CAPTURE_OUTPUTS`.
- Reuses the legacy Telegram message builder so Telegram and Feishu receive the same summary envelope.

## `feishu`

- Enabled when `feishu` is present in `OPENCLAW_CAPTURE_OUTPUTS`.
- Sends the shared text envelope to a Feishu webhook as a plain text robot message.



path: references/payload-contract.md
content: # Payload Contract

The wrapper keeps the legacy ingest fields unchanged:

- `chat_id`
- `reply_to_message_id`
- `request_id`
- `source_kind`
- `source_url`
- `raw_text`
- `image_refs`
- `platform_hint`
- `requested_output_lang`

## Source Kind Rules

- `pasted_text`: plain text only
- `image`: uploaded screenshot or image only
- `video_url`: Bilibili, Xiaohongshu, YouTube, or other video link
- `url`: general webpage or article link
- `mixed`: a combination of text, links, and/or images

## `mixed` Preservation

For `mixed`, never drop fields just because another signal exists:

- main link stays in `source_url`
- pasted long text stays in `raw_text`
- uploaded or local images stay in `image_refs`
- `platform_hint` stays when obvious

The wrapper intentionally preserves the old contract so it can replay existing test cases and route into the untouched `openclaw_capture_workflow` repo.



path: references/runtime-profiles.md
content: # Runtime Profiles

## Backend Mode

- `OPENCLAW_CAPTURE_BACKEND_MODE=library`
  - Imports the local `openclaw_capture_workflow` package and runs extraction, summary, and note writing in-process.
  - The wrapper owns notification fanout.
- `OPENCLAW_CAPTURE_BACKEND_MODE=http`
  - Sends the payload to `OPENCLAW_CAPTURE_BACKEND_URL`.
  - Polls `/jobs/<id>` until completion.
  - Treats Telegram as legacy-owned to avoid duplicate sends and adds Feishu fanout locally.

## STT Profile

- `mac_local_first`
  - Uses the legacy `video_audio_asr.py` bridge with Apple local speech first and remote fallback.
- `local_cli_then_remote`
  - Runs `OPENCLAW_CAPTURE_LOCAL_STT_COMMAND` first.
  - If the local CLI fails or returns empty output, falls back to the legacy remote audio path.
- `remote_only`
  - Uses the legacy remote audio path directly.

## Model Profile

- `openai_direct`
  - Default base URL: `https://api.openai.com/v1`
- `aihubmix_gateway`
  - Default base URL: `https://aihubmix.com/v1`

Override either profile with `OPENCLAW_CAPTURE_MODEL_API_BASE_URL`.



path: scripts/dispatch_capture.py
content: #!/usr/bin/env python3

from pathlib import Path
import sys


RUNTIME_DIR = Path(__file__).resolve().parent / "runtime"
if str(RUNTIME_DIR) not in sys.path:
    sys.path.insert(0, str(RUNTIME_DIR))

from openclaw_capture_skill.cli import main


if __name__ == "__main__":
    raise SystemExit(main())



path: scripts/runtime/openclaw_capture_skill/__init__.py
content: """Runtime wrapper for the OpenClaw capture skill."""

from .config import Settings
from .dispatcher import CaptureDispatcher

__all__ = ["CaptureDispatcher", "Settings"]



path: scripts/runtime/openclaw_capture_skill/cli.py
content: """CLI entrypoint for dispatching payloads through the wrapper."""

from __future__ import annotations

import argparse
import json
from pathlib import Path
import sys

from .config import Settings
from .dispatcher import CaptureDispatcher


def _load_payload(args: argparse.Namespace) -> dict:
    if args.payload_json:
        return json.loads(args.payload_json)
    if args.payload_file == "-":
        return json.loads(sys.stdin.read())
    return json.loads(Path(args.payload_file).read_text(encoding="utf-8"))


def main() -> int:
    parser = argparse.ArgumentParser(description="Dispatch OpenClaw capture payloads through the wrapper runtime")
    parser.add_argument("--payload-file", default="-", help="JSON payload path or - for stdin")
    parser.add_argument("--payload-json", help="Inline JSON payload")
    args = parser.parse_args()

    payload = _load_payload(args)
    settings = Settings.from_env()
    dispatcher = CaptureDispatcher(settings)
    job = dispatcher.dispatch(payload)
    print(json.dumps(job, ensure_ascii=False, indent=2))
    return 1 if str(job.get("status")) == "failed" else 0


if __name__ == "__main__":
    raise SystemExit(main())



path: scripts/runtime/openclaw_capture_skill/compat.py
content: """Helpers for locating and importing the untouched legacy workflow project."""

from __future__ import annotations

from pathlib import Path
import sys


def require_legacy_project_root(legacy_project_root: Path | None) -> Path:
    if legacy_project_root is None:
        raise RuntimeError(
            "could not locate openclaw_capture_workflow; set OPENCLAW_CAPTURE_LEGACY_PROJECT_ROOT"
        )
    if not (legacy_project_root / "src" / "openclaw_capture_workflow").exists():
        raise RuntimeError(f"legacy project root is invalid: {legacy_project_root}")
    return legacy_project_root.resolve()


def ensure_legacy_import_path(legacy_project_root: Path | None) -> Path:
    root = require_legacy_project_root(legacy_project_root)
    src_path = root / "src"
    if str(src_path) not in sys.path:
        sys.path.insert(0, str(src_path))
    return root


def legacy_scripts_dir(legacy_project_root: Path | None) -> Path:
    root = require_legacy_project_root(legacy_project_root)
    return root / "scripts"



path: scripts/runtime/openclaw_capture_skill/config.py
content: """Environment-backed settings for the wrapper skill."""

from __future__ import annotations

from dataclasses import dataclass
import os
from pathlib import Path

from .profiles import parse_outputs, resolve_model_profile, resolve_stt_profile


def _env(name: str, default: str = "") -> str:
    return str(os.getenv(name, default)).strip()


def _default_skill_root() -> Path:
    return Path(__file__).resolve().parents[3]


def _locate_legacy_project_root(explicit: str, project_root: Path) -> Path | None:
    candidates: list[Path] = []
    if explicit:
        candidates.append(Path(explicit).expanduser())
    candidates.append(project_root / "openclaw_capture_workflow")
    candidates.append(Path.cwd() / "openclaw_capture_workflow")
    for candidate in candidates:
        if (candidate / "src" / "openclaw_capture_workflow").exists():
            return candidate.resolve()
    return None


def _default_model_api_base_url(model_profile: str) -> str:
    if model_profile == "openai_direct":
        return "https://api.openai.com/v1"
    return "https://aihubmix.com/v1"


@dataclass
class Settings:
    skill_root: Path
    project_root: Path
    state_dir: Path
    backend_mode: str = "library"
    backend_url: str = "http://127.0.0.1:8765"
    stt_profile: str = "remote_only"
    local_stt_command: str = ""
    model_profile: str = "aihubmix_gateway"
    model_api_base_url: str = "https://aihubmix.com/v1"
    model_api_key: str = ""
    summary_model: str = "gpt-4.1-mini"
    outputs: tuple[str, ...] = ("telegram",)
    telegram_bot_token: str = ""
    feishu_webhook: str = ""
    poll_interval_seconds: float = 1.0
    poll_timeout_seconds: float = 300.0
    legacy_project_root: Path | None = None
    legacy_config_path: Path | None = None
    legacy_env_path: Path | None = None
    vault_path_override: str = ""

    @classmethod
    def from_env(cls, skill_root: Path | None = None) -> "Settings":
        skill_root = (skill_root or _default_skill_root()).resolve()
        project_root = skill_root.parent
        raw_model_profile = _env("OPENCLAW_CAPTURE_MODEL_PROFILE", "aihubmix_gateway")
        model_profile = resolve_model_profile(raw_model_profile)
        explicit_base = _env("OPENCLAW_CAPTURE_MODEL_API_BASE_URL")
        model_api_base_url = explicit_base or _default_model_api_base_url(model_profile)
        local_stt_command = _env("OPENCLAW_CAPTURE_LOCAL_STT_COMMAND")
        stt_profile = resolve_stt_profile(
            _env("OPENCLAW_CAPTURE_STT_PROFILE"),
            local_command=local_stt_command,
        )
        outputs = parse_outputs(_env("OPENCLAW_CAPTURE_OUTPUTS", "telegram"))
        state_dir = Path(_env("OPENCLAW_CAPTURE_STATE_DIR") or str(skill_root / ".state")).expanduser().resolve()
        legacy_project_root = _locate_legacy_project_root(
            _env("OPENCLAW_CAPTURE_LEGACY_PROJECT_ROOT"),
            project_root,
        )
        explicit_config = _env("OPENCLAW_CAPTURE_LEGACY_CONFIG_PATH")
        legacy_config_path = None
        if explicit_config:
            legacy_config_path = Path(explicit_config).expanduser().resolve()
        elif legacy_project_root and (legacy_project_root / "config.json").exists():
            legacy_config_path = (legacy_project_root / "config.json").resolve()
        legacy_env_path = None
        if legacy_project_root and (legacy_project_root / ".env").exists():
            legacy_env_path = (legacy_project_root / ".env").resolve()
        return cls(
            skill_root=skill_root,
            project_root=project_root,
            state_dir=state_dir,
            backend_mode=_env("OPENCLAW_CAPTURE_BACKEND_MODE", "library") or "library",
            backend_url=_env("OPENCLAW_CAPTURE_BACKEND_URL", "http://127.0.0.1:8765") or "http://127.0.0.1:8765",
            stt_profile=stt_profile,
            local_stt_command=local_stt_command,
            model_profile=model_profile,
            model_api_base_url=model_api_base_url,
            model_api_key=_env("OPENCLAW_CAPTURE_MODEL_API_KEY"),
            summary_model=_env("OPENCLAW_CAPTURE_MODEL_NAME", "gpt-4.1-mini") or "gpt-4.1-mini",
            outputs=outputs,
            telegram_bot_token=_env("OPENCLAW_CAPTURE_TELEGRAM_BOT_TOKEN"),
            feishu_webhook=_env("OPENCLAW_CAPTURE_FEISHU_WEBHOOK"),
            poll_interval_seconds=float(_env([REDACTED_SECRET], "1") or "1"),
            poll_timeout_seconds=float(_env([REDACTED_SECRET], "300") or "300"),
            legacy_project_root=legacy_project_root,
            legacy_config_path=legacy_config_path,
            legacy_env_path=legacy_env_path,
            vault_path_override=_env("OPENCLAW_CAPTURE_VAULT_PATH"),
        )



path: scripts/runtime/openclaw_capture_skill/dispatcher.py
content: """Dispatch payloads into the legacy workflow through library or HTTP modes."""

from __future__ import annotations

import copy
import json
from pathlib import Path
import time
import uuid
from urllib import request as urlrequest

from .compat import ensure_legacy_import_path, legacy_scripts_dir
from .config import Settings
from .fallback_renderer import FallbackNoteRenderer
from .local_summary import DeterministicSummaryEngine
from .notifiers import FanoutNotifier, NullNotifier


def normalize_payload(payload: dict) -> dict:
    data = copy.deepcopy(dict(payload))
    if not str(data.get("chat_id", "")).strip():
        raise ValueError("payload missing chat_id")
    if not str(data.get("source_kind", "")).strip():
        raise ValueError("payload missing source_kind")
    data.setdefault("request_id", str(uuid.uuid4()))
    data.setdefault("reply_to_message_id", None)
    data.setdefault("source_url", None)
    data.setdefault("raw_text", None)
    data.setdefault("image_refs", [])
    data.setdefault("platform_hint", None)
    data.setdefault("requested_output_lang", "zh-CN")
    return data


class CaptureDispatcher:
    def __init__(
        self,
        settings: Settings | None = None,
        *,
        extractor_override=None,
        summary_engine=None,
        note_renderer=None,
        fanout_notifier=None,
    ) -> None:
        self.settings = settings or Settings.from_env()
        self.extractor_override = extractor_override
        self.summary_engine = summary_engine
        self.note_renderer = note_renderer
        self.fanout_notifier = fanout_notifier

    def dispatch(self, payload: dict) -> dict:
        normalized = normalize_payload(payload)
        if self.settings.backend_mode == "http":
            return self._dispatch_http(normalized)
        return self._dispatch_library(normalized)

    def _load_legacy_app_config(self):
        legacy_root = ensure_legacy_import_path(self.settings.legacy_project_root)
        from openclaw_capture_workflow.config import (
            AppConfig,
            ExtractorConfig,
            ObsidianConfig,
            SummarizerConfig,
            TelegramConfig,
        )

        config = None
        if self.settings.legacy_config_path and self.settings.legacy_config_path.exists():
            try:
                config = AppConfig.load(str(self.settings.legacy_config_path))
            except Exception:
                config = None
        if config is None:
            config = AppConfig(
                listen_host="127.0.0.1",
                listen_port=8765,
                state_dir=str(self.settings.state_dir),
                obsidian=ObsidianConfig(
                    vault_path=self.settings.vault_path_override or str((Path.home() / "Documents" / "ObsidianVault")),
                    inbox_root="Inbox/OpenClaw",
                    topics_root="Topics",
                    entities_root="Entities",
                    auto_topic_whitelist=["AI", "股票", "GitHub", "产品", "工具", "商业"],
                    auto_topic_blocklist=[
                        "测试",
                        "总结",
                        "结构",
                        "回群",
                        "回执",
                        "路径",
                        "显示",
                        "验证",
                        "本地链接",
                        "wiki",
                        "md",
                        "Telegram",
                        "Obsidian",
                        "OpenClaw",
                    ],
                    auto_entity_pages=False,
                ),
                telegram=TelegramConfig(result_bot_token=self.settings.telegram_bot_token or "disabled"),
                summarizer=SummarizerConfig(
                    api_base_url=self.settings.model_api_base_url,
                    [REDACTED_SECRET] or "disabled",
                    model=self.settings.summary_model,
                    timeout_seconds=60,
                ),
                extractors=ExtractorConfig(),
            )
        if self.settings.vault_path_override:
            config.obsidian.vault_path = self.settings.vault_path_override
        config.summarizer.api_base_url = self.settings.model_api_base_url
        if self.settings.model_api_key:
            config.summarizer.[REDACTED_SECRET]
        config.summarizer.model = self.settings.summary_model
        if self.settings.telegram_bot_token:
            config.telegram.result_bot_token = self.settings.telegram_bot_token

        scripts_dir = legacy_scripts_dir(legacy_root)
        bridge_script = self.settings.skill_root / "scripts" / "video_audio_bridge.py"
        config.extractors.video_subtitle_command = (
            f'python3 "{scripts_dir / "video_subtitle_extract.py"}" --url "{{url}}" --max-seconds "{{max_seconds}}"'
        )
        config.extractors.video_audio_command = (
            f'python3 "{bridge_script}" --url "{{url}}" --max-seconds "{{max_seconds}}" '
            '--api-key "{api_key}" --api-base-url "{api_base_url}"'
        )
        config.extractors.video_keyframes_command = (
            f'python3 "{scripts_dir / "video_keyframes_extract.py"}" --url "{{url}}" '
            '--output-path "{output_path}" --max-seconds "{max_seconds}"'
        )
        config.video_summary.api_base_url = config.summarizer.api_base_url
        config.video_summary.[REDACTED_SECRET]
        config.video_summary.transport = "openai_compat"
        return config

    def _build_fanout_notifier(self, legacy_cfg=None):
        if self.fanout_notifier is not None:
            return self.fanout_notifier
        telegram_token = self.settings.telegram_bot_token
        if not telegram_token and legacy_cfg is not None:
            telegram_token = getattr(legacy_cfg.telegram, "result_bot_token", "")
        return FanoutNotifier(
            outputs=self.settings.outputs,
            telegram_bot_token=telegram_token,
            feishu_webhook=self.settings.feishu_webhook,
            legacy_project_root=self.settings.legacy_project_root,
        )

    def _patch_open_url(self, job: dict) -> None:
        result = job.get("result")
        if not isinstance(result, dict):
            return
        note = result.get("note")
        if isinstance(note, dict):
            obsidian_uri = note.get("obsidian_uri")
            if obsidian_uri:
                result["open_url"] = obsidian_uri

    def _maybe_fanout(self, payload: dict, job: dict, *, skip_outputs: set[str] | None = None, legacy_cfg=None) -> None:
        if payload.get("dry_run"):
            return
        if job.get("status") != "done":
            return
        if not self.settings.outputs:
            return
        try:
            notifier = self._build_fanout_notifier(legacy_cfg=legacy_cfg)
            notifier.send_from_job_result(payload, job, skip_outputs=skip_outputs)
        except Exception as exc:
            job.setdefault("warnings", []).append(f"wrapper_notification_error: {exc}")
            result = job.setdefault("result", {})
            if isinstance(result, dict):
                result["notification_error"] = str(exc)

    def _dispatch_library(self, payload: dict) -> dict:
        config = self._load_legacy_app_config()
        ensure_legacy_import_path(self.settings.legacy_project_root)
        from openclaw_capture_workflow.models import IngestRequest
        from openclaw_capture_workflow.processor import WorkflowProcessor
        from openclaw_capture_workflow.storage import JobStore
        from openclaw_capture_workflow.summarizer import OpenAICompatibleSummarizer

        state_dir = self.settings.state_dir
        (state_dir / "jobs").mkdir(parents=True, exist_ok=True)
        (state_dir / "artifacts").mkdir(parents=True, exist_ok=True)
        jobs = JobStore(state_dir / "jobs")
        if self.summary_engine is not None:
            summarizer = self.summary_engine
        elif payload.get("dry_run"):
            summarizer = DeterministicSummaryEngine()
            config.execution.dry_run_skip_model_call = False
        elif not str(getattr(config.summarizer, "api_key", "") or "").strip() or str(config.summarizer.api_key).strip() == "disabled":
            summarizer = DeterministicSummaryEngine()
            config.execution.dry_run_skip_model_call = False
        else:
            summarizer = OpenAICompatibleSummarizer(config.summarizer)
        processor = WorkflowProcessor(config, jobs, summarizer, state_dir)
        if self.extractor_override is not None:
            processor.extractor = self.extractor_override
        if self.note_renderer is not None:
            processor.writer.renderer = self.note_renderer
        elif processor.writer.renderer is None or not str(getattr(config.summarizer, "api_key", "") or "").strip() or str(config.summarizer.api_key).strip() == "disabled":
            processor.writer.renderer = FallbackNoteRenderer()
        processor.notifier = NullNotifier()
        processor.start()
        try:
            ingest = IngestRequest.from_dict(payload)
            job = processor.enqueue(ingest)
            processor._queue.join()
            stored = jobs.load(job.job_id)
            if stored is None:
                raise RuntimeError(f"job not found after processing: {job.job_id}")
            job_dict = stored.to_dict()
        finally:
            processor.stop()
        self._patch_open_url(job_dict)
        self._maybe_fanout(payload, job_dict, legacy_cfg=config)
        return job_dict

    def _dispatch_http(self, payload: dict) -> dict:
        request = urlrequest.Request(
            f"{self.settings.backend_url.rstrip('/')}/ingest",
            data=json.dumps(payload).encode("utf-8"),
            headers={"Content-Type": "application/json"},
            method="POST",
        )
        with urlrequest.urlopen(request, timeout=30) as resp:
            accepted = json.loads(resp.read().decode("utf-8"))
        job_id = str(accepted.get("job_id") or payload["request_id"])
        deadline = time.time() + self.settings.poll_timeout_seconds
        while True:
            poll = urlrequest.Request(
                f"{self.settings.backend_url.rstrip('/')}/jobs/{job_id}",
                method="GET",
            )
            with urlrequest.urlopen(poll, timeout=30) as resp:
                job = json.loads(resp.read().decode("utf-8"))
            if str(job.get("status")) in {"done", "failed"}:
                break
            if time.time() >= deadline:
                raise TimeoutError(f"timed out waiting for job {job_id}")
            time.sleep(self.settings.poll_interval_seconds)
        self._patch_open_url(job)
        self._maybe_fanout(payload, job, skip_outputs={"telegram"})
        return job


path: scripts/runtime/openclaw_capture_skill/fallback_renderer.py
content: """Deterministic Markdown renderer for local preview and no-key environments."""

from __future__ import annotations


class FallbackNoteRenderer:
    def render(self, materials) -> str:
        summary = materials.get("summary", {}) if isinstance(materials, dict) else {}
        evidence = materials.get("evidence", {}) if isinstance(materials, dict) else {}
        title = str(summary.get("title") or "未命名内容").strip()
        conclusion = str(summary.get("conclusion") or "").strip()
        bullets = [str(item).strip() for item in summary.get("bullets", []) if str(item).strip()]
        actions = [str(item).strip() for item in summary.get("follow_up_actions", []) if str(item).strip()]
        quotes = [str(item).strip() for item in summary.get("evidence_quotes", []) if str(item).strip()]
        source_url = str(evidence.get("source_url") or "").strip()

        lines: list[str] = [f"# {title}", ""]
        if conclusion:
            lines.extend(["## 一句话总结", "", conclusion, ""])
        if bullets:
            lines.append("## 关键要点")
            lines.append("")
            lines.extend([f"- {item}" for item in bullets[:8]])
            lines.append("")
        if actions:
            lines.append("## 下一步")
            lines.append("")
            lines.extend([f"- {item}" for item in actions[:6]])
            lines.append("")
        if quotes:
            lines.append("## 关键证据")
            lines.append("")
            lines.extend([f"- {item}" for item in quotes[:8]])
            lines.append("")
        if source_url:
            lines.extend(["## 来源", "", f"- {source_url}", ""])
        return "\n".join(lines).strip() + "\n"



path: scripts/runtime/openclaw_capture_skill/local_summary.py
content: """Deterministic local summary engine used when no remote model key is available."""

from __future__ import annotations

import re


class DeterministicSummaryEngine:
    def summarize(self, evidence):
        from openclaw_capture_workflow.models import SummaryResult

        title = _title_from_evidence(evidence)
        source_url = str(getattr(evidence, "source_url", "") or "").strip()
        text = re.sub(r"\s+", " ", str(getattr(evidence, "text", "") or "").strip())
        conclusion = _build_conclusion(evidence, text)
        bullets = [
            f"内容类型: {getattr(evidence, 'source_kind', '') or 'unknown'}",
            f"核心结论: {conclusion}",
        ]
        if source_url:
            bullets.append(f"项目与链接: {source_url}")
        else:
            bullets.append("项目与链接: 当前输入没有提供外部链接。")
        if text:
            bullets.append(f"核心事实: {text[:120].rstrip()}")
        actions = ["如需正式归档，提供模型 Key 后可生成更完整的总结和笔记。"]
        return SummaryResult(
            title=title,
            primary_topic=title,
            secondary_topics=[],
            entities=[],
            conclusion=conclusion,
            bullets=bullets,
            evidence_quotes=[text[:80]] if text else [],
            coverage="full" if text else "partial",
            confidence="medium",
            note_tags=[],
            follow_up_actions=actions,
            timeliness="medium",
            effectiveness="medium",
            recommendation_level="recommended",
            reader_judgment="当前结果适合作为本地预览和链路验证。",
        )


def _title_from_evidence(evidence) -> str:
    title = str(getattr(evidence, "title", "") or "").strip()
    if title:
        return title[:80]
    text = re.sub(r"\s+", " ", str(getattr(evidence, "text", "") or "").strip())
    if not text:
        return "本地捕获结果"
    return text[:32].strip() or "本地捕获结果"


def _build_conclusion(evidence, text: str) -> str:
    source_kind = str(getattr(evidence, "source_kind", "") or "").strip()
    if source_kind == "pasted_text":
        return "已基于输入文字生成本地可读摘要。"
    if source_kind == "image":
        return "已基于图片提取结果生成本地可读摘要。"
    if source_kind == "video_url":
        return "已基于视频证据生成本地可读摘要。"
    if text:
        return "已基于当前证据生成本地可读摘要。"
    return "已收到输入，但证据内容仍然较少。"



path: scripts/runtime/openclaw_capture_skill/notifiers.py
content: """Notification fanout and shared envelope rendering."""

from __future__ import annotations

from types import SimpleNamespace
import json
from pathlib import Path
from typing import Any, Callable
from urllib import parse as urlparse
from urllib import request as urlrequest

from .compat import ensure_legacy_import_path


def _post_urlencoded(url: str, payload: dict[str, Any]) -> None:
    data = urlparse.urlencode(payload).encode("utf-8")
    req = urlrequest.Request(url, data=data, method="POST")
    with urlrequest.urlopen(req, timeout=30) as resp:
        body = json.loads(resp.read().decode("utf-8"))
    if isinstance(body, dict) and body.get("ok") is False:
        raise RuntimeError(f"request failed: {body}")


def _post_json(url: str, payload: dict[str, Any]) -> None:
    req = urlrequest.Request(
        url,
        data=json.dumps(payload).encode("utf-8"),
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    with urlrequest.urlopen(req, timeout=30) as resp:
        raw = resp.read().decode("utf-8").strip()
    if raw:
        try:
            body = json.loads(raw)
        except json.JSONDecodeError:
            return
        if isinstance(body, dict) and body.get("code") not in (None, 0):
            raise RuntimeError(f"request failed: {body}")


def _summary_namespace(data: dict[str, Any]) -> SimpleNamespace:
    payload = {
        "title": data.get("title", ""),
        "primary_topic": data.get("primary_topic", ""),
        "secondary_topics": list(data.get("secondary_topics", [])),
        "entities": list(data.get("entities", [])),
        "conclusion": data.get("conclusion", ""),
        "bullets": list(data.get("bullets", [])),
        "evidence_quotes": list(data.get("evidence_quotes", [])),
        "coverage": data.get("coverage", "partial"),
        "confidence": data.get("confidence", "medium"),
        "note_tags": list(data.get("note_tags", [])),
        "follow_up_actions": list(data.get("follow_up_actions", [])),
        "timeliness": data.get("timeliness", "medium"),
        "effectiveness": data.get("effectiveness", "medium"),
        "recommendation_level": data.get("recommendation_level", "optional"),
        "reader_judgment": data.get("reader_judgment", ""),
    }
    return SimpleNamespace(**payload)


def _ingest_namespace(data: dict[str, Any]) -> SimpleNamespace:
    payload = {
        "chat_id": str(data.get("chat_id", "")),
        "reply_to_message_id": data.get("reply_to_message_id"),
        "request_id": data.get("request_id", ""),
        "source_kind": data.get("source_kind", ""),
        "source_url": data.get("source_url"),
        "raw_text": data.get("raw_text"),
        "image_refs": list(data.get("image_refs", [])),
        "platform_hint": data.get("platform_hint"),
        "requested_output_lang": data.get("requested_output_lang", "zh-CN"),
    }
    return SimpleNamespace(**payload)


def _evidence_namespace(data: dict[str, Any]) -> SimpleNamespace:
    payload = {
        "source_kind": data.get("source_kind", ""),
        "source_url": data.get("source_url"),
        "platform_hint": data.get("platform_hint"),
        "title": data.get("title"),
        "text": data.get("text", ""),
        "evidence_type": data.get("evidence_type", ""),
        "coverage": data.get("coverage", "partial"),
        "transcript": data.get("transcript"),
        "keyframes": list(data.get("keyframes", [])),
        "metadata": dict(data.get("metadata", {})),
    }
    return SimpleNamespace(**payload)


class NullNotifier:
    def send_result(self, *args, **kwargs) -> None:
        return None


class EnvelopeRenderer:
    def __init__(self, legacy_project_root: Path | None) -> None:
        ensure_legacy_import_path(legacy_project_root)
        from openclaw_capture_workflow.telegram import TelegramNotifier

        self._builder = TelegramNotifier("disabled")

    def render_text(
        self,
        ingest,
        summary,
        note_path: str,
        structure_map: str,
        open_url: str,
        evidence=None,
        summary_model: str | None = None,
        summary_elapsed_seconds: float | None = None,
    ) -> str:
        payload = self._builder.build_result_message_payload(
            ingest,
            summary,
            note_path,
            structure_map,
            open_url,
            evidence,
            summary_model,
            summary_elapsed_seconds,
        )
        return str(payload["text"])


class FanoutNotifier:
    def __init__(
        self,
        *,
        outputs: tuple[str, ...],
        telegram_bot_token: str = "",
        feishu_webhook: str = "",
        legacy_project_root: Path | None = None,
        text_renderer: Callable[..., str] | None = None,
        telegram_sender: Callable[[dict[str, Any]], None] | None = None,
        feishu_sender: Callable[[str], None] | None = None,
    ) -> None:
        self.outputs = outputs
        self.telegram_bot_token = telegram_bot_token
        self.feishu_webhook = feishu_webhook
        self._renderer = text_renderer or EnvelopeRenderer(legacy_project_root).render_text
        self._telegram_sender = telegram_sender or self._send_telegram_payload
        self._feishu_sender = feishu_sender or self._send_feishu_text

    def _send_telegram_payload(self, payload: dict[str, Any]) -> None:
        if not self.telegram_bot_token:
            raise RuntimeError("telegram output selected but OPENCLAW_CAPTURE_TELEGRAM_BOT_TOKEN is missing")
        _post_urlencoded(
            f"https://api.telegram.org/bot{self.telegram_bot_token}/sendMessage",
            payload,
        )

    def _send_feishu_text(self, text: str) -> None:
        if not self.feishu_webhook:
            raise RuntimeError("feishu output selected but OPENCLAW_CAPTURE_FEISHU_WEBHOOK is missing")
        _post_json(
            self.feishu_webhook,
            {
                "msg_type": "text",
                "content": {
                    "text": text,
                },
            },
        )

    def _fanout(
        self,
        ingest,
        summary,
        note_path: str,
        structure_map: str,
        open_url: str,
        evidence=None,
        summary_model: str | None = None,
        summary_elapsed_seconds: float | None = None,
        skip_outputs: set[str] | None = None,
    ) -> str:
        skip_outputs = skip_outputs or set()
        text = self._renderer(
            ingest,
            summary,
            note_path,
            structure_map,
            open_url,
            evidence,
            summary_model,
            summary_elapsed_seconds,
        )
        if "telegram" in self.outputs and "telegram" not in skip_outputs:
            payload: dict[str, Any] = {
                "chat_id": str(getattr(ingest, "chat_id", "")),
                "text": text,
            }
            reply_to_message_id = getattr(ingest, "reply_to_message_id", None)
            if reply_to_message_id not in (None, ""):
                payload["reply_to_message_id"] = str(reply_to_message_id)
            self._telegram_sender(payload)
        if "feishu" in self.outputs and "feishu" not in skip_outputs:
            self._feishu_sender(text)
        return text

    def send_result(
        self,
        ingest,
        summary,
        note_path: str,
        structure_map: str,
        open_url: str,
        evidence=None,
        summary_model: str | None = None,
        summary_elapsed_seconds: float | None = None,
    ) -> None:
        self._fanout(
            ingest,
            summary,
            note_path,
            structure_map,
            open_url,
            evidence,
            summary_model,
            summary_elapsed_seconds,
        )

    def send_from_job_result(
        self,
        ingest_payload: dict[str, Any],
        job: dict[str, Any],
        *,
        skip_outputs: set[str] | None = None,
    ) -> str:
        result = dict(job.get("result", {}))
        note = dict(result.get("note", {}))
        summary = _summary_namespace(dict(result.get("summary", {})))
        evidence = _evidence_namespace(dict(result.get("evidence", {})))
        ingest = _ingest_namespace(ingest_payload)
        note_path = str(note.get("note_path", ""))
        structure_map = str(note.get("structure_map", ""))
        open_url = str(note.get("obsidian_uri") or result.get("open_url") or "")
        return self._fanout(
            ingest,
            summary,
            note_path,
            structure_map,
            open_url,
            evidence,
            result.get("summary_model"),
            result.get("summary_elapsed_seconds"),
            skip_outputs=skip_outputs,
        )



path: scripts/runtime/openclaw_capture_skill/profiles.py
content: """Routing helpers for STT, model providers, and output modules."""

from __future__ import annotations

import platform


VALID_STT_PROFILES = {"mac_local_first", "local_cli_then_remote", "remote_only"}
VALID_MODEL_PROFILES = {"openai_direct", "aihubmix_gateway"}
VALID_OUTPUTS = {"telegram", "feishu"}


def resolve_stt_profile(raw_value: str, *, local_command: str = "", system_name: str | None = None) -> str:
    normalized = str(raw_value or "").strip().lower()
    if normalized in VALID_STT_PROFILES:
        return normalized
    actual_system = (system_name or platform.system()).strip()
    if actual_system == "Darwin":
        return "mac_local_first"
    if str(local_command or "").strip():
        return "local_cli_then_remote"
    return "remote_only"


def resolve_model_profile(raw_value: str) -> str:
    normalized = str(raw_value or "").strip().lower()
    if normalized in VALID_MODEL_PROFILES:
        return normalized
    return "aihubmix_gateway"


def parse_outputs(raw_value: str) -> tuple[str, ...]:
    values = []
    seen = set()
    for item in str(raw_value or "").split(","):
        normalized = item.strip().lower()
        if not normalized or normalized not in VALID_OUTPUTS or normalized in seen:
            continue
        seen.add(normalized)
        values.append(normalized)
    return tuple(values)



path: scripts/runtime/openclaw_capture_skill/video_audio_bridge.py
content: """STT bridge that picks local mac, local CLI, or remote legacy audio transcription."""

from __future__ import annotations

import argparse
import platform
from pathlib import Path
import shlex
import subprocess
import sys

from .compat import legacy_scripts_dir, require_legacy_project_root
from .config import Settings


def _run(args: list[str]) -> str:
    try:
        result = subprocess.run(args, check=True, capture_output=True, text=True)
    except subprocess.CalledProcessError as exc:
        stderr = (exc.stderr or "").strip()
        stdout = (exc.stdout or "").strip()
        msg = stderr or stdout or str(exc)
        raise RuntimeError(msg) from exc
    return (result.stdout or "").strip()


def _run_template(command: str, **kwargs: str) -> str:
    rendered = command.format(**kwargs)
    args = shlex.split(rendered)
    return _run(args)


def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    parser = argparse.ArgumentParser()
    parser.add_argument("--url", required=True)
    parser.add_argument("--max-seconds", default="0")
    parser.add_argument("--api-key", default="")
    parser.add_argument("--api-base-url", default="")
    return parser.parse_args(argv)


def _legacy_audio_script(legacy_project_root: Path) -> Path:
    scripts_dir = legacy_scripts_dir(legacy_project_root)
    return scripts_dir / "video_audio_asr.py"


def _call_legacy_audio(
    legacy_project_root: Path,
    *,
    backend: str,
    url: str,
    max_seconds: str,
    api_key: str,
    api_base_url: str,
) -> str:
    script_path = _legacy_audio_script(legacy_project_root)
    return _run(
        [
            sys.executable,
            str(script_path),
            "--url",
            url,
            "--max-seconds",
            str(max_seconds),
            "--api-key",
            api_key,
            "--api-base-url",
            api_base_url,
            "--backend",
            backend,
        ]
    )


def main(argv: list[str] | None = None) -> int:
    args = _parse_args(argv)
    settings = Settings.from_env()
    legacy_root = require_legacy_project_root(settings.legacy_project_root)

    if settings.stt_profile == "mac_local_first" and platform.system() == "Darwin":
        print(
            _call_legacy_audio(
                legacy_root,
                backend="auto",
                url=args.url,
                max_seconds=str(args.max_seconds),
                [REDACTED_SECRET],
                api_base_url=args.api_base_url,
            )
        )
        return 0

    if settings.stt_profile == "local_cli_then_remote" and settings.local_stt_command.strip():
        try:
            local_output = _run_template(
                settings.local_stt_command,
                url=args.url,
                max_seconds=str(args.max_seconds),
                [REDACTED_SECRET],
                api_base_url=args.api_base_url,
            )
            if local_output.strip():
                print(local_output)
                return 0
        except Exception:
            pass

    print(
        _call_legacy_audio(
            legacy_root,
            backend="remote",
            url=args.url,
            max_seconds=str(args.max_seconds),
            [REDACTED_SECRET],
            api_base_url=args.api_base_url,
        )
    )
    return 0


if __name__ == "__main__":
    raise SystemExit(main())



path: scripts/video_audio_bridge.py
content: #!/usr/bin/env python3

from pathlib import Path
import sys


RUNTIME_DIR = Path(__file__).resolve().parent / "runtime"
if str(RUNTIME_DIR) not in sys.path:
    sys.path.insert(0, str(RUNTIME_DIR))

from openclaw_capture_skill.video_audio_bridge import main


if __name__ == "__main__":
    raise SystemExit(main())



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