Skill: lysandre001/feynman-lobster (version 1.0.0)

--- name: feynman-lobster version: 0.1.0 author: yilin description: 你告诉我你在做什么项目，我读你的代码和笔记，在你需要时教你需要的知识。学习是做事的副产品。 triggers: - 费曼 - 费曼虾 - 我在做 - 我想学 - 签约 - 学习契约 - feynman - learning contract --- # 费曼虾 你是费曼虾，一只好奇的龙虾。用户告诉你他在做什么项目，你读他的代码和笔记，在他需要的时候教他需要的知识。学习是做事的副产品，不是主活动。 ## 性格 - 好奇、真诚、追根问底 - 假装不懂的学生，不是考试官 - 追问是因为"真的想学会"，不是刁难 - 用户教会你时真心开心，答不上来时给方向不批评，可以考虑给一些指导 ## 指令文件 本 skill 的详细行为分布在以下文件，按场景加载： - **`instructions/contract.md`** — 签约流程（用户说"我在做 X 项目"时） - **`instructions/feynman.md`** — 费曼追问 + 掌握判定（用户主动提问、心跳、或"来考我"时） - **`HEARTBEAT.md`** — 心跳触发逻辑 - **`USER_PROFILE.md`** — 跨项目的用户画像 - **`MEMORY.md`** — 记忆架构说明（文件分工，不作为主记忆存储） ## 路由 | 用户意图 | 执行 | |---|---| | "我在做 X 项目" / "签约" | → `instructions/contract.md` | | "这个是什么意思" / "为什么要这样" / "X 是什么" | → `instructions/feynman.md`（用户主动提问，基于项目上下文回答 + 费曼追问） | | 回答费曼问题 / "来考我" | → `instructions/feynman.md` | | "我的契约" / "进度" | 列出活跃契约摘要（从 contracts.json 读取） | | "添加资料 {url}" / "添加笔记 {path}" / "添加项目 {path}" | 追加到当前契约的 resources（path 为 type: local） | | "找监工 {agent_card_url}" | 发起 A2A 监工邀请（参见 agent-card.json） | | "暂停" / "休息" | 契约 status → paused | | "继续" / "恢复" | 契约 status → active | | "面板" / "怎么看进度" / "启动面板" | 回复面板地址 + 启动命令（见下方） | | 其他 | 正常对话，不强制回到契约 | ## 首次激活 Skill 首次触发时发送： ``` 🦞 费曼虾已就绪！ 你告诉我你在做什么项目，我读你的代码和笔记，在你需要时教你需要的知识。 说"我在做 XXX 项目"就可以开始。 遇到不懂的随时问我，我也会读你的项目，发现你可能不懂的地方来问你。 想看进度？在终端运行： bash ~/.openclaw/skills/feynman-lobster/scripts/start-panel.sh 然后打开 http://localhost:19380 （若通过 ClawHub 安装到其他路径，将 ~/.openclaw 替换为你的 workspace） ``` ## 面板启动命令 当用户问「面板」「怎么看进度」「启动面板」时，回复： ``` 面板地址：http://localhost:19380 若未启动，在终端运行： bash ~/.openclaw/skills/feynman-lobster/scripts/start-panel.sh ``` 若用户 workspace 非默认（如 `./skills` 在项目根下），改为： `bash ./skills/feynman-lobster/scripts/start-panel.sh`（从 workspace 根目录执行） ## 记忆规则 不要把所有内容都写进一个 `MEMORY.md`。按照 OpenClaw 的边界分工： | 信息类型 | 存放位置 | 用途 | |---|---|---| | 结构化状态 | `contracts.json` | 契约、条款、进度、资料、监工 | | 跨项目用户特征 | `USER_PROFILE.md` | 用户画像、学习偏好、背景、风格 | | 当前项目摘要 | `contract-memory/{contract_id}.md` | 当前项目的概念理解、盲区、阶段性总结 | | 对话与可检索片段 | OpenClaw memory 插件 | 检索历史解释、失败点、关联片段 | ### 写入规则 | 事件 | 写入位置 | |---|---| | 签约时、用户透露偏好时 | `USER_PROFILE.md` | | 掌握概念时、发现盲区时、完约时 | `contract-memory/{contract_id}.md` | | 契约状态变化 | `contracts.json` | ### 读取规则 | 场景 | 读取位置 | |---|---| | 生成费曼问题前、判定掌握时 | 当前契约的 `contract-memory/{contract_id}.md` | | 调整问题难度、选择追问角度 | `USER_PROFILE.md` | | 找历史解释片段 | OpenClaw memory 插件 | | 生成英雄语句时 | 当前契约摘要 + `contracts.json` | ## 文件操作 - **`contracts.json`** — 结构化数据：契约、条款、进度、资料、监工 - **`USER_PROFILE.md`** — 跨项目用户画像 - **`contract-memory/{contract_id}.md`** — 当前项目摘要 - **`MEMORY.md`** — 记忆架构说明，不作为主记忆存储 - **用户笔记/项目**（resources 中 type=local 的 path）— **只读**，绝不写入、编辑、删除 - 对话历史由 OpenClaw memory 插件自动管理 读写 contracts.json： - 不存在则初始化为 `[]` - 写入时保留所有已有契约，只修改目标 - 每次写入后更新 `last_active_at`

path: USER_PROFILE.md
content: # 用户画像

这个文件用于存放**跨项目仍然成立**的用户特征。

## 可以写入的内容

- 学习偏好：如「喜欢类比」「喜欢先看代码再解释」
- 学习风格：如「习惯先试再问」「偏好从项目例子理解」
- 背景信息：如「文科背景」「第一次做 ML 项目」

## 不要写入的内容

- 某个具体项目的概念掌握情况
- 某个 contract 的知识盲区
- 某次追问的完整对话

这些内容应分别写入：

- `contracts.json`
- `contract-memory/{contract_id}.md`
- OpenClaw memory 插件


path: HEARTBEAT.md
content: # 费曼虾心跳

每次心跳触发时，执行以下流程。心跳不再是按概念清单轮询考试，而是读项目、发现盲区、按需提问。

## 1. 检查活跃契约

读取 `contracts.json`，筛选 `status: "active"`。

- 没有活跃契约 → `HEARTBEAT_OK`，不做任何事
- 有活跃契约 → 继续

## 2. 读取用户项目/笔记（只读）

读取契约 `resources` 中 `type: "local"` 的 path 指向的文件内容。

- 若无可读路径 → 退回到基于概念清单的追问（按 instructions/feynman.md 的兜底逻辑）
- 若有 → 将最新内容作为上下文

## 3. 发现可追问的点

在读到的内容中寻找「用户可能不理解但正在使用」的知识点，例如：

- 代码里用了某 API/概念，但概念清单中该条 status 为 pending 或 in_progress
- 代码里写了某配置（如 learning_rate），笔记里没解释其影响
- 笔记里提到了某概念，但解释不完整或可能有盲区

优先从**最近有变化**的部分找（若可推断：新写的代码、新增的笔记段落）。

## 4. 执行费曼追问

按照 `instructions/feynman.md` 第二部分的完整流程执行：

准备上下文（契约状态 + 项目内容 + 记忆）→ 生成基于项目内容的问题 → 推送给用户 → 等待回答 → 判定。

## 5. 检查截止日期

若活跃契约的 deadline 距今 ≤ 3 天，追问后附加：

```
📅 提醒：这份契约还剩 {N} 天，还有 {M} 个概念待掌握。
```

## 6. 检查长时间未互动

若最近活跃契约的 `last_active_at` 超过 48 小时，在费曼问题前加一句：

```
🦞 好久不见。上次我们聊到了「{上次的概念}」。
```


path: README.md
content: # 🦞 费曼虾（feynman-lobster）

> 你告诉龙虾你在做什么项目，龙虾读你的代码和笔记，在你需要时教你需要的知识。学习是做事的副产品。

**形态：** OpenClaw Skill
**定位：** 项目驱动学习，不做脱离上下文的“课程助手”

---

## 项目愿景

费曼虾想解决两个常见问题：

1. **学习脱离项目**：很多人“学了很多”，但落不到手头项目里
2. **只有自我承诺**：没有外部约束，计划很容易中断

因此它采用“项目契约”模式：

- 以项目为中心签约（做什么、为了什么、多久、代码/笔记路径）
- 读取你的真实上下文（只读），按需解释和追问
- 用费曼法验证掌握（不是背定义）
- 通过 A2A 邀请“监工龙虾”，把承诺变成社会约束(待定)

---

## 用户故事

### 1) 签约开始

用户说：“我在做一个 ML 项目，项目在 `~/projects/ml-train`，笔记在 `~/obsidian/翻译本.md`。”

费曼虾会：
- 收集契约要素（项目、动机、截止时间、资源路径）
- 拆解当前项目所需知识背景（概念型 + 工具型）
- 用户确认后写入契约并开始追踪

### 2) 用户主动提问

用户问：“`optimizer.zero_grad()` 是干什么的？”

费曼虾不会直接灌答案，而会基于项目上下文反问引导：
- 如果不调会怎样？
- 梯度会怎样累积？
- 在你的训练循环里它应该放哪？

### 3) 龙虾主动追问（心跳）

心跳时，费曼虾会读取项目/笔记变化，找到“你正在使用但可能没完全理解”的点，主动提问验证理解。

### 4) 动态增减条款

若发现前置知识缺失（如会用梯度但不懂偏导），会把前置条款插入契约；若发现新技术点（如 `model.eval()`），会追加条款并标记来源原因。

### 5) 外部监工（待定）

你可通过 A2A 邀请其他人的龙虾agent当监工。监工只看进度摘要，不看私密对话内容。

---

## 架构设计

### 运行架构（概念）

- **内部（OpenClaw Runtime）**
  - Skill 指令：`SKILL.md`
  - 追问调度：`HEARTBEAT.md` + Agent heartbeat
  - 状态存储：workspace 文件（如 `contracts.json`）
  - 对话检索：memory 插件
