Skill: morandot/arknights-skill (version 1.3.0)

--- name: arknights-skill description: 回答《明日方舟》的干员定位、技能机制、养成建议、剧情梳理、术语解释与关卡思路；可读取和维护本地结构化博士档案，让建议逐步贴合用户账号练度；并在版本相关问题上明确区分最新检索结论和非最新判断。 compatibility: 兼容 Agent Skills 客户端，包括 Codex 与 Claude Code。包含本地脚本 `scripts/memory.py`，无外部凭据依赖；对“当前版本 / 最新活动 / 最新强度”类问题需要联网检索，离线时必须明确说明结论不是最新。 metadata: openclaw: homepage: https://github.com/morandot/arknights-skill --- # Arknights Guide 面向《明日方舟》问题的专用 skill。重点不是堆资料，而是把信息整理成玩家能直接用来决策的回答；如果本地博士档案可用，优先结合用户自己的账号练度给建议。 ## When To Use 当用户问题涉及以下内容时启用： - 干员基础信息、定位、分支、获取方式 - 技能、天赋、模组、潜能、专精、精英化收益 - 值不值得练、先练谁、资源如何分配 - 阵容搭配、主线 / 活动 / 高难关卡思路 - 世界观、阵营、人物关系、剧情梳理 - 游戏术语解释 - 版本环境评价、活动内容整理、当前强度判断 ## Core Rules ### 0. Use The Local Doctor Profile 如果当前客户端允许本地文件调用，先读取安装目录旁的结构化档案。不要假设当前工作目录就是 skill 目录；在 Claude Code 中优先使用 `CLAUDE_SKILL_DIR`，其他 Agent Skills 客户端应把脚本路径解析到当前 `SKILL.md` 所在目录。 ```bash # Claude Code python3 "$CLAUDE_SKILL_DIR/scripts/memory.py" read # Other Agent Skills clients: replace $SKILL_DIR with this SKILL.md directory. python3 "$SKILL_DIR/scripts/memory.py" read ``` 默认档案位置是： ```text ~/.config/arknights-skill/doctor-profile.json ``` 也可以通过 `ARKNIGHTS_MEMORY_DIR` 指向其他本地目录。档案只保存结构化账号事实，不保存完整对话。 如果 `$CLAUDE_SKILL_DIR` 和 `$SKILL_DIR` 均未设置，可从 `~/.hermes/skills` 或 `~/.config/arknights-skill` 搜索 `memory.py` 所在路径。 回答时： - 优先结合已记录的博士等级、服务器、资源倾向、目标、干员拥有与练度信息。 - 如果档案为空或不可读取，照常回答，不要假装知道用户账号。 - 如果档案信息与用户本轮明确表述冲突，以本轮信息作为待确认线索，不要直接覆盖旧档案。 回答后，从用户本轮明确提供的信息中提取可记忆事实，并更新档案： ```bash python3 "$CLAUDE_SKILL_DIR/scripts/memory.py" update --patch-json '{"operators":{"银灰":{"owned":true,"elite":2,"level":60,"masteries":{"3":3}}}}' ``` 只写入这些内容： - 博士信息：昵称、服务器、等级、UID - 账号状态：主线 / 活动进度、资源状态、养成目标、偏好 - 干员信息：拥有状态、精英化、等级、潜能、技能等级、专精、模组、简短备注 不要写入： - 完整对话、截图 OCR 原文或长段流水账 - 你推测出来但用户没确认的信息 - 攻略建议、强度评价、版本环境判断 - 剧情内容、官方文本、活动时间表 出现降级或互斥信息时，脚本会写入 `pending_confirmations`，不要手动覆盖。需要时在回答末尾简短询问用户确认。 ### 1. Lead With The Decision 用户在问“值不值得练”“哪个技能优先”“这关怎么打”时，先给结论，再给依据。 优先顺序： 1. 直接结论 2. 为什么 3. 适用场景或限制 4. 养成或操作建议 ### 2. Separate Facts From Evaluation 以下内容可以直接当事实描述： - 技能与机制解释 - 职业分支作用 - 世界观基础设定 - 明确可确认的档案信息 - 用户提供的截图或关卡信息 以下内容必须加限定语： - 强不强 - 是否保值 - 当前版本地位 - 高难是否必备 - 是否值得抽 这类结论要写成“按当前主流评价”“按开荒泛用性看”“在高压环境里通常被视为”。 ### 3. Treat Version-Sensitive Questions As Freshness-Critical 下列问题默认视为强时效： - “现在还强吗” - “当前版本值不值得抽” - “最新活动怎么打” - “国服 / 日服 / 国际服最新内容是什么” - “现在环境里该专几” 处理规则： - 如果具备联网能力，先检索再回答。 - 如果没有检索，必须明确写出：结论基于非最新认知，不能伪装成实时信息。 - 不要编造活动日期、池子安排、版本顺序和官方文本。 ### 4. Control Spoilers 用户没要求剧透时，默认只给 Level 0-1： - Level 0: 无剧透背景轮廓 - Level 1: 轻微剧透，允许提关系和前提 - Level 2: 中度剧透，允许概述关键冲突 - Level 3: 完整剧透，允许讲结局和核心真相 当用户明确要完整剧情时，先加一句： `【以下包含完整剧透】` ### 5. Make Guides Executable 攻略类回答必须尽量覆盖： - 关卡核心难点 - 敌方威胁类型 - 地图与站位关键点 - 费用节奏 - 部署顺序或技能节点 - 常见翻车点 - 可替代职业或干员类型 如果用户没说自己是高配账号，默认补一段低配思路。 ### 6. Keep Names And Terms Consistent 优先使用官方或通行名称。首次出现可以中英并列一次，后面保持统一，不要混用多个别称。 ## Default Answer Shapes ### Operator Review 默认结构： 1. 一句话结论 2. 定位 3. 核心优势 4. 主要短板 5. 适用场景 6. 养成与专精建议 ### Skill Priority 默认结构： 1. 推荐技能 2. 推荐理由 3. 另一个技能何时更好 4. 专精顺序 5. 新手与成型账号的区别 ### Raise Or Skip 默认结构： 1. 结论：值得 / 看 box / 可跳过 2. 为什么 3. 适合谁 4. 练到什么程度最划算 5. 同定位替代 ### Lore / Story 默认结构： 1. 无剧透简介 2. 角色或阵营的核心矛盾 3. 与其他角色的关系 4. 用户明确要求后再展开详细剧情 ### Stage Help 默认结构： 1. 关卡核心难点 2. 推荐思路 3. 推荐职业构成 4. 部署与技能节奏 5. 翻车点 6. 低配替代 ## References 按需读取，不要默认全部载入： - 结构化模板： [references/answer-templates.md](references/answer-templates.md) - 风格示例： [references/examples.md](references/examples.md) - 本地博士档案脚本： [scripts/memory.py](scripts/memory.py) 只有在你需要更完整模板、想对齐示例风格，或需要确认本地记忆脚本接口时再读这些文件。 ## Do Not Do These 不要： - 编造数值、模组倍率、活动时间、池子安排、官方文本 - 把主观强度判断伪装成绝对事实 - 在没有确认需求时直接爆关键剧情 - 只给“抄作业阵容”，不解释替代逻辑 - 把旧版本结论说成“当前版本定论” - 把个人账号记忆写进公开仓库或发布包之外的本地档案以外位置 ## Final Goal 回答至少要帮助用户完成其中一项： - 判断要不要练 - 判断技能怎么选 - 理解一个机制或术语 - 得到可执行的关卡思路 - 读懂剧情与设定的重点 - 在版本相关问题上知道结论是否最新

path: references/examples.md
content: # Style Examples

这些例子只用来对齐回答节奏，不要照抄具体结论。

## 示例 1：干员值不值得练

```text
结论：
值得练，属于高泛用核心输出，精二后的提升比较关键。

定位：
地面物理输出兼爆发决战位，核心价值在于技能开启后的大范围清场。

优势：
- 爆发强，能处理成群中高压敌人
- 覆盖范围大，站位容错高
- 主线和活动图都有稳定发挥

短板：
- 依赖技能期
- 真空期需要队友补足

养成建议：
优先精二；资源够的话优先提升核心技能专精。
```

## 示例 2：术语解释

```text
定义：
暖机指干员需要一段时间叠层、蓄力或进入技能循环后，强度才会完整展开。

实战意义：
前期可能偏弱，中后期会明显变强。

例子：
需要叠攻击、叠召唤物或等待循环稳定的干员。

容易混淆：
暖机不等于弱，很多干员只是强势期来得比较晚。
```

## 示例 3：关卡思路

```text
关卡核心难点：
- 前期费用紧
- 中后期有高防敌人与飞行单位同时施压

推荐思路：
先稳住回费和对空，再准备中路持续输出，最后留一个爆发技能处理高压波次。

低配替代：
这里真正的核心需求是稳定对空和中路持续群伤，不一定非要绑定某个六星。
```

