
Zettel Builder
- 13 installs
- 58 repo stars
- Updated May 21, 2026
- wshuyi/zettel-builder
Helps with ai & agent building tasks.
About
zettel-builder is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- zettel-builder
- AI & Agent Building
- AI-coding skill
Zettel Builder by the numbers
- 13 all-time installs (skills.sh)
- Ranked #11,409 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 27, 2026 (Skillselion catalog sync)
npx skills add https://github.com/wshuyi/zettel-builder --skill zettel-builderAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 13 |
|---|---|
| repo stars | ★ 58 |
| Last updated | May 21, 2026 |
| Repository | wshuyi/zettel-builder ↗ |
What it does
Helps with ai & agent building tasks.
Files
zettel-builder
让卡片自己长出来:素材进 → 切卡 → 连边 → 巡检 → 拼文。用户在每个分叉点保留决策权,但不必逐张手动建卡。
核心理念(凡修改先读一遍)
1. 原子化是为了可重组,不是为了字数小。判断一张卡是否合格的硬标准:脱离上下文,半年后的你能不能独立读懂? 能,就是合格,几百字也行。不能,再短也是垃圾。 2. "用自己的话重述"不是同义词替换。必须产生新判断:这个观点和我已知的什么冲突?边界条件是什么?我相不相信?没有这一步,重述只是更慢的复制。 3. 链接的价值在于写出为什么连。盲连等于不连。每条 link 必须带 reason 字段(一行话)。 4. 文章不会"自己长出来"——文章是从已有写作方向上的稠密簇中浮现的。本 skill 帮你更高效地组织既定方向的材料,不替你决定写什么。这是诚实的边界。 5. 触发分两层:mechanical scan 由 systemd 离线跑(纯 Python,不切卡只列待办);agent scan 由用户或 /schedule 触发(真正切卡 + 价值观重述 + 连边)。
参见 references/philosophy.md(为什么这样设计 + 与 topic-inspiration 的区别)。
路径默认
| 参数 | 默认 | 说明 |
|---|---|---|
zettel_root | ~/zettel/ | 卡片仓根目录,独立 git 仓 |
wiki_root | ~/wiki/published-articles/ | 上游素材来源 |
raw_articles | ${wiki_root}/raw/articles/ | 已发表文章源(241 篇+) |
raw_getnote | ${wiki_root}/raw/getnote/ | GetNote 笔记同步(1974+) |
published_source | ~/Dropbox/cn_articles_published/ | 已发表文章上游(去重对照) |
卡片格式硬规则
完整规范见 references/card-format.md。一行话总结:
---
id: YYYYMMDD-HHMM-<kebab-标题>
status: fleeting | literature | permanent
created: <ISO8601>
source: {type: ..., ref: ...}
tags: [...]
entities: [...]
links: [{id: ..., reason: ...}]
voice_passed: true|false
---
# 标题(直接陈述一个判断,不是名词短语)
正文(150-800 字,可超,以脱离上下文可读为准)。永远不要:
- 同时往一张卡塞多个独立判断(切两张)
- 标题写成名词("原子化原则")而不是判断("原子化的目的是可重组")
- 链接只列 id 不写 reason
- 把 raw 原文复制进卡(literature note 只引路径 + 短摘录)
四种模式
Mode 1: ingest — 单次喂料
用法:/zettel ingest <来源>。来源可以是:
- 一段贴在对话里的文本
- 一个文件路径(
/zettel ingest /path/to/note.md) - 一个 URL(走 markdown-proxy 先抓正文)
- 本次对话上方的若干消息("把上面那段对话切卡")
步骤:
1. 来源归一:把素材转为 Markdown 正文 + 来源元数据(type/ref/timestamp)。 2. 原子切分(v1.13.0 加强:长文必须多卡):
- 识别所有独立判断,每个一张卡。判断标志:"X 不是 Y,而是 Z"、"X 的真正原因是…"、"X 和 Y 的边界是…"、"X 表面 ... 实际 ..."
- wiki-article 长文必须通读全文找全 candidates,典型字数对应产卡数:短文 2-3 张 / 中长 3-6 张 / 长文 6-10 张 / 巨长 10-15 张
- 若 > 1500 字的 article 只产 1 张卡,必须在
processed/<date>.jsonl写明理由(否则视为信息压缩失败) - 详细判读流程见
references/voice-alignment.mdStep B.2 - 列举不算判断(三条原则、四种类型),除非每条独立成段并含论证
3. 价值观重述:对每张卡,调 references/voice-alignment.md 的协议——读 references/voice-snapshot.md(你自己的价值观/文风快照)的核心条款,重写卡的正文。关键:不是改文风,是产生新判断。如果原文是引述他人,重述时必须加上"我同意/不同意/部分同意,因为…"。 4. 链接候选:跑 scripts/link_candidates.py --card-id <new-id>,得到 top-10 候选卡。对每个候选,agent 判定是否真连 + 写一行 reason。无连不强求。 5. 去重 + 链接(v1.6.0 agent-judged):嵌入只做召回,最终判断必须 agent 读全文。
- 召回阶段(嵌入):算候选卡嵌入,跟
_index/embeddings.npycosine,取: - 近邻 top-20(sim 倒序)
- 远距 top-5(从 sim<0.5 的卡里随机抽,关键 — 防语义局部扎堆)
- 判断阶段(必须 Read 候选卡全文):
- 对近邻候选:Oven 读全文,判定"这是否真的同一判断"。不基于 sim 自动跳过。
- 真同一判断 → dedup-skip,记
{action:"dedup-skip", existing:<id>, agent_reason:"..."} - 表面相似但实质不同 → 写新卡,但必须自动 link 到那张近似卡,relation + reason 说清差异
- 对远距候选:Oven 读全文,判定"有没有非显然的跨域关联"。命中即建立 link,relation 通常
extend/counter,reason 说明跨域桥梁。 - 绝不直接用 sim ≥ 0.85 自动跳过 — 必须 agent 看完判定
- 绝不只从近邻候选选 link — 至少考察 5 张远距,防扎堆
5.5. filename + id 生成(v1.9.0,关键):写盘前确定 filename 和 frontmatter:
id(frontmatter)= 13 字符YYYYMMDD-HHMM纯时间戳。不含标题文字aliases(frontmatter)= 完整 H1 标题作为第一条(Obsidian 双链友好,改标题不破链)filename=<short-id>-<主语-主结论>.md,slug ≤ 20 中文字符,首词主语,英文小写连字符- 完整规则见
references/card-naming.md - 链接(frontmatter
links:+ 正文[[]])永远引用短 id
6. 图片搬运(v1.4.0):若 source 是含  的 raw markdown,先跑 python3 scripts/migrate_images.py --card-id <id> --source <raw-path> 把图复制到 assets/<id>/,记录路径 mapping。后续正文用新路径引用关键图(支撑本卡判断的;不必全搬)。 7. 写盘(v1.6.0 self-contained + 实质化引用 + Obsidian 双链):cards/<id>.md,正文结构:
## 原文要点节(blockquote 完整搬运 200-500 字原文,含图)## 我的判断节(选边表态 + 边界 + 必须自然引述 ≥1 张相关卡)## 相关卡片节(Obsidian 双链索引)
关键(v1.6.0):实质化引用。## 我的判断 不能只是孤立陈述,必须在正文里自然引述至少一张相关卡:
- 错(只有 ## 相关卡片 节列条目):
## 相关卡片\n- [[X]] — support: ... - 对(## 我的判断 正文里融入):
...如 [[X]] 所论 A 边界在 B,但本卡聚焦的是 C。[[X]] 强调测量,本卡关心如何处置——这是同一光谱上的两端。
引述必须言之有物(说出"为什么相关"+"差异点"),不是机械加 [[]]。validator 检查 ## 我的判断 节里至少有 1 个 [[]],否则 voice_passed=false。
## 相关卡片 节继续做 index(给 cluster_inspect 等脚本用),validator 校验两边数量一致。 8. git push 硬条款(v1.2.0):写完所有卡片后必须执行 cd ~/zettel && git add -A && git commit -m "feat(ingest): <一句话摘要 N 张卡>" && git pull --rebase origin main && git push origin main。不依赖 systemd timer 兜底。push 失败必须显式报错(常见原因:无网、远程有冲突)。 9. 回报:列出新建卡片清单 + 每张的链接情况 + dedup-skip 数量 + push 是否成功。
Mode 2: scan — 消化 mechanical queue
用法:/zettel scan(或由 OpenClaw cron / event-driven trigger)。
前置:scan_mechanical.py 由 systemd 每小时跑,把 raw/ 新增文件写进 _queue/*.json。Agent scan 消化这些 queue 项。
步骤:
1. 状态检查:读 _queue/ 当前积压;读 _state/last_scan.json;读 `_state/mode.json` 判定当前模式(bootstrap 或 steady)。 2. 批量限额(v1.3.0 mode-aware):
bootstrap模式:N=20(库存高速消化期,挑战长 session token budget)steady模式:N=5(日常消化,event-driven 触发)- 读
_state/mode.json中thresholds.bootstrap_batch_size/steady_batch_size,出错时 fallback 默认值。
3. 对每个 queue 项:
- 读 source 原文(literature note 只引路径,不复制原文)
- 走 ingest 的 Step 2-5(切卡 + 重述 + 链接 + 写盘)
- 处理完把 queue 项移到
_state/processed/<date>.jsonl
4. 顺手 inspect:跑一次 Mode 3 inspect 的轻量版,若发现文章就绪簇,发 Telegram 提醒。 5. 更新 _state/processed/<date>.jsonl(只动 agent scan 自己的进度)。绝不修改 _state/last_scan.json — 那是 mechanical scan 的 raw 游标,agent scan 写它会造成新增 raw 文件被跳过(详见 references/scan-mechanical.md 游标所有权章节)。 6. git push 硬条款(v1.2.0):cd ~/zettel && git add -A && git commit -m "feat(scan): <N 张新卡 + Y 项处理>" && git pull --rebase origin main && git push origin main。跨机器同步零延迟。push 失败必须显式报错。
Mode 3: inspect — 巡检卡片池
用法:/zettel inspect(或 scan 末尾自动跑轻量版)。
目的:找出三类信号——文章就绪簇 / 孤儿卡 / 缺口。不自动出文,只给建议。
步骤:
1. 跑 scripts/build_index.py:重建 tag/entity/link 索引。 2. 跑 scripts/embed_lite.py --include-published:同时刷新 cards/ 和 published-articles 的嵌入,供 overlap 检测用。 3. 跑 scripts/cluster_inspect.py:输出 JSON 报告,每个簇含:
- 成员 id 列表 + 密度 + voice_ratio
- gaps:counter_gap / case_gap / evidence_gap / time_gap(基于簇内 link relation 枚举判定)
- published_overlap(v1.3.0 新增):跟已发表文章的语义重叠 — top-3 候选 +
overlap_class(covered≥0.75 /adjacent0.6-0.75 /clean<0.6) - kind:
core(densest 子图)或whole(整连通分量)
4. LLM 命题:对每个新就绪簇,把 suggested_title 占位符替换为陈述性判断句(不要名词短语)。 5. 去重:对照 _state/cluster_history.json,过滤已建议过的簇(除非密度显著上升)。 6. 回报(v1.3.0 协议):Telegram 摘要 + 每簇含 overlap_class 标记:
covered:⚠️ 红色 — "主题已被 <已发文章标题> 覆盖,建议跳过或明确新角度"adjacent:橙色 — "主题相邻 <已发文章标题>,需要说明差异"clean:绿色 — "干净新主题,可推荐 /zettel write"
簇就绪判定细则见 references/cluster-detection.md。published-overlap 算法和阈值理由见 references/published-overlap.md。
Mode 4: write — 把簇拼成文章
用法:/zettel write <cluster-id> 或 /zettel write 后选最就绪簇。
步骤:
1. 读簇:从 _state/pending_articles.json 取 cluster-id,加载成员卡。 2. 缺口检查:若簇含 gap 标记,询问用户是否先调 deep-research 补卡(调 /deep-research 走 Skill 路由,主题 = 缺口描述,返回后回流为新卡 + 重跑簇就绪判定)。 3. 大纲合成:按卡片的链接关系排序成大纲(不是按时间)。 4. 移交写作 Skill:调你自己的写作 Skill(例如 /your-writer),输入 = 大纲 + 成员卡正文 + 已链接的相关 wiki concepts/。关键:本 skill 不自己写,只把材料组好。 5. 回流:文章发表后(用户手动通知或 wiki-sync 检测到 published-source 新增),把对应卡的 cluster_history 标为 published,卡的 frontmatter 加 produced_article: <wiki-path>。 6. git push 硬条款(v1.2.0):若 cluster_history 或卡 frontmatter 有改动,cd ~/zettel && git add -A && git commit -m "chore(write): <cluster-id> handed off" && git pull --rebase && git push。
调度集成(双层)
Layer 1: systemd(无 LLM,hourly)
scripts/zettel-hourly-sync.sh 由 zettel-sync.service(systemd user unit)hourly 触发,链在 wiki-sync.service 之后(After= + Wants=),保证 raw/ 已刷新。
只做无 LLM 的工作: 1. build_index.py 重建 tag/entity/link/term 索引 2. embed_lite.py 增量刷新 BGE 嵌入 3. scan_mechanical.py 检测 raw/ 新增 → 写 _queue/ 4. cluster_inspect.py 重算 ready/near_ready 5. git pull/push 兜底(以防 LLM session 漏 push)
Layer 2: OpenClaw cron(LLM session,daily/weekly)
~/.openclaw/cron/jobs.json 中两条 job,target=Oven(workspace-openai,GPT-5.5,@your-openclaw-bot):
| Job | 频率 | 干什么 |
|---|---|---|
zettel-daily-scan | 0 9 * * * | 消化 _queue/ 中 mechanical scan 攒下的待办,Mode 2 跑一遍,Telegram 摘要 |
zettel-weekly-inspect | 0 10 * * 0 | 周日深巡:LLM 给就绪簇命题、解读 gap,Telegram 推荐 /zettel write 候选 |
为什么是 OpenClaw 不是 Hermes:OpenClaw cron 原生支持 payload.kind: agentTurn(唤起 LLM 跑一段对话),Hermes cron 只是 script-driven(跑 Python 脚本)。zettel 的 scan/inspect 需要 LLM session,架构上只有 OpenClaw 能直接表达。
详见 references/scan-mechanical.md(systemd 层)+ references/openclaw-cron.md(Oven 层)。
Mode 5: propose — 命题作文(v1.12.0)
用法:
- 命令式:
/zettel propose <seed-text> - 自然语言(给 Oven):"围绕 X 写一篇"、"用 X 这个想法启发,从我笔记里找点料"
与 Mode 4 write 的区别:Mode 4 是自下而上(就绪簇 → 文章);Mode 5 是自顶向下(user seed → 从网络挑材料组簇 → 文章)。
步骤:
1. Python 召回:python3 scripts/propose_seed.py --seed "<text>"
- 算 seed 的 BGE 嵌入
- 跟 cards/ 嵌入做 cosine,取 top-30 候选
- 写
_state/proposal_<ts>.json(含 seed + candidates + 元数据)
2. Agent 判定 + 分角色(必须 Read 每张候选卡正文):
- support:本卡可作论据支撑 seed
- counter:本卡反对 / 限定 seed
- case:本卡是 seed 的具体案例
- context:本卡提供背景 / 前史
- unrelated:看似相关其实不是,丢弃
不是 sim 高就纳入 — 必须看实际内容判断。
3. 写 proposal 文档:~/zettel/_drafts/proposal_<ts>.md
- Seed 原文(引言)
- 分角色卡片清单(每条
[[<card-id>]] — <role>: <一句话相关性说明>) - 拟标题(基于 seed + 候选卡综合命题,陈述性判断,不要名词短语)
- 建议大纲(把 seed 作为论点,把候选卡组织成承接它的论证链)
- 缺口标注(若主要 support 链上有空洞,可触发 deep-research 补卡)
4. 回报:
- Telegram 简报 + proposal_id
- 用户审视
_drafts/proposal_<ts>.md后,可: /zettel write proposal_<ts>:移交你自己的写作 Skill 出稿/zettel propose ...:换 seed 重来- 手动编辑 proposal:增删卡 / 改大纲
5. git push 硬条款:proposal 文档写完后 cd ~/zettel && git add/commit/push,跨机器可见
触发关键词(Oven 在 Telegram 接到这些自动走 Mode 5):
/zettel propose <seed>- "围绕 X 写一篇" / "用 X 这个想法启发" / "命题作文 X" / "基于这个想法写"
与其他 skill 的关系
| Skill | 关系 |
|---|---|
| 你的写作 Skill | Mode 4 移交;Mode 1 重述阶段复用其 values + style-guide(在 references/voice-snapshot.md 维护快照) |
| 选题/topic-inspiration 类 Skill | 互补:那个是看缺口选题(自顶向下),本 skill 是已有素材长卡(自底向上)。两者可以串:选题输出 → 转 deep-research → 卡入 zettel |
| deep-research | Mode 4 缺口补漏;调用方式见 references/deep-research-handoff.md |
| 你的笔记/收藏源(如 GetNote、Readwise) | 上游源(通过 wiki-sync 等方式落到 raw/) |
| 闲聊/想法延伸 Skill(如 getseed) | 不重叠:那类是把闲聊延伸成线性产物;本 skill 是积累卡片网络。可串接:线性产物输出后转 ingest |
不做的事(防边界滑坡)
- 不自动发文:就绪簇只发 Telegram 建议,等用户
/zettel write - 不改你的写作风格规则:重述时直接读
references/voice-snapshot.md(你预先填好的价值观/文风快照),不在本 skill 复制业务文风 - 不当 GetNote 镜像:literature note 引用 raw/ 路径,不复制原文
- 不解决"该写什么":本 skill 帮你在既定方向上更高效组织,不替你决定方向(这是诚实的边界)
- 本地嵌入做语义召回(v1.1.0):BGE-small-zh-v1.5 via
fastembed,~90MB 缓存在~/.cache/fastembed/(不进 git);TF-IDF 作为降级路径。最终是否真连仍由 agent 判断
失败时
| 现象 | 排查 |
|---|---|
| ingest 找不到判断,只能列举 | 素材本身是清单/事实集,不适合做 permanent note;考虑只入 fleeting/ |
| 链接候选全是低相关度 | 检查 _index/ 是否过期,跑 scripts/build_index.py --rebuild |
| scan 反复处理同一文件 | 检查 _state/last_scan.json 的 mtime 是否被 git 重置;processed/<date>.jsonl 是否漏写 |
| 簇就绪却没收到 Telegram | 检查 _state/cluster_history.json 是否已把该簇标过(去重逻辑) |
| 价值观重述变成换词游戏 | agent 重读 references/voice-alignment.md,该协议明确要求产生新判断;否则不算 voice_passed |
版本
- v1.4.0 (2026-05-20) — Self-contained 协议:正文必须含
## 原文要点块(blockquote 完整搬运 200-500 字原文,不是 30 字摘要)+## 我的判断节。图片搬运:原素材含时,scripts/migrate_images.py复制图到~/zettel/assets/<card-id>/,卡正文改用新路径引用。脱离 source.ref 仍可读为硬测试。scan_mechanical.pyqueue item 增加source_images字段。旧 raw-source 卡(v1.3 及之前)归档到cards/_archived_v13/,raw 重新入队 Oven 按新协议重做。 - v1.13.0 (2026-05-20) — wiki-article 多卡切分。用户发现 v1.12 时 82 篇文章只产 83 张卡(平均 1.01),信息压缩过严。新协议:长文必须通读 + 找全 atomic 判断 + 按字数对应产卡数(短 2-3 / 中长 3-6 / 长 6-10 / 巨长 10-15)。1500+ 字的文章只产 1 张需在 processed jsonl 写明理由。规则见
references/voice-alignment.mdStep B.2。已切 1 张的 82 篇暂不强制重做,bootstrap 跑完后由用户决定是否回填。 - v1.16.1 (2026-05-21) — Deep-research 后台补漏 worker。新 cron
zettel-deep-research-worker(disabled 默认,手动触发或 cron schedule)。流程:读_state/deep_research_pending.json下一个 pending → 跑/deep-research→ 存~/zettel/raw/deep_research/<proposal_id>__<gap_type>/→ Mode 1 ingest 切多卡(source.type=deep-research)→ 更新top3_outlines.json的deep_research_status→ render HTML → push → Telegram。触发词 "/zettel deepresearch" / "补一个 gap" 等。 - v1.16.0 (2026-05-21) — 图文 TOP3 + GitHub Pages 部署。
render_top3_html.py生成图文 HTML(SVG 关系图 + LLM outline + gap badges + 推荐动作 + GitHub blob 卡片链接)。Pages: https://your-github-user.github.io/zettel/。Oven 写top3_outlines.json+deep_research_pending.json,Python 渲染 HTML。 - v1.15.0 (2026-05-21) — Multi-view cluster detection。详上。
- v1.12.0 (2026-05-20) — Mode 5 命题作文 + 证据图优先。
- Mode 5 propose:用户给 seed(案例/论点/问题),Python 召回 top-30 候选卡 → agent 读卡判定相关性+分角色(support/counter/case/context)→ 写
_drafts/proposal_<ts>.md→ 用户审视后/zettel write移交写作 Skill。自顶向下"命题作文",跟 Mode 4 (自下而上从就绪簇出文) 互补。 - 证据图协议:wiki-article 源默认
migrate_images.py --skip-first --include-remote。第一张图通常是题图(封面/装饰),跳;evidence 图(idx 1+)是 R2 远程 URL,卡正文直接用 URL,不复制本地。选图标准:证据图(截图/数据图/对比图)优先,题图不要。 - v1.11.0 (2026-05-20) — 冷启动来源门禁 + Obsidian 归档隐藏 + YAML 双引号修复。bootstrap 阶段 source.type 只接受 wiki-getnote / wiki-article(其他源走手动 Mode 1 ingest)。归档 5 张 chat-source 历史卡到
cards/_archived_bootstrap_chat_v1_11/(它们是 zettel-builder 自身做 smoke test 时的 demo 卡,不符合冷启动协议)。.obsidian/app.json改用 prefixcards/_archived一键隐藏所有归档目录,Obsidian 不再展示 broken / archived 卡。修复 1504 卡的双引号嵌套 YAML(单引号外包)。 - v1.10.0 (2026-05-20) — 时效性过滤 + 穿越时间原理提取。判定 source.created_at 距今是否 > 12 个月。老素材只切原理/规则/方法论层判断,不切绑定具体型号/工具/价格/功能的判断。已加抽象化技巧(把"GPT-4 写代码"提到"高能力模型扩展开发者产出方式"层)。完整规则在
references/voice-alignment.mdStep B.4。AI 迭代极快,这条防止 zettel 网络被"几年前如此"的死信息稀释。 - v1.9.2 (2026-05-20) — 回退到 id = filename stem 单一形式(用户反馈 v1.9.0/v1.9.1 alias 解析机制不稳 + 加认知负担)。aliases 字段删除。wikilink 直接用完整 filename stem,Obsidian 原生解析。
references/card-naming.md完全重写。 - v1.9.1 (2026-05-20) — 短 id 也作为 alias 修复 Obsidian dangling link。已被 v1.9.2 取代。
- v1.9.0 (2026-05-20) — 试图 filename / id 解耦,引入短 id + aliases。已被 v1.9.2 取代(过度设计)。
- v1.8.0 (2026-05-20) — session-review 过滤器。session-review 类素材(tag/文件名/结构判定)进卡时,只切用户层判断(需求/价值观/偏好/纠正/边界),不切技术层(问题描述/解决方案/工具细节/Codex 修复)。规则在
references/voice-alignment.mdStep B.5。同步加 cleanup cronzettel-tech-cleanup-once,Oven 巡现有 cards/ 归档技术细节卡到cards/_archived_technical_v18/。 - v1.7.0 (2026-05-20) — 原文时间戳。frontmatter
source.created_at必填(chat 可缺);正文## 原文要点节首行 italic 标注*原文时间:<ts>*。来源:wiki-getnote 读其 frontmattercreated_at,wiki-article 查raw/metadata/article-dates.json。backfill_source_timestamp.py已对所有现有 54 张非 chat 卡补齐(20 wiki-article + 34 wiki-getnote)。 - v1.6.0 (2026-05-20) — Agent-judged 链接 + Serendipity 涌现机制。变更:
- dedup/link 判断改为 agent 读全文,不再信余弦自动跳过。召回扩大到 top-20 近邻 + top-5 远距(sim<0.5 随机),Oven 看完才决定。
## 我的判断正文必须自然引述至少 1 张相关卡(言之有物的[[]]内嵌),不只是## 相关卡片节列条目。validator 校验。- 新 cron job `zettel-serendipity-daily`:每天 3 次(08:00 / 14:00 / 20:00)随机抽 20 张互不相连的卡,Oven 读全文找潜在关联,命中即建立 link(
serendipitytag),reason 说明跨域桥梁。 - Serendipity → cluster 涌现联动:每次 serendipity 跑完立刻调
cluster_inspect。新就绪簇(尤其含 serendipity 链接的)立即 Telegram 高优先级推送"⚡ 涌现:一个新主题快速饱和"。 - v1.5.0 (2026-05-20) — 库存全量消化模式(替代之前的"标记 already-scanned + 仅处理增量"误用)。变更:
scan_mechanical.py现在默认入队 raw/getnote + raw/articles 全部(241 已发文章不再 skip;已发文章里的判断也是宝贵原料,经去重后回流网络)- 三档语义去重(协议见 Mode 1 Step 5):写盘前嵌入比对,max_sim ≥0.85 跳过,0.7-0.85 强制 link,<0.7 新建。一篇 source 可对应零张或多张卡
- bootstrap_runner.sh 守护进程:持续从 queue 取批喂 Oven,不再受 cron 间隔限速;遇 quota/rate-limit 用指数退避(2/4/8/16/32 min);queue 持续 1 hour 空后自动切 steady(disable bootstrap cron + enable event-driven + 更新 mode.json),发 Telegram"bootstrap 完成"
- v1.4.0 (2026-05-20) — Self-contained 协议 + 图片搬运 + voice_passed validator(详上)
- v1.3.0 (2026-05-20) — 双态调度(bootstrap → steady)、published-articles 嵌入去重、event-driven trigger。
- 双态:
_state/mode.json记录当前态。bootstrap N=20,steady N=5。zettel-bootstrap-hourlycron 每小时跑直到 queue 排空;手动openclaw cron disable <id>切 steady。 - published-overlap:
embed_lite.py --include-published把~/Dropbox/cn_articles_published/all/*.md也嵌入。cluster_inspect.py对每个就绪簇算 centroid → cosine match → top-3 候选 +overlap_class。Mode 3 LLM 命题时必读 overlap,Telegram 摘要用红/橙/绿三色标记。 - event-driven:
zettel-hourly-sync.shStep 6:steady 态下,wiki 出现新内容(.new-content-trigger)时立即openclaw cron run <event-driven-scan-id>,无需等下一轮 cron。 - v1.2.0 (2026-05-20) — Mode 1/2/4 写卡后显式
git add + commit + push(不靠 systemd 兜底)。新增 OpenClaw cron 编排:Oven (workspace-openai, GPT-5.5) 接管 daily scan + weekly inspect,Telegram 通过 @your-openclaw-bot 原生发送。 - v1.1.0 (2026-05-20) — 链接候选改 BGE-small-zh-v1.5 嵌入(语义,主)+ tag/entity/链邻居结构信号(辅);TF-IDF 降级路径保留。mechanical scan 不再预算 candidate_links,该计算移入 agent 路径。
scripts/inspect.py→cluster_inspect.py(避免 stdlib 撞名)。 - v1.0.0 (2026-05-20) — 初版。TF-IDF + 4 因子打分。
__pycache__/
*.pyc
.DS_Store
MIT License
Copyright (c) 2026 Shuyi Wang
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
zettel-builder
一个自底向上生长的 Zettelkasten 卡片笔记系统:素材进来就长卡,价值观重述、自动连边、周期性巡检文章就绪簇,缺口处可触发深度调研补卡。
状态:面向 AI agent harness(Claude Code / OpenClaw / Codex / Hermes)的 Skill。已脱敏发布——原版围绕个人写作风格构建,这里把写作风格那部分替换成了可填写的模板。
---
它解决什么问题
写作素材越积越多,但真要写时却找不到该用哪些。常见做法是攒一堆"碎念笔记",可是这些笔记彼此孤立,不会自己长成文章。
zettel-builder 把"素材消化"这一步流程化:
- 每段素材切成多张原子卡(一卡一判断,半年后脱离上下文也能读懂)
- 每张卡末段必须做价值观重述(我同意/不同意/部分同意 + 边界),不是改写文风,是产生新判断
- 嵌入只做语义召回,agent 必须读全文判定是否真连/真同(不基于相似度阈值自动跳过)
- 周期巡检卡池,发现"文章就绪簇"(密度够 + 视角配齐 + 没被过去文章覆盖)就发 Telegram 提醒
- 簇拼成大纲后移交给你自己的写作 Skill 出稿
文章不会"自己长出来"——它是从已有方向上的稠密簇中浮现的。这个 skill 帮你更高效地组织既定方向的材料,但不替你决定写什么。
四种调用模式
| 模式 | 触发 | 做什么 |
|---|---|---|
| ingest | /zettel ingest <来源> | 把素材切原子卡 → 价值观重述 → 候选链接 |
| scan | /zettel scan | 消化 _queue/ 里积累的待办素材 |
| inspect | /zettel inspect | 巡检卡池找就绪簇 / 孤儿卡 / 缺口 |
| write | /zettel write <cluster-id> | 把指定簇拼成大纲移交写作 Skill 出稿 |
安装
方式 A — 把仓库交给你自己的 AI agent
请安装这个 Skill。先判断我当前使用的是 OpenClaw、Claude Code、Codex 还是 Hermes(优先按这个匹配顺序)。根据本机已有 Skill 目录、同步链或 agent 配置完成安装,并在安装后验证 zettel-builder 能被当前环境发现和调用。不要改写 Skill 内容;只有在适配本机外部服务路径或账号能力时,才做必要的本机配置说明。方式 B — 手动安装(Claude Code 示例)
git clone https://github.com/wshuyi/zettel-builder.git ~/.claude/skills/zettel-builderOpenClaw / Hermes / Codex 则放到对应 harness 的 skill 搜索根目录下后 reload。
使用前准备
zettel-builder 是"调度型"Skill,本身不写文章,依赖几个外部能力。请按需准备:
1. 你的写作风格快照(必填) 首次启用前,请打开 references/voice-snapshot.md(包内有模板),填入你自己的核心价值观(3-7 条)和文风硬规则。Mode 1 价值观重述会读它,没填则重述会滑回 "客观介绍"。
2. 你的写作 Skill(可选) Mode 4 / Mode 5 把就绪簇或命题草稿移交给你自己的写作 Skill 出稿。没有的话也能用——卡片照样长,只是出稿那步要手动接管。
3. 素材源(可选) 默认在 ~/wiki/published-articles/raw/{articles,getnote}/ 找上游素材。可以是已发表文章、笔记导出、Readwise 高亮、知识库快照等任何 Markdown。没有上游也能用 Mode 1 单次喂料。
4. 深度调研 Skill(可选) Mode 4 检测到簇有 evidence_gap 时可调 /deep-research。没有就跳过 gap 提示。
5. OpenClaw 调度(可选) 包内 references/openclaw-cron.md 说明了 daily-scan + weekly-inspect 的 cron 配置。也可以纯手动 /zettel scan、/zettel inspect。
6. 嵌入模型(自动) 默认走 fastembed + BGE-small-zh-v1.5(~90MB 缓存到 ~/.cache/fastembed/),TF-IDF 作为降级路径。
设计取向
- 自底向上:素材进来就长卡,不等"要写时再找"。和"自顶向下选题"型 Skill 互补、不重叠。
- agent 判读,嵌入只做召回:去重和链接的最终判断必须 agent 读全文,不基于相似度自动跳过。这是有意识的"贵"。
- 价值观重述是硬规则:不是同义词替换,是产生新判断。没有"我同意/不同意 + 边界"那一段,卡视为未完成。
- 本 skill 不写文章:拼大纲、组材料、找证据缺口;出稿是你写作 Skill 的事。
- 手动决策点保留:自动 = 切卡、连边、找簇;人工 = 决定写哪个簇、最终是否要写。
注意事项
- 包内
references/voice-snapshot.md是模板,需要你自己填入价值观/文风条款再启用。 - 长文切卡会消耗较多 token(GPT-5.5 类模型在 ingest 时每篇 1500+ 字的文章可能切 6-10 张卡)。建议先做小批量试切再上 cron。
- OpenClaw cron 集成里的 chat_id / bot 名称是占位符,请替换为你自己的 Telegram 配置。
目录结构
zettel-builder/
├── SKILL.md # 主 Skill 定义(模式路由 + 协议)
├── references/ # 详细协议(价值观重述、链接、聚类等)
│ └── voice-snapshot.md # 价值观/文风模板——首次启用前必须填写
├── scripts/ # Python 辅助脚本(嵌入、链接候选、聚类等)
└── agents/ # (为未来子 agent 预留的占位)License
MIT — 见 LICENSE。可自由使用、修改、再分发,欢迎注明出处但不强制。
来源
由 王树义 在长期写作实践中迭代设计,已对外脱敏;适合需要"卡片网络"工作流但不想从零搭框架的同行使用。
卡片格式规范
文件命名
cards/<YYYYMMDD>-<HHMM>-<kebab-slug>.md
例:cards/20260520-1430-原子化的目的是可重组.md
slug 由标题机器化生成:中文保留汉字,去标点,空格转 -,英文小写。最大 50 字符。
Frontmatter 完整字段
---
id: 20260520-1430-原子化的目的是可重组
status: permanent # 必填: fleeting | literature | permanent
created: 2026-05-20T14:30:00+08:00 # 必填, ISO8601
updated: 2026-05-20T14:30:00+08:00 # 必填, 改卡时更新
source: # 必填
type: chat # chat | wiki-article | wiki-getnote | deep-research | external-url | user-paste
ref: "对话片段或路径或 URL"
excerpt: "原文最关键 30-100 字" # 仅 literature note 必填
created_at: "2026-05-15 12:29:27" # v1.7.0:原素材时间戳。wiki-getnote 读其 frontmatter created_at;wiki-article 查 ~/wiki/.../raw/metadata/article-dates.json;chat 可缺。
tags: [zettelkasten, knowledge-management] # 选填,自由列表
entities: [Luhmann, Ahrens] # 选填,人名/产品/工具/概念专名
links: # 选填
- id: 20260518-1900-link-must-have-reason
relation: counter # 必填:support | counter | parent | child | extend
reason: "对立观点 — 强调字数不是判断标准"
- id: 20260512-1100-...
relation: parent
reason: "上位概念 — 知识管理的整体框架"
voice_passed: true # permanent 必须 true; literature 可 false
produced_article: # 选填,本卡参与的已发表文章 wiki 路径
- "concepts/zettelkasten-misuse.md"
---三类卡的差异
fleeting note
- 路径:
fleeting/<id>.md(不进 cards/) - 用途:捕捉闪念,不必完整,不必有 link
- 生命周期:周期性被 agent 评估升级为 literature 或 permanent;否则 30 天后归档
voice_passed可为 false- 不参与 cluster 检测
literature note
- 路径:
cards/<id>.md - 用途:对某篇原文(wiki article / GetNote / 外部 URL)的关键判断提炼,必须有 `source.excerpt`
- 必须用自己的话写,但可以未做完整价值观重述
voice_passed可为 false- 参与 cluster 检测(权重低于 permanent)
permanent note
- 路径:
cards/<id>.md - 用途:独立成立的判断,经过价值观重述
- 硬规则:
voice_passed: true必须- 标题是判断句不是名词
- 正文 150-800 字(可破例,但要能通过"半年后的我能脱离上下文读懂"测试)
- 至少 1 条 link(允许唯一例外:这是该主题第一张卡)
- 参与 cluster 检测(权重最高)
正文写作硬规则
1. 标题是判断,不是名词
- 错:
# 原子化原则 - 对:
# 原子化的目的是可重组,不是字数小
2. 不写"在本卡中我们将讨论…"(AI 痕迹) 3. 不堆叠列表(三条原则、五个步骤)——除非每条独立成段并含论证 4. 引用必须带出处:
- 引 Luhmann/Ahrens 等具体人物时,在
source.ref或正文括注里说明出处 - 拒绝"卢曼说过…"式模糊引用
5. 不复制原文:literature note 的 excerpt 限 30-100 字;permanent note 不允许直接抄 6. 必须有自己的判断:即使是 literature note,也要在末段写"我看这条的态度是…" 7. 链接段落约定:
- 若有 ≥3 条 link,正文末尾加
## 与其他卡的连接,逐条展开 reason(超过 frontmatter 的一行 reason) - 若有 1-2 条 link,只在 frontmatter 里写
"半年后可读"自测清单
写完一张 permanent note,问自己:
- [ ] 标题是不是一个独立的判断?
- [ ] 不读上下文,只读本卡,我能理解这个判断在说什么吗?
- [ ] 我能不能复述这个判断的反方观点?(如果不能,说明加工不足)
- [ ] 引用的人名/概念/工具有没有交代清楚?
- [ ] 至少一条 link 的 reason 是否能让我看到本卡和被链卡的接缝?
- [ ] (v1.4.0)
## 原文要点块是否完整搬运了支撑本卡判断所必需的原始文字 / 数据 / 引文?(脱离 source.ref 仍可读) - [ ] (v1.4.0) 如果原素材有图,关键图是否已搬到
assets/<card-id>/并在正文里引用?
7 条全 yes 才能 voice_passed: true。
v1.4.0 硬规则:self-contained + 图片搬运
Self-contained 原则:卡必须独立完整。读者拿到一张卡,不查 source.ref、不去 raw 目录翻原文,也能完整理解这张卡讲什么、依据是什么、作者的态度。
不达标的写法(v1.3 以前常见,v1.4.0 起拒收):
- ❌ "原素材只有一句短记和一张图,因此本卡先放在 literature 状态" — 元 commentary,不算内容
- ❌ "如某 GetNote 所述" — 让读者去翻原文
- ❌
source.excerpt只 30-100 字摘要 — 太短,本卡正文得自带原文要点
正文结构(literature + permanent 通用):
# <判断标题>
<导语:1-2 句陈述本卡的核心判断>
## 原文要点
*原文时间:2026-05-15 12:29:27*
> 直接搬运的原文段落或要点(200-500 字)。
> 含具体数据、人物名、产品名、引文、案例细节。
> 用 blockquote 区分,这是"原作者说什么",不是"我说什么"。
如有图,在原文要点节里(图相对路径用 `../assets/<card-id>/`):

**v1.7.0 时间戳要求**:`## 原文要点` 标题后**必须**有一行 `*原文时间:<YYYY-MM-DD HH:MM:SS>*`(italic,blockquote 之外)。来源:
- `wiki-getnote`:读 raw markdown frontmatter `created_at` 字段
- `wiki-article`:在 `~/wiki/published-articles/raw/metadata/article-dates.json` 按文件 stem 查 `articles.<stem>.date`
- `chat` / `deep-research` / `user-paste`:写当时对话/调研/粘贴的时间
## 我的判断
<3-5 段:重述 + 我同意/不同意/部分同意 + 边界 + 反例>
## 相关卡片
<v1.5.0:Obsidian 双链节,把 frontmatter links 镜像出来,Obsidian 图谱视图才能用。每条一行:>
- [[<card-id>]] — <relation>: <reason>
- [[<另一 card-id>]] — <relation>: <reason>为什么 `## 相关卡片` 是双链镜像:
Obsidian 读 [[wikilink]] 建立反向链接 + 图谱视图。它不解析 frontmatter 里嵌套的 links: - id: ... 结构。所以两边必须同步:
- frontmatter
links:给本 skill 的脚本用(cluster_inspect、link_candidates、validate_cards) - 正文
## 相关卡片节给 Obsidian 用
validator 会检查两者数量 + ID 一致。漂移即 voice_passed=false。
图片搬运:
- 原素材若引用
,等价路径~/wiki/published-articles/raw/images/<note_id>-NN.png - 切卡时:运行
scripts/migrate_images.py --card-id <id> --source <raw-md-path>把图复制到~/zettel/assets/<card-id>/,正文里改用 - 不复制图但卡里靠图说话 = self-contained 失败 = voice_passed: false
例外: chat 源卡或纯讨论卡(无 raw 文件 + 无图),允许跳过 ## 原文要点 块,但必须在正文里把对话/讨论内容自带说清楚。
Card Naming(v1.9.2,简化版)
用户反馈:之前 v1.9.0/v1.9.1 引入"短 id + alias 解析"两套引用,为了应对一个不常发生的"改标题"场景。回退到最简方案:id = filename stem,Obsidian wikilink 直接用 filename。
规则
1. id = filename stem(不含 .md 扩展名)
id: 20260520-1005-极快模型把-ai-编程重心从写代码推向验收filename 和 id 一一对应,Obsidian 解析 [[<id>]] 直接 filename match,无需 alias。
2. filename slug 规则
<YYYYMMDD-HHMM>-<主语-主结论>.md
- 第一段时间戳固定 13 字符
YYYYMMDD-HHMM - 后半部分 = 主语 + 主结论,用
-连接 - 中文字数 ≤ 20(英文字符可放宽)
- 首词是主语(名词/主题词),不是动词或语气词
- 英文一律小写,标点全去掉
3. 不要 aliases: 字段
filename 已经够好(短 id 前缀 + 可读 slug)。aliases 没必要,删掉。
例外:若以后某张卡要支持多个标题别名(如老标题历史),可加 aliases 字段。默认不加。
4. links 和 wikilink 都用完整 filename stem
links:
- id: 20260520-1134-可试玩部署比截图更能证明-ai-编程工作流变化
relation: support
reason: "..."## 相关卡片
- [[20260520-1134-可试玩部署比截图更能证明-ai-编程工作流变化]] — support: ...改标题怎么办
少见。真发生时: 1. git mv 改 filename 2. 同步更新 frontmatter id: 3. grep -rl '\[\[<old-id>\]\]' ~/zettel/cards/ 找所有引用,sed 批量替换 4. 引用是确定的全文搜索,改完跑 validator 验证
不为这个边缘场景搞 alias 解析双层架构。
与 Obsidian 的关系
- vault 直接打开
~/zettel/ - wikilink
[[<filename-stem>]]直接命中 filename - 图谱视图 / 反向链接 / 双链 picker 全部 native 工作,零额外配置
历史
- v1.9.0:试图 id/filename 解耦,引入短 id 和 aliases。失败案例:
[[<short-id>]]在 Obsidian 里弹出新空白卡,因为 alias 解析机制对纯 id alias 不稳。 - v1.9.1:补救——把短 id 也加入 aliases。Works,但加了认知负担(两种引用形式并存)。
- v1.9.2:回退到 id = filename stem 单一形式。用户反馈"加这些东西怪麻烦的",确实如此。
簇就绪判定 (cluster detection)
何谓"就绪"
一个卡片簇就绪 = 可以拼成一篇文章的初稿。三个条件全满足:
1. 密度:子图节点 ≥ 5 张 permanent 卡,且子图内边数 / 完全图边数 ≥ 0.4 2. 方向收敛:卡片的 tags 交集 ≥ 1 个核心 tag,且大多数卡的 reason 指向同一中心问题 3. 价值观一致:成员卡 voice_passed: true 比例 ≥ 80%
不就绪信号
- 节点 ≥ 5 但密度 < 0.3 → 还是松散的并列,不是有论证的论述
- 节点 < 5 → 论据不足,文章会显单薄
- voice_passed 比例低 → 半成品太多,先升级
- 大量"延伸"型 link → 没真争论点,容易写成水文
算法 (scripts/cluster_inspect.py 实现)
def detect_clusters(cards_index, links_graph):
# 1. 在 permanent-only 子图上跑社区发现
G = build_graph(filter(status=='permanent', cards))
communities = louvain(G) # 或 connected_components 退化版
# 2. 对每个社区算指标
for c in communities:
if len(c) < 5: continue
density = edges(c) / (len(c)*(len(c)-1)/2)
if density < 0.4: continue
# 3. 方向收敛检查
core_tags = intersect_tags(c)
if not core_tags: continue
# 4. voice_passed 比例
voice_ratio = sum(card.voice_passed for card in c) / len(c)
if voice_ratio < 0.8: continue
yield {
"cluster_id": hash(sorted_ids(c)),
"members": [card.id for card in c],
"core_tags": core_tags,
"density": density,
"voice_ratio": voice_ratio,
"suggested_title": derive_title(c),
}去重
每个就绪簇生成 cluster_id = sha1(sorted(member_ids))。
_state/cluster_history.json 记录已建议过的 cluster_id。再次扫到同一簇:
- 若成员未变 → 跳过(已建议过)
- 若成员新增 ≥ 2 → 重新发建议(标注为 "expanded")
- 若 density 显著上升(≥0.1)→ 重新发建议(标注为 "denser")
缺口标注
对每个就绪簇,inspect 还会标注 gaps:
- time_gap:簇内最新卡 mtime 距今 > 90 天 → 可能需要补"近期发展"
- counter_gap:簇内无任何"对立"型 link → 论述缺少反方
- evidence_gap:簇内 literature note 比例 < 20% → 证据偏弱,可调 deep-research
- case_gap:簇内无任何"下位"(具体例子)链接 → 论述缺案例
gaps 字段进入 Mode 4 写文时的决策点:用户可选择"先补缺再写"或"直接写"。
输出格式
_state/pending_articles.json:
[
{
"cluster_id": "abc123...",
"detected_at": "2026-05-20T16:00:00+08:00",
"members": ["20260518-...", "20260519-...", "..."],
"core_tags": ["zettelkasten", "knowledge-management"],
"suggested_title": "卡片笔记的四个被神化的传播版本",
"density": 0.62,
"voice_ratio": 1.0,
"gaps": ["counter_gap"],
"status": "pending_user_decision"
}
]发 Telegram 时只发标题 + cluster_id + 成员数 + gaps,不发完整 JSON。 用户回复 /zettel write <cluster_id> 进入 Mode 4。
不就绪时的建议
inspect 在没就绪簇时仍输出:
- near-ready clusters:差 1-2 个条件就就绪的 → 可能引导用户去补卡或建链
- orphan rate:孤儿卡比例,> 20% 触发警告
- dead branches:某 tag 下卡片超过 30 天无新增 → 可能是"开过头但没继续"的主题
deep-research 缺口补漏协议
何时调用
只在 Mode 4 (write) 中,某就绪簇被标 evidence_gap 或 counter_gap,且用户选择"先补缺再写"时调用。
不在 Mode 1/2/3 中调用 deep-research。理由:
- ingest/scan 是高频低成本工作,不应触发昂贵的全网调研
- inspect 只是巡检,不主动改变状态
调用方式
deep-research 没有库式 API,只能通过 Skill 路由(/deep-research <主题>)。
构造调研主题时:
1. 取簇的 core_tags + suggested_title 2. 取 gap 类型:
evidence_gap→ "针对 [核心命题],近 3 年的实证研究 / 数据 / 案例"counter_gap→ "[核心命题] 的反方观点和批评"
3. 写一个 ≤200 字的调研 brief 给 deep-research
例(假设簇是"卡片笔记被神化的传播"):
/deep-research
主题:卡片笔记法(Zettelkasten)的方法论批评
背景:我已经有一组卡片,论点是"卡片笔记的几个传播版本被神化了——原子化不该被字数化,'文章自己长出来'是半真半假"。现在我想补充 counter_gap:
需要调研:
1. 学术界/方法论圈对 Zettelkasten 的批评(主要批评点)
2. Sönke Ahrens 在《How to Take Smart Notes》之外是否有过自我修正
3. Luhmann 卡片盒方法论是否被过度神化的近年讨论(2020+)
不需要:
- 卡片笔记入门介绍
- 工具评测调研结果回流
deep-research 产出 ~/Downloads/research/<topic>/FINAL_调研报告.md。
zettel-builder Mode 4 在调研完成后:
1. 读 FINAL_调研报告.md 2. 对其中可切的判断,走 Mode 1 ingest 流程(切卡 + 重述 + 链接),source.type = deep-research,source.ref 指向 FINAL_调研报告.md 路径 3. 新卡入 cards/,自动建立和簇成员的链接 4. 重跑簇就绪判定(密度可能上升,gap 可能消除) 5. 通知用户:"补卡完成,簇现在 [就绪/仍有缺口],可以 /zettel write"
不做的事
- 不让 deep-research 直接写卡:它不知道用户价值观,必须走 ingest 协议
- 不在调研失败时假装成功:deep-research 失败(配额耗尽 / API 错误 / 内容审查)→ 写 _state/gaps_pending.json 记录待补,通知用户
- 不无限补:同一 gap 最多调 deep-research 一次。仍未消除则在簇报告里标
gap_persistent,让用户决定是否写
调用频率上限
- 单次会话最多 1 次 deep-research 调用(避免长会话堆叠)
- 同一 cluster_id 一辈子最多 2 次 deep-research(避免反复补)
- 配额检查:调用前看
~/.codex/state.json(或相应配额接口),若已耗 80% 提示用户
与 topic-inspiration 的对比
| 场景 | 用 topic-inspiration | 用 zettel deep-research handoff |
|---|---|---|
| "最近该写什么" | ✓ | ✗ |
| "这个簇缺反方观点" | ✗ | ✓ |
| "我有半成品卡需要补证据" | ✗ | ✓ |
| "扫一遍当前关切找选题" | ✓ | ✗ |
链接协议
两步流程
Step 1: 机器候选(脚本)
scripts/link_candidates.py --card-id <new-id> --top-k 10
候选算法(纯 Python,不调 LLM):
score(new_card, existing_card) =
0.4 * tag_overlap_jaccard
+ 0.3 * entity_overlap_jaccard
+ 0.2 * tfidf_cosine_on_body
+ 0.1 * link_neighborhood_overlaplink_neighborhood_overlap:如果 new_card 和 existing_card 共享某第三方卡作为链接邻居,加分。即"我们都连了 X,所以我们之间可能也该连"。
返回 top-10 candidates(JSON 数组,每条含 id、score、相同 tags、相同 entities、TF-IDF top 3 共同 term)。
Step 2: Agent 判定(LLM)
对每个候选卡:
1. 读 new_card 和 candidate 的正文 2. 判断关系类型,选一个(写入 frontmatter relation 字段,枚举值固定):
- support:candidate 给 new_card 提供论据
- counter:candidate 反对 new_card 的判断
- parent:candidate 是 new_card 的更一般框架(上位)
- child:candidate 是 new_card 的具体例子(下位)
- extend:同主题不同侧面(延伸)
- 无关:看似相关其实不是,丢弃,不写 link
为什么必须用枚举,不只用自由文本 reason:cluster gap 检测(cluster_inspect.py)需要程式化判断"本簇是否有反方观点"。靠在 reason 里 substring 匹配"对立"、"反对"会漏("相反"、"质疑"、"反例")也会误("不反对")。relation 是机器读的,reason 是人读的。 3. 写 1 行 reason(≤30 字),示例:
"对立观点 — 强调字数,而本卡强调可重组""下位例子 — Luhmann 自己卡片的字数实情""上位框架 — 知识可重组性是更一般原则"
4. 若选"无关",不写 link
链接数量纪律
| 卡类型 | 建议链接数 | 强制 |
|---|---|---|
| fleeting | 0 | — |
| literature | 1-3 | 不强制 |
| permanent | 2-5 | ≥1 条(允许唯一例外:该主题首卡) |
上限 5 条:超过 5 条说明这张卡太"杂",应该拆。
关系类型选用提示
- 不要把所有 link 都选"延伸"——这是最弱的关系,容易变成"什么都连"
- "支撑/对立"是最有价值的链接,优先识别
- "上位/下位"形成层级,有助于后续 cluster 检测
- 一张卡的所有 link 不应是同一类型(全"延伸" = 没真想)
反向链接
不需要双向写。scripts/build_index.py 会建反向索引(_index/links_graph.json)。 某卡被多少其他卡连(in-degree)是 cluster 检测的关键指标。
当无可链时
允许首卡无 link。但若已有 50+ 张卡仍 0 link:
1. 跑 scripts/build_index.py --rebuild 确认索引未过期 2. 跑 link_candidates.py --top-k 30 --threshold 0.05(放宽阈值)看是否真的没相关 3. 如真无关,考虑:这张卡是不是入错仓了?是不是太冷门?
孤儿卡数量 > 卡片总数 20% 时,触发 inspect 警告。
不做的事
- 不让 agent 看完所有现有卡再选:候选必须经过机器短列,否则 token 爆炸
- 不写双向冗余:in/out 由 index 推导
- 不引入图数据库:JSON 索引 + 重建脚本足够
- 不为漂亮的网状结构而强连:无 reason 的连等于不连
OpenClaw cron 集成(Layer 2)
本 reference 描述 v1.2.0 起 Oven (workspace-openai, GPT-5.5, @your-openclaw-bot) 接管的两个 agent-turn cron job。Layer 1(systemd 无 LLM 部分)见 scan-mechanical.md。为什么是 OpenClaw 不是 Hermes
| OpenClaw cron | Hermes cron | |
|---|---|---|
payload.kind = agentTurn(唤起 LLM session) | ✅ 原生 | ❌ 不支持 |
Script: X.py(定时跑 Python) | ❌ 不是设计目标 | ✅ 原生(现有 wiki-auto-git-sync 等都是此形态) |
zettel scan/inspect 必须是 LLM session(切原子卡 + 价值观重述 + 写链接 reason),架构上只有 OpenClaw 能直接表达。Hermes 的 cron 留给 systemd 已经在做的纯脚本工作(没必要平迁)。
两个 job
zettel-daily-scan
| 字段 | 值 |
|---|---|
| cron | 0 9 * * * (每天 09:00) |
| agent | openai(Oven) |
| session | isolated |
| timeout | 1800s (30 min,足以处理 5 项 queue) |
| thinking | high |
| tools | exec read write |
| delivery | announce → telegram:<YOUR_TELEGRAM_CHAT_ID> (account=openai) |
| best-effort-deliver | true(delivery 失败不阻塞 job) |
| message | 见下 |
Message 关键点:
- 必读 SKILL.md Mode 2 + voice-alignment.md + voice-snapshot.md
- 处理上限 N=5(防长会话)
- 写卡完成后必须显式
git add + commit + pull --rebase + push - 回报:新增卡数 / 是否触发就绪簇 / git push 状态
zettel-weekly-inspect
| 字段 | 值 |
|---|---|
| cron | 0 10 * * 0 (周日 10:00) |
| agent | openai(Oven) |
| session | isolated |
| timeout | 900s (15 min,inspect 主要是命题 + 解读,不涉密) |
| thinking | high |
| tools | exec read write |
| delivery | announce → telegram:<YOUR_TELEGRAM_CHAT_ID> (account=openai) |
Message 关键点:
- 先跑
build_index.py+cluster_inspect.py重算 - 读
pending_articles.json+near_ready.json - 对每个新就绪簇 LLM 命题(替换
suggested_title占位符为陈述性判断,不要名词短语) - 解读
gaps的具体含义 - Telegram 摘要 + 推荐动作
添加/编辑命令
# 查看
openclaw cron list
# 添加(完整命令见 SKILL.md 注释,这里只示意)
openclaw cron add --name "zettel-daily-scan" --cron "0 9 * * *" \
--agent openai --session isolated --light-context --expect-final \
--timeout-seconds 1800 --tools "exec read write" --thinking high \
--channel telegram --account openai --to <YOUR_TELEGRAM_CHAT_ID> \
--announce --best-effort-deliver \
--message "..."
# 编辑某条
openclaw cron edit <job-id> --cron "0 8 * * *"
# 一次性立即跑(调试)
openclaw cron run <job-id>
# 看运行历史
openclaw cron runs --id <job-id>模型差异管控
Oven 用 GPT-5.5,不是 Claude/DeepSeek。GPT-5.5 中文写作偏"翻译腔",但本 skill 通过几条硬约束把模型差异收窄:
1. SKILL.md Mode 1 Step 3 强制读 references/voice-snapshot.md(你自己的价值观/文风快照)— 规则压在生成之前 2. voice-alignment.md 的 D 步 AI 痕迹自检清单覆盖了 GPT 常见的"深入剖析/全方位/不仅而且/总而言之"等模式 3. card-format.md 标题硬规则(判断句不是名词)防止 GPT 生成 "X 的 N 种 method" 类清单式标题 4. 每张 permanent 卡末段必须有"我同意/不同意/部分同意 + 边界"——这条让模型必须选边表态,不能滑回"客观介绍"模式
实测后若发现 GPT-5.5 输出仍偏"翻译腔",有两条路:
- (a) Mode 1 Step 3 强化 — 在 voice-alignment.md 增加"反翻译腔"专项规则
- (b) 切到 Hermes deepseek profile — 但要解决 Hermes 没有原生 agent-turn cron 的架构问题(可能要 systemd cron →
hermes chat -p deepseek "..."的绕道)
先观察一周,再决定。
失败排查
| 现象 | 排查 |
|---|---|
openclaw cron run <id> 立刻返回 enqueued: true 但没动静 | 看 ~/.openclaw/cron/jobs-state.json 是否 runningAtMs 还在;长时间没结束可能是 gateway 卡了,systemctl --user restart openclaw-gateway |
| Telegram 没收到摘要 | ~/.openclaw/cron/runs/<id>.jsonl 末尾 deliveryStatus 字段;bestEffort: true 时即使 delivery 失败 job 也 ok |
| git push 失败但 job 报 ok | Oven 把 push 失败当 "soft fail";检查 cards/ 最新文件是否已 commit;手动 cd ~/zettel && git push 补救 |
| Oven 跑了但没切出卡 | _queue/ 是否真的有待办;processed/<date>.jsonl 是否已有今日条目;若 queue 满但 processed 空,看 Oven 的 session 日志 ~/.openclaw/workspace-openai/ |
| 跨日 Oven 处理同一项两次 | processed/<date>.jsonl 写入失败;检查 jsonl 文件权限和磁盘 |
设计哲学(凡修改先读)
为什么不直接复用 topic-inspiration?
topic-inspiration 是"等到要写时,从语料里挑选题"——一次性扫描,产出 8 个候选,流程结束。 zettel-builder 是"素材进来就被消化为卡,卡之间持续连边"——素材生命周期内反复加工。
两个工作流的输入相同(llm-wiki + GetNote),但时态不同:
| 维度 | topic-inspiration | zettel-builder |
|---|---|---|
| 时态 | 一次性"现在该写什么" | 持续"积累中" |
| 产物 | 选题清单(临时) | 卡片网络(持久资产) |
| 触发 | 用户显式调 | systemd + /schedule + 用户调 |
| 价值观加工 | 只在生成大纲时 | 每张卡入库时 |
两者可串行:topic-inspiration 找出"缺评论的话题" → 调 deep-research → 输出回流为新卡入 zettel 仓。
为什么不真的全自动?
诚实地说,Zettelkasten 自动生长的传播版本有两个谎言: 1. "卡片自己长出文章":不会。Luhmann 一辈子写 70 多本书,但他的卡片盒服务的是他已经在做的研究方向,不是凭空涌现。 2. "用自己的话重述就是内化":很多人执行成同义词替换,没有真正加工。
本 skill 在这两个点上不撒谎:
- 簇就绪只发建议,不自动出文(因为方向选择只有用户能做)
- 重述协议(
voice-alignment.md)要求产生新判断,否则不算voice_passed
为什么 mechanical scan 和 agent scan 要拆?
systemd 定时器没有 LLM,但有些工作就是不需要 LLM:
- 列出"过去一小时 raw/ 里新增哪些文件" — 纯 Python 可以
- 算 TF-IDF top terms — 纯 Python 可以
- 算候选链接 top-K — 纯 Python 可以(基于 tag/entity 索引)
需要 LLM 的部分:
- 判断哪几个判断该切成几张卡
- 价值观重述
- 判断 top-K 候选里哪些真的该连 + 写 reason
把不需要 LLM 的部分离线化,等 Claude 会话时只做必须 LLM 才能做的工作,效率最高。
为什么链接必须带 reason?
无 reason 的链接 = 不连。原因: 1. 半年后回看一张卡,你不会记得当初为什么连那条 2. 链接是为"重组成文"服务的,reason 就是未来重组时的接缝词 3. 强制 reason 也防止 agent 滥连(为了"显得连了很多"而连)
双态调度:bootstrap 与 steady(v1.3.0)
初期(用户库存几千篇 GetNote + 几百篇已发文章)和稳态(每天几条新增)的工作模式完全不同:
- bootstrap 态:目标是快速搭起卡片网络。N=20/小时,Oven 持续消化,直到
_queue/排空。理由:网络越早形成,后续筛选判断越有上下文,inspect 才有信号 - steady 态:目标是对增量做精修。N=5/事件触发(wiki 有新内容时),不再 hourly 强跑
状态记在 _state/mode.json,手动切换(openclaw cron disable zettel-bootstrap-hourly)而非自动——状态机加在 cron 上太复杂,用户自己看着切换更稳。
为什么不一开始就 N=20 hourly:GPT-5.5 单 session token budget 在 30 分钟 timeout 内,N=20 已是经验上限。再大要拆 batch,做有限并行,引入新失败模式。bootstrap 阶段每天可处理 ~480 张卡上限,实测一周左右消化完一个有 2000+ 篇 GetNote 的库存。
published-articles 重叠检测(v1.3.0)
cluster_inspect 在 v1.0-v1.2 只看簇内信号(密度/voice_ratio/tags),不关心"该主题是否已被某篇已发表文章覆盖"。这导致即使簇就绪,推荐出稿也可能是重复劳动。
v1.3.0 把 topic-inspiration 的"对照已发布内容"信号下沉到 cluster_inspect:复用 BGE 嵌入,对 ~/Dropbox/cn_articles_published/all/*.md 也做嵌入,簇 centroid vs published 取 top-3 cosine,分 covered/adjacent/clean 三档。
细节见 references/published-overlap.md。
链接候选的语义层(v1.1.0 变更)
v1.0 用 TF-IDF + tag/entity overlap。v1.1.0 改为 BGE-small-zh-v1.5 嵌入 + 结构信号(tag/entity/链邻居)。
理由:中文 2-gram TF-IDF 在"原子化的目的"和"卡片要可重组"这类同义判断上根本撞不到任何重叠 token,召回为零。语义嵌入解决这个问题。
实现轻量:本机 onnxruntime + tokenizers + jieba 已经装了,只新加 fastembed 一个 Python 包 + 一次性下载 90MB 模型(缓存在 ~/.cache/fastembed/)。模型不进 git 仓。
降级保证:
- 若
fastembed不可用或模型未下载(新机器、离线),link_candidates.py自动降级到 TF-IDF 路径,不阻塞 - 嵌入 cache(
_index/embeddings.npy+_index/embeddings_meta.json)随 git 同步,避免每台机器重算
为什么不更重的模型(jina-base-zh 768d / bge-large-zh-v1.5 1024d):
- 卡片数 < 几万时,512 维已远超 top-K 召回需求
- bge-small 在 C-MTEB 上对短文本任务的实际差距 < 3%,模型尺寸只有 1/4
- 卡片数 > 10000 + 召回质量明显退化再升级,不预先付出复杂度
不做的边界(再次重申)
| 想做 | 为什么不做 |
|---|---|
| 自动出文 | 方向选择是人类工作,出文等于偷渡决策 |
| 替代 topic-inspiration | 时态不同,各司其职 |
| GetNote 全量镜像 | 双源漂移、维护负担、git 仓膨胀 |
| 跨机器 embedding 同步 | 引入复杂度,收益不明 |
| 自动判定文章风格 | 直接复用你自己的写作 Skill,不在本 skill 重写 |
published-articles 重叠检测(v1.3.0)
引入背景:cluster_inspect 在 v1.0-v1.2 只看簇内密度/voice_ratio/tags,不关心"该主题是否已被某篇已发表文章覆盖"。这导致即使簇就绪,推荐出稿也可能是重复劳动。
本机制把 topic-inspiration 的"对照已发布内容"核心信号下沉到 cluster_inspect。
算法
1. 预备:published 嵌入
embed_lite.py --include-published(或--published-only)对~/Dropbox/cn_articles_published/all/*.md做嵌入- 每篇取
标题 + 前 500 字正文(去 frontmatter)做输入 - 增量 hash-keyed,只重算变动的文章
- 存
_index/published_embeddings.npy+published_embeddings_meta.json
2. 簇 centroid
- 对每个 ready/near 簇,把成员卡的卡片嵌入做平均(L2 normalize 后)→ centroid 向量
3. Top-3 match + overlap_class
sims = published_vecs @ centroid # cosine (both L2-normalized)
top3 = argsort(sims)[::-1][:3]
max_sim = top3[0].sim
if max_sim >= 0.75: overlap_class = "covered"
elif max_sim >= 0.6: overlap_class = "adjacent"
else: overlap_class = "clean"4. 写入簇记录
{
"cluster_id": "...",
"published_overlap": {
"top": [{"title": "...", "sim": 0.78, "path": "..."}, ...],
"overlap_class": "covered",
"max_sim": 0.78
},
...
}阈值理由
BGE-small-zh-v1.5 在中文 STS-B 上的实测分布:
- 同义不同表述:0.75-0.90(同主题肯定 ≥0.75)
- 相邻话题:0.55-0.75(主题边界模糊带)
- 不相关:<0.5
经验阈值:
- 0.75 (covered):简单 sanity check — 已发文章里如果有 >0.75 相似度的,大概率是同一论点,推荐重写没意义
- 0.6 (adjacent):相邻但不重复,值得继续写但要在文章里明确"和 <旧文> 的差异"。0.6 偏宽松,目的是让用户意识到有相似过往,而不是阻止写作
- <0.6 (clean):可放心推荐
阈值后续可调:_state/overlap_thresholds.json(预留,默认值在脚本里写死)。
Mode 3 的使用协议
cluster_inspect 出 JSON 后,Mode 3 的 LLM 命题阶段:
1. 对每个新就绪簇,必读 published_overlap.top 三条 2. Telegram 摘要里强制标颜色(用 emoji 或 Markdown):
- 🔴
covered:"⚠️ 主题已被《<title>》覆盖(sim=0.XX),建议跳过或确认新角度" - 🟠
adjacent:"主题相邻《<title>》,需要在文章里说明差异" - 🟢
clean:"干净新主题,可推荐 /zettel write"
3. 用户回复 /zettel write <cluster-id> 时,Mode 4 把 overlap top-3 一并传给写作 Skill,让写作 Skill 在文章开头处理"和过往写作的关系"(自然衔接,不是免责声明)
限制和已知失效场景
- 嵌入捕捉的是语义相似度,不是"已经做过结论的相似度"。两篇文章可能讨论同主题但结论相反,嵌入会判 covered,但实际新结论值得写。这条要靠 LLM 命题阶段(Mode 3)做二次判断,不能光看 max_sim。
- published-articles 标题信息密度高,正文前 500 字采样可能不够。如果发现 covered 误报多,可调成"标题 + 全文"(代价是嵌入更慢、更费内存)。当前默认前 500 字是 GetNote 系列实测的甜点。
- *仅对 `~/Dropbox/cn_articles_published/all/.md
检查**。如果用户在公众号/星球有其他平台首发的文章,不在本目录则不会被去重。后续可扩展到 wikiconcepts/` 或显式配置多目录。
失败处理
| 现象 | 排查 |
|---|---|
所有簇的 published_overlap 都是 None | _index/published_embeddings.npy 未生成。跑 embed_lite.py --published-only |
| max_sim 集中在 0.5-0.6 区间 | published 嵌入用了不同的输入字段(比如纯标题)。检查 published_text_for_embed() 实现 |
| overlap_class 总是 covered | 阈值过严或嵌入输入太短。把 --published-dir 改成更广的语料,或调高 0.75 阈值到 0.8 |
| Dropbox 路径不存在 | embed_lite.refresh_published 返回 ok: False reason: published_dir_missing,不抛异常。cluster_inspect 此时 published_overlap=None,行为退化到 v1.2.0 |
mechanical scan (systemd 离线层)
职责边界
mechanical scan 由 zettel-sync.service(systemd user unit)hourly 触发。 做这些(纯 Python,无 LLM):
1. 增量发现 raw/ 新文件 2. 写 _queue/*.json 待办项 3. git pull --rebase + git push 双向同步 4. 更新 _state/last_scan.json
不做这些(需要 LLM):
- 不切卡
- 不写 literature note
- 不价值观重述
- 不写链接 reason
- 不出 cluster 报告
那些等 agent scan 来做。
触发链
wiki-sync.service (hourly, 每 hour 0 分 + 随机 0-120 秒)
↓ After=
zettel-sync.service (hourly, 等 wiki-sync 完成)
├─ scan_mechanical.py
└─ auto-git-sync.shAfter=wiki-sync.service 保证 raw/ 已刷新。 Wants=wiki-sync.service 保证 wiki-sync 失败时 zettel-sync 仍尝试(以防 wiki-sync 长期挂掉拖死 zettel-sync)。
scan_mechanical.py 步骤(v1.1.0 简化)
1. 读 _state/last_scan.json -> last_scan_at, scanned_files
2. 列出 raw/articles/*.md 和 raw/getnote/*.md 中 mtime > last_scan_at 的文件
3. 对每个新文件:
a. 读前 300 字作 preview
b. 简易中文 2-gram 分词 + 全仓 TF -> top-5 terms(仅作 queue 元数据)
c. 写 _queue/<ts>-<source-slug>.json
4. 更新 _state/last_scan.json (last_scan_at = now, scanned_files += new paths)v1.0 → v1.1.0 变更:不再在 mechanical scan 中预算 candidate_links。原因:链接候选现在基于 BGE 嵌入,而嵌入计算属于"agent 处理路径"(创建卡片时同步算),不该在 raw 入队阶段提前算(被引用文件未来未必入卡;算了也是浪费)。
embed_lite refresh 由 zettel-hourly-sync.sh Step 2 显式触发,在 build_index 之后、mechanical scan 之前,只刷新 cards/ 增量,与 raw/ 解耦。
git 同步由 zettel-hourly-sync.sh Step 5 处理(不在 scan_mechanical.py 里)。
TF-IDF 计算
不维护全量 TF-IDF 模型(避免 sklearn 重依赖)。 用简化版:
# 词频 = 文档内出现次数
# IDF = log(N / 包含该词的文档数 + 1)
# tag/entity 也参与索引,作为"高权重词"实现位置:scripts/_tfidf_lite.py(脚本内部依赖,不暴露给用户)。
索引文件
_index/tags.json:{tag: [card_id, card_id, ...]} _index/entities.json:{entity_name: [card_id, ...]} _index/term_inverted.json:{term: [card_id, ...]}(简化 TF-IDF 用) _index/links_graph.json:{card_id: {out: [...], in: [...]}}
scripts/build_index.py 重建这四个文件。被 mechanical scan 在卡数变化时调用。
游标所有权(关键)
两个游标,owner 严格分离,任何一方不得越界写另一方的文件:
| 游标文件 | owner | 内容 | 谁写 |
|---|---|---|---|
_state/last_scan.json | mechanical scan | raw/ 文件被本系统看见的截止时间 + 已记录文件清单 | 只有 scan_mechanical.py |
_state/processed/<date>.jsonl | agent scan | queue item 被 agent 消化记录 | 只有 agent scan(Mode 2) |
为什么必须分离: 若 agent scan 一次只处理 N=5 个 queue 项就推进 last_scan_at,则 mechanical scan 下次再跑时,会把"已经存在但还没被 agent 处理"的 raw 新文件当作"已 scan",永远不再入队。卡片增量丢失,且无任何报错信号。
实施保证:
scan_mechanical.py是唯一 import_state/last_scan.json写权限的代码- agent scan 处理 queue 项时,只删/移
_queue/*.json文件本身,以及追加_state/processed/<date>.jsonl - code review 时若发现 agent 路径下出现
last_scan.json写操作,视为 bug
错误处理
| 错误 | 处理 |
|---|---|
| raw/ 不存在 | 写 .sync.log warning,跳过,不阻塞 git sync |
| git pull --rebase 冲突 | abort rebase,写 .sync.log ERROR,发 Telegram 警告 |
| git push 被拒 | 重试一次,仍失败则写 ERROR,不强推 |
_queue/ 积压 > 50 | 写 warning,不阻塞 |
日志
所有 mechanical scan 输出追加到 ~/zettel/.sync.log(类似 wiki 的 .sync.log)。 每次开始/结束写一行带时间戳的分隔符,方便排查。
价值观重述协议
本协议在 ingest 和 scan 的 Step 3 被调用。
目的:把素材里的判断,转化成"经过我重新审视、带有我立场"的判断。
不是同义词替换
错误执行:把"卢曼说,如果一句话你不能用自己的话解释一遍,说明你并没有真正理解它"改成"卢曼指出,无法用自己的语言复述的内容,意味着尚未真正掌握"。这是更慢的复制,不是重述。
正确执行的四步
Step A: 读你的价值观底色
cat references/voice-snapshot.md
(本仓库内置 references/voice-snapshot.md 模板,首次启用时请按模板填入你自己的价值观/文风条款。)
读完后,在头脑里持有你最核心的几条立场——例如:
- 以人为本(读者得到具体可用的东西)
- 证据驱动(不接受"大家都说…")
- 来源诚实(谁说的、在哪本书/篇文章)
- 反对 AI 痕迹(空话套话、过度对仗、过度排比)
具体条款以你 voice-snapshot.md 中填写的为准。
Step B: 对原素材的判断做三问
对每个待切卡的判断,问:
1. 冲突:这个判断和我已知的什么冲突?(若全无冲突,要么是常识不值得入卡,要么是没认真想) 2. 边界:这个判断在什么条件下成立?反例是什么? 3. 态度:我同意/部分同意/不同意。必须选边,不能写"这是一种值得思考的观点"。
把三问的答案写进卡的正文末段。
Step B.3: 来源类型门禁(v1.11.0)
bootstrap 阶段(mode.json 显示 bootstrap):source.type 只接受 wiki-getnote 或 wiki-article。其他类型(chat / external-url / user-paste / deep-research)不入 bootstrap queue。
为什么:冷启动期要建立的是用户既有素材的网络(GetNote 多年积累 + 已发表文章),而不是即时对话或外部资源。对话/外部源走 Mode 1 手动 ingest 单独处理,有意识地放进网络。
steady 态:可放宽,接受所有 source 类型。
chat 源例外:用户在 Mode 1 手动 /zettel ingest <text> 时,允许直接切卡(走 chat source)。但 Mode 2 (scan / bootstrap) 永远不应该产生 chat-source 卡。
Step B.2: wiki-article 源的多卡切分(v1.13.0,关键)
长文不要硬压成 1 张卡。这是切卡协议的根本——一张卡承载一个独立判断,一篇 3000-5000 字的文章通常含 5-10 个独立判断,每个都该单独成卡。
判读流程(wiki-article 必走):
1. 通读全文(不是看摘要):识别所有 atomic 判断 — 即满足"X 不是 Y 而是 Z" / "X 的真正原因是 ..." / "X 和 Y 的边界是 ..." / "X 表面 ... 实际 ..." 模式的句子。允许 10-20 个候选,宁多勿少。 2. 每个候选独立评估:
- 是不是 atomic(单一判断,不依赖其他判断才能成立)?
- 是不是 timeless(剥离具体型号词后仍成立)? — 时效性的剔除(走 Step B.4)
- 跟已有卡是否重复(走 dedup 协议)?
3. 批量切卡:通过筛选的每个候选,单独成卡。一篇文章典型应切:
- 短文(< 1500 字):2-3 张
- 中长文(1500-3500 字):3-6 张
- 长文(3500-6000 字):6-10 张
- 巨长文(> 6000 字):10-15 张
硬条款:如果一篇 wiki-article(> 1500 字)只产出 1 张卡,必须在 processed/<date>.jsonl 写明理由,例如:
- "通篇只一个独立判断,其他全是引言/装饰/案例堆叠"
- "其他 N 个候选判断全部 dedup-skip(已在卡 X / Y / Z)"
- "其他候选全是时效性内容,抽象化后失去信息量"
没有理由就是 anti-pattern,等同于信息压缩失败。
反例(v1.13.0 之前的实际情况,82 篇文章只产 83 张卡):错。
Step B.4: 时效性判定 + 抽象化(v1.10.0,关键)
判定:source 创建时间(source.created_at)距今多久?
- <= 12 个月:正常切卡,无过滤
- > 12 个月(老素材):切卡协议变更 — 只切穿越时间的原理/规则/方法论判断,不切绑定具体型号/工具/价格/版本的判断
为什么:AI 领域迭代极快,2-3 年前的"GPT-4 写宋词好"、"Claude 3 处理长文档强"这类具体能力对比早就过时;若照搬入卡,网络里全是"几年前如此"的死信息,稀释簇密度,误导未来选题。但老素材里的原理性判断(如"用户验证模型能力要看古典文体"、"工作流瓶颈正从写代码移向验收"、"模型订阅价值要按注意力回报评估")仍然穿越时间,这些才是要切的。
两类判断的区分:
| 维度 | 穿越时间(切卡) | 时效性强(不切或抽象化) |
|---|---|---|
| 例 | "工作流瓶颈在迁移" | "GPT-4 写代码很快" |
| 例 | "极快模型把重心推向验收" | "Antigravity 2.0 五分钟做完贪吃蛇" |
| 例 | "AI 工具选择要看可控性和依赖" | "Cursor 的 .cursorrules 怎么配" |
| 检验 | 把具体型号词替换为抽象类,判断是否仍成立 + 有信息量 | 替换后空洞或失去意义 |
抽象化技巧(若原素材判断有时效性外壳但内核穿越时间):
- "GPT-4 能写代码" → 提取 "高能力模型在哪些任务上扩展了开发者的产出方式"(抽象层)
- "DeepSeek R1 开源对中国 AI 市场冲击" → 提取 "开源顶尖模型对市场动态的结构性影响"(抽象层)
- 抽象化后的判断要能在 5 年后读仍有信息量,才入卡
绝不切的内容(老素材尤其要警惕):
- 具体型号上手体验("XX 模型试用感受")
- 当时价格对比("$200 的 Pro 值不值")
- 当时功能差异列表("Claude vs GPT 哪个长文好")
- 当时市场格局快照("Google 这波出牌势头强")
如果老素材通篇都是上述时效性内容,整篇 0 张卡 — 这不是损失,因为这些信息对未来已无用。
Step B.5: 素材类型判定 + 过滤(v1.8.0,关键)
判定:source 是不是 "session-review / 复盘 / 反思" 类素材?
判定方式(任一命中):
- raw 文件 frontmatter
tags:含:经验复盘 / session-review / session_review / 复盘 / Session 回顾 / 反思 - 文件名含:
session 经验复盘/复盘/session-retro - 正文以"## Context / ## Result / ## Session 回顾"结构开头
命中则按 session-review 过滤切卡:
只切以下判断类型(用户层):
- 用户的需求:用户对工具 / 工作流 / 产物的明确要求(例:卡片要 self-contained / 不要每次问我 / Telegram 要原生发)
- 用户的价值观:用户判断好坏的尺度(例:嵌入只是辅助,关联必须 agent 判;扎堆是 anti-pattern;已发文章是种子不是终态)
- 用户的偏好:用户的工作习惯(例:配额充足别克制;直接开干别问;后退等待;按规矩走)
- 用户的纠正:用户对 agent 的修正(例:不要擅自添加副标题;别只看 sim 数值;先停 runner 再改协议)
- 用户的边界:用户明确不要 / 不接受的(例:技术细节不重要;读者视角已被覆盖的不再写)
绝不切以下内容(技术层,用户已明确不再需要):
- 技术问题描述(parser 解析 bug / pytest 失败 / API 429)
- 解决方案与实现(改 _common.py / 加 fallback path / 写 systemd unit)
- 工具或框架内部细节(openclaw cron 字段 / fastembed 模型加载)
- Codex 校验的具体 FAIL / 修复轨迹
- Skill 内部架构、文件结构、守护进程逻辑
- 排错过程("我以为 X 实际是 Y" 层面)
边界提示:
- "用户提了什么 / 纠正了什么" → 价值层(切)
- "我们怎么实现 / 怎么排错" → 技术层(不切)
- "我们设计了什么规则" → 取决于规则本身:
- "卡片必须含原文要点" → 价值规则(切)
- "用 sha1 over full_bytes 做 hash" → 技术规则(不切)
session-review 类素材常常一篇切 0-3 张卡(只挑用户层判断);技术层的描述忽略。这不是丢失信息——技术细节给 future agent 查资料用,卡片网络是给人(用户)用的。
Step C: 重写正文(v1.4.0 self-contained 版)
正文结构:
# <判断标题>
[导语:1-2 句陈述本卡的核心判断,即标题的展开]
## 原文要点
> [直接搬运原文 200-500 字,含具体数据、人物、案例、引文]
> [如原素材有图,搬到 assets/<card-id>/ 并  引用]
## 我的判断
[第一段:接住原文判断,概括它在说什么]
[第二段——关键]
"我看这条的态度是…":同意/不同意/部分同意 + 边界 + 反例。
这一段才是"经过我重新审视"的证据。两条硬性必要条件(缺一不可,否则 voice_passed: false):
1. `## 原文要点` 块完整搬运:不是 30-100 字摘要,是真正足以支撑本卡判断的 200-500 字。包含原素材的具体证据。读者脱离 source.ref 也能完整读懂。 2. `## 我的判断` 末段必须选边:同意/不同意/部分同意 + 边界。
反模式(v1.4.0 起拒收):
- "原素材只有一句短记" → 这是元 commentary 不是内容
- "如某 GetNote 所述" → 让读者翻原文,违反 self-contained
- 只放
excerpt30 字然后大段抽象 → 读者无法验证你抽象得对不对
Step C.1: 图片搬运(v1.4.0 新增)
如果 source 是 raw markdown 且含 :
1. 运行 python3 scripts/migrate_images.py --card-id <id> --source <raw-path>(相对于本 skill 目录) 2. 脚本会:扫源文件的 image refs → 复制到 ~/zettel/assets/<card-id>/ → 输出路径 mapping 3. 在本卡的 ## 原文要点 节里用新路径  引用关键图(不必所有图都搬,只搬支撑本卡判断的)
不搬图但卡里依赖图说话 = self-contained 失败。
Step D: AI 痕迹自检
剔除以下短语(与你的 voice-snapshot.md 中的"禁用短语清单"一致;此处给出常见示例,以你填写的为准):
- "深入剖析"、"全方位"、"多维度"
- "首先… 其次… 最后…"(除非真的是三步骤工序)
- "不仅… 而且…"(过度对仗)
- "让我们一起…"、"我们将看到…"
- "总而言之"、"综上所述"
- 排比三连(连续三个"…的…")
- 任何带"赋能"、"全链路"、"闭环"的话术
发现就改写,改不掉就删掉那段。
引述他人时的硬规则
若卡的核心判断来自他人(书、论文、博客、采访):
1. source.ref 必填(书名 + 章节 / 文章 + URL / 视频 + 时间戳) 2. 正文里必须有"我同意/不同意/部分同意"的表态 3. 若只是转述无加工,不能入 permanent;只能进 literature 4. 不接受"研究表明"、"专家指出"等模糊主语 — 找不到具体出处就别引
与原素材保持距离
重述完成后,把卡的正文和原素材并排,问:
- 如果我把这张卡发给原作者,他会不会觉得"你只是把我的话换了说法"?
- 如果会,重述不合格,回到 Step B 重做。
合格的重述应该让原作者觉得"你接住了我的判断,但你站在自己的位置上回应了它"。
价值观与文风快照(模板)
这是模板文件。zettel-builder 假设你已有一个写作风格 Skill(或个人写作守则)。
把你自己的"价值观底色 + 文风硬规则 + 重述禁用短语"按下方结构填入。
没有的话,可以先用一个"简版"——只写 3 条价值观 + 5 条文风硬规则即可启动 Mode 1。
---
第一部分:价值观底色(来自你的 values.md)
核心立场
列出 3-7 条你的核心立场。每条一句话陈述 + 简短解释。
这些是"价值观重述"时你要表达的判断方向。
1. (示例)认知主权不可让渡
AI 可以提建议、做初稿、查资料,但价值判断和核心逻辑连接必须由人完成。
- 写作时不要让 AI 代替读者思考,要引导读者自己得出结论
- 文章要留出「思考的空间」,而非填鸭式灌输
2. (示例)深度优于广度
宁可少讲一个概念讲透,也不要泛泛罗列十个概念。
- 一篇文章只解决一个核心问题
- 读者读完应该能复述逻辑链条,而非只记得几个名词
3. (在此添加你自己的第 3-N 条立场)
…
隐性立场
你看到某个话题时,会自然站在哪一方?对哪种修辞反感?
写下来——重述时如果原文与你站位相反,重述要把这点表达出来。
故事来源约束(建议保留为硬规则)
故事性叙述必须有据可查,严禁凭空捏造。
| 故事来源 | 可用性 |
|---|---|
| 素材中有 | ✅ 直接用 |
| 外部可验证 | ✅ 搜索后用 |
| 个人真实经历 | ✅ 可用(需是真实场景) |
| 凭空编造 | ❌ 禁止 |
边界意识(诚实承认无法回答)
你不确定的问题、还没想清楚的争议——列在这里。重述时遇到,宁可提问也不强答。
---
第二部分:文风硬规则(来自你的 style-guide.md)
下面是 zettel-builder 价值观重述时最常踩坑的几类信号。
建议至少保留这 6 大类,具体词表换成你自己的红线词。
1. 零列表原则(按需调整)
除非是代码块、绝对线性的操作步骤、或链接后记,严禁列表。
症状:「有三个好处:1. 2. 3. ……」
治疗:用因果逻辑(「正因为……所以……」)、递进关系(「这还不够,更绝的是……」)把信息点溶进段落里。
如果你的写作风格反而拥抱列表,把本节改成你的实际偏好即可。
2. 「装腔」/「爹味」短语禁令
列出你最反感的"教师腔/编辑腔"短语。示例:
严禁使用:「综上所述」「我们必须注意到」「本文旨在」「值得注意的是」。
3. 粗俗词汇禁令
列出你不愿意出现的粗俗词 + 雅化替代表。示例:
| 粗俗 | 替代 |
|---|---|
| 脱裤子放屁 | 多此一举 / 画蛇添足 |
| 骚操作 | 妙招 / 巧妙的做法 |
4. AI 辅助痕迹清零(建议保留)
严禁出现暗示 AI 辅助写作的内容。读者读到的应当是「作者亲笔」。
禁止:
- 工具名直接出现(「我用 X 工具来写作」)
- 流程暗示(「经过素材消化阶段」「在形态生长过程中」)
- 技术术语泄露(「超富集查询」「断点续写」)
- 工作流描述(「我让 AI 先整理素材」)
如果你的写作场景不需要这条(如内部研究笔记),可以删除整节。
5. AI 文风信号清除(建议保留)
| 信号 | AI 写法 | 人的写法 |
|---|---|---|
| 精确整数时间 | 「72 小时内」「48 小时后」 | 「没几天」「大半个月」 |
| 对称排比过度 | 三段完全平行的句式 | 前两段平行,第三段打破节奏 |
| 万能连接词 | 「值得注意的是」「事实上」 | 直接说事,不加帽子 |
| 总结性排列 | 「第一…第二…第三…」 | 用因果/转折串联 |
核心判断:写完一段后念给朋友听。你会这么说话吗?不会 → 改。
6. 去绝对化(可信度策略)
绝对化/夸张表述会降低读者信任。
| ❌ 绝对化 | ✅ 有分寸 |
|---|---|
| 修一次管一辈子 | 修一次长期有效 |
| 这个改进是永久的 | 这个改进是持久的 |
| 省下十倍的时间 | 省下可观的时间 |
去绝对化 ≠ 去详细化:具体数据有真实来源应保留。
---
第三部分:zettel 重述时的禁用短语清单(整合)
把第二部分中"一出现就要改"的短语汇总到这里,方便 Mode 1 Step 3 一次扫完。
zettel 卡片的 ## 我的判断 节,严禁出现以下短语(请按需替换):
- 「深入剖析」「全方位」「多维度」
- 「首先… 其次… 最后…」(除非真的是三步骤工序)
- 「不仅… 而且…」(过度对仗)
- 「让我们一起…」「我们将看到…」
- 「总而言之」「综上所述」「值得注意的是」「事实上」「有趣的是」
- 排比三连(连续三个「…的…」)
- 「赋能」「全链路」「闭环」
- 「我们必须注意到」「本文旨在」
发现 → 改写;改不掉 → 删那段。
---
变更日志
- YYYY-MM-DD:初版。从你的写作 Skill values.md + style-guide.md 抽取与"价值观重述"语境直接相关的条款。
"""Shared helpers for zettel-builder scripts. Stdlib only."""
from __future__ import annotations
import json
import re
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
DEFAULT_ZETTEL_ROOT = Path(os.environ.get("ZETTEL_ROOT", Path.home() / "zettel"))
# v1.17.0 (2026-05-21): raw/ 现在直接在 zettel 仓内维护(hourly_pulse 拉取),
# 不再依赖 ~/wiki/published-articles/ 中转。
# 名字保留为 WIKI_ROOT 是历史兼容(scan_mechanical.py / backfill_source_timestamp.py 引用),
# 默认值改为 zettel_root,语义变为"raw 的父目录"。
DEFAULT_WIKI_ROOT = Path(os.environ.get("WIKI_ROOT", DEFAULT_ZETTEL_ROOT))
def now_iso() -> str:
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
def read_json(path: Path, default: Any = None) -> Any:
if not path.exists():
return default
try:
return json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return default
def write_json(path: Path, data: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(path)
FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n(.*)$", re.DOTALL)
def parse_frontmatter(text: str) -> tuple[dict, str]:
"""Return (frontmatter_dict, body). Naive YAML — supports the subset we use.
Avoids PyYAML dependency. Only the schema in card-format.md is supported.
"""
m = FRONTMATTER_RE.match(text)
if not m:
return {}, text
fm_raw, body = m.group(1), m.group(2)
fm: dict[str, Any] = {}
current_key = None
current_list: list | None = None
current_obj: dict | None = None
obj_key: str | None = None
for raw_line in fm_raw.splitlines():
if not raw_line.strip():
continue
if not raw_line.startswith(" "):
current_list = None
current_obj = None
obj_key = None
if ":" not in raw_line:
continue
key, _, val = raw_line.partition(":")
key = key.strip()
val = val.strip()
if val == "":
# Defer container type: don't assign fm[key] yet. The first
# indented child line decides — '- ' makes it list, 'k: v' makes it dict.
if key in fm:
del fm[key]
current_key = key
current_list = None
current_obj = None
obj_key = key
elif val.startswith("[") and val.endswith("]"):
inner = val[1:-1].strip()
fm[key] = [x.strip() for x in inner.split(",") if x.strip()] if inner else []
current_key = None
else:
fm[key] = _coerce(val)
current_key = None
else:
stripped = raw_line.strip()
if stripped.startswith("- "):
# List item: lazily make fm[current_key] a list
if current_key:
if current_key not in fm or not isinstance(fm.get(current_key), list):
fm[current_key] = []
item_body = stripped[2:].strip()
if ":" in item_body:
new_item: dict[str, Any] = {}
k, _, v = item_body.partition(":")
new_item[k.strip()] = _coerce(v.strip())
if current_key:
fm[current_key].append(new_item)
current_obj = new_item
else:
if current_key:
fm[current_key].append(_coerce(item_body))
elif ":" in stripped and current_key:
# Indented k:v line under a parent.
# If we're inside a list (last '- ' seen), populate the last dict.
# Else, this is a nested dict — lazily make fm[current_key] a dict.
k, _, v = stripped.partition(":")
if isinstance(fm.get(current_key), list) and current_obj is not None:
current_obj[k.strip()] = _coerce(v.strip())
else:
if not isinstance(fm.get(current_key), dict):
fm[current_key] = {}
fm[current_key][k.strip()] = _coerce(v.strip())
current_obj = fm[current_key]
return fm, body
def _coerce(val: str) -> Any:
if val == "":
return ""
if val.lower() == "true":
return True
if val.lower() == "false":
return False
if val.lower() in ("null", "~"):
return None
if val.startswith('"') and val.endswith('"'):
return val[1:-1]
if val.startswith("'") and val.endswith("'"):
return val[1:-1]
if re.match(r"^-?\d+$", val):
return int(val)
if re.match(r"^-?\d+\.\d+$", val):
return float(val)
return val
def tokens_cn_en(text: str) -> list[str]:
"""Mixed Chinese/English tokenizer.
- English: whitespace + lower
- Chinese: 2-grams over consecutive CJK runs
- Numbers kept as tokens
"""
text = text.lower()
out: list[str] = []
# Strip markdown noise
text = re.sub(r"[`*_#>\[\]()]", " ", text)
for chunk in re.findall(r"[A-Za-z0-9]+|[一-鿿]+", text):
if chunk[0].isascii():
if len(chunk) >= 2:
out.append(chunk)
else:
if len(chunk) == 1:
continue # skip 1-char CJK (too noisy)
for i in range(len(chunk) - 1):
out.append(chunk[i:i + 2])
return out
def load_cards(cards_dir: Path) -> list[dict]:
"""Return list of card dicts: {id, path, frontmatter, body}."""
cards = []
if not cards_dir.exists():
return cards
for fp in sorted(cards_dir.glob("*.md")):
try:
text = fp.read_text(encoding="utf-8")
except OSError:
continue
fm, body = parse_frontmatter(text)
if not fm.get("id"):
continue
cards.append({"id": fm["id"], "path": str(fp), "fm": fm, "body": body})
return cards
def log_line(log_path: Path, line: str) -> None:
log_path.parent.mkdir(parents=True, exist_ok=True)
with log_path.open("a", encoding="utf-8") as f:
f.write(f"[{now_iso()}] {line}\n")
#!/usr/bin/env bash
# bootstrap_runner.sh — Persistent loop that drives Oven through the entire raw
# queue until empty, then auto-switches the system to steady state.
#
# Architecture:
# - Triggers `openclaw cron run zettel-bootstrap-hourly` and waits for the run
# to land in ~/.openclaw/cron/runs/<id>.jsonl as finished/failed.
# - On success: backoff resets to 30s, next iteration triggers immediately.
# - On rate-limit/quota/timeout: exponential backoff (60 → 120 → 240 → 480 → 960s),
# capped at 1920s (32 min). Backoff resets after one success.
# - When queue is empty for 2 consecutive checks (~5 min idle), runs cleanup:
# disable bootstrap-hourly cron, enable event-driven-scan, flip mode.json to
# steady, send Telegram completion notice, exit 0.
#
# Run as: systemctl --user start zettel-bootstrap-runner.service
# Or directly: bash zettel-bootstrap-runner.service
set -euo pipefail
ZETTEL_ROOT="${ZETTEL_ROOT:-$HOME/zettel}"
LOG="$ZETTEL_ROOT/.bootstrap.log"
exec > >(tee -a "$LOG") 2>&1
trigger_telegram() {
local msg="$1"
if [[ -f "$HOME/.config/secrets.zsh" ]]; then
eval "$(grep '^export ' "$HOME/.config/secrets.zsh")"
curl -s -X POST "https://api.telegram.org/bot${SIMPSON_BOT_TOKEN}/sendMessage" \
-d "chat_id=${TELEGRAM_CHAT_ID}" -d "text=$msg" > /dev/null || true
fi
}
resolve_job_id() {
local name="$1"
python3 -c "
import json
d = json.load(open('$HOME/.openclaw/cron/jobs.json'))
for j in d['jobs']:
if j.get('name') == '$name':
print(j['id']); break
" 2>/dev/null
}
BOOT_ID=$(resolve_job_id "zettel-bootstrap-hourly")
EVENT_ID=$(resolve_job_id "zettel-event-driven-scan")
if [[ -z "$BOOT_ID" ]]; then
echo "FATAL: zettel-bootstrap-hourly job not found"
trigger_telegram "❌ bootstrap_runner 启动失败:zettel-bootstrap-hourly cron job 找不到"
exit 1
fi
echo "===== $(date '+%Y-%m-%d %H:%M:%S') bootstrap_runner start (boot=$BOOT_ID) ====="
backoff=30
consec_empty_checks=0
cards_at_start=$(ls "$ZETTEL_ROOT/cards"/*.md 2>/dev/null | wc -l)
trigger_telegram "🚀 bootstrap_runner 启动。当前卡数 $cards_at_start。开始消化全量库存。每跑完 100 张推一次进度。"
total_cards_added=0
last_telegram_milestone=0
while true; do
queue_size=$(ls "$ZETTEL_ROOT/_queue"/*.json 2>/dev/null | wc -l)
cards_now=$(ls "$ZETTEL_ROOT/cards"/*.md 2>/dev/null | wc -l)
total_cards_added=$((cards_now - cards_at_start))
echo "[$(date '+%H:%M:%S')] queue=$queue_size cards=$cards_now added_since_start=$total_cards_added backoff=$backoff"
# Empty-queue exit condition: 3 consecutive empty checks = 2 × 150s waits = ~5 min idle
# (Codex caught: >=2 only gave 1 wait = ~2.5 min, too eager)
if [[ "$queue_size" -eq 0 ]]; then
consec_empty_checks=$((consec_empty_checks + 1))
if [[ "$consec_empty_checks" -ge 3 ]]; then
echo "queue empty for ${consec_empty_checks} consecutive checks (~5 min); switching to steady state"
# Cron flips first — if either fails, abort the switch and retry next loop
# (Codex caught: don't flip mode.json + send Telegram if cron state didn't actually change)
if ! openclaw cron disable "$BOOT_ID" 2>&1 | tail -1; then
echo "ERROR: disable bootstrap-hourly failed; will retry next loop"
consec_empty_checks=0
sleep 60
continue
fi
if [[ -n "$EVENT_ID" ]]; then
if ! openclaw cron enable "$EVENT_ID" 2>&1 | tail -1; then
echo "ERROR: enable event-driven failed; re-enabling bootstrap and retrying"
openclaw cron enable "$BOOT_ID" 2>&1 | tail -1 || true
consec_empty_checks=0
sleep 60
continue
fi
fi
# Now flip mode.json
python3 -c "
import json, datetime
p = '$ZETTEL_ROOT/_state/mode.json'
d = json.load(open(p))
d['mode'] = 'steady'
d['switched_at'] = datetime.datetime.now(datetime.timezone.utc).astimezone().isoformat(timespec='seconds')
d['switched_by'] = 'bootstrap_runner: queue drained'
d['cards_at_switch'] = $cards_now
d['cards_added_during_bootstrap'] = $total_cards_added
json.dump(d, open(p,'w'), ensure_ascii=False, indent=2)
"
# Commit + push (failures here are non-fatal — git can be retried)
cd "$ZETTEL_ROOT"
git add -A && git commit -m "chore: bootstrap complete, switched to steady" 2>&1 | tail -2 || true
git pull --rebase origin main 2>&1 | tail -1 || true
git push origin main 2>&1 | tail -1 || true
trigger_telegram "✅ bootstrap 完成!网络已建立。本轮共生成 ${total_cards_added} 张新卡,总卡数 ${cards_now}。已切到 steady 态:bootstrap-hourly cron 已 disabled,event-driven 已 enabled。后续走 wiki-sync 触发的事件驱动模式。"
echo "===== bootstrap_runner exit success ====="
exit 0
fi
echo "queue empty (${consec_empty_checks}/3), sleeping 150s before recheck"
sleep 150
continue
fi
consec_empty_checks=0
# Trigger one Oven run via openclaw cron run
run_output=$(openclaw cron run "$BOOT_ID" 2>&1 || true)
run_id=$(echo "$run_output" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('runId',''))" 2>/dev/null || true)
if [[ -z "$run_id" ]]; then
# Could be "already-running" or other transient
reason=$(echo "$run_output" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('reason',''))" 2>/dev/null || true)
if [[ "$reason" == "already-running" ]]; then
echo "openclaw says already-running, waiting 60s and retry"
sleep 60
continue
fi
echo "ERROR: openclaw cron run failed: $run_output"
echo "backoff ${backoff}s and retry"
sleep "$backoff"
backoff=$((backoff * 2))
if [[ "$backoff" -gt 1920 ]]; then backoff=1920; fi
continue
fi
echo "triggered run $run_id"
# Wait for this specific run to finish (poll jsonl every 15s, timeout 30min)
jsonl="$HOME/.openclaw/cron/runs/$BOOT_ID.jsonl"
waited=0
while [[ "$waited" -lt 1800 ]]; do
sleep 15
waited=$((waited + 15))
if [[ -f "$jsonl" ]]; then
last_line=$(tail -n 1 "$jsonl")
this_run_id=$(echo "$last_line" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('runId',''))" 2>/dev/null || true)
action=$(echo "$last_line" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('action',''))" 2>/dev/null || true)
status=$(echo "$last_line" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('status',''))" 2>/dev/null || true)
if [[ "$this_run_id" == "$run_id" ]] && [[ "$action" == "finished" ]]; then
echo "run $run_id done: status=$status"
if [[ "$status" == "ok" ]]; then
backoff=30
else
error=$(echo "$last_line" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('error','')[:200])" 2>/dev/null || true)
echo "run failed: $error"
# Codex noted: OpenClaw's exact quota error string isn't documented.
# Conservative — any non-ok status triggers exponential backoff;
# known quota-like strings get longer back-off explicitly.
if echo "$error" | grep -qiE "quota|rate.?limit|429|too.?many|exceeded|throttle"; then
echo "quota/rate-like error, exponential backoff ${backoff}s"
sleep "$backoff"
backoff=$((backoff * 2))
if [[ "$backoff" -gt 1920 ]]; then backoff=1920; fi
else
echo "other error, baseline backoff 60s (backoff state preserved)"
sleep 60
fi
fi
break
fi
fi
done
if [[ "$waited" -ge 1800 ]]; then
echo "WARN: run $run_id did not finish within 30 min, moving on"
fi
# Telegram milestone: every 100 cards added
milestone=$((total_cards_added / 100))
if [[ "$milestone" -gt "$last_telegram_milestone" ]] && [[ "$total_cards_added" -gt 0 ]]; then
trigger_telegram "📊 bootstrap 进度:已新增 ${total_cards_added} 张卡,_queue 剩余 ${queue_size} 项。"
last_telegram_milestone=$milestone
fi
done
#!/usr/bin/env python3
"""Rebuild ~/zettel/_index/{tags,entities,term_inverted,links_graph}.json from cards/."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from _common import DEFAULT_ZETTEL_ROOT, load_cards, log_line, tokens_cn_en, write_json # noqa: E402
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--zettel-root", type=Path, default=DEFAULT_ZETTEL_ROOT)
args = parser.parse_args()
zroot: Path = args.zettel_root
log_path = zroot / ".sync.log"
log_line(log_path, "===== build_index start =====")
cards = load_cards(zroot / "cards")
tags_idx: dict[str, list[str]] = {}
entities_idx: dict[str, list[str]] = {}
term_inv: dict[str, list[str]] = {}
links_graph: dict[str, dict] = {}
for c in cards:
cid = c["id"]
fm = c["fm"]
body = c["body"]
for tag in fm.get("tags", []) or []:
tags_idx.setdefault(str(tag), []).append(cid)
for ent in fm.get("entities", []) or []:
entities_idx.setdefault(str(ent), []).append(cid)
# term inverted index (lite tf-idf input)
seen_terms = set()
for tok in tokens_cn_en(body):
if tok in seen_terms:
continue
seen_terms.add(tok)
term_inv.setdefault(tok, []).append(cid)
# links graph
node = links_graph.setdefault(cid, {"out": [], "in": []})
for link in fm.get("links", []) or []:
if isinstance(link, dict) and link.get("id"):
target = str(link["id"])
node["out"].append({"id": target, "reason": link.get("reason", "")})
links_graph.setdefault(target, {"out": [], "in": []})["in"].append({"id": cid, "reason": link.get("reason", "")})
idx_dir = zroot / "_index"
write_json(idx_dir / "tags.json", tags_idx)
write_json(idx_dir / "entities.json", entities_idx)
write_json(idx_dir / "term_inverted.json", term_inv)
write_json(idx_dir / "links_graph.json", links_graph)
log_line(log_path, f"index built: cards={len(cards)} tags={len(tags_idx)} entities={len(entities_idx)} terms={len(term_inv)}")
log_line(log_path, "===== build_index done =====")
print(f"index: cards={len(cards)} tags={len(tags_idx)} entities={len(entities_idx)} terms={len(term_inv)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""v1.15.0 experimental: multi-view cluster detection.
Four parallel candidate views:
1. link-cluster — explicit [[]] dense subgraph (existing, relaxed thresholds)
2. tag-cluster — cards sharing the same tag (≥ N members)
3. entity-cluster — cards sharing the same entity (≥ N members)
4. embedding-cluster — k-NN graph on BGE vectors, connected components (loose semantic neighborhood)
Outputs _state/multi_view_candidates.json for review without touching the
production pending_articles.json (run with --apply to also overwrite that).
"""
from __future__ import annotations
import argparse
import hashlib
import json
import sys
from collections import defaultdict
from pathlib import Path
sys.path.append(str(Path(__file__).parent))
from _common import DEFAULT_ZETTEL_ROOT, load_cards, now_iso, read_json, write_json # noqa: E402
def _connected_components(adj: dict[str, set[str]], node_set: set[str] | None = None) -> list[set[str]]:
seen = set()
out = []
pool = node_set if node_set is not None else set(adj.keys())
for node in pool:
if node in seen:
continue
comp = set()
stack = [node]
while stack:
cur = stack.pop()
if cur in seen:
continue
seen.add(cur)
if cur not in pool:
continue
comp.add(cur)
for nb in adj.get(cur, set()):
if nb not in seen and nb in pool:
stack.append(nb)
if comp:
out.append(comp)
return out
def _density(nodes: set[str], adj: dict[str, set[str]]) -> float:
n = len(nodes)
if n < 2:
return 0.0
edges = sum(len(adj.get(m, set()) & nodes) for m in nodes) // 2
return edges / (n * (n - 1) / 2)
def _build_link_adj(cards: list[dict]) -> dict[str, set[str]]:
perm_ids = {c["id"] for c in cards if c["fm"].get("status") == "permanent"}
adj = {cid: set() for cid in perm_ids}
for c in cards:
if c["id"] not in perm_ids:
continue
for link in c["fm"].get("links", []) or []:
tgt = link.get("id") if isinstance(link, dict) else None
if tgt and tgt in perm_ids:
adj[c["id"]].add(tgt)
adj[tgt].add(c["id"])
return adj
def _link_clusters(adj: dict[str, set[str]], min_size: int = 3, min_density: float = 0.3) -> list[dict]:
out = []
for comp in _connected_components(adj):
if len(comp) < min_size:
continue
d = _density(comp, adj)
if d >= min_density:
out.append({"view": "link", "members": sorted(comp), "size": len(comp), "density": round(d, 3)})
return out
def _tag_clusters(cards: list[dict], by_id: dict, min_size: int = 3,
max_size: int = 30) -> list[dict]:
"""Filter to focused topical tags (3-30 members). >30 = macro tag (too generic)."""
tag_idx = defaultdict(set)
for c in cards:
if c["fm"].get("status") != "permanent":
continue
for t in c["fm"].get("tags", []) or []:
tag_idx[str(t)].add(c["id"])
out = []
for tag, members in tag_idx.items():
if not (min_size <= len(members) <= max_size):
continue
out.append({
"view": "tag",
"tag": tag,
"members": sorted(members),
"size": len(members),
})
return out
def _entity_clusters(cards: list[dict], by_id: dict, min_size: int = 3,
max_size: int = 30) -> list[dict]:
ent_idx = defaultdict(set)
for c in cards:
if c["fm"].get("status") != "permanent":
continue
for e in c["fm"].get("entities", []) or []:
ent_idx[str(e)].add(c["id"])
out = []
for ent, members in ent_idx.items():
if not (min_size <= len(members) <= max_size):
continue
out.append({
"view": "entity",
"entity": ent,
"members": sorted(members),
"size": len(members),
})
return out
def _embedding_clusters(zroot: Path, by_id: dict,
k: int = 3, sim_threshold: float = 0.78,
min_size: int = 3, max_size: int = 30) -> list[dict]:
try:
from embed_lite import load_cache
except ImportError:
return []
ids, vecs = load_cache(zroot)
if not ids or vecs is None or vecs.shape[0] < min_size:
return []
import numpy as np
# Filter to permanent cards only
perm_mask = []
for cid in ids:
c = by_id.get(cid)
perm_mask.append(c is not None and c["fm"].get("status") == "permanent")
perm_mask = np.array(perm_mask)
perm_ids = [ids[i] for i, m in enumerate(perm_mask) if m]
perm_vecs = vecs[perm_mask]
if perm_vecs.shape[0] < min_size:
return []
# k-NN graph: for each card, top-k cosine neighbors with sim ≥ threshold
sim_mtx = perm_vecs @ perm_vecs.T # cosine since BGE-normalized
adj: dict[str, set[str]] = {cid: set() for cid in perm_ids}
for i, cid in enumerate(perm_ids):
# exclude self, sort by sim descending
order = sim_mtx[i].argsort()[::-1]
added = 0
for j in order:
if j == i:
continue
if sim_mtx[i][j] < sim_threshold:
break
adj[cid].add(perm_ids[j])
adj[perm_ids[j]].add(cid) # undirected
added += 1
if added >= k:
break
out = []
for comp in _connected_components(adj):
if not (min_size <= len(comp) <= max_size):
continue
d = _density(comp, adj)
out.append({
"view": "embedding",
"members": sorted(comp),
"size": len(comp),
"density": round(d, 3),
})
return out
def _annotate(cluster: dict, by_id: dict) -> dict:
members = cluster["members"]
n = len(members)
# core tags
tag_counts = defaultdict(int)
for m in members:
for t in by_id[m]["fm"].get("tags", []) or []:
tag_counts[str(t)] += 1
core_tags = sorted([t for t, c in tag_counts.items() if c >= max(2, int(0.4 * n))])
# voice ratio
voice_count = sum(1 for m in members if by_id[m]["fm"].get("voice_passed") is True)
voice_ratio = voice_count / n if n else 0
cluster["core_tags"] = core_tags
cluster["voice_ratio"] = round(voice_ratio, 3)
cluster["cluster_id"] = hashlib.sha1((cluster["view"] + ":" + ",".join(members)).encode("utf-8")).hexdigest()[:12]
# Sample member titles
titles = []
for m in members[:6]:
body = by_id[m].get("body", "")
for line in body.splitlines():
if line.startswith("# "):
titles.append({"id": m, "title": line[2:].strip()})
break
cluster["sample_titles"] = titles
return cluster
def _merge_overlap(candidates: list[dict], jaccard_threshold: float = 0.6) -> list[dict]:
"""Merge clusters whose member sets overlap heavily (Jaccard >= threshold)."""
# Sort by size descending; iterate, keep cluster if not strongly subsumed by any kept one.
cands = sorted(candidates, key=lambda x: -x["size"])
kept = []
kept_sets = []
for c in cands:
m = set(c["members"])
absorb = False
for i, ks in enumerate(kept_sets):
inter = len(m & ks)
union = len(m | ks)
jaccard = inter / union if union else 0
if jaccard >= jaccard_threshold:
# Absorbed: tag the kept cluster with all views it covers
kept[i].setdefault("merged_views", set()).add(c["view"])
if c["view"] == "tag":
kept[i].setdefault("merged_views", set()).add(f"tag:{c.get('tag','')}")
elif c["view"] == "entity":
kept[i].setdefault("merged_views", set()).add(f"entity:{c.get('entity','')}")
absorb = True
break
if not absorb:
kept.append(c)
kept_sets.append(m)
# Convert merged_views set to sorted list for JSON
for k in kept:
if "merged_views" in k:
k["merged_views"] = sorted(k["merged_views"])
return kept
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--zettel-root", type=Path, default=DEFAULT_ZETTEL_ROOT)
parser.add_argument("--min-size", type=int, default=3)
parser.add_argument("--min-link-density", type=float, default=0.3)
parser.add_argument("--embed-k", type=int, default=3)
parser.add_argument("--embed-sim", type=float, default=0.78)
parser.add_argument("--max-size", type=int, default=30,
help="upper bound for candidate cluster size (skip macro tags)")
parser.add_argument("--apply", action="store_true",
help="Also write to _state/pending_articles.json (overwriting prod)")
parser.add_argument("--max-show", type=int, default=10)
args = parser.parse_args()
zroot = args.zettel_root
cards = load_cards(zroot / "cards")
by_id = {c["id"]: c for c in cards}
adj = _build_link_adj(cards)
link = _link_clusters(adj, min_size=args.min_size, min_density=args.min_link_density)
tag = _tag_clusters(cards, by_id, min_size=args.min_size, max_size=args.max_size)
ent = _entity_clusters(cards, by_id, min_size=args.min_size, max_size=args.max_size)
emb = _embedding_clusters(zroot, by_id, k=args.embed_k,
sim_threshold=args.embed_sim,
min_size=args.min_size, max_size=args.max_size)
print(f"per-view candidate counts:")
print(f" link: {len(link)}")
print(f" tag: {len(tag)}")
print(f" entity: {len(ent)}")
print(f" embedding: {len(emb)}")
all_cands = link + tag + ent + emb
for c in all_cands:
_annotate(c, by_id)
merged = _merge_overlap(all_cands, jaccard_threshold=0.6)
# Sort merged by size desc + density tiebreaker
merged.sort(key=lambda x: (-x["size"], -x.get("density", 0)))
print(f"\nafter overlap-merge (jaccard ≥ 0.6): {len(merged)} candidates")
print(f"\ntop {args.max_show} merged candidates:")
for i, c in enumerate(merged[:args.max_show]):
view = c["view"]
if view == "tag":
label = f"tag:{c.get('tag','?')}"
elif view == "entity":
label = f"ent:{c.get('entity','?')}"
else:
label = view
dens = f" density={c.get('density','-')}" if c.get("density") is not None else ""
merged_in = f" [+{len(c.get('merged_views',[]))} other views]" if c.get('merged_views') else ""
print(f" {i+1}. [{label}] size={c['size']}{dens} voice={c.get('voice_ratio',0)} tags={c.get('core_tags',[])[:3]}{merged_in}")
for t in c.get("sample_titles", [])[:3]:
print(f" - {t['title'][:60]}")
out = {
"generated_at": now_iso(),
"summary": {
"total_cards": len(cards),
"link_candidates": len(link),
"tag_candidates": len(tag),
"entity_candidates": len(ent),
"embedding_candidates": len(emb),
"merged_candidates": len(merged),
},
"candidates": merged,
}
out_path = zroot / "_state" / "multi_view_candidates.json"
write_json(out_path, out)
print(f"\nwrote: {out_path}")
if args.apply:
prod = zroot / "_state" / "pending_articles.json"
write_json(prod, merged)
print(f"applied to: {prod}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""Compute top-K link candidates for a given card.
Primary: cosine similarity over BGE-small-zh-v1.5 embeddings (semantic).
Fallback: TF-IDF + tag/entity overlap (lexical) if embedder unavailable.
Final score (when embeddings available) blends semantic with structural signals
so cards that share tags/entities and link-neighbors rank higher than purely
text-similar ones.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
sys.path.append(str(Path(__file__).parent))
from _common import DEFAULT_ZETTEL_ROOT, load_cards, read_json, tokens_cn_en # noqa: E402
def jaccard(a: set, b: set) -> float:
if not a and not b:
return 0.0
inter = len(a & b)
union = len(a | b)
return inter / union if union else 0.0
def _tfidf_cosine_lite(tokens_a: list[str], tokens_b: list[str], term_inv: dict, n_docs: int) -> float:
import math
def vec(tokens):
tf: dict[str, int] = {}
for t in tokens:
tf[t] = tf.get(t, 0) + 1
v = {}
for t, c in tf.items():
df = len(term_inv.get(t, [])) or 1
idf = math.log((n_docs + 1) / df) + 1.0
v[t] = c * idf
return v
va, vb = vec(tokens_a), vec(tokens_b)
if not va or not vb:
return 0.0
common = set(va) & set(vb)
dot = sum(va[t] * vb[t] for t in common)
na = math.sqrt(sum(v * v for v in va.values()))
nb = math.sqrt(sum(v * v for v in vb.values()))
return dot / (na * nb) if na and nb else 0.0
def _structural_score(my_card: dict, other_card: dict, my_neighbors: set, link_graph: dict) -> tuple[float, dict]:
my_tags = set(my_card["fm"].get("tags", []) or [])
my_ents = set(my_card["fm"].get("entities", []) or [])
o_tags = set(other_card["fm"].get("tags", []) or [])
o_ents = set(other_card["fm"].get("entities", []) or [])
t_score = jaccard(my_tags, o_tags)
e_score = jaccard(my_ents, o_ents)
o_nbrs = set()
for entry in link_graph.get(other_card["id"], {}).get("out", []) or []:
o_nbrs.add(entry["id"])
for entry in link_graph.get(other_card["id"], {}).get("in", []) or []:
o_nbrs.add(entry["id"])
nbr_score = len(my_neighbors & o_nbrs) / max(1, len(my_neighbors | o_nbrs))
return (
0.5 * t_score + 0.3 * e_score + 0.2 * nbr_score,
{"shared_tags": sorted(my_tags & o_tags), "shared_entities": sorted(my_ents & o_ents)},
)
def _embedding_path(zroot: Path, card_id: str, cards: list[dict], by_id: dict, top_k: int, threshold: float) -> list[dict] | None:
"""Return candidates using embeddings, or None if unavailable."""
try:
from embed_lite import EmbedderUnavailable, refresh, top_k_similar
except ImportError:
return None
try:
refresh(zroot, verbose=False)
except Exception as e:
sys.stderr.write(f"embed refresh failed: {e}\n")
return None
sims = top_k_similar(card_id, k=max(top_k * 2, 20), zettel_root=zroot, threshold=threshold)
if not sims:
return None
link_graph = read_json(zroot / "_index" / "links_graph.json", default={})
my_card = by_id[card_id]
my_nbrs = set()
for entry in link_graph.get(card_id, {}).get("out", []) or []:
my_nbrs.add(entry["id"])
for entry in link_graph.get(card_id, {}).get("in", []) or []:
my_nbrs.add(entry["id"])
results = []
for cid, sem in sims:
if cid not in by_id:
continue
other = by_id[cid]
struct, meta = _structural_score(my_card, other, my_nbrs, link_graph)
# Semantic dominates; structural boosts ties and surfaces shared-context cards
score = 0.7 * float(sem) + 0.3 * struct
results.append({
"id": cid,
"score": round(score, 4),
"semantic": round(float(sem), 4),
"structural": round(struct, 4),
"shared_tags": meta["shared_tags"],
"shared_entities": meta["shared_entities"],
"candidate_title_excerpt": (other["body"].splitlines()[0][:80] if other["body"] else ""),
"source": "embedding",
})
results.sort(key=lambda x: -x["score"])
return results[:top_k]
def _tfidf_path(zroot: Path, card_id: str, cards: list[dict], by_id: dict, top_k: int, threshold: float) -> list[dict]:
"""Fallback: TF-IDF + structural."""
me = by_id[card_id]
my_tokens = tokens_cn_en(me["body"])
term_inv = read_json(zroot / "_index" / "term_inverted.json", default={})
link_graph = read_json(zroot / "_index" / "links_graph.json", default={})
my_nbrs = set()
for entry in link_graph.get(card_id, {}).get("out", []) or []:
my_nbrs.add(entry["id"])
for entry in link_graph.get(card_id, {}).get("in", []) or []:
my_nbrs.add(entry["id"])
n_docs = len(cards)
results = []
for c in cards:
if c["id"] == card_id:
continue
struct, meta = _structural_score(me, c, my_nbrs, link_graph)
sem_lex = _tfidf_cosine_lite(my_tokens, tokens_cn_en(c["body"]), term_inv, n_docs)
score = 0.5 * sem_lex + 0.5 * struct
if score < threshold:
continue
results.append({
"id": c["id"],
"score": round(score, 4),
"semantic": round(sem_lex, 4),
"structural": round(struct, 4),
"shared_tags": meta["shared_tags"],
"shared_entities": meta["shared_entities"],
"candidate_title_excerpt": (c["body"].splitlines()[0][:80] if c["body"] else ""),
"source": "tfidf",
})
results.sort(key=lambda x: -x["score"])
return results[:top_k]
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--zettel-root", type=Path, default=DEFAULT_ZETTEL_ROOT)
parser.add_argument("--card-id", required=True)
parser.add_argument("--top-k", type=int, default=10)
parser.add_argument("--threshold", type=float, default=0.05)
parser.add_argument("--force-tfidf", action="store_true",
help="Skip embedding path; use TF-IDF only (for debugging or no-net machines)")
args = parser.parse_args()
zroot: Path = args.zettel_root
cards = load_cards(zroot / "cards")
by_id = {c["id"]: c for c in cards}
if args.card_id not in by_id:
for c in load_cards(zroot / "fleeting"):
by_id[c["id"]] = c
if args.card_id not in by_id:
print(json.dumps({"error": f"card not found: {args.card_id}"}), file=sys.stderr)
return 1
results = None
if not args.force_tfidf:
results = _embedding_path(zroot, args.card_id, cards, by_id, args.top_k, args.threshold)
if results is None:
results = _tfidf_path(zroot, args.card_id, cards, by_id, args.top_k, args.threshold)
print(json.dumps(results, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""Mode 5: command-line entry for 'propose' (命题作文).
User gives a seed (a case, an argument, an observation), and this script:
1. Embeds the seed text using fastembed
2. Finds top-K candidate cards by cosine similarity
3. Writes _state/proposal_<ts>.json with seed + candidates + metadata
The Oven (or any agent) then reads this JSON, opens each candidate card,
judges relevance + role (support/counter/case/context), drafts an outline,
and writes ~/zettel/_drafts/proposal_<ts>.md.
Usage:
python3 propose_seed.py --seed "若数据集本身有偏,..."
python3 propose_seed.py --seed-file /path/to/seed.txt
"""
from __future__ import annotations
import argparse
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
sys.path.append(str(Path(__file__).parent))
from _common import DEFAULT_ZETTEL_ROOT, load_cards, now_iso # noqa: E402
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--zettel-root", type=Path, default=DEFAULT_ZETTEL_ROOT)
parser.add_argument("--seed", default=None, help="Seed text (case / argument / question)")
parser.add_argument("--seed-file", type=Path, default=None,
help="Read seed from a file instead of CLI")
parser.add_argument("--top-k", type=int, default=30,
help="Number of candidate cards to surface for agent review")
args = parser.parse_args()
if args.seed_file:
seed = args.seed_file.read_text(encoding="utf-8").strip()
elif args.seed:
seed = args.seed
else:
print("error: --seed or --seed-file required", file=sys.stderr)
return 1
if not seed:
print("error: empty seed", file=sys.stderr)
return 1
try:
from embed_lite import Embedder, load_cache
except ImportError as e:
print(f"error: embed_lite unavailable: {e}", file=sys.stderr)
return 2
# Embed the seed
emb = Embedder.get()
seed_vec = emb.embed([seed])[0]
# Normalize (defensive — should already be normalized by Embedder.embed)
import numpy as np
n = float(np.linalg.norm(seed_vec))
if n == 0:
print("error: seed embedded to zero vector", file=sys.stderr)
return 3
seed_vec = seed_vec / n
# Load card embeddings
ids, vecs = load_cache(args.zettel_root)
if not ids or vecs is None or vecs.shape[0] == 0:
print("error: no card embeddings available (run build_index + embed_lite first)",
file=sys.stderr)
return 4
sims = vecs @ seed_vec
order = sims.argsort()[::-1][:args.top_k]
cards = load_cards(args.zettel_root / "cards")
by_id = {c["id"]: c for c in cards}
candidates = []
for i in order:
cid = ids[i]
card = by_id.get(cid)
if not card:
continue
# Title from first H1
title = ""
for line in card["body"].splitlines():
if line.startswith("# "):
title = line[2:].strip()
break
# Judge excerpt
judge_excerpt = ""
ji = card["body"].find("## 我的判断")
if ji >= 0:
judge_excerpt = card["body"][ji:ji + 600]
candidates.append({
"id": cid,
"title": title,
"sim": round(float(sims[i]), 4),
"tags": card["fm"].get("tags", []),
"entities": card["fm"].get("entities", []),
"status": card["fm"].get("status", ""),
"path": card["path"],
"judge_excerpt": judge_excerpt,
})
ts = datetime.now(timezone.utc).astimezone().strftime("%Y%m%dT%H%M")
output = {
"generated_at": now_iso(),
"proposal_id": f"proposal_{ts}",
"seed": seed,
"n_candidates": len(candidates),
"candidates": candidates,
}
out_path = args.zettel_root / "_state" / f"proposal_{ts}.json"
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(output, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps({
"ok": True,
"proposal_id": output["proposal_id"],
"output_path": str(out_path),
"seed_length": len(seed),
"n_candidates": len(candidates),
"top_sim": candidates[0]["sim"] if candidates else 0,
}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env bash
# sync_raw_to_zettel.sh — Keep ~/zettel/raw/ in sync with upstream raw sources.
#
# Short-term: mirrors from wiki repo (where R2 processing is already centralized).
# Long-term: if wiki repo is decommissioned, replace step 2-3 with direct
# Dropbox + GetNote API sync (see ~/wiki/.../scripts/sync-raw.sh for reference).
#
# Idempotent: rsync --delete keeps target a clean mirror; symlink ensures
# images stay accessible locally.
set -euo pipefail
ZETTEL_ROOT="${ZETTEL_ROOT:-$HOME/zettel}"
WIKI_RAW="$HOME/wiki/published-articles/raw"
IMG_SRC="$HOME/knowledge/getnote-local/images"
mkdir -p "$ZETTEL_ROOT/raw/getnote" "$ZETTEL_ROOT/raw/articles"
# Mirror articles (text only, 4.3 MB)
if [[ -d "$WIKI_RAW/articles" ]]; then
rsync -a --delete "$WIKI_RAW/articles/" "$ZETTEL_ROOT/raw/articles/"
fi
# Mirror getnote (text only with image refs, 21 MB)
if [[ -d "$WIKI_RAW/getnote" ]]; then
rsync -a --delete "$WIKI_RAW/getnote/" "$ZETTEL_ROOT/raw/getnote/"
fi
# Symlink images (1.2 GB, not in git — points to local GetNote cache)
if [[ -d "$IMG_SRC" ]]; then
if [[ -L "$ZETTEL_ROOT/raw/images" ]] || [[ ! -e "$ZETTEL_ROOT/raw/images" ]]; then
ln -sfn "$IMG_SRC" "$ZETTEL_ROOT/raw/images"
fi
fi
echo "sync_raw_to_zettel: $(ls "$ZETTEL_ROOT/raw/getnote" | wc -l) getnote, $(ls "$ZETTEL_ROOT/raw/articles" | wc -l) articles"