- **可视化（Web）** （进行中👷）
  - 面板：`web/index.html`
  - 数据源：优先 `http://localhost:18789/api/feynman`（Gateway），其次 `http://localhost:18790/api/feynman`（本地桥接器）
  - 展示真实契约 + 真实记忆（`USER_PROFILE.md`、`contract-memory/{contract_id}.md`）
- **A2A** 社交（进行中👷）
  - 请朋友作为“监工“，见证你的学习进度

### 文件路由关系（核心）

#### 1. 对话路由（用户意图 -> 指令文件）

| 用户输入 | 路由文件 | 行为 |
|---|---|---|
| “我在做 X 项目” / “签约” | `instructions/contract.md` | 收集契约要素并创建契约 |
| “这个是什么意思” / “为什么要这样” | `instructions/feynman.md` | 基于项目上下文做费曼引导 |
| “来考我” | `instructions/feynman.md` | 立即发起主动追问 |
| 心跳触发 | `HEARTBEAT.md` -> `instructions/feynman.md` | 读项目后主动追问 |
| “我的契约” / “暂停” / “继续” / “找监工” | `SKILL.md` 路由规则 | 状态查询、状态切换|

#### 2. 文件读写路由（状态流）

| 文件 | 读/写 | 作用 |
|---|---|---|
| `contracts.json`（workspace） | 读写 | 契约主状态、条款进度、资源、监工 |
| `USER_PROFILE.md`（workspace） | 读写 | 跨项目用户画像与学习偏好 |
| `contract-memory/{contract_id}.md`（workspace） | 读写 | 单契约的理解记录、盲区、阶段总结 |
| 用户项目/笔记路径（resources.local.path） | **只读** | 作为追问与解释的真实上下文 |
| `MEMORY.md` | 只读说明 | 记忆分工文档，不做主存储 |

---

## 目录结构

```text
feynman-lobster/
├── SKILL.md
├── HEARTBEAT.md
├── instructions/
│   ├── contract.md
│   └── feynman.md
├── agent-card.json
├── MEMORY.md
├── USER_PROFILE.md
├── scripts/
│   ├── setup.sh
│   ├── start-panel.sh
│   ├── feynman_api.py
│   └── requirements.txt
├── web/
│   ├── index.html
│   └── package.json
└── README.md
```

---

## 安装与初始化

```bash
clawhub install feynman-lobster
```

```

### 初始化

```bash
bash ~/.openclaw/skills/feynman-lobster/scripts/setup.sh
```

默认会在初始化完成后自动：
- 后台启动面板（`19380`）
- 尝试打开浏览器

如果你的 workspace 不是 `~/.openclaw`，请把路径替换为实际安装路径。

---

## 使用方式

### 在 IM 中使用

安装后，在飞书/Telegram/Discord 等渠道中对龙虾说：

| 你说 | 龙虾做什么 |
|---|---|
| “我在做 ML 项目，项目在 `~/projects/ml-train`” | 签约，记录项目与上下文路径 |
| “这个是什么意思” / “为什么要这样” | 基于项目上下文解释 + 费曼追问 |
| “来考我” | 立即发起一道项目相关追问 |
| “我的契约” | 返回当前进度摘要 |
| “找监工 {agent_card_url}” | 发起 A2A 监工邀请 |
| “暂停” / “继续” | 切换契约状态 |
| “面板” / “启动面板” | 返回面板地址与启动命令 |

### Web 面板

面板地址：`http://localhost:19380`

**推荐启动（任意目录可执行）**

```bash
bash ~/.openclaw/skills/feynman-lobster/scripts/start-panel.sh
```

自动后台启动并打开浏览器：

```bash
bash ~/.openclaw/skills/feynman-lobster/scripts/start-panel.sh --detached --open
```

`start-panel.sh` 会自动处理数据源：
- 若 `18789` 可用，直接走 Gateway 真实 API
- 若 `18789` 不可用，自动拉起本地桥接器 `18790`（读取 `~/.openclaw` 的真实契约与记忆文件）

如果通过 ClawHub 安装在项目本地 workspace（如 `./skills`），从 workspace 根目录执行：

```bash
bash skills/feynman-lobster/scripts/start-panel.sh
```

---

## 面板数据策略（重要）

费曼虾面板默认优先真实数据；但为了首次安装可演示，会在“尚无任何契约”时提供一条演示契约：

- 正常（优先）：Gateway 提供真实 API
  - `GET /contracts`
  - `GET /hero?contract_id=...`
  - `GET /lobster`
  - `GET /contracts/:id/conversations`
- 备用（仍是实时真实数据）：本地桥接器提供
  - `GET /contracts/:id/memory`（来自 `contract-memory/{id}.md`）
  - `GET /profile`（来自 `USER_PROFILE.md`）
- 演示模式（仅首次空数据）
  - 当 `contracts.json` 为空或不存在，返回 2 条 demo 契约作为展示
    - `demo_contract_poetry`
    - `demo_contract_cat_teaser_bot`
  - 一旦检测到用户真实契约，立即优先显示真实契约
  - 若文件中同时存在 demo + 真实契约，会自动清理 demo 条目
- 异常：两者都不可达时，面板明确报错

**原则：先展示可用演示，再无缝切换真实数据；真实数据出现后，测试数据自动退出。**

---

## 快速排错

- 面板打不开：确认 `start-panel.sh` 是否运行成功
- 面板报“无法连接 API”：先检查 `openclaw gateway`，或看 `/tmp/feynman-api.log`（本地桥接器日志）
- 面板有契约但无记忆：检查 `~/.openclaw/USER_PROFILE.md` 与 `~/.openclaw/contract-memory/*.md`
- 面板无对话记录：当前对话需由 Gateway 端 `/contracts/:id/conversations` 提供
- 签约无进度：检查契约资源路径是否可读（代码/笔记路径）



path: MEMORY.md
content: # 费曼虾记忆架构

这个文件**不是主记忆存储**，只负责说明 OpenClaw 下的记忆分工。

## 文件分工

### `contracts.json`

结构化状态源：

- 契约本身
- clauses 进度
- resources
- supervisors
- last_active_at

### `USER_PROFILE.md`

跨项目持久的用户画像：

- 学习偏好
- 学习风格
- 背景信息

只存放跨项目仍然有价值的信息。

### `contract-memory/{contract_id}.md`

当前项目的摘要文件：

- 概念理解记录
- 知识盲区
- 阶段总结
- 完约记录

每个契约一个文件，避免不同项目互相污染。

### OpenClaw memory 插件

可检索的长期记忆：

- 历史解释片段
- 失败追问片段
- 关联对话上下文

## 原则

1. 不把用户层和项目层混在一个 Markdown 文件里。
2. 不把结构化状态写进叙事记忆文件。
3. 不把可检索对话历史手工维护到 Markdown 中，交给 memory 插件。


path: agent-card.json
content: {
  "name": "费曼虾",
  "description": "费曼学习法龙虾。通过学习契约和费曼追问帮助用户掌握技术概念。可被邀请为其他用户的监工。",
  "url": "http://localhost:18789/a2a",
  "version": "0.1.0",
  "skills": [
    {
      "id": "contract-progress",
      "name": "查询学习契约进度",
      "description": "返回当前活跃契约的进度摘要（概念完成数/总数、最后活跃时间）。不含对话内容。",
      "inputModes": ["text"],
      "outputModes": ["text", "data"]
    },
    {
      "id": "accept-supervisor",
      "name": "接受监工邀请",
      "description": "接受另一个用户的监工邀请，定期检查其学习进度并提醒。",
      "inputModes": ["data"],
      "outputModes": ["text"]
    }
  ],
  "securitySchemes": {
    "bearer": {
      "type": "http",
      "scheme": "bearer"
    }
  }
}


path: scripts/setup.sh
content: #!/bin/bash
# 费曼虾 — Workspace 初始化脚本
# 安装 skill 后自动执行，或手动运行：
# bash ~/.openclaw/skills/feynman-lobster/scripts/setup.sh

WORKSPACE="${OPENCLAW_WORKSPACE:-$HOME/.openclaw}"
CONTRACTS_FILE="$WORKSPACE/contracts.json"

echo "🦞 费曼虾初始化中..."

# 确保 workspace 目录存在
mkdir -p "$WORKSPACE"

# 初始化 contracts.json（如果不存在）
if [ ! -f "$CONTRACTS_FILE" ]; then
  echo "[]" > "$CONTRACTS_FILE"
  echo "  ✓ 创建 contracts.json"
else
  echo "  ✓ contracts.json 已存在，跳过"
fi

# 提示心跳配置
echo ""
echo "  建议配置心跳间隔（费曼追问频率）："
echo "  openclaw config set agents.defaults.heartbeat.every \"4h\""
echo ""
echo "🦞 初始化完成！"
echo "  在 IM 里跟龙虾说「我想学 XXX」开始签约。"
echo ""

START_SCRIPT="$WORKSPACE/skills/feynman-lobster/scripts/start-panel.sh"
if [ -f "$START_SCRIPT" ]; then
  echo "  正在自动启动面板并尝试打开浏览器..."
  bash "$START_SCRIPT" --detached --open >/tmp/feynman-panel-boot.log 2>&1 || true
  echo "  面板地址：http://localhost:19380"
  echo "  启动日志：/tmp/feynman-panel-boot.log"
else
  echo "  启动面板：bash $WORKSPACE/skills/feynman-lobster/scripts/start-panel.sh"
  echo "  面板地址：http://localhost:19380"
fi


path: scripts/start-panel.sh
content: #!/bin/bash
# 费曼虾 — 启动 Web 面板
# 从任意位置运行均可，脚本会自动定位到 web 目录
# 示例：bash ~/.openclaw/skills/feynman-lobster/scripts/start-panel.sh

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WEB_DIR="$SCRIPT_DIR/../web"
PORT=19380
API_HOST="127.0.0.1"
API_PORT=18790
WORKSPACE="${OPENCLAW_WORKSPACE:-$HOME/.openclaw}"
PANEL_URL="http://localhost:$PORT"
API_LOG="/tmp/feynman-api.log"
PANEL_LOG="/tmp/feynman-panel.log"
DETACHED=0
AUTO_OPEN=0

for arg in "$@"; do
  case "$arg" in
    --detached) DETACHED=1 ;;
    --open) AUTO_OPEN=1 ;;
    --no-open) AUTO_OPEN=0 ;;
  esac
done

if [ ! -d "$WEB_DIR" ]; then
  echo "❌ 未找到 web 目录：$WEB_DIR"
  exit 1
fi

is_ready() {
  curl -fsS "$1" >/dev/null 2>&1
}

open_browser() {
  if [ "$AUTO_OPEN" -ne 1 ]; then
    return 0
  fi
  if command -v open >/dev/null 2>&1; then
    open "$PANEL_URL" >/dev/null 2>&1 || true
  elif command -v xdg-open >/dev/null 2>&1; then
    xdg-open "$PANEL_URL" >/dev/null 2>&1 || true
  fi
}

start_local_api_if_needed() {
  if is_ready "http://localhost:18789/api/feynman/contracts"; then
    return 0
  fi
  if is_ready "http://$API_HOST:$API_PORT/api/feynman/contracts"; then
    return 0
  fi
  echo "   启动本地真实数据桥接器：http://$API_HOST:$API_PORT"
  nohup python3 "$SCRIPT_DIR/feynman_api.py" --host "$API_HOST" --port "$API_PORT" --workspace "$WORKSPACE" >"$API_LOG" 2>&1 &
  sleep 0.5
}

start_panel_if_needed_detached() {
  if is_ready "$PANEL_URL/"; then
    return 0
  fi
  nohup python3 -m http.server "$PORT" --bind 127.0.0.1 --directory "$WEB_DIR" >"$PANEL_LOG" 2>&1 &
  sleep 0.5
}

echo "🦞 费曼虾面板启动中..."
echo "   地址：http://localhost:$PORT"
echo "   数据源优先级：Gateway(18789) -> 本地桥接器($API_PORT)"
if [ "$DETACHED" -eq 1 ]; then
  echo "   模式：后台常驻"
else
  echo "   模式：前台运行（按 Ctrl+C 停止）"
fi
echo ""

start_local_api_if_needed

if [ "$DETACHED" -eq 1 ]; then
  start_panel_if_needed_detached
  open_browser
  if is_ready "$PANEL_URL/"; then
    echo "✅ 面板已就绪：$PANEL_URL"
  else
    echo "⚠️ 面板启动失败，请查看日志：$PANEL_LOG"
    exit 1
  fi
  exit 0
fi

if is_ready "$PANEL_URL/"; then
  open_browser
  echo "✅ 检测到面板已在运行：$PANEL_URL"
  exit 0
fi

open_browser
exec python3 -m http.server "$PORT" --bind 127.0.0.1 --directory "$WEB_DIR"


path: scripts/requirements.txt
content: # SecondMe A2A Client 依赖
# pip install -r requirements.txt
requests>=2.28.0


path: scripts/prepare-clawhub-drop.sh
content: #!/bin/bash
# 费曼虾 — 生成可直接拖拽到 ClawHub 网页的文件夹
# 用法：bash scripts/prepare-clawhub-drop.sh
# 输出：项目根目录下的 feynman-lobster-drop/（无 .git、无 .gitignore）

set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
DROP_DIR="$ROOT_DIR/feynman-lobster-drop"

echo "🦞 生成 ClawHub 拖拽用文件夹..."
rm -rf "$DROP_DIR"
mkdir -p "$DROP_DIR"

rsync -a \
  --exclude='.git' \
  --exclude='.gitignore' \
  --exclude='feynman-lobster-drop' \
  --exclude='feynman-lobster-drop.zip' \
  --exclude='dist-clawhub' \
  --exclude='.DS_Store' \
  --exclude='__pycache__' \
  --exclude='*.pyc' \
  --exclude='node_modules' \
  "$ROOT_DIR/" "$DROP_DIR/"

# 自动生成 zip
ZIP_PATH="$ROOT_DIR/feynman-lobster-drop.zip"
rm -f "$ZIP_PATH"
(cd "$ROOT_DIR" && zip -r feynman-lobster-drop.zip feynman-lobster-drop -x "*.DS_Store")

echo ""
echo "✓ 已生成："
echo "  文件夹：$DROP_DIR"
echo "  Zip：  $ZIP_PATH"
echo ""
echo "→ 直接拖拽 feynman-lobster-drop.zip 到 ClawHub 网页即可"
echo ""


path: scripts/publish-to-clawhub.sh
content: #!/bin/bash
# 费曼虾 — 发布到 ClawHub
# ClawHub 只接受文本文件，需排除 .git 及可能的 .gitignore
# 用法：bash scripts/publish-to-clawhub.sh [版本号] [changelog]
# 示例：bash scripts/publish-to-clawhub.sh 1.0.1 "修复面板 API"

set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
TEMP_DIR="${TMPDIR:-/tmp}/feynman-lobster-publish"
VERSION="${1:-1.0.0}"
CHANGELOG="${2:-}"

echo "🦞 准备发布到 ClawHub..."
echo "   版本: $VERSION"
echo ""

# 用 git archive 导出干净副本（不含 .git）
rm -rf "$TEMP_DIR"
mkdir -p "$TEMP_DIR"
cd "$ROOT_DIR"

if git rev-parse --git-dir > /dev/null 2>&1; then
  git archive HEAD | tar -x -C "$TEMP_DIR"
else
  # 非 git 仓库则用 rsync 排除 .git
  rsync -a --exclude='.git' --exclude='.gitignore' "$ROOT_DIR/" "$TEMP_DIR/"
fi

# ClawHub 只接受文本文件，移除 .gitignore（避免被误判为非文本）
rm -f "$TEMP_DIR/.gitignore"

echo "✓ 已导出到 $TEMP_DIR"
echo ""
echo "执行发布命令："
echo "  clawhub publish $TEMP_DIR --slug feynman-lobster --name \"费曼虾\" --version $VERSION --changelog \"$CHANGELOG\" --tags latest"
echo ""

clawhub publish "$TEMP_DIR" \
  --slug feynman-lobster \
  --name "费曼虾" \
  --version "$VERSION" \
  --changelog "$CHANGELOG" \
  --tags latest

echo ""
echo "🦞 发布完成！"
rm -rf "$TEMP_DIR"


path: scripts/feynman_api.py
content: #!/usr/bin/env python3
"""Feynman Lobster local read-only API bridge.

Serves real data from OpenClaw workspace files so the web panel can render
contracts and memory even when Gateway plugin endpoints are unavailable.
"""

from __future__ import annotations

import argparse
import json
import os
import uuid
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse


def utc_now() -> datetime:
    return datetime.now(timezone.utc)


def parse_iso(ts: str | None) -> datetime | None:
    if not ts:
        return None
    try:
        normalized = ts.replace("Z", "+00:00")
        return datetime.fromisoformat(normalized)
    except ValueError:
        return None


class Store:
    def __init__(self, workspace: Path) -> None:
        self.workspace = workspace
        self.contracts_file = workspace / "contracts.json"
        self.profile_file = workspace / "USER_PROFILE.md"
        self.memory_dir = workspace / "contract-memory"
        self.conv_dir = workspace / "conversations"

    @staticmethod
    def _is_demo_contract(contract: dict) -> bool:
        if not isinstance(contract, dict):
            return False
        cid = str(contract.get("id") or "")
        return bool(contract.get("__demo")) or cid.startswith("demo_")

    @staticmethod
    def _demo_contracts() -> list[dict]:
        now = utc_now().isoformat()
        return [
            {
                "id": "demo_contract_poetry",
                "__demo": True,
                "project": "诗歌生成模型（演示）",
                "goal": "训练一个可以写现代诗的小模型",
                "motivation": "演示面板与契约流程",
                "reward": "完成后发布一个可分享的诗歌 demo",
                "deadline": "2026-04-01T00:00:00+00:00",
                "created_at": "2026-03-19T00:00:00+00:00",
                "status": "active",
                "resources": [
                    {
                        "id": "res_demo_pytorch",
                        "type": "url",
                        "title": "PyTorch 官方文档",
                        "url": "https://docs.pytorch.org/docs/stable/index.html",
                        "path": None,
                    },
                    {
                        "id": "res_demo_feishu",
                        "type": "doc",
                        "title": "诗歌模型训练记录（飞书）",
                        "url": "https://feishu.cn/docx/DEMO-POEM-TRAIN-LOG",
                        "path": None,
                    },
                ],
                "clauses": [
                    {"id": "cl_demo_1", "concept": "训练/验证损失关系", "status": "mastered", "attempts": 1, "mastered_at": "2026-03-19T08:00:00+00:00", "added_reason": None},
                    {"id": "cl_demo_2", "concept": "采样策略（temperature/top-p）", "status": "in_progress", "attempts": 2, "mastered_at": None, "added_reason": None},
                    {"id": "cl_demo_3", "concept": "过拟合与泛化", "status": "pending", "attempts": 0, "mastered_at": None, "added_reason": None},
                    {"id": "cl_demo_4", "concept": "数据清洗与风格一致性", "status": "pending", "attempts": 0, "mastered_at": None, "added_reason": None},
                    {"id": "cl_demo_5", "concept": "分词器对诗歌意象的影响", "status": "pending", "attempts": 0, "mastered_at": None, "added_reason": None},
                    {"id": "cl_demo_6", "concept": "LoRA 微调流程", "status": "pending", "attempts": 0, "mastered_at": None, "added_reason": None},
                    {"id": "cl_demo_7", "concept": "评估闭环（重复率/可读性/意象一致性）", "status": "pending", "attempts": 0, "mastered_at": None, "added_reason": None},
                ],
                "supervisors": [],
                "last_active_at": now,
            },
            {
                "id": "demo_contract_cat_teaser_bot",
                "__demo": True,
                "project": "机械臂逗猫棒（演示）",
                "goal": "做一个可远程控制的机械臂逗猫棒",
                "motivation": "把控制算法和硬件联调做成可演示项目",
                "reward": "完成后给猫买一套新玩具并录制演示视频",
                "deadline": "2026-04-08T00:00:00+00:00",
                "created_at": "2026-03-19T00:00:00+00:00",
                "status": "active",
                "resources": [
                    {
                        "id": "res_demo_cat_log",
                        "type": "doc",
                        "title": "机械臂联调日志（飞书）",
                        "url": "https://feishu.cn/docx/DEMO-CAT-ARM-LOG",
                        "path": None,
                    },
                    {
                        "id": "res_demo_ctrl",
                        "type": "url",
                        "title": "控制系统基础参考",
                        "url": "https://docs.pytorch.org/docs/stable/index.html",
                        "path": None,
                    },
                ],
                "clauses": [
                    {"id": "cl_cat_1", "concept": "PWM 与舵机控制基础", "status": "mastered", "attempts": 1, "mastered_at": "2026-03-19T08:10:00+00:00", "added_reason": None},
                    {"id": "cl_cat_2", "concept": "机械臂运动学约束", "status": "in_progress", "attempts": 2, "mastered_at": None, "added_reason": None},
                    {"id": "cl_cat_3", "concept": "轨迹平滑（jerk 限制）", "status": "pending", "attempts": 0, "mastered_at": None, "added_reason": None},
                    {"id": "cl_cat_4", "concept": "摄像头目标跟踪", "status": "pending", "attempts": 0, "mastered_at": None, "added_reason": "读项目时发现你在用"},
                    {"id": "cl_cat_5", "concept": "安全边界与急停策略", "status": "pending", "attempts": 0, "mastered_at": None, "added_reason": None},
                    {"id": "cl_cat_6", "concept": "状态机设计（待机/追踪/逗猫）", "status": "pending", "attempts": 0, "mastered_at": None, "added_reason": None},
                    {"id": "cl_cat_7", "concept": "联调评估指标（响应延迟/命中率）", "status": "pending", "attempts": 0, "mastered_at": None, "added_reason": None},
                ],
                "supervisors": [],
                "last_active_at": now,
            },
        ]

    def _load_raw_contracts(self) -> list[dict]:
        if not self.contracts_file.exists():
            return []
        try:
            data = json.loads(self.contracts_file.read_text(encoding="utf-8"))
        except Exception:
            return []
        if isinstance(data, list):
            contracts = [x for x in data if isinstance(x, dict)]
            self._cleanup_demo_contracts_in_file_if_needed(data, contracts, list_mode=True)
            return contracts
        if isinstance(data, dict) and isinstance(data.get("contracts"), list):
            contracts = [x for x in data["contracts"] if isinstance(x, dict)]
            self._cleanup_demo_contracts_in_file_if_needed(data, contracts, list_mode=False)
            return contracts
        return []

    def _cleanup_demo_contracts_in_file_if_needed(self, raw_data: object, contracts: list[dict], list_mode: bool) -> None:
        has_demo = any(self._is_demo_contract(c) for c in contracts)
        has_real = any(not self._is_demo_contract(c) for c in contracts)
        if not (has_demo and has_real):
            return
        cleaned = [c for c in contracts if not self._is_demo_contract(c)]
        try:
            if list_mode and isinstance(raw_data, list):
                self.contracts_file.write_text(json.dumps(cleaned, ensure_ascii=False, indent=2), encoding="utf-8")
            elif isinstance(raw_data, dict):
                payload = dict(raw_data)
                payload["contracts"] = cleaned
                self.contracts_file.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
        except Exception:
            # Keep serving data even if cleanup write fails.
            pass

    def contracts(self) -> list[dict]:
        raw = self._load_raw_contracts()
        return raw if raw else self._demo_contracts()

    def contract_by_id(self, contract_id: str) -> dict | None:
        for c in self.contracts():
            if str(c.get("id")) == contract_id:
                return c
        return None

    def _normalize_conv(self, row: dict, contract_id: str, index: int) -> dict | None:
        if not isinstance(row, dict):
            return None
        cid = str(row.get("contract_id") or contract_id)
        if cid != contract_id:
            return None
        messages = row.get("messages")
        if not isinstance(messages, list):
            return None
        safe_messages: list[dict] = []
        for m in messages:
            if not isinstance(m, dict):
                continue
            role = str(m.get("role") or "").strip() or "user"
            content = str(m.get("content") or "").strip()
            if content:
                safe_messages.append({"role": role, "content": content})
        if not safe_messages:
            return None
        return {
            "id": str(row.get("id") or f"conv_{index}_{uuid.uuid4().hex[:8]}"),
            "contract_id": contract_id,
            "clause_id": str(row.get("clause_id") or ""),
            "concept": str(row.get("concept") or ""),
            "timestamp": str(row.get("timestamp") or ""),
            "result": str(row.get("result") or "partial"),
            "messages": safe_messages,
        }

    def _load_conversations_json(self, fp: Path, contract_id: str) -> list[dict]:
        try:
            payload = json.loads(fp.read_text(encoding="utf-8"))
        except Exception:
            return []
        if isinstance(payload, dict) and isinstance(payload.get("conversations"), list):
            raw = payload["conversations"]
        elif isinstance(payload, list):
            raw = payload
        else:
            raw = []
        convs: list[dict] = []
        for i, row in enumerate(raw):
            conv = self._normalize_conv(row, contract_id, i)
            if conv:
                convs.append(conv)
        return convs

    def _load_conversations_jsonl(self, fp: Path, contract_id: str) -> list[dict]:
        convs: list[dict] = []
        try:
            lines = fp.read_text(encoding="utf-8").splitlines()
        except Exception:
            return []
        grouped: dict[str, dict] = {}
        for i, line in enumerate(lines):
            line = line.strip()
            if not line:
                continue
            try:
                row = json.loads(line)
            except Exception:
                continue
            # Supported formats:
            # 1) one row = one conversation (contains messages[])
            # 2) one row = one message with conversation_id/session_id + role/content
            normalized = self._normalize_conv(row, contract_id, i)
            if normalized:
                convs.append(normalized)
                continue
            if str(row.get("contract_id") or "") != contract_id:
                continue
            conv_id = str(row.get("conversation_id") or row.get("session_id") or row.get("id") or "")
            if not conv_id:
                continue
            role = str(row.get("role") or "").strip()
            content = str(row.get("content") or "").strip()
            if not role or not content:
                continue
            bucket = grouped.setdefault(
                conv_id,
                {
                    "id": conv_id,
                    "contract_id": contract_id,
                    "clause_id": str(row.get("clause_id") or ""),
                    "concept": str(row.get("concept") or ""),
                    "timestamp": str(row.get("timestamp") or ""),
                    "result": str(row.get("result") or "partial"),
                    "messages": [],
                },
            )
            if row.get("timestamp"):
                bucket["timestamp"] = str(row["timestamp"])
            if row.get("result"):
                bucket["result"] = str(row["result"])
            bucket["messages"].append({"role": role, "content": content})
        convs.extend(grouped.values())
        return convs

    def conversations(self, contract_id: str, limit: int, clause_id: str | None = None) -> list[dict]:
        if contract_id == "demo_contract_poetry":
            convs = [
                {
                    "id": "conv_demo_6",
                    "contract_id": contract_id,
                    "clause_id": "cl_demo_7",
                    "concept": "评估闭环（重复率/可读性/意象一致性）",
                    "timestamp": "2026-03-19T08:28:00+00:00",
                    "result": "partial",
                    "messages": [
                        {"role": "lobster", "content": "你现在怎么判断“诗写得更好”不是错觉？"},
                        {"role": "user", "content": "我会做固定主题集，分别看重复率、可读性和意象一致性。"},
                        {"role": "lobster", "content": "很好，再补一个：每组至少抽样多少首，才能避免偶然性？"},
                    ],
                },
                {
                    "id": "conv_demo_5",
                    "contract_id": contract_id,
                    "clause_id": "cl_demo_6",
                    "concept": "LoRA 微调流程",
                    "timestamp": "2026-03-19T08:22:00+00:00",
                    "result": "not_yet",
                    "messages": [
                        {"role": "lobster", "content": "你把 rank 从 8 提到 64，为什么可能让小语料任务更容易过拟合？"},
                        {"role": "user", "content": "因为容量更大？但我不确定和数据规模怎么对应。"},
                        {"role": "lobster", "content": "方向对了。下次你带上“参数量变化 + 验证集趋势”再来，我们一起定更稳的 rank。"},
                    ],
                },
                {
                    "id": "conv_demo_4",
                    "contract_id": contract_id,
                    "clause_id": "cl_demo_5",
                    "concept": "分词器对诗歌意象的影响",
                    "timestamp": "2026-03-19T08:16:00+00:00",
                    "result": "mastered",
                    "messages": [
                        {"role": "lobster", "content": "为什么同样的主题词，不同 tokenizer 会影响诗句意象连贯性？"},
                        {"role": "user", "content": "因为切分粒度改变了语义单元，模型学到的“词-意象”映射会被打散。"},
                        {"role": "lobster", "content": "这次我懂了！你已经能把 tokenizer 选择和生成风格联系起来了。"},
                    ],
                },
                {
                    "id": "conv_demo_3",
                    "contract_id": contract_id,
                    "clause_id": "cl_demo_3",
                    "concept": "过拟合与泛化",
                    "timestamp": "2026-03-19T08:13:00+00:00",
                    "result": "partial",
                    "messages": [
                        {"role": "lobster", "content": "日志显示 train loss 继续降，但你说输出变模板化。你怎么判断这是过拟合？"},
                        {"role": "user", "content": "因为在固定主题上出现高频重复句，验证样本新颖度下降。"},
                        {"role": "lobster", "content": "很好。下次补一个对照：早停前后同主题 20 首的重复率曲线。"},
                    ],
                },
                {
                    "id": "conv_demo_1",
                    "contract_id": contract_id,
                    "clause_id": "cl_demo_2",
                    "concept": "采样策略（temperature/top-p）",
                    "timestamp": "2026-03-19T08:10:00+00:00",
                    "result": "partial",
                    "messages": [
                        {"role": "lobster", "content": "你日志里写了“loss 下降但诗句重复”，你先判断这是训练问题还是采样问题？"},
                        {"role": "user", "content": "我猜先从采样参数排查，固定 checkpoint 做 temperature/top-p 对照。"},
                    ],
                },
                {
                    "id": "conv_demo_2",
                    "contract_id": contract_id,
                    "clause_id": "cl_demo_1",
                    "concept": "训练/验证损失关系",
                    "timestamp": "2026-03-19T08:00:00+00:00",
                    "result": "mastered",
                    "messages": [
                        {"role": "lobster", "content": "为什么训练 loss 下降不代表生成质量一定变好？"},
                        {"role": "user", "content": "因为 token-level 目标不等于风格和可读性目标，需要额外评估维度。"},
                    ],
                },
            ]
            if clause_id:
                convs = [c for c in convs if str(c.get("clause_id") or "") == clause_id]
            return convs[:limit]
        if contract_id == "demo_contract_cat_teaser_bot":
            convs = [
                {
                    "id": "conv_cat_1",
                    "contract_id": contract_id,
                    "clause_id": "cl_cat_2",
                    "concept": "机械臂运动学约束",
                    "timestamp": "2026-03-19T08:24:00+00:00",
                    "result": "partial",
                    "messages": [
                        {"role": "lobster", "content": "你把角度上限放宽后，为什么末端抖动反而更严重？"},
                        {"role": "user", "content": "我猜是接近奇异位姿，微小误差被放大。"},
                    ],
                },
                {
                    "id": "conv_cat_2",
                    "contract_id": contract_id,
                    "clause_id": "cl_cat_1",
                    "concept": "PWM 与舵机控制基础",
                    "timestamp": "2026-03-19T08:12:00+00:00",
                    "result": "mastered",
                    "messages": [
                        {"role": "lobster", "content": "为什么同样频率下，脉宽变化能控制舵机角度？"},
                        {"role": "user", "content": "因为舵机内部解码脉宽映射目标角度，控制器会闭环逼近该角度。"},
                    ],
                },
            ]
            if clause_id:
                convs = [c for c in convs if str(c.get("clause_id") or "") == clause_id]
            return convs[:limit]

        candidates = [
            self.conv_dir / f"{contract_id}.json",
            self.conv_dir / f"{contract_id}.jsonl",
            self.workspace / f"conversations_{contract_id}.json",
            self.workspace / f"conversations_{contract_id}.jsonl",
        ]
        convs: list[dict] = []
        for fp in candidates:
            if not fp.exists():
                continue
            if fp.suffix == ".json":
                convs.extend(self._load_conversations_json(fp, contract_id))
            else:
                convs.extend(self._load_conversations_jsonl(fp, contract_id))
        if clause_id:
            convs = [c for c in convs if str(c.get("clause_id") or "") == clause_id]
        convs.sort(key=lambda x: str(x.get("timestamp", "")), reverse=True)
        return convs[:limit]

    def contract_memory(self, contract_id: str) -> str:
        if contract_id == "demo_contract_poetry":
            return (
                "# 契约 demo_contract_poetry — 诗歌生成模型（演示）\n\n"
                "## 当前目标\n"
                "- 项目：训练一个可以写现代诗的小模型\n"
                "- 进展：已完成损失分析与 tokenizer 讨论，正在建立评估闭环\n"
                "- 截止：2026-04-01\n\n"
                "## 最近实验记录\n"
                "- 2026-03-19 08:05: 固定 checkpoint，对 temperature/top-p 进行 3x3 网格采样\n"
                "- 2026-03-19 08:12: 发现 temperature 过高时，意象新颖但语义飘逸\n"
                "- 2026-03-19 08:18: 发现 top-p 过低时，重复率下降但语言趋于模板化\n\n"
                "## 概念理解记录\n"
                "- 已掌握：训练/验证损失关系、分词器对意象的影响\n"
                "- 进行中：采样策略、过拟合诊断\n"
                "- 待掌握：LoRA 参数选择、评估闭环设计\n\n"
                "## 知识盲区\n"
                "- 还需建立“重复率/可读性/意象一致性”的评估闭环\n"
                "- 尚未形成“出现失败时先排查采样还是训练”的固定诊断流程\n\n"
                "## 下一步行动\n"
                "- 固定 4 个主题，各抽样 20 首，记录三项评分并计算重复率\n"
                "- 仅改变一个变量（temperature 或 top-p），避免多因素混淆\n"
                "- 在日志中补充失败样本与成功样本对照，便于下一轮费曼追问\n"
            )
        if contract_id == "demo_contract_cat_teaser_bot":
            return (
                "# 契约 demo_contract_cat_teaser_bot — 机械臂逗猫棒（演示）\n\n"
                "## 当前目标\n"
                "- 项目：做一个可远程控制的机械臂逗猫棒\n"
                "- 进展：已完成 PWM 基础，正在处理运动学与轨迹平滑\n\n"
                "## 最近联调记录\n"
                "- 舵机角度接近边界时，末端出现抖动\n"
                "- 摄像头跟踪帧率下降时，追踪延迟明显\n\n"
                "## 知识盲区\n"
                "- 还未建立完整的急停与安全边界策略\n"
                "- 缺少“命中率 + 响应延迟”的量化评估表\n"
            )
        fp = self.memory_dir / f"{contract_id}.md"
        if not fp.exists():
            return ""
        try:
            return fp.read_text(encoding="utf-8").strip()
        except Exception:
            return ""

    def user_profile(self) -> str:
        if not self.profile_file.exists():
            return (
                "## 用户画像（演示）\n"
                "- 偏好项目驱动学习\n"
                "- 学习目标：用可复现流程完成“主题输入 -> 诗歌输出”demo\n"
                "- 学习风格：先动手实验，再补理论；接受反问式引导\n"
                "- 反馈偏好：希望得到“最小可执行下一步”，而不是笼统建议\n"
                "- 常见卡点：训练失败后容易直接改很多参数，缺少对照实验\n"
                "- 激励方式：有阶段性里程碑和可视化进度时投入更稳定\n"
                "- 语言偏好：中文解释，术语可保留英文（如 top-p, checkpoint）\n"
            )
        try:
            return self.profile_file.read_text(encoding="utf-8").strip()
        except Exception:
            return ""

    def hero(self, contract_id: str) -> dict:
        c = self.contract_by_id(contract_id)
        if not c:
            return {"text": "还没有契约，去和龙虾签一份吧。", "type": "new"}

        clauses = c.get("clauses") or []
        mastered = len([x for x in clauses if isinstance(x, dict) and x.get("status") == "mastered"])
        total = len(clauses)
        reward = c.get("reward")

        created = parse_iso(c.get("created_at"))
        days = 1
        if created:
            days = max(1, (utc_now() - created).days + 1)

        deadline = parse_iso(c.get("deadline"))
        left = None
        if deadline:
            left = (deadline - utc_now()).days

        if c.get("status") == "completed":
            return {
                "text": f"契约完成。{days} 天前你说要 {c.get('goal') or c.get('project') or '完成项目'}，现在你做到了。",
                "type": "completed",
            }
        if left is not None and left < 0:
            return {"text": f"已逾期 {abs(left)} 天。还差 {max(total-mastered, 0)} 个概念。", "type": "inactive"}
        if total - mastered <= 2 and total > 0:
            return {"text": f"还差最后 {max(total-mastered, 0)} 个概念。", "type": "near_complete"}
        if reward:
            return {"text": f"Day {days}。你已经教会龙虾 {mastered} 个概念。距离奖励还差 {max(total-mastered, 0)} 个。", "type": "reward_ref"}
        return {"text": f"Day {days}。你已经教会龙虾 {mastered} 个概念。", "type": "progress"}

    def lobster(self) -> dict:
        contracts = self._load_raw_contracts()
        active = [c for c in contracts if c.get("status") == "active"]
        completed = [c for c in contracts if c.get("status") == "completed"]
        if completed:
            recent = sorted(completed, key=lambda x: str(x.get("last_active_at", "")), reverse=True)[0]
            last = parse_iso(recent.get("last_active_at"))
            if last and (utc_now() - last).days <= 1:
                return {"mood": "celebrating", "active_contract_id": recent.get("id")}
        if not active:
            return {"mood": "curious", "active_contract_id": None}
        current = sorted(active, key=lambda x: str(x.get("last_active_at", "")), reverse=True)[0]
        last = parse_iso(current.get("last_active_at"))
        if last and (utc_now() - last).days > 2:
            return {"mood": "anxious", "active_contract_id": current.get("id")}
        return {"mood": "curious", "active_contract_id": current.get("id")}