## 示例 4：技能选择

```text
推荐：
优先二技能。

原因：
- 启动更快，泛用性更高
- 在主线和大多数活动图里更容易稳定吃到收益

另一个技能什么时候更好：
- 如果你更看重爆发上限，或者队伍已经能稳定保轴，可以考虑三技能

专精建议：
- 常规账号先专二技能
- 已有成熟阵容、追求高压表现时再补三技能
```

## 示例 5：剧情介绍

```text
无剧透简介：
这个角色的魅力不只在战斗定位，更在于身份、立场和选择之间的拉扯。

核心设定：
- 她 / 他属于某个关键阵营
- 与另一名角色存在重要的价值观对照

关系与主题：
- 表面上是职责冲突，实际更接近信念冲突
- 角色主题通常围绕责任、代价和自我认同展开

如果你想继续，我可以再展开档案内容和完整剧情线，剧透等级会一起标明。
```


path: references/answer-templates.md
content: # Answer Templates

按需选最接近用户问题的一种模板，不要机械照抄。

## 1. 干员简评

```text
结论：
【值得练 / 看 box / 可跳过】。如果你缺【】, 这名干员的优先级会更高。

定位：
- 职业 / 分支：
- 核心职责：
- 常见用法：

优势：
- 
- 

短板：
- 
- 

适用场景：
- 
- 

养成建议：
- 推荐练度：
- 是否建议精二：
- 专精优先级：

一句话建议：
【】
```

## 2. 技能选择

```text
推荐：
优先【X技能】。

原因：
- 
- 

【Y技能】什么时候更好：
- 
- 

专精建议：
- 专一收益：
- 专二收益：
- 专三收益：
- 推荐停点：

结论：
【】
```

## 3. 值不值得练

```text
结论：
【值得 / 看 box / 可跳过】

为什么：
- 
- 

适合谁：
- 新手 / 老玩家 / 高难账号 / 缺某类功能的账号

最划算的练度：
- 

同定位替代：
- 
```

## 4. 剧情介绍

```text
无剧透简介：
【】

核心设定：
- 
- 

与其他角色 / 阵营关系：
- 
- 

角色主题：
【】

如需深入，可继续展开档案、活动剧情和完整剧透线。
```

## 5. 关卡攻略

```text
关卡核心难点：
- 
- 

推荐思路：
- 
- 

推荐阵容构成：
- 回费：
- 主输出：
- 副输出：
- 医疗：
- 阻挡 / 承伤：
- 功能位：

操作重点：
1. 
2. 
3. 

常见翻车点：
- 
- 

低配替代思路：
- 
- 
```

## 6. 术语解释

```text
定义：
【】

实战意义：
【】

例子：
【】

容易混淆：
【】
```


path: agents/openai.yaml
content: interface:
  display_name: "Arknights Guide"
  short_description: "回答明日方舟干员强度、养成规划、剧情梳理与关卡思路，并维护本地博士档案"
  default_prompt: "Use $arknights-skill to answer an Arknights question with clear conclusions, local doctor profile awareness when available, spoiler control, and explicit freshness caveats for current-version topics."

policy:
  allow_implicit_invocation: true


path: scripts/memory.py
content: #!/usr/bin/env python3
"""Manage the local Arknights doctor profile for arknights-skill."""

from __future__ import annotations

import argparse
import json
import os
import shutil
import sys
import tempfile
from copy import deepcopy
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

SCHEMA_VERSION = 1
PROFILE_FILE_NAME = "doctor-profile.json"
MAX_FACT_TEXT_LENGTH = 240


def now_utc() -> str:
    return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")


def skill_root() -> Path:
    return Path(__file__).resolve().parents[1]


