Compare commits

..

25 Commits

Author SHA1 Message Date
696fa3a1b8 Daily backup 2026-04-07 00:00:03 2026-04-07 00:00:03 -07:00
1eb455d5b6 Daily backup 2026-04-06 00:00:03 2026-04-06 00:00:03 -07:00
2b7495aa7d Daily backup 2026-04-05 00:00:03 2026-04-05 00:00:03 -07:00
5f9294bdd8 Daily backup 2026-04-04 00:00:03 2026-04-04 00:00:03 -07:00
Yanxin Lu
acc42c4381 move note search 2026-04-03 15:44:25 -07:00
Yanxin Lu
f410df3e7a note search md files 2026-04-03 15:40:05 -07:00
Yanxin Lu
bb1b1dad2f note search 2026-04-03 15:28:40 -07:00
7e5bbabb29 Daily backup 2026-04-03 00:00:03 2026-04-03 00:00:03 -07:00
a5e49573ca Daily backup 2026-04-02 00:00:03 2026-04-02 00:00:03 -07:00
Yanxin Lu
7451cd73c9 Merge branch 'main' of ssh://git.luyanxin.com:8103/lyx/youlu-openclaw-workspace
merge
2026-04-01 19:51:57 -07:00
Yanxin Lu
aa8a35b920 sender email 2026-04-01 19:51:51 -07:00
de93c33c07 Daily backup 2026-04-01 00:00:03 2026-04-01 00:00:03 -07:00
Yanxin Lu
0910bd5d5c contacts: support multiple address book collections
Migadu exposes "family" and "business" address books via CardDAV.
The contacts script now searches all subdirs under contacts/ and
adds new contacts to the "family" collection by default.
2026-03-31 13:33:04 -07:00
Yanxin Lu
3825f3dcdb move himalaya wrapper from skills/himalaya/ to scripts/
The himalaya skill is a third-party ClawhHub package — putting custom
scripts inside it risks conflicts on updates. The wrapper is local
customization, so it belongs in scripts/ alongside email_processor.
2026-03-31 13:20:10 -07:00
Yanxin Lu
732a86cf09 calendar: use himalaya wrapper for email sending (defense-in-depth)
cal_tool.py now calls himalaya.sh wrapper instead of himalaya directly,
so recipients are validated at both the calendar layer (contacts.py
resolve) and the himalaya layer (wrapper header parsing).
2026-03-31 13:16:36 -07:00
Yanxin Lu
f05a84d8ca contacts: extract into standalone skill, add himalaya wrapper for all email sends
Refactors the contacts system from being embedded in cal_tool.py into a
standalone contacts skill that serves as the single source of truth for
recipient validation across all outbound email paths.

- New skills/contacts/ skill: list, add, delete, resolve commands
- New skills/himalaya/scripts/himalaya.sh wrapper: validates To/Cc/Bcc
  recipients against contacts for message send, template send, and
  message write commands; passes everything else through unchanged
- cal_tool.py now delegates to contacts.py resolve instead of inline logic
- TOOLS.md updated: agent should use himalaya wrapper, not raw himalaya
2026-03-31 11:12:08 -07:00
Yanxin Lu
cd1ee050ed calendar: add contacts system to prevent recipient address hallucination
Agent hallucinated "xiaojuzi@meta.com" instead of looking up the correct
address from USER.md. Prompting rules can't reliably prevent this, so
recipient validation is now enforced at the tool level.

- Add contact list/add/delete subcommands (vCard .vcf files synced via CardDAV)
- send --to now resolves through contacts: accepts names, name:type, or
  known emails; rejects unknown addresses with available contacts shown
- Multi-email contacts supported with type qualifier (e.g. "小橘子:work")
- Adding contacts and sending are separate operations (no chaining)
2026-03-31 10:43:06 -07:00
b7b99fdb61 Daily backup 2026-03-31 00:00:03 2026-03-31 00:00:03 -07:00
d2fd8e7928 Daily backup 2026-03-30 00:00:02 2026-03-30 00:00:02 -07:00
7d7eb9bcbb Daily backup 2026-03-29 00:00:02 2026-03-29 00:00:02 -07:00
73d6c4d537 Daily backup 2026-03-28 00:00:03 2026-03-28 00:00:03 -07:00
Yanxin Lu
c66ccc4c44 calendar: add --alarm flag to send command for custom reminder triggers
Previously the send command hardcoded a 1-day VALARM. Now accepts
--alarm with duration format (e.g. 1h, 30m, 2d), defaulting to 1d.
2026-03-27 09:05:30 -07:00
11b3e39586 Daily backup 2026-03-27 00:00:08 2026-03-27 00:00:08 -07:00
Yanxin Lu
7227574b62 Merge branch 'main' of ssh://git.luyanxin.com:8103/lyx/youlu-openclaw-workspace
merge
2026-03-26 09:19:23 -07:00
Yanxin Lu
e1f1c0f334 calendar: add EXDATE support for cancelling single occurrences of recurring events
event delete on a recurring event previously deleted the entire .ics file,
killing the whole series. Now requires --date (adds EXDATE) or --all (deletes
series) for recurring events, refusing to act without either flag.
2026-03-26 09:19:02 -07:00
43 changed files with 4221 additions and 133 deletions

View File