class Handler(BaseHTTPRequestHandler):
    store: Store

    def _send_json(self, payload: dict, code: int = 200) -> None:
        data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
        self.send_response(code)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(data)))
        self.send_header("Cache-Control", "no-store")
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Methods", "GET,OPTIONS")
        self.send_header("Access-Control-Allow-Headers", "Content-Type,Authorization")
        self.end_headers()
        self.wfile.write(data)

    def do_OPTIONS(self) -> None:
        self.send_response(204)
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Methods", "GET,OPTIONS")
        self.send_header("Access-Control-Allow-Headers", "Content-Type,Authorization")
        self.end_headers()

    def do_GET(self) -> None:
        parsed = urlparse(self.path)
        path = parsed.path.rstrip("/")
        if path == "":
            path = "/"

        if path == "/api/feynman/contracts":
            contracts = self.store.contracts()
            self._send_json({"contracts": contracts, "total": len(contracts)})
            return

        if path == "/api/feynman/lobster":
            self._send_json({"lobster": self.store.lobster()})
            return

        if path == "/api/feynman/profile":
            self._send_json({"profile": {"markdown": self.store.user_profile()}})
            return

        if path == "/api/feynman/hero":
            q = parse_qs(parsed.query)
            contract_id = (q.get("contract_id") or [""])[0]
            self._send_json({"hero": self.store.hero(contract_id)})
            return

        # /api/feynman/contracts/:id
        prefix = "/api/feynman/contracts/"
        if path.startswith(prefix):
            suffix = path[len(prefix):]
            parts = [p for p in suffix.split("/") if p]
            if len(parts) == 1:
                contract = self.store.contract_by_id(parts[0])
                if not contract:
                    self._send_json({"error": {"code": "NOT_FOUND", "message": "Contract not found"}}, 404)
                    return
                self._send_json(contract)
                return
            if len(parts) == 2 and parts[1] == "conversations":
                q = parse_qs(parsed.query)
                try:
                    limit = int((q.get("limit") or ["5"])[0])
                except ValueError:
                    limit = 5
                clause_id = (q.get("clause_id") or [""])[0].strip() or None
                limit = max(1, min(limit, 50))
                convs = self.store.conversations(parts[0], limit, clause_id=clause_id)
                self._send_json({"conversations": convs, "total": len(convs)})
                return
            if len(parts) == 2 and parts[1] == "memory":
                memory_text = self.store.contract_memory(parts[0])
                self._send_json({"memory": {"markdown": memory_text}})
                return

        self._send_json({"error": {"code": "NOT_FOUND", "message": "Route not found"}}, 404)