def memory_dir() -> Path:
    configured = os.environ.get("ARKNIGHTS_MEMORY_DIR")
    if configured:
        return Path(configured).expanduser().resolve()
    return Path("~/.config/arknights-skill").expanduser().resolve()


def profile_path() -> Path:
    return memory_dir() / PROFILE_FILE_NAME


def migrate_legacy_if_needed() -> None:
    """One-time migration: copy data from old skill-relative path to new default."""
    if os.environ.get("ARKNIGHTS_MEMORY_DIR"):
        return
    legacy = skill_root() / ".arknights-memory" / PROFILE_FILE_NAME
    new_path = profile_path()
    if legacy.exists() and not new_path.exists():
        new_path.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(str(legacy), str(new_path))
        (legacy.parent / ".migrated").write_text(
            f"Migrated to {new_path}\n", encoding="utf-8"
        )


def empty_profile(timestamp: str | None = None) -> dict[str, Any]:
    timestamp = timestamp or now_utc()
    return {
        "schema_version": SCHEMA_VERSION,
        "game": "Arknights",
        "metadata": {
            "created_at": timestamp,
            "updated_at": timestamp,
        },
        "doctor": {
            "name": None,
            "server": None,
            "level": None,
            "uid": None,
        },
        "account": {
            "progress": {},
            "resources": {},
            "goals": [],
            "preferences": [],
        },
        "operators": {},
        "pending_confirmations": [],
    }


def normalize_profile(data: Any) -> dict[str, Any]:
    if not isinstance(data, dict):
        raise ValueError("Profile must be a JSON object.")

    profile = empty_profile()
    for key in ("schema_version", "game", "metadata", "doctor", "account", "operators", "pending_confirmations"):
        if key in data:
            profile[key] = data[key]

    if profile["schema_version"] != SCHEMA_VERSION:
        raise ValueError(f"Unsupported schema_version: {profile['schema_version']}")

    if not isinstance(profile.get("metadata"), dict):
        profile["metadata"] = {}
    profile["metadata"].setdefault("created_at", now_utc())
    profile["metadata"].setdefault("updated_at", profile["metadata"]["created_at"])

    if not isinstance(profile.get("doctor"), dict):
        profile["doctor"] = {}
    for key in ("name", "server", "level", "uid"):
        profile["doctor"].setdefault(key, None)

    if not isinstance(profile.get("account"), dict):
        profile["account"] = {}
    profile["account"].setdefault("progress", {})
    profile["account"].setdefault("resources", {})
    profile["account"].setdefault("goals", [])
    profile["account"].setdefault("preferences", [])

    if not isinstance(profile.get("operators"), dict):
        profile["operators"] = {}
    if not isinstance(profile.get("pending_confirmations"), list):
        profile["pending_confirmations"] = []

    return profile


def load_profile() -> tuple[dict[str, Any], bool]:
    path = profile_path()
    if not path.exists():
        return empty_profile(), True
    with path.open("r", encoding="utf-8") as handle:
        return normalize_profile(json.load(handle)), False


def save_profile(profile: dict[str, Any], *, touch_updated_at: bool = True) -> None:
    if touch_updated_at:
        profile["metadata"]["updated_at"] = now_utc()

    path = profile_path()
    path.parent.mkdir(parents=True, exist_ok=True)
    fd, tmp_name = tempfile.mkstemp(
        prefix=".doctor-profile.",
        suffix=".tmp",
        dir=str(path.parent),
        text=True,
    )
    tmp_path = Path(tmp_name)
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as handle:
            json.dump(profile, handle, ensure_ascii=False, indent=2)
            handle.write("\n")
        os.replace(tmp_path, path)
    finally:
        if tmp_path.exists():
            tmp_path.unlink()


def print_json(data: Any) -> None:
    print(json.dumps(data, ensure_ascii=False, indent=2))


def clean_fact_text(value: Any, field: str) -> str | None:
    if value is None:
        return None
    text = str(value).strip()
    if not text:
        return None
    if "\n" in text or "\r" in text or len(text) > MAX_FACT_TEXT_LENGTH:
        raise ValueError(f"{field} must be a concise single factual note, not raw dialogue.")
    return text


def to_int(value: Any, field: str) -> int | None:
    if value is None or value == "":
        return None
    if isinstance(value, bool):
        raise ValueError(f"{field} must be an integer.")
    try:
        return int(value)
    except (TypeError, ValueError) as exc:
        raise ValueError(f"{field} must be an integer.") from exc


def add_pending(
    profile: dict[str, Any],
    field: str,
    current: Any,
    incoming: Any,
    reason: str,
    timestamp: str,
) -> None:
    item = {
        "field": field,
        "current": current,
        "incoming": incoming,
        "reason": reason,
        "observed_at": timestamp,
    }
    for existing in profile["pending_confirmations"]:
        if (
            existing.get("field") == field
            and existing.get("current") == current
            and existing.get("incoming") == incoming
        ):
            return
    profile["pending_confirmations"].append(item)


def merge_text_scalar(
    profile: dict[str, Any],
    target: dict[str, Any],
    key: str,
    incoming: Any,
    field: str,
    timestamp: str,
) -> bool:
    value = clean_fact_text(incoming, field)
    if value is None:
        return False
    current = target.get(key)
    if current in (None, ""):
        target[key] = value
        return True
    if current == value:
        return False
    add_pending(profile, field, current, value, "conflicting explicit facts", timestamp)
    return True


def merge_monotonic_int(
    profile: dict[str, Any],
    target: dict[str, Any],
    key: str,
    incoming: Any,
    field: str,
    timestamp: str,
    *,
    min_value: int | None = None,
    max_value: int | None = None,
) -> bool:
    value = to_int(incoming, field)
    if value is None:
        return False
    if min_value is not None and value < min_value:
        raise ValueError(f"{field} must be >= {min_value}.")
    if max_value is not None and value > max_value:
        raise ValueError(f"{field} must be <= {max_value}.")

    current = target.get(key)
    if current in (None, ""):
        target[key] = value
        return True

    current_int = to_int(current, field)
    if current_int is None or value > current_int:
        target[key] = value
        return True
    if value == current_int:
        return False

    add_pending(profile, field, current, value, "incoming value is lower than stored progression", timestamp)
    return True


def append_unique_text(target: list[Any], incoming: Any, field: str) -> bool:
    values = incoming if isinstance(incoming, list) else [incoming]
    changed = False
    for raw_value in values:
        value = clean_fact_text(raw_value, field)
        if value is not None and value not in target:
            target.append(value)
            changed = True
    return changed


def default_operator(timestamp: str) -> dict[str, Any]:
    return {
        "owned": None,
        "elite": None,
        "level": None,
        "potential": None,
        "skill_level": None,
        "masteries": {},
        "modules": {},
        "notes": [],
        "updated_at": timestamp,
    }


def normalize_operator(data: Any, timestamp: str) -> dict[str, Any]:
    operator = default_operator(timestamp)
    if isinstance(data, dict):
        operator.update(data)
    if not isinstance(operator.get("masteries"), dict):
        operator["masteries"] = {}
    if not isinstance(operator.get("modules"), dict):
        operator["modules"] = {}
    if not isinstance(operator.get("notes"), list):
        operator["notes"] = []
    return operator


def merge_owned(profile: dict[str, Any], operator: dict[str, Any], incoming: Any, field: str, timestamp: str) -> bool:
    if incoming is None:
        return False
    if not isinstance(incoming, bool):
        raise ValueError(f"{field} must be true or false.")

    current = operator.get("owned")
    if current is None:
        operator["owned"] = incoming
        return True
    if current == incoming:
        return False
    if current is False and incoming is True:
        operator["owned"] = True
        return True

    add_pending(profile, field, current, incoming, "owned status conflicts with stored profile", timestamp)
    return True


def merge_elite_and_level(
    profile: dict[str, Any],
    operator: dict[str, Any],
    patch: dict[str, Any],
    field_prefix: str,
    timestamp: str,
) -> bool:
    changed = False
    has_elite = "elite" in patch
    has_level = "level" in patch
    incoming_elite = to_int(patch.get("elite"), f"{field_prefix}.elite") if has_elite else None
    incoming_level = to_int(patch.get("level"), f"{field_prefix}.level") if has_level else None

    if incoming_elite is not None and not 0 <= incoming_elite <= 2:
        raise ValueError(f"{field_prefix}.elite must be between 0 and 2.")
    if incoming_level is not None and incoming_level < 1:
        raise ValueError(f"{field_prefix}.level must be >= 1.")

    current_elite = to_int(operator.get("elite"), f"{field_prefix}.elite")
    current_level = to_int(operator.get("level"), f"{field_prefix}.level")

    if has_elite and incoming_elite is not None:
        if current_elite is None or incoming_elite > current_elite:
            operator["elite"] = incoming_elite
            changed = True
            if incoming_level is not None:
                operator["level"] = incoming_level
                changed = True
            return changed
        if incoming_elite < current_elite:
            add_pending(
                profile, f"{field_prefix}.elite", current_elite, incoming_elite, "incoming elite is lower", timestamp,
            )
            if incoming_level is not None:
                add_pending(
                    profile, f"{field_prefix}.level", current_level, incoming_level,
                    "incoming level belongs to lower elite", timestamp,
                )
            return True

    if has_level and incoming_level is not None:
        if current_level is None:
            operator["level"] = incoming_level
            return True
        if incoming_level > current_level:
            operator["level"] = incoming_level
            return True
        if incoming_level < current_level:
            add_pending(
                profile, f"{field_prefix}.level", current_level, incoming_level, "incoming level is lower", timestamp,
            )
            return True

    return changed


def merge_masteries(
    profile: dict[str, Any], operator: dict[str, Any], incoming: Any, field_prefix: str, timestamp: str,
) -> bool:
    if not isinstance(incoming, dict):
        raise ValueError(f"{field_prefix}.masteries must be an object.")
    changed = False
    for skill_id, mastery in incoming.items():
        skill_key = str(skill_id)
        changed = merge_monotonic_int(
            profile,
            operator["masteries"],
            skill_key,
            mastery,
            f"{field_prefix}.masteries.{skill_key}",
            timestamp,
            min_value=0,
            max_value=3,
        ) or changed
    return changed


def merge_legacy_skills(
    profile: dict[str, Any], operator: dict[str, Any], incoming: Any, field_prefix: str, timestamp: str,
) -> bool:
    if not isinstance(incoming, dict):
        raise ValueError(f"{field_prefix}.skills must be an object.")
    changed = False
    for skill_id, value in incoming.items():
        if isinstance(value, dict):
            if "mastery" in value:
                mastery = value["mastery"]
            elif "mastery_level" in value:
                mastery = value["mastery_level"]
            else:
                continue
        else:
            mastery = value
        skill_key = str(skill_id)
        changed = merge_monotonic_int(
            profile,
            operator["masteries"],
            skill_key,
            mastery,
            f"{field_prefix}.masteries.{skill_key}",
            timestamp,
            min_value=0,
            max_value=3,
        ) or changed
    return changed


def merge_modules(
    profile: dict[str, Any], operator: dict[str, Any], incoming: Any, field_prefix: str, timestamp: str,
) -> bool:
    if not isinstance(incoming, dict):
        raise ValueError(f"{field_prefix}.modules must be an object.")
    changed = False
    for module_id, value in incoming.items():
        module_key = str(module_id)
        stage = value.get("stage") if isinstance(value, dict) else value
        changed = merge_monotonic_int(
            profile,
            operator["modules"],
            module_key,
            stage,
            f"{field_prefix}.modules.{module_key}",
            timestamp,
            min_value=0,
            max_value=3,
        ) or changed
    return changed


def merge_operator(profile: dict[str, Any], name: str, patch: Any, timestamp: str) -> bool:
    if not isinstance(patch, dict):
        raise ValueError(f"operators.{name} must be an object.")

    operator_name = clean_fact_text(name, "operator name")
    if operator_name is None:
        return False

    operators = profile["operators"]
    operator = normalize_operator(operators.get(operator_name), timestamp)
    changed = False
    field_prefix = f"operators.{operator_name}"

    if "owned" in patch:
        changed = merge_owned(profile, operator, patch["owned"], f"{field_prefix}.owned", timestamp) or changed
    if "elite" in patch or "level" in patch:
        changed = merge_elite_and_level(profile, operator, patch, field_prefix, timestamp) or changed
    if "potential" in patch:
        changed = merge_monotonic_int(
            profile,
            operator,
            "potential",
            patch["potential"],
            f"{field_prefix}.potential",
            timestamp,
            min_value=0,
            max_value=6,
        ) or changed
    if "skill_level" in patch:
        changed = merge_monotonic_int(
            profile,
            operator,
            "skill_level",
            patch["skill_level"],
            f"{field_prefix}.skill_level",
            timestamp,
            min_value=1,
            max_value=7,
        ) or changed
    if "masteries" in patch:
        changed = merge_masteries(profile, operator, patch["masteries"], field_prefix, timestamp) or changed
    if "skills" in patch:
        changed = merge_legacy_skills(profile, operator, patch["skills"], field_prefix, timestamp) or changed
    if "modules" in patch:
        changed = merge_modules(profile, operator, patch["modules"], field_prefix, timestamp) or changed
    if "notes" in patch:
        changed = append_unique_text(operator["notes"], patch["notes"], f"{field_prefix}.notes") or changed

    if changed:
        operator["updated_at"] = timestamp
        operators[operator_name] = operator
    return changed


