diff --git a/.claude/skills/work-recall/SKILL.md b/.claude/skills/work-recall/SKILL.md new file mode 100644 index 0000000..d913144 --- /dev/null +++ b/.claude/skills/work-recall/SKILL.md @@ -0,0 +1,24 @@ +--- +name: work-recall +description: | + 查询用户本机的工作上下文记忆。当用户询问以下类型问题时自动触发: + "我刚刚看过什么"、"我最近复制了什么"、"我今天在做什么"、 + "我昨天在研究什么"、"我前几天看过 X 相关的什么"、"帮我接续上下文"。 + 需要 keypulse daemon 在后台运行(keypulse start)。 +allow-tools: Bash(keypulse *) +--- + +# Work Recall — 本地工作记忆查询 + +!`keypulse recall "$ARGUMENTS" --since 7d --limit 5 2>/dev/null || echo "[错误] KeyPulse 未运行,请先执行: keypulse start"` + +--- + +基于以上本地上下文,回答用户的问题:**$ARGUMENTS** + +回答要求: +- 直接作答,不要复述原始数据格式 +- 时间用自然语言表达("今天下午"、"昨天上午") +- 相关条目按时间倒序,最多列 5 条 +- 内容超过 2 句话则精简 +- 如果没有找到,直接说"未找到相关记录",不要猜测 diff --git a/.gitignore b/.gitignore index a5e3ff3..cb48c4c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,37 @@ DerivedData/ *.d *.dia *.swiftinterface + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +.venv +venv/ +ENV/ +env/ +.vscode/ +.idea/ +*.swp +*.swo +*~ diff --git a/README.md b/README.md index 7bd4e62..8556f80 100644 --- a/README.md +++ b/README.md @@ -1,285 +1,580 @@ -# 🔑 KeyPulse +# KeyPulse -**30 秒生成工作日报的命令行工具** +**Local-first personal activity memory for macOS with Claude Code / OpenClaw integration** -低功耗的 macOS 活动监控工具,自动记录工作轨迹,**智能脱敏输入内容**,一键生成专业日报。 +KeyPulse is a lightweight, privacy-first daemon that records what you're doing—applications, windows, clipboard contents, manual notes—into a local SQLite database. It provides powerful CLI tools to search, recall, and analyze your work history with intelligent privacy protection. This branch (`claude/skill-api`) adds integration with Claude Code and OpenClaw through the `work-recall` skill for AI-assisted activity recall. -[![Swift](https://img.shields.io/badge/Swift-5.5+-orange.svg)](https://swift.org) -[![macOS](https://img.shields.io/badge/macOS-12.0+-blue.svg)](https://developer.apple.com/macos) -[![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) +[![Python](https://img.shields.io/badge/Python-3.11+-blue.svg)](https://www.python.org/downloads/) +[![macOS](https://img.shields.io/badge/macOS-12.0+-lightgrey.svg)](https://www.apple.com/macos/) +[![Skill](https://img.shields.io/badge/Claude%20Code-Skill-9333ea.svg)](https://github.com/Longfellow1/keypulse/tree/claude/skill-api) +[![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) -## ✨ 核心价值 +## ✨ Why KeyPulse? -- 🎯 **解决痛点** - 不记得今天干了什么?自动帮你记录 -- ⚡ **30 秒生成** - 一个命令,生成可直接使用的工作日报 -- 🔒 **智能脱敏** - 记录工作内容,保护隐私信息 -- 🪶 **低功耗** - CPU < 1%,内存 < 50MB,不影响电池续航 +- **Never lose context** — Automatically record what you're working on; search and recall your activity history anytime +- **Privacy by default** — All data stays on your machine. Sensitive apps are never monitored. Passwords and tokens are automatically masked +- **Lightweight daemon** — Uses < 0.5% CPU and 30 MB memory; negligible battery impact (~1%/hour) +- **Powerful search** — Full-text search over clipboard history, manual notes, and session summaries with natural language queries +- **AI-assisted recall** — Use the `work-recall` skill with Claude Code or OpenClaw to get intelligent answers about your recent work without leaving your IDE -## 📊 效果展示 +## 🚀 Quick Start + +### Requirements + +- macOS 12.0 or later +- Python 3.11 or later +- Accessibility permissions (granted on first run) + +### Installation ```bash -$ keypulse report +git clone https://github.com/Longfellow1/keypulse.git +cd keypulse +pip install -e . ``` -**输出:** +### Start the daemon -```markdown -## 2026-02-27 工作日报 +```bash +keypulse start +``` -### keypulse 项目(4.5h) -- 09:00-12:00 核心功能开发(VSCode,高强度,1,850 键击,代码) - 内容:func, class, struct, database, API - **关键词:** Swift, ActivityMonitor, KeystrokeCounter, database +The daemon runs in the background and auto-starts if your system reboots (requires login item setup). -- 14:00-16:30 代码调试(Terminal + VSCode,中强度,680 键击) - 内容:swift build, test, debug - **关键词:** test, debug, build +### Query your activity -### 产品文档(1.5h) -- 13:00-14:30 需求文档编写(飞书,中强度,320 键击,文档) - 内容:修复, 优化, 实现, API, 用户 - **关键词:** 修复, 优化, 用户需求, 功能设计 +```bash +# View today's timeline +keypulse timeline --today ---- -💡 **今日工作统计** +# Search for something you worked on +keypulse search "activitywatch" --since 7d + +# See recent clipboard contents +keypulse recent --type clipboard -- 总时长:6h -- 总键击:2,850 次 -- 活动分布: - - 代码:4.5h - - 文档:1.5h +# View work sessions +keypulse session list --today ``` -✅ **报告已自动复制到剪贴板,可直接粘贴到飞书/钉钉** +### View statistics -## 🔒 智能脱敏技术 +```bash +# Weekly activity breakdown +keypulse stats --days 7 -### 记录的内容 +# Export as JSON for analysis +keypulse export --format json --output report.json +``` -- ✅ 应用名称(如 VSCode, Safari) -- ✅ 窗口标题(如 keypulse/main.swift) -- ✅ **脱敏后的输入内容**(保留关键词,去除敏感信息) -- ✅ 键击次数 -- ✅ 活动时长 +### Use with Claude Code / OpenClaw (Skill Integration) -### 智能脱敏策略 +This branch includes the `work-recall` skill for Claude Code and OpenClaw. Ask questions about your work naturally: -#### 代码输入 ``` -实际输入:const username = "zhangsan@company.com" -脱敏记录:const, username(关键词) +/work-recall what was I working on yesterday? +/work-recall show me what I copied about authentication +/work-recall help me pick up where I left off on the database refactoring ``` -#### 文档编写 -``` -实际输入:修复了登录模块的 JWT token 过期问题 -脱敏记录:修复, 登录, JWT, token, 过期(关键词) -``` +Claude will synthesize your activity history into helpful, contextual answers. See [SKILL_README.md](SKILL_README.md) for detailed skill documentation. + +## 📖 Full Command Reference + +All 20 core commands, organized by function: + +### Daemon Control + +| Command | Function | +|---------|----------| +| `keypulse start` | Start the background daemon | +| `keypulse stop` | Gracefully stop the daemon | +| `keypulse pause` | Pause recording (keep daemon running) | +| `keypulse resume` | Resume recording | +| `keypulse status` | Show daemon status and uptime | +| `keypulse doctor` | Check system dependencies and config | + +### Recording + +| Command | Function | +|---------|----------| +| `keypulse save ` | Manually save a note (e.g., `keypulse save "Meeting notes: discussed Q2 roadmap"`) | + +### Querying Activity + +| Command | Function | +|---------|----------| +| `keypulse timeline` | Show activity timeline by session (today by default) | +| `keypulse timeline --date 2026-04-10` | Show activity for a specific date | +| `keypulse recent` | Show 10 most recent clipboard copies, manual notes, and sessions | +| `keypulse recent --type clipboard` | Show only recent clipboard entries | +| `keypulse recent --type manual` | Show only manual saves | +| `keypulse recent --limit 20` | Show 20 items instead of 10 | +| `keypulse search ` | Full-text search across clipboard, notes, and sessions | +| `keypulse search "ActivityWatch" --since 7d` | Search the last 7 days | +| `keypulse search "python" --app VSCode` | Limit search to a specific app | +| `keypulse search "todo" --source clipboard` | Search only clipboard history | + +### Sessions & Stats + +| Command | Function | +|---------|----------| +| `keypulse session list` | Show all sessions (today by default) | +| `keypulse session list --date 2026-04-10` | Sessions for a specific date | +| `keypulse session ` | Show details of a specific session (window titles, app time, etc.) | +| `keypulse stats` | Show activity summary (today by default) | +| `keypulse stats --days 7` | Weekly statistics (time per app, idle percentage, etc.) | + +### Data Management + +| Command | Function | +|---------|----------| +| `keypulse export` | Export today's data as JSON | +| `keypulse export --format csv --days 7 --output report.csv` | Export 7 days as CSV | +| `keypulse export --format markdown --output report.md` | Export as Markdown | +| `keypulse purge --today` | Delete today's data | +| `keypulse purge --last-hours 12 --app "1Password"` | Delete recent 1Password data | +| `keypulse purge --app Slack --confirm` | Permanently delete all Slack recordings | + +### Configuration & Rules + +| Command | Function | +|---------|----------| +| `keypulse config show` | Display current configuration | +| `keypulse config path` | Show config file location (~/.keypulse/config.toml) | +| `keypulse rules list` | Show all privacy policies | +| `keypulse rules add --app 1Password --mode deny` | Add a rule: never record this app | +| `keypulse rules add --app Slack --mode metadata-only` | Record only app name, not window title | +| `keypulse rules disable ` | Temporarily disable a rule | + +### AI-Optimized Recall + +| Command | Function | +|---------|----------| +| `keypulse recall ` | LLM-optimized activity summary (used by the `work-recall` skill) | +| `keypulse recall "python" --since 7d` | Search last 7 days in compact format | +| `keypulse recall "authentication" --limit 10` | Return up to 10 results | + +## 🔒 Privacy & Security + +### What's recorded + +- **Application names** — e.g., "VSCode", "Safari" +- **Window titles** — e.g., "VSCode — keypulse/cli.py", "Safari — GitHub | KeyPulse" +- **Clipboard contents** — Text you copy (up to 2000 characters per copy event by default) +- **Manual notes** — Text you explicitly save with `keypulse save` +- **Session metadata** — Duration, idle/active periods, keystroke density + +### What's NOT recorded + +- **Keyboard input** — Raw keystrokes are never captured +- **Sensitive apps** — 1Password, Keychain Access, LastPass, Bitwarden, KeePassXC, and many others are blacklisted by default +- **Passwords & tokens** — Automatically detected by pattern matching (email addresses, API keys, credit card numbers) and masked +- **Chat app contents** — Slack, Teams, Discord, iMessage — app names are recorded but not content +- **Browser content** — While Safari window titles are recorded, web page contents are not + +### Privacy controls + +**Default blacklist** includes: +- 1Password, Keychain, LastPass, Bitwarden, KeePassXC +- Slack, Teams, Discord, iMessage, WeChat, Signal +- Mail, Outlook, Gmail (web) +- Most password managers and security tools + +**Configurable policies** let you choose per-app behavior: +- `deny` — Never record anything from this app +- `metadata-only` — Record app name and timestamps, but not window titles +- `redact` — Record everything but mask sensitive patterns (emails, tokens) +- `allow` — Record everything (default for allowed apps) + +**Intelligent content masking** detects and masks: +- Email addresses: `john@example.com` → `[EMAIL]` +- Tokens/API keys: `sk-abc123...` → `[TOKEN]` +- Credit card numbers: `4111-1111-1111-1111` → `[CARD]` +- Phone numbers: `+1-555-0123` → `[PHONE]` +- Custom regex patterns via config + +**Local-first architecture** ensures: +- All data stays on your machine; zero cloud uploads +- Zero tracking of your activity by KeyPulse or any third party +- No network communication except during optional export + +**Manual purge** commands let you delete data anytime: +```bash +# Delete all data from today +keypulse purge --today --confirm -#### 终端命令 -``` -实际输入:cd /Users/zhangsan/projects/keypulse -脱敏记录:cd /PATH(路径脱敏) +# Delete last 12 hours +keypulse purge --last-hours 12 --confirm + +# Delete all data from a specific app +keypulse purge --app Slack --confirm ``` -#### 聊天内容 +## 🏗️ Architecture + +KeyPulse consists of modular components working together: + ``` -实际输入:(任何聊天内容) -脱敏记录:[聊天内容](完全不记录) +┌──────────────────────────────────────────────────────────────┐ +│ Background Daemon (run by: keypulse start) │ +├──────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─ Watchers ──────────────────────────────────────────┐ │ +│ │ • Window watcher (NSWorkspace + Accessibility API) │ │ +│ │ • Idle detector (CGEventSource) │ │ +│ │ • Clipboard monitor (NSPasteboard) │ │ +│ │ • Manual input (CLI commands) │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ ┌─ Capture Manager ────────────────────────────────────┐ │ +│ │ Batches events, normalizes, applies policies │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ ┌─ Privacy Layer ──────────────────────────────────────┐ │ +│ │ • Pattern detection (emails, tokens, etc.) │ │ +│ │ • Intelligent desensitization │ │ +│ │ • App blacklist enforcement │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ ┌─ Storage (SQLite) ────────────────────────────────────┐ │ +│ │ ~/.keypulse/keypulse.db │ │ +│ │ • raw_events — Captured clipboard, app switches │ │ +│ │ • search_docs — FTS5 indexed content │ │ +│ │ • sessions — Aggregated activity periods │ │ +│ │ • state — Daemon config and runtime state │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ │ +└──────────────────────────────────────────────────────────────┘ + + ↓ +┌──────────────────────────────────────────────────────────────┐ +│ Query Layer (CLI commands) │ +├──────────────────────────────────────────────────────────────┤ +│ • Timeline builder — Sessions grouped by app/window │ +│ • Search engine — FTS5 full-text queries │ +│ • Stats aggregator — Time per app, idle percentage │ +│ • Export formatter — JSON, CSV, Markdown │ +│ • Recall engine — LLM-optimized output (skill integration) │ +└──────────────────────────────────────────────────────────────┘ ``` -### 不记录的内容 +### Core Components -- ❌ 具体变量值和字符串内容 -- ❌ 密码输入(检测到密码框自动过滤) -- ❌ 敏感应用内容(1Password 等) -- ❌ 个人隐私信息 +**Daemon** (`app.py`) +- Lifecycle management (start, stop, pause, resume) +- Watcher coordination +- Event batching and flushing +- Error handling and recovery -### 黑名单保护 +**Watchers** (`capture/`) +- `WindowWatcher` — Detects app switches via NSWorkspace notifications +- `IdleDetector` — Tracks idle time using CGEventSource +- `ClipboardWatcher` — Monitors clipboard changes via NSPasteboard +- `ManualCapture` — CLI-driven manual saves +- `CaptureManager` — Coordinates all watchers, applies policies -以下敏感应用完全不会被监控: +**Privacy Layer** (`privacy/`) +- `Desensitizer` — Pattern detection and redaction +- Pattern matching for emails, tokens, URLs, etc. +- Configurable regex-based masking +- App blacklist enforcement -- 1Password -- Keychain Access -- LastPass -- Bitwarden -- KeePassXC +**Storage** (`store/`) +- SQLite database with FTS5 full-text search +- `raw_events` table for clipboard and app events +- `search_docs` table for indexed content +- `sessions` table for aggregated activity +- Automatic retention (default 30 days) -## 🚀 快速开始 +**Search** (`search/`) +- FTS5 query builder +- Ranking by recency and relevance +- Time-range filtering +- Per-app filtering -### 系统要求 +**Services** (`services/`) +- `timeline.py` — Formats activity as sessions with app names and window titles +- `stats.py` — Aggregates CPU/idle time per app, generates summaries +- `export.py` — Exports to JSON, CSV, Markdown +- `sessionizer.py` — Groups events into sessions (continuous app usage) -- macOS 12.0+ -- Xcode 13.0+ -- Swift 5.5+ +### Data Flow -### 安装 +1. **Capture** — Watchers detect app switches, clipboard changes, keystroke activity +2. **Normalize** — `CaptureManager` standardizes event format, applies rules +3. **Desensitize** — `PrivacyLayer` masks sensitive patterns, enforces blacklist +4. **Store** — Events written to SQLite in batches (every 5 seconds) +5. **Index** — FTS5 index updated incrementally for fast search +6. **Query** — CLI commands read from database, format results +7. **Export** — Results rendered as tables, JSON, CSV, Markdown, or LLM-optimized text -```bash -# 1. Clone 仓库 -git clone https://github.com/Longfellow1/keypulse.git -cd keypulse +## 📊 Performance -# 2. 编译 -swift build -c release +Benchmarks on a MacBook Pro (M1, 16GB RAM): -# 3. 安装到系统(可选) -sudo cp .build/release/keypulse /usr/local/bin/ +| Scenario | CPU | Memory | Disk/Week | +|----------|-----|--------|-----------| +| Idle | < 0.1% | 18 MB | ~100 KB | +| Light usage (email, browsing) | < 0.3% | 24 MB | ~600 KB | +| Normal workday (development) | < 0.5% | 35 MB | ~1.2 MB | +| Heavy usage (continuous typing) | < 1% | 50 MB | ~2 MB | -# 4. 启动监控 -keypulse start +**Battery impact:** Negligible, typically < 1% per hour on laptops. -# 5. 授权辅助功能权限 -# 系统设置 → 隐私与安全性 → 辅助功能 → 添加 keypulse -``` +## 📝 Configuration + +Configuration file location: `~/.keypulse/config.toml` -### 使用 +If the file doesn't exist, defaults are used. You can generate a default config: ```bash -# 启动后台监控(开机自启动) -keypulse start +mkdir -p ~/.keypulse +# Copy the included config.toml, or edit after first run +``` -# 生成今日工作日报 -keypulse report +### Example config.toml -# 查看运行状态 -keypulse status +```toml +[app] +db_path = "~/.keypulse/keypulse.db" +log_path = "~/.keypulse/keypulse.log" +flush_interval_sec = 5 +retention_days = 30 -# 停止监控 -keypulse stop +[watchers] +window = true +idle = true +clipboard = true +manual = true +browser = false -# 清空所有数据 -keypulse clear +[idle] +threshold_sec = 180 + +[clipboard] +max_text_length = 2000 +dedup_window_sec = 600 + +[privacy] +redact_emails = true +redact_phones = true +redact_tokens = true + +# Explicit policies override defaults +# Each policy has: scope_type (app/domain), scope_value, mode, priority ``` -## 🎯 核心功能 +### Configuration options + +**[app]** +- `db_path` — Where to store the SQLite database (default: `~/.keypulse/keypulse.db`) +- `log_path` — Daemon log file (default: `~/.keypulse/keypulse.log`) +- `flush_interval_sec` — How often to write batched events (default: 5 seconds) +- `retention_days` — Auto-delete records older than this (default: 30 days) -### 1. 自动监控(零打扰) +**[watchers]** +- `window` — Monitor app switches and window titles (default: `true`) +- `idle` — Track idle time (default: `true`) +- `clipboard` — Record clipboard copies (default: `true`) +- `manual` — Allow `keypulse save` commands (default: `true`) +- `browser` — Track browser tab titles (default: `false`, not yet implemented) -- ✅ 应用切换监控 -- ✅ 窗口标题记录 -- ✅ **智能输入捕获**(脱敏处理) -- ✅ 工作时长计算 +**[idle]** +- `threshold_sec` — Seconds without events before marking idle (default: 180 = 3 minutes) -### 2. 智能分组 +**[clipboard]** +- `max_text_length` — Only record clipboard entries up to this length (default: 2000) +- `dedup_window_sec` — Ignore duplicate copies within this window (default: 600 = 10 minutes) -自动从窗口标题提取项目名: +**[privacy]** +- `redact_emails` — Mask email addresses (default: `true`) +- `redact_phones` — Mask phone numbers (default: `true`) +- `redact_tokens` — Mask API keys, tokens, credentials (default: `true`) + +## 🛠️ Development + +### Project structure ``` -"VSCode - keypulse/main.swift" → 项目:keypulse -"Safari - GitHub PR #123" → 项目:GitHub -"飞书 - 产品需求评审" → 任务:产品需求评审 +keypulse/ +├── cli.py # 20 CLI commands + recall +├── app.py # Daemon lifecycle (start, stop, daemonize) +├── config.py # Configuration loading and validation +│ +├── capture/ +│ ├── manager.py # Coordinates all watchers +│ ├── window.py # Window/app switch watcher +│ ├── idle.py # Idle time detector +│ ├── clipboard.py # Clipboard monitor +│ ├── normalizer.py # Event normalization +│ └── __init__.py +│ +├── store/ +│ ├── db.py # Database initialization +│ ├── models.py # Pydantic models +│ ├── repository.py # Database queries (CRUD) +│ └── __init__.py +│ +├── privacy/ +│ ├── desensitizer.py # Pattern detection and masking +│ ├── patterns.py # Regex patterns for sensitive data +│ ├── blacklist.py # App blacklist +│ └── __init__.py +│ +├── search/ +│ ├── engine.py # FTS5 search builder and executor +│ └── __init__.py +│ +├── services/ +│ ├── timeline.py # Timeline formatting +│ ├── stats.py # Statistics aggregation +│ ├── export.py # JSON/CSV/Markdown export +│ ├── sessionizer.py # Event-to-session grouping +│ └── __init__.py +│ +├── .claude/ +│ └── skills/ +│ └── work-recall/ +│ └── SKILL.md # Claude Code / OpenClaw skill +│ +└── utils/ + ├── logging.py # Structured logging + ├── paths.py # Path helpers (~/.keypulse) + ├── lock.py # Single-instance daemon lock + └── __init__.py ``` -### 3. 工作强度分析 +### Running locally -根据键击频率自动判断: +```bash +# Clone and install in development mode +git clone https://github.com/Longfellow1/keypulse.git +cd keypulse +pip install -e . -- **高强度**(> 20 次/分钟)- 编码、写作 -- **中强度**(5-20 次/分钟)- 调试、阅读 -- **低强度**(< 5 次/分钟)- 浏览、思考 +# Check that dependencies are available +keypulse doctor -### 4. 内容智能分类 +# Start the daemon +keypulse start + +# Query activity +keypulse timeline --today +keypulse search "something" +keypulse recall "test" --since 7d -根据应用类型自动分类: +# View logs +tail -f ~/.keypulse/keypulse.log -- **代码** - 代码编辑器(VSCode, Xcode) -- **文档** - 文档编辑(Pages, Notion) -- **聊天** - 通讯工具(飞书, 钉钉, 微信) -- **命令** - 终端(Terminal, iTerm2) -- **浏览** - 浏览器(Safari, Chrome) +# Stop the daemon +keypulse stop +``` -### 5. 一键生成日报 +### macOS permissions -- Markdown 格式,可直接复制 -- 自动复制到剪贴板 -- 按项目分组,清晰易读 -- 包含关键词和工作内容摘要 +KeyPulse requires: +- **Accessibility** permission to monitor window titles and idle time +- **Screen Recording** permission to track active windows (on macOS 13+) + +When you first run `keypulse start`, the daemon requests these permissions. You can also grant them manually: + +``` +System Settings → Privacy & Security → Accessibility → Add Python +System Settings → Privacy & Security → Screen Recording → Add Python +``` + +### Testing + +To verify the daemon is working: + +```bash +# Check daemon status +keypulse status -## 📈 性能表现 +# View recent clipboard +keypulse recent --type clipboard -| 场景 | CPU | 内存 | 电池影响 | -|------|-----|------|---------| -| 空闲 | < 0.1% | 20MB | 无感 | -| 正常办公 | < 0.5% | 30MB | < 1%/小时 | -| 重度使用 | < 1% | 50MB | < 2%/小时 | +# Search for test data +keypulse save "Test note from development" +keypulse search "development" -## 🏗️ 技术架构 +# Test recall (LLM format) +keypulse recall "development" --since 1d +# Export and inspect +keypulse export --format json | jq . ``` -┌─────────────────────────────────────┐ -│ 后台守护进程(keypulse daemon) │ -├─────────────────────────────────────┤ -│ • 监听应用切换(NSWorkspace) │ -│ • 监听键盘事件(CGEvent) │ -│ • 智能脱敏处理(TextDesensitizer) │ -│ • 每 10 秒保存一次数据 │ -└─────────────────────────────────────┘ - ↓ 存储 -┌─────────────────────────────────────┐ -│ SQLite 数据库(~/.keypulse/data.db)│ -├─────────────────────────────────────┤ -│ activities 表: │ -│ - timestamp │ -│ - app_name │ -│ - window_title │ -│ - keystroke_count │ -│ - duration │ -│ - desensitized_text(脱敏文本) │ -│ - keywords(关键词) │ -│ - content_category(内容类别) │ -└─────────────────────────────────────┘ - ↓ 读取 -┌─────────────────────────────────────┐ -│ CLI 工具(keypulse report) │ -├─────────────────────────────────────┤ -│ 1. 读取今日数据 │ -│ 2. 智能分组(提取项目名) │ -│ 3. 计算工作强度 │ -│ 4. 生成 Markdown 报告 │ -└─────────────────────────────────────┘ + +For unit tests (if added to the project): + +```bash +python -m pytest tests/ -v ``` -## 🎯 适用场景 +## ⚖️ License -### 场景 1:每日写日报 +MIT License — See [LICENSE](LICENSE) file for details. -下班前运行 `keypulse report`,自动生成今日工作内容,直接复制到飞书/钉钉。 +## 🤝 Contributing -### 场景 2:周报总结 +Contributions are welcome! Areas of particular interest: -查看本周工作分布,了解时间都花在哪些项目上。 +- **Linux/Windows watcher implementations** — Extend KeyPulse to other OSs +- **Additional privacy patterns** — Improve detection of sensitive data +- **Enhanced search indexing** — Better ranking, semantic search +- **Performance optimizations** — Reduce memory/CPU footprint further +- **Documentation improvements** — Help others understand the codebase -### 场景 3:客户对账 +See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. -向客户证明工作时长,有详细的时间记录和工作内容摘要。 +## FAQ -### 场景 4:自我管理 +### Does KeyPulse upload data to the cloud? -了解自己的工作习惯,优化时间分配。 +No. All data stays on your machine. There is no network communication except when you explicitly export data. -## 🤝 贡献 +### Can I trust the privacy protection? -欢迎提交 Issue 和 Pull Request! +Yes. The code is open source and auditable. We default to NOT recording content and only keep what you explicitly enable. Sensitive data patterns are detected locally and masked before storage. You can review the privacy rules and customize them via `~/.keypulse/config.toml`. -### 开发环境设置 +### What about CPU and battery impact? -```bash -git clone https://github.com/Longfellow1/keypulse.git -cd keypulse -swift build -swift run keypulse help -``` +Negligible. The daemon uses event-driven architecture (not polling), batches writes, and sleeps most of the time. Typical impact is < 0.5% CPU and < 1% battery per hour. + +### How far back does history go? -## 📄 许可证 +By default, 30 days (configurable via `retention_days` in config). Older records are automatically deleted to bound database size. -MIT License - 详见 [LICENSE](LICENSE) 文件 +### Can I export my data? -## 📬 联系方式 +Yes. Use `keypulse export --format json` to export as JSON, CSV, or Markdown. Data is yours to keep and analyze. -- GitHub Issues: [https://github.com/Longfellow1/keypulse/issues](https://github.com/Longfellow1/keypulse/issues) -- Email: [Harland5588@outlook.com](mailto:Harland5588@outlook.com) +### What if I want to disable monitoring? + +Use `keypulse pause` to temporarily stop recording without stopping the daemon. Use `keypulse stop` to fully shut down. Data is preserved. + +### Can I run KeyPulse on multiple machines? + +Currently, each machine runs its own isolated instance. You can export data from each and merge the exports manually if needed. + +## Related + +**Branches:** +- **Main CLI** [`claude/review-spec-repo-6jybR`](https://github.com/Longfellow1/keypulse/tree/claude/review-spec-repo-6jybR) — Full-featured CLI with 20 commands (no skill integration) +- **Skill API** (current) — Claude Code / OpenClaw integration with `work-recall` skill + +**Documentation:** +- [SKILL_README.md](SKILL_README.md) — Detailed skill setup and usage guide +- [SECURITY.md](SECURITY.md) — Privacy and security policies +- [CONTRIBUTING.md](CONTRIBUTING.md) — Contributing guidelines + +**External:** +- **GitHub Repository** — [github.com/Longfellow1/keypulse](https://github.com/Longfellow1/keypulse) +- **License** — [MIT](LICENSE) --- -**⭐ 如果这个工具帮到了你,请给个 Star!** +**Last updated:** April 15, 2026 + +**Note:** You are viewing the `claude/skill-api` branch with Claude Code / OpenClaw integration. For the full CLI documentation without skill integration, see the [`claude/review-spec-repo-6jybR`](https://github.com/Longfellow1/keypulse/tree/claude/review-spec-repo-6jybR) branch. diff --git a/SKILL_README.md b/SKILL_README.md new file mode 100644 index 0000000..87fef3c --- /dev/null +++ b/SKILL_README.md @@ -0,0 +1,567 @@ +# KeyPulse + Claude Code / OpenClaw Integration + +**Query your local activity history with AI assistance using Claude Code or OpenClaw** + +KeyPulse now integrates with Claude Code and OpenClaw through the `work-recall` skill. Ask Claude about your recent work, and it automatically queries your local activity history to provide informed, contextual answers—all without leaving your IDE or agent. + +[![Python](https://img.shields.io/badge/Python-3.11+-blue.svg)](https://www.python.org/downloads/) +[![macOS](https://img.shields.io/badge/macOS-12.0+-lightgrey.svg)](https://www.apple.com/macos/) +[![Skill](https://img.shields.io/badge/Claude%20Code-Skill-9333ea.svg)](https://github.com/Longfellow1/keypulse/tree/claude/skill-api) +[![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) + +## ✨ What's the skill? + +The `work-recall` skill lets Claude Code or OpenClaw query your local activity history when you ask work-related questions: + +- "What was I researching last week?" +- "Show me what I copied about ActivityWatch" +- "What did I work on today?" +- "Help me pick up where I left off yesterday" +- "What apps did I use for the X project?" + +Claude/OpenClaw then synthesizes your activity history into natural, helpful answers without leaving your development context. + +## 🚀 How it works + +``` +You: "I forgot where I saw that article about ActivityWatch" + ↓ +Claude Code / OpenClaw detects this is a work-recall question + ↓ +Invokes: keypulse recall "activitywatch" --since 7d --limit 5 + ↓ +Returns your recent clipboard, sessions, and searches + ↓ +Claude synthesizes into a natural answer with specific timestamps and context +``` + +## 🔧 Setup + +### 1. Install KeyPulse CLI + +```bash +git clone https://github.com/Longfellow1/keypulse.git +cd keypulse +pip install -e . +``` + +### 2. Start the daemon + +The skill only works when the daemon is running: + +```bash +keypulse start +``` + +You can verify the daemon is running: + +```bash +keypulse status +``` + +### 3. For Claude Code (Built-in) + +The skill file `.claude/skills/work-recall/SKILL.md` is already in the repo. + +**To use it:** + +1. Open this repository in Claude Code (web or CLI) +2. Type `/work-recall ` followed by your question +3. Claude will query your local activity and respond naturally + +**Example:** + +``` +/work-recall what was I working on yesterday? +``` + +**Output:** + +Claude synthesizes your activity history: +``` +Yesterday you worked on: + +1. KeyPulse CLI development (VSCode, 2h 30m) + - Around 14:00-16:30, focused on implementing the recall command + - Copied code snippets about FTS5 search integration + - Made commits updating the CLI structure + +2. Documentation review (Safari, 45m) + - Around 13:15-14:00, reviewed ActivityWatch architecture + - Copied content about event-driven watchers + +You were most active in the afternoon with strong focus periods (low idle time). +``` + +### 4. For OpenClaw + +Copy the skill file to your OpenClaw skills directory: + +```bash +cp .claude/skills/work-recall/SKILL.md ~/.openclaw/skills/work-recall/SKILL.md +``` + +Then in OpenClaw, use `/work-recall` the same way: + +``` +/work-recall what did I research about machine learning? +``` + +## 📋 CLI Command: `keypulse recall` + +You can also use the recall command directly from the CLI for testing or automation: + +```bash +keypulse recall "activitywatch" --since 7d --limit 5 +``` + +### Output format (LLM-optimized) + +The output is structured for efficient LLM consumption: + +``` +[搜索: activitywatch] + 今天 14:32 [clipboard] VSCode: ActivityWatch is an open-source time-tracking tool... + 昨天 10:15 [manual] Terminal: Read ActivityWatch docs on GH + +[最近剪贴板] + 今天 16:45 Safari: "ActivityWatch API reference" + 今天 15:20 VSCode: "def init_watcher():" + +[今天时间线] + 14:00-14:30 Safari (18m) - window: ActivityWatch GitHub + 14:30-16:45 VSCode (2h 15m) - window: keypulse/watchers.py + 16:45-17:00 Safari (15m) - window: ActivityWatch Issues + +[手动保存] + 今天 16:00 "Need to integrate FTS5 indexing for clipboard" +``` + +### Command options + +```bash +keypulse recall [OPTIONS] + +Options: + --since TIMESPEC Search time range (default: 7d) + Examples: 7d, 24h, 2w, today, yesterday + --limit N Max results to return (default: 5) + --help Show help message +``` + +## ⚠️ Important Notes + +### Requires daemon running + +The skill only works if `keypulse start` is already running in the background. If the daemon stops, you'll see an error: + +``` +[错误] KeyPulse 未运行,请先执行: keypulse start +``` + +Start it again: + +```bash +keypulse start +keypulse status +``` + +### Local-only execution + +All data stays on your machine: +- The skill runs locally; no data is sent to Claude/OpenClaw servers +- Activity history never leaves your computer +- No network communication for skill queries + +### Privacy-first by design + +Sensitive apps are automatically excluded from recall: +- 1Password, Keychain, LastPass, Bitwarden — never recorded +- Slack, Teams, Discord — app names only, no content +- Passwords and tokens — automatically masked +- Custom rules via `~/.keypulse/config.toml` + +## 🎯 Skill Capabilities + +### What the skill CAN do + +✅ Search clipboard history by keyword +✅ Browse session timeline (today, this week, past days) +✅ Retrieve manual notes you saved with `keypulse save` +✅ Answer "what was I doing X days ago?" +✅ Find related activities by keyword or app +✅ Provide natural language summaries of your work +✅ Help context-switch back to previous projects + +### What the skill DOES NOT do + +❌ Auto-summarize sessions (that's up to Claude's synthesis) +❌ Generate reports (use `keypulse stats` for that) +❌ Access browser history or document contents (only what you've copied) +❌ Export or share data outside your machine +❌ Predict future activity or provide recommendations + +## 📊 Differences from CLI + +| Feature | CLI (`keypulse search`) | Skill (`/work-recall`) | +|---------|----------------------|---------------------| +| Output format | Rich tables, pretty printing | Plain text, token-optimized | +| Default limit | 50 results | 5 (compact for LLM context) | +| Time range | Configurable via --since | Defaults to last 7 days | +| Filtering | By app, source, date | By query keyword only | +| Purpose | Exploration, reporting | LLM-assisted work recall | +| Automation | Manual CLI commands | Auto-triggered by questions | + +### CLI example: + +```bash +keypulse search "python" --since 30d --app VSCode --limit 50 +``` + +### Skill equivalent: + +``` +/work-recall python development work I did +``` + +## ⚙️ Configuration + +Both the CLI and skill use the same configuration file at `~/.keypulse/config.toml`. + +### Key settings affecting the skill + +```toml +[clipboard] +max_text_length = 2000 # Max clipboard length indexed +dedup_window_sec = 600 # Dedup identical copies within 10 min + +[privacy] +redact_emails = true # Mask email addresses in results +redact_tokens = true # Mask API keys and tokens +redact_phones = true # Mask phone numbers + +[app] +retention_days = 30 # How far back history goes +flush_interval_sec = 5 # How often data is saved +``` + +### Disable the skill temporarily + +Pause the daemon without stopping it: + +```bash +keypulse pause +``` + +Resume later: + +```bash +keypulse resume +``` + +Or stop the daemon entirely: + +```bash +keypulse stop +``` + +## 🔍 Troubleshooting + +### "KeyPulse not running" error + +**Error message:** +``` +[错误] KeyPulse 未运行,请先执行: keypulse start +``` + +**Solution:** + +```bash +keypulse start +keypulse status +``` + +Check if there were any startup issues: + +```bash +tail -f ~/.keypulse/keypulse.log +``` + +### Skill not appearing in Claude Code + +The skill will appear when: +1. Claude Code recognizes this repository (opened as a project) +2. The `.claude/skills/work-recall/SKILL.md` file exists in the repo +3. You're using an up-to-date version of Claude Code + +If it still doesn't appear: +- Refresh the project view +- Restart Claude Code +- Verify the file exists: `ls -la .claude/skills/work-recall/SKILL.md` + +### No results from search + +If `/work-recall ` returns no results: + +**Check that data is being recorded:** + +```bash +keypulse timeline --today +keypulse recent --type clipboard +``` + +If you see output, the daemon is working but hasn't recorded anything matching your query yet. Give it time to accumulate data. + +**Check privacy rules:** + +Make sure the apps you're using aren't blacklisted: + +```bash +keypulse config show +``` + +Look at the privacy section and any explicit rules. + +### Poor search results + +If the skill returns irrelevant results: + +1. **Be more specific** in your question: + - Bad: `/work-recall python` + - Good: `/work-recall python async performance optimization` + +2. **Check the clipboard** was actually captured: + ```bash + keypulse recent --type clipboard --limit 20 + ``` + +3. **Search with the CLI** for comparison: + ```bash + keypulse search "your query" --since 7d + ``` + +4. **Review recent activity:** + ```bash + keypulse timeline --today + ``` + +### Daemon keeps stopping + +If `keypulse status` shows "not running" when you expect it to be: + +1. Check the log for errors: + ```bash + tail -50 ~/.keypulse/keypulse.log + ``` + +2. Restart with verbose logging: + ```bash + keypulse stop + keypulse start + ``` + +3. Verify system permissions (macOS): + ```bash + System Settings → Privacy & Security → Accessibility + System Settings → Privacy & Security → Screen Recording + ``` + +4. Check for competing instances: + ```bash + pgrep -f "keypulse" | wc -l + ``` + + If more than one, manually kill extras: + ```bash + pkill -f "keypulse" + keypulse start + ``` + +## 🧠 Using the skill effectively + +### Ask natural questions + +The skill is designed for conversational work-recall: + +``` +✅ "What was I researching yesterday afternoon?" +✅ "Show me the code I copied about async/await" +✅ "What project did I work on 3 days ago?" +✅ "Help me remember what I was debugging last week" + +❌ "latest search results" (too vague) +❌ "all clipboard entries" (too broad) +❌ "predictive suggestions" (out of scope) +``` + +### Use for context switching + +When returning to a project: + +``` +/work-recall what was I working on with the authentication system last week? +``` + +Claude will synthesize your recent activity to help you get back up to speed. + +### Combine with Claude's other capabilities + +The skill works alongside Claude's other features: + +``` +/work-recall X component implementation details + +Now, can you review my latest code and suggest improvements based on what I was working on? +``` + +Claude can now read both your activity history and your code together. + +### Use the CLI for deeper analysis + +For detailed exploration, use the CLI directly: + +```bash +keypulse timeline --date 2026-04-10 +keypulse stats --days 7 +keypulse export --format json --days 30 | jq . > my_activity.json +``` + +## 🔗 Related + +### Within this repository + +- **Main CLI Branch** — Full command reference and architecture docs: [`claude/review-spec-repo-6jybR`](https://github.com/Longfellow1/keypulse/tree/claude/review-spec-repo-6jybR) +- **Architecture** — See [README on main branch](https://github.com/Longfellow1/keypulse/blob/claude/review-spec-repo-6jybR/README.md) for detailed system design +- **Privacy Policy** — [SECURITY.md](SECURITY.md) +- **Contributing** — [CONTRIBUTING.md](CONTRIBUTING.md) + +### External resources + +- **GitHub Repository** — [github.com/Longfellow1/keypulse](https://github.com/Longfellow1/keypulse) +- **License** — MIT, see [LICENSE](LICENSE) + +## FAQ + +### Does the skill upload my data anywhere? + +No. All queries run locally. Your activity history never leaves your machine. The skill invokes `keypulse recall` which reads from your local database and returns plain text. + +### Can I share this skill with others? + +The skill itself is open source (MIT license), but it only works with a personal KeyPulse daemon. Each user needs to: +1. Install KeyPulse +2. Start their own daemon +3. Set up the skill in their Claude Code / OpenClaw + +The skill can't access other people's activity data; it's isolated to your machine. + +### What if I have privacy concerns? + +KeyPulse is designed for privacy: +- Open source — audit the code anytime +- Local-first — no uploads or tracking +- Configurable — control exactly what gets recorded +- Maskable — sensitive data is auto-detected and redacted + +Review the privacy settings in `~/.keypulse/config.toml` and the Privacy & Security section of the [main README](https://github.com/Longfellow1/keypulse/tree/claude/review-spec-repo-6jybR). + +### How much data does it store? + +By default, 30 days of activity (configurable). Typical disk usage: +- Light usage: ~600 KB/week +- Normal workday: ~1.2 MB/week +- Heavy usage: ~2 MB/week + +The database is stored at `~/.keypulse/keypulse.db`. + +### Can I use the skill offline? + +Yes, the skill works entirely offline once the daemon is running. All data is local, and there's no network requirement for skill queries. + +### What happens if the daemon crashes? + +The skill will return an error: +``` +[错误] KeyPulse 未运行,请先执行: keypulse start +``` + +Restart it: +```bash +keypulse start +``` + +Your data is preserved in the SQLite database. + +## Examples + +### Example 1: Finding yesterday's research + +**Question:** +``` +/work-recall What did I research about authentication yesterday? +``` + +**Claude synthesizes and responds:** +``` +Yesterday around 14:30-16:00, you were researching authentication in Safari +and implementing related code in VSCode. You copied several resources: + +- "OAuth 2.0 specification" (14:45) +- Code snippet from passport.js authentication example (15:20) +- Your manual note: "Implement JWT refresh token strategy" (15:45) + +You spent most of that time in VSCode (1h 30m), suggesting you were actively +coding while referencing the research material. +``` + +### Example 2: Returning to a project + +**Question:** +``` +/work-recall Help me pick up where I left off with the database refactoring +``` + +**Claude synthesizes and responds:** +``` +Your last work on database refactoring was 3 days ago (April 12). You spent +2 hours in VSCode on the keypulse/store/schema.py file, and your notes mention: + +- "Add migration scripts for FTS5 schema changes" +- "Batch write optimization — test at 5s flush intervals" + +You also copied several SQL patterns related to transaction handling. +Today might be a good time to check your most recent commits and any +TODOs you left in the code. +``` + +### Example 3: App usage patterns + +**Question:** +``` +/work-recall How much time did I spend on communication vs coding this week? +``` + +**Claude synthesizes and responds:** +``` +Based on your recent activity this week: + +**Coding/Development (VSCode, Terminal): ~18 hours** +- Mostly focused work with low idle time +- Frequent context switches between files + +**Communication (Slack, Mail): ~4 hours** +- Spike on Wednesday afternoon (team sync) +- Usually 15-30 minute blocks + +**Research (Safari, docs): ~6 hours** +- ActivityWatch architecture review +- Performance optimization research + +You maintained good focus overall with about 70% deep work time. +``` + +--- + +**Last updated:** April 15, 2026 + +**Note:** This skill requires Python 3.11+, macOS 12.0+, and the KeyPulse daemon to be running. See [SKILL_SETUP.md](SKILL_SETUP.md) for detailed installation and troubleshooting. diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..944b9e9 --- /dev/null +++ b/config.toml @@ -0,0 +1,44 @@ +[app] +db_path = "~/.keypulse/keypulse.db" +log_path = "~/.keypulse/keypulse.log" +flush_interval_sec = 5 +retention_days = 30 + +[watchers] +window = true +idle = true +clipboard = true +manual = true +browser = false + +[idle] +threshold_sec = 180 + +[clipboard] +max_text_length = 2000 +dedup_window_sec = 600 + +[privacy] +redact_emails = true +redact_phones = true +redact_tokens = true + +[[policies]] +scope_type = "app" +scope_value = "1Password" +mode = "deny" + +[[policies]] +scope_type = "app" +scope_value = "Keychain Access" +mode = "deny" + +[[policies]] +scope_type = "app" +scope_value = "Terminal" +mode = "metadata-only" + +[[policies]] +scope_type = "window" +scope_value = "login" +mode = "deny" diff --git a/keypulse/__init__.py b/keypulse/__init__.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/keypulse/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/keypulse/app.py b/keypulse/app.py new file mode 100644 index 0000000..10b55e2 --- /dev/null +++ b/keypulse/app.py @@ -0,0 +1,107 @@ +from __future__ import annotations +import os +import signal +import sys +import time +from pathlib import Path +from typing import Optional + +from keypulse.config import Config +from keypulse.utils.lock import SingleInstanceLock +from keypulse.utils.logging import setup_logging, get_logger +from keypulse.store.db import init_db + +logger = get_logger("app") + + +def daemonize(pid_path: Path): + """ + Standard Unix double-fork daemonization. + After this call, the current process IS the daemon (new PID written to pid_path). + The calling process (parent) exits immediately after first fork. + """ + # First fork + pid = os.fork() + if pid > 0: + # Parent: wait briefly then exit + sys.exit(0) + + os.setsid() + os.umask(0o022) + + # Second fork + pid = os.fork() + if pid > 0: + sys.exit(0) + + # Redirect stdio to /dev/null + sys.stdout.flush() + sys.stderr.flush() + devnull = os.open(os.devnull, os.O_RDWR) + os.dup2(devnull, sys.stdin.fileno()) + os.dup2(devnull, sys.stdout.fileno()) + os.dup2(devnull, sys.stderr.fileno()) + os.close(devnull) + + # Write PID + pid_path.parent.mkdir(parents=True, exist_ok=True) + pid_path.write_text(str(os.getpid())) + + +def run(config: Optional[Config] = None): + """ + Main daemon execution: starts CaptureManager, blocks until SIGTERM/SIGINT. + Call this AFTER daemonize(). + """ + if config is None: + config = Config.load() + + setup_logging(config.log_path_expanded) + logger.info("KeyPulse daemon starting") + + from keypulse.capture.manager import CaptureManager + + manager = CaptureManager(config) + lock = SingleInstanceLock() + + def _shutdown(signum, frame): + logger.info(f"Signal {signum} received, shutting down") + try: + manager.stop() + except Exception as e: + logger.error(f"Error during stop: {e}") + finally: + lock.release() + sys.exit(0) + + signal.signal(signal.SIGTERM, _shutdown) + signal.signal(signal.SIGINT, _shutdown) + + try: + manager.start() + logger.info("KeyPulse daemon running") + while True: + time.sleep(1) + except Exception as e: + logger.error(f"Fatal error: {e}") + lock.release() + sys.exit(1) + + +def start_daemon(config: Optional[Config] = None): + """ + Fork to background and start daemon. + Returns immediately in the original (parent) process. + The child becomes the daemon. + """ + if config is None: + config = Config.load() + + from keypulse.utils.paths import get_pid_path + pid_path = get_pid_path() + + # Double-fork: after this, parent exits, child continues as daemon + daemonize(pid_path) + + # --- Only daemon process reaches here --- + run(config) diff --git a/keypulse/capture/__init__.py b/keypulse/capture/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/keypulse/capture/aggregator.py b/keypulse/capture/aggregator.py new file mode 100644 index 0000000..3f31404 --- /dev/null +++ b/keypulse/capture/aggregator.py @@ -0,0 +1,80 @@ +from __future__ import annotations +import json +from datetime import datetime, timezone +from typing import Optional +from keypulse.store.models import RawEvent, Session +from keypulse.store.repository import upsert_session +from keypulse.store.db import get_conn + + +class Aggregator: + """Tracks current session, cuts sessions on window-switch or idle.""" + + def __init__(self): + self._current_session: Optional[Session] = None + + def process(self, event: RawEvent) -> Optional[Session]: + """ + Update session state for this event. + Returns the current session (after update), or None. + """ + now = event.ts_start + + # Idle start: close current session + if event.event_type == "idle_start": + if self._current_session: + self._close_current(now) + return None + + # Idle end / resume: will open new session on next window event + if event.event_type == "idle_end": + return None + + # Window focus: new app → new session + if event.event_type == "window_focus": + if self._current_session and self._current_session.app_name != event.app_name: + self._close_current(now) + if not self._current_session: + self._current_session = Session( + started_at=now, + ended_at=now, + app_name=event.app_name, + primary_window_title=event.window_title, + ) + + # Update current session if exists + if self._current_session: + self._current_session.ended_at = now + self._current_session.event_count += 1 + try: + start = datetime.fromisoformat(self._current_session.started_at) + end = datetime.fromisoformat(now) + self._current_session.duration_sec = int((end - start).total_seconds()) + except Exception: + pass + # Update title if window_title changed + if event.window_title and event.event_type == "window_focus": + self._current_session.primary_window_title = event.window_title + upsert_session(self._current_session) + + return self._current_session + + def _close_current(self, ended_at: str): + if self._current_session: + self._current_session.ended_at = ended_at + try: + start = datetime.fromisoformat(self._current_session.started_at) + end = datetime.fromisoformat(ended_at) + self._current_session.duration_sec = int((end - start).total_seconds()) + except Exception: + pass + upsert_session(self._current_session) + self._current_session = None + + def flush(self): + """Force-close current session with current time.""" + now = datetime.now(timezone.utc).isoformat() + self._close_current(now) + + def current_session(self) -> Optional[Session]: + return self._current_session diff --git a/keypulse/capture/base.py b/keypulse/capture/base.py new file mode 100644 index 0000000..bc79d69 --- /dev/null +++ b/keypulse/capture/base.py @@ -0,0 +1,54 @@ +from __future__ import annotations +import abc +import queue +import threading +from dataclasses import dataclass, field +from typing import Optional +from keypulse.store.models import RawEvent + + +class BaseWatcher(abc.ABC): + """Abstract base class for all event watchers.""" + + name: str = "base" + + def __init__(self, event_queue: queue.Queue): + self._queue = event_queue + self._running = threading.Event() + self._paused = threading.Event() + self._thread: Optional[threading.Thread] = None + + def start(self): + if self._thread and self._thread.is_alive(): + return + self._running.set() + self._paused.clear() + self._thread = threading.Thread(target=self._run, daemon=True, name=f"watcher-{self.name}") + self._thread.start() + + def stop(self): + self._running.clear() + if self._thread: + self._thread.join(timeout=5) + self._thread = None + + def pause(self): + self._paused.set() + + def resume(self): + self._paused.clear() + + def is_running(self) -> bool: + return self._thread is not None and self._thread.is_alive() + + def health(self) -> dict: + return {"name": self.name, "running": self.is_running(), "paused": self._paused.is_set()} + + def emit(self, event: RawEvent): + """Put event onto shared queue.""" + self._queue.put(event) + + @abc.abstractmethod + def _run(self): + """Main watcher loop. Must check self._running.is_set() and self._paused.is_set().""" + ... diff --git a/keypulse/capture/manager.py b/keypulse/capture/manager.py new file mode 100644 index 0000000..fca2fd6 --- /dev/null +++ b/keypulse/capture/manager.py @@ -0,0 +1,180 @@ +from __future__ import annotations +import json +import queue +import signal +import threading +import time +from datetime import datetime, timezone +from typing import Optional + +from keypulse.capture.aggregator import Aggregator +from keypulse.capture.base import BaseWatcher +from keypulse.capture.policy import PolicyEngine +from keypulse.capture.normalizer import normalize_manual_event +from keypulse.config import Config +from keypulse.privacy.desensitizer import desensitize, truncate +from keypulse.store.db import init_db +from keypulse.store.models import RawEvent, SearchDoc +from keypulse.store.repository import ( + insert_raw_event, insert_search_doc, seed_policies_from_config, set_state, apply_retention +) +from keypulse.utils.logging import get_logger + +logger = get_logger("manager") + + +class CaptureManager: + def __init__(self, config: Config): + self.config = config + self._queue: queue.Queue = queue.Queue() + self._watchers: dict[str, BaseWatcher] = {} + self._policy = PolicyEngine() + self._aggregator = Aggregator() + self._running = threading.Event() + self._paused = threading.Event() + self._flush_thread: Optional[threading.Thread] = None + + def start(self): + """Initialize DB, seed policies, start watchers and flush loop.""" + init_db(self.config.db_path_expanded) + seed_policies_from_config(self.config.policies) + self._policy.reload() + apply_retention(self.config.app.retention_days) + + self._init_watchers() + for w in self._watchers.values(): + w.start() + + self._running.set() + self._flush_thread = threading.Thread( + target=self._flush_loop, daemon=True, name="flush-loop" + ) + self._flush_thread.start() + set_state("status", "running") + set_state("started_at", datetime.now(timezone.utc).isoformat()) + logger.info("CaptureManager started") + + def stop(self): + """Stop all watchers and flush remaining events.""" + self._running.clear() + for w in self._watchers.values(): + w.stop() + self._drain_queue() + self._aggregator.flush() + if self._flush_thread: + self._flush_thread.join(timeout=10) + set_state("status", "stopped") + logger.info("CaptureManager stopped") + + def pause(self): + self._paused.set() + for w in self._watchers.values(): + w.pause() + set_state("status", "paused") + + def resume(self): + self._paused.clear() + for w in self._watchers.values(): + w.resume() + set_state("status", "running") + + def health(self) -> dict: + return { + "running": self._running.is_set(), + "paused": self._paused.is_set(), + "watchers": {name: w.health() for name, w in self._watchers.items()}, + "queue_size": self._queue.qsize(), + } + + def save_manual(self, text: str, tags: Optional[str] = None): + """Inject a manual save event directly.""" + event = normalize_manual_event(text=text, tags=tags) + self._queue.put(event) + + def _init_watchers(self): + cfg = self.config.watchers + if cfg.window: + from keypulse.capture.watchers.window import WindowWatcher + self._watchers["window"] = WindowWatcher(self._queue) + if cfg.idle: + from keypulse.capture.watchers.idle import IdleWatcher + self._watchers["idle"] = IdleWatcher( + self._queue, self.config.idle.threshold_sec + ) + if cfg.clipboard: + from keypulse.capture.watchers.clipboard import ClipboardWatcher + self._watchers["clipboard"] = ClipboardWatcher( + self._queue, + max_text_length=self.config.clipboard.max_text_length, + dedup_window_sec=self.config.clipboard.dedup_window_sec, + ) + if cfg.manual: + from keypulse.capture.watchers.manual import ManualWatcher + self._watchers["manual"] = ManualWatcher(self._queue) + + def _flush_loop(self): + while self._running.is_set(): + if not self._paused.is_set(): + self._drain_queue() + time.sleep(self.config.app.flush_interval_sec) + + def _drain_queue(self): + events = [] + while True: + try: + events.append(self._queue.get_nowait()) + except queue.Empty: + break + for event in events: + try: + self._process_event(event) + except Exception as e: + logger.error(f"Error processing event: {e}") + + def _process_event(self, event: RawEvent): + # 1. Policy + result = self._policy.apply(event) + if result is None: + logger.debug(f"Event denied by policy: {event.source}/{event.event_type}") + return + + # 2. Desensitize content + if result.content_text: + result.content_text = desensitize( + result.content_text, + redact_emails=self.config.privacy.redact_emails, + redact_phones=self.config.privacy.redact_phones, + redact_tokens=self.config.privacy.redact_tokens, + ) + result.content_text = truncate( + result.content_text, self.config.clipboard.max_text_length + ) + + # 3. Session tracking + session = self._aggregator.process(result) + if session: + result.session_id = session.id + + # 4. Persist raw event + row_id = insert_raw_event(result) + result.id = row_id + + # 5. Index searchable content + if result.source in ("clipboard", "manual") and result.content_text: + tags = None + if result.metadata_json: + try: + tags = json.loads(result.metadata_json).get("tags") + except Exception: + pass + doc = SearchDoc( + ref_type=result.source, + ref_id=str(row_id), + title=result.window_title or result.app_name, + body=result.content_text, + tags=tags, + app_name=result.app_name, + ) + insert_search_doc(doc) + + set_state("last_flush", datetime.now(timezone.utc).isoformat()) diff --git a/keypulse/capture/normalizer.py b/keypulse/capture/normalizer.py new file mode 100644 index 0000000..32c2df6 --- /dev/null +++ b/keypulse/capture/normalizer.py @@ -0,0 +1,82 @@ +from __future__ import annotations +import hashlib +import json +from datetime import datetime, timezone +from typing import Optional +from keypulse.store.models import RawEvent + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _hash(text: str) -> str: + return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16] + + +def normalize_window_event( + event_type: str, + app_name: Optional[str], + window_title: Optional[str], + process_name: Optional[str], + ts_start: Optional[str] = None, + ts_end: Optional[str] = None, + metadata: Optional[dict] = None, +) -> RawEvent: + return RawEvent( + source="window", + event_type=event_type, + ts_start=ts_start or _now(), + ts_end=ts_end, + app_name=app_name, + window_title=window_title, + process_name=process_name, + metadata_json=json.dumps(metadata) if metadata else None, + ) + + +def normalize_idle_event( + event_type: str, # idle_start | idle_end + idle_seconds: float = 0.0, + ts_start: Optional[str] = None, +) -> RawEvent: + return RawEvent( + source="idle", + event_type=event_type, + ts_start=ts_start or _now(), + metadata_json=json.dumps({"idle_seconds": idle_seconds}), + ) + + +def normalize_clipboard_event( + text: str, + app_name: Optional[str] = None, + ts_start: Optional[str] = None, +) -> RawEvent: + return RawEvent( + source="clipboard", + event_type="clipboard_copy", + ts_start=ts_start or _now(), + app_name=app_name, + content_text=text, + content_hash=_hash(text), + ) + + +def normalize_manual_event( + text: str, + tags: Optional[str] = None, + app_name: Optional[str] = None, + window_title: Optional[str] = None, + ts_start: Optional[str] = None, +) -> RawEvent: + return RawEvent( + source="manual", + event_type="manual_save", + ts_start=ts_start or _now(), + app_name=app_name, + window_title=window_title, + content_text=text, + content_hash=_hash(text), + metadata_json=json.dumps({"tags": tags}) if tags else None, + ) diff --git a/keypulse/capture/policy.py b/keypulse/capture/policy.py new file mode 100644 index 0000000..195f6bb --- /dev/null +++ b/keypulse/capture/policy.py @@ -0,0 +1,70 @@ +from __future__ import annotations +import re +from typing import Optional +from keypulse.store.models import RawEvent +from keypulse.store.repository import get_all_policies + +# Policy modes +ALLOW = "allow" +DENY = "deny" +METADATA_ONLY = "metadata-only" +REDACT = "redact" +TRUNCATE = "truncate" + + +class PolicyEngine: + def __init__(self): + self._policies: list[dict] = [] + + def reload(self): + """Reload policies from DB.""" + self._policies = get_all_policies() + + def evaluate(self, event: RawEvent) -> str: + """ + Returns the effective mode for this event. + Default is ALLOW if no rules match. + Priority: lower number = higher priority. + """ + for policy in sorted(self._policies, key=lambda p: p.get("priority", 100)): + if not policy.get("enabled", 1): + continue + scope_type = policy["scope_type"] + scope_value = policy["scope_value"] + mode = policy["mode"] + + match = False + if scope_type == "app" and event.app_name: + match = scope_value.lower() in event.app_name.lower() + elif scope_type == "source": + match = scope_value == event.source + elif scope_type == "window" and event.window_title: + match = scope_value.lower() in event.window_title.lower() + elif scope_type == "content" and event.content_text: + try: + match = bool(re.search(scope_value, event.content_text, re.IGNORECASE)) + except re.error: + match = scope_value.lower() in event.content_text.lower() + + if match: + return mode + + return ALLOW + + def apply(self, event: RawEvent) -> Optional[RawEvent]: + """ + Apply policy to event. Returns modified event or None if denied. + """ + mode = self.evaluate(event) + if mode == DENY: + return None + if mode == METADATA_ONLY: + event.content_text = None + event.content_hash = None + if mode == REDACT: + # Caller should run desensitizer separately; mark level + event.sensitivity_level = max(event.sensitivity_level, 2) + if mode == TRUNCATE: + if event.content_text and len(event.content_text) > 500: + event.content_text = event.content_text[:500] + "...[policy truncated]" + return event diff --git a/keypulse/capture/watchers/__init__.py b/keypulse/capture/watchers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/keypulse/capture/watchers/browser.py b/keypulse/capture/watchers/browser.py new file mode 100644 index 0000000..8b72cdc --- /dev/null +++ b/keypulse/capture/watchers/browser.py @@ -0,0 +1,16 @@ +from __future__ import annotations +import queue +import time +from keypulse.capture.base import BaseWatcher +from keypulse.utils.logging import get_logger + +logger = get_logger("watcher.browser") + + +class BrowserWatcher(BaseWatcher): + """Placeholder — browser watcher not implemented in MVP.""" + name = "browser" + + def _run(self): + logger.info("BrowserWatcher: not implemented in MVP, exiting.") + return diff --git a/keypulse/capture/watchers/clipboard.py b/keypulse/capture/watchers/clipboard.py new file mode 100644 index 0000000..3b742d6 --- /dev/null +++ b/keypulse/capture/watchers/clipboard.py @@ -0,0 +1,82 @@ +from __future__ import annotations +import hashlib +import queue +import time +from datetime import datetime, timezone +from typing import Optional + +from keypulse.capture.base import BaseWatcher +from keypulse.capture.normalizer import normalize_clipboard_event +from keypulse.utils.logging import get_logger + +logger = get_logger("watcher.clipboard") + +POLL_INTERVAL = 1.0 # seconds + + +def _get_current_app_name() -> Optional[str]: + try: + from AppKit import NSWorkspace + app = NSWorkspace.sharedWorkspace().frontmostApplication() + return app.localizedName() if app else None + except Exception: + return None + + +class ClipboardWatcher(BaseWatcher): + name = "clipboard" + + def __init__(self, event_queue: queue.Queue, max_text_length: int = 2000, dedup_window_sec: int = 600): + super().__init__(event_queue) + self._max_len = max_text_length + self._dedup_window = dedup_window_sec + self._last_count: Optional[int] = None + self._recent_hashes: dict[str, float] = {} # hash → timestamp + + def _run(self): + try: + from AppKit import NSPasteboard, NSPasteboardTypeString + except ImportError: + logger.error("pyobjc-framework-AppKit not available") + return + + pb = NSPasteboard.generalPasteboard() + self._last_count = pb.changeCount() + + while self._running.is_set(): + if self._paused.is_set(): + time.sleep(POLL_INTERVAL) + continue + try: + count = pb.changeCount() + if count != self._last_count: + self._last_count = count + text = pb.stringForType_(NSPasteboardTypeString) + if text and isinstance(text, str): + self._handle_copy(text) + except Exception as e: + logger.error(f"ClipboardWatcher error: {e}") + time.sleep(POLL_INTERVAL) + + def _handle_copy(self, text: str): + now = time.monotonic() + # Clean up old dedup entries + self._recent_hashes = { + h: t for h, t in self._recent_hashes.items() + if now - t < self._dedup_window + } + h = hashlib.sha256(text.encode()).hexdigest()[:16] + if h in self._recent_hashes: + logger.debug("Clipboard dedup hit, skipping") + return + self._recent_hashes[h] = now + + # Truncate before emitting (further desensitization happens in manager) + if len(text) > self._max_len: + text = text[:self._max_len] + "...[truncated]" + + app_name = _get_current_app_name() + ts = datetime.now(timezone.utc).isoformat() + event = normalize_clipboard_event(text=text, app_name=app_name, ts_start=ts) + self.emit(event) + logger.debug(f"Clipboard captured ({len(text)} chars) from {app_name}") diff --git a/keypulse/capture/watchers/idle.py b/keypulse/capture/watchers/idle.py new file mode 100644 index 0000000..4bd680a --- /dev/null +++ b/keypulse/capture/watchers/idle.py @@ -0,0 +1,59 @@ +from __future__ import annotations +import queue +import time +from datetime import datetime, timezone +from typing import Optional + +from keypulse.capture.base import BaseWatcher +from keypulse.capture.normalizer import normalize_idle_event +from keypulse.utils.logging import get_logger + +logger = get_logger("watcher.idle") + +POLL_INTERVAL = 10 # seconds + + +def _get_idle_seconds() -> float: + """Return seconds since last user input event.""" + try: + import Quartz + return Quartz.CGEventSourceSecondsSinceLastEventType( + Quartz.kCGEventSourceStateHIDSystemState, + Quartz.kCGAnyInputEventType, + ) + except Exception as e: + logger.debug(f"idle_seconds error: {e}") + return 0.0 + + +class IdleWatcher(BaseWatcher): + name = "idle" + + def __init__(self, event_queue: queue.Queue, threshold_sec: int = 180): + super().__init__(event_queue) + self._threshold = threshold_sec + self._is_idle = False + + def _run(self): + while self._running.is_set(): + if self._paused.is_set(): + time.sleep(POLL_INTERVAL) + continue + try: + idle_secs = _get_idle_seconds() + now = datetime.now(timezone.utc).isoformat() + + if idle_secs >= self._threshold and not self._is_idle: + self._is_idle = True + self.emit(normalize_idle_event("idle_start", idle_secs, now)) + logger.debug(f"Idle started ({idle_secs:.0f}s)") + + elif idle_secs < self._threshold and self._is_idle: + self._is_idle = False + self.emit(normalize_idle_event("idle_end", idle_secs, now)) + logger.debug("Idle ended") + + except Exception as e: + logger.error(f"IdleWatcher error: {e}") + + time.sleep(POLL_INTERVAL) diff --git a/keypulse/capture/watchers/manual.py b/keypulse/capture/watchers/manual.py new file mode 100644 index 0000000..8f91402 --- /dev/null +++ b/keypulse/capture/watchers/manual.py @@ -0,0 +1,17 @@ +from __future__ import annotations +import queue +from keypulse.capture.base import BaseWatcher + + +class ManualWatcher(BaseWatcher): + """ + ManualWatcher does not poll anything. + Events are injected via CaptureManager.save_manual() → queue.put(). + The _run loop is a no-op keep-alive. + """ + name = "manual" + + def _run(self): + import time + while self._running.is_set(): + time.sleep(1) diff --git a/keypulse/capture/watchers/window.py b/keypulse/capture/watchers/window.py new file mode 100644 index 0000000..d2ea8ba --- /dev/null +++ b/keypulse/capture/watchers/window.py @@ -0,0 +1,100 @@ +from __future__ import annotations +import queue +import threading +import time +from datetime import datetime, timezone +from typing import Optional + +from keypulse.capture.base import BaseWatcher +from keypulse.capture.normalizer import normalize_window_event +from keypulse.utils.logging import get_logger + +logger = get_logger("watcher.window") + +HEARTBEAT_INTERVAL = 30 # seconds + + +def _get_frontmost_app() -> tuple[Optional[str], Optional[str], Optional[str]]: + """Returns (app_name, window_title, process_name). Requires Accessibility permission.""" + try: + from AppKit import NSWorkspace + app = NSWorkspace.sharedWorkspace().frontmostApplication() + if not app: + return None, None, None + app_name = app.localizedName() + process_name = app.bundleIdentifier() or app_name + pid = app.processIdentifier() + window_title = _get_window_title(pid) + return app_name, window_title, process_name + except Exception as e: + logger.debug(f"get_frontmost_app error: {e}") + return None, None, None + + +def _get_window_title(pid: int) -> Optional[str]: + """Get focused window title via Accessibility API.""" + try: + import ApplicationServices as AS + app_elem = AS.AXUIElementCreateApplication(pid) + err, window = AS.AXUIElementCopyAttributeValue(app_elem, "AXFocusedWindow", None) + if err != 0 or not window: + return None + err, title = AS.AXUIElementCopyAttributeValue(window, "AXTitle", None) + return title if err == 0 else None + except Exception: + return None + + +class WindowWatcher(BaseWatcher): + name = "window" + + def __init__(self, event_queue: queue.Queue): + super().__init__(event_queue) + self._last_app: Optional[str] = None + self._last_title: Optional[str] = None + self._last_heartbeat: float = 0.0 + + def _run(self): + """ + Poll for frontmost app changes every 1 second. + Emit window_focus on app/title change, window_heartbeat every 30s. + """ + while self._running.is_set(): + if self._paused.is_set(): + time.sleep(1) + continue + try: + app_name, window_title, process_name = _get_frontmost_app() + now = datetime.now(timezone.utc).isoformat() + now_mono = time.monotonic() + + changed = (app_name != self._last_app) or (window_title != self._last_title) + + if changed and app_name: + event = normalize_window_event( + event_type="window_focus", + app_name=app_name, + window_title=window_title, + process_name=process_name, + ts_start=now, + ) + self.emit(event) + self._last_app = app_name + self._last_title = window_title + self._last_heartbeat = now_mono + + elif (now_mono - self._last_heartbeat) >= HEARTBEAT_INTERVAL and app_name: + event = normalize_window_event( + event_type="window_heartbeat", + app_name=app_name, + window_title=window_title, + process_name=process_name, + ts_start=now, + ) + self.emit(event) + self._last_heartbeat = now_mono + + except Exception as e: + logger.error(f"WindowWatcher error: {e}") + + time.sleep(1) diff --git a/keypulse/cli.py b/keypulse/cli.py new file mode 100644 index 0000000..59b9057 --- /dev/null +++ b/keypulse/cli.py @@ -0,0 +1,972 @@ +from __future__ import annotations +import os +import sys +import time +import signal +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Optional + +import click +from rich.console import Console +from rich.table import Table +from rich.panel import Panel +from rich.text import Text + +from keypulse.config import Config +from keypulse.store.db import init_db +from keypulse.store.repository import ( + get_sessions, + get_session_by_id, + query_raw_events, + purge_raw_events, + get_state, + set_state, + get_all_policies, + insert_policy, + apply_retention, +) +from keypulse.store.models import Policy, RawEvent, SearchDoc +from keypulse.privacy.desensitizer import desensitize +from keypulse.utils.paths import get_data_dir, get_db_path, get_pid_path, get_log_path, get_config_path +from keypulse.utils.lock import SingleInstanceLock +from keypulse.utils.logging import setup_logging +from keypulse.app import start_daemon, daemonize, run +from keypulse.services.timeline import get_timeline_rows +from keypulse.services.stats import get_stats +from keypulse.services.export import export_json, export_csv, export_markdown +from keypulse.services.sessionizer import sessions_for_today, recent_sessions +from keypulse.search.engine import search, recent_clipboard, recent_manual, recent_sessions_docs +from keypulse.capture.normalizer import normalize_manual_event + + +# Shared console objects +console = Console() +err_console = Console(stderr=True) + + +def get_config() -> Config: + """Load config from standard locations.""" + return Config.load() + + +def require_db(cfg: Config): + """Initialize database if not already done.""" + init_db(cfg.db_path_expanded) + + +@click.group() +def main(): + """KeyPulse — macOS personal activity monitoring CLI.""" + pass + + +# ═════════════════════════════════════════════════════════════════════════════ +# 1. START +# ═════════════════════════════════════════════════════════════════════════════ + +@main.command() +@click.option("--config", "config_path", default=None, help="Path to config.toml") +def start(config_path): + """Start the KeyPulse daemon.""" + cfg = Config.load() if not config_path else _load_config_from(config_path) + lock = SingleInstanceLock() + + if lock.is_running(): + err_console.print(f"[red]Already running (PID {lock.get_pid()})[/red]") + sys.exit(1) + + # Init DB in parent so errors surface here + require_db(cfg) + + # Fork to daemon + pid = os.fork() + if pid == 0: + # Child process: become daemon + daemonize(get_pid_path()) + run(cfg) + sys.exit(0) + else: + # Parent process: wait briefly then report + time.sleep(0.5) + daemon_pid = lock.get_pid() + if daemon_pid: + console.print(f"[green]KeyPulse started (PID {daemon_pid})[/green]") + else: + console.print("[green]KeyPulse started[/green]") + + +def _load_config_from(path: str) -> Config: + """Load config from explicit path.""" + import tomllib + with open(path, "rb") as f: + data = tomllib.load(f) + return Config.model_validate(data) + + +# ═════════════════════════════════════════════════════════════════════════════ +# 2. STOP +# ═════════════════════════════════════════════════════════════════════════════ + +@main.command() +def stop(): + """Stop the KeyPulse daemon.""" + lock = SingleInstanceLock() + pid = lock.get_pid() + + if not pid: + console.print("[yellow]KeyPulse is not running.[/yellow]") + return + + try: + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + console.print("[yellow]KeyPulse is not running.[/yellow]") + return + + # Wait up to 5 seconds for process to exit + for _ in range(50): + time.sleep(0.1) + if not lock.is_running(): + console.print("[green]KeyPulse stopped.[/green]") + return + + console.print("[yellow]Stop signal sent (may still be shutting down).[/yellow]") + + +# ═════════════════════════════════════════════════════════════════════════════ +# 3. PAUSE +# ═════════════════════════════════════════════════════════════════════════════ + +@main.command() +def pause(): + """Pause activity monitoring.""" + cfg = get_config() + require_db(cfg) + set_state("status", "paused") + console.print("[yellow]Monitoring paused[/yellow]") + + +# ═════════════════════════════════════════════════════════════════════════════ +# 4. RESUME +# ═════════════════════════════════════════════════════════════════════════════ + +@main.command() +def resume(): + """Resume activity monitoring.""" + cfg = get_config() + require_db(cfg) + set_state("status", "running") + console.print("[green]Monitoring resumed[/green]") + + +# ═════════════════════════════════════════════════════════════════════════════ +# 5. STATUS +# ═════════════════════════════════════════════════════════════════════════════ + +@main.command() +@click.option("--plain", is_flag=True, default=False, help="Plain text output") +def status(plain): + """Show daemon status.""" + cfg = get_config() + require_db(cfg) + + lock = SingleInstanceLock() + pid = lock.get_pid() + is_running = pid is not None + status_val = get_state("status") or "unknown" + started_at = get_state("started_at") or "—" + last_flush = get_state("last_flush") or "—" + + # DB size + db_path = cfg.db_path_expanded + db_size = db_path.stat().st_size if db_path.exists() else 0 + db_size_mb = db_size / (1024 * 1024) + + # Enabled watchers + enabled = [] + if cfg.watchers.window: + enabled.append("window") + if cfg.watchers.idle: + enabled.append("idle") + if cfg.watchers.clipboard: + enabled.append("clipboard") + if cfg.watchers.manual: + enabled.append("manual") + if cfg.watchers.browser: + enabled.append("browser") + + if plain: + print(f"running={is_running}") + print(f"pid={pid or 'none'}") + print(f"status={status_val}") + print(f"started_at={started_at}") + print(f"db_path={db_path}") + print(f"db_size_mb={db_size_mb:.2f}") + print(f"last_flush={last_flush}") + print(f"enabled_watchers={','.join(enabled)}") + else: + table = Table(show_header=False, box=None) + table.add_row("Running", "[green]yes[/green]" if is_running else "[red]no[/red]") + if is_running: + table.add_row("PID", str(pid)) + table.add_row("Status", status_val) + table.add_row("Started at", started_at) + table.add_row("DB path", str(db_path)) + table.add_row("DB size", f"{db_size_mb:.2f} MB") + table.add_row("Last flush", last_flush) + table.add_row("Enabled watchers", ", ".join(enabled)) + console.print(table) + + +# ═════════════════════════════════════════════════════════════════════════════ +# 6. DOCTOR +# ═════════════════════════════════════════════════════════════════════════════ + +@main.command() +@click.option("--plain", is_flag=True, default=False, help="Plain text output") +def doctor(plain): + """Check system configuration.""" + checks = {} + + # Python version + import sys as sys_module + checks["Python >= 3.11"] = sys_module.version_info >= (3, 11) + + # pyobjc-framework-AppKit + try: + import AppKit + checks["pyobjc-framework-AppKit"] = True + except ImportError: + checks["pyobjc-framework-AppKit"] = False + + # pyobjc-framework-Quartz + try: + import Quartz + checks["pyobjc-framework-Quartz"] = True + except ImportError: + checks["pyobjc-framework-Quartz"] = False + + # Accessibility permission + try: + from ApplicationServices import AXIsProcessTrusted + checks["Accessibility permission"] = AXIsProcessTrusted() + except Exception: + checks["Accessibility permission"] = False + + # DB path writable + cfg = get_config() + db_path = cfg.db_path_expanded + db_path.parent.mkdir(parents=True, exist_ok=True) + try: + test_file = db_path.parent / ".write_test" + test_file.write_text("test") + test_file.unlink() + checks["DB path writable"] = True + except Exception: + checks["DB path writable"] = False + + # Config path exists + config_path = get_config_path() + checks["Config file exists"] = config_path.exists() + + if plain: + for check_name, passed in checks.items(): + status = "OK" if passed else "FAIL" + print(f"{check_name}: {status}") + else: + table = Table(title="System Check", show_header=True, header_style="bold cyan") + table.add_column("Check") + table.add_column("Status") + for check_name, passed in checks.items(): + status = "[green]✓[/green]" if passed else "[red]✗[/red]" + table.add_row(check_name, status) + console.print(table) + + # Exit with error if any check failed + if not all(checks.values()): + sys.exit(1) + + +# ═════════════════════════════════════════════════════════════════════════════ +# 7. SAVE +# ═════════════════════════════════════════════════════════════════════════════ + +@main.command() +@click.option("--text", default=None, help="Text to save") +@click.option("--tag", default=None, help="Tag for the note") +@click.option("--plain", is_flag=True, default=False, help="Plain text output") +def save(text, tag, plain): + """Save a manual note.""" + # Read from stdin if no --text provided + if text is None: + if not sys.stdin.isatty(): + text = sys.stdin.read() + else: + err_console.print("[red]No text provided. Use --text or pipe via stdin.[/red]") + sys.exit(1) + + if not text or not text.strip(): + err_console.print("[red]Cannot save empty text.[/red]") + sys.exit(1) + + cfg = get_config() + require_db(cfg) + + # Normalize and insert + from keypulse.store.repository import insert_raw_event + + event = normalize_manual_event(text.strip(), tags=tag) + event_id = insert_raw_event(event) + + # Also insert search doc + doc = SearchDoc( + ref_type="manual", + ref_id=str(event_id), + title=text[:100] if text else "Note", + body=text, + tags=tag, + app_name=None, + ) + from keypulse.store.repository import insert_search_doc + insert_search_doc(doc) + + if not plain: + console.print("[green]Saved.[/green]") + else: + print("saved") + + +# ═════════════════════════════════════════════════════════════════════════════ +# 8. TIMELINE +# ═════════════════════════════════════════════════════════════════════════════ + +@main.command() +@click.option("--date", default=None, help="Date in YYYY-MM-DD format") +@click.option("--today", is_flag=True, default=False, help="Show today's timeline") +@click.option("--plain", is_flag=True, default=False, help="Plain text output") +def timeline(date, today, plain): + """Show activity timeline.""" + cfg = get_config() + require_db(cfg) + + # Determine which date to use + if date is None and today: + date_str = None # Will default to today + elif date: + date_str = date + else: + date_str = None # Default to today + + rows = get_timeline_rows(date_str) + + if plain: + for row in rows: + print(f"{row['start']}\t{row['end']}\t{row['app']}\t{row['title']}\t{row['duration']}") + else: + if not rows: + console.print("[yellow]No activities found.[/yellow]") + return + + table = Table(title="Activity Timeline", show_header=True, header_style="bold cyan") + table.add_column("Start") + table.add_column("End") + table.add_column("App") + table.add_column("Title") + table.add_column("Duration") + + for row in rows: + table.add_row( + row["start"], + row["end"], + row["app"], + row["title"], + row["duration"], + ) + console.print(table) + + +# ═════════════════════════════════════════════════════════════════════════════ +# 9. RECENT +# ═════════════════════════════════════════════════════════════════════════════ + +@main.command() +@click.option("--type", "item_type", default=None, type=click.Choice(["clipboard", "manual", "session"]), + help="Filter by type") +@click.option("--limit", default=20, help="Number of items to show") +@click.option("--plain", is_flag=True, default=False, help="Plain text output") +def recent(item_type, limit, plain): + """Show recent items.""" + cfg = get_config() + require_db(cfg) + + items = [] + + if item_type is None or item_type == "clipboard": + items.extend([(item, "clipboard") for item in recent_clipboard(limit)]) + if item_type is None or item_type == "manual": + items.extend([(item, "manual") for item in recent_manual(limit)]) + if item_type is None or item_type == "session": + items.extend([(item, "session") for item in recent_sessions_docs(limit)]) + + # Sort by created_at descending + items.sort(key=lambda x: x[0].get("created_at") or x[0].get("started_at", ""), reverse=True) + items = items[:limit] + + if plain: + for item, item_type_val in items: + title = item.get("title") or item.get("app_name") or "—" + body = item.get("body", "")[:50] if item.get("body") else "" + ts = item.get("created_at") or item.get("started_at", "—") + print(f"{ts}\t{item_type_val}\t{title}\t{body}") + else: + if not items: + console.print("[yellow]No recent items found.[/yellow]") + return + + table = Table(title="Recent Items", show_header=True, header_style="bold cyan") + table.add_column("Time") + table.add_column("Type") + table.add_column("Title/App") + table.add_column("Body") + + for item, item_type_val in items: + title = item.get("title") or item.get("app_name") or "—" + body = item.get("body", "")[:80] if item.get("body") else "" + ts = item.get("created_at") or item.get("started_at", "—") + + # Format timestamp nicely + try: + dt = datetime.fromisoformat(ts) + ts_fmt = dt.astimezone().strftime("%Y-%m-%d %H:%M:%S") + except Exception: + ts_fmt = ts + + table.add_row(ts_fmt, item_type_val, title, body) + + console.print(table) + + +# ═════════════════════════════════════════════════════════════════════════════ +# 10. STATS +# ═════════════════════════════════════════════════════════════════════════════ + +@main.command() +@click.option("--days", default=7, help="Number of days to analyze") +@click.option("--plain", is_flag=True, default=False, help="Plain text output") +def stats(days, plain): + """Show activity statistics.""" + cfg = get_config() + require_db(cfg) + + stats_data = get_stats(days) + + if plain: + print(f"days={stats_data['days']}") + print(f"total_sessions={stats_data['total_sessions']}") + print(f"total_active_secs={stats_data['total_active_secs']}") + print(f"total_active_human={stats_data['total_active_human']}") + print(f"active_days={stats_data['active_days']}") + print(f"clipboard_count={stats_data['clipboard_count']}") + print(f"manual_count={stats_data['manual_count']}") + for app in stats_data['app_distribution']: + h = app['duration_sec'] // 3600 + m = (app['duration_sec'] % 3600) // 60 + print(f"app={app['app']},duration={h}h{m}m") + else: + # Summary panel + summary_text = f""" +Total Sessions: {stats_data['total_sessions']} +Active Time: {stats_data['total_active_human']} +Active Days: {stats_data['active_days']} +Clipboard Events: {stats_data['clipboard_count']} +Manual Saves: {stats_data['manual_count']} + """.strip() + console.print(Panel(summary_text, title=f"Activity Stats — Last {days} days", expand=False)) + + # App distribution + if stats_data['app_distribution']: + table = Table(title="Top Apps by Duration", show_header=True, header_style="bold cyan") + table.add_column("App") + table.add_column("Duration") + for app in stats_data['app_distribution']: + h = app['duration_sec'] // 3600 + m = (app['duration_sec'] % 3600) // 60 + table.add_row(app['app'], f"{h}h {m}m") + console.print(table) + + +# ═════════════════════════════════════════════════════════════════════════════ +# 11. SEARCH +# ═════════════════════════════════════════════════════════════════════════════ + +@main.command() +@click.argument("query") +@click.option("--app", default=None, help="Filter by app name") +@click.option("--since", default=None, help="Time filter (7d, 24h, or YYYY-MM-DD)") +@click.option("--source", default=None, help="Filter by source (clipboard, manual, session)") +@click.option("--limit", default=50, help="Number of results") +@click.option("--plain", is_flag=True, default=False, help="Plain text output") +def search_cmd(query, app, since, source, limit, plain): + """Search activity.""" + cfg = get_config() + require_db(cfg) + + results = search(query, app_name=app, since=since, source=source, limit=limit) + + if plain: + for result in results: + body = result.get("body", "")[:80] if result.get("body") else "" + title = result.get("title", "")[:80] if result.get("title") else "" + ts = result.get("created_at", "—") + ref_type = result.get("ref_type", "—") + app_name = result.get("app_name", "—") + print(f"{ts}\t{ref_type}\t{app_name}\t{title}\t{body}") + else: + if not results: + console.print("[yellow]No results found.[/yellow]") + return + + table = Table(title=f"Search Results for '{query}'", show_header=True, header_style="bold cyan") + table.add_column("Time") + table.add_column("Type") + table.add_column("App") + table.add_column("Title/Body") + + for result in results: + body = result.get("body", "")[:80] if result.get("body") else "" + title = result.get("title", "")[:80] if result.get("title") else "" + content = body or title or "—" + ts = result.get("created_at", "—") + + # Format timestamp + try: + dt = datetime.fromisoformat(ts) + ts_fmt = dt.astimezone().strftime("%Y-%m-%d %H:%M:%S") + except Exception: + ts_fmt = ts + + ref_type = result.get("ref_type", "—") + app_name = result.get("app_name", "—") + + table.add_row(ts_fmt, ref_type, app_name, content) + + console.print(table) + + +# ═════════════════════════════════════════════════════════════════════════════ +# 12. SESSION (subgroup) +# ═════════════════════════════════════════════════════════════════════════════ + +@main.group() +def session(): + """Manage sessions.""" + pass + + +@session.command("list") +@click.option("--date", default=None, help="Date in YYYY-MM-DD format") +@click.option("--limit", default=100, help="Number of sessions to show") +@click.option("--plain", is_flag=True, default=False, help="Plain text output") +def session_list(date, limit, plain): + """List sessions.""" + cfg = get_config() + require_db(cfg) + + sessions = get_sessions(date_str=date, limit=limit) + + if plain: + for s in sessions: + session_id = s["id"][:8] if s["id"] else "—" + start = s.get("started_at", "—") + end = s.get("ended_at", "—") + app = s.get("app_name", "—") + title = (s.get("primary_window_title") or "")[:60] + duration = s.get("duration_sec", 0) + print(f"{session_id}\t{start}\t{end}\t{app}\t{title}\t{duration}") + else: + if not sessions: + console.print("[yellow]No sessions found.[/yellow]") + return + + table = Table(title="Sessions", show_header=True, header_style="bold cyan") + table.add_column("ID") + table.add_column("Start") + table.add_column("End") + table.add_column("App") + table.add_column("Title") + table.add_column("Duration") + + for s in sessions: + session_id = s["id"][:8] if s["id"] else "—" + start = s.get("started_at", "—") + end = s.get("ended_at", "—") + app = s.get("app_name", "—") + title = (s.get("primary_window_title") or "")[:60] + duration = f"{s.get('duration_sec', 0)}s" + + # Format timestamps + try: + start_dt = datetime.fromisoformat(start) + start = start_dt.astimezone().strftime("%H:%M:%S") + except Exception: + pass + + try: + end_dt = datetime.fromisoformat(end) + end = end_dt.astimezone().strftime("%H:%M:%S") + except Exception: + pass + + table.add_row(session_id, start, end, app, title, duration) + + console.print(table) + + +@session.command("show") +@click.argument("session_id") +@click.option("--plain", is_flag=True, default=False, help="Plain text output") +def session_show(session_id, plain): + """Show details of a session.""" + cfg = get_config() + require_db(cfg) + + session_data = get_session_by_id(session_id) + if not session_data: + err_console.print(f"[red]Session not found: {session_id}[/red]") + sys.exit(1) + + if plain: + for key, value in session_data.items(): + print(f"{key}={value}") + else: + table = Table(show_header=False, box=None) + for key, value in session_data.items(): + table.add_row(str(key), str(value)) + console.print(table) + + +# ═════════════════════════════════════════════════════════════════════════════ +# 13. EXPORT +# ═════════════════════════════════════════════════════════════════════════════ + +@main.command() +@click.option("--format", type=click.Choice(["json", "csv", "md"]), default="json", + help="Export format") +@click.option("--days", default=None, type=int, help="Number of days to export") +@click.option("--date", default=None, help="Specific date in YYYY-MM-DD format") +@click.option("--output", default=None, help="Output file path") +def export(format, days, date, output): + """Export activity data.""" + cfg = get_config() + require_db(cfg) + + if format == "json": + data = export_json(days=days, date_str=date) + elif format == "csv": + data = export_csv(days=days, date_str=date) + elif format == "md": + data = export_markdown(days=days, date_str=date) + else: + err_console.print(f"[red]Unknown format: {format}[/red]") + sys.exit(1) + + if output: + Path(output).write_text(data) + console.print(f"[green]Exported to {output}[/green]") + else: + print(data) + + +# ═════════════════════════════════════════════════════════════════════════════ +# 14. PURGE +# ═════════════════════════════════════════════════════════════════════════════ + +@main.command() +@click.option("--today", is_flag=True, default=False, help="Purge today's data") +@click.option("--last-hours", type=int, default=None, help="Purge last N hours") +@click.option("--app", default=None, help="Purge data for specific app") +@click.option("--confirm", is_flag=True, default=False, help="Confirm deletion without prompt") +def purge(today, last_hours, app, confirm): + """Delete activity data.""" + cfg = get_config() + require_db(cfg) + + # Compute since/until + now = datetime.now(timezone.utc) + + if today: + since = now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat() + until = now.isoformat() + elif last_hours is not None: + since = (now - timedelta(hours=last_hours)).isoformat() + until = now.isoformat() + else: + err_console.print("[red]Specify --today or --last-hours[/red]") + sys.exit(1) + + # Count what would be deleted + from keypulse.store.db import get_conn + conn = get_conn() + clauses = ["ts_start >= ?"] + params = [since] + + if until: + clauses.append("ts_start <= ?") + params.append(until) + if app: + clauses.append("app_name LIKE ?") + params.append(f"%{app}%") + + where = "WHERE " + " AND ".join(clauses) + count = conn.execute(f"SELECT COUNT(*) FROM raw_events {where}", params).fetchone()[0] + + if count == 0: + console.print("[yellow]No data found to delete.[/yellow]") + return + + # Prompt if not confirmed + if not confirm: + msg = f"Delete {count} events" + if app: + msg += f" from {app}" + msg += "?" + if not click.confirm(msg): + console.print("[yellow]Cancelled.[/yellow]") + return + + # Delete + purge_raw_events(since=since, until=until, app_name=app) + + # Also delete associated sessions and search docs + conn.execute( + f"DELETE FROM sessions WHERE started_at >= ? {'AND started_at <= ?' if until else ''}", + (since, until) if until else (since,) + ) + conn.execute( + f"DELETE FROM search_docs WHERE created_at >= ? {'AND created_at <= ?' if until else ''}", + (since, until) if until else (since,) + ) + conn.commit() + + console.print(f"[green]Deleted {count} events.[/green]") + + +# ═════════════════════════════════════════════════════════════════════════════ +# 15. CONFIG (subgroup) +# ═════════════════════════════════════════════════════════════════════════════ + +@main.group(name="config") +def config_group(): + """Manage configuration.""" + pass + + +@config_group.command("show") +@click.option("--plain", is_flag=True, default=False, help="Plain text output") +def config_show(plain): + """Show current configuration.""" + cfg = get_config() + + if plain: + print(f"db_path={cfg.app.db_path}") + print(f"log_path={cfg.app.log_path}") + print(f"flush_interval_sec={cfg.app.flush_interval_sec}") + print(f"retention_days={cfg.app.retention_days}") + print(f"watchers_window={cfg.watchers.window}") + print(f"watchers_idle={cfg.watchers.idle}") + print(f"watchers_clipboard={cfg.watchers.clipboard}") + print(f"watchers_manual={cfg.watchers.manual}") + print(f"watchers_browser={cfg.watchers.browser}") + print(f"idle_threshold_sec={cfg.idle.threshold_sec}") + print(f"clipboard_max_text_length={cfg.clipboard.max_text_length}") + print(f"clipboard_dedup_window_sec={cfg.clipboard.dedup_window_sec}") + else: + config_dict = cfg.model_dump() + import json + console.print(json.dumps(config_dict, indent=2)) + + +@config_group.command("path") +def config_path(): + """Show config file path.""" + path = get_config_path() + print(str(path)) + + +# ═════════════════════════════════════════════════════════════════════════════ +# 16. RULES (subgroup) +# ═════════════════════════════════════════════════════════════════════════════ + +@main.group() +def rules(): + """Manage privacy policies.""" + pass + + +@rules.command("list") +@click.option("--plain", is_flag=True, default=False, help="Plain text output") +def rules_list(plain): + """List all privacy policies.""" + cfg = get_config() + require_db(cfg) + + policies = get_all_policies() + + if plain: + for p in policies: + print(f"{p['id']}\t{p['scope_type']}\t{p['scope_value']}\t{p['mode']}\t{p['priority']}") + else: + if not policies: + console.print("[yellow]No policies configured.[/yellow]") + return + + table = Table(title="Privacy Policies", show_header=True, header_style="bold cyan") + table.add_column("ID") + table.add_column("Scope Type") + table.add_column("Scope Value") + table.add_column("Mode") + table.add_column("Priority") + + for p in policies: + table.add_row( + str(p.get("id", "—")), + p.get("scope_type", "—"), + p.get("scope_value", "—"), + p.get("mode", "—"), + str(p.get("priority", "—")), + ) + console.print(table) + + +@rules.command("add") +@click.option("--scope-type", required=True, help="Scope type (app, window, source, content)") +@click.option("--scope-value", required=True, help="Scope value (e.g., 'Safari', 'password')") +@click.option("--mode", required=True, help="Mode (allow, deny, metadata-only, redact, truncate)") +@click.option("--priority", type=int, default=100, help="Priority (lower = higher priority)") +def rules_add(scope_type, scope_value, mode, priority): + """Add a new privacy policy.""" + cfg = get_config() + require_db(cfg) + + policy = Policy( + scope_type=scope_type, + scope_value=scope_value, + mode=mode, + priority=priority, + ) + + policy_id = insert_policy(policy) + console.print(f"[green]Policy added (ID: {policy_id})[/green]") + + +@rules.command("disable") +@click.argument("rule_id", type=int) +def rules_disable(rule_id): + """Disable a privacy policy.""" + cfg = get_config() + require_db(cfg) + + from keypulse.store.db import get_conn + conn = get_conn() + conn.execute("UPDATE policies SET enabled=0 WHERE id=?", (rule_id,)) + conn.commit() + + console.print(f"[green]Policy {rule_id} disabled.[/green]") + + +# ═════════════════════════════════════════════════════════════════════════════ +# RECALL — single LLM-optimised context dump (used by skill) +# ═════════════════════════════════════════════════════════════════════════════ + +@main.command() +@click.argument("query", default="") +@click.option("--since", default="7d", help="Time window (7d / 24h / YYYY-MM-DD)") +@click.option("--limit", default=5, help="Max results per section") +def recall(query: str, since: str, limit: int): + """ + Return a compact, LLM-ready context summary. + Combines FTS search + recent clipboard + today timeline + manual saves. + Designed for skill/agent consumption — plain text, low token. + """ + cfg = get_config() + require_db(cfg) + + lines: list[str] = [] + + def _fmt_ts(ts: str) -> str: + try: + dt = datetime.fromisoformat(ts).astimezone() + now = datetime.now(dt.tzinfo) + delta = now - dt + if delta.days == 0: + return dt.strftime("今天 %H:%M") + elif delta.days == 1: + return dt.strftime("昨天 %H:%M") + else: + return dt.strftime("%m-%d %H:%M") + except Exception: + return ts + + # ── 1. FTS keyword search ─────────────────────────────────────────── + if query: + results = search(query, since=since, limit=limit) + lines.append(f"[搜索: {query}]") + if results: + for r in results: + ts = _fmt_ts(r.get("created_at", "")) + rtype = r.get("ref_type", "") + app = r.get("app_name") or "" + content = (r.get("body") or r.get("title") or "")[:120] + lines.append(f" {ts} [{rtype}] {app}: {content}") + else: + lines.append(" (无匹配记录)") + lines.append("") + + # ── 2. Recent clipboard ───────────────────────────────────────────── + clips = recent_clipboard(limit) + lines.append("[最近剪贴板]") + if clips: + for c in clips: + ts = _fmt_ts(c.get("created_at", "")) + body = (c.get("body") or "")[:100] + app = c.get("app_name") or "" + lines.append(f" {ts} {app}: {body}") + else: + lines.append(" (无记录)") + lines.append("") + + # ── 3. Today's top sessions (by duration) ─────────────────────────── + from keypulse.services.sessionizer import sessions_for_today + today_sessions = sessions_for_today() + today_sessions.sort(key=lambda s: s.get("duration_sec") or 0, reverse=True) + lines.append("[今日主要活动]") + if today_sessions: + for s in today_sessions[:limit]: + dur = s.get("duration_sec") or 0 + m = dur // 60 + app = s.get("app_name") or "" + title = (s.get("primary_window_title") or "")[:60] + start = _fmt_ts(s.get("started_at", "")) + lines.append(f" {start} {app} ({m}min): {title}") + else: + lines.append(" (无记录)") + lines.append("") + + # ── 4. Manual saves ───────────────────────────────────────────────── + saves = recent_manual(limit) + if saves: + lines.append("[手动保存]") + for s in saves: + ts = _fmt_ts(s.get("created_at", "")) + body = (s.get("body") or "")[:100] + tags = s.get("tags") or "" + tag_str = f" #{tags}" if tags else "" + lines.append(f" {ts}{tag_str}: {body}") + lines.append("") + + print("\n".join(lines)) + + +if __name__ == "__main__": + main() diff --git a/keypulse/config.py b/keypulse/config.py new file mode 100644 index 0000000..4ef2d12 --- /dev/null +++ b/keypulse/config.py @@ -0,0 +1,75 @@ +from __future__ import annotations +import tomllib +from pathlib import Path +from typing import Optional +from pydantic import BaseModel, Field +from pydantic_settings import BaseSettings + + +class AppConfig(BaseModel): + db_path: str = "~/.keypulse/keypulse.db" + log_path: str = "~/.keypulse/keypulse.log" + flush_interval_sec: int = 5 + retention_days: int = 30 + + +class WatchersConfig(BaseModel): + window: bool = True + idle: bool = True + clipboard: bool = True + manual: bool = True + browser: bool = False + + +class IdleConfig(BaseModel): + threshold_sec: int = 180 + + +class ClipboardConfig(BaseModel): + max_text_length: int = 2000 + dedup_window_sec: int = 600 + + +class PrivacyConfig(BaseModel): + redact_emails: bool = True + redact_phones: bool = True + redact_tokens: bool = True + + +class PolicyConfig(BaseModel): + scope_type: str + scope_value: str + mode: str # allow|deny|metadata-only|redact|truncate + enabled: bool = True + priority: int = 100 + + +class Config(BaseModel): + app: AppConfig = Field(default_factory=AppConfig) + watchers: WatchersConfig = Field(default_factory=WatchersConfig) + idle: IdleConfig = Field(default_factory=IdleConfig) + clipboard: ClipboardConfig = Field(default_factory=ClipboardConfig) + privacy: PrivacyConfig = Field(default_factory=PrivacyConfig) + policies: list[PolicyConfig] = Field(default_factory=list) + + @classmethod + def load(cls) -> "Config": + """Load config from ~/.keypulse/config.toml or ./config.toml, falling back to defaults.""" + paths = [ + Path.home() / ".keypulse" / "config.toml", + Path("config.toml"), + ] + for p in paths: + if p.exists(): + with open(p, "rb") as f: + data = tomllib.load(f) + return cls.model_validate(data) + return cls() + + @property + def db_path_expanded(self) -> Path: + return Path(self.app.db_path).expanduser() + + @property + def log_path_expanded(self) -> Path: + return Path(self.app.log_path).expanduser() diff --git a/keypulse/privacy/__init__.py b/keypulse/privacy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/keypulse/privacy/desensitizer.py b/keypulse/privacy/desensitizer.py new file mode 100644 index 0000000..4eab79d --- /dev/null +++ b/keypulse/privacy/desensitizer.py @@ -0,0 +1,37 @@ +import re +from keypulse.privacy.detectors import detect, has_sensitive_content + + +def desensitize(text: str, redact_emails: bool = True, redact_phones: bool = True, redact_tokens: bool = True) -> str: + """Replace sensitive patterns with *** placeholders.""" + if not text: + return text + enabled = { + "email": redact_emails, + "phone_cn": redact_phones, + "id_card": True, + "bank_card": True, + "api_key": redact_tokens, + "bearer_token": redact_tokens, + "jwt": redact_tokens, + "aws_key": redact_tokens, + "url_token": redact_tokens, + "private_key": redact_tokens, + } + detections = detect(text, enabled) + if not detections: + return text + result = [] + prev = 0 + for d in detections: + result.append(text[prev:d.start]) + result.append(f"[{d.pattern_name.upper()}]") + prev = d.end + result.append(text[prev:]) + return "".join(result) + + +def truncate(text: str, max_length: int) -> str: + if len(text) <= max_length: + return text + return text[:max_length] + "...[truncated]" diff --git a/keypulse/privacy/detectors.py b/keypulse/privacy/detectors.py new file mode 100644 index 0000000..8acf34c --- /dev/null +++ b/keypulse/privacy/detectors.py @@ -0,0 +1,50 @@ +import re +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class Detection: + pattern_name: str + start: int + end: int + replacement: str = "***" + + +# Compiled patterns +PATTERNS: dict[str, re.Pattern] = { + "email": re.compile(r"[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+"), + "phone_cn": re.compile(r"(? list[Detection]: + """Return list of detections in text.""" + results = [] + for name, pattern in PATTERNS.items(): + if enabled and not enabled.get(name, True): + continue + for m in pattern.finditer(text): + results.append(Detection(pattern_name=name, start=m.start(), end=m.end())) + # Sort by start position, remove overlaps + results.sort(key=lambda d: d.start) + merged = [] + for d in results: + if merged and d.start < merged[-1].end: + if d.end > merged[-1].end: + merged[-1] = Detection(merged[-1].pattern_name, merged[-1].start, d.end) + else: + merged.append(d) + return merged + + +def has_sensitive_content(text: str) -> bool: + return bool(detect(text)) diff --git a/keypulse/search/__init__.py b/keypulse/search/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/keypulse/search/engine.py b/keypulse/search/engine.py new file mode 100644 index 0000000..a134c66 --- /dev/null +++ b/keypulse/search/engine.py @@ -0,0 +1,94 @@ +from __future__ import annotations +from datetime import datetime, timedelta, timezone +from typing import Optional +from keypulse.store.repository import search_docs_fts +from keypulse.search.query_builder import build_fts_query +from keypulse.store.db import get_conn + + +def _parse_since(since_str: Optional[str]) -> Optional[str]: + """Parse --since value like '7d', '24h', '2026-04-01'.""" + if not since_str: + return None + since_str = since_str.strip() + if since_str.endswith("d"): + days = int(since_str[:-1]) + return (datetime.now(timezone.utc) - timedelta(days=days)).isoformat() + if since_str.endswith("h"): + hours = int(since_str[:-1]) + return (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat() + # Assume ISO date + return since_str + + +def search( + query: str, + app_name: Optional[str] = None, + since: Optional[str] = None, + source: Optional[str] = None, + limit: int = 50, +) -> list[dict]: + """ + Search search_docs via FTS5. + Returns list of result dicts with: id, ref_type, ref_id, title, body, app_name, created_at, score + """ + fts_query = build_fts_query(query) + since_ts = _parse_since(since) + + conn = get_conn() + params = [fts_query] + extra_where = "" + + if app_name: + extra_where += " AND sd.app_name LIKE ?" + params.append(f"%{app_name}%") + if since_ts: + extra_where += " AND sd.created_at >= ?" + params.append(since_ts) + if source: + extra_where += " AND sd.ref_type = ?" + params.append(source) + + params.append(limit) + + sql = f""" + SELECT sd.id, sd.ref_type, sd.ref_id, sd.title, sd.body, sd.app_name, + sd.created_at, sd.tags, bm25(search_docs_fts) as score + FROM search_docs sd + JOIN search_docs_fts ON search_docs_fts.rowid = sd.id + WHERE search_docs_fts MATCH ?{extra_where} + ORDER BY score, sd.created_at DESC + LIMIT ? + """ + try: + rows = conn.execute(sql, params).fetchall() + return [dict(r) for r in rows] + except Exception as e: + # FTS5 syntax error or other — return empty + return [] + + +def recent_clipboard(limit: int = 20) -> list[dict]: + conn = get_conn() + rows = conn.execute( + "SELECT * FROM search_docs WHERE ref_type='clipboard' ORDER BY created_at DESC LIMIT ?", + (limit,), + ).fetchall() + return [dict(r) for r in rows] + + +def recent_manual(limit: int = 20) -> list[dict]: + conn = get_conn() + rows = conn.execute( + "SELECT * FROM search_docs WHERE ref_type='manual' ORDER BY created_at DESC LIMIT ?", + (limit,), + ).fetchall() + return [dict(r) for r in rows] + + +def recent_sessions_docs(limit: int = 20) -> list[dict]: + conn = get_conn() + rows = conn.execute( + "SELECT * FROM sessions ORDER BY started_at DESC LIMIT ?", (limit,) + ).fetchall() + return [dict(r) for r in rows] diff --git a/keypulse/search/query_builder.py b/keypulse/search/query_builder.py new file mode 100644 index 0000000..4555818 --- /dev/null +++ b/keypulse/search/query_builder.py @@ -0,0 +1,31 @@ +from __future__ import annotations +import re + + +def build_fts_query(raw: str) -> str: + """ + Convert a user query string to an FTS5 query. + - Multi-word queries become: word1 word2 (AND by default in FTS5) + - Quoted phrases pass through: "exact phrase" + - Special chars are escaped to avoid FTS5 syntax errors + """ + raw = raw.strip() + if not raw: + return '""' + + # If already has quotes (phrase search), pass through with basic sanitization + if '"' in raw: + # Sanitize: allow only word chars, spaces, and quotes + safe = re.sub(r'[^\w\s"\'*-]', ' ', raw) + return safe.strip() or '""' + + # Split on whitespace, escape each term + terms = raw.split() + escaped = [] + for t in terms: + # FTS5 special chars: " * ^ { } + t_clean = re.sub(r'["\^{}]', '', t) + if t_clean: + escaped.append(t_clean) + + return " ".join(escaped) if escaped else '""' diff --git a/keypulse/services/__init__.py b/keypulse/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/keypulse/services/export.py b/keypulse/services/export.py new file mode 100644 index 0000000..762d332 --- /dev/null +++ b/keypulse/services/export.py @@ -0,0 +1,90 @@ +from __future__ import annotations +import csv +import io +import json +from datetime import datetime, timedelta, timezone +from typing import Optional +from keypulse.store.repository import get_sessions, query_raw_events +from keypulse.store.db import get_conn + + +def _get_date_range(days: Optional[int] = None, date_str: Optional[str] = None): + if date_str: + since = f"{date_str}T00:00:00" + until = f"{date_str}T23:59:59" + elif days: + since = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat() + until = None + else: + since = None + until = None + return since, until + + +def export_json(days: Optional[int] = None, date_str: Optional[str] = None) -> str: + since, until = _get_date_range(days, date_str) + sessions = get_sessions(limit=10000) if not since else [] + if since: + conn = get_conn() + rows = conn.execute( + "SELECT * FROM sessions WHERE started_at >= ? ORDER BY started_at", + (since,), + ).fetchall() + sessions = [dict(r) for r in rows] + events = query_raw_events(since=since, until=until, limit=50000) + data = {"sessions": sessions, "events": events} + return json.dumps(data, ensure_ascii=False, indent=2, default=str) + + +def export_csv(days: Optional[int] = None, date_str: Optional[str] = None) -> str: + since, until = _get_date_range(days, date_str) + events = query_raw_events(since=since, until=until, limit=50000) + if not events: + return "" + buf = io.StringIO() + writer = csv.DictWriter(buf, fieldnames=list(events[0].keys())) + writer.writeheader() + writer.writerows(events) + return buf.getvalue() + + +def export_markdown(days: Optional[int] = None, date_str: Optional[str] = None) -> str: + since, until = _get_date_range(days, date_str) + conn = get_conn() + if since: + sessions = [ + dict(r) + for r in conn.execute( + "SELECT * FROM sessions WHERE started_at >= ? ORDER BY started_at", + (since,), + ).fetchall() + ] + else: + sessions = get_sessions(limit=500) + + label = date_str or (f"Last {days} days" if days else "All time") + lines = [f"# KeyPulse Export — {label}", ""] + + if not sessions: + lines.append("_No data found._") + return "\n".join(lines) + + lines.append("## Sessions") + lines.append("") + lines.append("| Start | End | App | Title | Duration |") + lines.append("|-------|-----|-----|-------|----------|") + for s in sessions: + def fmt(ts): + try: + return datetime.fromisoformat(ts).astimezone().strftime("%H:%M") + except Exception: + return ts or "" + dur = s.get("duration_sec") or 0 + h, m = dur // 3600, (dur % 3600) // 60 + dur_str = f"{h}h{m:02d}m" if h else f"{m}m" + title = (s.get("primary_window_title") or "")[:50] + lines.append( + f"| {fmt(s['started_at'])} | {fmt(s['ended_at'])} | {s.get('app_name','')} | {title} | {dur_str} |" + ) + + return "\n".join(lines) diff --git a/keypulse/services/sessionizer.py b/keypulse/services/sessionizer.py new file mode 100644 index 0000000..c982d13 --- /dev/null +++ b/keypulse/services/sessionizer.py @@ -0,0 +1,18 @@ +from __future__ import annotations +from datetime import datetime, date, timedelta, timezone +from typing import Optional +from keypulse.store.repository import get_sessions + + +def sessions_for_date(date_str: str) -> list[dict]: + """Return sessions for YYYY-MM-DD.""" + return get_sessions(date_str=date_str) + + +def sessions_for_today() -> list[dict]: + today = date.today().isoformat() + return sessions_for_date(today) + + +def recent_sessions(limit: int = 20) -> list[dict]: + return get_sessions(limit=limit) diff --git a/keypulse/services/stats.py b/keypulse/services/stats.py new file mode 100644 index 0000000..79c70e1 --- /dev/null +++ b/keypulse/services/stats.py @@ -0,0 +1,68 @@ +from __future__ import annotations +from collections import defaultdict +from datetime import datetime, timedelta, timezone +from keypulse.store.repository import query_raw_events, get_sessions +from keypulse.store.db import get_conn + + +def get_stats(days: int = 7) -> dict: + """Aggregate stats for last N days.""" + since = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat() + conn = get_conn() + + # Total sessions + total_sessions = conn.execute( + "SELECT COUNT(*) FROM sessions WHERE started_at >= ?", (since,) + ).fetchone()[0] + + # Total active seconds + total_secs = conn.execute( + "SELECT COALESCE(SUM(duration_sec), 0) FROM sessions WHERE started_at >= ?", + (since,), + ).fetchone()[0] + + # App distribution (top 10 by total duration) + app_rows = conn.execute( + """SELECT app_name, SUM(duration_sec) as total_sec + FROM sessions + WHERE started_at >= ? AND app_name IS NOT NULL + GROUP BY app_name + ORDER BY total_sec DESC + LIMIT 10""", + (since,), + ).fetchall() + app_distribution = [{"app": r[0], "duration_sec": r[1]} for r in app_rows] + + # Clipboard count + clipboard_count = conn.execute( + "SELECT COUNT(*) FROM raw_events WHERE source='clipboard' AND ts_start >= ?", + (since,), + ).fetchone()[0] + + # Manual saves + manual_count = conn.execute( + "SELECT COUNT(*) FROM raw_events WHERE source='manual' AND ts_start >= ?", + (since,), + ).fetchone()[0] + + # Active days + active_days = conn.execute( + "SELECT COUNT(DISTINCT substr(started_at, 1, 10)) FROM sessions WHERE started_at >= ?", + (since,), + ).fetchone()[0] + + def fmt_duration(secs: int) -> str: + h = secs // 3600 + m = (secs % 3600) // 60 + return f"{h}h {m}m" + + return { + "days": days, + "total_sessions": total_sessions, + "total_active_secs": total_secs, + "total_active_human": fmt_duration(total_secs), + "active_days": active_days, + "app_distribution": app_distribution, + "clipboard_count": clipboard_count, + "manual_count": manual_count, + } diff --git a/keypulse/services/timeline.py b/keypulse/services/timeline.py new file mode 100644 index 0000000..03adbb5 --- /dev/null +++ b/keypulse/services/timeline.py @@ -0,0 +1,41 @@ +from __future__ import annotations +from datetime import datetime, timezone +from typing import Optional +from keypulse.services.sessionizer import sessions_for_date, sessions_for_today + + +def _fmt_duration(secs: int) -> str: + if secs < 60: + return f"{secs}s" + m = secs // 60 + if m < 60: + return f"{m}m" + return f"{m // 60}h{m % 60:02d}m" + + +def _fmt_ts(ts: str) -> str: + try: + dt = datetime.fromisoformat(ts) + return dt.astimezone().strftime("%H:%M:%S") + except Exception: + return ts + + +def get_timeline_rows(date_str: Optional[str] = None) -> list[dict]: + """ + Returns list of dicts with keys: + start, end, app, title, duration, duration_secs + """ + sessions = sessions_for_date(date_str) if date_str else sessions_for_today() + rows = [] + for s in sessions: + rows.append({ + "id": s["id"], + "start": _fmt_ts(s["started_at"]), + "end": _fmt_ts(s["ended_at"]), + "app": s.get("app_name") or "—", + "title": (s.get("primary_window_title") or "")[:60], + "duration": _fmt_duration(s.get("duration_sec") or 0), + "duration_secs": s.get("duration_sec") or 0, + }) + return rows diff --git a/keypulse/store/__init__.py b/keypulse/store/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/keypulse/store/db.py b/keypulse/store/db.py new file mode 100644 index 0000000..06a22b3 --- /dev/null +++ b/keypulse/store/db.py @@ -0,0 +1,36 @@ +import sqlite3 +import threading +from pathlib import Path +from keypulse.store.migrations import run_migrations + +_local = threading.local() + +_db_path: Path | None = None + + +def init_db(db_path: Path): + global _db_path + db_path.parent.mkdir(parents=True, exist_ok=True) + _db_path = db_path + conn = _get_conn() + run_migrations(conn) + + +def _get_conn() -> sqlite3.Connection: + if not hasattr(_local, "conn") or _local.conn is None: + if _db_path is None: + raise RuntimeError("DB not initialized. Call init_db() first.") + conn = sqlite3.connect(str(_db_path), check_same_thread=False) + conn.row_factory = sqlite3.Row + _local.conn = conn + return _local.conn + + +def get_conn() -> sqlite3.Connection: + return _get_conn() + + +def close(): + if hasattr(_local, "conn") and _local.conn: + _local.conn.close() + _local.conn = None diff --git a/keypulse/store/migrations.py b/keypulse/store/migrations.py new file mode 100644 index 0000000..854fe65 --- /dev/null +++ b/keypulse/store/migrations.py @@ -0,0 +1,136 @@ +import sqlite3 +from datetime import datetime, timezone + +SCHEMA_VERSION = 1 + +MIGRATIONS = [ + # v1 + """ + CREATE TABLE IF NOT EXISTS raw_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL, + event_type TEXT NOT NULL, + ts_start TEXT NOT NULL, + ts_end TEXT, + app_name TEXT, + window_title TEXT, + process_name TEXT, + content_text TEXT, + content_hash TEXT, + metadata_json TEXT, + sensitivity_level INTEGER DEFAULT 0, + skipped_reason TEXT, + session_id TEXT, + created_at TEXT NOT NULL + ); + """, + """ + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + started_at TEXT NOT NULL, + ended_at TEXT NOT NULL, + app_name TEXT, + primary_window_title TEXT, + duration_sec INTEGER DEFAULT 0, + event_count INTEGER DEFAULT 0, + summary TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + """, + """ + CREATE TABLE IF NOT EXISTS search_docs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ref_type TEXT NOT NULL, + ref_id TEXT NOT NULL, + title TEXT, + body TEXT, + tags TEXT, + app_name TEXT, + created_at TEXT NOT NULL + ); + """, + """ + CREATE VIRTUAL TABLE IF NOT EXISTS search_docs_fts USING fts5( + title, + body, + app_name, + content=search_docs, + content_rowid=id + ); + """, + """ + CREATE TRIGGER IF NOT EXISTS search_docs_ai AFTER INSERT ON search_docs BEGIN + INSERT INTO search_docs_fts(rowid, title, body, app_name) + VALUES (new.id, new.title, new.body, new.app_name); + END; + """, + """ + CREATE TRIGGER IF NOT EXISTS search_docs_ad AFTER DELETE ON search_docs BEGIN + INSERT INTO search_docs_fts(search_docs_fts, rowid, title, body, app_name) + VALUES ('delete', old.id, old.title, old.body, old.app_name); + END; + """, + """ + CREATE TRIGGER IF NOT EXISTS search_docs_au AFTER UPDATE ON search_docs BEGIN + INSERT INTO search_docs_fts(search_docs_fts, rowid, title, body, app_name) + VALUES ('delete', old.id, old.title, old.body, old.app_name); + INSERT INTO search_docs_fts(rowid, title, body, app_name) + VALUES (new.id, new.title, new.body, new.app_name); + END; + """, + """ + CREATE TABLE IF NOT EXISTS policies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scope_type TEXT NOT NULL, + scope_value TEXT NOT NULL, + mode TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + priority INTEGER NOT NULL DEFAULT 100, + config_json TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + """, + """ + CREATE TABLE IF NOT EXISTS app_state ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at TEXT NOT NULL + ); + """, + """ + CREATE INDEX IF NOT EXISTS idx_raw_events_ts_start ON raw_events(ts_start); + CREATE INDEX IF NOT EXISTS idx_raw_events_source ON raw_events(source); + CREATE INDEX IF NOT EXISTS idx_raw_events_app_name ON raw_events(app_name); + CREATE INDEX IF NOT EXISTS idx_raw_events_session_id ON raw_events(session_id); + CREATE INDEX IF NOT EXISTS idx_sessions_started_at ON sessions(started_at); + CREATE INDEX IF NOT EXISTS idx_search_docs_ref ON search_docs(ref_type, ref_id); + """, +] + + +def run_migrations(conn: sqlite3.Connection): + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + conn.execute(""" + CREATE TABLE IF NOT EXISTS _schema_version ( + version INTEGER NOT NULL, + applied_at TEXT NOT NULL + ) + """) + conn.commit() + row = conn.execute("SELECT MAX(version) FROM _schema_version").fetchone() + current = row[0] if row[0] is not None else 0 + for i, sql in enumerate(MIGRATIONS, start=1): + if i > current: + # Some migration entries have multiple statements + for stmt in sql.strip().split(";"): + stmt = stmt.strip() + if stmt: + conn.execute(stmt) + conn.execute( + "INSERT INTO _schema_version(version, applied_at) VALUES (?, ?)", + (i, datetime.now(timezone.utc).isoformat()) + ) + conn.commit() diff --git a/keypulse/store/models.py b/keypulse/store/models.py new file mode 100644 index 0000000..42fce7e --- /dev/null +++ b/keypulse/store/models.py @@ -0,0 +1,78 @@ +from __future__ import annotations +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Optional +import uuid + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _uuid() -> str: + return str(uuid.uuid4()) + + +@dataclass +class RawEvent: + source: str # window|idle|clipboard|manual|browser + event_type: str # window_focus|window_heartbeat|window_blur|idle_start|idle_end|clipboard_copy|manual_save + ts_start: str + ts_end: Optional[str] = None + app_name: Optional[str] = None + window_title: Optional[str] = None + process_name: Optional[str] = None + content_text: Optional[str] = None + content_hash: Optional[str] = None + metadata_json: Optional[str] = None + sensitivity_level: int = 0 + skipped_reason: Optional[str] = None + session_id: Optional[str] = None + id: Optional[int] = None + created_at: str = field(default_factory=_now) + + +@dataclass +class Session: + id: str = field(default_factory=_uuid) + started_at: str = field(default_factory=_now) + ended_at: str = field(default_factory=_now) + app_name: Optional[str] = None + primary_window_title: Optional[str] = None + duration_sec: int = 0 + event_count: int = 0 + summary: Optional[str] = None + created_at: str = field(default_factory=_now) + updated_at: str = field(default_factory=_now) + + +@dataclass +class SearchDoc: + ref_type: str # clipboard|manual|session + ref_id: str + title: Optional[str] = None + body: Optional[str] = None + tags: Optional[str] = None + app_name: Optional[str] = None + id: Optional[int] = None + created_at: str = field(default_factory=_now) + + +@dataclass +class Policy: + scope_type: str # app|window|source|content + scope_value: str + mode: str # allow|deny|metadata-only|redact|truncate + enabled: bool = True + priority: int = 100 + config_json: Optional[str] = None + id: Optional[int] = None + created_at: str = field(default_factory=_now) + updated_at: str = field(default_factory=_now) + + +@dataclass +class AppState: + key: str + value: Optional[str] + updated_at: str = field(default_factory=_now) diff --git a/keypulse/store/repository.py b/keypulse/store/repository.py new file mode 100644 index 0000000..ee9168a --- /dev/null +++ b/keypulse/store/repository.py @@ -0,0 +1,219 @@ +import hashlib +import json +from datetime import datetime, timezone, timedelta +from typing import Optional +from keypulse.store.db import get_conn +from keypulse.store.models import RawEvent, Session, SearchDoc, Policy, AppState + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +# ── RawEvent ───────────────────────────────────────────────────────────────── + +def insert_raw_event(e: RawEvent) -> int: + conn = get_conn() + cur = conn.execute( + """INSERT INTO raw_events + (source, event_type, ts_start, ts_end, app_name, window_title, + process_name, content_text, content_hash, metadata_json, + sensitivity_level, skipped_reason, session_id, created_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + (e.source, e.event_type, e.ts_start, e.ts_end, e.app_name, + e.window_title, e.process_name, e.content_text, e.content_hash, + e.metadata_json, e.sensitivity_level, e.skipped_reason, + e.session_id, e.created_at), + ) + conn.commit() + return cur.lastrowid + + +def query_raw_events( + source: Optional[str] = None, + app_name: Optional[str] = None, + since: Optional[str] = None, + until: Optional[str] = None, + limit: int = 500, +) -> list[dict]: + conn = get_conn() + clauses, params = [], [] + if source: + clauses.append("source = ?") + params.append(source) + if app_name: + clauses.append("app_name LIKE ?") + params.append(f"%{app_name}%") + if since: + clauses.append("ts_start >= ?") + params.append(since) + if until: + clauses.append("ts_start <= ?") + params.append(until) + where = ("WHERE " + " AND ".join(clauses)) if clauses else "" + rows = conn.execute( + f"SELECT * FROM raw_events {where} ORDER BY ts_start DESC LIMIT ?", + (*params, limit) + ).fetchall() + return [dict(r) for r in rows] + + +def purge_raw_events(since: Optional[str] = None, until: Optional[str] = None, app_name: Optional[str] = None): + conn = get_conn() + clauses, params = [], [] + if since: + clauses.append("ts_start >= ?") + params.append(since) + if until: + clauses.append("ts_start <= ?") + params.append(until) + if app_name: + clauses.append("app_name LIKE ?") + params.append(f"%{app_name}%") + where = ("WHERE " + " AND ".join(clauses)) if clauses else "" + conn.execute(f"DELETE FROM raw_events {where}", params) + conn.commit() + + +# ── Session ─────────────────────────────────────────────────────────────────── + +def upsert_session(s: Session): + conn = get_conn() + conn.execute( + """INSERT INTO sessions + (id, started_at, ended_at, app_name, primary_window_title, + duration_sec, event_count, summary, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(id) DO UPDATE SET + ended_at=excluded.ended_at, + duration_sec=excluded.duration_sec, + event_count=excluded.event_count, + summary=excluded.summary, + updated_at=excluded.updated_at""", + (s.id, s.started_at, s.ended_at, s.app_name, s.primary_window_title, + s.duration_sec, s.event_count, s.summary, s.created_at, s.updated_at), + ) + conn.commit() + + +def get_sessions(date_str: Optional[str] = None, limit: int = 200) -> list[dict]: + """date_str format: YYYY-MM-DD""" + conn = get_conn() + if date_str: + rows = conn.execute( + "SELECT * FROM sessions WHERE started_at LIKE ? ORDER BY started_at ASC LIMIT ?", + (f"{date_str}%", limit) + ).fetchall() + else: + rows = conn.execute( + "SELECT * FROM sessions ORDER BY started_at DESC LIMIT ?", (limit,) + ).fetchall() + return [dict(r) for r in rows] + + +def get_session_by_id(session_id: str) -> Optional[dict]: + conn = get_conn() + row = conn.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone() + return dict(row) if row else None + + +# ── SearchDoc ───────────────────────────────────────────────────────────────── + +def insert_search_doc(doc: SearchDoc) -> int: + conn = get_conn() + cur = conn.execute( + """INSERT INTO search_docs (ref_type, ref_id, title, body, tags, app_name, created_at) + VALUES (?,?,?,?,?,?,?)""", + (doc.ref_type, doc.ref_id, doc.title, doc.body, doc.tags, doc.app_name, doc.created_at), + ) + conn.commit() + return cur.lastrowid + + +def search_docs_fts(query: str, app_name: Optional[str] = None, limit: int = 50) -> list[dict]: + conn = get_conn() + sql = """ + SELECT sd.*, bm25(search_docs_fts) as score + FROM search_docs sd + JOIN search_docs_fts ON search_docs_fts.rowid = sd.id + WHERE search_docs_fts MATCH ? + """ + params = [query] + if app_name: + sql += " AND sd.app_name LIKE ?" + params.append(f"%{app_name}%") + sql += " ORDER BY score, sd.created_at DESC LIMIT ?" + params.append(limit) + rows = conn.execute(sql, params).fetchall() + return [dict(r) for r in rows] + + +# ── Policy ──────────────────────────────────────────────────────────────────── + +def get_all_policies() -> list[dict]: + conn = get_conn() + rows = conn.execute( + "SELECT * FROM policies WHERE enabled=1 ORDER BY priority ASC" + ).fetchall() + return [dict(r) for r in rows] + + +def insert_policy(p: Policy) -> int: + conn = get_conn() + cur = conn.execute( + """INSERT INTO policies (scope_type, scope_value, mode, enabled, priority, config_json, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?)""", + (p.scope_type, p.scope_value, p.mode, int(p.enabled), p.priority, + p.config_json, p.created_at, p.updated_at), + ) + conn.commit() + return cur.lastrowid + + +def seed_policies_from_config(policy_configs: list): + """Insert policies from config if not already seeded.""" + conn = get_conn() + count = conn.execute("SELECT COUNT(*) FROM policies").fetchone()[0] + if count > 0: + return # already seeded + for pc in policy_configs: + p = Policy( + scope_type=pc.scope_type, + scope_value=pc.scope_value, + mode=pc.mode, + enabled=pc.enabled, + priority=pc.priority, + ) + insert_policy(p) + + +# ── AppState ────────────────────────────────────────────────────────────────── + +def set_state(key: str, value: str): + conn = get_conn() + conn.execute( + "INSERT INTO app_state(key, value, updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at", + (key, value, _now()), + ) + conn.commit() + + +def get_state(key: str) -> Optional[str]: + conn = get_conn() + row = conn.execute("SELECT value FROM app_state WHERE key=?", (key,)).fetchone() + return row[0] if row else None + + +# ── Retention ───────────────────────────────────────────────────────────────── + +def apply_retention(retention_days: int): + """Delete raw_events and clipboard docs older than retention_days.""" + cutoff = (datetime.now(timezone.utc) - timedelta(days=retention_days)).isoformat() + conn = get_conn() + conn.execute("DELETE FROM raw_events WHERE created_at < ?", (cutoff,)) + conn.execute( + "DELETE FROM search_docs WHERE ref_type='clipboard' AND created_at < ?", + (cutoff,) + ) + conn.execute("VACUUM") + conn.commit() diff --git a/keypulse/utils/__init__.py b/keypulse/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/keypulse/utils/lock.py b/keypulse/utils/lock.py new file mode 100644 index 0000000..0193e79 --- /dev/null +++ b/keypulse/utils/lock.py @@ -0,0 +1,44 @@ +import os +import signal +from pathlib import Path +from keypulse.utils.paths import get_pid_path + + +class SingleInstanceLock: + def __init__(self): + self.pid_path = get_pid_path() + + def acquire(self) -> bool: + """Try to acquire lock. Returns True if acquired, False if already running.""" + if self.pid_path.exists(): + try: + pid = int(self.pid_path.read_text().strip()) + os.kill(pid, 0) # Check if process exists + return False # Already running + except (ProcessLookupError, ValueError): + pass # Stale PID file + self.pid_path.write_text(str(os.getpid())) + return True + + def release(self): + if self.pid_path.exists(): + try: + pid = int(self.pid_path.read_text().strip()) + if pid == os.getpid(): + self.pid_path.unlink() + except (ValueError, OSError): + pass + + def get_pid(self) -> int | None: + """Return PID of running instance, or None.""" + if not self.pid_path.exists(): + return None + try: + pid = int(self.pid_path.read_text().strip()) + os.kill(pid, 0) + return pid + except (ProcessLookupError, ValueError, OSError): + return None + + def is_running(self) -> bool: + return self.get_pid() is not None diff --git a/keypulse/utils/logging.py b/keypulse/utils/logging.py new file mode 100644 index 0000000..6132fe0 --- /dev/null +++ b/keypulse/utils/logging.py @@ -0,0 +1,32 @@ +import logging +import json +from datetime import datetime, timezone +from pathlib import Path + + +class JSONFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + data = { + "ts": datetime.now(timezone.utc).isoformat(), + "level": record.levelname, + "logger": record.name, + "msg": record.getMessage(), + } + if record.exc_info: + data["exc"] = self.formatException(record.exc_info) + return json.dumps(data, ensure_ascii=False) + + +def setup_logging(log_path: Path, level: str = "INFO"): + log_path.parent.mkdir(parents=True, exist_ok=True) + root = logging.getLogger("keypulse") + root.setLevel(getattr(logging, level.upper(), logging.INFO)) + # File handler (JSON) + fh = logging.FileHandler(log_path, encoding="utf-8") + fh.setFormatter(JSONFormatter()) + root.addHandler(fh) + return root + + +def get_logger(name: str) -> logging.Logger: + return logging.getLogger(f"keypulse.{name}") diff --git a/keypulse/utils/paths.py b/keypulse/utils/paths.py new file mode 100644 index 0000000..9ce39c7 --- /dev/null +++ b/keypulse/utils/paths.py @@ -0,0 +1,23 @@ +from pathlib import Path + + +def get_data_dir() -> Path: + d = Path.home() / ".keypulse" + d.mkdir(parents=True, exist_ok=True) + return d + + +def get_db_path() -> Path: + return get_data_dir() / "keypulse.db" + + +def get_pid_path() -> Path: + return get_data_dir() / "keypulse.pid" + + +def get_log_path() -> Path: + return get_data_dir() / "keypulse.log" + + +def get_config_path() -> Path: + return get_data_dir() / "config.toml" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ed10ebd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.backends.legacy:build" + +[project] +name = "keypulse" +version = "0.1.0" +description = "Local-first personal activity memory layer for macOS" +requires-python = ">=3.11" +dependencies = [ + "click>=8.1", + "rich>=13.7", + "pydantic>=2.5", + "pydantic-settings>=2.0", + "pyobjc-framework-AppKit>=10.0", + "pyobjc-framework-Quartz>=10.0", +] + +[project.scripts] +keypulse = "keypulse.cli:main" + +[tool.setuptools.packages.find] +where = ["."] +include = ["keypulse*"]