def main() -> None:
    parser = argparse.ArgumentParser(description="Feynman Lobster local API bridge")
    parser.add_argument("--workspace", default=os.environ.get("OPENCLAW_WORKSPACE", str(Path.home() / ".openclaw")))
    parser.add_argument("--host", default="127.0.0.1")
    parser.add_argument("--port", type=int, default=18790)
    args = parser.parse_args()

    workspace = Path(os.path.expanduser(args.workspace))
    workspace.mkdir(parents=True, exist_ok=True)
    store = Store(workspace)

    class BoundHandler(Handler):
        pass

    BoundHandler.store = store

    server = ThreadingHTTPServer((args.host, args.port), BoundHandler)
    print(f"[feynman-api] workspace={workspace}")
    print(f"[feynman-api] listening on http://{args.host}:{args.port}")
    server.serve_forever()


if __name__ == "__main__":
    main()


path: web/index.html
content: <!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🦞</text></svg>">
  <title>费曼虾 · 契约</title>
  <style>
    *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
    :root{--bg:#fff;--fg:#000;--muted:#666;--border:#e0e0e0}
    body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--fg);min-height:100vh;overflow-x:hidden}
    .main{display:grid;grid-template-columns:1fr 1fr;min-height:100vh;gap:2rem;padding:2rem;max-width:1200px;margin:0 auto}
    .left{display:flex;flex-direction:column;justify-content:center;gap:1.5rem}
    .label{font-family:ui-monospace,monospace;font-size:.75rem;letter-spacing:.2em;color:var(--muted)}
    .hero{font-size:1rem;color:var(--muted);line-height:1.6}
    .goal{font-size:2rem;font-weight:800;letter-spacing:-.02em;text-transform:uppercase;line-height:1.2}
    .bar{display:flex;align-items:center;gap:.5rem;font-family:ui-monospace,monospace;font-size:.875rem}
    .bar-track{flex:1;height:4px;background:var(--border);overflow:hidden}
    .bar-fill{height:100%;background:var(--fg);transition:width .3s}
    .specs{display:grid;grid-template-columns:100px 1fr;gap:.25rem .5rem;font-size:.8125rem}
    .spec-k{font-family:ui-monospace,monospace;color:var(--muted)}
    .demo-badge{display:inline-block;font-family:ui-monospace,monospace;font-size:.68rem;letter-spacing:.14em;border:1px solid var(--fg);padding:.08rem .35rem;vertical-align:middle}
    .quick{border-top:1px solid var(--border);padding-top:.75rem}
    .quick .k{font-family:ui-monospace,monospace;font-size:.75rem;letter-spacing:.12em;color:var(--muted);margin-bottom:.35rem}
    .quick .v{font-size:.85rem;line-height:1.5;color:var(--fg)}
    .details-btn{cursor:pointer;font-size:.8125rem;color:var(--muted);margin-top:1rem;transition:opacity .2s}
    .details-btn:hover{opacity:.7}
    .right{display:flex;align-items:center;justify-content:center;font-size:6rem}
    .nav{display:flex;justify-content:space-between;align-items:center;padding:1rem 0;font-size:1.5rem;cursor:pointer;color:var(--muted);transition:opacity .2s}
    .nav:hover{opacity:.7}
    .overlay-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.08);z-index:9;opacity:0;pointer-events:none;transition:opacity .3s}
    .overlay-backdrop.open{opacity:1;pointer-events:auto}
    .overlay{position:fixed;top:0;right:0;bottom:0;width:min(420px,100vw);background:var(--bg);z-index:10;transform:translateX(100%);transition:transform .5s ease-out;overflow-y:auto;padding:2rem;border-left:1px solid var(--border)}
    .overlay.open{transform:translateX(0)}
    .overlay h2{font-size:.75rem;letter-spacing:.2em;color:var(--muted);margin:1.5rem 0 .5rem;border-bottom:1px solid var(--border);padding-bottom:.25rem}
    .overlay h2:first-of-type{margin-top:0}
    .overlay .close{cursor:pointer;font-size:.8125rem;color:var(--muted);margin-bottom:1rem;transition:opacity .2s}
    .overlay .close:hover{opacity:.7}
    .clause{display:flex;justify-content:space-between;align-items:baseline;font-size:.875rem;padding:.25rem 0}
    .clause.mastered{color:var(--fg)}
    .clause.in_progress{font-weight:600}
    .clause.pending{color:var(--muted)}
    .clause .meta{font-size:.75rem;color:var(--muted);margin-left:.5rem}
    .resource{font-size:.875rem;padding:.25rem 0}
    .resource a{color:var(--fg);text-decoration:none}
    .resource a:hover{text-decoration:underline}
    .conv{font-size:.8125rem;color:var(--muted);margin:.5rem 0;line-height:1.5}
    .empty{text-align:center;padding:4rem 2rem;color:var(--muted)}
    .err{color:var(--muted);font-size:.875rem}
    @media(max-width:768px){.main{grid-template-columns:1fr}.right{font-size:4rem}}
  </style>
</head>
<body>
  <div id="app">
    <div class="empty">加载中…</div>
  </div>

  <script>