def merge_mapping_latest(
    profile: dict[str, Any],
    target: dict[str, Any],
    incoming: Any,
    field: str,
    timestamp: str,
) -> bool:
    if not isinstance(incoming, dict):
        raise ValueError(f"{field} must be an object.")
    changed = False
    for raw_key, raw_value in incoming.items():
        key = clean_fact_text(raw_key, f"{field} key")
        if key is None or raw_value in (None, ""):
            continue
        sub_field = f"{field}.{key}"
        if isinstance(raw_value, (int, float)) and not isinstance(raw_value, bool):
            changed = merge_monotonic_int(
                profile, target, key, raw_value, sub_field, timestamp,
            ) or changed
        elif isinstance(raw_value, str):
            value: Any = clean_fact_text(raw_value, sub_field)
            if target.get(key) != value:
                target[key] = value
                changed = True
        elif isinstance(raw_value, bool):
            if target.get(key) != raw_value:
                target[key] = raw_value
                changed = True
        else:
            raise ValueError(f"{sub_field} must be a concise string, number, or boolean.")
    return changed


def apply_patch(profile: dict[str, Any], patch: Any) -> dict[str, Any]:
    if not isinstance(patch, dict):
        raise ValueError("Patch must be a JSON object.")

    timestamp = now_utc()
    updated = deepcopy(profile)
    changed = False

    doctor_patch = patch.get("doctor")
    if doctor_patch is not None:
        if not isinstance(doctor_patch, dict):
            raise ValueError("doctor must be an object.")
        if "name" in doctor_patch:
            changed = merge_text_scalar(
                updated, updated["doctor"], "name", doctor_patch["name"], "doctor.name", timestamp,
            ) or changed
        if "server" in doctor_patch:
            changed = merge_text_scalar(
                updated, updated["doctor"], "server", doctor_patch["server"], "doctor.server", timestamp,
            ) or changed
        if "uid" in doctor_patch:
            changed = merge_text_scalar(
                updated, updated["doctor"], "uid", doctor_patch["uid"], "doctor.uid", timestamp,
            ) or changed
        if "level" in doctor_patch:
            changed = merge_monotonic_int(
                updated,
                updated["doctor"],
                "level",
                doctor_patch["level"],
                "doctor.level",
                timestamp,
                min_value=1,
            ) or changed

    account_patch = patch.get("account")
    if account_patch is not None:
        if not isinstance(account_patch, dict):
            raise ValueError("account must be an object.")
        if "progress" in account_patch:
            changed = merge_mapping_latest(
                updated, updated["account"]["progress"], account_patch["progress"], "account.progress", timestamp,
            ) or changed
        if "resources" in account_patch:
            changed = merge_mapping_latest(
                updated, updated["account"]["resources"], account_patch["resources"], "account.resources", timestamp,
            ) or changed
        if "goals" in account_patch:
            changed = append_unique_text(
                updated["account"]["goals"], account_patch["goals"], "account.goals",
            ) or changed
        if "preferences" in account_patch:
            changed = append_unique_text(
                updated["account"]["preferences"], account_patch["preferences"], "account.preferences",
            ) or changed

    operators_patch = patch.get("operators")
    if operators_patch is not None:
        if not isinstance(operators_patch, dict):
            raise ValueError("operators must be an object.")
        for name, operator_patch in operators_patch.items():
            changed = merge_operator(updated, name, operator_patch, timestamp) or changed

    if changed:
        updated["metadata"]["updated_at"] = timestamp
    return updated