@@ -4,9 +4,14 @@ _这份文件记录持续性项目和重要状态跨会话保留。_
## 📝 重要规则
### 邮件发送规则v2
- **youlu@luyanxin.com → mail@luyx.org**: 直接发送,无需确认(用户 SimpleLogin 别名
- 其他所有对外邮件: 仍需确认
### 邮件发送规则v3
- **绝不向不在通讯录中的地址发送任何邮件**(日历邀请和普通邮件均适用
- 发送邮件/邀请时使用通讯录名称(如 `小橘子:work`),不要手写邮箱地址
- 通讯录管理: `contacts.sh list/add/delete/resolve``skills/contacts/`
- 添加联系人和发送邮件是**独立操作**,不要在同一次请求中先 add 再 send
- 普通邮件使用 himalaya 包装器(`scripts/himalaya.sh`),自动校验收件人
- **youlu@luyanxin.com → mail@luyx.org**: 直接发送,无需确认(用户 SimpleLogin 别名,需在通讯录中)
- 其他所有对外邮件: 确认后再发送
### 代码审查规则
写/改/部署代码前,必须先确认:
@@ -73,7 +78,7 @@ _这份文件记录持续性项目和重要状态跨会话保留。_
### 3. 日历邀请 + CalDAV 同步
**状态**: 运行中
**创建**: 2026-03-18
**更新**: 2026-03-25添加 RRULE 支持 + 事件管理 + 安全规则
**更新**: 2026-03-31收件人校验移至 contacts 技能 + himalaya 包装器
**配置**:
- 技能: `~/.openclaw/workspace/skills/calendar/`
- 日历数据: `~/.openclaw/workspace/calendars/` (home/work/tasks/journals)
@@ -98,14 +103,35 @@ _这份文件记录持续性项目和重要状态跨会话保留。_
---
### 4. Notesearch 笔记搜索
**状态**: 运行中
**创建**: 2026-04-03
**配置**:
- 工具: `~/.openclaw/workspace/skills/notesearch/`
- 笔记库: `/home/lyx/Documents/obsidian-yanxin`Obsidian vault独立 git 仓库)
- 嵌入模型: `qwen3-embedding:0.6b`(通过 Ollama
- 索引: `<vault>/.index/`gitignored
- 技术栈: LlamaIndex + Ollama
**功能**:
- 基于向量搜索的语义检索,用户提问时搜索 Obsidian 笔记
- 返回相关片段、文件路径和相关性分数
- 笔记更新后需重新索引(`notesearch.sh index`
---
## 📁 项目文件索引
| 项目 | 位置 |
|------|------|
| 邮件处理器 | `~/.openclaw/workspace/scripts/email_processor/` |
| 通讯录 | `~/.openclaw/workspace/skills/contacts/`(数据: `contacts/default/`|
| 日历/待办 | `~/.openclaw/workspace/skills/calendar/` |
| 日历数据 | `~/.openclaw/workspace/calendars/` (home=事件, tasks=待办) |
| himalaya 包装器 | `~/.openclaw/workspace/scripts/himalaya.sh` |
| 笔记搜索 | `~/.openclaw/workspace/skills/notesearch/` |
| Obsidian 笔记库 | `/home/lyx/Documents/obsidian-yanxin` |
---
_最后更新: 2026-03-25_
_最后更新: 2026-04-03_

View File

@@ -28,21 +28,44 @@ Skills define _how_ tools work. This file is for _your_ specifics — the stuff
**本地配置:**
- 二进制:`~/.local/bin/himalaya`
- **安全包装器**`~/.openclaw/workspace/scripts/himalaya.sh`(验证收件人)
- 配置:`~/.config/himalaya/config.toml`
- 文档:`~/.openclaw/workspace/skills/himalaya/SKILL.md`
**核心用法:**
**重要:发送邮件时必须使用包装器,不要直接调用 himalaya**
```bash
himalaya envelope list --page-size 20 # 列出邮件
himalaya message read <id> # 读取邮件
himalaya message delete <id> # 删除邮件
himalaya message write # 写新邮件(交互式
HIMALAYA=~/.openclaw/workspace/scripts/himalaya.sh
$HIMALAYA envelope list --page-size 20 # 列出邮件(直接透传)
$HIMALAYA message read <id> # 读取邮件(直接透传
$HIMALAYA message delete <id> # 删除邮件(直接透传)
cat msg.txt | $HIMALAYA template send # 发送邮件(校验收件人)
```
**邮件发送规则:**
- **所有发送命令通过包装器**,自动校验收件人在通讯录中
- **youlu@luyanxin.com → mail@luyx.org**: 直接发送,无需确认(用户 SimpleLogin 别名)
- 其他所有对外邮件: 仍需确认
### 通讯录contacts
**目录**: `~/.openclaw/workspace/skills/contacts/`
**数据**: `~/.openclaw/workspace/contacts/`vCard .vcf 文件,按 Migadu 地址簿分目录CardDAV 同步)
```bash
CONTACTS=~/.openclaw/workspace/skills/contacts/scripts/contacts.sh
$CONTACTS list # 列出所有联系人
$CONTACTS add --name "小橘子" --email "x@y.com" --type work # 添加联系人
$CONTACTS delete --name "小橘子" # 删除联系人
$CONTACTS resolve "小橘子:work" # 解析为邮箱地址
```
**安全规则**:
- **添加联系人和发送邮件是独立操作**,不要在同一次请求中先 add 再 send
- 所有邮件发送himalaya 和日历邀请)都会校验收件人在通讯录中
### 🌐 网页操作 - 工具选择决策表
| 场景 | 首选 | 次选 |
@@ -112,15 +135,16 @@ agent-browser close
```bash
SKILL_DIR=~/.openclaw/workspace/skills/calendar
# 发送日历邀请(--from 默认 youlu@luyanxin.com
# 发送日历邀请(--to 用通讯录名称,不要直接写邮箱地址
$SKILL_DIR/scripts/calendar.sh send \
--to "friend@example.com" \
--to "小橘子:work" \
--subject "Lunch" --summary "Lunch at Tartine" \
--start "2026-03-20T12:00:00" --end "2026-03-20T13:00:00"
--start "2026-03-20T12:00:00" --end "2026-03-20T13:00:00" \
--alarm 1h # 提前1小时提醒默认1d支持 1d/2h/30m
# 发送周期性邀请(--start 必须落在 BYDAY 指定的那天!)
$SKILL_DIR/scripts/calendar.sh send \
--to "alice@example.com" \
--to "小橘子:work" \
--subject "Weekly Shot" --summary "Allergy Shot (Tue)" \
--start "2026-03-31T14:30:00" --end "2026-03-31T15:00:00" \
--rrule "FREQ=WEEKLY;COUNT=13;BYDAY=TU"
@@ -131,7 +155,12 @@ $SKILL_DIR/scripts/calendar.sh reply --envelope-id 42 --action accept
# 事件管理(查看、搜索、删除)
$SKILL_DIR/scripts/calendar.sh event list
$SKILL_DIR/scripts/calendar.sh event list --search "Allergy"
$SKILL_DIR/scripts/calendar.sh event delete --match "Allergy Shot (Tue)"
# 取消周期性事件的单次(加 EXDATE系列保留
$SKILL_DIR/scripts/calendar.sh event delete --match "Allergy Shot" --date "2026-03-28"
# 删除整个周期性系列(需要 --all 安全标志)
$SKILL_DIR/scripts/calendar.sh event delete --match "Allergy Shot" --all
# 删除非周期性事件
$SKILL_DIR/scripts/calendar.sh event delete --match "Lunch"
# 待办管理
$SKILL_DIR/scripts/calendar.sh todo add --summary "跟进报销" --due "2026-03-25" --priority high
@@ -142,16 +171,48 @@ $SKILL_DIR/scripts/calendar.sh todo delete --match "报销"
$SKILL_DIR/scripts/calendar.sh todo check # 每日摘要cron
```
**支持操作**: 发送邀请 (`send`)、接受/拒绝/暂定 (`reply`)、事件管理 (`event list/delete`)、待办管理 (`todo add/list/edit/complete/delete/check`)
**支持操作**: 发送邀请 (`send`)、接受/拒绝/暂定 (`reply`)、事件管理 (`event list/delete --date/--all`)、待办管理 (`todo add/list/edit/complete/delete/check`)
**依赖**: himalaya邮件、vdirsyncerCalDAV 同步、khal查看日历、todoman待办管理
**同步**: 发送/回复/待办变更后自动 `vdirsyncer sync`,心跳也会定期同步
**注意**: 发送日历邀请属于对外邮件,需先确认
**安全规则**:
- **`send --to` 只接受通讯录中的联系人**,不认识的地址会被拒绝(防止地址幻觉)
- **添加联系人和发送邮件是独立操作**,不要在同一次请求中先 add 再 send
- 周期性邀请务必先 `--dry-run` 验证 ICS 内容
- **绝不用 `rm` 删日历 .ics 文件**,只用 `event delete`
- **取消周期性事件的单次用 `--date`**,不要用 `--all`(会删掉整个系列)
- 连续发多封邮件时,每封间隔 10 秒以上Migadu SMTP 限频)
### Notesearch 笔记搜索
**目录**: `~/.openclaw/workspace/skills/notesearch/`
**配置**: `~/.openclaw/workspace/skills/notesearch/config.json`
**笔记库**: `/home/lyx/Documents/obsidian-yanxin`Obsidian vaultgit 管理)
基于向量搜索的笔记检索工具,使用 LlamaIndex + Ollama 嵌入模型索引 Obsidian 笔记。
```bash
NOTESEARCH=~/.openclaw/workspace/skills/notesearch/notesearch.sh
# 搜索笔记(返回相关片段 + 文件路径 + 相关性分数)
$NOTESEARCH search "allergy shots"
$NOTESEARCH search "project planning" --top-k 3
# 重建索引(笔记更新后需要重新索引)
$NOTESEARCH index
```
**工作流程**:
1. 用户提问 → 用 `search` 找到相关笔记片段
2. 如果需要完整内容 → `cat /home/lyx/Documents/obsidian-yanxin/<文件路径>`
3. 根据笔记内容回答用户问题
**注意**:
- 搜索基于语义(向量相似度),不仅仅是关键词匹配
- 笔记更新后需要运行 `$NOTESEARCH index` 重建索引
- 嵌入模型: `qwen3-embedding:0.6b`(通过 Ollama
### OpenClaw Cron 定时任务
**规则**: 确定性 shell 任务用 `systemEvent`,需要 LLM 判断的用 `agentTurn`

View File

@@ -0,0 +1,24 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//OpenClaw//Calendar//EN
CALSCALE:GREGORIAN
BEGIN:VEVENT
SUMMARY:洗牙 (Cleaning Appointment)
DTSTART;TZID=America/Los_Angeles:20260617T080000
DTEND;TZID=America/Los_Angeles:20260617T100000
DTSTAMP:20260327T051052Z
UID:0d714ba0-4cab-41b9-a3b0-59bab7271286@openclaw
SEQUENCE:0
ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;SCHEDULE-STATUS=1.1:mailto:erica.ji
ang@anderson.ucla.edu
LOCATION:2221 Lincoln Blvd\, Santa Monica\, California 90405\, United State
s
ORGANIZER;CN=youlu@luyanxin.com:mailto:youlu@luyanxin.com
STATUS:CONFIRMED
BEGIN:VALARM
ACTION:DISPLAY
DESCRIPTION:Reminder: 洗牙 (Cleaning Appointment)
TRIGGER:-P1D
END:VALARM
END:VEVENT
END:VCALENDAR

View File

@@ -1,20 +1,48 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//OpenClaw//Calendar//EN
CALSCALE:GREGORIAN
PRODID:-//Apple Inc.//macOS 26.3.1//EN
BEGIN:VTIMEZONE
TZID:America/Los_Angeles
BEGIN:DAYLIGHT
DTSTART:20070311T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
TZNAME:PDT
TZOFFSETFROM:-0800
TZOFFSETTO:-0700
END:DAYLIGHT
BEGIN:STANDARD
DTSTART:20071104T020000
RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
TZNAME:PST
TZOFFSETFROM:-0700
TZOFFSETTO:-0800
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
SUMMARY:Allergy Shot (Sat)
DTSTART;TZID=America/Los_Angeles:20260328T090000
ATTENDEE;CN=youlu@luyanxin.com;CUTYPE=INDIVIDUAL;EMAIL=youlu@luyanxin.com;P
ARTSTAT=ACCEPTED:mailto:youlu@luyanxin.com
ATTENDEE;CUTYPE=UNKNOWN;EMAIL=Erica.Jiang@anderson.ucla.edu;ROLE=REQ-PARTIC
IPANT;RSVP=TRUE;SCHEDULE-STATUS=1.1:mailto:Erica.Jiang@anderson.ucla.edu
DTEND;TZID=America/Los_Angeles:20260328T093000
DTSTAMP:20260325T160918Z
UID:1374d6ce-5f83-4c2e-b9a1-120cd2b949e5@openclaw
RRULE:FREQ=WEEKLY;COUNT=13;BYDAY=SA
ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;SCHEDULE-STATUS=1.1:mailto:Erica.Ji
ang@anderson.ucla.edu
DTSTAMP:20260403T160300Z
DTSTART;TZID=America/Los_Angeles:20260328T090000
EXDATE;TZID=America/Los_Angeles:20260328T090000
LAST-MODIFIED:20260403T160258Z
LOCATION:11965 Venice Blvd. #300\, Los Angeles\, CA 90066
ORGANIZER;CN=Youlu:mailto:youlu@luyanxin.com
ORGANIZER;CN=Youlu;EMAIL=youlu@luyanxin.com:mailto:youlu@luyanxin.com
RRULE:FREQ=WEEKLY;COUNT=13;BYDAY=SA
SEQUENCE:0
SUMMARY:Allergy Shot (Sat)
TRANSP:OPAQUE
UID:1374d6ce-5f83-4c2e-b9a1-120cd2b949e5@openclaw
BEGIN:VALARM
ACKNOWLEDGED:20260403T160258Z
ACTION:DISPLAY
DESCRIPTION:Reminder
TRIGGER:-P1D
UID:FADBDE52-87C0-40C8-96ED-B0DEC5A6D441
X-WR-ALARMUID:FADBDE52-87C0-40C8-96ED-B0DEC5A6D441
END:VALARM
END:VEVENT
END:VCALENDAR

View File

@@ -1,20 +1,48 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//OpenClaw//Calendar//EN
CALSCALE:GREGORIAN
PRODID:-//Apple Inc.//macOS 26.3.1//EN
BEGIN:VTIMEZONE
TZID:America/Los_Angeles
BEGIN:DAYLIGHT
DTSTART:20070311T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
TZNAME:PDT
TZOFFSETFROM:-0800
TZOFFSETTO:-0700
END:DAYLIGHT
BEGIN:STANDARD
DTSTART:20071104T020000
RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
TZNAME:PST
TZOFFSETFROM:-0700
TZOFFSETTO:-0800
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
SUMMARY:Allergy Shot (Tue)
DTSTART;TZID=America/Los_Angeles:20260331T143000
ATTENDEE;CN=youlu@luyanxin.com;CUTYPE=INDIVIDUAL;EMAIL=youlu@luyanxin.com;P
ARTSTAT=ACCEPTED:mailto:youlu@luyanxin.com
ATTENDEE;CUTYPE=UNKNOWN;EMAIL=Erica.Jiang@anderson.ucla.edu;ROLE=REQ-PARTIC
IPANT;RSVP=TRUE:mailto:Erica.Jiang@anderson.ucla.edu
DTEND;TZID=America/Los_Angeles:20260331T150000
DTSTAMP:20260325T160802Z
UID:59c533e2-4153-42dd-b717-c42e104521d9@openclaw
RRULE:FREQ=WEEKLY;COUNT=13;BYDAY=TU
ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;SCHEDULE-STATUS=1.1:mailto:Erica.Ji
ang@anderson.ucla.edu
DTSTAMP:20260406T213025Z
DTSTART;TZID=America/Los_Angeles:20260331T143000
EXDATE;TZID=America/Los_Angeles:20260331T143000
LAST-MODIFIED:20260406T213023Z
LOCATION:11965 Venice Blvd. #300\, Los Angeles\, CA 90066
ORGANIZER;CN=Youlu:mailto:youlu@luyanxin.com
ORGANIZER;CN=Youlu;EMAIL=youlu@luyanxin.com:mailto:youlu@luyanxin.com
RRULE:FREQ=WEEKLY;COUNT=13;BYDAY=TU
SEQUENCE:0
SUMMARY:Allergy Shot (Tue)
TRANSP:OPAQUE
UID:59c533e2-4153-42dd-b717-c42e104521d9@openclaw
BEGIN:VALARM
ACKNOWLEDGED:20260406T213023Z
ACTION:DISPLAY
DESCRIPTION:Reminder
TRIGGER:-P1D
UID:2850F4A5-B704-4A07-BC97-D284593D0CFB
X-WR-ALARMUID:2850F4A5-B704-4A07-BC97-D284593D0CFB
END:VALARM
END:VEVENT
END:VCALENDAR

View File

@@ -0,0 +1,24 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//OpenClaw//Calendar//EN
CALSCALE:GREGORIAN
BEGIN:VEVENT
SUMMARY:OB 医生门诊Michelle C. Tsai
DTSTART;TZID=America/Los_Angeles:20260915T110000
DTEND;TZID=America/Los_Angeles:20260915T120000
DTSTAMP:20260331T161307Z
UID:715095b2-d24c-4eee-87fc-aa08a3b3b720@openclaw
SEQUENCE:0
ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;SCHEDULE-STATUS=1.1:mailto:Erica.Ji
ang@anderson.ucla.edu
LOCATION:UCLA Health Marina del Rey Primary & Specialty Care\, 13160 Mindan
ao Way\, Suite 317\, Marina Del Rey CA 90292
ORGANIZER;CN=youlu@luyanxin.com:mailto:youlu@luyanxin.com
STATUS:CONFIRMED
BEGIN:VALARM
ACTION:DISPLAY
DESCRIPTION:Reminder: OB 医生门诊Michelle C. Tsai
TRIGGER:-P1D
END:VALARM
END:VEVENT
END:VCALENDAR

View File

@@ -1,20 +1,48 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//OpenClaw//Calendar//EN
CALSCALE:GREGORIAN
PRODID:-//Apple Inc.//macOS 26.3.1//EN
BEGIN:VTIMEZONE
TZID:America/Los_Angeles
BEGIN:DAYLIGHT
DTSTART:20070311T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
TZNAME:PDT
TZOFFSETFROM:-0800
TZOFFSETTO:-0700
END:DAYLIGHT
BEGIN:STANDARD
DTSTART:20071104T020000
RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
TZNAME:PST
TZOFFSETFROM:-0700
TZOFFSETTO:-0800
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
SUMMARY:Allergy Shot (Thu)
DTSTART;TZID=America/Los_Angeles:20260326T073000
ATTENDEE;CN=youlu@luyanxin.com;CUTYPE=INDIVIDUAL;EMAIL=youlu@luyanxin.com;P
ARTSTAT=ACCEPTED:mailto:youlu@luyanxin.com
ATTENDEE;CUTYPE=UNKNOWN;EMAIL=Erica.Jiang@anderson.ucla.edu;ROLE=REQ-PARTIC
IPANT;RSVP=TRUE:mailto:Erica.Jiang@anderson.ucla.edu
DTEND;TZID=America/Los_Angeles:20260326T080000
DTSTAMP:20260325T160851Z
UID:7b822ffc-1d3b-4a95-8835-f2e75a0f583d@openclaw
RRULE:FREQ=WEEKLY;COUNT=13;BYDAY=TH
ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;SCHEDULE-STATUS=1.1:mailto:Erica.Ji
ang@anderson.ucla.edu
DTSTAMP:20260401T194350Z
DTSTART;TZID=America/Los_Angeles:20260326T073000
LAST-MODIFIED:20260401T143011Z
LOCATION:11965 Venice Blvd. #300\, Los Angeles\, CA 90066
ORGANIZER;CN=Youlu:mailto:youlu@luyanxin.com
ORGANIZER;CN=youlu@luyanxin.com;EMAIL=youlu@luyanxin.com:mailto:youlu@luyan
xin.com
RRULE:FREQ=WEEKLY;COUNT=13;BYDAY=TH
SEQUENCE:0
SUMMARY:Allergy Shot (Thu)
TRANSP:OPAQUE
UID:7b822ffc-1d3b-4a95-8835-f2e75a0f583d@openclaw
BEGIN:VALARM
ACKNOWLEDGED:20260401T143011Z
ACTION:DISPLAY
DESCRIPTION:Reminder
TRIGGER:-P1D
UID:42D85383-621D-438A-AC74-3794A2B54943
X-WR-ALARMUID:42D85383-621D-438A-AC74-3794A2B54943
END:VALARM
END:VEVENT
END:VCALENDAR

View File

@@ -0,0 +1,24 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//OpenClaw//Calendar//EN
CALSCALE:GREGORIAN
BEGIN:VEVENT
SUMMARY:RE 医生门诊Lindsay L. Kroener
DTSTART;TZID=America/Los_Angeles:20260903T110000
DTEND;TZID=America/Los_Angeles:20260903T120000
DTSTAMP:20260331T161227Z
UID:a6f1ecf4-312c-4147-8dd5-9609c86159db@openclaw
SEQUENCE:0
ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;SCHEDULE-STATUS=1.1:mailto:Erica.Ji
ang@anderson.ucla.edu
LOCATION:UCLA OBGYN Santa Monica\, 1245 16th St\, Suite 202\, Santa Monica
CA 90404
ORGANIZER;CN=youlu@luyanxin.com:mailto:youlu@luyanxin.com
STATUS:CONFIRMED
BEGIN:VALARM
ACTION:DISPLAY
DESCRIPTION:Reminder: RE 医生门诊Lindsay L. Kroener
TRIGGER:-P1D
END:VALARM
END:VEVENT
END:VCALENDAR

View File

@@ -0,0 +1,23 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//OpenClaw//Calendar//EN
CALSCALE:GREGORIAN
BEGIN:VEVENT
SUMMARY:做超声波 (Ultrasound Appointment)
DTSTART;TZID=America/Los_Angeles:20260327T103000
DTEND;TZID=America/Los_Angeles:20260327T113000
DTSTAMP:20260327T050940Z
UID:afa99ba8-e13e-4b82-82dc-822d6eb1e3b9@openclaw
SEQUENCE:0
ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;SCHEDULE-STATUS=1.1:mailto:erica.ji
ang@anderson.ucla.edu
LOCATION:12555 W Jefferson Blvd\, Los Angeles CA 90066-7032
ORGANIZER;CN=youlu@luyanxin.com:mailto:youlu@luyanxin.com
STATUS:CONFIRMED
BEGIN:VALARM
ACTION:DISPLAY
DESCRIPTION:Reminder: 做超声波 (Ultrasound Appointment)
TRIGGER:-P1D
END:VALARM
END:VEVENT
END:VCALENDAR

View File

@@ -0,0 +1,49 @@
BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
PRODID:-//Apple Inc.//macOS 26.3.1//EN
BEGIN:VTIMEZONE
TZID:America/Los_Angeles
BEGIN:DAYLIGHT
DTSTART:20070311T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
TZNAME:PDT
TZOFFSETFROM:-0800
TZOFFSETTO:-0700
END:DAYLIGHT
BEGIN:STANDARD
DTSTART:20071104T020000
RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
TZNAME:PST
TZOFFSETFROM:-0700
TZOFFSETTO:-0800
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
ATTENDEE;CN=youlu@luyanxin.com;CUTYPE=INDIVIDUAL;EMAIL=youlu@luyanxin.com;P
ARTSTAT=ACCEPTED:mailto:youlu@luyanxin.com
ATTENDEE;CUTYPE=UNKNOWN;EMAIL=Erica.Jiang@anderson.ucla.edu;ROLE=REQ-PARTIC
IPANT;RSVP=TRUE:mailto:Erica.Jiang@anderson.ucla.edu
DESCRIPTION:带二狗去 Shane Veterinary Medical Center 看病
DTEND;TZID=America/Los_Angeles:20260406T163000
DTSTAMP:20260405T223800Z
DTSTART;TZID=America/Los_Angeles:20260406T153000
LAST-MODIFIED:20260405T223757Z
LOCATION:Shane Veterinary Medical Center
ORGANIZER;CN=youlu@luyanxin.com;EMAIL=youlu@luyanxin.com:mailto:youlu@luyan
xin.com
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:带二狗看病
TRANSP:OPAQUE
UID:b1c9bb0f-89ed-4ada-a88c-74b3d549274a@openclaw
BEGIN:VALARM
ACKNOWLEDGED:20260405T223757Z
ACTION:DISPLAY
DESCRIPTION:Reminder: 带二狗看病
TRIGGER:-P1D
UID:AB8511BE-ED23-4BCC-93C4-E79A68AA4DBD
X-WR-ALARMUID:AB8511BE-ED23-4BCC-93C4-E79A68AA4DBD
END:VALARM
END:VEVENT
END:VCALENDAR

View File

@@ -0,0 +1,24 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//OpenClaw//Calendar//EN
CALSCALE:GREGORIAN
BEGIN:VEVENT
SUMMARY:皮肤科门诊April W. Armstrong
DTSTART;TZID=America/Los_Angeles:20260720T083000
DTEND;TZID=America/Los_Angeles:20260720T093000
DTSTAMP:20260331T161245Z
UID:b75b3eb4-ad6f-4b71-b91c-9082462c0261@openclaw
SEQUENCE:0
ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;SCHEDULE-STATUS=1.1:mailto:Erica.Ji
ang@anderson.ucla.edu
LOCATION:SM Dermatology\, 2001 Santa Monica Blvd\, Suite 1090\, Santa Monic
a CA 90404
ORGANIZER;CN=youlu@luyanxin.com:mailto:youlu@luyanxin.com
STATUS:CONFIRMED
BEGIN:VALARM
ACTION:DISPLAY
DESCRIPTION:Reminder: 皮肤科门诊April W. Armstrong
TRIGGER:-P1D
END:VALARM
END:VEVENT
END:VCALENDAR

View File

@@ -0,0 +1,26 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Apple Inc.//iOS 26.3.1//EN
CALSCALE:GREGORIAN
BEGIN:VTODO
COMPLETED:20260405T035527Z
CREATED:20260403T162742Z
DTSTAMP:20260404T135144Z
DUE;VALUE=DATE:20260405
LAST-MODIFIED:20260405T035527Z
PERCENT-COMPLETE:100
PRIORITY:1
SEQUENCE:2
STATUS:COMPLETED
SUMMARY:报税
UID:2977e496-0ce9-42c5-ae91-eabfd3837b82@openclaw
BEGIN:VALARM
ACKNOWLEDGED:20260404T135140Z
ACTION:DISPLAY
DESCRIPTION:Reminder
TRIGGER:-P1D
UID:A56270D2-4179-4B9C-8D6D-9A316ECDA136
X-WR-ALARMUID:A56270D2-4179-4B9C-8D6D-9A316ECDA136
END:VALARM
END:VTODO
END:VCALENDAR

View File

@@ -1,18 +1,26 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//OpenClaw//Calendar//EN
PRODID:-//Apple Inc.//iOS 26.3.1//EN
CALSCALE:GREGORIAN
BEGIN:VTODO
COMPLETED:20260327T164224Z
CREATED:20260324T232920Z
DTSTAMP:20260324T232920Z
DTSTAMP:20260326T074522Z
DUE;VALUE=DATE:20260327
LAST-MODIFIED:20260327T164224Z
PERCENT-COMPLETE:100
PRIORITY:5
STATUS:NEEDS-ACTION
SEQUENCE:2
STATUS:COMPLETED
SUMMARY:做census问卷调查
UID:2bca1160-3894-4199-ab4b-a44e9ad4098b@openclaw
BEGIN:VALARM
ACKNOWLEDGED:20260326T074519Z
ACTION:DISPLAY
DESCRIPTION:Todo: 做census问卷调查
DESCRIPTION:Reminder
TRIGGER:-P1D
UID:AE53A037-B570-4C8A-90EB-C66C275A4EB6
X-WR-ALARMUID:AE53A037-B570-4C8A-90EB-C66C275A4EB6
END:VALARM
END:VTODO
END:VCALENDAR

View File

@@ -1,21 +0,0 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//OpenClaw//Calendar//EN
BEGIN:VTODO
CREATED:20260325T043151Z
DESCRIPTION:确认iui人工授精费用保险报销进度
DTSTAMP:20260325T043151Z
DUE;VALUE=DATE:20260327
LAST-MODIFIED:20260325T045748Z
PRIORITY:5
SEQUENCE:2
STATUS:NEEDS-ACTION
SUMMARY:跟进iui保险报销
UID:2e35b4ea-794a-4467-b568-6c85bfca7412@openclaw
BEGIN:VALARM
ACTION:DISPLAY
DESCRIPTION:Todo: 跟进iui保险报销
TRIGGER:-P1D
END:VALARM
END:VTODO
END:VCALENDAR

View File

@@ -0,0 +1,22 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//OpenClaw//Calendar//EN
BEGIN:VTODO
COMPLETED:20260331T215051Z
CREATED:20260331T215047Z
DTSTAMP:20260331T215047Z
DUE;VALUE=DATE:20260403
LAST-MODIFIED:20260331T215051Z
PERCENT-COMPLETE:100
PRIORITY:5
SEQUENCE:3
STATUS:COMPLETED
SUMMARY:Test Todo
UID:30cf6b8c-8477-4111-9868-1de34d63c335@openclaw
BEGIN:VALARM
ACTION:DISPLAY
DESCRIPTION:Todo: Test Todo
TRIGGER:-P1D
END:VALARM
END:VTODO
END:VCALENDAR

View File

@@ -0,0 +1,22 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:DAVx5/4.5.10-gplay ical4j/3.2.19 (at.techbee.jtx)
BEGIN:VTODO
DTSTAMP:20260329T054801Z
UID:66e80b0c-88d9-4eb8-9d66-6bf667b82106@openclaw
SEQUENCE:2
CREATED:20260326T163301Z
LAST-MODIFIED:20260329T054630Z
SUMMARY:把USC的电脑寄回去
STATUS:COMPLETED
COMPLETED:20260329T054630Z
PERCENT-COMPLETE:100
PRIORITY:5
DUE;VALUE=DATE:20260405
BEGIN:VALARM
ACTION:DISPLAY
TRIGGER:-P1D
DESCRIPTION:Todo: 把USC的电脑寄回去
END:VALARM
END:VTODO
END:VCALENDAR

View File

@@ -0,0 +1,26 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Apple Inc.//iOS 26.3.1//EN
CALSCALE:GREGORIAN
BEGIN:VTODO
COMPLETED:20260404T225027Z
CREATED:20260403T162816Z
DTSTAMP:20260404T135144Z
DUE;VALUE=DATE:20260405
LAST-MODIFIED:20260404T225027Z
PERCENT-COMPLETE:100
PRIORITY:1
SEQUENCE:2
STATUS:COMPLETED
SUMMARY:报销IUI费用到FSA
UID:906202b8-6df5-4ac2-bf3b-e59ffaddccd6@openclaw
BEGIN:VALARM
ACKNOWLEDGED:20260404T135140Z
ACTION:DISPLAY
DESCRIPTION:Reminder
TRIGGER:-P1D
UID:0A2D0B7D-0FDD-48B4-9E27-C54EBD3B120B
X-WR-ALARMUID:0A2D0B7D-0FDD-48B4-9E27-C54EBD3B120B
END:VALARM
END:VTODO
END:VCALENDAR

View File

@@ -0,0 +1,26 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Apple Inc.//iOS 26.3.1//EN
CALSCALE:GREGORIAN
BEGIN:VTODO
COMPLETED:20260405T154326Z
CREATED:20260327T164116Z
DTSTAMP:20260403T160300Z
DUE;VALUE=DATE:20260403
LAST-MODIFIED:20260405T154326Z
PERCENT-COMPLETE:100
PRIORITY:5
SEQUENCE:4
STATUS:COMPLETED
SUMMARY:跟进iui保险报销
UID:aa4868bb-b602-418f-8067-20d00fe2b27c@openclaw
BEGIN:VALARM
ACKNOWLEDGED:20260403T160300Z
ACTION:DISPLAY
DESCRIPTION:Reminder
TRIGGER:-P1D
UID:B9845E49-9FAE-4B7C-9CF3-877C37CF534D
X-WR-ALARMUID:B9845E49-9FAE-4B7C-9CF3-877C37CF534D
END:VALARM
END:VTODO
END:VCALENDAR

View File

@@ -2,14 +2,14 @@ BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//OpenClaw//Calendar//EN
BEGIN:VTODO
CREATED:20260322T214938Z
CREATED:20260403T162701Z
DESCRIPTION:询问iui报销相关事宜
DTSTAMP:20260322T214938Z
DUE;VALUE=DATE:20260404
DTSTAMP:20260403T162701Z
DUE;VALUE=DATE:20260410
PRIORITY:5
STATUS:NEEDS-ACTION
SUMMARY:打电话给progyny问iui报销
UID:1a6aec16-5981-4035-a8a1-2ca1f0854956@openclaw
UID:bbfa2934-f7fd-4444-9c33-e8569f9a7ceb@openclaw
BEGIN:VALARM
ACTION:DISPLAY
DESCRIPTION:Todo: 打电话给progyny问iui报销

View File

@@ -0,0 +1,18 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//OpenClaw//Calendar//EN
BEGIN:VTODO
CREATED:20260403T163408Z
DTSTAMP:20260403T163408Z
DUE;VALUE=DATE:20260408
PRIORITY:5
STATUS:NEEDS-ACTION
SUMMARY:发complain信
UID:d708aad8-9f8c-4e39-806b-f7dfc29e1d88@openclaw
BEGIN:VALARM
ACTION:DISPLAY
DESCRIPTION:Todo: 发complain信
TRIGGER:-P1D
END:VALARM
END:VTODO
END:VCALENDAR

View File

@@ -0,0 +1,8 @@
BEGIN:VCARD
VERSION:3.0
PRODID:-//OpenClaw//Contacts//EN
UID:2c48a2f9-fda7-4ad7-bb12-13068e9ef92e@openclaw
FN:小橘子
EMAIL;TYPE=WORK:Erica.Jiang@anderson.ucla.edu
EMAIL;TYPE=PERSONAL:xueweijiang0313@gmail.com
END:VCARD

View File

@@ -0,0 +1,7 @@
BEGIN:VCARD
VERSION:3.0
PRODID:-//OpenClaw//Contacts//EN
UID:3eaa4b2c-7246-4c9a-9720-fb9b865b45d1@openclaw
FN:小鹿
EMAIL;TYPE=PERSONAL:mail@luyx.org
END:VCARD

View File

@@ -343,6 +343,7 @@ def cmd_scan(config, recent=None, dry_run=False):
email_data = build_email_data(envelope, body, config)
print(f"{email_data['subject'][:55]}")
print(f" From: {email_data['sender'][:60]}")
# Run the LLM classifier (returns tags instead of confidence)
action, tags, summary, reason, duration = classifier.classify_email(

195
scripts/himalaya.sh Executable file
View File

@@ -0,0 +1,195 @@
#!/usr/bin/env bash
# himalaya wrapper — validates outbound email recipients against the contacts list.
#
# Drop-in replacement for himalaya. All commands pass through unchanged except
# those that send email, which first validate To/Cc/Bcc recipients.
#
# Gated commands:
# message send — parses MIME headers from stdin
# template send — parses MML headers from stdin
# message write — parses -H header flags from args
#
# All other commands (envelope list, message read, message delete, folder,
# flag, attachment, account, etc.) pass through directly.
#
# Usage: use this script wherever you would use `himalaya`.
set -euo pipefail
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
# Find the real himalaya binary (skip this script if it's in PATH)
SCRIPT_PATH="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
HIMALAYA=""
while IFS= read -r candidate; do
resolved="$(cd "$(dirname "$candidate")" && pwd)/$(basename "$candidate")"
if [[ "$resolved" != "$SCRIPT_PATH" ]]; then
HIMALAYA="$candidate"
break
fi
done < <(which -a himalaya 2>/dev/null || true)
if [[ -z "$HIMALAYA" ]]; then
# Fallback: check common locations
for path in "$HOME/.local/bin/himalaya" /usr/local/bin/himalaya /usr/bin/himalaya; do
if [[ -x "$path" ]]; then
HIMALAYA="$path"
break
fi
done
fi
if [[ -z "$HIMALAYA" ]]; then
echo "Error: himalaya binary not found" >&2
exit 1
fi
CONTACTS="$(cd "$(dirname "$0")/../skills/contacts/scripts" && pwd)/contacts.py"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
validate_address() {
local addr="$1"
# Skip empty addresses
[[ -z "$addr" ]] && return 0
# Validate against contacts
python3 "$CONTACTS" resolve "$addr" > /dev/null 2>&1
return $?
}
# Extract email address from "Display Name <email>" or bare "email" format
extract_email() {
local raw="$1"
raw="$(echo "$raw" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
if [[ "$raw" == *"<"*">"* ]]; then
echo "$raw" | sed 's/.*<//;s/>.*//'
else
echo "$raw"
fi
}
# Validate a comma-separated list of addresses. Prints errors to stderr.
# Returns 0 if all valid, 1 if any invalid.
validate_address_list() {
local header_value="$1"
local all_valid=0
# Split on commas
while IFS= read -r addr; do
addr="$(extract_email "$addr")"
[[ -z "$addr" ]] && continue
if ! validate_address "$addr"; then
all_valid=1
fi
done < <(echo "$header_value" | tr ',' '\n')
return $all_valid
}
# Parse To/Cc/Bcc from MIME/MML headers in a file.
# Headers end at the first blank line.
validate_stdin_headers() {
local tmpfile="$1"
local failed=0
# Extract header block (everything before first blank line)
while IFS= read -r line; do
# Stop at blank line (end of headers)
[[ -z "$line" ]] && break
# Match To:, Cc:, Bcc: headers (case-insensitive)
if echo "$line" | grep -iqE '^(to|cc|bcc):'; then
local value
value="$(echo "$line" | sed 's/^[^:]*:[[:space:]]*//')"
if ! validate_address_list "$value"; then
failed=1
fi
fi
done < "$tmpfile"
return $failed
}
# ---------------------------------------------------------------------------
# Detect sending commands
# ---------------------------------------------------------------------------
# Collect all args into a string for pattern matching
ALL_ARGS="$*"
# Check if this is a sending command
is_stdin_send=false
is_write_send=false
# "message send" or "template send" — reads from stdin
if echo "$ALL_ARGS" | grep -qE '(message|template)[[:space:]]+send'; then
is_stdin_send=true
fi
# "message write" — may have -H flags with recipients
if echo "$ALL_ARGS" | grep -qE 'message[[:space:]]+write'; then
is_write_send=true
fi
# ---------------------------------------------------------------------------
# Handle stdin-based sends (message send, template send)
# ---------------------------------------------------------------------------
if $is_stdin_send; then
# Read stdin into temp file
tmpfile="$(mktemp)"
trap 'rm -f "$tmpfile"' EXIT
cat > "$tmpfile"
# Validate recipients from headers
if ! validate_stdin_headers "$tmpfile"; then
exit 1
fi
# Pass through to real himalaya
cat "$tmpfile" | exec "$HIMALAYA" "$@"
exit $?
fi
# ---------------------------------------------------------------------------
# Handle message write with -H flags
# ---------------------------------------------------------------------------
if $is_write_send; then
# Parse -H flags for To/Cc/Bcc without consuming args
failed=0
original_args=("$@")
while [[ $# -gt 0 ]]; do
case "$1" in
-H)
shift
if [[ $# -gt 0 ]]; then
header="$1"
if echo "$header" | grep -iqE '^(to|cc|bcc):'; then
value="$(echo "$header" | sed 's/^[^:]*:[[:space:]]*//')"
if ! validate_address_list "$value"; then
failed=1
fi
fi
fi
;;
esac
shift
done
if [[ $failed -ne 0 ]]; then
exit 1
fi
exec "$HIMALAYA" "${original_args[@]}"
fi
# ---------------------------------------------------------------------------
# Pass through everything else
# ---------------------------------------------------------------------------
exec "$HIMALAYA" "$@"

View File

@@ -20,7 +20,16 @@ See `TESTING.md` for dry-run and live test steps, verification checklists, and t
- `khal` for reading calendar (optional but recommended)
- Runs via `uv run` (dependencies managed in `pyproject.toml`)
## Important: Email Sending Rules
## Important: Recipient Validation
The `send` command **only accepts recipients that exist in the contacts list**. This prevents hallucinated email addresses.
- `--to "小橘子:work"` — resolves contact name + email type
- `--to "小橘子"` — resolves by name (errors if contact has multiple emails)
- `--to "user@example.com"` — accepted only if the email exists in contacts
- Unknown addresses are **rejected** with the available contacts list shown
**Adding contacts and sending invites are separate operations.** Do not add a contact and send to it in the same request — contact additions should be a deliberate, user-initiated action.
Calendar invites are outbound emails. Follow the workspace email rules:
- **youlu@luyanxin.com -> mail@luyx.org**: send directly, no confirmation needed
@@ -57,20 +66,22 @@ $SKILL_DIR/scripts/calendar.sh todo check
## Sending Invites
```bash
# --to accepts contact names (resolved via contacts list)
$SKILL_DIR/scripts/calendar.sh send \
--to "friend@example.com" \
--to "小橘子:work" \
--subject "Lunch on Friday" \
--summary "Lunch at Tartine" \
--start "2026-03-20T12:00:00" \
--end "2026-03-20T13:00:00" \
--location "Tartine Bakery, SF"
--location "Tartine Bakery, SF" \
--alarm 1h
```
### Send Options
| Flag | Required | Description |
|-----------------|----------|------------------------------------------------|
| `--to` | Yes | Recipient(s), comma-separated |
| `--to` | Yes | Recipient(s) contact name, name:type, or known email |
| `--subject` | Yes | Email subject line |
| `--summary` | Yes | Event title (shown on calendar) |
| `--start` | Yes | Start time, ISO 8601 (`2026-03-20T14:00:00`) |
@@ -82,6 +93,7 @@ $SKILL_DIR/scripts/calendar.sh send \
| `--organizer` | No | Organizer display name (defaults to `--from`) |
| `--rrule` | No | Recurrence rule (e.g. `FREQ=WEEKLY;COUNT=13;BYDAY=TU`) |
| `--uid` | No | Custom event UID (auto-generated if omitted) |
| `--alarm` | No | Reminder trigger: `1d`, `2h`, `30m` (default: `1d`) |
| `--account` | No | Himalaya account name (if not default) |
| `--dry-run` | No | Print ICS + MIME without sending |
@@ -159,18 +171,41 @@ $SKILL_DIR/scripts/calendar.sh event list --format "{uid} {title}"
# Custom date range
$SKILL_DIR/scripts/calendar.sh event list --range-start "2026-04-01" --range-end "2026-04-30"
# Delete by UID
$SKILL_DIR/scripts/calendar.sh event delete --uid "abc123@openclaw"
# Delete a single (non-recurring) event
$SKILL_DIR/scripts/calendar.sh event delete --match "Lunch at Tartine"
# Delete by summary match
$SKILL_DIR/scripts/calendar.sh event delete --match "Allergy Shot (Tue)"
# Cancel ONE occurrence of a recurring event (adds EXDATE, keeps the series)
$SKILL_DIR/scripts/calendar.sh event delete --match "Allergy Shot" --date "2026-03-28"
# Delete an entire recurring series (requires --all safety flag)
$SKILL_DIR/scripts/calendar.sh event delete --match "Allergy Shot" --all
```
### Event Delete Safety
- Deletes only ONE event at a time. If multiple events match, it lists them and exits.
- **Recurring events require `--date` or `--all`**. Without either flag, the tool refuses to act and shows usage.
- `--date YYYY-MM-DD`: Adds an EXDATE to skip that one occurrence. The rest of the series continues.
- `--all`: Deletes the entire .ics file (the whole series). Use only when the user explicitly wants to cancel all future occurrences.
- **NEVER use `rm` on calendar .ics files directly.** Always use `event delete`.
- After deleting, verify with `event list` or `khal list`.
- After deleting/cancelling, verify with `event list` or `khal list`.
---
## Recipient Resolution
The `send --to` flag delegates to the **contacts skill** (`skills/contacts/`) for address resolution. See the contacts skill SKILL.md for full documentation on adding/managing contacts.
```bash
# By name (works when contact has a single email)
$SKILL_DIR/scripts/calendar.sh send --to "小鹿" ...
# By name + type (required when contact has multiple emails)
$SKILL_DIR/scripts/calendar.sh send --to "小橘子:work" ...
# By raw email (must exist in contacts)
$SKILL_DIR/scripts/calendar.sh send --to "Erica.Jiang@anderson.ucla.edu" ...
```
---

View File

@@ -1,9 +1,12 @@
# Testing the Calendar Skill
End-to-end tests for send, reply, todo, calendar sync, and local calendar. All commands use `--dry-run` first, then live.
End-to-end tests for contacts, send, reply, todo, calendar sync, and local calendar. All commands use `--dry-run` first, then live.
**Important**: Tests 1-3 (contacts) must run first — `send` requires recipients to be in the contacts list.
```bash
SKILL_DIR=~/.openclaw/workspace/skills/calendar
CONTACTS_DIR=~/.openclaw/workspace/skills/contacts
# Use a date 3 days from now for test events
TEST_DATE=$(date -d "+3 days" +%Y-%m-%d)
@@ -11,31 +14,154 @@ TEST_DATE=$(date -d "+3 days" +%Y-%m-%d)
---
## 1. Dry Run: Send Invite
## 1. Contact Add and List
Set up test contacts needed for send tests.
```bash
# Add a contact with a single email
$CONTACTS_DIR/scripts/contacts.sh add --name "测试用户" --email "mail@luyx.org"
# List contacts
$CONTACTS_DIR/scripts/contacts.sh list
# Add a contact with typed email and nickname
$CONTACTS_DIR/scripts/contacts.sh add --name "测试多邮箱" --email "work@example.com" --type work --nickname "多邮箱"
# Add a second email to the same contact
$CONTACTS_DIR/scripts/contacts.sh add --name "测试多邮箱" --email "home@example.com" --type home
# List again — should show both emails
$CONTACTS_DIR/scripts/contacts.sh list
```
**Verify:**
- [ ] `contact add` prints "Added contact: ..."
- [ ] Second `contact add` prints "Updated contact: ... — added ..."
- [ ] `contact list` shows all contacts with email types
- [ ] `.vcf` files created in `~/.openclaw/workspace/contacts/family/`
## 2. Recipient Resolution (Send Validation)
Test that `send --to` correctly resolves contacts and rejects unknown addresses.
```bash
# Name resolves (single email contact) — should work
$SKILL_DIR/scripts/calendar.sh send \
--to "测试用户" \
--subject "Resolve Test" --summary "Resolve Test" \
--start "${TEST_DATE}T15:00:00" --end "${TEST_DATE}T16:00:00" \
--dry-run
# Name:type resolves — should work
$SKILL_DIR/scripts/calendar.sh send \
--to "测试多邮箱:work" \
--subject "Resolve Test" --summary "Resolve Test" \
--start "${TEST_DATE}T15:00:00" --end "${TEST_DATE}T16:00:00" \
--dry-run
# Nickname resolves — should work
$SKILL_DIR/scripts/calendar.sh send \
--to "多邮箱:home" \
--subject "Resolve Test" --summary "Resolve Test" \
--start "${TEST_DATE}T15:00:00" --end "${TEST_DATE}T16:00:00" \
--dry-run
# Known raw email resolves — should work
$SKILL_DIR/scripts/calendar.sh send \
--to "mail@luyx.org" \
--subject "Resolve Test" --summary "Resolve Test" \
--start "${TEST_DATE}T15:00:00" --end "${TEST_DATE}T16:00:00" \
--dry-run
# Unknown email REJECTED — should FAIL
$SKILL_DIR/scripts/calendar.sh send \
--to "xiaojuzi@meta.com" \
--subject "Resolve Test" --summary "Resolve Test" \
--start "${TEST_DATE}T15:00:00" --end "${TEST_DATE}T16:00:00" \
--dry-run
# Multi-email without type REJECTED — should FAIL (ambiguous)
$SKILL_DIR/scripts/calendar.sh send \
--to "测试多邮箱" \
--subject "Resolve Test" --summary "Resolve Test" \
--start "${TEST_DATE}T15:00:00" --end "${TEST_DATE}T16:00:00" \
--dry-run
# Unknown name REJECTED — should FAIL
$SKILL_DIR/scripts/calendar.sh send \
--to "不存在的人" \
--subject "Resolve Test" --summary "Resolve Test" \
--start "${TEST_DATE}T15:00:00" --end "${TEST_DATE}T16:00:00" \
--dry-run
```
**Verify:**
- [ ] First 4 commands succeed (show ICS output)
- [ ] Unknown email fails with "not found in contacts" + available contacts list
- [ ] Multi-email without type fails with "has multiple emails. Specify type"
- [ ] Unknown name fails with "not found" + available contacts list
## 3. Contact Delete
```bash
# Delete the multi-email test contact
$CONTACTS_DIR/scripts/contacts.sh delete --name "测试多邮箱"
# Verify it's gone
$CONTACTS_DIR/scripts/contacts.sh list
# Delete by nickname — should fail (contact already deleted)
$CONTACTS_DIR/scripts/contacts.sh delete --name "多邮箱"
```
**Verify:**
- [ ] Delete prints "Deleted contact: 测试多邮箱"
- [ ] `contact list` no longer shows that contact
- [ ] Second delete fails with "No contact matching"
- [ ] `.vcf` file removed from contacts dir
---
## 4. Dry Run: Send Invite
**Prerequisite**: "测试用户" contact from test 1 must exist.
Generates the ICS and MIME email without sending. Check that:
- ICS has `METHOD:REQUEST`
- MIME has `Content-Type: text/calendar; method=REQUEST`
- Only `--to` recipients appear as attendees
- Times and timezone look correct
- ICS has `BEGIN:VALARM` with correct `TRIGGER` duration
```bash
# Default alarm (1 day before)
$SKILL_DIR/scripts/calendar.sh send \
--to "mail@luyx.org" \
--to "测试用户" \
--subject "Test Invite" \
--summary "Test Event" \
--start "${TEST_DATE}T15:00:00" \
--end "${TEST_DATE}T16:00:00" \
--dry-run
# Custom alarm (1 hour before)
$SKILL_DIR/scripts/calendar.sh send \
--to "测试用户" \
--subject "Test Invite (1h alarm)" \
--summary "Test Event (1h alarm)" \
--start "${TEST_DATE}T15:00:00" \
--end "${TEST_DATE}T16:00:00" \
--alarm 1h \
--dry-run
```
## 2. Live Send: Self-Invite
## 5. Live Send: Self-Invite
Send a real invite to `mail@luyx.org` only (no confirmation needed per email rules).
```bash
$SKILL_DIR/scripts/calendar.sh send \
--to "mail@luyx.org" \
--to "测试用户" \
--subject "Calendar Skill Test" \
--summary "Calendar Skill Test" \
--start "${TEST_DATE}T15:00:00" \
@@ -49,7 +175,7 @@ $SKILL_DIR/scripts/calendar.sh send \
- [ ] Email shows Accept/Decline/Tentative buttons (not just an attachment)
- [ ] `.ics` file saved to `~/.openclaw/workspace/calendars/home/`
## 3. Verify Calendar Sync and Local Calendar
## 6. Verify Calendar Sync and Local Calendar
After sending in step 2, check that the event synced and appears locally.
@@ -70,7 +196,7 @@ khal list "$TEST_DATE"
- [ ] `.ics` file exists in `~/.openclaw/workspace/calendars/home/`
- [ ] `khal list` shows "Calendar Skill Test" on the test date
## 4. Reply: Accept the Self-Invite
## 7. Reply: Accept the Self-Invite
The invite sent in step 2 should be in the inbox. Find it, then accept it. This tests the full reply flow without needing an external sender.
@@ -93,14 +219,14 @@ $SKILL_DIR/scripts/calendar.sh reply \
- [ ] `vdirsyncer sync` ran
- [ ] `khal list "$TEST_DATE"` still shows the event
## 5. Reply: Decline an Invite
## 8. Reply: Decline an Invite
Send another self-invite, then decline it. This verifies decline removes the event from local calendar.
```bash
# Send a second test invite
$SKILL_DIR/scripts/calendar.sh send \
--to "mail@luyx.org" \
--to "测试用户" \
--subject "Decline Test" \
--summary "Decline Test Event" \
--start "${TEST_DATE}T17:00:00" \
@@ -121,7 +247,7 @@ $SKILL_DIR/scripts/calendar.sh reply \
- [ ] Event removed from local calendar
- [ ] `khal list "$TEST_DATE"` does NOT show "Decline Test Event"
## 6. Verify Final Calendar State
## 9. Verify Final Calendar State
After all tests, confirm the calendar is in a clean state.
@@ -138,7 +264,7 @@ khal list today 7d
---
## 7. Dry Run: Add Todo
## 10. Dry Run: Add Todo
Generates the VTODO ICS without saving. Check that:
- ICS has `BEGIN:VTODO`
@@ -154,7 +280,7 @@ $SKILL_DIR/scripts/calendar.sh todo add \
--dry-run
```
## 8. Live Add: Create a Todo
## 11. Live Add: Create a Todo
```bash
$SKILL_DIR/scripts/calendar.sh todo add \
@@ -170,7 +296,7 @@ $SKILL_DIR/scripts/calendar.sh todo add \
- [ ] `todo list` (todoman directly) shows "Test Todo"
- [ ] `vdirsyncer sync` ran
## 9. List Todos
## 12. List Todos
```bash
# Via our wrapper (formatted Chinese output)
@@ -188,7 +314,7 @@ $SKILL_DIR/scripts/calendar.sh todo list --all
- [ ] Priority grouping is correct in wrapper output
- [ ] `--all` flag works (same output when none are completed)
## 10. Edit a Todo
## 13. Edit a Todo
Change the due date and priority of the test todo from step 8.
@@ -236,7 +362,7 @@ $SKILL_DIR/scripts/calendar.sh todo edit --match "Test Todo"
# Should print "Nothing to change" message
```
## 11. Complete a Todo
## 14. Complete a Todo
```bash
$SKILL_DIR/scripts/calendar.sh todo complete --match "Test Todo"
@@ -248,7 +374,7 @@ $SKILL_DIR/scripts/calendar.sh todo complete --match "Test Todo"
- [ ] `$SKILL_DIR/scripts/calendar.sh todo list --all` — appears as completed (with checkmark)
- [ ] `vdirsyncer sync` ran
## 12. Delete a Todo
## 15. Delete a Todo
Create a second test todo, then delete it.
@@ -271,7 +397,7 @@ $SKILL_DIR/scripts/calendar.sh todo delete --match "Delete Me"
- [ ] `todo list` (todoman) does not show "Delete Me Todo"
- [ ] `vdirsyncer sync` ran
## 13. Todo Check (Cron Output)
## 16. Todo Check (Cron Output)
```bash
# Create a test todo
@@ -293,7 +419,7 @@ $SKILL_DIR/scripts/calendar.sh todo check
# Should produce no output
```
## 14. Dry Run: Recurring Event (--rrule)
## 17. Dry Run: Recurring Event (--rrule)
Test recurring event generation. Use a date that falls on a Tuesday.
@@ -302,7 +428,7 @@ Test recurring event generation. Use a date that falls on a Tuesday.
NEXT_TUE=$(python3 -c "from datetime import date,timedelta; d=date.today(); d+=timedelta((1-d.weekday())%7 or 7); print(d)")
$SKILL_DIR/scripts/calendar.sh send \
--to "mail@luyx.org" \
--to "测试用户" \
--subject "Recurring Test (Tue)" \
--summary "Recurring Test (Tue)" \
--start "${NEXT_TUE}T14:30:00" \
@@ -316,14 +442,14 @@ $SKILL_DIR/scripts/calendar.sh send \
- [ ] DTSTART falls on a Tuesday
- [ ] No validation errors
## 15. Validation: DTSTART/BYDAY Mismatch
## 18. Validation: DTSTART/BYDAY Mismatch
Verify the tool rejects mismatched DTSTART and BYDAY.
```bash
# This should FAIL — start is on a Tuesday but BYDAY=TH
$SKILL_DIR/scripts/calendar.sh send \
--to "mail@luyx.org" \
--to "测试用户" \
--subject "Mismatch Test" \
--summary "Mismatch Test" \
--start "${NEXT_TUE}T09:00:00" \
@@ -337,7 +463,7 @@ $SKILL_DIR/scripts/calendar.sh send \
- [ ] Error message says DTSTART falls on TU but RRULE says BYDAY=TH
- [ ] Suggests changing --start to a date that falls on TH
## 16. Event List
## 19. Event List
```bash
# List upcoming events
@@ -355,12 +481,12 @@ $SKILL_DIR/scripts/calendar.sh event list --format "{uid} {title}"
- [ ] Search narrows results correctly
- [ ] UIDs are displayed with --format
## 17. Event Delete
## 20. Event Delete
```bash
# Send a throwaway event first
$SKILL_DIR/scripts/calendar.sh send \
--to "mail@luyx.org" \
--to "测试用户" \
--subject "Delete Me Event" \
--summary "Delete Me Event" \
--start "${TEST_DATE}T20:00:00" \
@@ -382,11 +508,65 @@ $SKILL_DIR/scripts/calendar.sh event list --search "Delete Me"
- [ ] Other events are untouched
- [ ] `vdirsyncer sync` ran after delete
## 18. Regression: Existing Invite Commands
## 21. Event Delete: Cancel Single Occurrence (EXDATE)
Verify new features didn't break VEVENT flow.
Test that `--date` cancels one occurrence of a recurring event without deleting the series.
```bash
# Create a recurring event (weekly on Saturday, 4 weeks)
NEXT_SAT=$(python3 -c "from datetime import date,timedelta; d=date.today(); d+=timedelta((5-d.weekday())%7 or 7); print(d)")
$SKILL_DIR/scripts/calendar.sh send \
--to "测试用户" \
--subject "EXDATE Test (Sat)" \
--summary "EXDATE Test (Sat)" \
--start "${NEXT_SAT}T10:00:00" \
--end "${NEXT_SAT}T11:00:00" \
--rrule "FREQ=WEEKLY;COUNT=4;BYDAY=SA"
# Verify it exists
$SKILL_DIR/scripts/calendar.sh event list --search "EXDATE Test"
# Cancel just the first occurrence
$SKILL_DIR/scripts/calendar.sh event delete --match "EXDATE Test" --date "$NEXT_SAT"
# Verify: .ics file still exists (not deleted)
ls ~/.openclaw/workspace/calendars/home/ | grep -i exdate
```
**Verify:**
- [ ] `event delete --match ... --date ...` prints "Cancelled ... (added EXDATE, series continues)"
- [ ] `.ics` file still exists in calendar dir
- [ ] `khal list` no longer shows the cancelled date but shows subsequent Saturdays
## 22. Event Delete: Recurring Without --date or --all (Safety Guard)
```bash
# Try to delete the recurring event without --date or --all — should FAIL
$SKILL_DIR/scripts/calendar.sh event delete --match "EXDATE Test"
```
**Verify:**
- [ ] Script exits with error
- [ ] Error message explains the two options: `--date` or `--all`
## 23. Event Delete: Recurring With --all
```bash
# Delete the entire series
$SKILL_DIR/scripts/calendar.sh event delete --match "EXDATE Test" --all
```
**Verify:**
- [ ] .ics file is removed
- [ ] `event list --search "EXDATE Test"` shows nothing
## 24. Regression: Send Rejects Unknown Addresses
Verify that `send` no longer accepts arbitrary email addresses.
```bash
# This MUST fail — raw unknown email should be rejected
$SKILL_DIR/scripts/calendar.sh send \
--to "test@example.com" \
--subject "Regression Test" \
@@ -397,9 +577,9 @@ $SKILL_DIR/scripts/calendar.sh send \
```
**Verify:**
- [ ] ICS has `BEGIN:VEVENT`, `METHOD:REQUEST`
- [ ] No RRULE present (single event)
- [ ] No errors
- [ ] Command exits with error
- [ ] Error shows "not found in contacts" with available contacts list
- [ ] No ICS generated
---
@@ -441,3 +621,7 @@ todo list
| Recurring events on wrong day | DTSTART was not aligned with BYDAY. Delete the event and resend with correct `--start` |
| SMTP rate limit / EOF error | Too many sends too fast. Wait 10+ seconds between sends (Migadu limit) |
| Events disappeared after cleanup | **Never use `rm *.ics`** on calendar dirs. Use `event delete --match` instead |
| Recurring series deleted when cancelling one date | Use `--date YYYY-MM-DD` to add EXDATE, not bare `event delete` (which requires `--all` for recurring) |
| `send` rejects email address | Address not in contacts. Add with `contacts.sh add` first (separate from send) |
| `send` says "has multiple emails" | Contact has work+home emails. Use `name:type` syntax (e.g. `小橘子:work`) |
| Contacts dir empty after sync | Check vdirsyncer CardDAV pair is configured for `contacts/default/` |

View File

@@ -6,16 +6,16 @@ Uses the icalendar library for proper RFC 5545 ICS generation and parsing.
Uses himalaya CLI for email delivery. Syncs to local CalDAV via vdirsyncer.
Subcommands:
python calendar.py send [options] # create and send an invite (supports --rrule)
python calendar.py reply [options] # accept/decline/tentative
python calendar.py event list [options] # list/search calendar events
python calendar.py event delete [options] # delete an event by UID or summary
python calendar.py todo add [options] # create a VTODO task
python calendar.py todo list [options] # list pending tasks
python calendar.py todo edit [options] # edit a task's fields
python calendar.py todo complete [options] # mark task as done
python calendar.py todo delete [options] # remove a task
python calendar.py todo check # daily digest for cron
python calendar.py send [options] # create and send an invite (supports --rrule)
python calendar.py reply [options] # accept/decline/tentative
python calendar.py event list [options] # list/search calendar events
python calendar.py event delete [options] # delete an event by UID or summary
python calendar.py todo add [options] # create a VTODO task
python calendar.py todo list [options] # list pending tasks
python calendar.py todo edit [options] # edit a task's fields
python calendar.py todo complete [options] # mark task as done
python calendar.py todo delete [options] # remove a task
python calendar.py todo check # daily digest for cron
"""
import argparse
@@ -40,6 +40,7 @@ DEFAULT_TIMEZONE = "America/Los_Angeles"
DEFAULT_FROM = "youlu@luyanxin.com"
CALENDAR_DIR = Path.home() / ".openclaw" / "workspace" / "calendars" / "home"
TASKS_DIR = Path.home() / ".openclaw" / "workspace" / "calendars" / "tasks"
CONTACTS_SCRIPT = Path(__file__).resolve().parent.parent.parent / "contacts" / "scripts" / "contacts.py"
PRODID = "-//OpenClaw//Calendar//EN"
# RFC 5545 priority mapping
@@ -64,9 +65,12 @@ def _sync_calendar():
print("Warning: CalDAV sync failed (will retry on next heartbeat)")
HIMALAYA_WRAPPER = Path(__file__).resolve().parent.parent.parent.parent / "scripts" / "himalaya.sh"
def _send_email(email_str, account=None):
"""Send a raw MIME email via himalaya message send (stdin)."""
cmd = ["himalaya"]
"""Send a raw MIME email via himalaya wrapper (validates recipients)."""
cmd = [str(HIMALAYA_WRAPPER)]
if account:
cmd += ["--account", account]
cmd += ["message", "send"]
@@ -161,6 +165,28 @@ def _validate_rrule_dtstart(rrule_dict, dtstart):
sys.exit(1)
# ---------------------------------------------------------------------------
# Contacts (delegates to contacts skill)
# ---------------------------------------------------------------------------
def _resolve_recipient(to_str):
"""Resolve a --to value to an email address via the contacts skill.
Delegates to contacts.py resolve, which validates against the contact list.
Exits with error if the address is not in contacts.
"""
result = subprocess.run(
[sys.executable, str(CONTACTS_SCRIPT), "resolve", to_str],
capture_output=True, text=True,
)
if result.returncode != 0:
# Print the contacts script's error output directly
print(result.stderr, end="", file=sys.stderr)
sys.exit(1)
return result.stdout.strip()
# ---------------------------------------------------------------------------
# Send invite
# ---------------------------------------------------------------------------
@@ -196,7 +222,7 @@ def cmd_send(args):
if args.description:
event.add("description", args.description)
recipients = [addr.strip() for addr in args.to.split(",")]
recipients = [_resolve_recipient(addr.strip()) for addr in args.to.split(",")]
for addr in recipients:
event.add("attendee", f"mailto:{addr}", parameters={
@@ -210,11 +236,20 @@ def cmd_send(args):
_validate_rrule_dtstart(rrule, start)
event.add("rrule", rrule)
# 1-day reminder
# Reminder alarm
alarm_trigger = timedelta(days=-1) # default
if args.alarm:
alarm_str = args.alarm
if alarm_str.endswith("d"):
alarm_trigger = timedelta(days=-int(alarm_str[:-1]))
elif alarm_str.endswith("h"):
alarm_trigger = timedelta(hours=-int(alarm_str[:-1]))
elif alarm_str.endswith("m"):
alarm_trigger = timedelta(minutes=-int(alarm_str[:-1]))
alarm = Alarm()
alarm.add("action", "DISPLAY")
alarm.add("description", f"Reminder: {args.summary}")
alarm.add("trigger", timedelta(days=-1))
alarm.add("trigger", alarm_trigger)
event.add_component(alarm)
cal.add_component(event)
@@ -729,7 +764,14 @@ def cmd_event_list(args):
def cmd_event_delete(args):
"""Delete a calendar event by UID or summary match."""
"""Delete a calendar event or cancel a single occurrence of a recurring event.
For recurring events:
--date YYYY-MM-DD Cancel one occurrence by adding EXDATE (keeps the series)
--all Delete the entire series (required safety flag)
Without --date or --all on a recurring event, the command refuses to act.
"""
if not args.uid and not args.match:
print("Error: --uid or --match is required", file=sys.stderr)
sys.exit(1)
@@ -755,9 +797,9 @@ def cmd_event_delete(args):
uid = str(component.get("uid", ""))
summary = str(component.get("summary", ""))
if args.uid and args.uid in uid:
matches.append((ics_path, uid, summary))
matches.append((ics_path, uid, summary, component))
elif args.match and args.match in summary:
matches.append((ics_path, uid, summary))
matches.append((ics_path, uid, summary, component))
if not matches:
target = args.uid or args.match
@@ -765,16 +807,72 @@ def cmd_event_delete(args):
sys.exit(1)
if len(matches) > 1:
print(f"Error: Multiple events match:", file=sys.stderr)
for _, uid, summary in matches:
for _, uid, summary, _ in matches:
print(f" - {summary} (uid: {uid})", file=sys.stderr)
sys.exit(1)
ics_path, uid, summary = matches[0]
ics_path.unlink()
print(f"Deleted event: {summary} (uid: {uid})")
ics_path, uid, summary, vevent = matches[0]
has_rrule = vevent.get("rrule") is not None
if has_rrule and not args.date and not args.all:
print(
f"Error: '{summary}' is a recurring event. Use one of:\n"
f" --date YYYY-MM-DD Cancel a single occurrence\n"
f" --all Delete the entire series",
file=sys.stderr,
)
sys.exit(1)
if args.date and has_rrule:
# Add EXDATE to cancel a single occurrence
_add_exdate(ics_path, vevent, args.date, summary, uid)
else:
# Delete the entire event (single event, or recurring with --all)
ics_path.unlink()
if has_rrule:
print(f"Deleted recurring event series: {summary} (uid: {uid})")
else:
print(f"Deleted event: {summary} (uid: {uid})")
_sync_calendar()
def _add_exdate(ics_path, vevent, date_str, summary, uid):
"""Add an EXDATE to a recurring event to cancel a single occurrence."""
exclude_date = _parse_date(date_str)
# Verify the date is a valid occurrence (matches the RRULE pattern)
dtstart = vevent.get("dtstart").dt
rrule = vevent.get("rrule")
# Check if dtstart is a datetime or date
if isinstance(dtstart, datetime):
start_date = dtstart.date() if hasattr(dtstart, 'date') else dtstart
else:
start_date = dtstart
if exclude_date < start_date:
print(f"Error: --date {date_str} is before the event start ({start_date})", file=sys.stderr)
sys.exit(1)
# Re-read and modify the ICS file to add EXDATE
cal = Calendar.from_ical(ics_path.read_bytes())
for component in cal.walk():
if component.name == "VEVENT" and str(component.get("uid", "")) == uid:
# EXDATE value type must match DTSTART value type
if isinstance(dtstart, datetime):
# Use a datetime with the same time as DTSTART
exclude_dt = datetime.combine(exclude_date, dtstart.time())
component.add("exdate", [exclude_dt], parameters={"TZID": vevent.get("dtstart").params.get("TZID", DEFAULT_TIMEZONE)})
else:
component.add("exdate", [exclude_date])
break
ics_path.write_bytes(cal.to_ical())
print(f"Cancelled {summary} on {date_str} (added EXDATE, series continues)")
print(f"Updated: {ics_path}")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
@@ -798,6 +896,7 @@ def main():
send_p.add_argument("--rrule", default="", help="RRULE string (e.g. FREQ=WEEKLY;COUNT=13;BYDAY=TU)")
send_p.add_argument("--uid", default="", help="Custom event UID")
send_p.add_argument("--account", default="", help="Himalaya account")
send_p.add_argument("--alarm", default="1d", help="Reminder trigger (e.g. 1d, 2h, 30m)")
send_p.add_argument("--dry-run", action="store_true", help="Preview without sending")
# --- reply ---
@@ -823,9 +922,11 @@ def main():
elist_p.add_argument("--format", default="", help="khal format string (e.g. '{uid} {title}')")
# event delete
edel_p = event_sub.add_parser("delete", help="Delete an event")
edel_p = event_sub.add_parser("delete", help="Delete an event or cancel one occurrence")
edel_p.add_argument("--uid", default="", help="Event UID")
edel_p.add_argument("--match", default="", help="Match on summary text")
edel_p.add_argument("--date", default="", help="Cancel single occurrence on this date (YYYY-MM-DD, for recurring events)")
edel_p.add_argument("--all", action="store_true", help="Delete entire recurring series (safety flag)")
# --- todo ---
todo_p = subparsers.add_parser("todo", help="Manage VTODO tasks")

67
skills/contacts/SKILL.md Normal file
View File

@@ -0,0 +1,67 @@
---
name: contacts
description: "Contact management with CardDAV sync. Validates email recipients for calendar invites and himalaya email sending. Prevents hallucinated addresses."
metadata: {"clawdbot":{"emoji":"📇","requires":{"bins":["python3","vdirsyncer"]}}}
---
# Contacts
Manage a local vCard contact list synced to Migadu CardDAV via vdirsyncer. Used by the calendar tool and himalaya wrapper to validate recipient addresses before sending.
## Why This Exists
LLMs can hallucinate email addresses — inventing plausible-looking addresses from context instead of looking up the correct one. This contact list serves as a **tool-level allowlist**: outbound emails can only go to addresses that exist in the contacts.
**Adding contacts and sending emails are separate operations.** Never add a contact and send to it in the same request.
## Prerequisites
- Python 3 (no external dependencies)
- `vdirsyncer` configured with a CardDAV pair for contacts sync
- Contacts directory: `~/.openclaw/workspace/contacts/` (subdirs per address book, e.g. `family/`, `business/`)
## Usage
```bash
SKILL_DIR=~/.openclaw/workspace/skills/contacts
# List all contacts
$SKILL_DIR/scripts/contacts.sh list
# Add a contact (single email)
$SKILL_DIR/scripts/contacts.sh add --name "小鹿" --email "mail@luyx.org"
# Add with email type and nickname
$SKILL_DIR/scripts/contacts.sh add --name "小橘子" --email "Erica.Jiang@anderson.ucla.edu" --type work --nickname "小橘子"
# Add a second email to an existing contact
$SKILL_DIR/scripts/contacts.sh add --name "小橘子" --email "xueweijiang0313@gmail.com" --type home
# Resolve a name to email address (used by other tools)
$SKILL_DIR/scripts/contacts.sh resolve "小橘子:work"
# Output: Erica.Jiang@anderson.ucla.edu
# Delete a contact
$SKILL_DIR/scripts/contacts.sh delete --name "小橘子"
```
## Resolve Formats
The `resolve` command accepts three formats:
| Format | Example | Behavior |
|--------|---------|----------|
| Name | `小橘子` | Match by FN or NICKNAME. Error if multiple emails (use type). |
| Name:type | `小橘子:work` | Match by name, select email by type. |
| Raw email | `user@example.com` | Accept only if the email exists in contacts. |
Unknown addresses are **rejected** with the available contacts list shown.
## Integration
- **Calendar tool** (`cal_tool.py`): `send --to` calls `contacts.py resolve` before sending invites
- **Himalaya wrapper** (`himalaya.sh`): validates To/Cc/Bcc headers before passing to himalaya for email delivery
## Data
Contacts are stored as vCard 3.0 `.vcf` files in `~/.openclaw/workspace/contacts/<collection>/`, synced to Migadu CardDAV via vdirsyncer. Collections match Migadu address books (e.g. `family/`, `business/`). New contacts are added to `family/` by default. The resolve command searches all collections.

View File

@@ -0,0 +1,349 @@
#!/usr/bin/env python3
"""
Contacts — Manage a local vCard contact list synced via CardDAV.
Used by the himalaya wrapper and calendar tool to validate recipient
addresses before sending. Prevents hallucinated email addresses.
Subcommands:
contacts.py list # list all contacts
contacts.py add [options] # add a contact
contacts.py delete [options] # delete a contact
contacts.py resolve <name|name:type|email> # resolve to email address
"""
import argparse
import subprocess
import sys
import uuid
from pathlib import Path
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
CONTACTS_ROOT = Path.home() / ".openclaw" / "workspace" / "contacts"
DEFAULT_COLLECTION = "family" # default address book for new contacts
PRODID = "-//OpenClaw//Contacts//EN"
# ---------------------------------------------------------------------------
# vCard parsing
# ---------------------------------------------------------------------------
def _parse_vcf(path):
"""Parse a .vcf file into a dict with fn, nickname, emails, uid."""
text = path.read_text(encoding="utf-8", errors="replace")
result = {"fn": "", "nickname": "", "emails": [], "uid": "", "path": path}
for line in text.splitlines():
line_upper = line.upper()
if line_upper.startswith("FN:"):
result["fn"] = line.split(":", 1)[1].strip()
elif line_upper.startswith("NICKNAME:"):
result["nickname"] = line.split(":", 1)[1].strip()
elif line_upper.startswith("UID:"):
result["uid"] = line.split(":", 1)[1].strip()
elif "EMAIL" in line_upper and ":" in line:
# Handle EMAIL;TYPE=WORK:addr and EMAIL:addr
parts = line.split(":", 1)
email_addr = parts[1].strip()
email_type = ""
param_part = parts[0].upper()
if "TYPE=" in param_part:
for param in param_part.split(";"):
if param.startswith("TYPE="):
email_type = param.split("=", 1)[1].lower()
result["emails"].append({"address": email_addr, "type": email_type})
return result
def _load_contacts():
"""Load all contacts from all collections under CONTACTS_ROOT."""
if not CONTACTS_ROOT.is_dir():
return []
contacts = []
for subdir in sorted(CONTACTS_ROOT.iterdir()):
if not subdir.is_dir():
continue
for vcf_path in sorted(subdir.glob("*.vcf")):
try:
contact = _parse_vcf(vcf_path)
if contact["fn"] or contact["emails"]:
contacts.append(contact)
except Exception:
continue
return contacts
def _build_vcf(fn, emails, nickname="", uid=""):
"""Build a vCard 3.0 string."""
uid = uid or f"{uuid.uuid4()}@openclaw"
lines = [
"BEGIN:VCARD",
"VERSION:3.0",
f"PRODID:{PRODID}",
f"UID:{uid}",
f"FN:{fn}",
]
if nickname:
lines.append(f"NICKNAME:{nickname}")
for e in emails:
if e.get("type"):
lines.append(f"EMAIL;TYPE={e['type'].upper()}:{e['address']}")
else:
lines.append(f"EMAIL:{e['address']}")
lines.append("END:VCARD")
return "\n".join(lines) + "\n"
def _print_contact_error(message, contacts):
"""Print error with available contacts list."""
print(f"Error: {message}", file=sys.stderr)
if contacts:
print("Available contacts:", file=sys.stderr)
for c in contacts:
name = c["fn"]
nick = f" ({c['nickname']})" if c["nickname"] and c["nickname"] != c["fn"] else ""
for e in c["emails"]:
label = f" [{e['type']}]" if e["type"] else ""
print(f" {name}{nick}{label} <{e['address']}>", file=sys.stderr)
else:
print("No contacts found. Add contacts with: contacts.sh add", file=sys.stderr)
def _format_contact(c):
"""Format a contact for display."""
name = c["fn"]
nick = f" ({c['nickname']})" if c["nickname"] and c["nickname"] != c["fn"] else ""
lines = []
for e in c["emails"]:
label = f" [{e['type']}]" if e["type"] else ""
lines.append(f" {name}{nick}{label} <{e['address']}>")
return "\n".join(lines)
def _sync_contacts():
"""Sync contacts to CardDAV server via vdirsyncer."""
try:
subprocess.run(
["vdirsyncer", "sync"],
capture_output=True, text=True, check=True,
)
print("Synced to CardDAV server")
except (subprocess.CalledProcessError, FileNotFoundError):
print("Warning: CardDAV sync failed (will retry on next heartbeat)")
# ---------------------------------------------------------------------------
# Resolve (used by himalaya wrapper and calendar tool)
# ---------------------------------------------------------------------------
def resolve(to_str):
"""Resolve a --to value to an email address via contacts lookup.
Supported formats:
"小橘子" -> match by FN or NICKNAME, use sole email or error if multiple
"小橘子:work" -> match by name, select email by TYPE
"user@example.com" -> match by email address in contacts
Returns the resolved email address string, or exits with error.
"""
contacts = _load_contacts()
# Format: "name:type"
if ":" in to_str and "@" not in to_str:
name_part, type_part = to_str.rsplit(":", 1)
type_part = type_part.lower()
else:
name_part = to_str
type_part = ""
# If it looks like a raw email, search contacts for it
if "@" in name_part:
for c in contacts:
for e in c["emails"]:
if e["address"].lower() == name_part.lower():
return e["address"]
_print_contact_error(f"Email '{name_part}' not found in contacts.", contacts)
sys.exit(1)
# Search by FN or NICKNAME (case-insensitive)
matches = []
for c in contacts:
if (c["fn"].lower() == name_part.lower() or
c["nickname"].lower() == name_part.lower()):
matches.append(c)
if not matches:
_print_contact_error(f"Contact '{name_part}' not found.", contacts)
sys.exit(1)
if len(matches) > 1:
_print_contact_error(f"Multiple contacts match '{name_part}'.", contacts)
sys.exit(1)
contact = matches[0]
emails = contact["emails"]
if not emails:
print(f"Error: Contact '{contact['fn']}' has no email addresses.", file=sys.stderr)
sys.exit(1)
# If type specified, filter by type
if type_part:
typed = [e for e in emails if e["type"] == type_part]
if not typed:
avail = ", ".join(f"{e['type'] or 'default'}={e['address']}" for e in emails)
print(f"Error: No '{type_part}' email for '{contact['fn']}'. Available: {avail}", file=sys.stderr)
sys.exit(1)
return typed[0]["address"]
# No type specified
if len(emails) == 1:
return emails[0]["address"]
# Multiple emails, require type qualifier
avail = ", ".join(f"{e['type'] or 'default'}={e['address']}" for e in emails)
print(
f"Error: '{contact['fn']}' has multiple emails. Specify type with '{name_part}:<type>'.\n"
f" Available: {avail}",
file=sys.stderr,
)
sys.exit(1)
# ---------------------------------------------------------------------------
# Subcommands
# ---------------------------------------------------------------------------
def cmd_list(args):
"""List all contacts."""
contacts = _load_contacts()
if not contacts:
print("No contacts found. Add contacts with: contacts.sh add")
return
for c in contacts:
print(_format_contact(c))
def cmd_add(args):
"""Add a new contact (creates or updates a .vcf file)."""
target_dir = CONTACTS_ROOT / DEFAULT_COLLECTION
target_dir.mkdir(parents=True, exist_ok=True)
emails = [{"address": args.email, "type": args.type or ""}]
nickname = args.nickname or ""
# Check if contact with same name already exists (update it)
existing = _load_contacts()
for c in existing:
if c["fn"].lower() == args.name.lower():
# Add email to existing contact if not duplicate
existing_addrs = {e["address"].lower() for e in c["emails"]}
if args.email.lower() in existing_addrs:
print(f"Contact '{args.name}' already has email {args.email}")
return
# Re-read and update
emails = c["emails"] + emails
nickname = nickname or c["nickname"]
vcf_str = _build_vcf(args.name, emails, nickname, c["uid"])
c["path"].write_text(vcf_str, encoding="utf-8")
print(f"Updated contact: {args.name} — added {args.email}")
_sync_contacts()
return
# New contact
uid = f"{uuid.uuid4()}@openclaw"
vcf_str = _build_vcf(args.name, emails, nickname, uid)
dest = target_dir / f"{uid}.vcf"
dest.write_text(vcf_str, encoding="utf-8")
print(f"Added contact: {args.name} <{args.email}>")
_sync_contacts()
def cmd_delete(args):
"""Delete a contact."""
if not args.name and not args.uid:
print("Error: --name or --uid is required", file=sys.stderr)
sys.exit(1)
contacts = _load_contacts()
if not contacts:
print("No contacts found.", file=sys.stderr)
sys.exit(1)
matches = []
for c in contacts:
if args.name:
if (c["fn"].lower() == args.name.lower() or
c["nickname"].lower() == args.name.lower()):
matches.append(c)
elif args.uid:
if args.uid in c["uid"]:
matches.append(c)
if not matches:
target = args.name or args.uid
_print_contact_error(f"No contact matching '{target}'.", contacts)
sys.exit(1)
if len(matches) > 1:
print(f"Error: Multiple contacts match:", file=sys.stderr)
for c in matches:
print(f" {c['fn']} (uid: {c['uid']})", file=sys.stderr)
sys.exit(1)
contact = matches[0]
contact["path"].unlink()
print(f"Deleted contact: {contact['fn']}")
_sync_contacts()
def cmd_resolve(args):
"""Resolve a name/alias to an email address. Prints the email to stdout."""
email = resolve(args.query)
print(email)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description="Contact management with CardDAV sync")
subparsers = parser.add_subparsers(dest="command", required=True)
# list
subparsers.add_parser("list", help="List all contacts")
# add
add_p = subparsers.add_parser("add", help="Add a contact")
add_p.add_argument("--name", required=True, help="Display name (e.g. 小橘子)")
add_p.add_argument("--email", required=True, help="Email address")
add_p.add_argument("--nickname", default="", help="Nickname for lookup")
add_p.add_argument("--type", default="", help="Email type (work, home)")
# delete
del_p = subparsers.add_parser("delete", help="Delete a contact")
del_p.add_argument("--name", default="", help="Contact name or nickname")
del_p.add_argument("--uid", default="", help="Contact UID")
# resolve
res_p = subparsers.add_parser("resolve", help="Resolve name to email address")
res_p.add_argument("query", help="Contact name, name:type, or email to resolve")
args = parser.parse_args()
if args.command == "list":
cmd_list(args)
elif args.command == "add":
cmd_add(args)
elif args.command == "delete":
cmd_delete(args)
elif args.command == "resolve":
cmd_resolve(args)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# contacts — manage the local contact list (vCard / CardDAV).
#
# Usage:
# ./contacts.sh list # list all contacts
# ./contacts.sh add [options] # add a contact
# ./contacts.sh delete [options] # delete a contact
# ./contacts.sh resolve <name|name:type|email> # resolve to email address
#
# No external dependencies (pure Python 3).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
exec python3 "$SCRIPT_DIR/contacts.py" "$@"

View File

@@ -9,6 +9,30 @@ metadata: {"clawdbot":{"emoji":"📧","requires":{"bins":["himalaya"]},"install"
Himalaya is a CLI email client that lets you manage emails from the terminal using IMAP, SMTP, Notmuch, or Sendmail backends.
## Recipient-Safe Wrapper
Use the wrapper script (`scripts/himalaya.sh`) instead of calling `himalaya` directly. It validates outbound email recipients against the contacts list (see `skills/contacts/`) before sending.
**Gated commands** (recipients validated before sending):
- `message send` — parses To/Cc/Bcc from MIME headers on stdin
- `template send` — parses To/Cc/Bcc from MML headers on stdin
- `message write` — parses `-H` header flags for To/Cc/Bcc
**Pass-through commands** (no validation needed):
- Everything else: `envelope list`, `message read`, `message delete`, `folder`, `flag`, `attachment`, `account`, etc.
```bash
HIMALAYA=~/.openclaw/workspace/scripts/himalaya.sh
# All commands work the same as `himalaya`
$HIMALAYA envelope list
$HIMALAYA message read 42
# Sending commands validate recipients first
cat message.txt | $HIMALAYA template send # validates To/Cc/Bcc
$HIMALAYA message write -H "To:小橘子:work" -H "Subject:Test" "body"
```
## References
- `references/configuration.md` (config file setup + IMAP/SMTP authentication)

View File

@@ -0,0 +1,76 @@
# notesearch
Local vector search over markdown notes using LlamaIndex + Ollama.
Point it at an Obsidian vault (or any folder of `.md` files), build a vector index, and search by meaning — not just keywords.
## Setup
```bash
cd ~/.openclaw/workspace/skills/notesearch
uv sync
```
Requires Ollama running locally with an embedding model pulled:
```bash
ollama pull qwen3-embedding:0.6b
```
## Usage
### Build the index
```bash
./notesearch.sh index --vault /path/to/vault
```
### Search
```bash
./notesearch.sh search "where do I get my allergy shots"
```
Output:
```
[0.87] Health/allergy.md
Started allergy shots in March 2026. Clinic is at 123 Main St.
[0.72] Daily/2026-03-25.md
Went to allergy appointment today.
```
### Configuration
Edit `config.json`:
```json
{
"vault": "/home/lyx/Documents/obsidian-yanxin",
"index_dir": null,
"ollama_url": "http://localhost:11434",
"embedding_model": "qwen3-embedding:0.6b"
}
```
Values can also be set via flags or env vars. Priority: **flag > env var > config.json > fallback**.
| Flag | Env var | Config key | Default |
|------|---------|------------|---------|
| `--vault` | `NOTESEARCH_VAULT` | `vault` | `/home/lyx/Documents/obsidian-yanxin` |
| `--index-dir` | `NOTESEARCH_INDEX_DIR` | `index_dir` | `<vault>/.index/` |
| `--ollama-url` | `NOTESEARCH_OLLAMA_URL` | `ollama_url` | `http://localhost:11434` |
| `--embedding-model` | `NOTESEARCH_EMBEDDING_MODEL` | `embedding_model` | `qwen3-embedding:0.6b` |
| `--top-k` | — | — | `5` |
## Tests
```bash
uv run pytest
```
## How it works
1. **Index**: reads all `.md` files, splits on markdown headings, embeds each chunk via Ollama, stores vectors locally
2. **Search**: embeds your query, finds the most similar chunks, returns them with file paths and relevance scores

View File

@@ -0,0 +1,4 @@
{
"slug": "notesearch",
"version": "0.1.0"
}

View File

@@ -0,0 +1,6 @@
{
"vault": "/home/lyx/Documents/obsidian-yanxin",
"index_dir": null,
"ollama_url": "http://localhost:11434",
"embedding_model": "qwen3-embedding:0.6b"
}

View File

@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
exec uv run python -m notesearch "$@"

View File

View File

@@ -0,0 +1,5 @@
"""Allow running as `python -m notesearch`."""
from notesearch.cli import main
main()

View File

@@ -0,0 +1,89 @@
"""CLI entry point for notesearch."""
import argparse
import os
import sys
from notesearch.core import (
FALLBACK_EMBEDDING_MODEL,
FALLBACK_OLLAMA_URL,
FALLBACK_VAULT,
build_index,
get_config_value,
search,
)
def _resolve(flag_value: str | None, env_name: str, config_key: str, fallback: str) -> str:
"""Resolve a value with priority: flag > env var > config.json > fallback."""
if flag_value:
return flag_value
env = os.environ.get(env_name)
if env:
return env
return get_config_value(config_key, fallback)
def cmd_index(args: argparse.Namespace) -> None:
vault = _resolve(args.vault, "NOTESEARCH_VAULT", "vault", FALLBACK_VAULT)
index_dir = _resolve(args.index_dir, "NOTESEARCH_INDEX_DIR", "index_dir", "") or None
ollama_url = _resolve(args.ollama_url, "NOTESEARCH_OLLAMA_URL", "ollama_url", FALLBACK_OLLAMA_URL)
model = _resolve(args.model, "NOTESEARCH_EMBEDDING_MODEL", "embedding_model", FALLBACK_EMBEDDING_MODEL)
print(f"Indexing vault: {vault}")
print(f"Model: {model}")
idx_path = build_index(vault, index_dir, ollama_url, model)
print(f"Index saved to: {idx_path}")
def cmd_search(args: argparse.Namespace) -> None:
vault = _resolve(args.vault, "NOTESEARCH_VAULT", "vault", FALLBACK_VAULT)
index_dir = _resolve(args.index_dir, "NOTESEARCH_INDEX_DIR", "index_dir", "") or None
ollama_url = _resolve(args.ollama_url, "NOTESEARCH_OLLAMA_URL", "ollama_url", FALLBACK_OLLAMA_URL)
results = search(args.query, vault, index_dir, ollama_url, args.top_k)
if not results:
print("No results found.")
return
for r in results:
print(f"[{r['score']:.2f}] {r['file']}")
print(r["text"])
print()
def main() -> None:
parser = argparse.ArgumentParser(
prog="notesearch",
description="Local vector search over markdown notes",
)
parser.add_argument("--vault", help="Path to the Obsidian vault")
parser.add_argument("--index-dir", help="Path to store/load the index")
parser.add_argument("--ollama-url", help="Ollama API URL")
subparsers = parser.add_subparsers(dest="command", required=True)
# index
idx_parser = subparsers.add_parser("index", help="Build the search index")
idx_parser.add_argument("--embedding-model", dest="model", help="Ollama embedding model name")
# search
search_parser = subparsers.add_parser("search", help="Search the notes")
search_parser.add_argument("query", help="Search query")
search_parser.add_argument("--top-k", type=int, default=5, help="Number of results")
args = parser.parse_args()
try:
if args.command == "index":
cmd_index(args)
elif args.command == "search":
cmd_search(args)
except (FileNotFoundError, ValueError) as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,124 @@
"""Core indexing and search logic."""
import json
from pathlib import Path
from llama_index.core import (
SimpleDirectoryReader,
StorageContext,
VectorStoreIndex,
load_index_from_storage,
)
from llama_index.core.node_parser import MarkdownNodeParser
from llama_index.embeddings.ollama import OllamaEmbedding
FALLBACK_VAULT = "/home/lyx/Documents/obsidian-yanxin"
FALLBACK_EMBEDDING_MODEL = "qwen3-embedding:0.6b"
FALLBACK_OLLAMA_URL = "http://localhost:11434"
METADATA_FILE = "notesearch_meta.json"
CONFIG_FILE = Path(__file__).parent.parent / "config.json"
def load_config() -> dict:
"""Load config from config.json. Returns empty dict if not found."""
if CONFIG_FILE.exists():
return json.loads(CONFIG_FILE.read_text())
return {}
def get_config_value(key: str, fallback: str) -> str:
"""Get a config value from config.json, with a hardcoded fallback."""
config = load_config()
return config.get(key) or fallback
def _get_index_dir(vault_path: str, index_dir: str | None) -> Path:
if index_dir:
return Path(index_dir)
return Path(vault_path) / ".index"
def _get_embed_model(ollama_url: str, model: str) -> OllamaEmbedding:
return OllamaEmbedding(model_name=model, base_url=ollama_url)
def build_index(
vault_path: str = FALLBACK_VAULT,
index_dir: str | None = None,
ollama_url: str = FALLBACK_OLLAMA_URL,
model: str = FALLBACK_EMBEDDING_MODEL,
) -> Path:
"""Build a vector index from markdown files in the vault."""
vault = Path(vault_path)
if not vault.is_dir():
raise FileNotFoundError(f"Vault not found: {vault_path}")
idx_path = _get_index_dir(vault_path, index_dir)
idx_path.mkdir(parents=True, exist_ok=True)
# Check for markdown files before loading (SimpleDirectoryReader raises
# its own error on empty dirs, but we want a clearer message)
md_files = list(vault.rglob("*.md"))
if not md_files:
raise ValueError(f"No markdown files found in {vault_path}")
documents = SimpleDirectoryReader(
str(vault),
recursive=True,
required_exts=[".md"],
).load_data()
embed_model = _get_embed_model(ollama_url, model)
parser = MarkdownNodeParser()
nodes = parser.get_nodes_from_documents(documents)
index = VectorStoreIndex(nodes, embed_model=embed_model)
index.storage_context.persist(persist_dir=str(idx_path))
# Save metadata so we can detect model mismatches
meta = {"model": model, "ollama_url": ollama_url, "vault_path": vault_path}
(idx_path / METADATA_FILE).write_text(json.dumps(meta, indent=2))
return idx_path
def search(
query: str,
vault_path: str = FALLBACK_VAULT,
index_dir: str | None = None,
ollama_url: str = FALLBACK_OLLAMA_URL,
top_k: int = 5,
) -> list[dict]:
"""Search the index and return matching chunks."""
idx_path = _get_index_dir(vault_path, index_dir)
if not idx_path.exists():
raise FileNotFoundError(
f"Index not found at {idx_path}. Run 'notesearch index' first."
)
# Load metadata and check model
meta_file = idx_path / METADATA_FILE
if meta_file.exists():
meta = json.loads(meta_file.read_text())
model = meta.get("model", FALLBACK_EMBEDDING_MODEL)
else:
model = FALLBACK_EMBEDDING_MODEL
embed_model = _get_embed_model(ollama_url, model)
storage_context = StorageContext.from_defaults(persist_dir=str(idx_path))
index = load_index_from_storage(storage_context, embed_model=embed_model)
retriever = index.as_retriever(similarity_top_k=top_k)
results = retriever.retrieve(query)
return [
{
"score": round(r.score, 4),
"file": r.node.metadata.get("file_path", "unknown"),
"text": r.node.text,
}
for r in results
]

View File

@@ -0,0 +1,18 @@
[project]
name = "notesearch"
version = "0.1.0"
description = "Local vector search over markdown notes using LlamaIndex + Ollama"
requires-python = ">=3.11"
dependencies = [
"llama-index",
"llama-index-embeddings-ollama",
]
[project.scripts]
notesearch = "notesearch.cli:main"
[tool.pytest.ini_options]
testpaths = ["tests"]
[dependency-groups]
dev = ["pytest"]

View File

View File

@@ -0,0 +1,152 @@
"""Tests for notesearch core functionality."""
import hashlib
import json
from pathlib import Path
from typing import Any
from unittest.mock import patch
import pytest
from llama_index.core.base.embeddings.base import BaseEmbedding
from notesearch.core import FALLBACK_EMBEDDING_MODEL, METADATA_FILE, build_index, search
class FakeEmbedding(BaseEmbedding):
"""Deterministic embedding model for testing."""
model_name: str = "test-model"
def _get_text_embedding(self, text: str) -> list[float]:
h = hashlib.md5(text.encode()).digest()
return [b / 255.0 for b in h] * 48 # 768-dim
def _get_query_embedding(self, query: str) -> list[float]:
return self._get_text_embedding(query)
async def _aget_text_embedding(self, text: str) -> list[float]:
return self._get_text_embedding(text)
async def _aget_query_embedding(self, query: str) -> list[float]:
return self._get_text_embedding(query)
def _mock_embed_model(*args: Any, **kwargs: Any) -> FakeEmbedding:
return FakeEmbedding()
@pytest.fixture
def sample_vault(tmp_path: Path) -> Path:
"""Create a temporary vault with sample markdown files."""
vault = tmp_path / "vault"
vault.mkdir()
(vault / "health").mkdir()
(vault / "health" / "allergy.md").write_text(
"# Allergy Treatment\n\n"
"Started allergy shots in March 2026.\n"
"Weekly schedule: Tuesday and Thursday.\n"
"Clinic is at 123 Main St.\n"
)
(vault / "work").mkdir()
(vault / "work" / "project-alpha.md").write_text(
"# Project Alpha\n\n"
"## Goals\n"
"Launch the new API by Q2.\n"
"Migrate all users to v2 endpoints.\n\n"
"## Status\n"
"Backend is 80% done. Frontend blocked on design review.\n"
)
(vault / "recipes.md").write_text(
"# Favorite Recipes\n\n"
"## Pasta Carbonara\n"
"Eggs, pecorino, guanciale, black pepper.\n"
"Cook pasta al dente, mix off heat.\n"
)
return vault
@pytest.fixture
def empty_vault(tmp_path: Path) -> Path:
"""Create an empty vault directory."""
vault = tmp_path / "empty_vault"
vault.mkdir()
return vault
class TestBuildIndex:
def test_missing_vault(self, tmp_path: Path) -> None:
with pytest.raises(FileNotFoundError, match="Vault not found"):
build_index(vault_path=str(tmp_path / "nonexistent"))
def test_empty_vault(self, empty_vault: Path) -> None:
with pytest.raises(ValueError, match="No markdown files found"):
build_index(vault_path=str(empty_vault))
@patch("notesearch.core._get_embed_model", _mock_embed_model)
def test_builds_index(self, sample_vault: Path, tmp_path: Path) -> None:
index_dir = tmp_path / "index"
idx_path = build_index(
vault_path=str(sample_vault),
index_dir=str(index_dir),
)
assert idx_path == index_dir
assert idx_path.exists()
assert (idx_path / METADATA_FILE).exists()
meta = json.loads((idx_path / METADATA_FILE).read_text())
assert meta["vault_path"] == str(sample_vault)
assert "model" in meta
@patch("notesearch.core._get_embed_model", _mock_embed_model)
def test_index_stores_model_metadata(self, sample_vault: Path, tmp_path: Path) -> None:
index_dir = tmp_path / "index"
build_index(
vault_path=str(sample_vault),
index_dir=str(index_dir),
model="custom-model",
)
meta = json.loads((index_dir / METADATA_FILE).read_text())
assert meta["model"] == "custom-model"
class TestSearch:
def test_missing_index(self, tmp_path: Path) -> None:
with pytest.raises(FileNotFoundError, match="Index not found"):
search("test query", vault_path=str(tmp_path))
@patch("notesearch.core._get_embed_model", _mock_embed_model)
def test_search_returns_results(self, sample_vault: Path, tmp_path: Path) -> None:
index_dir = tmp_path / "index"
build_index(vault_path=str(sample_vault), index_dir=str(index_dir))
results = search(
"allergy shots",
vault_path=str(sample_vault),
index_dir=str(index_dir),
top_k=3,
)
assert len(results) > 0
assert all("score" in r for r in results)
assert all("file" in r for r in results)
assert all("text" in r for r in results)
@patch("notesearch.core._get_embed_model", _mock_embed_model)
def test_search_respects_top_k(self, sample_vault: Path, tmp_path: Path) -> None:
index_dir = tmp_path / "index"
build_index(vault_path=str(sample_vault), index_dir=str(index_dir))
results = search(
"anything",
vault_path=str(sample_vault),
index_dir=str(index_dir),
top_k=1,
)
assert len(results) == 1

2154
skills/notesearch/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff