
Carl Weread
- 5 installs
- 23 repo stars
- Updated July 10, 2026
- learnprompt/carl-weread
Helps with ai & agent building tasks.
About
carl-weread is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- carl-weread
- AI & Agent Building
- AI-coding skill
Carl Weread by the numbers
- 5 all-time installs (skills.sh)
- Ranked #13,046 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/learnprompt/carl-weread --skill carl-wereadAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 5 |
|---|---|
| repo stars | ★ 23 |
| Last updated | July 10, 2026 |
| Repository | learnprompt/carl-weread ↗ |
What it does
Helps with ai & agent building tasks.
Files
carl-weread
定位
carl-weread 不是阅读统计,不是通用书单,也不是年度报告。
它是「上下文驱动的阅读行动系统」:根据用户最近真实在做的事,从微信读书里找一个今天能读完的小阅读块,并把读后收获接回项目、笔记和内容生产。
何时使用
用户出现以下意图时使用:
- 今天读什么 / 推荐一章 / 推荐一小节
- 推荐一本我没读过但现在该读的书
- 根据我最近在做的事找一本书里的章节
- 读完了,帮我消化
- 把这次阅读变成行动卡 / Obsidian 笔记 / 选题线索
- 本周阅读闭环 / 哪些阅读真的进入工作了
不要用于:
- 只想生成完整阅读可视化报告:建议转向
yao-weread-skill类工作流。 - 只想把 Markdown 做成 EPUB:建议转向
qiaomu-epub-book-generator类工作流。 - 只想系统规划一个新领域书单:可借鉴
huashu-weread的 path 思路。
工作流路由
| 用户意图 | 工作流 |
|---|---|
| 今天读什么 / 推荐一章 | scripts/carl_weread.py today / workflows/today-chapter.md |
| 推荐一本没读透但现在该读的书 | scripts/carl_weread.py recommend |
| 读完了 / 帮我消化 | scripts/carl_weread.py after-read / workflows/digest-apply.md |
| 本周阅读复盘 | scripts/carl_weread.py weekly / workflows/weekly-loop.md |
强制规则
1. 不允许只推荐书名;必须尽量定位到章节或小节。 2. 推荐理由必须连接用户最近真实上下文,不能只说「这本书很好」。 3. 输出必须包含一个读前问题和一个读后行动。 4. 用户要「推荐一本没读过但现在该读的书」时,必须交叉检查书架、笔记和推荐/相似书候选,不能只返回微信读书推荐列表。 5. 读后消化必须先看有没有划线/转述;没有划线时走无划线追问协议,不要硬写行动卡。 6. 能生成 weread:// 链接时必须生成。 7. 如果上下文不足,先退化为「基于微信读书最近阅读推荐」,并明确说明口径。 8. 不导出书籍全文;只使用微信读书 API 可访问的元数据、用户自己的笔记/划线、公开热度信息。 9. 不打印 WEREAD_API_KEY;如需持久化,只能写入 ~/.config/carl-weread/api_key 这种权限为 600 的私有 key 文件,不能写入 config.toml、README、日志或普通 shell 配置。
数据源
| 数据 | 来源 | 用途 |
|---|---|---|
| 最近日记 | Obsidian 10_日记/ | 判断当下关注点 |
| 活跃项目 | Obsidian 20_项目/ | 判断真实任务压力 |
| 选题库 | Obsidian 15_自媒体/选题库/ | 判断内容生产方向 |
| 普通本地笔记 | 任意 Markdown/TXT 文件夹 | 无 Obsidian 用户的上下文降级 |
| 当前对话/用户 brief | 用户本轮输入 | 无本地笔记时的轻量上下文 |
| 书架 | WeRead /shelf/sync | 找已有书和最近活跃书 |
| 笔记概览 | WeRead /user/notebooks | 判断真读过什么 |
| 个人想法 | WeRead /review/list/mine | 查看自己对某本书的评论/想法 |
| 公开点评 | WeRead /review/list、/review/single | 补充公共讨论和单条想法详情 |
| 热门划线 | WeRead /book/bestbookmarks、/book/underlines、/book/readreviews | 判断章节中大家反复标记的问题 |
| 章节目录 | WeRead /book/chapterinfo | 定位章节 |
| 阅读进度 | WeRead /book/getprogress | 避免推荐不合适章节 |
| 推荐书籍 | WeRead /book/recommend、/book/similar | 个性化推荐和相似书补充 |
安装/依赖原则
- 不强制先安装官方 WeRead Skill;本 skill 自带
scripts/weread.sh作为 API helper。 - 必须配置
WEREAD_API_KEY或私有 key 文件,Key 从官方微信读书 Skill/API 入口获取。 - Obsidian 不是硬依赖;有 Obsidian 时走 Full Mode,没有 Obsidian 时按「普通文件夹 → 当前对话 brief → WeRead-only」逐级降级。
调用约定
- 首次上下文配置通过
scripts/setup.py --mode ...写入~/.config/carl-weread/config.toml。 - 首次 API Key 配置优先通过
scripts/setup_api_key.py写入~/.config/carl-weread/api_key;也支持临时环境变量WEREAD_API_KEY。 - 微信读书 API 通过
scripts/weread.sh调用。 scripts/weread.sh覆盖书架、搜索、统计、书籍详情、章节目录、阅读进度、笔记/划线、公开点评、个人想法、热门划线、个性化推荐和相似书推荐。- 上下文通过
scripts/collect_context.py --config ...、carl_weread.context.collect_context_for_config或carl_weread.context.collect_recent_context收集。 - V0.3统一入口优先用
scripts/carl_weread.py recommend|today|after-read|weekly。 - 今日推荐可用
scripts/carl_weread.py today --brief ...或scripts/today_live.py --brief ...一键执行真实 WeRead 拉取和推荐。 - 候选章节可以用
scripts/fetch_candidates.py --output ...从真实 WeRead API 拉取,或用scripts/build_candidates.py从已保存 JSON 离线生成。 - 调试时也可以用
scripts/today.py --config ... --chapters ...串联本地候选章节验证。 - 未读书推荐用
scripts/carl_weread.py recommend --brief ...。 - 读后消化用
scripts/carl_weread.py after-read;也保留scripts/digest_apply.py作为底层行动卡入口。 - 周复盘用
scripts/carl_weread.py weekly --cards ... --context ...;也保留scripts/weekly_loop.py。 - 章节选择逻辑通过
carl_weread.today_chapter执行。 - 输出格式遵守
shared/output-style.md。
Common Pitfalls
1. 推一本书而不是一小节。 修正:优先查章节目录,给今天能读完的阅读块。 2. 只看书架不看笔记。 修正:书架表示兴趣,笔记表示真读过,必须交叉。 3. 读完只总结不行动。 修正:每次 digest 必须生成一个可执行动作。 4. 用泛泛的 AI 话术解释推荐。 修正:必须引用用户最近上下文中的真实任务或问题。 5. 输出裸 bookId。 修正:尽量隐藏在 weread:// 深度链接里。 6. 假设 `weread://` 一定能点开。 修正:输出 open 'weread://...' 命令,同时给书名和章节名兜底;提醒用户安装并登录微信读书客户端。 7. 统计和相似推荐漏传默认参数。 修正:readdata 默认 --mode=overall,recommend/similar 默认 --count=12 --maxIdx=0。 8. 接口频率超限。 修正:不要连续重复拉取;优先复用已保存的候选章节 JSON 或缓存。
Verification Checklist
- [ ]
scripts/weread.sh --help可运行。 - [ ]
scripts/carl_weread.py recommend --brief ...可推荐一本没读透但现在该读的书。 - [ ]
scripts/today_live.py --brief ...可生成今日一小节。 - [ ]
scripts/carl_weread.py after-read ... --auto-fetch可检查划线;无划线时输出追问协议。 - [ ]
scripts/digest_apply.py ... --writeback可生成/写回阅读行动卡。 - [ ]
scripts/weekly_loop.py --cards ...可生成周复盘。 - [ ]
python -m pytest tests -q通过。 - [ ]
SKILL.mdfrontmatter 合法,description 未超 1024 字符。 - [ ] 三个 workflow 文件存在。
- [ ] README 能让第一次看到的人理解差异化和完整安装方式。
.venv/
__pycache__/
*.pyc
.pytest_cache/
.env
reports/generated/
*.log
.DS_Store
"""carl-weread: context-driven WeRead action coach."""
__version__ = "0.3.0"
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from .digest_apply import ReadingInput, build_action_card
@dataclass(frozen=True)
class AfterReadInput:
book_title: str
chapter_title: str
current_problem: str = ""
highlights: list[str] = field(default_factory=list)
@dataclass(frozen=True)
class AfterReadResult:
status: str
markdown: str
should_writeback: bool
def extract_highlights(payload: Any) -> list[str]:
"""Extract user highlights from common WeRead bookmark/underline response shapes."""
if isinstance(payload, list):
items = payload
elif isinstance(payload, dict):
items = []
containers = [payload]
for key in ("data", "result"):
value = payload.get(key)
if isinstance(value, dict):
containers.append(value)
for container in containers:
for key in ("updated", "bookmarks", "underlines", "items", "reviews"):
value = container.get(key)
if isinstance(value, list):
items = value
break
if items:
break
else:
return []
highlights: list[str] = []
for item in items:
if not isinstance(item, dict):
continue
for key in ("markText", "abstract", "content", "text", "review", "summary"):
value = item.get(key)
if value is not None and str(value).strip():
highlights.append(str(value).strip())
break
return highlights
def build_no_highlight_protocol(reading: AfterReadInput) -> str:
problem = reading.current_problem or "你读这一节前想解决的那个问题"
return "\n".join(
[
f"# 无划线读后检查|《{reading.book_title}》{reading.chapter_title}",
"",
"这次先不强行总结。没有划线时,更可能是读得太散,或者问题没有咬住文本。",
"",
"## 先回答 3 个问题",
f"1. 这一节里哪句话最接近「{problem}」?请补一条原文或自己的转述。",
"2. 如果只能把这一节变成一个动作,你会改哪个项目、文章或判断?",
"3. 这节有没有让你改变原来的看法?如果没有,下一次应该换书还是换问题?",
"",
"## 下一步",
"补一条划线/转述后,再生成阅读行动卡;否则这次阅读只记录为浏览,不算进入工作。",
]
)
def build_after_read_result(reading: AfterReadInput) -> AfterReadResult:
highlights = [item.strip() for item in reading.highlights if item.strip()]
if not highlights:
return AfterReadResult(
status="no-highlights",
markdown=build_no_highlight_protocol(reading),
should_writeback=False,
)
card = build_action_card(
ReadingInput(
book_title=reading.book_title,
chapter_title=reading.chapter_title,
highlights=highlights,
current_problem=reading.current_problem,
)
)
return AfterReadResult(status="action-card", markdown=card, should_writeback=True)
from __future__ import annotations
from dataclasses import asdict, dataclass
from typing import Any
from .today_chapter import Chapter, is_substantive_chapter_title
@dataclass(frozen=True)
class BookRef:
book_id: str
title: str
def _unwrap_items(payload: Any, keys: tuple[str, ...]) -> list[Any]:
if isinstance(payload, list):
return payload
if not isinstance(payload, dict):
return []
containers = [payload]
for wrapper_key in ("data", "result"):
wrapped = payload.get(wrapper_key)
if isinstance(wrapped, dict):
containers.append(wrapped)
for container in containers:
for key in keys:
value = container.get(key)
if isinstance(value, list):
return value
return []
def _string_value(item: dict[str, Any], keys: tuple[str, ...]) -> str:
for key in keys:
value = item.get(key)
if value is not None:
text = str(value).strip()
if text:
return text
return ""
def extract_book_refs(payload: Any, limit: int | None = None) -> list[BookRef]:
refs: list[BookRef] = []
for raw_item in _unwrap_items(payload, ("books", "bookProgress", "updated", "synckeys")):
if not isinstance(raw_item, dict):
continue
item = raw_item.get("book") if isinstance(raw_item.get("book"), dict) else raw_item
book_id = _string_value(item, ("book_id", "bookId", "bookid", "id"))
title = _string_value(item, ("book_title", "title", "bookName", "name"))
if book_id and title:
refs.append(BookRef(book_id=book_id, title=title))
if limit is not None and len(refs) >= limit:
break
return refs
def _is_readable_chapter(title: str, item: dict[str, Any]) -> bool:
normalized = title.strip()
if not is_substantive_chapter_title(normalized):
return False
word_count = item.get("wordCount") or item.get("word_count")
if isinstance(word_count, int) and word_count <= 20:
return False
return True
def chapters_from_chapterinfo(book_id: str, book_title: str, payload: Any) -> list[Chapter]:
chapters: list[Chapter] = []
for item in _unwrap_items(payload, ("chapters", "chapterInfos", "chapterInfo")):
if not isinstance(item, dict):
continue
chapter_uid = _string_value(item, ("chapter_uid", "chapterUid", "uid", "chapterId", "id"))
title = _string_value(item, ("title", "chapterName", "name"))
if not _is_readable_chapter(title, item):
continue
if chapter_uid and title:
chapters.append(Chapter(book_id=book_id, book_title=book_title, chapter_uid=chapter_uid, title=title))
return chapters
def merge_book_refs(*groups: list[BookRef], limit: int | None = None) -> list[BookRef]:
merged: list[BookRef] = []
seen: set[str] = set()
for group in groups:
for ref in group:
if ref.book_id in seen:
continue
seen.add(ref.book_id)
merged.append(ref)
if limit is not None and len(merged) >= limit:
return merged
return merged
def build_candidate_chapters(
shelf_payload: Any,
notebooks_payload: Any,
chapterinfo_by_book_id: dict[str, Any],
limit_books: int | None = None,
) -> list[Chapter]:
book_refs = merge_book_refs(
extract_book_refs(shelf_payload),
extract_book_refs(notebooks_payload),
limit=limit_books,
)
candidates: list[Chapter] = []
for ref in book_refs:
payload = chapterinfo_by_book_id.get(ref.book_id)
if payload is None:
continue
candidates.extend(chapters_from_chapterinfo(ref.book_id, ref.title, payload))
return candidates
def chapters_to_jsonable(chapters: list[Chapter]) -> list[dict[str, str]]:
return [asdict(chapter) for chapter in chapters]
from __future__ import annotations
import argparse
import json
from pathlib import Path
import subprocess
import sys
from typing import Any
from .after_read import AfterReadInput, build_after_read_result, extract_highlights
from .config import ContextConfig, load_config
from .context import collect_context_for_config
from .unread_advisor import format_unread_recommendation, recommend_unread_book
from .weekly_loop import WeeklyReportInput, build_weekly_report, load_cards
from .writeback import write_action_card
def default_config_path() -> Path:
return Path.home() / ".config" / "carl-weread" / "config.toml"
def _load_config_or_chat(path: Path) -> ContextConfig:
return load_config(path) if path.exists() else ContextConfig(mode="chat")
def _run_weread(weread_script: Path, args: list[str]) -> Any:
command = [str(weread_script), *args]
if weread_script.suffix == ".py":
command = [sys.executable, str(weread_script), *args]
try:
result = subprocess.run(command, text=True, capture_output=True, timeout=90)
except subprocess.TimeoutExpired:
print(f"WeRead命令超时:{' '.join(command[:2])} ...", file=sys.stderr)
raise SystemExit(4)
if result.returncode != 0:
sys.stderr.write(result.stderr)
raise SystemExit(result.returncode)
return json.loads(result.stdout)
def _cmd_recommend(args: argparse.Namespace) -> int:
config = _load_config_or_chat(args.config)
context = collect_context_for_config(config, brief=args.brief)
shelf_payload = _run_weread(args.weread_script, ["shelf"])
notebooks_payload = _run_weread(args.weread_script, ["notebooks", f"--count={args.notebooks_count}"])
recommendation_payloads = [_run_weread(args.weread_script, ["recommend", f"--count={args.count}"])]
similar_payloads = []
for book_id in args.similar_book_id or []:
similar_payloads.append(_run_weread(args.weread_script, ["similar", f"--bookId={book_id}", f"--count={args.count}"]))
search_payloads = []
if args.keyword:
search_payloads.append(_run_weread(args.weread_script, ["search", f"--keyword={args.keyword}"]))
rec = recommend_unread_book(
context=context,
shelf_payload=shelf_payload,
notebooks_payload=notebooks_payload,
recommendation_payloads=recommendation_payloads,
similar_payloads=similar_payloads,
search_payloads=search_payloads,
brief=args.brief or args.keyword or "",
)
if args.json:
print(json.dumps(rec.__dict__, ensure_ascii=False, indent=2))
else:
print(format_unread_recommendation(rec))
return 0
def _cmd_after_read(args: argparse.Namespace) -> int:
highlights = list(args.highlight or [])
if args.highlights_file:
highlights.extend(
line.strip()
for line in args.highlights_file.read_text(encoding="utf-8").splitlines()
if line.strip()
)
if args.auto_fetch:
if not args.book_id or not args.chapter_uid:
print("--auto-fetch 需要同时提供 --book-id 和 --chapter-uid", file=sys.stderr)
return 2
payload = _run_weread(
args.weread_script,
["underlines", f"--bookId={args.book_id}", f"--chapterUid={args.chapter_uid}"],
)
highlights.extend(extract_highlights(payload))
if not args.book_title or not args.chapter_title:
print("after-read 需要 --book-title 和 --chapter-title", file=sys.stderr)
return 2
result = build_after_read_result(
AfterReadInput(
book_title=args.book_title,
chapter_title=args.chapter_title,
current_problem=args.current_problem or "",
highlights=highlights,
)
)
if args.writeback and result.should_writeback:
config = _load_config_or_chat(args.config)
write_result = write_action_card(
result.markdown,
config,
f"{args.book_title}-{args.chapter_title}",
output_dir=args.writeback_dir,
)
print(write_result.message)
if write_result.path is None:
print(result.markdown)
return 0
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(result.markdown.rstrip() + "\n", encoding="utf-8")
print(f"已写入:{args.output}")
return 0
print(result.markdown)
return 0
def _cmd_weekly(args: argparse.Namespace) -> int:
report = build_weekly_report(WeeklyReportInput(cards=load_cards(args.cards), context=args.context or ""))
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(report + "\n", encoding="utf-8")
print(f"已写入周复盘:{args.output}")
else:
print(report)
return 0
def _cmd_today(args: argparse.Namespace) -> int:
script = Path(__file__).resolve().parents[1] / "scripts" / "today_live.py"
command = [
sys.executable,
str(script),
"--config",
str(args.config),
"--weread-script",
str(args.weread_script),
"--limit-books",
str(args.limit_books),
"--notebooks-count",
str(args.notebooks_count),
]
if args.brief:
command.extend(["--brief", args.brief])
result = subprocess.run(command, text=True)
return result.returncode
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="carl-weread V0.3 unified reading coach CLI")
subparsers = parser.add_subparsers(dest="command", required=True)
recommend = subparsers.add_parser("recommend", help="推荐一本没读透但现在该读的书")
recommend.add_argument("--config", type=Path, default=default_config_path())
recommend.add_argument("--weread-script", type=Path, default=Path("scripts/weread.sh"))
recommend.add_argument("--brief", help="当前问题/项目上下文")
recommend.add_argument("--keyword", help="额外搜索关键词")
recommend.add_argument("--similar-book-id", action="append", help="用相似书扩展候选,可传多次")
recommend.add_argument("--count", type=int, default=12)
recommend.add_argument("--notebooks-count", type=int, default=200)
recommend.add_argument("--json", action="store_true")
recommend.set_defaults(func=_cmd_recommend)
today = subparsers.add_parser("today", help="推荐今天只读哪一小节")
today.add_argument("--config", type=Path, default=default_config_path())
today.add_argument("--weread-script", type=Path, default=Path("scripts/weread.sh"))
today.add_argument("--brief", help="当前问题/项目上下文")
today.add_argument("--limit-books", type=int, default=5)
today.add_argument("--notebooks-count", type=int, default=200)
today.set_defaults(func=_cmd_today)
after_read = subparsers.add_parser("after-read", help="读后检查划线;有划线生成行动卡,无划线走追问协议")
after_read.add_argument("--config", type=Path, default=default_config_path())
after_read.add_argument("--weread-script", type=Path, default=Path("scripts/weread.sh"))
after_read.add_argument("--book-id")
after_read.add_argument("--chapter-uid")
after_read.add_argument("--book-title")
after_read.add_argument("--chapter-title")
after_read.add_argument("--current-problem")
after_read.add_argument("--highlight", action="append")
after_read.add_argument("--highlights-file", type=Path)
after_read.add_argument("--auto-fetch", action="store_true")
after_read.add_argument("--writeback", action="store_true")
after_read.add_argument("--writeback-dir", type=Path)
after_read.add_argument("--output", type=Path)
after_read.set_defaults(func=_cmd_after_read)
weekly = subparsers.add_parser("weekly", help="从阅读行动卡生成周复盘")
weekly.add_argument("--cards", nargs="*", type=Path, default=[])
weekly.add_argument("--context", default="")
weekly.add_argument("--output", type=Path)
weekly.set_defaults(func=_cmd_weekly)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
VALID_MODES = {"obsidian", "folder", "chat", "weread-only"}
PATH_REQUIRED_MODES = {"obsidian", "folder"}
@dataclass(frozen=True)
class ContextConfig:
mode: str
path: Path | None = None
def validate(self) -> None:
if self.mode not in VALID_MODES:
raise ValueError(f"unsupported mode: {self.mode}")
if self.mode in PATH_REQUIRED_MODES and self.path is None:
raise ValueError(f"path is required for {self.mode} mode")
def _escape_toml_string(value: str) -> str:
return value.replace("\\", "\\\\").replace('"', '\\"')
def write_config(config: ContextConfig, output_path: str | Path) -> None:
config.validate()
output = Path(output_path).expanduser()
output.parent.mkdir(parents=True, exist_ok=True)
lines = ["[context]", f'mode = "{_escape_toml_string(config.mode)}"']
if config.path is not None:
lines.append(f'path = "{_escape_toml_string(str(Path(config.path).expanduser()))}"')
lines.append("")
output.write_text("\n".join(lines), encoding="utf-8")
def _parse_simple_toml_context(text: str) -> dict[str, str]:
in_context = False
values: dict[str, str] = {}
for raw_line in text.splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if line.startswith("[") and line.endswith("]"):
in_context = line == "[context]"
continue
if not in_context or "=" not in line:
continue
key, raw_value = line.split("=", 1)
value = raw_value.strip()
if value.startswith('"') and value.endswith('"'):
value = value[1:-1].replace('\\"', '"').replace("\\\\", "\\")
values[key.strip()] = value
return values
def load_config(config_path: str | Path) -> ContextConfig:
path = Path(config_path).expanduser()
values = _parse_simple_toml_context(path.read_text(encoding="utf-8"))
mode = values.get("mode")
if mode is None:
raise ValueError("config missing context.mode")
context_path = Path(values["path"]).expanduser() if values.get("path") else None
config = ContextConfig(mode=mode, path=context_path)
config.validate()
return config
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, timedelta
from pathlib import Path
from .config import ContextConfig
@dataclass(frozen=True)
class ContextItem:
kind: str
source: str
text: str
def _read_snippet(path: Path, max_chars: int) -> str:
return path.read_text(encoding="utf-8", errors="ignore")[:max_chars].strip()
def _recent_daily_paths(vault: Path, today: date, days: int) -> list[Path]:
daily_dir = vault / "10_日记"
return [daily_dir / f"{today - timedelta(days=offset):%Y-%m-%d}.md" for offset in range(days)]
def _first_markdown_files(root: Path, limit: int = 8) -> list[Path]:
if not root.exists():
return []
return sorted((p for p in root.rglob("*.md") if p.is_file()), key=lambda p: p.stat().st_mtime, reverse=True)[:limit]
def _first_context_files(root: Path, limit: int = 8) -> list[Path]:
if not root.exists():
return []
patterns = ("*.md", "*.txt")
candidates: list[Path] = []
for pattern in patterns:
candidates.extend(p for p in root.rglob(pattern) if p.is_file())
return sorted(candidates, key=lambda p: p.stat().st_mtime, reverse=True)[:limit]
def collect_recent_context(
vault_path: str | Path,
today: date | None = None,
days: int = 3,
max_chars_per_file: int = 1200,
) -> list[ContextItem]:
"""Collect a compact context bundle from Obsidian daily/project/topic notes."""
vault = Path(vault_path).expanduser()
today = today or date.today()
items: list[ContextItem] = []
structured_dirs = [vault / "10_日记", vault / "20_项目", vault / "15_自媒体" / "选题库"]
has_obsidian_structure = any(path.exists() for path in structured_dirs)
if not has_obsidian_structure:
for path in _first_context_files(vault, limit=8):
items.append(ContextItem("context", str(path.relative_to(vault)), _read_snippet(path, max_chars_per_file)))
return [item for item in items if item.text]
for path in _recent_daily_paths(vault, today, days):
if path.exists():
items.append(ContextItem("daily", str(path.relative_to(vault)), _read_snippet(path, max_chars_per_file)))
for path in _first_markdown_files(vault / "20_项目", limit=6):
items.append(ContextItem("project", str(path.relative_to(vault)), _read_snippet(path, max_chars_per_file)))
for path in _first_markdown_files(vault / "15_自媒体" / "选题库", limit=6):
items.append(ContextItem("topic", str(path.relative_to(vault)), _read_snippet(path, max_chars_per_file)))
return [item for item in items if item.text]
def collect_context_for_config(
config: ContextConfig,
brief: str | None = None,
today: date | None = None,
days: int = 3,
max_chars_per_file: int = 1200,
) -> list[ContextItem]:
"""Collect context using the configured mode."""
config.validate()
if config.mode in {"obsidian", "folder"}:
return collect_recent_context(config.path, today=today, days=days, max_chars_per_file=max_chars_per_file)
if config.mode == "chat":
text = (brief or "").strip()
return [ContextItem("brief", "current-input", text)] if text else []
return []
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass(frozen=True)
class ReadingInput:
book_title: str
chapter_title: str
highlights: list[str] = field(default_factory=list)
current_problem: str = ""
def _first_highlight(reading: ReadingInput) -> str:
return reading.highlights[0].strip() if reading.highlights else "这次阅读需要补一条具体划线或读后想法。"
def _concept_name(reading: ReadingInput) -> str:
if "验证" in _first_highlight(reading) or "验证" in reading.current_problem:
return "阅读进入验证链路"
if "行动" in reading.current_problem:
return "行动型阅读"
return "上下文驱动阅读"
def build_action_card(reading: ReadingInput) -> str:
"""Build an Obsidian-ready action card from a reading note."""
highlight = _first_highlight(reading)
concept = _concept_name(reading)
problem = reading.current_problem or "这次阅读还没有绑定到具体问题,需要补充它解释了哪个真实卡点。"
return f"""# 阅读行动卡|《{reading.book_title}》{reading.chapter_title}
## 一句话收获
{highlight}
## 它解释了我最近哪个问题
{problem}
## 可以马上改的一个动作
把这次阅读里的一个判断,改写成当前项目里可检查的验证标准,并在今天推进一次。
## 可沉淀的原子笔记
[[{concept}]]:阅读不是在脑子里多存一条知识,而是要进入一个可验证的行动链路。只有能改变下一步动作的内容,才真正进入了工作系统。
## 可发展的内容选题
标题方向:AI 时代读书,不该从书单开始,而该从你今天卡住的问题开始。
切入角度:用《{reading.book_title}》这一节作为例子,展示如何把一段阅读变成一个项目动作。
""".strip()
from __future__ import annotations
from datetime import datetime
from zoneinfo import ZoneInfo
from urllib.parse import urlencode
def seconds_to_readable(seconds: int) -> str:
"""Convert WeRead duration seconds to a Chinese human-readable string."""
minutes = max(0, int(seconds)) // 60
hours, mins = divmod(minutes, 60)
if hours and mins:
return f"{hours} 小时 {mins} 分钟"
if hours:
return f"{hours} 小时"
return f"{mins} 分钟"
def format_date(timestamp: int, tz: str = "Asia/Shanghai") -> str:
"""Format a Unix timestamp as YYYY-MM-DD in the requested timezone."""
return datetime.fromtimestamp(int(timestamp), ZoneInfo(tz)).strftime("%Y-%m-%d")
def weread_link(book_id: str, chapter_uid: str | None = None) -> str:
"""Build a WeRead deep link for a book or a specific chapter."""
params = {"bId": book_id}
if chapter_uid:
params["chapterUid"] = str(chapter_uid)
return "weread://reading?" + urlencode(params)
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from .candidates import _unwrap_items
@dataclass(frozen=True)
class BookProfile:
book_id: str
title: str
author: str = ""
category: str = ""
intro: str = ""
source: str = "unknown"
note_count: int = 0
progress_percent: float | None = None
def _string_value(item: dict[str, Any], keys: tuple[str, ...]) -> str:
for key in keys:
value = item.get(key)
if value is None:
continue
text = str(value).strip()
if text:
return text
return ""
def _number_value(item: dict[str, Any], keys: tuple[str, ...]) -> float | None:
for key in keys:
value = item.get(key)
if value in (None, ""):
continue
try:
return float(value)
except (TypeError, ValueError):
continue
return None
def _book_dict(raw_item: Any) -> dict[str, Any] | None:
if not isinstance(raw_item, dict):
return None
for key in ("book", "bookInfo", "bookinfo", "info"):
value = raw_item.get(key)
if isinstance(value, dict):
merged = dict(value)
for outer_key in ("noteCount", "note_count", "readingTime", "progress", "progressPercent", "source"):
if outer_key in raw_item and outer_key not in merged:
merged[outer_key] = raw_item[outer_key]
return merged
return raw_item
def extract_book_profiles(payload: Any, source: str = "unknown") -> list[BookProfile]:
"""Extract book profiles from common WeRead response shapes.
The WeRead gateway returns slightly different envelopes across endpoints. This
helper stays deliberately permissive so workflow code can reason about books
without depending on one exact JSON shape.
"""
raw_items = _unwrap_items(
payload,
(
"books",
"bookInfos",
"bookInfo",
"items",
"records",
"recommendBooks",
"data",
"bookProgress",
"updated",
"synckeys",
),
)
profiles: list[BookProfile] = []
for raw_item in raw_items:
item = _book_dict(raw_item)
if item is None:
continue
book_id = _string_value(item, ("book_id", "bookId", "bookid", "id"))
title = _string_value(item, ("book_title", "title", "bookName", "name"))
if not book_id or not title:
continue
note_count = int(_number_value(item, ("noteCount", "note_count", "bookmarkCount", "reviewCount")) or 0)
progress = _number_value(item, ("progressPercent", "progress", "percent", "readProgress"))
profiles.append(
BookProfile(
book_id=book_id,
title=title,
author=_string_value(item, ("author", "authorName", "writer")),
category=_string_value(item, ("category", "categoryName", "newCategory", "type")),
intro=_string_value(item, ("intro", "brief", "description", "shortIntro")),
source=source,
note_count=note_count,
progress_percent=progress,
)
)
return profiles
def merge_profiles(*groups: list[BookProfile]) -> list[BookProfile]:
merged: dict[str, BookProfile] = {}
for group in groups:
for profile in group:
existing = merged.get(profile.book_id)
if existing is None:
merged[profile.book_id] = profile
continue
merged[profile.book_id] = BookProfile(
book_id=existing.book_id,
title=existing.title or profile.title,
author=existing.author or profile.author,
category=existing.category or profile.category,
intro=existing.intro or profile.intro,
source="/".join(dict.fromkeys([*existing.source.split("/"), *profile.source.split("/")])),
note_count=max(existing.note_count, profile.note_count),
progress_percent=existing.progress_percent if existing.progress_percent is not None else profile.progress_percent,
)
return list(merged.values())
def reading_evidence_ids(notebooks_payload: Any, progress_payloads: dict[str, Any] | None = None) -> set[str]:
ids = {profile.book_id for profile in extract_book_profiles(notebooks_payload, source="notebooks") if profile.note_count > 0}
for book_id, payload in (progress_payloads or {}).items():
profiles = extract_book_profiles(payload, source="progress")
if any((profile.progress_percent or 0) >= 20 for profile in profiles):
ids.add(book_id)
return ids
from __future__ import annotations
from dataclasses import dataclass
import re
from .formatting import weread_link
@dataclass(frozen=True)
class ContextItem:
kind: str
source: str
text: str
@dataclass(frozen=True)
class Chapter:
book_id: str
book_title: str
chapter_uid: str
title: str
@dataclass(frozen=True)
class TodayChapterRecommendation:
book_title: str
chapter_title: str
why: str
reading_question: str
apply_action: str
deep_link: str
_KEYWORD_ALIASES = {
"agent": ["agent", "skill", "subagent", "codex", "hermes", "ai software", "harness"],
"product": ["产品", "pm", "用户", "增长", "需求"],
"writing": ["写作", "选题", "公众号", "脚本", "内容"],
"design": ["设计", "审美", "ui", "ux", "视觉"],
}
_NON_CONTENT_CHAPTER_TITLES = {
"封面",
"目录",
"版权",
"版权页",
"版权信息",
"书名页",
"扉页",
"献词",
"插图",
"插图目录",
"内容简介",
"作者简介",
"前言",
"序",
"推荐序",
"作者序",
"再版序",
}
_BRIEF_TRIGGER_PHRASES = {
"今天读什么",
"今天读哪一小节",
"推荐一章",
"推荐一小节",
}
def _tokens(text: str) -> set[str]:
return {t.lower() for t in re.findall(r"[A-Za-z][A-Za-z0-9_-]*|[\u4e00-\u9fff]{2,}", text)}
def _expanded_tokens(text: str) -> set[str]:
tokens = _tokens(text)
expanded = set(tokens)
joined = " ".join(tokens)
for key, aliases in _KEYWORD_ALIASES.items():
if any(alias.lower() in joined for alias in aliases):
expanded.update(alias.lower() for alias in aliases)
expanded.add(key)
return expanded
def is_substantive_chapter_title(title: str) -> bool:
normalized = re.sub(r"\s+", "", title).strip("::,.,。!?!?")
if not normalized:
return False
return normalized not in _NON_CONTENT_CHAPTER_TITLES
def _has_specific_context(text: str) -> bool:
normalized = re.sub(r"\s+", "", text).strip("::,.,。!?!?")
if not normalized:
return False
return normalized not in _BRIEF_TRIGGER_PHRASES
def choose_today_chapter(context: list[ContextItem], chapters: list[Chapter]) -> TodayChapterRecommendation:
"""Pick the chapter most connected to the current context."""
chapters = [chapter for chapter in chapters if is_substantive_chapter_title(chapter.title)]
if not chapters:
raise ValueError("chapters must not be empty")
context_text = "\n".join(item.text for item in context)
context_tokens = _expanded_tokens(context_text)
def score(chapter: Chapter) -> int:
chapter_tokens = _expanded_tokens(f"{chapter.book_title} {chapter.title}")
return len(context_tokens & chapter_tokens)
chosen = max(chapters, key=score)
strongest_context = context[0].text if context else "最近上下文不足"
short_context = strongest_context.replace("\n", " ")[:80]
if _has_specific_context(context_text):
why = f"你最近的上下文里反复出现「{short_context}」,这一节最接近当前问题。"
else:
why = "当前没有可用的项目/笔记上下文;我先按微信读书最近书架和笔记里的可读章节推荐这一节。"
return TodayChapterRecommendation(
book_title=chosen.book_title,
chapter_title=chosen.title,
why=why,
reading_question="读这一节时只问一个问题:它能解释我最近哪个真实卡点?",
apply_action="读完后写下一条可以在今天项目里立刻改掉的动作。",
deep_link=weread_link(chosen.book_id, chosen.chapter_uid),
)
from __future__ import annotations
from dataclasses import dataclass
import re
from typing import Any
from .reading_profile import BookProfile, extract_book_profiles, merge_profiles
from .today_chapter import ContextItem, _expanded_tokens
@dataclass(frozen=True)
class UnreadRecommendation:
book_id: str
book_title: str
author: str
source: str
why: str
evidence: list[str]
first_reading_question: str
next_action: str
def _context_text(context: list[ContextItem], brief: str = "") -> str:
return "\n".join([brief, *(item.text for item in context)]).strip()
def _known_ids(*groups: list[BookProfile]) -> set[str]:
return {profile.book_id for group in groups for profile in group}
def _tokens_for_profile(profile: BookProfile) -> set[str]:
return _expanded_tokens(" ".join([profile.title, profile.author, profile.category, profile.intro]))
def _score_profile(profile: BookProfile, context_tokens: set[str], shelf_ids: set[str], notebook_ids: set[str]) -> tuple[int, str]:
profile_tokens = _tokens_for_profile(profile)
overlap = context_tokens & profile_tokens
score = len(overlap) * 10
reasons: list[str] = []
if overlap:
reasons.append("命中当前问题关键词:" + "、".join(sorted(overlap)[:5]))
if profile.book_id not in shelf_ids:
score += 8
reasons.append("不在当前书架里,更像真正的新候选")
elif profile.book_id not in notebook_ids:
score += 4
reasons.append("在书架里但还没有明显笔记痕迹")
if "recommend" in profile.source:
score += 3
reasons.append("来自微信读书个性化推荐")
if "similar" in profile.source:
score += 2
reasons.append("来自相似书扩展")
if "search" in profile.source:
score += 1
reasons.append("来自当前问题搜索")
return score, ";".join(reasons) or "候选来源完整,但和当前问题的文本重合较少"
def recommend_unread_book(
context: list[ContextItem],
shelf_payload: Any,
notebooks_payload: Any,
recommendation_payloads: list[Any] | None = None,
similar_payloads: list[Any] | None = None,
search_payloads: list[Any] | None = None,
brief: str = "",
) -> UnreadRecommendation:
"""Recommend one not-yet-digested book using shelf, notes and candidate sources."""
shelf = extract_book_profiles(shelf_payload, source="shelf")
notebooks = extract_book_profiles(notebooks_payload, source="notebooks")
recommended = [
profile
for payload in (recommendation_payloads or [])
for profile in extract_book_profiles(payload, source="recommend")
]
similar = [
profile
for payload in (similar_payloads or [])
for profile in extract_book_profiles(payload, source="similar")
]
search = [
profile
for payload in (search_payloads or [])
for profile in extract_book_profiles(payload, source="search")
]
candidates = merge_profiles(recommended, similar, search, shelf)
if not candidates:
raise ValueError("no book candidates")
shelf_ids = _known_ids(shelf)
notebook_ids = _known_ids(notebooks)
context_tokens = _expanded_tokens(_context_text(context, brief))
ranked = []
for profile in candidates:
if profile.book_id in notebook_ids and profile.note_count > 0:
continue
score, reason = _score_profile(profile, context_tokens, shelf_ids, notebook_ids)
ranked.append((score, profile, reason))
if not ranked:
raise ValueError("no unread candidates after filtering notebook evidence")
ranked.sort(key=lambda item: (item[0], item[1].title), reverse=True)
score, chosen, reason = ranked[0]
evidence = [reason]
if chosen.book_id in shelf_ids:
evidence.append("书架里能看到这本书,但当前笔记列表没有显示已消化证据。")
else:
evidence.append("书架和笔记中暂未看到这本书,适合作为新输入。")
if chosen.category:
evidence.append(f"分类线索:{chosen.category}")
if chosen.intro:
clean_intro = re.sub(r"\s+", " ", chosen.intro).strip()[:120]
evidence.append(f"简介线索:{clean_intro}")
return UnreadRecommendation(
book_id=chosen.book_id,
book_title=chosen.title,
author=chosen.author,
source=chosen.source,
why=f"这本书和当前问题最接近,同时没有明显读后笔记证据。评分口径是:当前上下文匹配、是否已在书架、是否已有笔记、候选来源可信度。",
evidence=evidence,
first_reading_question="翻开后先看目录,只问:哪一章能解释我现在最卡的那个问题?",
next_action="不要先通读。先选一个章节,读完只写一条划线和一张行动卡。",
)
def format_unread_recommendation(rec: UnreadRecommendation) -> str:
author = f"|{rec.author}" if rec.author else ""
lines = [
"今天推荐一本没读透但现在该读的书:",
f"《{rec.book_title}》{author}",
"",
"为什么是它:",
rec.why,
"",
"证据:",
*[f"- {item}" for item in rec.evidence],
"",
"读前问题:",
rec.first_reading_question,
"",
"读完只做一个动作:",
rec.next_action,
]
return "\n".join(lines)
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import re
@dataclass(frozen=True)
class WeeklyReportInput:
cards: list[str]
context: str = ""
def _first_heading(markdown: str) -> str:
for line in markdown.splitlines():
line = line.strip()
if line.startswith("# "):
return line[2:].strip()
return "未命名阅读卡"
def _section(markdown: str, heading: str) -> str:
pattern = re.compile(rf"^## {re.escape(heading)}\s*$", re.MULTILINE)
match = pattern.search(markdown)
if not match:
return ""
start = match.end()
next_match = re.search(r"^## ", markdown[start:], re.MULTILINE)
end = start + next_match.start() if next_match else len(markdown)
return markdown[start:end].strip()
def load_cards(paths: list[str | Path]) -> list[str]:
cards: list[str] = []
for raw_path in paths:
path = Path(raw_path).expanduser()
if path.is_dir():
files = sorted(path.glob("*.md"), key=lambda p: p.stat().st_mtime, reverse=True)
cards.extend(p.read_text(encoding="utf-8", errors="ignore") for p in files)
elif path.exists():
cards.append(path.read_text(encoding="utf-8", errors="ignore"))
return [card for card in cards if card.strip()]
def build_weekly_report(report_input: WeeklyReportInput) -> str:
"""Build a concise weekly reading-action report from action cards and context."""
cards = report_input.cards
headings = [_first_heading(card) for card in cards]
actions = [_section(card, "可以马上改的一个动作") for card in cards]
topics = [_section(card, "可发展的内容选题") for card in cards]
problems = [_section(card, "它解释了我最近哪个问题") for card in cards]
evidence = [action for action in actions if action]
if not cards:
main_reading = "本周还没有可复盘的阅读行动卡。"
evidence_block = "没有证据。先完成一次 today → after-read,再复盘。"
impact_block = "阅读还没有进入文章、项目或判断。"
escape_block = "如果只是收藏书和章节,但没有行动卡,就算作逃避式收藏。"
next_line = "下周只做一件事:完成一张阅读行动卡。"
stop_line = "先停止扩书单,直到第一张行动卡出现。"
candidates = ["从当前最卡的问题开始,而不是从书单开始。"]
else:
main_reading = "\n".join(f"- {heading}" for heading in headings[:5])
evidence_block = "\n".join(f"- {item.splitlines()[0]}" for item in evidence[:5]) or "还缺少行动证据。"
impact_block = _derive_impact(report_input.context, problems, topics)
escape_block = "没有行动卡支撑的章节先不算读进去;下周减少只收藏不应用的阅读。"
next_line = _derive_next_line(report_input.context, headings, topics)
stop_line = _derive_stop_line(headings, evidence)
candidates = _derive_candidates(headings, topics)
return "\n".join([
"# 本周阅读行动闭环",
"",
"## 本周真正读进去的东西",
main_reading,
"",
"## 已经进入工作的证据",
evidence_block,
"",
"## 阅读影响",
impact_block,
"",
"## 逃避式收藏",
escape_block,
"",
"## 下周停止读什么",
stop_line,
"",
"## 下周只保留一条阅读主线",
next_line,
"",
"## 下周 3 个 Today Chapter 候选",
*[f"{idx}. {candidate}" for idx, candidate in enumerate(candidates[:3], start=1)],
]).strip()
def _derive_next_line(context: str, headings: list[str], topics: list[str]) -> str:
text = " ".join([context, *headings, *topics])
if any(word in text for word in ("文章", "选题", "公众号", "内容", "脚本")):
return "把阅读服务于一篇可发布内容:每次读完只沉淀一个能进入文章的判断。"
if any(word.lower() in text.lower() for word in ("agent", "skill", "workflow", "hermes")):
return "把阅读服务于一个可演示工作流:每次读完只改进一个可验证动作。"
return "把阅读服务于当前最卡的项目:每次读完只推进一个今天能完成的小动作。"
def _derive_impact(context: str, problems: list[str], topics: list[str]) -> str:
text = " ".join([context, *problems, *topics])
if any(word in text for word in ("文章", "选题", "公众号", "脚本", "内容")):
return "本周阅读已经进入内容生产:优先把行动卡里的判断改写成文章段落或视频脚本测试点。"
if any(word.lower() in text.lower() for word in ("agent", "skill", "workflow", "cli", "hermes")):
return "本周阅读已经进入产品/工程判断:优先把行动卡里的概念变成可运行命令、测试或README说明。"
if any(item.strip() for item in problems):
return "本周阅读至少解释了一个真实问题;下周要看它有没有改变具体行动。"
return "行动卡里还缺少清晰问题,影响证据偏弱。下周先补「它解释了我最近哪个问题」。"
def _derive_stop_line(headings: list[str], evidence: list[str]) -> str:
if not evidence:
return "停止继续收藏新书;先把本周已读章节补成一张能行动的卡。"
if len(headings) >= 4:
return "停止横向开新主题;下周只沿着最有行动证据的一条线往下读。"
return "停止为了安心而补书单;只保留能推动当前动作的章节。"
def _derive_candidates(headings: list[str], topics: list[str]) -> list[str]:
candidates: list[str] = []
for heading in headings[:3]:
candidates.append(f"延续 {heading}:找下一节能直接改动作的内容")
for topic in topics:
lines = topic.splitlines()
if not lines:
continue
first = lines[0].replace("标题方向:", "").strip()
if first:
candidates.append(f"围绕「{first}」补一节案例型阅读")
candidates.append("从本周最卡的项目问题倒推一节书,而不是顺着目录读。")
return candidates[:3]
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from pathlib import Path
import re
from .config import ContextConfig
@dataclass(frozen=True)
class WritebackResult:
path: Path | None
mode: str
message: str
def slugify_title(title: str, max_length: int = 48) -> str:
"""Build a filesystem-safe, readable slug for Chinese/English titles."""
cleaned = re.sub(r'[\\/:*?"<>|#\n\r\t]+', "-", title).strip(" -")
cleaned = re.sub(r"\s+", "-", cleaned)
cleaned = re.sub(r"-+", "-", cleaned)
return (cleaned or "reading-card")[:max_length].strip(" -") or "reading-card"
def default_writeback_dir(config: ContextConfig) -> Path | None:
"""Return the default directory for persisted action cards, if this mode writes files."""
if config.mode == "obsidian" and config.path is not None:
vault = Path(config.path).expanduser()
preferred = vault / "15_自媒体" / "阅读行动卡"
fallback = vault / "阅读行动卡"
return preferred if preferred.parent.exists() else fallback
if config.mode == "folder" and config.path is not None:
return Path(config.path).expanduser() / "carl-weread" / "reading-cards"
return None
def write_action_card(
card_markdown: str,
config: ContextConfig,
title: str,
output_dir: str | Path | None = None,
today: date | None = None,
) -> WritebackResult:
"""Persist an action card when the configured mode has a real filesystem target.
Chat and WeRead-only modes intentionally do not write files; the caller should return
the markdown in conversation instead.
"""
target_dir = Path(output_dir).expanduser() if output_dir is not None else default_writeback_dir(config)
if target_dir is None:
return WritebackResult(path=None, mode=config.mode, message="当前模式不写文件,已在对话中返回行动卡。")
day = today or date.today()
target_dir.mkdir(parents=True, exist_ok=True)
path = target_dir / f"{day:%Y-%m-%d}-{slugify_title(title)}.md"
path.write_text(card_markdown.rstrip() + "\n", encoding="utf-8")
return WritebackResult(path=path, mode=config.mode, message=f"已写入阅读行动卡:{path}")
carl-weread 一阶段验收报告
日期:2026-05-19
结论
一阶段已经不适合继续无边界迭代。当前应进入 Superpower / Office Hours 验收。
按「最小可验收版本」口径,完成度约 85%。 按 README 里写的完整 V0.1 闭环口径,完成度约 75%-80%。
差距主要不在主体链路,而在两个尾部能力:
1. 真实 WeRead API 样本下的字段兼容性还没有用真实 Key 跑通。 2. 「读后行动卡写回」目前有 workflow 与输出契约,但还没有完整代码实现。
因此建议冻结一阶段开发,只做验收、记录问题、再决定二阶段范围。
一阶段原始目标
V0.1 要验证的闭环是:
最近真实上下文 → 识别当下问题 → 匹配微信读书的一章/一小节 → 给读前问题 → 读后生成行动卡当前完成情况
| 模块 | 状态 | 说明 |
|---|---|---|
| Skill 定位与差异化 | 已完成 | README 和 SKILL.md 已明确:不是阅读统计,而是上下文驱动的阅读行动系统。 |
| 不依赖官方 WeRead Skill | 已完成 | 项目自带 scripts/weread.sh,只要求 WEREAD_API_KEY。 |
| API Key 安全边界 | 已完成 | Key 只从环境变量读取,不写入配置,不作为命令行参数传入。 |
| 无 Obsidian 用户支持 | 已完成 | 支持 Obsidian / Folder / Chat / WeRead-only 四档模式。 |
| 首次配置 | 已完成 | scripts/setup.py --mode ... 可生成配置。 |
| 上下文收集 | 已完成 | collect_context_for_config(...) 支持多模式降级。 |
| 候选章节构建 | 已完成 | carl_weread/candidates.py、build_candidates.py、fetch_candidates.py 已实现。 |
| 今日章节推荐 | 已完成 | today.py 可用本地候选章节生成推荐。 |
| 一键命令 | 已完成 | today_live.py 可串联配置、上下文、WeRead 数据和今日推荐。 |
| 输出风格 | 已完成 | shared/output-style.md 规定必须包含章节、理由、读前问题、读后动作、weread 链接。 |
| 测试 | 已完成 | 当前测试通过:25 passed。 |
| 真实 API 样本加固 | 部分完成 | 已做防御式字段兼容,但当前环境没有 WEREAD_API_KEY,未跑真实样本。 |
| 读后行动卡写回 | 未完成 | workflow 已定义,代码尚未实现写回 Obsidian / folder / chat。 |
| 包安装 / CLI entrypoint | 未完成 | 目前仍以 scripts/*.py 方式运行。 |
验证结果
最近一次测试:
.venv/bin/python -m pytest tests -q结果:
25 passed in 0.39s工作区当前仍有未提交修改和新增文件,验收前建议不要继续加功能,而是整理 commit 或打验收分支。
不建议继续迭代的原因
继续写新功能会把一阶段变成开放式开发,难以判断 MVP 是否成立。
当前已经可以验收的核心问题是:
这个 Skill 的差异化是不是成立?
用户是否真的需要「今天只读这一小节」?
四档上下文降级是否足够降低安装门槛?
WeRead 数据和用户当前问题之间的匹配是否值得继续投入?
这些问题需要 Superpower / Office Hours 评价,而不是继续写代码解决。
给 Superpower 的验收重点
请重点看:
1. Skill 的定位是否足够清晰。 2. V0.1 是否过大,是否应该缩成「today chapter」一个闭环。 3. 四档模式是否让非 Obsidian 用户也能上手。 4. 当前 README / SKILL.md / workflows 是否能让另一个 Agent 接手。 5. 是否应该把「行动卡写回」放到二阶段,而不是一阶段继续补。
给 Office Hours 的验收重点
请重点问:
1. 真实需求:用户是真的想读更多,还是想把阅读变成行动? 2. 现状替代:用户现在用什么方式决定今天读什么?痛点强不强? 3. 最窄切口:是否只保留「根据一句 brief 推荐微信读书一小节」作为首发? 4. 观察证据:卡尔自己是否会连续 7 天使用? 5. 未来扩展:如果这个切口成立,是否自然扩展到行动卡、周复盘、内容选题?
建议冻结范围
一阶段冻结在:
setup.py
→ today_live.py
→ 输出今天一小节推荐不要继续把以下内容塞进一阶段:
- Obsidian 写回完整实现
- 周复盘自动化
- 包发布和安装器
- 多平台同步
- 更复杂的排序算法
这些应进入二阶段候选池。
建议下一步
1. 停止新增功能。 2. 整理当前 diff,形成验收 commit 或验收分支。 3. 把本报告、README、SKILL.md、workflow 文件交给 Superpower / Office Hours。 4. 根据验收结论决定二阶段只做一个方向:
- A:真实 API 字段兼容 + 排序策略
- B:读后行动卡写回
- C:安装和分发体验
我的建议:二阶段优先做 B,但必须等验收后再定。
carl-weread 一阶段代码与安全验收
日期:2026-05-19
结论
有条件通过。
当前代码可以进入一阶段验收,不建议继续扩展功能。验收阻塞点不是测试失败或安全问题,而是:
1. 当前环境没有 WEREAD_API_KEY,无法跑真实 WeRead API 端到端。 2. today_live.py 在默认配置文件不存在时错误提示不够友好,这是体验问题,不阻塞一阶段概念验收。 3. 真实 API 字段兼容性还需要二阶段用脱敏样本加固。
已执行检查
1. 测试
命令:
.venv/bin/python -m pytest tests -q结果:
25 passed in 0.39s2. 编译检查
命令:
.venv/bin/python -m compileall carl_weread scripts -q结果:通过,无报错。
3. Skill 基础检查
scripts/weread.sh --help:exit code 0SKILL.mddescription 长度:131 字符- description 小于 1024:通过
4. 安全边界检查
检查目标:
- 是否硬编码真实 API Key / token / password / secret
- 是否出现
shell=True、os.system、eval、exec、pickle.loads WEREAD_API_KEY是否只从环境变量读取
结果:
- 未发现新增危险执行模式。
- 未发现真实密钥硬编码。
- 仓库中出现的
wrk-secret是测试假值,用于验证不泄露 API Key,不是真实凭据。 WEREAD_API_KEY的使用符合当前约定:只从环境变量读取,不写入配置文件,不作为正常命令行参数传入。
当前已覆盖的关键路径
| 路径 | 状态 |
|---|---|
| 配置读写不保存 API Key | 已有测试 |
| Obsidian / Folder / Chat / WeRead-only 模式 | 已有测试 |
| 普通文件夹 fallback | 已有测试 |
| 候选章节标准化 | 已有测试 |
| 从 fake WeRead helper 拉候选 | 已有测试 |
fetch_candidates.py 不接受 --api-key | 已有测试 |
today_live.py 不接受 --api-key | 已有测试 |
| 一键 today live fake helper 成功路径 | 已有测试 |
| 无 API Key 安全失败 | 已有测试 |
不阻塞一阶段的已知问题
1. 当前环境没有真实 WEREAD_API_KEY
因此无法验证真实 WeRead API 响应字段。当前 candidates 模块是防御式兼容:
- book id 支持
bookId/book_id/id - 书名支持
title/bookName/name - chapter id 支持
chapterUid/uid/chapterId - 章节名支持
chapterName/title
但这还需要真实脱敏样本验证。
2. 默认配置缺失时提示不友好
已知现象:未提供 --config 且默认配置不存在时,today_live.py 会抛 FileNotFoundError。
建议二阶段或验收后第一修复:
找不到配置文件:~/.config/carl-weread/config.toml
请先运行:scripts/setup.py --mode chat并返回 exit code 2。
这不影响当前一阶段主链路验收,因为提供 config 时无 Key 安全失败路径已经正常。
3. 独立代码审查子任务超时
尝试调用独立 code/security reviewer 子任务时发生超时。已用本地测试、编译、静态搜索和手动检查补充完成验收。该超时不代表项目测试失败。
验收意见
代码层面可以冻结一阶段。
当前不要继续补功能,建议只做:
1. 保存验收报告。 2. 整理 commit / branch。 3. 用真实脱敏 API 样本作为二阶段第一批测试输入。
carl-weread 一阶段最终验收结论
日期:2026-05-19
最终结论
一阶段有条件通过,建议立即冻结,不继续迭代功能。
一阶段通过的口径不是「完整阅读行动系统」,而是:
Phase 1: Today Chapter MVP即:
一句真实 brief / 最近上下文
→ 微信读书候选章节
→ 推荐今天只读的一小节
→ 给读前问题
→ 给读后一个动作验收来源
本次验收拆成三份报告:
1. docs/phase-1-superpower-review.md
- 视角:Superpower / Skill 设计验收
- 结论:有条件通过
2. docs/phase-1-office-hours-review.md
- 视角:Office Hours / 需求验证
- 结论:有条件通过
3. docs/phase-1-code-validation.md
- 视角:代码、安全、测试验收
- 结论:有条件通过
原始阶段总结:
4. docs/phase-1-acceptance.md
验收通过的部分
| 能力 | 结论 |
|---|---|
| Skill 定位 | 通过 |
| 与其他 WeRead Skill 的差异化 | 通过 |
| 不依赖官方 WeRead Skill 安装 | 通过 |
WEREAD_API_KEY 安全边界 | 通过 |
| Obsidian / Folder / Chat / WeRead-only 四档模式 | 通过 |
首次配置 setup.py | 通过 |
| 上下文收集 | 通过 |
| WeRead 候选章节标准化 | 通过 |
fetch_candidates.py 真实 API 候选生成链路 | 通过 fake helper 测试 |
today.py 本地推荐闭环 | 通过 |
today_live.py 一键推荐主链路 | 通过 fake helper 测试 |
| 输出格式 | 通过 |
| 自动测试 | 通过,25 passed |
| 编译检查 | 通过 |
有条件通过的原因
一阶段不是完全通过,原因有三点:
1. 范围口径需要冻结
文档里同时出现了 today、digest、weekly 三条线。验收口径必须明确:
today-chapter:一阶段主链路
digest-apply:二阶段入口/输出契约
weekly-loop:二阶段候选否则会变成开放式开发。
2. 真实 WeRead API 样本未验证
当前环境没有 WEREAD_API_KEY,所以真实 API 端到端没有跑通。
这不阻塞一阶段概念验收,但阻塞真实用户稳定使用前的质量确认。
3. 用户需求强度未验证
Office Hours 视角下,最大问题是:
用户真的会连续 7 天使用「今天只读这一小节」吗?这需要真实使用实验,而不是继续写功能。
明确不进入一阶段的内容
以下内容全部冻结到二阶段候选池,不再继续塞进一阶段:
- Obsidian / folder 写回完整实现
- 周复盘自动化
- 正式包发布 / CLI entrypoint
- 多平台同步
- 复杂排序算法 / embedding
- 完整阅读知识库系统
二阶段候选优先级
验收后只建议从三个方向里选一个,不要并行:
A. 真实 API 样本兼容 + 推荐质量观测
优先级最高。因为核心承诺是推荐准。
B. 对话内轻量行动卡
先验证用户读完后是否愿意要行动卡,不急着写回。
C. 安装和分发体验
只有当自用/小范围测试证明需求成立,再做分发。
推荐验证实验
1. 卡尔 7 天自用
每天输入一句真实 brief,只看:
- 推荐相关度
- 是否读完
- 是否执行读后动作
- 是否产生项目推进或内容线索
- 是否愿意明天继续用
通过标准:
- 7 天至少 5 天完成阅读
- 至少 4 天执行动作
- 平均相关度 ≥ 4/5
2. 5 人 concierge 测试
不要求安装,让目标用户每天发一句「我今天卡在……」。
通过标准:
- 5 人中至少 3 人连续用 3 天
- 至少 2 人认为比自己打开微信读书找更有用
- 至少 1 人主动问能否每天自动给
3. A/B 对照
同一个问题给两种输出:
A. 直接 AI 行动建议 B. 微信读书一小节 + 读前问题 + 读后动作
如果 B 经常输给 A,定位要调整为:
用个人书架增强行动建议而不是单纯每日阅读推荐。
最终 Stop Line
一阶段现在停止在:
setup.py
→ today_live.py
→ 输出今天一小节推荐接下来不要继续写功能。下一步应该是:
1. 整理当前 diff。 2. 打验收 commit / branch。 3. 进入 7 天自用和小范围 concierge 测试。 4. 用测试结果决定二阶段方向。
carl-weread 一阶段 Office Hours 验收
日期:2026-05-19
结论
有条件通过。
一阶段可以进入真实使用验证,但不能判定为完整产品通过。当前最大的风险不是功能没写完,而是还没有证明:
用户会不会连续 7 天回来问:今天我该读哪一小节?Office Hours 判断
| 问题 | 判断 | 说明 |
|---|---|---|
| 真实痛点是否成立 | 方向成立,强度未验证 | 用户可能不是想读更多,而是想把阅读变成行动。 |
| 现状替代是否清楚 | 需要补充观察 | 最大替代不是其他 WeRead Skill,而是直接问 AI 要建议。 |
| 最窄切口是否足够窄 | 需要继续收窄 | 首发应冻结为「一句 brief → 今天一小节 → 一个行动」。 |
| 用户画像是否具体 | 还可更窄 | 首批用户应聚焦有微信读书积累、有项目压力、需要内容/工作流推进的人。 |
| 是否会连续 7 天使用 | 未证明 | 这是二阶段前最关键的验证。 |
最大风险
1. 用户真正要的可能不是阅读,而是行动
用户说想读书,但真实需求可能是:
我今天怎么推进项目?如果是这样,阅读只是素材源之一。carl-weread 需要证明:从个人书架里找一小节,比直接问 AI 更可信、更能推动行动。
2. 四档模式降低门槛,但也稀释验证
Obsidian / Folder / Chat / WeRead-only 都有价值,但验证时不要同时验证四条线。
建议先验证最窄链路:
Chat brief + WeRead 数据 → Today Chapter3. 行动卡写回可能过早工程化
不要先做完整写回。先验证用户读完后是否愿意回复「读完了」,并接受对话里的行动卡。
只有当用户多次手动复制或明确要求保存,再做 Obsidian/folder 写回。
建议验证实验
实验 1:卡尔 7 天自用
每天只输入一句真实 brief,记录:
- 推荐相关度:1-5 分
- 是否真的读完
- 读后动作是否执行
- 如果没用,真实替代是什么
- 是否愿意明天继续用
通过标准:
- 7 天中至少 5 天完成阅读
- 至少 4 天执行读后动作
- 平均推荐相关度 ≥ 4/5
- 至少 3 次产生真实项目推进或内容线索
实验 2:5 人 concierge 测试
不要求安装,让 5 个目标用户每天发一句:
我今天卡在……然后返回一小节推荐。
通过标准:
- 5 人中至少 3 人愿意连续用 3 天
- 至少 2 人认为比自己打开微信读书找更有用
- 至少 1 人主动问能不能每天自动给
实验 3:A/B 对照
同一个问题给两种输出:
A. 直接 AI 行动建议 B. 微信读书一小节 + 读前问题 + 读后动作
观察用户更想用哪一个。
如果 B 经常输给 A,定位应改成:
用个人书架增强行动建议而不是单纯「每日阅读推荐」。
二阶段建议
二阶段不要先做安装分发,也不要直接做完整写回。
优先级建议:
1. 真实 API 字段兼容 + 推荐质量观测 2. 对话内轻量行动卡 3. 用户明确要求后,再做 Obsidian/folder 写回
Office Hours 最终意见
一阶段可以冻结并进入验证。接下来不要继续开发功能,应该用真实使用证明这个问题:
今天只读这一小节,是否真的比直接问 AI 更有价值?carl-weread 一阶段 Superpower 验收
日期:2026-05-19
结论
有条件通过。
carl-weread 的一阶段可以进入验收,但验收口径必须收窄为:
Phase 1: Today Chapter MVP即:
用户一句真实 brief / 最近上下文
→ 微信读书候选章节
→ 今天只读这一小节
→ 读前问题
→ 读后一个动作不要把完整行动卡写回、周复盘、安装分发、复杂排序算法继续塞进一阶段。
验收判断
| 项目 | 判断 | 说明 |
|---|---|---|
| 定位清晰度 | 通过 | 「把微信读书变成每日问题解药」足够清楚,明显区别于阅读统计、书单和年报。 |
| Skill 差异化 | 通过 | 重点不是读了多少,而是今天真实问题对应哪一小节。 |
| 主链路完整度 | 通过 | setup.py、上下文模式、WeRead候选、today_live.py 已形成一键推荐链路。 |
| Agent 接手性 | 基本通过 | README / SKILL / today workflow 足以让另一个 Agent 执行 today-chapter。 |
| 阶段边界 | 有条件通过 | README/SKILL 中仍出现 digest/weekly/行动卡完整闭环叙事,需要明确这些不是一阶段阻塞。 |
| 行动卡写回 | 不进入一阶段 | workflow 已定义,但写回不是当前验收范围。 |
| weekly loop | 不进入一阶段 | 可保留为二阶段 workflow 草案。 |
阻塞项
唯一真正阻塞验收的不是代码,而是范围口径不统一。
当前材料里同时存在三种口径:
1. README 的 V0.1 闭环:包含读后生成行动卡。 2. phase acceptance 文档:建议冻结在 today 推荐。 3. SKILL 触发范围:包含 today、digest、weekly 三条线。
验收前应明确:
一阶段主链路:today-chapter。
digest-apply:只作为二阶段入口/输出契约,不验收写回。
weekly-loop:二阶段候选,不进入一阶段完成标准。非阻塞建议
1. README 后续可以分成三类:
- 已实现:setup/context/candidates/today_live
- 契约已定义:digest-apply/weekly-loop
- 二阶段候选:写回、周复盘、安装分发、排序算法
2. SKILL 中可加阶段说明:
- Phase 1 stable: today-chapter
- Experimental contracts: digest-apply, weekly-loop
3. 不要现在升级复杂排序或 embedding;Today MVP 用轻量规则足够验收。
Superpower 最终意见
可以验收,但必须冻结为:
Today Chapter MVP:根据最近上下文推荐今天只读的一小节,并给出读前问题和读后动作。如果继续把行动卡写回、周复盘、安装体验都纳入一阶段,会导致范围膨胀,无法判断 MVP 是否成立。
[
{
"book_id": "demo-agent-book",
"book_title": "AI Harness Engineering",
"chapter_uid": "chapter-7",
"title": "Agent Skill 交付验证"
},
{
"book_id": "demo-writing-book",
"book_title": "写作是门手艺",
"chapter_uid": "chapter-2",
"title": "从真实问题开始写"
}
]
[project]
name = "carl-weread"
version = "0.3.0"
description = "Context-driven WeRead action coach skill."
requires-python = ">=3.9"
dependencies = []
[project.scripts]
carl-weread = "carl_weread.cli:main"
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
<div align="center">
carl-weread
把微信读书从「读了多少」改成「今天哪个问题可以被读懂一点」。
  
<br>
微信读书行动型阅读教练:根据当前问题,推荐一本书里的今天一小节,并把阅读变成行动卡。
<br>
官方微信读书已经有书架、搜索、笔记、统计和推荐。它能回答「你读了多久」「有哪些书」「这本书有哪些笔记」。
carl-weread想多走一步:你现在卡住的事,微信读书里哪本书、哪一节,能帮你今天少囤一点资料,多做一个判断?
当前问题 → 书架/笔记/章节交叉 → 推荐一本书的一小节 → 读后行动卡 → Markdown回流 → 周阅读行动复盘看差异 · 功能总览 · 核心方法 · 触发话术 · 装上就能用
</div>
---
看差异
同样一个问题:
我最近信息焦虑,总是在囤资料但不产出,今天该读什么?
普通推荐很容易给一串书名:信息管理、效率、认知、写作、项目管理。书可能都对,但问题还在原地。
真正需要被回答的是:
- 我现在是缺信息,还是缺一个能收束问题的框架?
- 这本书我是不是早就收藏过、读过、划过线?
- 今天到底读哪一小节,读完以后做什么?
carl-weread要给的是一个能在今天完成的小切口:
今天只读这一小节:
《某本书》|某一章
为什么是它:
资料已经够多,真正卡住的是问题没有收束。这一节刚好能把「继续搜索」改成「提出一个更好的问题」。
读前问题:
我现在反复搜索,是为了做哪个判断?
读完只做一个动作:
把当前文章选题改写成一个问题句,别再扩成资料清单。
打开:weread://reading?bId=...&chapterUid=...一句话定位:
carl-weread更像「今天这个问题,只读哪一节,然后怎么用」。---
和原版/参考项目的区别
| 项目 | 更像什么 | 强项 | carl-weread的差异 |
|---|---|---|---|
| 微信读书官方能力(App统计 / WeRead Skill) | 阅读数据与原子接口 | 书架、搜索、笔记、统计、推荐接口完整 | 不停在查数据,往「当前问题→章节→行动」走 |
| huashu-weread | 读书顾问 | 书架+笔记交叉,推荐下一本书,规划主题路径 | 参考它的交叉分析思路,但把目标缩到「今天只读哪一节」 |
| jerlin-weread | CLI工程底座 | API文档、脚本、字段约束更稳 | 学它的命令化组织方式,再把命令接成阅读工作流 |
---
功能总览
| 能力 | 当前状态 | 做什么 | 入口 |
|---|---|---|---|
| API Key安全初始化 | ✅ 已完成 | 将key写入私有文件,权限600,不进仓库 | scripts/setup_api_key.py |
| 上下文模式 | ✅ 已完成 | 支持Obsidian、普通文件夹、Chat、WeRead-only四档上下文 | scripts/setup.py --mode ... |
| 今日推荐 | ✅ 已完成 | 拉取真实WeRead数据,根据当前问题推荐一本书里的一节 | 自然语言触发 / scripts/today_live.py |
| 读后行动卡 | ✅ 已完成 | 把书名、章节、划线、当前问题压成一张行动卡 | 自然语言触发 / scripts/digest_apply.py |
| 写回Obsidian/本地文件夹 | ✅ 已完成 | 把阅读行动卡写回carl-weread/reading-cards/ | --writeback |
| 周阅读行动复盘 | ✅ 已完成 | 从行动卡看哪些阅读真的进入了文章、项目和判断 | 自然语言触发 / scripts/weekly_loop.py |
| WeRead原子API helper | ✅ 已完成 | 书架、搜索、统计、详情、目录、进度、笔记、划线、点评、推荐 | scripts/weread.sh ... |
| 未读书交叉验证推荐 | ✅ V0.3已完成 | 过滤已深读/只收藏/已消化,推荐真正没读过但现在该读的书 | scripts/carl_weread.py recommend |
| 苏格拉底式无划线协议 | ✅ V0.3已完成 | 读完后先查有没有划线;没有划线就用问题追回理解 | scripts/carl_weread.py after-read |
| 统一产品级CLI | ✅ V0.3已完成 | 从一堆scripts/收束到recommend/today/after-read/weekly | scripts/carl_weread.py ... |
---
核心方法
1. 先问问题,再找书
很多阅读推荐从主题开始,比如AI、产品、心理学。carl-weread先看你现在卡在哪里。
| 用户输入 | 先判断什么 | 阅读目标 |
|---|---|---|
| 我信息焦虑 | 是缺信息,还是缺收束问题的方法 | 减少输入,形成判断 |
| 我写文章卡住了 | 是缺案例,缺结构,还是缺一个反常识角度 | 推进一篇具体内容 |
| 我项目推进慢 | 是缺技术方案,缺产品判断,还是缺下一步动作 | 读完能改一个动作 |
| 我不知道读什么 | 是真没方向,还是收藏太多没有消化 | 少推荐,多收束 |
2. 书架、笔记、章节要交叉看
单看书架,会把「收藏过」误判成「真的需要」。单看笔记,又容易只围着旧兴趣转。carl-weread把几类数据放在一起看:
| 数据源 | 接口 | 揭示什么 | 用在什么地方 |
|---|---|---|---|
| 书架 | /shelf/sync | 用户主动收藏/分类的兴趣 | 找候选书,不当作唯一证据 |
| 笔记本概览 | /user/notebooks | 真读过什么、哪些书有痕迹 | 判断深读、浅读、只收藏 |
| 章节目录 | /book/chapterinfo | 今天能不能定位到小节 | 避免只推荐一本书 |
| 阅读进度 | /book/getprogress | 是否正在读、读到哪里 | 避免推荐明显不合适的位置 |
| 个人划线 | /book/bookmarklist | 用户真正停下来的地方 | 读后行动卡的证据 |
| 热门划线/点评 | /book/bestbookmarks、/review/list | 大家反复标记的问题 | 辅助判断章节价值 |
| 推荐/相似书 | /book/recommend、/book/similar | 新候选来源 | 找没读透但现在该读的书 |
3. 阅读统计不比时长,比影响
微信读书已经能告诉你读了多久。这里不再做一个低配统计页。
weekly-loop关心的是:
这周哪次阅读变成了文章角度?
哪条划线进入了项目决策?
哪本书只是制造了收藏安全感?
下周只保留哪一条阅读主线?所以它吃的输入包括:
微信读书统计 + 本周划线/行动卡 + Obsidian项目/选题上下文---
触发话术
| 你可以这样说 | 走哪个能力 | 预期输出 |
|---|---|---|
| 今天读哪一小节 | today-chapter | 一本书里的一个章节/小节,附读前问题和行动 |
| 根据我的书架和最近问题,推荐一本现在最该读的书,只告诉我今天读哪一节 | today-chapter | 书名、章节、推荐理由、打开链接 |
| 我信息焦虑,帮我少读一点但读准一点 | today-chapter / after-read | 先收束问题,再推荐小阅读块 |
| 这本书我读到哪了 | 原子能力:progress | 阅读进度和打开链接 |
| 查一下这本书的目录和我的划线 | 原子能力:book-info/bookmarks/chapters | 书籍详情、章节目录、个人划线 |
| 读完了,帮我消化成行动 | digest-apply | 阅读行动卡、一个最小行动、选题线索 |
| 把这次阅读闭环存到Obsidian | writeback | 写入carl-weread/reading-cards/ |
| 本周阅读行动复盘 | weekly-loop | 本周真正进入工作的阅读、逃避式收藏、下周主线 |
| 给我推荐一本我没读过但现在该读的书 | unread-advisor | 交叉验证已读/未读后推荐一本新书 |
---
装上就能用
先说结论:如果你只是想让Agent理解这套阅读方法,安装SKILL.md就够;如果你要运行微信读书API、脚本、测试和写回功能,需要clone完整仓库。
轻量安装:只让Agent读到说明
hermes skills install https://raw.githubusercontent.com/LearnPrompt/carl-weread/main/SKILL.md这一步只会安装SKILL.md。它适合加载使用说明,但不会带上scripts/、carl_weread/、workflows/和测试文件。
完整安装:真正运行这套工具
git clone https://github.com/LearnPrompt/carl-weread ~/.hermes/skills/carl-weread
cd ~/.hermes/skills/carl-weread
python3 -m venv .venv
.venv/bin/python -m pip install -U pip pytest
.venv/bin/python -m pytest tests -q如果你已经clone了仓库,也可以在仓库里跑安装脚本:
scripts/install_skill.py配置微信读书API Key
自然语言版:
帮我配置carl-weread的微信读书API Key。不要把key写进仓库,也不要在回复里打印key。代码版:
scripts/setup_api_key.py它会把key写入~/.config/carl-weread/api_key,文件权限为600,不会写入config.toml,也不会打印key。
也可以只在当前shell临时使用:
export WEREAD_API_KEY="<你的微信读书API Key>"API Key获取入口:<https://weread.qq.com/r/weread-skills>
---
没有Obsidian怎么用
可以用。Obsidian只是最高配上下文源,不是硬依赖。
| 模式 | 适合谁 | 上下文来源 | 输出能力 |
|---|---|---|---|
| Full Mode | Obsidian用户 | 日记、项目、选题库 + 微信读书 | 最完整的行动闭环 |
| Folder Mode | 有本地笔记但不用Obsidian | 任意Markdown/TXT文件夹 + 微信读书 | 可推荐章节并生成行动卡 |
| Chat Mode | 只在Agent里聊天 | 用户当前一句话/最近对话 + 微信读书 | 轻量推荐今天一小节 |
| WeRead-only Mode | 没有外部笔记 | 书架、笔记、阅读进度 | 基于最近阅读做推荐 |
首次配置也给两种说法。
自然语言版:
帮我把carl-weread配置成WeRead-only模式。先不接Obsidian,只根据我的微信读书书架、笔记和阅读进度推荐。代码版:
# Obsidian用户
scripts/setup.py --mode obsidian --path "/path/to/your/vault"
# 非Obsidian,但有本地Markdown/TXT笔记
scripts/setup.py --mode folder --path "/path/to/your/notes"
# 只使用当前对话上下文
scripts/setup.py --mode chat
# 只使用微信读书数据
scripts/setup.py --mode weread-only配置默认写入:~/.config/carl-weread/config.toml。
---
今日推荐:一本书里的一小节
自然语言版:
根据我的微信读书书架和最近问题,推荐一本现在最该读的书,并告诉我今天只读哪一节。
我的问题是:我正在写一篇微信读书Skill文章,想找一个能推进文章结构的阅读切口。代码版:
scripts/today_live.py \
--brief "我正在写一篇微信读书Skill文章,请根据我的书架推荐一本最适合现在读的书,并告诉我今天只读哪一节。"它会自动做这些事:
读取配置 → 收集上下文 → 拉微信读书书架/笔记/章节 → 选择一本书里的一小节 → 输出读前问题和读后动作重点放在少一点:只给今天能读完、能接上当前问题的一节。
---
V0.3:推荐一本没读透但现在该读的书
这条能力不是再给一串书单。它会先看书架和笔记,排除已经明显消化过的书,再把微信读书推荐、相似书和当前问题放在一起交叉验证。
自然语言版:
给我推荐一本我没读过但现在该读的书。
我的问题是:我在写Agent Skill文章,需要找一个能解释「为什么Skill不能只封装API」的阅读切口。代码版:
scripts/carl_weread.py recommend \
--brief "我在写Agent Skill文章,需要找一个能解释为什么Skill不能只封装API的阅读切口。"输出会包含:
推荐哪本书
为什么不是继续读老书
书架/笔记/推荐来源的证据
翻开后先问什么
读完只做哪个动作---
读后行动卡与写回
行动卡要贴着书走。它必须带着书名、章节、划线/想法和当前问题一起生成,否则就会变成空泛总结。
自然语言版:
我刚读完《AI Engineering》的「Evals and workflows」。
这句划线对我有用:Agent的价值在模型、工具、上下文和验证之间。
我现在的问题是:我在写一篇介绍carl-weread的文章,需要把阅读变成可演示动作。
请把这次阅读消化成一张行动卡;如果已经配置写回,就存到我的笔记目录。代码版:
scripts/carl_weread.py after-read \
--book-title "AI Engineering" \
--chapter-title "Evals and workflows" \
--highlight "Agent的价值在模型、工具、上下文和验证之间。" \
--current-problem "我在写一篇介绍carl-weread的文章,需要把阅读变成可演示动作。" \
--writeback如果你已经知道bookId和chapterUid,也可以让它先自动拉本章划线:
scripts/carl_weread.py after-read \
--book-id BOOK_ID \
--chapter-uid CHAPTER_UID \
--book-title "AI Engineering" \
--chapter-title "Evals and workflows" \
--current-problem "我在写一篇介绍carl-weread的文章,需要把阅读变成可演示动作。" \
--auto-fetch如果这一章没有划线,它不会硬写总结,而是输出「无划线读后检查」:让你补一条原文/转述,说明它解释了哪个问题,再决定要不要生成行动卡。
写回路径形如:
<你的笔记目录>/carl-weread/reading-cards/YYYY-MM-DD-书名-章节.md这张卡会保留「哪本书、哪一节、哪条划线、解决什么问题、下一步做什么」。周复盘从这些卡里读证据,避免空泛总结。
---
本周阅读行动复盘
自然语言版:
基于本周carl-weread写回的阅读行动卡,帮我做一次阅读复盘。
重点看:哪些阅读进入了文章或项目,哪些只是收藏安全感,下周只保留哪一条阅读主线。代码版:
scripts/carl_weread.py weekly \
--cards /path/to/reading-cards \
--context "本周在写Agent Skill文章,也在验证carl-weread的安装链路"这里不重复微信读书周报。微信读书周报告诉你「读了多久」,这里要回答:
哪本书的哪一节真的推进了工作?
哪条划线变成了文章角度或项目动作?
哪些收藏只是缓解焦虑?---
打开微信读书
推荐输出中会给weread://深度链接。它依赖本机微信读书客户端和系统协议注册。更稳妥的输出方式:
open 'weread://reading?bId=BOOK_ID&chapterUid=CHAPTER_UID'如果没有反应:
1. 安装并打开微信读书客户端。 2. 登录同一个微信读书账号。 3. 再执行上面的open命令。 4. 仍然打不开时,用书名和章节名在微信读书里手动搜索。
---
仓库结构
carl-weread/
├── SKILL.md # Agent读取的技能说明
├── README.md # 给人看的产品说明和安装指南
├── carl_weread/ # Python核心逻辑
├── scripts/ # 可直接运行的命令入口
├── workflows/ # 可复用工作流说明
├── shared/ # 输出风格等共享约定
└── tests/ # 回归测试---
当前边界
- V0.3已经跑通真实WeRead API helper、今日章节、未读书推荐、读后划线检查、无划线追问、行动卡写回、周复盘和统一CLI。
- 推荐章节仍使用轻量规则,不做embedding或全文语义检索;它适合演示和日常使用,不伪装成完整阅读智能体。
- 未读书推荐会根据书架、笔记和推荐/相似书数据做交叉判断,但微信读书接口字段在不同账号上可能有差异,异常时应保留原始JSON样本再补适配。
- 写回会写入真实Obsidian vault或普通文件夹;Chat/WeRead-only模式默认只在对话中返回,不写文件。
---
开发者附录:WeRead原子能力
普通用户可以跳过这里。这一节给开发者调试接口、排查字段和复用底层API。
| 能力 | 命令 |
|---|---|
| 查询书架 | scripts/weread.sh shelf |
| 书籍搜索 | scripts/weread.sh search --keyword=画家之眼 |
| 阅读统计 | scripts/weread.sh readdata |
| 书籍详情 | scripts/weread.sh book-info --bookId=BOOK_ID |
| 章节目录 | scripts/weread.sh chapters --bookId=BOOK_ID |
| 阅读进度 | scripts/weread.sh progress --bookId=BOOK_ID |
| 笔记概览 | scripts/weread.sh notebooks --count=20 |
| 个人划线 | scripts/weread.sh bookmarks --bookId=BOOK_ID |
| 个人想法 | scripts/weread.sh mine-reviews --bookid=BOOK_ID |
| 公开点评 | scripts/weread.sh reviews --bookId=BOOK_ID |
| 单条想法 | scripts/weread.sh review --reviewId=REVIEW_ID |
| 热门划线 | scripts/weread.sh best-bookmarks --bookId=BOOK_ID |
| 章节划线热度 | scripts/weread.sh underlines --bookId=BOOK_ID --chapterUid=CHAPTER_UID |
| 章节划线评论 | scripts/weread.sh readreviews --bookId=BOOK_ID --chapterUid=CHAPTER_UID --reviews='[]' |
| 推荐好书 | scripts/weread.sh recommend |
| 相似书推荐 | scripts/weread.sh similar --bookId=BOOK_ID |
| 接口列表 | scripts/weread.sh list-apis |
已知兼容处理:
readdata默认补--mode=overall,避免服务端返回参数格式错误。recommend和similar默认补--count=12 --maxIdx=0,避免部分环境缺分页参数失败。best-bookmarks默认补--chapterUid=0,表示全书热门划线。readreviews --reviews=...支持JSON数组/对象参数。- 频率超限时不要反复重试,优先复用
.cache/weread/或/private/tmp/中已保存的候选章节JSON。
开发者如果只想调试候选章节生成,可以拆开跑:
scripts/fetch_candidates.py \
--output .cache/weread/candidates.json \
--limit-books 5
scripts/today.py \
--config ~/.config/carl-weread/config.toml \
--brief "我最近在做一个可分享的微信读书Skill" \
--chapters .cache/weread/candidates.json---
致谢
- 微信读书官方API提供底层能力:<https://weread.qq.com/r/weread-skills>
- huashu-weread提供了「书架+笔记交叉分析」这个关键启发:<https://github.com/alchaincyf/huashu-weread>
- jerlin-weread启发了CLI化、字段契约和Agent可稳定调用的工程底座:<https://github.com/jerlinn/jerlin-weread>
---
<div align="center">
Made by LearnPrompt · 少读一点,读准一点
</div>
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from carl_weread.candidates import build_candidate_chapters, chapters_to_jsonable
def _read_json(path: Path):
return json.loads(path.read_text(encoding="utf-8"))
def _load_chapterinfo_by_book_id(chapter_dir: Path) -> dict[str, object]:
payloads: dict[str, object] = {}
for path in sorted(chapter_dir.glob("*.json")):
payloads[path.stem] = _read_json(path)
return payloads
def main() -> int:
parser = argparse.ArgumentParser(description="Build today-compatible chapter candidates from WeRead API JSON files.")
parser.add_argument("--shelf", required=True, type=Path, help="JSON output from scripts/weread.sh shelf")
parser.add_argument("--notebooks", required=True, type=Path, help="JSON output from scripts/weread.sh notebooks")
parser.add_argument("--chapter-dir", required=True, type=Path, help="Directory containing <bookId>.json chapterinfo files")
parser.add_argument("--output", required=True, type=Path, help="Output candidate chapters JSON")
parser.add_argument("--limit-books", type=int, help="Maximum books to include after merging shelf and notebooks")
args = parser.parse_args()
candidates = build_candidate_chapters(
_read_json(args.shelf),
_read_json(args.notebooks),
_load_chapterinfo_by_book_id(args.chapter_dir),
limit_books=args.limit_books,
)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(chapters_to_jsonable(candidates), ensure_ascii=False, indent=2), encoding="utf-8")
print(f"已写入候选章节:{args.output}({len(candidates)} 条)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
from __future__ import annotations
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from carl_weread.cli import main
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
from __future__ import annotations
import argparse
from datetime import date
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from carl_weread.config import load_config
from carl_weread.context import collect_context_for_config, collect_recent_context
def main() -> int:
parser = argparse.ArgumentParser(description="Collect compact context for carl-weread.")
parser.add_argument("vault", nargs="?", type=Path, help="Obsidian vault or notes folder path")
parser.add_argument("--config", type=Path, help="carl-weread config.toml")
parser.add_argument("--brief", help="Current user brief for chat mode")
parser.add_argument("--days", type=int, default=3)
parser.add_argument("--max-chars", type=int, default=1200)
args = parser.parse_args()
if args.config:
config = load_config(args.config)
items = collect_context_for_config(
config,
brief=args.brief,
today=date.today(),
days=args.days,
max_chars_per_file=args.max_chars,
)
elif args.vault:
items = collect_recent_context(args.vault, today=date.today(), days=args.days, max_chars_per_file=args.max_chars)
else:
parser.error("需要提供 vault 路径,或使用 --config 指定配置文件")
for item in items:
print(f"## {item.kind}: {item.source}\n{item.text}\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from carl_weread.config import ContextConfig, load_config
from carl_weread.digest_apply import ReadingInput, build_action_card
from carl_weread.writeback import write_action_card
def default_config_path() -> Path:
return Path.home() / ".config" / "carl-weread" / "config.toml"
def _input_from_args(args: argparse.Namespace) -> ReadingInput:
if args.input:
data = json.loads(args.input.read_text(encoding="utf-8"))
return ReadingInput(**data)
if not args.book_title or not args.chapter_title:
raise ValueError("未提供 --input 时,必须提供 --book-title 和 --chapter-title")
highlights = args.highlight or []
if args.highlights_file:
highlights.extend(
line.strip()
for line in args.highlights_file.read_text(encoding="utf-8").splitlines()
if line.strip()
)
return ReadingInput(
book_title=args.book_title,
chapter_title=args.chapter_title,
highlights=highlights,
current_problem=args.current_problem or "",
)
def main() -> int:
parser = argparse.ArgumentParser(description="Build and optionally write back a reading action card.")
parser.add_argument("--input", type=Path, help="JSON: book_title, chapter_title, highlights, current_problem")
parser.add_argument("--book-title", help="Book title when not using --input")
parser.add_argument("--chapter-title", help="Chapter title when not using --input")
parser.add_argument("--highlight", action="append", help="Highlight/thought; can be passed multiple times")
parser.add_argument("--highlights-file", type=Path, help="Plain text highlights, one per line")
parser.add_argument("--current-problem", help="Current project/content problem this reading should answer")
parser.add_argument("--output", type=Path, help="Optional explicit markdown output path")
parser.add_argument("--writeback", action="store_true", help="Write to the configured Obsidian/folder target")
parser.add_argument("--config", type=Path, default=default_config_path(), help="carl-weread config.toml")
parser.add_argument("--writeback-dir", type=Path, help="Override writeback directory")
args = parser.parse_args()
try:
reading = _input_from_args(args)
except (ValueError, TypeError, json.JSONDecodeError) as exc:
print(f"输入失败:{exc}", file=sys.stderr)
return 2
card = build_action_card(reading)
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(card + "\n", encoding="utf-8")
print(f"已写入阅读行动卡:{args.output}")
return 0
if args.writeback:
config = load_config(args.config) if args.config.exists() else ContextConfig(mode="chat")
result = write_action_card(card, config, f"{reading.book_title}-{reading.chapter_title}", output_dir=args.writeback_dir)
print(result.message)
if result.path is None:
print(card)
return 0
print(card)
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path
import subprocess
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from carl_weread.candidates import build_candidate_chapters, chapters_to_jsonable, extract_book_refs, merge_book_refs
def _run_weread(weread_script: Path, args: list[str]) -> object:
command = [str(weread_script), *args]
if weread_script.suffix == ".py":
command = [sys.executable, str(weread_script), *args]
result = subprocess.run(command, text=True, capture_output=True)
if result.returncode != 0:
sys.stderr.write(result.stderr)
raise SystemExit(result.returncode)
return json.loads(result.stdout)
def main() -> int:
parser = argparse.ArgumentParser(description="Fetch WeRead data and build today-compatible chapter candidates.")
parser.add_argument("--weread-script", type=Path, default=Path("scripts/weread.sh"), help="Path to scripts/weread.sh")
parser.add_argument("--output", required=True, type=Path, help="Output candidate chapters JSON")
parser.add_argument("--limit-books", type=int, default=5, help="Maximum books to fetch chapter lists for")
parser.add_argument("--notebooks-count", type=int, default=200, help="Notebook count passed to WeRead API")
args = parser.parse_args()
shelf_payload = _run_weread(args.weread_script, ["shelf"])
notebooks_payload = _run_weread(args.weread_script, ["notebooks", f"--count={args.notebooks_count}"])
book_refs = merge_book_refs(extract_book_refs(shelf_payload), extract_book_refs(notebooks_payload), limit=args.limit_books)
chapterinfo_by_book_id = {}
for ref in book_refs:
chapterinfo_by_book_id[ref.book_id] = _run_weread(args.weread_script, ["chapters", f"--bookId={ref.book_id}"])
candidates = build_candidate_chapters(shelf_payload, notebooks_payload, chapterinfo_by_book_id, limit_books=args.limit_books)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(chapters_to_jsonable(candidates), ensure_ascii=False, indent=2), encoding="utf-8")
print(f"已获取并写入候选章节:{args.output}({len(candidates)} 条)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
from __future__ import annotations
import argparse
from pathlib import Path
import shutil
import sys
IGNORE_DIRS = {".git", ".venv", "__pycache__", ".pytest_cache"}
IGNORE_FILES = {".env"}
def default_target() -> Path:
hermes_home = Path.home() / ".hermes"
return hermes_home / "skills" / "carl-weread"
def should_ignore(path: Path) -> bool:
return any(part in IGNORE_DIRS for part in path.parts) or path.name in IGNORE_FILES or path.suffix == ".pyc"
def copy_tree(source: Path, target: Path) -> None:
if target.exists():
shutil.rmtree(target)
target.mkdir(parents=True, exist_ok=True)
for path in source.rglob("*"):
rel = path.relative_to(source)
if should_ignore(rel):
continue
dest = target / rel
if path.is_dir():
dest.mkdir(parents=True, exist_ok=True)
elif path.is_file():
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, dest)
def main() -> int:
parser = argparse.ArgumentParser(description="Install the full carl-weread repo as a Hermes skill directory.")
parser.add_argument("--target", type=Path, default=default_target(), help="Target skill directory")
args = parser.parse_args()
source = Path(__file__).resolve().parents[1]
target = args.target.expanduser()
copy_tree(source, target)
print(f"已安装完整 skill 目录:{target}")
print("下一步:在目标 Hermes 环境运行 scripts/setup_api_key.py,然后运行 scripts/today_live.py。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import getpass
import os
from pathlib import Path
import stat
import sys
def default_key_path() -> Path:
return Path.home() / ".config" / "carl-weread" / "api_key"
def _read_key_from_stdin() -> str:
return sys.stdin.readline().strip()
def _read_key_interactively() -> str:
return getpass.getpass("Paste WEREAD_API_KEY: ").strip()
def _write_private_key(path: Path, api_key: str) -> None:
output = path.expanduser()
output.parent.mkdir(parents=True, exist_ok=True)
output.parent.chmod(0o700)
fd = os.open(output, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(api_key)
handle.write("\n")
finally:
output.chmod(0o600)
def main() -> int:
parser = argparse.ArgumentParser(description="Store WeRead API key for carl-weread.")
parser.add_argument("--output", type=Path, default=default_key_path(), help="private API key file path")
parser.add_argument("--stdin", action="store_true", help="read API key from stdin instead of secure prompt")
args = parser.parse_args()
api_key = _read_key_from_stdin() if args.stdin else _read_key_interactively()
if not api_key:
print("未写入:API Key 为空。", file=sys.stderr)
return 2
if not api_key.startswith("wrk-"):
print("未写入:WEREAD_API_KEY 应以 wrk- 开头。", file=sys.stderr)
return 2
_write_private_key(args.output, api_key)
mode = stat.S_IMODE(args.output.expanduser().stat().st_mode)
if mode != 0o600:
print("写入失败:API Key 文件权限不是 600。", file=sys.stderr)
return 3
print(f"已写入私有 API Key 文件:{args.output.expanduser()}")
print("提示:不会写入 config.toml,也不会打印 API Key。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
from __future__ import annotations
import argparse
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from carl_weread.config import ContextConfig, VALID_MODES, write_config
def default_config_path() -> Path:
return Path.home() / ".config" / "carl-weread" / "config.toml"
def main() -> int:
parser = argparse.ArgumentParser(description="Configure carl-weread context mode.")
parser.add_argument("--mode", required=True, choices=sorted(VALID_MODES), help="context mode")
parser.add_argument("--path", type=Path, help="Obsidian vault or notes folder path for obsidian/folder mode")
parser.add_argument("--output", type=Path, default=default_config_path(), help="config file path")
args = parser.parse_args()
try:
write_config(ContextConfig(mode=args.mode, path=args.path), args.output)
except ValueError as exc:
print(f"配置失败:{exc}", file=sys.stderr)
return 2
print(f"已写入配置:{args.output.expanduser()}")
print("提示:API Key 可通过 scripts/setup_api_key.py 写入私有 key 文件,不会写入配置文件。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from carl_weread.today_chapter import Chapter, ContextItem, choose_today_chapter
def main() -> int:
parser = argparse.ArgumentParser(description="Choose today's reading chapter from context and candidate chapters JSON.")
parser.add_argument("--context", required=True, type=Path, help="JSON list: {kind, source, text}")
parser.add_argument("--chapters", required=True, type=Path, help="JSON list: {book_id, book_title, chapter_uid, title}")
args = parser.parse_args()
context_data = json.loads(args.context.read_text(encoding="utf-8"))
chapter_data = json.loads(args.chapters.read_text(encoding="utf-8"))
context = [ContextItem(**item) for item in context_data]
chapters = [Chapter(**item) for item in chapter_data]
rec = choose_today_chapter(context, chapters)
print(json.dumps(rec.__dict__, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path
import subprocess
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from carl_weread.candidates import build_candidate_chapters, extract_book_refs, merge_book_refs
from carl_weread.config import load_config
from carl_weread.context import collect_context_for_config
from carl_weread.today_chapter import choose_today_chapter
def default_config_path() -> Path:
return Path.home() / ".config" / "carl-weread" / "config.toml"
def _run_weread(weread_script: Path, args: list[str]) -> object:
command = [str(weread_script), *args]
if weread_script.suffix == ".py":
command = [sys.executable, str(weread_script), *args]
try:
result = subprocess.run(command, text=True, capture_output=True, timeout=90)
except subprocess.TimeoutExpired:
print(f"WeRead命令超时:{' '.join(command[:2])} ...", file=sys.stderr)
raise SystemExit(4)
if result.returncode != 0:
sys.stderr.write(result.stderr)
raise SystemExit(result.returncode)
return json.loads(result.stdout)
def _fetch_candidates(weread_script: Path, limit_books: int, notebooks_count: int):
shelf_payload = _run_weread(weread_script, ["shelf"])
notebooks_payload = _run_weread(weread_script, ["notebooks", f"--count={notebooks_count}"])
book_refs = merge_book_refs(extract_book_refs(shelf_payload), extract_book_refs(notebooks_payload), limit=limit_books)
chapterinfo_by_book_id = {
ref.book_id: _run_weread(weread_script, ["chapters", f"--bookId={ref.book_id}"])
for ref in book_refs
}
return build_candidate_chapters(shelf_payload, notebooks_payload, chapterinfo_by_book_id, limit_books=limit_books)
def _format_markdown(rec) -> str:
return "\n".join(
[
"今天只读这一小节:",
f"《{rec.book_title}》|{rec.chapter_title}",
"",
"为什么是它:",
rec.why,
"",
"读前问题:",
rec.reading_question,
"",
"读完只做一个动作:",
rec.apply_action,
"",
f"打开:{rec.deep_link}",
]
)
def main() -> int:
parser = argparse.ArgumentParser(description="Fetch WeRead data and recommend today's chapter in one command.")
parser.add_argument("--config", type=Path, default=default_config_path(), help="carl-weread config.toml")
parser.add_argument("--brief", help="Current user brief for chat mode or extra context")
parser.add_argument("--weread-script", type=Path, default=Path("scripts/weread.sh"), help="Path to scripts/weread.sh")
parser.add_argument("--limit-books", type=int, default=5, help="Maximum books to fetch chapter lists for")
parser.add_argument("--notebooks-count", type=int, default=200, help="Notebook count passed to WeRead API")
args = parser.parse_args()
config = load_config(args.config)
context = collect_context_for_config(config, brief=args.brief)
candidates = _fetch_candidates(args.weread_script, args.limit_books, args.notebooks_count)
rec = choose_today_chapter(context, candidates)
print(_format_markdown(rec))
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from carl_weread.config import load_config
from carl_weread.context import collect_context_for_config
from carl_weread.today_chapter import Chapter, choose_today_chapter
def _load_chapters(path: Path) -> list[Chapter]:
data = json.loads(path.read_text(encoding="utf-8"))
return [Chapter(**item) for item in data]
def _format_markdown(rec) -> str:
return "\n".join(
[
"今天只读这一小节:",
f"《{rec.book_title}》|{rec.chapter_title}",
"",
"为什么是它:",
rec.why,
"",
"读前问题:",
rec.reading_question,
"",
"读完只做一个动作:",
rec.apply_action,
"",
f"打开:{rec.deep_link}",
]
)
def main() -> int:
parser = argparse.ArgumentParser(description="Choose today's carl-weread chapter.")
parser.add_argument("--config", required=True, type=Path, help="carl-weread config.toml")
parser.add_argument("--chapters", required=True, type=Path, help="candidate chapters JSON")
parser.add_argument("--brief", help="Current user brief for chat mode or extra context")
args = parser.parse_args()
config = load_config(args.config)
context = collect_context_for_config(config, brief=args.brief)
chapters = _load_chapters(args.chapters)
rec = choose_today_chapter(context, chapters)
print(_format_markdown(rec))
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
from __future__ import annotations
import argparse
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from carl_weread.weekly_loop import WeeklyReportInput, build_weekly_report, load_cards
def main() -> int:
parser = argparse.ArgumentParser(description="Build a weekly reading-action loop report.")
parser.add_argument("--cards", nargs="*", type=Path, default=[], help="Markdown card files or directories")
parser.add_argument("--context", default="", help="Optional weekly project/content context")
parser.add_argument("--output", type=Path, help="Optional markdown output path")
args = parser.parse_args()
report = build_weekly_report(WeeklyReportInput(cards=load_cards(args.cards), context=args.context))
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(report + "\n", encoding="utf-8")
else:
print(report)
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env bash
# carl-weread API helper. Keep this layer boring: one subcommand maps to one WeRead API.
set -euo pipefail
GATEWAY="${WEREAD_GATEWAY:-https://i.weread.qq.com/api/agent/gateway}"
SKILL_VERSION="${WEREAD_SKILL_VERSION:-1.0.3}"
API_KEY_FILE="${WEREAD_API_KEY_FILE:-$HOME/.config/carl-weread/api_key}"
usage() {
cat <<'EOF'
Usage: scripts/weread.sh <subcommand> [--key=value ...]
Subcommands:
search --keyword=STR
shelf
notebooks [--count=INT] [--lastSort=INT]
bookmarks --bookId=STR
mine-reviews --bookid=STR [--count=INT] [--synckey=INT]
reviews --bookId=STR [--reviewListType=INT] [--count=INT]
review --reviewId=STR
best-bookmarks --bookId=STR [--chapterUid=INT]
underlines --bookId=STR --chapterUid=INT
readreviews --bookId=STR --chapterUid=INT --reviews=JSON
chapters --bookId=STR
progress --bookId=STR
readdata [--mode=overall] [--baseTime=INT]
book-info --bookId=STR
recommend [--count=INT] [--maxIdx=INT]
similar --bookId=STR [--count=INT] [--maxIdx=INT]
list-apis
Auth:
scripts/setup_api_key.py
or export WEREAD_API_KEY=<your-api-key>
EOF
}
api_path() {
case "$1" in
search) echo "/store/search" ;;
shelf) echo "/shelf/sync" ;;
notebooks) echo "/user/notebooks" ;;
bookmarks) echo "/book/bookmarklist" ;;
mine-reviews) echo "/review/list/mine" ;;
reviews) echo "/review/list" ;;
review) echo "/review/single" ;;
best-bookmarks) echo "/book/bestbookmarks" ;;
underlines) echo "/book/underlines" ;;
readreviews) echo "/book/readreviews" ;;
chapters) echo "/book/chapterinfo" ;;
progress) echo "/book/getprogress" ;;
readdata) echo "/readdata/detail" ;;
book-info) echo "/book/info" ;;
recommend) echo "/book/recommend" ;;
similar) echo "/book/similar" ;;
list-apis) echo "/_list" ;;
*) return 1 ;;
esac
}
json_quote() {
python3 -c 'import json,sys; print(json.dumps(sys.argv[1], ensure_ascii=False))' "$1"
}
build_params_json() {
python3 - "$@" <<'PY'
import json, sys
params = {}
STRING_KEYS = {"bookId", "bookid", "reviewId", "sessionId"}
for arg in sys.argv[1:]:
if not arg.startswith('--') or '=' not in arg:
raise SystemExit(f"参数格式错误:{arg},应为 --key=value")
key, value = arg[2:].split('=', 1)
if key in {"api_name", "skill_version"}:
raise SystemExit(f"参数名不可使用:{key}")
if key in STRING_KEYS:
params[key] = value
elif value.isdigit():
params[key] = int(value)
elif value.startswith(("{", "[")):
try:
params[key] = json.loads(value)
except json.JSONDecodeError as exc:
raise SystemExit(f"JSON 参数格式错误:{key}={value} ({exc})")
else:
params[key] = value
print(json.dumps(params, ensure_ascii=False))
PY
}
if [[ $# -lt 1 || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
subcmd="$1"
shift
path="$(api_path "$subcmd")" || { echo "未知子命令:$subcmd" >&2; usage >&2; exit 2; }
case "$subcmd" in
readdata)
if [[ " $* " != *" --mode="* ]]; then
set -- --mode=overall "$@"
fi
;;
recommend)
if [[ " $* " != *" --count="* ]]; then
set -- --count=12 "$@"
fi
if [[ " $* " != *" --maxIdx="* ]]; then
set -- --maxIdx=0 "$@"
fi
;;
similar)
if [[ " $* " != *" --count="* ]]; then
set -- --count=12 "$@"
fi
if [[ " $* " != *" --maxIdx="* ]]; then
set -- --maxIdx=0 "$@"
fi
;;
best-bookmarks)
if [[ " $* " != *" --chapterUid="* ]]; then
set -- --chapterUid=0 "$@"
fi
;;
esac
if [[ -z "${WEREAD_API_KEY:-}" && -r "$API_KEY_FILE" ]]; then
IFS= read -r WEREAD_API_KEY < "$API_KEY_FILE"
export WEREAD_API_KEY
fi
if [[ -z "${WEREAD_API_KEY:-}" ]]; then
echo "缺少 WEREAD_API_KEY。请运行 scripts/setup_api_key.py,或临时 export WEREAD_API_KEY=<your-api-key>。" >&2
exit 3
fi
params="$(build_params_json "$@")"
python3 - "$GATEWAY" "$path" "$SKILL_VERSION" "$params" <<'PY'
import json, os, sys, urllib.error, urllib.request
gateway, path, skill_version, params_raw = sys.argv[1:]
api_key = os.environ["WEREAD_API_KEY"]
params = json.loads(params_raw)
payload = {
"api_name": path,
"skill_version": skill_version,
}
payload.update(params)
req = urllib.request.Request(
gateway,
data=json.dumps(payload, ensure_ascii=False).encode('utf-8'),
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
"User-Agent": "carl-weread/1.0",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
print(resp.read().decode('utf-8'))
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
print(f"WeRead API HTTP {exc.code}: {exc.reason}", file=sys.stderr)
if body:
print(body, file=sys.stderr)
raise SystemExit(4)
except urllib.error.URLError as exc:
print(f"WeRead API 请求失败:{exc.reason}", file=sys.stderr)
raise SystemExit(4)
PY
Data Contract
WeRead 时间和单位
- Unix 时间戳:展示为
YYYY-MM-DD。 - 阅读时长:接口单位通常是秒,展示为「X 小时 Y 分钟」。
- 进度:展示为百分比,不直接输出原始数字口径。
Deep Link
| 场景 | 格式 |
|---|---|
| 打开书籍 | weread://reading?bId={bookId} |
| 打开章节 | weread://reading?bId={bookId}&chapterUid={chapterUid} |
输出边界
- 不输出 API Key。
- 不导出书籍全文。
- 用户自己的划线和想法可用于总结,但公开发布前应提醒隐私风险。
Output Style
核心表达
结论先行,少讲概念,多讲「为什么是这一节」和「读完做什么」。
推荐输出必须包含
1. 书名和章节名。 2. 为什么它连接用户最近的问题。 3. 一个读前问题。 4. 一个读后动作。 5. weread:// 链接和可复制的 open 'weread://...' 命令。 6. 手动兜底路径:书名、章节名;必要时提醒安装并登录微信读书客户端。
避免
- 泛泛夸书。
- 一次推荐太多。
- 把阅读复盘写成年度报告。
- 「不是……而是……」这类高频 AI 句式。
- 只输出一个裸
weread://文本,假设用户一定能直接打开。
from carl_weread.after_read import AfterReadInput, build_after_read_result, extract_highlights
def test_after_read_with_highlights_builds_action_card():
result = build_after_read_result(
AfterReadInput(
book_title="AI Harness Engineering",
chapter_title="验证章节",
current_problem="把 Skill 做成可验证交付",
highlights=["Agent 工作流需要可观测的验证标准。"],
)
)
assert result.status == "action-card"
assert result.should_writeback is True
assert "# 阅读行动卡|《AI Harness Engineering》验证章节" in result.markdown
assert "Agent 工作流需要可观测的验证标准" in result.markdown
def test_after_read_without_highlights_uses_socratic_protocol():
result = build_after_read_result(
AfterReadInput(
book_title="写作是门手艺",
chapter_title="从真实问题开始写",
current_problem="选题太散",
highlights=[],
)
)
assert result.status == "no-highlights"
assert result.should_writeback is False
assert "无划线读后检查" in result.markdown
assert "先回答 3 个问题" in result.markdown
assert "选题太散" in result.markdown
def test_extract_highlights_handles_underlines_payload():
payload = {
"data": {
"updated": [
{"markText": "第一条划线"},
{"abstract": "第二条划线"},
{"content": ""},
]
}
}
assert extract_highlights(payload) == ["第一条划线", "第二条划线"]
import json
import subprocess
import sys
from pathlib import Path
from carl_weread.candidates import (
BookRef,
build_candidate_chapters,
chapters_from_chapterinfo,
extract_book_refs,
)
ROOT = Path(__file__).resolve().parents[1]
def test_extract_book_refs_handles_wrapped_shelf_and_notebooks_shapes():
payload = {
"data": {
"books": [
{"bookId": "b1", "title": "AI Harness Engineering"},
{"book": {"bookId": "b2", "title": "写作是门手艺"}},
{"bookId": "", "title": "缺失 ID 的书"},
]
}
}
refs = extract_book_refs(payload)
assert refs == [
BookRef(book_id="b1", title="AI Harness Engineering"),
BookRef(book_id="b2", title="写作是门手艺"),
]
def test_chapters_from_chapterinfo_normalizes_common_chapter_fields():
payload = {
"data": {
"chapters": [
{"chapterUid": "c1", "title": "开场:为什么要从问题开始读"},
{"uid": "c2", "chapterName": "Agent Skill 交付验证"},
{"chapterUid": "cover", "title": "封面", "wordCount": 1},
{"chapterUid": "copyright", "title": "版权信息", "wordCount": 12},
{"chapterUid": "intro", "title": "内容简介", "wordCount": 200},
{"chapterUid": "title", "title": "书名页", "wordCount": 120},
{"chapterUid": "", "title": "缺失 uid 的章节"},
]
}
}
chapters = chapters_from_chapterinfo("b1", "AI Harness Engineering", payload)
assert [chapter.chapter_uid for chapter in chapters] == ["c1", "c2"]
assert chapters[1].book_id == "b1"
assert chapters[1].book_title == "AI Harness Engineering"
assert chapters[1].title == "Agent Skill 交付验证"
def test_build_candidate_chapters_merges_books_and_deduplicates_by_book_order():
shelf = {"data": {"books": [{"bookId": "b1", "title": "AI Harness Engineering"}]}}
notebooks = {
"data": {
"books": [
{"bookId": "b1", "title": "AI Harness Engineering"},
{"bookId": "b2", "title": "写作是门手艺"},
]
}
}
chapterinfo_by_book_id = {
"b1": {"data": {"chapters": [{"chapterUid": "c1", "title": "Agent Skill 交付验证"}]}},
"b2": {"data": {"chapters": [{"chapterUid": "c2", "title": "从真实问题开始写"}]}},
}
chapters = build_candidate_chapters(shelf, notebooks, chapterinfo_by_book_id)
assert [(chapter.book_id, chapter.chapter_uid) for chapter in chapters] == [("b1", "c1"), ("b2", "c2")]
def test_build_candidates_script_writes_today_compatible_json(tmp_path):
shelf_path = tmp_path / "shelf.json"
notebooks_path = tmp_path / "notebooks.json"
chapter_dir = tmp_path / "chapters"
output_path = tmp_path / "candidates.json"
chapter_dir.mkdir()
shelf_path.write_text(json.dumps({"data": {"books": [{"bookId": "b1", "title": "AI Harness Engineering"}]}}), encoding="utf-8")
notebooks_path.write_text(json.dumps({"data": {"books": []}}), encoding="utf-8")
(chapter_dir / "b1.json").write_text(
json.dumps({"data": {"chapters": [{"chapterUid": "c1", "title": "Agent Skill 交付验证"}]}}),
encoding="utf-8",
)
result = subprocess.run(
[
sys.executable,
str(ROOT / "scripts" / "build_candidates.py"),
"--shelf",
str(shelf_path),
"--notebooks",
str(notebooks_path),
"--chapter-dir",
str(chapter_dir),
"--output",
str(output_path),
],
cwd=ROOT,
text=True,
capture_output=True,
)
assert result.returncode == 0
assert "已写入候选章节" in result.stdout
data = json.loads(output_path.read_text(encoding="utf-8"))
assert data == [
{
"book_id": "b1",
"book_title": "AI Harness Engineering",
"chapter_uid": "c1",
"title": "Agent Skill 交付验证",
}
]
import json
import subprocess
import sys
from pathlib import Path
from carl_weread.config import ContextConfig, write_config
ROOT = Path(__file__).resolve().parents[1]
def _write_fake_weread(path: Path) -> None:
path.write_text(
"""
import json
import sys
cmd = sys.argv[1]
if cmd == "shelf":
print(json.dumps({"data": {"books": [{"bookId": "s1", "title": "旧书"}]}}))
elif cmd == "notebooks":
print(json.dumps({"data": {"books": [{"bookId": "n1", "title": "已读书", "noteCount": 3}]}}))
elif cmd == "recommend":
print(json.dumps({"data": {"books": [{"bookId": "r1", "title": "Agent Workflow Handbook", "author": "A", "intro": "agent workflow skill"}]}}))
elif cmd == "underlines":
print(json.dumps({"data": {"updated": [{"markText": "这章真正有用的是验证标准。"}]}}))
else:
raise SystemExit(9)
""".strip(),
encoding="utf-8",
)
def test_unified_cli_recommend_uses_weread_sources(tmp_path):
fake_weread = tmp_path / "fake_weread.py"
_write_fake_weread(fake_weread)
config_path = tmp_path / "config.toml"
write_config(ContextConfig(mode="chat"), config_path)
result = subprocess.run(
[
sys.executable,
str(ROOT / "scripts" / "carl_weread.py"),
"recommend",
"--config",
str(config_path),
"--weread-script",
str(fake_weread),
"--brief",
"我在做 Agent workflow",
],
cwd=ROOT,
text=True,
capture_output=True,
)
assert result.returncode == 0
assert "今天推荐一本没读透但现在该读的书" in result.stdout
assert "Agent Workflow Handbook" in result.stdout
def test_unified_cli_after_read_auto_fetch_builds_card(tmp_path):
fake_weread = tmp_path / "fake_weread.py"
_write_fake_weread(fake_weread)
result = subprocess.run(
[
sys.executable,
str(ROOT / "scripts" / "carl_weread.py"),
"after-read",
"--weread-script",
str(fake_weread),
"--book-id",
"b1",
"--chapter-uid",
"c1",
"--book-title",
"AI Harness Engineering",
"--chapter-title",
"验证章节",
"--current-problem",
"做出V0.3",
"--auto-fetch",
],
cwd=ROOT,
text=True,
capture_output=True,
)
assert result.returncode == 0
assert "# 阅读行动卡" in result.stdout
assert "验证标准" in result.stdout
def test_unified_cli_weekly_prints_report(tmp_path):
card = tmp_path / "card.md"
card.write_text(
"# 阅读行动卡|《AI Harness》验证章节\n\n## 可以马上改的一个动作\n把 CLI 做成一个入口。\n",
encoding="utf-8",
)
result = subprocess.run(
[
sys.executable,
str(ROOT / "scripts" / "carl_weread.py"),
"weekly",
"--cards",
str(tmp_path),
"--context",
"我在做 Agent Skill 文章",
],
cwd=ROOT,
text=True,
capture_output=True,
)
assert result.returncode == 0
assert "# 本周阅读行动闭环" in result.stdout
assert "把 CLI 做成一个入口" in result.stdout
import subprocess
import sys
from pathlib import Path
from carl_weread.config import ContextConfig, write_config
ROOT = Path(__file__).resolve().parents[1]
def test_collect_context_script_accepts_config_and_brief(tmp_path):
config_path = tmp_path / "config.toml"
write_config(ContextConfig(mode="chat"), config_path)
result = subprocess.run(
[
sys.executable,
str(ROOT / "scripts" / "collect_context.py"),
"--config",
str(config_path),
"--brief",
"今天想把微信读书做成一个可分享 skill。",
],
cwd=ROOT,
text=True,
capture_output=True,
)
assert result.returncode == 0
assert "## brief: current-input" in result.stdout
assert "可分享 skill" in result.stdout
from datetime import date
from carl_weread.context import collect_recent_context
def test_collect_recent_context_supports_generic_folder_without_obsidian_structure(tmp_path):
notes = tmp_path / "my-notes"
notes.mkdir()
(notes / "current-work.md").write_text("我最近在做微信读书 skill,要推荐今天读哪一小节。", encoding="utf-8")
(notes / "idea.txt").write_text("读书应该从当前问题开始,而不是从书单开始。", encoding="utf-8")
items = collect_recent_context(notes, today=date(2026, 5, 18), days=3, max_chars_per_file=80)
assert [item.kind for item in items] == ["context", "context"]
assert any("微信读书 skill" in item.text for item in items)
assert any("当前问题" in item.text for item in items)