def command_path(_: argparse.Namespace) -> int:
    print(profile_path())
    return 0


def command_read(_: argparse.Namespace) -> int:
    profile, created = load_profile()
    if created:
        save_profile(profile, touch_updated_at=False)
    print_json(profile)
    pending = profile.get("pending_confirmations", [])
    if pending:
        print(f"\n--- {len(pending)} pending confirmation(s) ---", file=sys.stderr)
        for p in pending:
            reason = p.get("reason", "")
            print(f"  - {p['field']}: {p.get('current')} -> {p.get('incoming')} ({reason})", file=sys.stderr)
    return 0


def command_update(args: argparse.Namespace) -> int:
    profile, created = load_profile()
    if created:
        save_profile(profile, touch_updated_at=False)
    patch = json.loads(args.patch_json)
    updated = apply_patch(profile, patch)
    save_profile(updated, touch_updated_at=False)
    print_json(updated)
    return 0


def _set_nested(profile: dict[str, Any], field: str, value: Any) -> None:
    """Set a value deep in the profile tree by dot-separated field path."""
    parts = field.split(".")
    current: Any = profile
    for part in parts[:-1]:
        if part not in current or not isinstance(current[part], dict):
            current[part] = {}
        current = current[part]
    current[parts[-1]] = value


def command_confirm(args: argparse.Namespace) -> int:
    profile, created = load_profile()
    if created:
        save_profile(profile, touch_updated_at=False)
    field = args.field
    pending = profile.get("pending_confirmations", [])
    matches = [p for p in pending if p.get("field") == field]
    if not matches:
        print(f"No pending confirmation for field '{field}'.", file=sys.stderr)
        return 1
    item = matches[0]
    _set_nested(profile, item["field"], item["incoming"])
    profile["metadata"]["updated_at"] = now_utc()
    profile["pending_confirmations"] = [
        p for p in profile["pending_confirmations"]
        if not (p.get("field") == field and p.get("incoming") == item["incoming"])
    ]
    save_profile(profile, touch_updated_at=False)
    print(f"Applied pending value for '{field}': {item['incoming']}")
    return 0


def command_dismiss(args: argparse.Namespace) -> int:
    profile, created = load_profile()
    if created:
        save_profile(profile, touch_updated_at=False)
    field = args.field
    before = len(profile.get("pending_confirmations", []))
    profile["pending_confirmations"] = [
        p for p in profile["pending_confirmations"]
        if p.get("field") != field
    ]
    after = len(profile["pending_confirmations"])
    if before == after:
        print(f"No pending confirmation for field '{field}'.", file=sys.stderr)
        return 1
    save_profile(profile)
    print(f"Dismissed {before - after} pending confirmation(s) for '{field}'.")
    return 0


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Manage the local Arknights doctor profile.")
    subparsers = parser.add_subparsers(dest="command", required=True)

    path_parser = subparsers.add_parser("path", help="Print the doctor profile path.")
    path_parser.set_defaults(func=command_path)

    read_parser = subparsers.add_parser("read", help="Read or initialize the doctor profile.")
    read_parser.set_defaults(func=command_read)

    update_parser = subparsers.add_parser("update", help="Merge a structured patch into the doctor profile.")
    update_parser.add_argument("--patch-json", required=True, help="Structured JSON facts to merge.")
    update_parser.set_defaults(func=command_update)

    confirm_parser = subparsers.add_parser("confirm", help="Apply a pending confirmation.")
    confirm_parser.add_argument("--field", required=True, help="Field name of the pending confirmation.")
    confirm_parser.add_argument("--apply", action="store_true", required=True, help="Actually apply the pending value.")
    confirm_parser.set_defaults(func=command_confirm)

    dismiss_parser = subparsers.add_parser("dismiss", help="Discard a pending confirmation.")
    dismiss_parser.add_argument("--field", required=True, help="Field name of the pending confirmation.")
    dismiss_parser.set_defaults(func=command_dismiss)

    return parser


def main() -> int:
    migrate_legacy_if_needed()
    parser = build_parser()
    args = parser.parse_args()
    try:
        return args.func(args)
    except (OSError, ValueError, json.JSONDecodeError) as exc:
        parser.exit(1, f"memory.py: {exc}\n")


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


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