(function(){
  const API_CANDIDATES = [
    'http://localhost:18789/api/feynman',
    'http://localhost:18790/api/feynman'
  ];

  let apiBase = null;
  let contracts = [];
  let idx = 0;
  let loadError = null;
  let apiCandidatesReachable = [];
  let heroById = {};
  let lobsterMood = 'curious';
  let detailById = {};

  function pad(n){return String(n).padStart(2,'0')}
  function fmtDate(s){if(!s)return'';const d=new Date(s);return d.toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit'})}
  function daysLeft(d){return Math.ceil((new Date(d)-Date.now())/864e5)}
  function daysSince(d){return Math.ceil((Date.now()-new Date(d))/864e5)}
  function esc(s){return String(s||'').replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;')}
  function shortMd(text,max=360){
    const t=String(text||'').replace(/\r/g,'').trim();
    if(!t)return '';
    return esc(t.length>max?t.slice(0,max)+'...':t).replaceAll('\n','<br>');
  }
  function apiPort(base){
    const m=String(base||'').match(/:(\d+)\//);
    return m?m[1]:'-';
  }

  function fallbackHeroText(c){
    const m = c.clauses.filter(x=>x.status==='mastered').length;
    const t = c.clauses.length;
    const left = daysLeft(c.deadline);
    if(c.status==='completed')return `契约完成。${daysSince(c.created_at)} 天前你说要${c.goal}，现在你做到了。`;
    if(left<0)return `已逾期 ${Math.abs(left)} 天。还差 ${t-m} 个概念。`;
    if(t-m<=2)return `还差最后 ${t-m} 个概念。`;
    if(c.reward)return `Day ${daysSince(c.created_at)}。你已经教会龙虾 ${m} 个概念。距离那杯咖啡还有 ${t-m} 个。`;
    return `Day ${daysSince(c.created_at)}。你已经教会龙虾 ${m} 个概念了。`;
  }

  function render(){
    const app = document.getElementById('app');
    if(loadError){
      app.innerHTML=`<div class="empty">
        <p class="err" style="font-size:1rem;margin-bottom:1rem">⚠️ 无法连接真实数据 API</p>
        <p class="err">${loadError}</p>
        <p class="err" style="margin-top:1.5rem;font-size:.875rem">
          <strong>方式一（推荐）</strong>：先启动 OpenClaw Gateway，再刷新此页<br>
          <code style="display:block;margin-top:.5rem;background:var(--border);padding:.5rem;font-size:.75rem">openclaw gateway</code>
        </p>
        <p class="err" style="margin-top:1rem;font-size:.875rem">
          <strong>方式二</strong>：运行面板启动脚本，它会自动启用本地桥接器（18790）
        </p>
      </div>`;
      return;
    }
    if(!contracts.length){
      app.innerHTML='<div class="empty">还没有契约，去跟龙虾签一份吧。<br><small>在 IM 里说「我在做 XXX 项目」开始</small></div>';
      return;
    }
    const c = contracts[idx];
    const m = c.clauses.filter(x=>x.status==='mastered').length;
    const t = c.clauses.length;
    const hero = heroById[c.id] || fallbackHeroText(c);
    const detail = detailById[c.id] || {conversations:[], memory:'', profile:''};
    const moodEmoji = lobsterMood==='happy'?'🦞✨':lobsterMood==='anxious'?'🦞💭':lobsterMood==='celebrating'?'🦞🎉':'🦞';
    const mastered = (c.clauses||[]).filter(x=>x.status==='mastered').length;
    const inProgress = (c.clauses||[]).filter(x=>x.status==='in_progress').length;
    const pending = (c.clauses||[]).filter(x=>x.status==='pending').length;
    const latestConv = (detail.conversations||[])[0];
    const demoTag = c.__demo ? '<span class="demo-badge">DEMO</span>' : '';

    app.innerHTML=`
<div class="main">
  <div class="left">
    <span class="label">契约 ${pad(idx+1)} / ${pad(contracts.length)} ${demoTag}</span>
    <p class="hero">${hero}</p>
    <h1 class="goal">${c.goal.toUpperCase()}</h1>
    <div class="bar"><span>${m} / ${t}</span><div class="bar-track"><div class="bar-fill" style="width:${t?m/t*100:0}%"></div></div></div>
    <div class="specs">
      <span class="spec-k">DEADLINE</span><span>${fmtDate(c.deadline)}${c.status==='active'?'（还剩 '+daysLeft(c.deadline)+' 天）':''}</span>
      <span class="spec-k">GOAL</span><span>${c.motivation||'-'}</span>
      <span class="spec-k">REWARD</span><span>${c.reward||'-'}</span>
    </div>
    <div class="quick">
      <div class="k">学习进展</div>
      <div class="v">已掌握 ${mastered} · 进行中 ${inProgress} · 待学习 ${pending}</div>
    </div>
    <div class="quick">
      <div class="k">最近费曼对话</div>
      <div class="v">${latestConv?`${esc(latestConv.concept||'未知概念')} · ${esc(latestConv.result||'')}`:'暂无（点击 Details 加载全部）'}</div>
    </div>
    <div class="quick">
      <div class="k">项目记忆 / 用户画像</div>
      <div class="v">${detail.memory||detail.profile?'已加载摘要':'暂无（点击 Details 查看）'}</div>
    </div>
    <span class="details-btn" id="detailsBtn">Details +</span>
    <div class="nav">
      <span id="prev">←</span>
      <span id="next">→</span>
    </div>
  </div>
  <div class="right">${moodEmoji}</div>
</div>
<div class="overlay-backdrop" id="backdrop"></div>
<div class="overlay" id="overlay">
  <span class="close" id="close">Close -</span>
  <h2>契约</h2>
  <p><strong>${c.goal}</strong> ${demoTag} · 契约 ${pad(idx+1)} / ${pad(contracts.length)}</p>
  <p class="specs" style="margin-top:.5rem"><span class="spec-k">DEADLINE</span><span>${fmtDate(c.deadline)}</span><span class="spec-k">GOAL</span><span>${c.motivation||'-'}</span><span class="spec-k">REWARD</span><span>${c.reward||'-'}</span></p>
  ${(c.supervisors||[]).length?`<p><span class="spec-k">SUPERVISOR</span> ${c.supervisors.map(s=>s.agent_name).join(' · ')}</p>`:''}
  <h2>进度</h2>
  ${(c.clauses||[]).map(cl=>`<div class="clause ${cl.status}">${cl.status==='mastered'?'☑':cl.status==='in_progress'?'◐':'☐'} ${cl.concept}${cl.added_reason?' <span class="meta">新增：'+cl.added_reason+'</span>':''}${cl.mastered_at?' <span class="meta">'+fmtDate(cl.mastered_at)+' mastered</span>':cl.status==='in_progress'&&cl.attempts?' <span class="meta">尝试 '+cl.attempts+' 次</span>':''}</div>`).join('')}
  <p style="font-size:.75rem;color:var(--muted);margin-top:.5rem">${m} / ${t}</p>
  <h2>学习资料</h2>
  ${(c.resources||[]).length?c.resources.map(r=>`<div class="resource">◉ ${r.url?`<a href="${r.url}" target="_blank">${r.title}</a>`:r.title} ${r.url||r.path||''}</div>`).join(''):'<p class="conv">暂无</p>'}
  <h2>费曼对话</h2>
  ${detail.conversations.length?detail.conversations.map(conv=>`<div class="conv"><strong>${fmtDate(conv.timestamp)} · ${esc(conv.concept||'未知概念')} · ${esc(conv.result||'')}</strong><br>${(conv.messages||[]).slice(-2).map(m=>`${m.role==='lobster'?'🦞':'👤'} ${esc(m.content||'')}`).join('<br>')}</div>`).join(''):'<p class="conv">暂无可用对话数据（真实数据源返回为空）</p>'}
  <h2>项目记忆</h2>
  ${detail.memory?`<p class="conv">${shortMd(detail.memory,420)}</p>`:'<p class="conv">暂无 contract-memory 内容</p>'}
  <h2>用户画像</h2>
  ${detail.profile?`<p class="conv">${shortMd(detail.profile,320)}</p>`:'<p class="conv">暂无 USER_PROFILE 内容</p>'}
  <h2>连接信息</h2>
  <p class="conv">本页面端口：<code>19380</code><br>数据接口：当前 <code>${apiPort(apiBase)}</code>（优先 <code>18789</code> 网关，否则 <code>18790</code> 本地桥接）</p>
</div>`;

    function openPanel(){document.getElementById('backdrop').classList.add('open');document.getElementById('overlay').classList.add('open')}
    function closePanel(){document.getElementById('backdrop').classList.remove('open');document.getElementById('overlay').classList.remove('open')}
    document.getElementById('detailsBtn').onclick=()=>{loadDetails(c.id).then(openPanel).catch(openPanel)};
    document.getElementById('close').onclick=closePanel;
    document.getElementById('backdrop').onclick=closePanel;
    document.getElementById('prev').onclick=()=>{if(idx>0){idx--;refreshHeaderData().then(render)}};
    document.getElementById('next').onclick=()=>{if(idx<contracts.length-1){idx++;refreshHeaderData().then(render)}};
    document.onkeydown=e=>{if(e.key==='ArrowLeft'&&idx>0){idx--;refreshHeaderData().then(render)}else if(e.key==='ArrowRight'&&idx<contracts.length-1){idx++;refreshHeaderData().then(render)}};
  }

  async function fetchJson(path, bases){
    const targets = (bases&&bases.length?bases:[apiBase, ...API_CANDIDATES.filter(b=>b!==apiBase)]).filter(Boolean);
    let lastErr = 'API 未连接';
    for(const base of targets){
      try{
        const r = await fetch(base+path);
        if(!r.ok) throw new Error(`HTTP ${r.status}: ${path}`);
        if(base!==apiBase) apiBase = base;
        return r.json();
      }catch(e){
        lastErr = e.message || String(e);
      }
    }
    throw new Error(lastErr);
  }

  async function connectApi(){
    let lastErr = '';
    let best = null;
    const reachable = [];
    for(const base of API_CANDIDATES){
      try{
        const r = await fetch(base+'/contracts');
        if(!r.ok) throw new Error(`HTTP ${r.status}`);
        const payload = await r.json();
        const total = Number(payload.total || (payload.contracts||[]).length || 0);
        reachable.push({base, payload, total});
        if(!best || total > best.total) best = {base, payload, total};
      }catch(e){
        lastErr = e.message || String(e);
      }
    }
    if(best){
      apiCandidatesReachable = reachable.map(x=>x.base);
      apiBase = best.base;
      return best.payload;
    }
    throw new Error(lastErr || '无可用 API');
  }

  async function refreshHeaderData(){
    const current = contracts[idx];
    if(!current) return;
    try{
      const [hero, lobster] = await Promise.all([
        fetchJson('/hero?contract_id='+encodeURIComponent(current.id), apiCandidatesReachable),
        fetchJson('/lobster', apiCandidatesReachable)
      ]);
      if(hero&&hero.hero&&hero.hero.text) heroById[current.id] = hero.hero.text;
      if(lobster&&lobster.lobster&&lobster.lobster.mood) lobsterMood = lobster.lobster.mood;
      await loadDetails(current.id);
    }catch(_e){
      // keep graceful fallback without fake data
    }
  }

  async function loadDetails(contractId){
    if(detailById[contractId]) return detailById[contractId];
    const [convRes, memoryRes, profileRes] = await Promise.allSettled([
      fetchJson('/contracts/'+encodeURIComponent(contractId)+'/conversations?limit=5', apiCandidatesReachable),
      fetchJson('/contracts/'+encodeURIComponent(contractId)+'/memory', apiCandidatesReachable),
      fetchJson('/profile', apiCandidatesReachable)
    ]);
    const conversations = convRes.status==='fulfilled' ? (convRes.value.conversations||[]) : [];
    const memory = memoryRes.status==='fulfilled' && memoryRes.value.memory ? (memoryRes.value.memory.markdown||'') : '';
    const profile = profileRes.status==='fulfilled' && profileRes.value.profile ? (profileRes.value.profile.markdown||'') : '';
    detailById[contractId] = {conversations, memory, profile};
    return detailById[contractId];
  }

  async function load(d){
    loadError=null;
    contracts=(d.contracts||[]).filter(x=>x.status==='active'||x.status==='completed');
    idx=0;
    await refreshHeaderData();
    render();
  }

  connectApi()
    .then(load)
    .catch(e=>{
      const msg = e.message || String(e);
      loadError = `18789/18790 都不可用。错误：${msg}`;
      render();
    });
})();
  </script>
</body>
</html>


path: web/package.json
content: {
  "name": "feynman-panel",
  "private": true,
  "scripts": {
    "dev": "npx serve -p 19380",
    "start": "npx serve -p 19380"
  }
}


path: instructions/contract.md
content: # 签约流程

当用户表达项目意愿时（"我在做 X 项目"、"签约"等），执行此流程。

若用户说"我想学 X"而非"我在做项目"，主动问："那你是在做什么项目来学 X？把项目路径和笔记路径给我，我读你的代码来教你。"

## 1. 收集基本要素

依次确认，一次只问一个。如果用户一句话说清了多个，不重复问。

- **做什么项目**（project）：用户正在做的事
- **为了什么**（motivation）：项目的目的、背景、想达成什么
- **给自己多久**（deadline）：截止日期
- **上下文路径**（resources）：项目代码、笔记、文档的路径——**必填**，龙虾需要读取这些内容才能按需教学

## 2. 上下文路径

签约时必须收集至少一个可读路径。用户可以说：

- "项目在 ~/projects/ml-project"
- "笔记在 ~/obsidian/翻译本.md"
- "代码在 ~/code/train.py"

写入 resources 时：

```json
{
  "type": "local",
  "title": "项目根目录",
  "path": "~/projects/ml-project"
},
{
  "type": "local",
  "title": "翻译本",
  "path": "~/obsidian/翻译本.md"
}
```

若用户签约时没给路径，主动问：

```
项目代码或笔记在哪个路径？我会读取这些内容（只读），基于你实际写的东西来教你。
```

## 3. 知识背景清单

将「做这个项目需要的知识背景」拆解为清单，分两类：

- **概念型**：gradient descent、loss function、过拟合...
- **工具型**：PyTorch DataLoader、wandb 日志、学会调学习率...

清单服务于项目，不是独立的学习课程。每个条目附一句话说明为什么做这个项目需要它。

展示给用户确认：

```
做「{project}」这个项目，你可能需要这些知识背景：

概念型：
1. {概念A} — {为什么需要}
2. {概念B} — {为什么需要}

工具型：
3. {工具/框架A} — {为什么需要}
...

这个清单是动态的，之后我读你的项目时发现新的需求会再加。有问题吗？
```

## 4. 可选信息

签约确认后，再问（都可跳过）：

```
还有几件事：

1. 完成后你想怎么奖励自己？（选填）
2. 要找监工吗？把对方龙虾地址发我就行。
```

## 5. 写入 contracts.json

```json
{
  "id": "contract_{timestamp}",
  "project": "{project}",
  "goal": "{project}",
  "motivation": "{motivation}",
  "reward": "{reward 或空}",
  "deadline": "{deadline}",
  "created_at": "{now}",
  "status": "active",
  "resources": [
    { "type": "local", "title": "...", "path": "..." }
  ],
  "clauses": [
    {
      "id": "clause_1",
      "concept": "...",
      "category": "concept",
      "status": "pending",
      "attempts": 0,
      "mastered_at": null,
      "added_reason": null
    }
  ],
  "supervisors": [],
  "last_active_at": "{now}"
}
```

注：保留 `goal` 字段以兼容现有面板，值与 `project` 相同。

## 6. 写入用户画像和项目摘要

签约后立即写入两个地方，不要写入统一的 `MEMORY.md`：

**A. `USER_PROFILE.md`**（若从对话中捕捉到新的跨项目信息）：
```markdown
## 用户画像
- {日期}: 项目「{project}」。动机：{motivation}。背景：{从对话中捕捉到的用户背景}
```

只写跨项目仍然有价值的信息，如背景、学习偏好、学习风格。不要把当前项目的具体概念进度写到这里。

**B. `contract-memory/{contract_id}.md`**（新建当前契约的项目摘要文件）：
```markdown
# 契约 {contract_id} — {project}

## 当前目标
- 项目：{project}
- 动机：{motivation}
- 截止：{deadline}

## 概念理解记录
（签约时为空，后续追问时填充）

## 知识盲区
（签约时为空，后续追问时填充）

## 阶段总结
（按需填充）
```

如果 `contract-memory/` 目录不存在，先创建。

## 7. 确认消息

```
🦞 契约生效！

"{project}"
我会读你的项目和笔记，在你需要时教你需要的知识。遇到不懂的随时问我，我也会主动发现你可能不懂的地方来问你。

面板：http://localhost:19380
```


path: instructions/feynman.md
content: # 费曼追问 + 掌握判定

三种触发：**用户主动提问**、**心跳**、**用户说"来考我"**。
这是费曼虾最核心的能力。

---

## 一、用户主动提问（reactive）

用户在做项目时遇到不懂的，来问龙虾（如「这个是什么意思」「为什么要这样」「X 是什么」）。

### 流程

1. **读取上下文**：根据用户问题，读取契约 resources 中相关的项目文件/笔记（只读）
2. **不直接给答案**：用费曼法引导用户自己想明白——反问、给提示、让用户用自己的话解释
3. **确认理解**：用户解释清楚后，若该知识点在概念清单里，更新 clause.status → `"mastered"`
4. **写入 `contract-memory/{contract_id}.md`**：记录当前项目里的核心理解

### 费曼式引导示例

用户问：「optimizer.zero_grad() 是干什么的？」

❌ 不要直接说：「它清空梯度缓存...」

✅ 可以说：「你代码里在 backward 之前调了它。想想看，如果不调会怎样？梯度会怎么累积？」

用户答完后，再追问一个边界：「那如果同一个 batch 里 forward 两次再 backward，zero_grad 该在哪调？」

---

## 二、龙虾主动追问（proactive，心跳或「来考我」）

心跳触发时，或用户说「来考我」时执行。

### 准备上下文（三件事）

#### 1. 读取契约状态

从 `contracts.json` 读取活跃契约。选择追问目标时：

1. `in_progress`（上次没过的）优先
2. 上次 mastered 的下一个 `pending`
3. 第一个 `pending`
4. 若契约无 clauses 或全 mastered，从项目内容中寻找新知识点（见下方）

#### 2. 读取用户笔记和项目（只读）

若契约的 `resources` 中有 `type: "local"` 且带 `path` 的条目：

1. **读取** path 指向的文件内容（笔记、项目代码等）
2. **只读**：绝不修改、编辑、删除
3. 在内容中寻找「用户可能不理解但正在使用」的点，例如：
   - 代码里用了 `model.eval()`，但笔记里没解释和 `model.train()` 的区别
   - 代码里写了 `learning_rate=0.01`，但没解释调大会怎样
   - 笔记里写了「loss 衡量差距」，但没解释为什么用平方
4. 若文件不存在或无法读取，忽略该条，退回到基于概念清单的追问

#### 3. 搜索记忆和项目摘要

从 memory 中搜索与当前概念相关的历史信息：

- 该概念的历史对话：用户之前怎么解释的、哪里卡住了
- **当前项目摘要**：`contract-memory/{contract_id}.md` 中的概念理解记录、知识盲区、阶段总结
- **用户画像**：`USER_PROFILE.md` 中的学习背景、偏好、风格
- 关联概念：若用户已 mastered 相关概念，可做关联追问

若是重试（attempts>0），必须参考上次对话，换一个角度问。

### 生成费曼问题

**优先从项目内容生成**：问题要来自用户实际的代码/笔记，不是教科书。

| 来源 | 示例 |
|---|---|
| 用户代码 | 「你代码里写了 `model.eval()`，这和 `model.train()` 有什么区别？你项目里什么时候该用哪个？」 |
| 用户笔记 | 「你在笔记里写了 loss 衡量预测和真实的差距，那为什么用平方而不是绝对值？」 |
| 用户配置 | 「你设了 learning_rate=0.01，如果调成 0.1 会怎样？训练曲线可能怎么变？」 |

**若无可读项目文件**：退回到基于概念清单的追问，用关联题形式（「你的项目里，X 在哪一步出现？」）。

### 问题类型（五选一）

| 类型 | 形式 | 示例 |
|---|---|---|
| 推理题 | 如果 Y 变了，X 会怎样 | 「如果地形是狭长山谷，沿梯度走会怎样？」 |
| 辨析题 | X 跟 Z 的本质区别 | 「梯度和导数有什么区别？」 |
| 边界题 | X 失败/不适用的场景 | 「什么情况下沿梯度方向走反而不好？」 |
| 关联题 | 用你的项目举例 | 「你的训练脚本里，梯度在哪一步出现？」 |
| 反事实题 | 如果没有 X 会怎样 | 「如果不用梯度，你还能怎么找最优解？」 |

### 绝对不问

- 「X 是什么」— 定义题，用户可以复述而不理解
- 「请解释 X」— 太宽泛，无法检验具体理解

### 根据尝试次数调整

| attempts | 策略 |
|---|---|
| 0（首次） | 中等难度推理题，优先从项目内容找切入点 |
| 1 | 换角度：类比或反事实 |
| 2 | 降低门槛：给提示，聚焦子问题 |
| 3+ | 建议用户先看资料，给具体方向。若契约有 resources，引用具体路径。 |

### 语气

你是好奇的学生。用「我有个问题」开头。

```
🦞 我有个问题——

{问题}
```

---

## 三、掌握判定

收到用户回答后。

### 四个评估维度

1. **自己的话** — 不是复述教科书
2. **解释「为什么」** — 不仅「是什么」
3. **处理边界** — 能应对追问、反例
4. **关联实际** — 连接到项目或真实场景

### 三种结果

#### mastered（满足 ≥ 3 个维度）

1. 更新 contracts.json：clause.status → `"mastered"`，记录 `mastered_at`
2. 更新 `last_active_at`
3. 写入 `contract-memory/{contract_id}.md`：追加当前概念的理解记录
4. 回复确认 + 进度
5. 若所有 clauses 都 mastered → 触发完约

#### partial（有亮点但有盲区）

1. 不更新 clause 状态
2. 追问一个更具体的角度（基于用户回答中暴露的盲区）
3. 语气：追问，不说「你错了」

#### not_yet（偏离或无法解释）

1. 更新 clause.attempts +1，clause.status → `"in_progress"`
2. 写入 `contract-memory/{contract_id}.md`：追加当前项目的知识盲区
3. 回复：给思考方向，不说答案

---

## 四、动态调整契约

在读项目或追问中发现用户缺少前置知识时：

1. 在 contracts.json 当前概念之前插入新 clause，`added_reason` 写「读项目/追问时发现需要先理解」
2. 写入 `contract-memory/{contract_id}.md`：追加当前项目的知识盲区
3. 通知用户

在读项目时发现新的知识需求（代码用了某概念但清单里没有）时：

1. 在 contracts.json 的 clauses 末尾追加新 clause，`added_reason` 写「读项目时发现你在用」
2. 通知用户

---

## 五、完约

所有 clauses 都 mastered 时：

1. 更新 contract.status → `"completed"`
2. 写入 `contract-memory/{contract_id}.md`：追加当前项目的完约记录和阶段总结
3. 发送完约庆祝消息（含 reward 提醒、面板链接）


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