diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..3b809d7 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,34 @@ +{ + "permissions": { + "allow": [ + "Bash(pip install:*)", + "Bash(python -c \":*)", + "Bash(python -m pytest tests/ -v)", + "Bash(python -m pytest tests/ --cov=memsync --cov-report=term-missing -q)", + "Bash(python -m pytest tests/ -q)", + "Bash(memsync doctor:*)", + "Bash(python -m pytest tests/test_cli.py -v)", + "Bash(python -m pytest tests/test_cli.py::TestCmdDoctor::test_all_checks_pass_returns_0 -v -s)", + "Bash(python -m pytest tests/test_cli.py -q)", + "Bash(python -m pytest -m smoke -v)", + "Bash(ruff check:*)", + "Bash(bandit -r memsync/ -c pyproject.toml)", + "Bash(echo \"=== SMOKE \\(25 tests, <2s\\) ===\")", + "Bash(python -m pytest -m smoke -q --no-cov)", + "Bash(bandit -r memsync/ -c pyproject.toml -q)", + "Bash(python -m pytest tests/ -q --no-cov)", + "Bash(python -c \"from memsync.daemon import DAEMON_VERSION; print\\(''daemon __init__ ok, version:'', DAEMON_VERSION\\)\")", + "Bash(python -m pytest tests/ -x -q)", + "Bash(python -c \"import apscheduler; import flask; print\\(''OK''\\)\")", + "Bash(python -m pytest tests/test_daemon_notify.py::TestNotifyEmail::test_uses_env_var_password_over_config -v)", + "Bash(python -m pytest tests/test_daemon_notify.py::TestNotifyEmail::test_uses_env_var_password_over_config -v -s)", + "Bash(python -m pytest tests/test_daemon_scheduler.py::TestJobNightlyRefresh::test_skips_when_no_session_log -v -s)", + "Bash(python -m pytest tests/ -q --cov-report=term-missing)", + "Bash(python -m pytest tests/test_cli.py::TestDaemonCLIGuard::test_schedule_shows_jobs -v -s)", + "Bash(python:*)", + "Bash(grep -v \"^$\")", + "Bash(memsync refresh:*)", + "Bash(memsync config:*)" + ] + } +} diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..fdd7c43 Binary files /dev/null and b/.coverage differ diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..5584187 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,30 @@ +--- +name: Bug report +about: Something isn't working +labels: bug +--- + +**OS:** (macOS / Windows / Linux + version) + +**Python version:** (e.g. 3.12.1) + +**Provider:** (onedrive / icloud / gdrive / custom) + +**`memsync status` output:** +``` +(paste here) +``` + +**Error message:** +``` +(paste full error here) +``` + +**Steps to reproduce:** +1. +2. +3. + +**Expected behavior:** + +**Actual behavior:** diff --git a/.github/ISSUE_TEMPLATE/provider_request.md b/.github/ISSUE_TEMPLATE/provider_request.md new file mode 100644 index 0000000..ec6ac05 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/provider_request.md @@ -0,0 +1,19 @@ +--- +name: Provider request +about: Request support for a new cloud storage provider +labels: provider +--- + +**Provider name:** (e.g. Dropbox, Box, Synology Drive) + +**OS(es) where you use it:** (macOS / Windows / Linux) + +**Default install path(s):** +- macOS: `~/...` +- Windows: `C:/Users/.../...` +- Linux: `~/...` + +**Are you willing to implement it?** +Yes / No / Maybe — if yes, see [docs/adding-a-provider.md](../../docs/adding-a-provider.md) for a guide. + +**Any other notes:** diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..970725e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint: + name: Lint & security + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dev dependencies + run: pip install -e ".[dev]" + + - name: ruff (lint + style) + run: ruff check memsync/ + + - name: bandit (security scan) + run: bandit -r memsync/ -c pyproject.toml + + test: + name: Test (${{ matrix.os }}, Python ${{ matrix.python-version }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: pip install -e ".[dev]" + + - name: Run tests (with coverage) + run: pytest tests/ -v + + - name: Smoke test + run: pytest -m smoke -v diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..54d84a7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,28 @@ +name: Release + +on: + push: + tags: + - "v*" + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + id-token: write # for PyPI Trusted Publishing (OIDC) + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Build + run: | + pip install build + python -m build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..de6d7f8 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,183 @@ +# ARCHITECTURE.md + +## System overview + +``` +User runs: memsync refresh --notes "..." + │ + ▼ + memsync/cli.py ← argument parsing, routes to commands + │ + ▼ + memsync/config.py ← loads ~/.config/memsync/config.toml + │ + ▼ + memsync/providers/.py ← resolves sync root path for this machine + │ + ▼ + memsync/sync.py ← calls Claude API, merges notes into memory + │ + ▼ + memsync/backups.py ← backs up before writing + │ + ▼ + memsync/claude_md.py ← syncs GLOBAL_MEMORY.md → ~/.claude/CLAUDE.md +``` + +--- + +## Module responsibilities + +### `memsync/cli.py` +- Entry point. Parses args, loads config, routes to command functions. +- Does NOT contain business logic — only wiring. +- Every command function signature: `def cmd_(args, config) -> int` +- Returns exit code. Print errors to stderr, output to stdout. + +### `memsync/config.py` +- Loads and saves `~/.config/memsync/config.toml` (Mac/Linux) + or `%APPDATA%\memsync\config.toml` (Windows). +- Exposes a `Config` dataclass — no raw dicts passed around the codebase. +- Handles missing keys with sensible defaults. +- See `CONFIG.md` for full schema. + +### `memsync/providers/` +- `__init__.py` — defines `BaseProvider` ABC and `get_provider(name)` registry function. +- One file per provider: `onedrive.py`, `icloud.py`, `gdrive.py`, `custom.py`. +- Each provider implements `detect() -> Path | None` and `is_available() -> bool`. +- See `PROVIDERS.md` for full spec and all three implementations. + +### `memsync/sync.py` +- The only module that calls the Anthropic API. +- Takes: notes (str), current memory (str), config (Config). +- Returns: updated memory (str), changed (bool). +- Does NOT write files. Caller handles I/O. +- See `PITFALLS.md` — this module has the most trust/safety concerns. + +### `memsync/backups.py` +- `backup(source: Path, backup_dir: Path) -> Path` — copies with timestamp. +- `prune(backup_dir: Path, keep_days: int) -> list[Path]` — removes old backups. +- `list_backups(backup_dir: Path) -> list[Path]` — sorted newest-first. +- `latest_backup(backup_dir: Path) -> Path | None` + +### `memsync/claude_md.py` +- `sync(memory_path: Path, target_path: Path) -> None` + - `target_path` comes from `config.claude_md_target` — never hardcoded. + - Mac/Linux: create symlink if not already correct, backup any existing file first. + - Windows: copy (symlinks require admin rights on Windows). +- `is_synced(memory_path: Path, target_path: Path) -> bool` + +--- + +## Data flow: `memsync init` + +``` +1. cli.py — parse args +2. config.py — check if config already exists (warn if --force not set) +3. providers/ — run detect() on each registered provider in priority order +4. cli.py — if multiple detected, prompt user to choose +5. config.py — write config with chosen provider + detected path +6. providers/ — call get_memory_root() to get the .claude-memory path +7. (filesystem) — create .claude-memory/, backups/, sessions/ dirs +8. (filesystem) — write starter GLOBAL_MEMORY.md if not exists +9. claude_md.py — sync to ~/.claude/CLAUDE.md +10. cli.py — print summary of what was created +``` + +## Data flow: `memsync refresh` + +``` +1. cli.py — parse args, read notes from --notes / --file / stdin +2. config.py — load config +3. providers/ — resolve memory root path +4. (filesystem) — read current GLOBAL_MEMORY.md +5. sync.py — call Claude API with current memory + notes +6. sync.py — enforce hard constraints (append-only diff) +7. backups.py — backup current file before overwriting +8. (filesystem) — write updated GLOBAL_MEMORY.md +9. claude_md.py — sync to ~/.claude/CLAUDE.md +10. sessions/ — append notes to dated session log +11. cli.py — print summary (changed/unchanged, backup path) +``` + +--- + +## File layout on disk + +``` +# In cloud sync folder (synced across machines): +OneDrive/.claude-memory/ ← or iCloud/.claude-memory/, etc. + GLOBAL_MEMORY.md ← source of truth + backups/ + GLOBAL_MEMORY_20260321_143022.md + GLOBAL_MEMORY_20260320_091145.md + ... + sessions/ + 2026-03-21.md ← raw notes, append-only, never deleted + 2026-03-20.md + ... + +# On each machine (not synced): +~/.config/memsync/config.toml ← machine-specific config +~/.claude/CLAUDE.md ← symlink → OneDrive/.claude-memory/GLOBAL_MEMORY.md + (or copy on Windows) +``` + +--- + +## What does NOT belong in this tool + +- Project-specific memory (that belongs in each project's CLAUDE.md) +- Cold storage / knowledge bases (use Hipocampus or RAG for that) +- Multi-user or team memory (out of scope for v1) +- Anything that requires a server, database, or API key beyond Anthropic's + +--- + +## Futureproofing decisions + +These are low-effort now and expensive to retrofit later. All three are +already reflected in the code specs above — this section explains the *why*. + +### 1. Version the memory file format + +Write a version comment at the top of every `GLOBAL_MEMORY.md` when it's +first created: + +```markdown + +# Global Memory +... +``` + +If the schema ever needs to change (section names, structure, anything), +the version comment lets migration code know what it's dealing with. +Without it, you can't distinguish an old file from a new one. + +Implementation: write this comment in `load_or_init_memory()` when creating +the starter template. Check for it in `refresh_memory_content()` and warn +(don't fail) if it's missing. + +### 2. Don't hardcode the CLAUDE.md target path + +`~/.claude/CLAUDE.md` is where Claude Code reads its global config today. +That could change. The target path lives in `config.claude_md_target` and +is never hardcoded anywhere in the logic modules. `cli.py` reads it from +config and passes it to `claude_md.sync()`. This is already reflected in +the `claude_md.py` module spec above. + +### 3. Keep the Anthropic SDK version loose + +`pyproject.toml` already has `anthropic>=0.40.0` — keep it that way. +Never pin to an exact version. Users should get SDK updates automatically +when they upgrade their environment. + +--- + +## Key constraints + +- Python 3.11+ only. Use match statements, `Path` everywhere, `tomllib` (stdlib). +- No dependencies beyond `anthropic`. Everything else stdlib. +- `tomllib` is read-only (stdlib in 3.11+). Use `tomli_w` for writing, or write TOML + manually for the simple schema we have. See `CONFIG.md`. +- Must work offline except for `memsync refresh` (the only command needing the API). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3478f3c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,92 @@ +# memsync — Project Context for Claude Code + +You are building **memsync**, a cross-platform CLI tool that maintains a global +identity-layer memory file for Claude Code users, synced across machines via +cloud storage they already have (OneDrive, iCloud Drive, Google Drive). + +This document is your entry point. Read all linked documents before writing any code. + +--- + +## Document map + +| File | What it contains | +|---|---| +| `CLAUDE.md` | This file — start here | +| `ARCHITECTURE.md` | Full system design, module map, data flow | +| `PROVIDERS.md` | Provider plugin system — BaseProvider ABC, all three implementations | +| `CONFIG.md` | Config file design, schema, platform paths | +| `COMMANDS.md` | Every CLI command — args, behavior, edge cases | +| `EXISTING_CODE.md` | Working prototype code — use this as the base, not a reference | +| `PITFALLS.md` | Known issues, trust boundaries, things that have already gone wrong | +| `REPO.md` | Repository structure, CI, PyPI, GitHub conventions | +| `STYLE.md` | Code style, naming conventions, what good looks like here | + +Read them in this order: +1. ARCHITECTURE.md — understand the shape of the system +2. EXISTING_CODE.md — understand what already works +3. PROVIDERS.md — the most important new piece +4. CONFIG.md — feeds into everything +5. PITFALLS.md — read before touching sync.py or providers +6. COMMANDS.md, REPO.md, STYLE.md — as needed + +--- + +## What this project is + +memsync solves a specific problem: Claude Code has no memory between sessions. +The standard fix is `~/.claude/CLAUDE.md`, but it drifts, bloats, and doesn't +sync across machines. + +memsync maintains one canonical `GLOBAL_MEMORY.md` in your cloud sync folder. +At session start, Claude Code reads it via a symlink (Mac/Linux) or copy (Windows) +at `~/.claude/CLAUDE.md`. After meaningful sessions, the user runs +`memsync refresh --notes "..."` and the Claude API merges the notes in. + +This is the **identity layer** — who the user is, what they're working on, standing +preferences. Not project docs. Not cold storage. Not a knowledge base. + +--- + +## What already exists + +A working prototype was built in a Claude.ai chat session. It covers: +- OneDrive path detection (Mac + Windows) +- Core refresh logic (Claude API call, backup, sync to CLAUDE.md) +- CLI with: init, refresh, status, show, diff, prune + +All prototype code is in `EXISTING_CODE.md`. Use it as the foundation. +Do not rewrite from scratch — refactor to fit the target architecture. + +--- + +## What needs to be built + +1. Provider abstraction layer (`memsync/providers/`) — BaseProvider ABC + 3 implementations +2. Config system (`memsync/config.py`) — TOML, platform-aware paths +3. Refactor existing code to use config + providers +4. Tests (`tests/`) — mocked filesystem + mocked API +5. CI (`.github/workflows/`) — test matrix Mac/Windows/Linux × Python 3.11/3.12 +6. Docs — README, CONTRIBUTING, adding-a-provider guide +7. GitHub issue templates + +See `REPO.md` for full repository layout and build order. + +--- + +## Hard rules + +- Never hardcode the model string. Always read from config. +- Never hardcode any path. Always go through the provider or config system. +- Hard constraints in GLOBAL_MEMORY.md are append-only. Enforce this in code, not prompts. +- Backups before every write. No exceptions. +- See `PITFALLS.md` before touching anything related to the Claude API call or path resolution. + +--- + +## Owner context + +Built by Ian (product leader, writer, not a full-time engineer). +Maintenance appetite: active at launch, wants to go passive or hand off over time. +That means: clear contributor docs, plugin architecture that doesn't require +touching core to add a provider, and CI that catches regressions without manual effort. diff --git a/COMMANDS.md b/COMMANDS.md new file mode 100644 index 0000000..c11dc7a --- /dev/null +++ b/COMMANDS.md @@ -0,0 +1,220 @@ +# COMMANDS.md + +## Command map + +``` +memsync +├── init Set up memory structure for the first time +├── refresh Merge session notes into global memory +├── show Print current GLOBAL_MEMORY.md +├── diff Diff current memory vs last backup +├── status Show paths, provider, sync state +├── config +│ ├── show Print current config.toml +│ └── set Update a config value +├── providers List all providers and their detection status +└── prune Remove old backups +``` + +--- + +## `memsync init` + +**Purpose:** First-time setup. Creates directory structure, writes starter memory, +syncs to CLAUDE.md. + +**Args:** +- `--force` — reinitialize even if memory already exists (prompts confirmation) +- `--provider ` — skip auto-detection, use this provider +- `--sync-root ` — skip auto-detection, use this path directly + +**Behavior:** +1. Check if config already exists → warn and exit unless `--force` +2. If no `--provider` given, run auto-detection across all providers + - If 0 detected: print friendly error explaining how to set manually + - If 1 detected: use it, confirm with user + - If 2+ detected: prompt user to choose +3. Resolve memory root from provider +4. Create: `memory_root/`, `memory_root/backups/`, `memory_root/sessions/` +5. If `GLOBAL_MEMORY.md` doesn't exist, write starter template +6. Write config +7. Run `claude_md.sync()` to create the CLAUDE.md link +8. Print summary + +**Output (success):** +``` +memsync initialized. + + Provider: OneDrive + Sync root: /Users/ian/OneDrive + Memory: /Users/ian/OneDrive/.claude-memory/GLOBAL_MEMORY.md + CLAUDE.md: /Users/ian/.claude/CLAUDE.md → (symlink) + +Next: edit your memory file, then run: + memsync refresh --notes "initial setup complete" +``` + +--- + +## `memsync refresh` + +**Purpose:** Core command. Merge session notes into GLOBAL_MEMORY.md via Claude API. + +**Args:** +- `--notes ` / `-n ` — notes as inline string +- `--file ` / `-f ` — read notes from file +- `--dry-run` — print what would change, don't write anything +- (stdin) — if no --notes or --file and stdin is not a tty, read from stdin + +**Exactly one of --notes, --file, or stdin must be provided.** + +**Behavior:** +1. Load config +2. Resolve memory path via provider +3. Read current GLOBAL_MEMORY.md +4. Call Claude API (see sync.py spec below and PITFALLS.md) +5. Enforce hard constraints (append-only diff) +6. If changed AND not dry-run: + a. Backup current file + b. Write updated memory + c. Sync to CLAUDE.md + d. Append notes to sessions/.md +7. Print summary + +**Output (changed):** +``` +Memory updated. + Backup: /Users/ian/OneDrive/.claude-memory/backups/GLOBAL_MEMORY_20260321_143022.md + Memory: /Users/ian/OneDrive/.claude-memory/GLOBAL_MEMORY.md + CLAUDE.md synced ✓ +``` + +**Output (no change):** +``` +No changes detected. +``` + +**Output (dry-run):** +``` +[DRY RUN] No files written. + +--- diff --- +- Old line ++ New line +... +``` + +--- + +## `memsync show` + +**Purpose:** Print current GLOBAL_MEMORY.md to stdout. + +**Args:** none + +**Use case:** Pipe to less, copy to clipboard, quick review. + +--- + +## `memsync diff` + +**Purpose:** Show unified diff between current memory and the most recent backup. + +**Args:** +- `--backup ` — diff against a specific backup instead of latest + +**Output:** Standard unified diff format. If no backups exist, print a message. + +--- + +## `memsync status` + +**Purpose:** Sanity check — what is memsync pointing at on this machine? + +**Output:** +``` +Platform: macOS (Darwin) +Config: /Users/ian/.config/memsync/config.toml ✓ +Provider: OneDrive +Sync root: /Users/ian/Library/CloudStorage/OneDrive-Personal ✓ +Memory: /Users/ian/Library/CloudStorage/OneDrive-Personal/.claude-memory/GLOBAL_MEMORY.md ✓ +CLAUDE.md: /Users/ian/.claude/CLAUDE.md → symlink ✓ +Backups: 14 file(s) +Session logs: 22 day(s) +Model: claude-sonnet-4-20250514 +``` + +--- + +## `memsync config show` + +Print the contents of config.toml. + +## `memsync config set ` + +Update a single config value and save. + +```bash +memsync config set provider icloud +memsync config set model claude-opus-4-20250514 +memsync config set keep_days 60 +memsync config set sync_root "/Users/ian/Dropbox" +``` + +After `config set sync_root`, automatically set provider to "custom". +After any change, print the updated value to confirm. + +--- + +## `memsync providers` + +List all registered providers and their detection status on this machine. + +**Output:** +``` +Available providers: + + onedrive OneDrive ✓ detected at /Users/ian/Library/CloudStorage/OneDrive-Personal + icloud iCloud Drive ✓ detected at /Users/ian/Library/Mobile Documents/com~apple~CloudDocs + gdrive Google Drive ✗ not detected + custom Custom Path ✗ no path configured + +Active provider: onedrive +``` + +--- + +## `memsync prune` + +**Args:** +- `--keep-days ` — default from config (30) +- `--dry-run` — list what would be deleted without deleting + +**Output:** +``` +Pruned 3 backup(s) older than 30 days. + removed: GLOBAL_MEMORY_20260101_120000.md + removed: GLOBAL_MEMORY_20260102_083000.md + removed: GLOBAL_MEMORY_20260115_201500.md +``` + +--- + +## Exit codes + +| Code | Meaning | +|---|---| +| 0 | Success | +| 1 | General error (printed to stderr) | +| 2 | Config not found — run `memsync init` | +| 3 | Memory file not found | +| 4 | Provider detection failed | +| 5 | API error | + +--- + +## Error message conventions + +- Always print errors to stderr +- Always suggest a fix, not just a description of the problem +- Example: `Error: no provider detected. Run 'memsync init' or set a custom path with 'memsync config set sync_root /path/to/folder'` diff --git a/CONFIG.md b/CONFIG.md new file mode 100644 index 0000000..865c5f0 --- /dev/null +++ b/CONFIG.md @@ -0,0 +1,247 @@ +# CONFIG.md + +## Config file location + +```python +import platform +from pathlib import Path + +def get_config_path() -> Path: + if platform.system() == "Windows": + import os + appdata = os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming")) + return Path(appdata) / "memsync" / "config.toml" + else: + # Mac and Linux — XDG standard + import os + xdg_config = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")) + return Path(xdg_config) / "memsync" / "config.toml" +``` + +--- + +## Config schema (TOML) + +```toml +[core] +provider = "onedrive" # which provider is active on this machine +model = "claude-sonnet-4-20250514" # Anthropic model for refresh +max_memory_lines = 400 # soft cap passed to the refresh prompt + +[paths] +# Optional overrides — set by memsync if auto-detect finds a non-default location +# sync_root = "/Users/ian/Library/CloudStorage/OneDrive-Personal" + +# Where to write the CLAUDE.md file that Claude Code reads at session start. +# Change this if Claude Code ever moves its config location, or if you use +# a non-standard Claude Code install. +claude_md_target = "~/.claude/CLAUDE.md" + +[backups] +keep_days = 30 + +[providers.onedrive] +# provider-specific config (currently unused, reserved for future) + +[providers.icloud] +# same + +[providers.gdrive] +# same +``` + +--- + +## Config dataclass + +```python +# memsync/config.py + +from __future__ import annotations +import tomllib +import platform +from dataclasses import dataclass, field +from pathlib import Path + + +@dataclass +class Config: + # [core] + provider: str = "onedrive" + model: str = "claude-sonnet-4-20250514" + max_memory_lines: int = 400 + + # [paths] + sync_root: Path | None = None # None = use provider auto-detect + claude_md_target: Path = Path("~/.claude/CLAUDE.md") + + # [backups] + keep_days: int = 30 + + @classmethod + def load(cls) -> "Config": + path = get_config_path() + if not path.exists(): + return cls() # all defaults + with open(path, "rb") as f: + raw = tomllib.load(f) + return cls._from_dict(raw) + + @classmethod + def _from_dict(cls, raw: dict) -> "Config": + core = raw.get("core", {}) + paths = raw.get("paths", {}) + backups = raw.get("backups", {}) + + sync_root = paths.get("sync_root") + claude_md_target = paths.get("claude_md_target", "~/.claude/CLAUDE.md") + return cls( + provider=core.get("provider", "onedrive"), + model=core.get("model", "claude-sonnet-4-20250514"), + max_memory_lines=core.get("max_memory_lines", 400), + sync_root=Path(sync_root) if sync_root else None, + claude_md_target=Path(claude_md_target).expanduser(), + keep_days=backups.get("keep_days", 30), + ) + + def save(self) -> None: + path = get_config_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(self._to_toml(), encoding="utf-8") + + def _to_toml(self) -> str: + """ + tomllib is read-only (stdlib). We write TOML manually. + Schema is simple enough that this is fine. + If it grows, add tomli_w as a dependency. + """ + lines = [ + "[core]", + f'provider = "{self.provider}"', + f'model = "{self.model}"', + f"max_memory_lines = {self.max_memory_lines}", + "", + "[paths]", + f'claude_md_target = "{self.claude_md_target.as_posix()}"', + ] + if self.sync_root: + # TOML strings need forward slashes or escaped backslashes + lines.append(f'sync_root = "{self.sync_root.as_posix()}"') + lines += [ + "", + "[backups]", + f"keep_days = {self.keep_days}", + "", + ] + return "\n".join(lines) + + +def get_config_path() -> Path: + if platform.system() == "Windows": + import os + appdata = os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming")) + return Path(appdata) / "memsync" / "config.toml" + else: + import os + xdg_config = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")) + return Path(xdg_config) / "memsync" / "config.toml" +``` + +--- + +## Model handling + +The model string is the only config value that will need regular user attention +as Anthropic releases new models. Design for this explicitly: + +**`memsync config set model `** — already in the plan, primary update path. + +**`memsync refresh --model `** — one-off override without touching config. +Useful when a user wants to test a new model before committing, or use a cheaper +model for a quick session without changing their default. + +```python +# In cmd_refresh — merge --model into config before passing to sync +if args.model: + config = dataclasses.replace(config, model=args.model) +result = refresh_memory_content(notes, current_memory, config) +``` + +**Friendly error on bad model string.** The Anthropic API returns a specific error +when a model ID is not found. Catch it and print a useful message: + +```python +except anthropic.BadRequestError as e: + if "model" in str(e).lower(): + print( + f"Error: model '{config.model}' may be unavailable or misspelled.\n" + f"Update with: memsync config set model \n" + f"Current models: https://docs.anthropic.com/en/docs/about-claude/models", + file=sys.stderr, + ) + return 5 + raise +``` + +**`memsync models` command** — v2, not v1. Would call the Anthropic API to list +available models and flag if the configured one is deprecated. Don't build it yet — +note it in CHANGELOG as a planned feature. + +**Valid model strings as of writing (2026-03):** +- `claude-sonnet-4-20250514` — default, best balance of quality and cost +- `claude-opus-4-20250514` — highest quality, higher cost +- `claude-haiku-4-5-20251001` — fastest, lowest cost, fine for simple memory updates + +Users on a budget can set Haiku as their default. The memory refresh prompt is +not complex enough to need Opus for most use cases. + +--- + +## `memsync config` commands + +``` +memsync config show + → prints current config.toml contents + +memsync config set provider icloud + → updates config.provider, saves + +memsync config set model claude-opus-4-20250514 + → updates config.model, saves + +memsync config set sync_root /path/to/custom/folder + → updates config.sync_root, saves + → also sets provider to "custom" automatically + +memsync config set keep_days 60 + → updates config.keep_days, saves +``` + +Valid keys for `memsync config set`: +- `provider` — must be a registered provider name +- `model` — any string (validated on first API call with friendly error) +- `sync_root` — path, must exist +- `claude_md_target` — path to write CLAUDE.md (default: `~/.claude/CLAUDE.md`) +- `max_memory_lines` — integer +- `keep_days` — integer + +--- + +## Notes + +- Config is machine-specific. It lives in `~/.config/` or `%APPDATA%`, NOT in the + sync folder. Two machines can use different providers pointing to the same cloud + storage location — that's fine and expected. + +- The model default (`claude-sonnet-4-20250514`) will rot as Anthropic releases new + models. The intent is for users to update it via `memsync config set model ...` + when they want to upgrade. Do not auto-update the model. Do not pin to a specific + version in code — always read from config. + +- `claude_md_target` defaults to `~/.claude/CLAUDE.md` but is configurable so users + aren't broken if Claude Code ever changes its config location, or if they have a + non-standard setup. Always expand `~` via `.expanduser()` before use. + +- `tomllib` (stdlib, Python 3.11+) is read-only. Writing is done manually via + `_to_toml()`. If the config schema grows significantly, add `tomli_w` as a + dependency. For now, keep the dep count at 1 (anthropic only). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..25c17cc --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,72 @@ +# Contributing to memsync + +Thanks for your interest. memsync is designed to be easy to extend — adding a new provider requires touching exactly one new file and one line in `__init__.py`. + +--- + +## Setup + +```bash +git clone https://github.com/YOUR_USERNAME/memsync +cd memsync +pip install -e ".[dev]" +``` + +Run tests: + +```bash +pytest tests/ -v +``` + +--- + +## Adding a provider + +The most common contribution is adding support for a new cloud storage provider (Dropbox, Box, Synology Drive, etc.). See [docs/adding-a-provider.md](docs/adding-a-provider.md) for a complete guide with a worked example. + +--- + +## Code style + +- Python 3.11+. Use `Path` everywhere, `from __future__ import annotations` at the top of every module. +- Type hints on all functions. +- No dependencies beyond `anthropic` (stdlib only, except dev deps). +- See [STYLE.md](STYLE.md) for the full style guide. + +--- + +## Module boundaries + +Each module has one job: + +- `sync.py` — calls the API, returns text. Does not write files. +- `cli.py` — handles I/O. Does not contain business logic. +- `providers/` — detect paths. Do not create directories or write files. +- `config.py` — loads and saves config. Does not call the API. + +--- + +## Tests + +- All tests use `tmp_path` for filesystem isolation — never touch `~/.config`, `~/.claude`, or any cloud folder. +- All tests mock the Anthropic API — never make real API calls. +- Tests run on macOS, Windows, and Linux via CI. If your change is platform-specific, add a `pytest.mark.skipif` guard. + +--- + +## Pull requests + +- Open an issue first for anything beyond a small bug fix. +- PRs require CI green on all 6 matrix combinations (3 OS × 2 Python versions). +- Squash merge — keep the commit history clean. +- Commit style: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:` prefix, present tense, no period. + +--- + +## Hard rules + +- Never hardcode the model string. Always read from `config.model`. +- Never hardcode paths. Always go through the provider or config system. +- Hard constraints in GLOBAL_MEMORY.md are append-only. This is enforced in Python in `sync.py` — do not remove that check. +- Backups before every write. No exceptions. +- Read [PITFALLS.md](PITFALLS.md) before touching `sync.py` or any provider. diff --git a/DAEMON.md b/DAEMON.md new file mode 100644 index 0000000..8e389d6 --- /dev/null +++ b/DAEMON.md @@ -0,0 +1,801 @@ +# DAEMON.md + +## What this module is + +The daemon is an optional, always-on companion to memsync core. It runs on a +persistent machine (Raspberry Pi, home server, always-on desktop) and automates +the operations that core requires you to trigger manually. + +It is a separate optional install. Core memsync never imports from this module. + +```bash +pip install memsync[daemon] # installs core + daemon extras +``` + +Read `CLAUDE.md` and `ARCHITECTURE.md` before this file — this module extends +that system, it does not replace any of it. + +--- + +## What it does + +| Feature | What it automates | +|---|---| +| Scheduled refresh | Runs `memsync refresh` nightly from session logs — no manual trigger needed | +| Backup mirror | rsync of `.claude-memory/` to a local path hourly — independent of OneDrive | +| Web UI | Browser-based view/edit of `GLOBAL_MEMORY.md` on the local network | +| Capture endpoint | REST endpoint for mobile notes (iPhone Shortcuts, etc.) | +| Drift detection | Alerts when `CLAUDE.md` on any machine is stale vs `GLOBAL_MEMORY.md` | +| Weekly digest | Email summary of the week's session logs and memory changes | + +All features are individually toggleable in config. None are on by default except +scheduled refresh and backup mirror. + +--- + +## Module structure + +``` +memsync/daemon/ +├── __init__.py # version, public API +├── scheduler.py # APScheduler wrapper, job definitions +├── web.py # Flask web UI (view + edit GLOBAL_MEMORY.md) +├── capture.py # REST endpoint for mobile note capture +├── watchdog.py # drift detection between CLAUDE.md and GLOBAL_MEMORY.md +├── digest.py # weekly email digest +├── service.py # systemd (Pi/Linux) and launchd (Mac) service install +└── notify.py # notification abstraction (email, file flag, log) +``` + +--- + +## New CLI commands + +``` +memsync daemon start start the daemon in the foreground (for testing) +memsync daemon start --detach start as background process +memsync daemon stop stop background process +memsync daemon status show running status, last job times, next scheduled runs +memsync daemon install register as system service (auto-starts on boot) +memsync daemon uninstall remove system service registration +memsync daemon schedule show all scheduled jobs and last/next run times +memsync daemon web open web UI in browser (shortcut) +``` + +--- + +## Config additions + +The daemon adds a `[daemon]` section to `config.toml`. Written by `memsync daemon install`, +not present in a core-only install. + +```toml +[daemon] +enabled = true + +# Scheduled refresh +# Reads today's sessions/.md and runs memsync refresh automatically. +# Cron syntax. Default: 11:55pm daily. +refresh_schedule = "55 23 * * *" +refresh_enabled = true + +# Backup mirror +# Independent local copy of .claude-memory/ — not subject to OneDrive sync. +# Empty string = disabled. +backup_mirror_path = "" +backup_mirror_schedule = "0 * * * *" # hourly + +# Web UI +web_ui_enabled = true +web_ui_port = 5000 +web_ui_host = "0.0.0.0" # 0.0.0.0 = accessible on local network + # 127.0.0.1 = localhost only + +# Mobile capture endpoint +capture_enabled = true +capture_port = 5001 +capture_token = "" # optional shared secret for the endpoint + +# Drift detection +drift_check_enabled = true +drift_check_interval_hours = 6 +drift_notify = "log" # "log", "email", or "file" + +# Weekly digest +digest_enabled = false +digest_schedule = "0 9 * * 1" # Monday 9am +digest_email_to = "" +digest_email_from = "" +digest_smtp_host = "" +digest_smtp_port = 587 +digest_smtp_user = "" +digest_smtp_password = "" # consider using keyring instead +``` + +--- + +## scheduler.py + +Uses APScheduler in blocking mode for foreground, background thread mode for detached. + +```python +# memsync/daemon/scheduler.py +from __future__ import annotations + +from pathlib import Path +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.schedulers.blocking import BlockingScheduler +from apscheduler.triggers.cron import CronTrigger + +from memsync.config import Config +from memsync.sync import refresh_memory_content +from memsync.backups import backup + + +def build_scheduler(config: Config, blocking: bool = False): + """ + Build and configure the scheduler from config. + blocking=True for foreground (testing), False for daemon mode. + """ + scheduler = BlockingScheduler() if blocking else BackgroundScheduler() + + if config.daemon.refresh_enabled: + scheduler.add_job( + func=job_nightly_refresh, + trigger=CronTrigger.from_crontab(config.daemon.refresh_schedule), + args=[config], + id="nightly_refresh", + name="Nightly memory refresh", + misfire_grace_time=3600, # run even if missed by up to 1 hour + ) + + if config.daemon.backup_mirror_path: + scheduler.add_job( + func=job_backup_mirror, + trigger=CronTrigger.from_crontab(config.daemon.backup_mirror_schedule), + args=[config], + id="backup_mirror", + name="Backup mirror sync", + misfire_grace_time=3600, + ) + + if config.daemon.drift_check_enabled: + scheduler.add_job( + func=job_drift_check, + trigger="interval", + hours=config.daemon.drift_check_interval_hours, + args=[config], + id="drift_check", + name="CLAUDE.md drift check", + ) + + if config.daemon.digest_enabled: + scheduler.add_job( + func=job_weekly_digest, + trigger=CronTrigger.from_crontab(config.daemon.digest_schedule), + args=[config], + id="weekly_digest", + name="Weekly digest email", + ) + + return scheduler + + +def job_nightly_refresh(config: Config) -> None: + """ + Read today's session log and run a refresh if there are notes. + Silently skips if no session log exists for today. + """ + from datetime import date + from memsync.providers import get_provider + + provider = get_provider(config.provider) + sync_root = config.sync_root or provider.detect() + if not sync_root: + return + + memory_root = provider.get_memory_root(sync_root) + today = date.today().strftime("%Y-%m-%d") + session_log = memory_root / "sessions" / f"{today}.md" + + if not session_log.exists(): + return + + notes = session_log.read_text(encoding="utf-8").strip() + if not notes: + return + + memory_path = memory_root / "GLOBAL_MEMORY.md" + current_memory = memory_path.read_text(encoding="utf-8") + + result = refresh_memory_content(notes, current_memory, config) + + if result["changed"]: + backup(memory_path, memory_root / "backups") + memory_path.write_text(result["updated_content"], encoding="utf-8") + from memsync.claude_md import sync as sync_claude_md + sync_claude_md(memory_path, config.claude_md_target) + + +def job_backup_mirror(config: Config) -> None: + """rsync .claude-memory/ to the local mirror path.""" + import shutil + from memsync.providers import get_provider + + provider = get_provider(config.provider) + sync_root = config.sync_root or provider.detect() + if not sync_root: + return + + memory_root = provider.get_memory_root(sync_root) + mirror = Path(config.daemon.backup_mirror_path).expanduser() + mirror.mkdir(parents=True, exist_ok=True) + + # Copy all files, preserve timestamps + for src in memory_root.rglob("*"): + if src.is_file(): + rel = src.relative_to(memory_root) + dst = mirror / rel + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + + +def job_drift_check(config: Config) -> None: + """Check if CLAUDE.md is stale relative to GLOBAL_MEMORY.md.""" + from memsync.claude_md import is_synced + from memsync.providers import get_provider + from memsync.daemon.notify import notify + + provider = get_provider(config.provider) + sync_root = config.sync_root or provider.detect() + if not sync_root: + return + + memory_root = provider.get_memory_root(sync_root) + memory_path = memory_root / "GLOBAL_MEMORY.md" + + if not is_synced(memory_path, config.claude_md_target): + notify( + config, + subject="memsync: CLAUDE.md is out of sync", + body=( + f"CLAUDE.md at {config.claude_md_target} does not match " + f"GLOBAL_MEMORY.md at {memory_path}.\n" + f"Run: memsync refresh to resync." + ), + ) + + +def job_weekly_digest(config: Config) -> None: + """Generate and email a weekly digest of session logs.""" + from memsync.daemon.digest import generate_and_send + generate_and_send(config) +``` + +--- + +## web.py + +Simple Flask app. Read-only view by default, edit mode behind a confirmation. +Accessible on the local network at `http://pi.local:5000` (or whatever the +Pi's hostname is). + +```python +# memsync/daemon/web.py +from __future__ import annotations + +from pathlib import Path +from flask import Flask, render_template_string, request, redirect, url_for + +from memsync.config import Config +from memsync.backups import backup +from memsync.claude_md import sync as sync_claude_md + +# Inline template — no separate template files needed for this simple UI +TEMPLATE = """ + + + + memsync — Global Memory + + + + +

Global Memory

+
+ {{ memory_path }}
+ Last modified: {{ last_modified }} + {% if message %} — {{ message }}{% endif %} +
+
+ +
+ + Cancel +
+
+ + +""" + + +def create_app(config: Config) -> Flask: + app = Flask(__name__) + app.config["MEMSYNC_CONFIG"] = config + + def get_memory_path() -> Path: + from memsync.providers import get_provider + provider = get_provider(config.provider) + sync_root = config.sync_root or provider.detect() + return provider.get_memory_root(sync_root) / "GLOBAL_MEMORY.md" + + @app.get("/") + def index(): + path = get_memory_path() + content = path.read_text(encoding="utf-8") if path.exists() else "" + import datetime + last_mod = ( + datetime.datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M") + if path.exists() else "never" + ) + return render_template_string( + TEMPLATE, + content=content, + memory_path=path, + last_modified=last_mod, + message=request.args.get("message", ""), + message_class=request.args.get("cls", "saved"), + ) + + @app.post("/save") + def save(): + path = get_memory_path() + new_content = request.form["content"] + try: + if path.exists(): + backup(path, path.parent / "backups") + path.write_text(new_content, encoding="utf-8") + sync_claude_md(path, config.claude_md_target) + return redirect("/?message=Saved+successfully&cls=saved") + except Exception as e: + return redirect(f"/?message=Error:+{e}&cls=error") + + return app + + +def run_web(config: Config) -> None: + app = create_app(config) + app.run( + host=config.daemon.web_ui_host, + port=config.daemon.web_ui_port, + debug=False, + ) +``` + +--- + +## capture.py + +Minimal REST endpoint. Accepts a POST with a note string, appends to today's +session log. Designed for iPhone Shortcuts or any HTTP client. + +```python +# memsync/daemon/capture.py +from __future__ import annotations + +from datetime import datetime +from pathlib import Path + +from flask import Flask, request, jsonify + +from memsync.config import Config + + +def create_capture_app(config: Config) -> Flask: + app = Flask(__name__) + + def get_session_log() -> Path: + from memsync.providers import get_provider + provider = get_provider(config.provider) + sync_root = config.sync_root or provider.detect() + memory_root = provider.get_memory_root(sync_root) + today = datetime.now().strftime("%Y-%m-%d") + return memory_root / "sessions" / f"{today}.md" + + def check_token() -> bool: + token = config.daemon.capture_token + if not token: + return True # no auth configured — accept all (local network only) + return request.headers.get("X-Memsync-Token") == token + + @app.post("/note") + def add_note(): + if not check_token(): + return jsonify({"error": "unauthorized"}), 401 + + body = request.get_json(silent=True) + if not body or "text" not in body: + return jsonify({"error": "missing 'text' field"}), 400 + + text = body["text"].strip() + if not text: + return jsonify({"error": "empty note"}), 400 + + log_path = get_session_log() + log_path.parent.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now().strftime("%H:%M:%S") + + with open(log_path, "a", encoding="utf-8") as f: + f.write(f"\n---\n### {timestamp} (captured)\n{text}\n") + + return jsonify({"ok": True, "timestamp": timestamp}) + + @app.get("/health") + def health(): + return jsonify({"ok": True}) + + return app + + +def run_capture(config: Config) -> None: + app = create_capture_app(config) + app.run( + host="0.0.0.0", # always local-network accessible + port=config.daemon.capture_port, + debug=False, + ) +``` + +**iPhone Shortcut setup:** Create a "Get Contents of URL" action with: +- URL: `http://pi.local:5001/note` +- Method: POST +- Headers: `X-Memsync-Token: ` (if configured) +- Body JSON: `{"text": "Shortcut Input"}` + +--- + +## service.py + +Installs memsync daemon as a system service so it starts on boot. + +```python +# memsync/daemon/service.py +from __future__ import annotations + +import platform +import subprocess +from pathlib import Path + +from memsync.config import get_config_path + + +SYSTEMD_UNIT = """\ +[Unit] +Description=memsync daemon +After=network.target + +[Service] +Type=simple +ExecStart={memsync_bin} daemon start +Restart=on-failure +RestartSec=10 +Environment=ANTHROPIC_API_KEY={api_key_placeholder} + +[Install] +WantedBy=multi-user.target +""" + +LAUNCHD_PLIST = """\ + + + + + Label + com.memsync.daemon + ProgramArguments + + {memsync_bin} + daemon + start + + RunAtLoad + + KeepAlive + + StandardOutPath + {log_dir}/memsync-daemon.log + StandardErrorPath + {log_dir}/memsync-daemon.err + + +""" + + +def install_service() -> None: + system = platform.system() + memsync_bin = _find_memsync_bin() + + if system == "Linux": + _install_systemd(memsync_bin) + elif system == "Darwin": + _install_launchd(memsync_bin) + else: + raise NotImplementedError( + "Service install not supported on Windows. " + "Run 'memsync daemon start --detach' from Task Scheduler instead." + ) + + +def uninstall_service() -> None: + system = platform.system() + if system == "Linux": + _uninstall_systemd() + elif system == "Darwin": + _uninstall_launchd() + + +def _install_systemd(memsync_bin: str) -> None: + unit_path = Path("/etc/systemd/system/memsync.service") + unit_content = SYSTEMD_UNIT.format( + memsync_bin=memsync_bin, + api_key_placeholder="", + ) + unit_path.write_text(unit_content) + subprocess.run(["systemctl", "daemon-reload"], check=True) + subprocess.run(["systemctl", "enable", "memsync"], check=True) + subprocess.run(["systemctl", "start", "memsync"], check=True) + print(f"Service installed: {unit_path}") + print("Set ANTHROPIC_API_KEY in the unit file, then: systemctl restart memsync") + + +def _install_launchd(memsync_bin: str) -> None: + log_dir = Path.home() / "Library" / "Logs" / "memsync" + log_dir.mkdir(parents=True, exist_ok=True) + plist_path = Path.home() / "Library" / "LaunchAgents" / "com.memsync.daemon.plist" + plist_content = LAUNCHD_PLIST.format(memsync_bin=memsync_bin, log_dir=log_dir) + plist_path.write_text(plist_content) + subprocess.run(["launchctl", "load", str(plist_path)], check=True) + print(f"Service installed: {plist_path}") + + +def _uninstall_systemd() -> None: + subprocess.run(["systemctl", "stop", "memsync"], check=False) + subprocess.run(["systemctl", "disable", "memsync"], check=False) + unit_path = Path("/etc/systemd/system/memsync.service") + if unit_path.exists(): + unit_path.unlink() + subprocess.run(["systemctl", "daemon-reload"], check=True) + print("Service removed.") + + +def _uninstall_launchd() -> None: + plist_path = Path.home() / "Library" / "LaunchAgents" / "com.memsync.daemon.plist" + if plist_path.exists(): + subprocess.run(["launchctl", "unload", str(plist_path)], check=False) + plist_path.unlink() + print("Service removed.") + + +def _find_memsync_bin() -> str: + import shutil + bin_path = shutil.which("memsync") + if not bin_path: + raise FileNotFoundError( + "memsync not found in PATH. Install with: pip install memsync[daemon]" + ) + return bin_path +``` + +--- + +## notify.py + +Abstraction so watchdog and digest can send alerts without caring about the channel. + +```python +# memsync/daemon/notify.py +from __future__ import annotations + +import logging +from memsync.config import Config + +logger = logging.getLogger("memsync.daemon") + + +def notify(config: Config, subject: str, body: str) -> None: + """ + Send a notification via the configured channel. + Channels: "log" (default), "email", "file" + Never raises — notification failure should not crash the daemon. + """ + try: + match config.daemon.drift_notify: + case "email": + _send_email(config, subject, body) + case "file": + _write_flag_file(config, subject, body) + case _: + logger.warning("%s: %s", subject, body) + except Exception as e: + logger.error("Notification failed: %s", e) + + +def _send_email(config: Config, subject: str, body: str) -> None: + import smtplib + from email.message import EmailMessage + + msg = EmailMessage() + msg["Subject"] = subject + msg["From"] = config.daemon.digest_email_from + msg["To"] = config.daemon.digest_email_to + msg.set_content(body) + + with smtplib.SMTP(config.daemon.digest_smtp_host, config.daemon.digest_smtp_port) as smtp: + smtp.starttls() + smtp.login(config.daemon.digest_smtp_user, config.daemon.digest_smtp_password) + smtp.send_message(msg) + + +def _write_flag_file(config: Config, subject: str, body: str) -> None: + from pathlib import Path + from datetime import datetime + + flag_dir = Path.home() / ".config" / "memsync" / "alerts" + flag_dir.mkdir(parents=True, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + flag_file = flag_dir / f"{ts}_alert.txt" + flag_file.write_text(f"{subject}\n\n{body}\n", encoding="utf-8") +``` + +--- + +## digest.py + +Weekly email summarizing what changed in the memory file and what was logged. + +```python +# memsync/daemon/digest.py +from __future__ import annotations + +from datetime import date, timedelta +from pathlib import Path + +import anthropic + +from memsync.config import Config + + +def generate_and_send(config: Config) -> None: + """Generate a weekly digest and send via configured email.""" + from memsync.providers import get_provider + from memsync.daemon.notify import _send_email + + provider = get_provider(config.provider) + sync_root = config.sync_root or provider.detect() + if not sync_root: + return + + memory_root = provider.get_memory_root(sync_root) + digest_text = generate_digest(memory_root, config) + + if digest_text: + _send_email( + config, + subject=f"memsync weekly digest — week of {date.today().strftime('%b %d')}", + body=digest_text, + ) + + +def generate_digest(memory_root: Path, config: Config) -> str: + """ + Collect this week's session logs and generate a plain-text summary + via the Claude API. + """ + today = date.today() + week_ago = today - timedelta(days=7) + + session_logs = [] + for i in range(7): + day = week_ago + timedelta(days=i + 1) + log_path = memory_root / "sessions" / f"{day.strftime('%Y-%m-%d')}.md" + if log_path.exists(): + session_logs.append(f"## {day.strftime('%A %b %d')}\n{log_path.read_text(encoding='utf-8')}") + + if not session_logs: + return "" + + all_notes = "\n\n".join(session_logs) + + client = anthropic.Anthropic() + response = client.messages.create( + model=config.model, + max_tokens=1000, + system=( + "You are summarizing a week of AI assistant session notes for the user. " + "Write a brief, plain-text weekly summary: what they worked on, " + "any notable decisions or completions, and anything that seems worth " + "following up on. 150-250 words. No headers. Direct and useful." + ), + messages=[{"role": "user", "content": all_notes}], + ) + + return response.content[0].text.strip() +``` + +--- + +## Pitfalls specific to the daemon + +### API key in systemd unit file +The systemd unit template includes a placeholder for `ANTHROPIC_API_KEY`. +Storing secrets in unit files is not ideal — they're world-readable by default. +Document that users should use `systemctl edit memsync` to add the key in an +override file, or use a secrets manager. Do not store keys in the repo. + +### Flask in production +The Flask dev server (`app.run()`) is fine for local network use on a Pi. +Do not suggest or document using it as a public-facing server. If a user +asks about exposing it to the internet, redirect them to proper WSGI + auth. + +### Port conflicts +5000 and 5001 are common dev ports. Document that they're configurable and +how to change them if there's a conflict. + +### systemd on Pi requires sudo +`systemctl enable` and the unit file write require root. The install function +will fail without it. Print a clear error and suggest `sudo memsync daemon install`. + +### APScheduler job persistence +APScheduler by default runs jobs in memory — if the daemon restarts, job +history is lost. That's fine for memsync (jobs are time-based, not state-based). +Do not add a job store database — it's unnecessary complexity. + +### Nightly refresh with empty session log +If the user didn't run any sessions that day, `sessions/.md` won't exist. +`job_nightly_refresh` handles this with an early return. Make sure this stays +in place — an empty notes payload to the API wastes tokens and may produce +hallucinated changes. + +--- + +## Build order for daemon module + +Do this after core memsync is complete and tested. + +1. `DaemonConfig` dataclass additions to `config.py` +2. `scheduler.py` + `notify.py` — the backbone +3. `web.py` — Flask UI +4. `capture.py` — REST endpoint +5. `service.py` — system service install +6. `digest.py` — weekly email (depends on notify) +7. Tests for scheduler jobs (mock filesystem + mock API) +8. Tests for web UI (Flask test client) +9. Tests for capture endpoint (Flask test client) +10. Update `pyproject.toml` with `[daemon]` optional dependencies +11. Update `REPO.md` directory structure +12. Update README with daemon section + +--- + +## pyproject.toml additions + +```toml +[project.optional-dependencies] +daemon = [ + "apscheduler>=3.10", + "flask>=3.0", +] +dev = [ + "pytest>=8.0", + "pytest-mock>=3.12", + "ruff>=0.4", +] +``` diff --git a/DAEMON_CONFIG.md b/DAEMON_CONFIG.md new file mode 100644 index 0000000..7710cd3 --- /dev/null +++ b/DAEMON_CONFIG.md @@ -0,0 +1,176 @@ +# DAEMON_CONFIG.md + +## What this file is + +Additions to `memsync/config.py` needed to support the daemon module. +Do not apply these until core memsync is complete and tested. +These changes are additive — nothing in the existing Config dataclass changes. + +--- + +## DaemonConfig dataclass + +Add this class to `memsync/config.py` alongside the existing `Config`: + +```python +@dataclass +class DaemonConfig: + """ + Configuration for the optional daemon module. + Only present in config.toml if the user has run 'memsync daemon install'. + """ + enabled: bool = True + + # Scheduled refresh + refresh_enabled: bool = True + refresh_schedule: str = "55 23 * * *" # 11:55pm daily + + # Backup mirror + backup_mirror_path: str = "" # empty = disabled + backup_mirror_schedule: str = "0 * * * *" # hourly + + # Web UI + web_ui_enabled: bool = True + web_ui_port: int = 5000 + web_ui_host: str = "0.0.0.0" + + # Capture endpoint + capture_enabled: bool = True + capture_port: int = 5001 + capture_token: str = "" # empty = no auth + + # Drift detection + drift_check_enabled: bool = True + drift_check_interval_hours: int = 6 + drift_notify: str = "log" # "log", "email", or "file" + + # Weekly digest + digest_enabled: bool = False + digest_schedule: str = "0 9 * * 1" # Monday 9am + digest_email_to: str = "" + digest_email_from: str = "" + digest_smtp_host: str = "" + digest_smtp_port: int = 587 + digest_smtp_user: str = "" + digest_smtp_password: str = "" +``` + +--- + +## Config dataclass update + +Add `daemon` field to the existing `Config` dataclass: + +```python +@dataclass +class Config: + # ... existing fields unchanged ... + + # Optional daemon config — only populated if [daemon] section exists in config.toml + daemon: DaemonConfig = field(default_factory=DaemonConfig) +``` + +--- + +## _from_dict update + +Add daemon section parsing to `Config._from_dict()`: + +```python +@classmethod +def _from_dict(cls, raw: dict) -> "Config": + # ... existing parsing unchanged ... + + daemon_raw = raw.get("daemon", {}) + daemon = DaemonConfig( + enabled=daemon_raw.get("enabled", True), + refresh_enabled=daemon_raw.get("refresh_enabled", True), + refresh_schedule=daemon_raw.get("refresh_schedule", "55 23 * * *"), + backup_mirror_path=daemon_raw.get("backup_mirror_path", ""), + backup_mirror_schedule=daemon_raw.get("backup_mirror_schedule", "0 * * * *"), + web_ui_enabled=daemon_raw.get("web_ui_enabled", True), + web_ui_port=daemon_raw.get("web_ui_port", 5000), + web_ui_host=daemon_raw.get("web_ui_host", "0.0.0.0"), + capture_enabled=daemon_raw.get("capture_enabled", True), + capture_port=daemon_raw.get("capture_port", 5001), + capture_token=daemon_raw.get("capture_token", ""), + drift_check_enabled=daemon_raw.get("drift_check_enabled", True), + drift_check_interval_hours=daemon_raw.get("drift_check_interval_hours", 6), + drift_notify=daemon_raw.get("drift_notify", "log"), + digest_enabled=daemon_raw.get("digest_enabled", False), + digest_schedule=daemon_raw.get("digest_schedule", "0 9 * * 1"), + digest_email_to=daemon_raw.get("digest_email_to", ""), + digest_email_from=daemon_raw.get("digest_email_from", ""), + digest_smtp_host=daemon_raw.get("digest_smtp_host", ""), + digest_smtp_port=daemon_raw.get("digest_smtp_port", 587), + digest_smtp_user=daemon_raw.get("digest_smtp_user", ""), + digest_smtp_password=daemon_raw.get("digest_smtp_password", ""), + ) + + return cls( + # ... existing fields unchanged ... + daemon=daemon, + ) +``` + +--- + +## _to_toml update + +Add daemon section to `Config._to_toml()`. +Only write the `[daemon]` section if `daemon.enabled` is True +(i.e. user has run `memsync daemon install`): + +```python +def _to_toml(self) -> str: + # ... existing lines unchanged ... + + if self.daemon.enabled: + lines += [ + "", + "[daemon]", + f"enabled = {str(self.daemon.enabled).lower()}", + f'refresh_schedule = "{self.daemon.refresh_schedule}"', + f"refresh_enabled = {str(self.daemon.refresh_enabled).lower()}", + f'backup_mirror_path = "{self.daemon.backup_mirror_path}"', + f'backup_mirror_schedule = "{self.daemon.backup_mirror_schedule}"', + f"web_ui_enabled = {str(self.daemon.web_ui_enabled).lower()}", + f"web_ui_port = {self.daemon.web_ui_port}", + f'web_ui_host = "{self.daemon.web_ui_host}"', + f"capture_enabled = {str(self.daemon.capture_enabled).lower()}", + f"capture_port = {self.daemon.capture_port}", + f'capture_token = "{self.daemon.capture_token}"', + f"drift_check_enabled = {str(self.daemon.drift_check_enabled).lower()}", + f"drift_check_interval_hours = {self.daemon.drift_check_interval_hours}", + f'drift_notify = "{self.daemon.drift_notify}"', + f"digest_enabled = {str(self.daemon.digest_enabled).lower()}", + f'digest_schedule = "{self.daemon.digest_schedule}"', + f'digest_email_to = "{self.daemon.digest_email_to}"', + f'digest_email_from = "{self.daemon.digest_email_from}"', + f'digest_smtp_host = "{self.daemon.digest_smtp_host}"', + f"digest_smtp_port = {self.daemon.digest_smtp_port}", + f'digest_smtp_user = "{self.daemon.digest_smtp_user}"', + f'digest_smtp_password = "{self.daemon.digest_smtp_password}"', + "", + ] + + return "\n".join(lines) +``` + +--- + +## Important: SMTP password handling + +Storing SMTP passwords in a plaintext config file is not ideal. +For v1 it's acceptable with a clear warning, but note in the README: + +> For better security, leave `digest_smtp_password` empty and use an +> app-specific password stored in your system keyring instead. +> Set it at runtime with: `MEMSYNC_SMTP_PASSWORD=... memsync daemon start` + +Add `MEMSYNC_SMTP_PASSWORD` env var support as a fallback in `notify.py`: + +```python +import os +password = config.daemon.digest_smtp_password or os.environ.get("MEMSYNC_SMTP_PASSWORD", "") +``` diff --git a/DAEMON_PITFALLS.md b/DAEMON_PITFALLS.md new file mode 100644 index 0000000..07b70a4 --- /dev/null +++ b/DAEMON_PITFALLS.md @@ -0,0 +1,138 @@ +# DAEMON_PITFALLS.md + +Daemon-specific pitfalls on top of the core ones in `PITFALLS.md`. +Read both before building the daemon module. + +--- + +## 1. Core module boundary is sacred + +The daemon imports from core. Core never imports from daemon. + +If you find yourself adding a daemon import to `sync.py`, `config.py`, +`backups.py`, or any other core module — stop. Restructure so the daemon +calls core, not the other way around. Violating this boundary means +`pip install memsync` (core only) pulls in daemon dependencies. + +--- + +## 2. The nightly refresh job must handle missing session logs gracefully + +If the user didn't run any sessions that day, `sessions/.md` won't exist. +`job_nightly_refresh` returns early if the file doesn't exist or is empty. +This is already in the spec — do not remove this guard. An empty notes +payload to the API wastes tokens and risks producing hallucinated changes. + +--- + +## 3. systemd unit file and API key exposure + +The generated systemd unit file includes `Environment=ANTHROPIC_API_KEY=...` +as a placeholder. Unit files in `/etc/systemd/system/` are world-readable by default. + +Two mitigations to document clearly: +- Use `systemctl edit memsync` to create a drop-in override file (mode 600) +- Use `EnvironmentFile=/etc/memsync/secrets` pointing to a mode 600 file + +Do not suggest storing the real key in the main unit file. +Print a prominent warning after `memsync daemon install` on Linux. + +--- + +## 4. Flask dev server is fine for local network, not for internet exposure + +`app.run()` is the Flask development server. It's single-threaded and has no +auth. This is acceptable for a Pi on a home LAN. It is not acceptable for any +internet-facing deployment. + +If a user asks about exposing the web UI to the internet: +- Tell them this is out of scope for v1 +- Point them toward nginx + basic auth as a general approach +- Do not add this to the tool itself + +--- + +## 5. Port conflicts on common dev machines + +5000 is used by AirPlay Receiver on Mac (macOS 12+) and many dev servers. +5001 is also commonly used. Document both ports as configurable. + +On Mac, if `web_ui_host = "0.0.0.0"` and port 5000 is taken by AirPlay, +the web UI will silently fail to start or throw a bind error. Print a clear +error message pointing to `memsync config set web_ui_port `. + +--- + +## 6. APScheduler timezone handling + +APScheduler uses local system time by default. On a Pi, make sure the system +timezone is set correctly (`timedatectl set-timezone America/New_York` or +wherever the user is). The nightly refresh at 11:55pm will fire at 11:55pm +in the Pi's system timezone, which may not match the user's timezone if the +Pi was set up with UTC (the default on many Pi images). + +Document this in the Pi setup guide. Add a note to `memsync daemon install` +output: "Make sure your Pi's timezone is set correctly: `timedatectl`" + +--- + +## 7. OneDrive sync lag on the Pi + +If the Pi has OneDrive mounted (via rclone or similar), there may be sync lag +between when `GLOBAL_MEMORY.md` is updated on a Mac/Windows machine and when +the Pi sees the change. The nightly refresh reads the file at job time — +if OneDrive hasn't synced yet, it reads a stale version. + +This is an inherent limitation of filesystem-based sync. Document it. +Workaround: schedule the nightly refresh a few minutes after midnight rather +than 11:55pm, giving OneDrive time to sync the day's changes before the +Pi reads them. + +--- + +## 8. The backup mirror is not a substitute for OneDrive + +The `job_backup_mirror` rsync copies files from the OneDrive-synced +`.claude-memory/` to a local path. It's a redundant local backup in case +OneDrive has an outage or the user accidentally deletes something in OneDrive. + +It is not a real-time mirror. It runs on a schedule (default: hourly). +Document this limitation clearly — it's not a safety net for changes made +in the last hour. + +--- + +## 9. Digest email and SMTP credentials + +SMTP credentials in a config file are a security concern. The v1 approach +(plaintext in config.toml) is acceptable with a warning, but: + +- Never log or print SMTP credentials +- Support `MEMSYNC_SMTP_PASSWORD` env var as an alternative (see DAEMON_CONFIG.md) +- Document that Gmail requires an App Password, not the account password +- Document that many ISPs block outbound port 587 — common user frustration + +--- + +## 10. Test isolation for daemon jobs + +Daemon jobs touch the filesystem and call the API. Tests must mock both. +Never let a test job run `job_nightly_refresh` against a real filesystem +or make a real API call. Use `tmp_path` and `unittest.mock.patch` throughout. + +For Flask tests, use the Flask test client — never bind to a real port in tests. + +```python +# Good +def test_capture_endpoint(tmp_config): + from memsync.daemon.capture import create_capture_app + config, tmp_path = tmp_config + app = create_capture_app(config) + client = app.test_client() + response = client.post("/note", json={"text": "test note"}) + assert response.status_code == 200 + +# Bad — binds to real port, can conflict with other tests +def test_capture_endpoint(): + run_capture(config) # never do this in tests +``` diff --git a/EXISTING_CODE.md b/EXISTING_CODE.md new file mode 100644 index 0000000..fa726fc --- /dev/null +++ b/EXISTING_CODE.md @@ -0,0 +1,526 @@ +# EXISTING_CODE.md + +This is the working prototype built before the architecture was formalized. +Use this as the foundation — refactor it to fit the target architecture +described in ARCHITECTURE.md. Do not rewrite from scratch. + +The prototype works and has been designed with the final architecture in mind. +The main gaps are: no provider abstraction, no config system, hardcoded model string. + +--- + +## memsync/paths.py (prototype) + +This becomes the provider system. Replace with `memsync/providers/`. +The detection logic here is correct and tested — migrate it into +`OneDriveProvider.detect()`. + +```python +""" +Path resolution for memsync. +Handles Mac, Windows, and OneDrive sync layer. +""" + +import os +import platform +from pathlib import Path + + +def get_platform() -> str: + system = platform.system() + if system == "Darwin": + return "mac" + elif system == "Windows": + return "windows" + else: + return "linux" + + +def get_onedrive_root() -> Path: + """ + Resolve the OneDrive root directory cross-platform. + Checks env vars first (most reliable), then common default paths. + """ + if get_platform() == "windows": + onedrive = os.environ.get("OneDrive") or os.environ.get("ONEDRIVE") + if onedrive: + return Path(onedrive) + candidates = [ + Path.home() / "OneDrive", + Path("C:/Users") / os.environ.get("USERNAME", "") / "OneDrive", + ] + else: + candidates = [ + Path.home() / "OneDrive", + Path.home() / "Library" / "CloudStorage" / "OneDrive-Personal", + ] + cloud_storage = Path.home() / "Library" / "CloudStorage" + if cloud_storage.exists(): + for d in cloud_storage.iterdir(): + if d.name.startswith("OneDrive"): + candidates.insert(0, d) + + for path in candidates: + if path.exists(): + return path + + raise FileNotFoundError( + "OneDrive directory not found. " + "Set MEMSYNC_ONEDRIVE env var to your OneDrive path." + ) + + +def get_memory_paths() -> dict[str, Path]: + """ + Returns all relevant paths for memsync. + MEMSYNC_ONEDRIVE env var overrides auto-detection. + """ + onedrive_override = os.environ.get("MEMSYNC_ONEDRIVE") + onedrive_root = Path(onedrive_override) if onedrive_override else get_onedrive_root() + + memory_root = onedrive_root / ".claude-memory" + + if get_platform() == "windows": + claude_config = Path.home() / ".claude" + else: + claude_config = Path.home() / ".claude" + + return { + "onedrive_root": onedrive_root, + "memory_root": memory_root, + "global_memory": memory_root / "GLOBAL_MEMORY.md", + "backups": memory_root / "backups", + "session_log": memory_root / "sessions", + "claude_config": claude_config, + "claude_md": claude_config / "CLAUDE.md", + } + + +def ensure_directories(paths: dict[str, Path]) -> None: + for key in ("memory_root", "backups", "session_log"): + paths[key].mkdir(parents=True, exist_ok=True) +``` + +--- + +## memsync/sync.py (prototype) + +The core API call and compaction logic. Migrate this into the new `sync.py` +but pull `model` from config instead of hardcoding it. +The system prompt here is the result of iteration — don't change it lightly. +See PITFALLS.md for why specific lines are the way they are. + +```python +""" +Memory refresh logic. +Calls Claude API to merge session notes into GLOBAL_MEMORY.md. +""" + +import shutil +from datetime import datetime +from pathlib import Path + +import anthropic + +from .paths import get_memory_paths, ensure_directories + +SYSTEM_PROMPT = """You are maintaining a persistent global memory file for an AI assistant user. +This file is loaded at the start of every Claude Code session, on every machine and project. +It is the user's identity layer — not project docs, not cold storage. + +YOUR JOB: +- Merge new session notes into the existing memory file +- Keep the file tight (under 400 lines) +- Update facts that have changed +- Demote completed items from "Current priorities" to a brief "Recent completions" section +- Preserve the user's exact voice, formatting, and section structure +- NEVER remove entries under any "Hard constraints" or "Constraints" section — only append +- If nothing meaningful changed, return the file UNCHANGED + +RETURN: Only the updated GLOBAL_MEMORY.md content. No explanation, no preamble.""" + + +def load_or_init_memory(path: Path) -> str: + if path.exists(): + return path.read_text(encoding="utf-8") + + return """\ +# Global Memory + +> Loaded by Claude Code at session start on all machines and projects. +> Edit directly or run: memsync refresh --notes "..." + +## Identity & context +- (Fill this in — who you are, your roles, active projects) + +## Current priorities +- (What you're working on right now) + +## Standing preferences +- (How you like to work — communication style, output format, etc.) + +## Hard constraints +- (Rules that must never be lost or softened through compaction) +""" + + +def backup_memory(memory_path: Path, backup_dir: Path) -> Path: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_path = backup_dir / f"GLOBAL_MEMORY_{timestamp}.md" + shutil.copy2(memory_path, backup_path) + return backup_path + + +def refresh_memory(notes: str, dry_run: bool = False) -> dict: + paths = get_memory_paths() + ensure_directories(paths) + + current_memory = load_or_init_memory(paths["global_memory"]) + + client = anthropic.Anthropic() + + user_prompt = f"""\ +CURRENT GLOBAL MEMORY: +{current_memory} + +SESSION NOTES: +{notes}""" + + response = client.messages.create( + model="claude-sonnet-4-20250514", # ← HARDCODED: move to config in refactor + max_tokens=4000, + system=SYSTEM_PROMPT, + messages=[{"role": "user", "content": user_prompt}], + ) + + updated_content = response.content[0].text.strip() + changed = updated_content != current_memory.strip() + + if dry_run: + return { + "updated_content": updated_content, + "backup_path": None, + "changed": changed, + "dry_run": True, + } + + backup_path = None + if paths["global_memory"].exists() and changed: + backup_path = backup_memory(paths["global_memory"], paths["backups"]) + + paths["global_memory"].write_text(updated_content, encoding="utf-8") + sync_to_claude_md(paths) + log_session_notes(notes, paths["session_log"]) + + return { + "updated_content": updated_content, + "backup_path": backup_path, + "changed": changed, + "dry_run": False, + } + + +def sync_to_claude_md(paths: dict) -> None: + """ + Keep ~/.claude/CLAUDE.md in sync with the OneDrive master. + Mac/Linux: symlink. Windows: copy. + """ + import platform + + source = paths["global_memory"] + dest = paths["claude_md"] + + dest.parent.mkdir(parents=True, exist_ok=True) + + if platform.system() == "Windows": + shutil.copy2(source, dest) + return + + if dest.is_symlink(): + if dest.resolve() == source.resolve(): + return + dest.unlink() + + if dest.exists(): + dest.rename(dest.with_suffix(".pre-memsync.bak")) + + try: + dest.symlink_to(source) + except OSError: + shutil.copy2(source, dest) + + +def log_session_notes(notes: str, session_dir: Path) -> None: + today = datetime.now().strftime("%Y-%m-%d") + log_path = session_dir / f"{today}.md" + timestamp = datetime.now().strftime("%H:%M:%S") + + with open(log_path, "a", encoding="utf-8") as f: + f.write(f"\n---\n### {timestamp}\n{notes}\n") + + +def prune_backups(backup_dir: Path, keep_days: int = 30) -> list[Path]: + from datetime import timedelta + + cutoff = datetime.now() - timedelta(days=keep_days) + deleted = [] + + for backup in backup_dir.glob("GLOBAL_MEMORY_*.md"): + try: + ts_str = backup.stem.replace("GLOBAL_MEMORY_", "") + ts = datetime.strptime(ts_str, "%Y%m%d_%H%M%S") + if ts < cutoff: + backup.unlink() + deleted.append(backup) + except ValueError: + pass + + return deleted +``` + +--- + +## memsync/cli.py (prototype) + +The full CLI. Refactor to pass `config` into each command function +and replace direct path dict calls with provider + config resolution. + +```python +""" +memsync CLI — see COMMANDS.md for full spec. +""" + +import sys +import argparse +from pathlib import Path + +from .paths import get_memory_paths, ensure_directories, get_platform +from .sync import refresh_memory, prune_backups, load_or_init_memory + + +def cmd_refresh(args: argparse.Namespace) -> int: + notes = "" + + if args.notes: + notes = args.notes + elif args.file: + note_path = Path(args.file) + if not note_path.exists(): + print(f"Error: file not found: {args.file}", file=sys.stderr) + return 1 + notes = note_path.read_text(encoding="utf-8") + else: + if not sys.stdin.isatty(): + notes = sys.stdin.read() + else: + print("Error: provide --notes, --file, or pipe notes via stdin.", file=sys.stderr) + return 1 + + if not notes.strip(): + print("Error: notes are empty.", file=sys.stderr) + return 1 + + print("Refreshing global memory...", end=" ", flush=True) + result = refresh_memory(notes, dry_run=args.dry_run) + + if args.dry_run: + print("\n[DRY RUN] No files written.\n") + if result["changed"]: + print("Changes detected. Updated content:") + print("─" * 60) + print(result["updated_content"]) + else: + print("No changes detected.") + return 0 + + if result["changed"]: + print("done.") + if result["backup_path"]: + print(f" Backup: {result['backup_path']}") + paths = get_memory_paths() + print(f" Memory: {paths['global_memory']}") + print(f" CLAUDE.md: {paths['claude_md']}") + else: + print("no changes.") + + return 0 + + +def cmd_status(args: argparse.Namespace) -> int: + try: + paths = get_memory_paths() + except FileNotFoundError as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + print(f"Platform: {get_platform()}") + print(f"OneDrive root: {paths['onedrive_root']}") + print(f"Memory file: {paths['global_memory']} ", end="") + print("✓" if paths["global_memory"].exists() else "✗ (not created yet)") + print(f"CLAUDE.md: {paths['claude_md']} ", end="") + + claude_md = paths["claude_md"] + if claude_md.is_symlink(): + print(f"→ symlink to {claude_md.resolve()}") + elif claude_md.exists(): + print("✓ (copy)") + else: + print("✗ (not synced)") + + backup_dir = paths["backups"] + if backup_dir.exists(): + backups = list(backup_dir.glob("GLOBAL_MEMORY_*.md")) + print(f"Backups: {len(backups)} file(s) in {backup_dir}") + + session_dir = paths["session_log"] + if session_dir.exists(): + sessions = list(session_dir.glob("*.md")) + print(f"Session logs: {len(sessions)} day(s) logged in {session_dir}") + + return 0 + + +def cmd_show(args: argparse.Namespace) -> int: + paths = get_memory_paths() + if not paths["global_memory"].exists(): + print("No global memory file yet. Run: memsync init") + return 1 + print(paths["global_memory"].read_text(encoding="utf-8")) + return 0 + + +def cmd_diff(args: argparse.Namespace) -> int: + import difflib + + paths = get_memory_paths() + backup_dir = paths["backups"] + + if not paths["global_memory"].exists(): + print("No global memory file yet.") + return 1 + + backups = sorted(backup_dir.glob("GLOBAL_MEMORY_*.md")) + if not backups: + print("No backups found.") + return 0 + + latest_backup = backups[-1] + current = paths["global_memory"].read_text(encoding="utf-8").splitlines(keepends=True) + previous = latest_backup.read_text(encoding="utf-8").splitlines(keepends=True) + + diff = list(difflib.unified_diff( + previous, current, + fromfile=f"backup ({latest_backup.name})", + tofile="current", + )) + + if diff: + print("".join(diff)) + else: + print("No differences from last backup.") + + return 0 + + +def cmd_prune(args: argparse.Namespace) -> int: + paths = get_memory_paths() + deleted = prune_backups(paths["backups"], keep_days=args.keep_days) + if deleted: + print(f"Pruned {len(deleted)} backup(s) older than {args.keep_days} days.") + for p in deleted: + print(f" removed: {p.name}") + else: + print(f"No backups older than {args.keep_days} days.") + return 0 + + +def cmd_init(args: argparse.Namespace) -> int: + try: + paths = get_memory_paths() + except FileNotFoundError as e: + print(f"Error: {e}", file=sys.stderr) + print("Set MEMSYNC_ONEDRIVE env var to your OneDrive path and retry.") + return 1 + + ensure_directories(paths) + + if paths["global_memory"].exists() and not args.force: + print(f"Memory file already exists: {paths['global_memory']}") + print("Use --force to reinitialize.") + return 0 + + starter = load_or_init_memory(Path("/dev/null")) + paths["global_memory"].write_text(starter, encoding="utf-8") + + from .sync import sync_to_claude_md + sync_to_claude_md(paths) + + print("memsync initialized.") + print(f" Memory: {paths['global_memory']}") + print(f" CLAUDE.md: {paths['claude_md']}") + return 0 + + +def main(): + parser = argparse.ArgumentParser( + prog="memsync", + description="Cross-platform global memory manager for Claude Code.", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + p_refresh = subparsers.add_parser("refresh", help="Merge session notes into global memory") + p_refresh.add_argument("--notes", "-n", help="Session notes as a string") + p_refresh.add_argument("--file", "-f", help="Path to a file containing session notes") + p_refresh.add_argument("--dry-run", action="store_true", help="Preview changes without writing") + p_refresh.set_defaults(func=cmd_refresh) + + p_status = subparsers.add_parser("status", help="Show paths and sync status") + p_status.set_defaults(func=cmd_status) + + p_show = subparsers.add_parser("show", help="Print current global memory") + p_show.set_defaults(func=cmd_show) + + p_diff = subparsers.add_parser("diff", help="Diff current memory against last backup") + p_diff.set_defaults(func=cmd_diff) + + p_prune = subparsers.add_parser("prune", help="Remove old backups") + p_prune.add_argument("--keep-days", type=int, default=30) + p_prune.set_defaults(func=cmd_prune) + + p_init = subparsers.add_parser("init", help="Initialize memory structure") + p_init.add_argument("--force", action="store_true") + p_init.set_defaults(func=cmd_init) + + args = parser.parse_args() + sys.exit(args.func(args)) + + +if __name__ == "__main__": + main() +``` + +--- + +## pyproject.toml (prototype) + +```toml +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "memsync" +version = "0.1.0" +description = "Cross-platform global memory manager for Claude Code" +requires-python = ">=3.11" +dependencies = [ + "anthropic>=0.40.0", +] + +[project.scripts] +memsync = "memsync.cli:main" + +[tool.setuptools.packages.find] +where = ["."] +include = ["memsync*"] +``` diff --git a/PITFALLS.md b/PITFALLS.md new file mode 100644 index 0000000..6adb766 --- /dev/null +++ b/PITFALLS.md @@ -0,0 +1,195 @@ +# PITFALLS.md + +Everything here either went wrong during prototyping, was identified as a risk, +or is a subtle behavior that will cause hard-to-debug issues if you miss it. +Read this before touching sync.py, any provider, or the CLAUDE.md sync logic. + +--- + +## 1. Hard constraints must be enforced in code, not just in the prompt + +**The problem:** The system prompt tells the model to never remove hard constraints. +But the model compresses by semantic salience — a constraint that didn't appear in +this week's session notes is easy to quietly drop. + +**The fix:** After getting the updated content back from the API, diff the +`## Hard constraints` section between old and new. Any item present in old +but missing in new gets re-appended. This is done in Python, not by the model. + +```python +def enforce_hard_constraints(old: str, new: str) -> str: + """ + Re-append any hard constraint lines that the model dropped. + Works on the raw markdown text — finds the constraints section and diffs it. + """ + old_constraints = extract_constraints_section(old) + new_constraints = extract_constraints_section(new) + + dropped = set(old_constraints) - set(new_constraints) + if not dropped: + return new + + # Re-append dropped constraints to the section + # Find the end of the constraints section in `new` and insert there + return reinsert_constraints(new, sorted(dropped)) +``` + +This is not implemented in the prototype yet. It must be in the refactor. + +--- + +## 2. The model string will rot + +`claude-sonnet-4-20250514` will become outdated. Do not hardcode it anywhere. +It lives in config. The prototype has it hardcoded in `sync.py` — that's the +first thing to fix in the refactor. + +The risk is not just stale output quality — old model strings may eventually +return API errors, silently breaking refresh for users who never check. + +--- + +## 3. iCloud hides dot-folders + +iCloud Drive on Mac does not sync folders whose names begin with `.` to other +devices. If the memory root is `.claude-memory`, it will exist on the Mac that +created it but be invisible to iCloud sync and won't appear on other Macs or +on Windows. + +**Fix:** The `ICloudProvider` overrides `get_memory_root()` to return +`claude-memory` (no leading dot). This is already in PROVIDERS.md. +Do not change it. Do not use `.claude-memory` with iCloud. + +--- + +## 4. Windows symlinks require admin rights + +On Windows, creating symlinks requires either admin rights or Developer Mode +enabled. Most users won't have either. Don't attempt a symlink on Windows — +always copy. The copy approach means `CLAUDE.md` can drift from `GLOBAL_MEMORY.md` +if the user edits the memory file directly without running `memsync refresh`. + +Document this clearly in the Windows section of the README. The copy gets +updated on every `memsync refresh`, so it's fine in practice. + +--- + +## 5. OneDrive path instability across client versions + +OneDrive has had three different default paths on Mac across major client versions: + +- `~/OneDrive` — old consumer client +- `~/Library/CloudStorage/OneDrive-Personal` — newer client +- `~/Library/CloudStorage/OneDrive - CompanyName` — business/work accounts + +The prototype's `get_onedrive_root()` checks all three. Keep all three checks. +Business account names vary (it's the company name in the Microsoft tenant). +The `startswith("OneDrive")` check in the CloudStorage loop catches most cases. + +If a user reports detection failure on Mac with a business OneDrive account, +the fix is: `memsync config set sync_root /path/to/their/onedrive` + +--- + +## 6. Google Drive path instability across client versions + +Google Drive is worse than OneDrive for this. There have been at least four +different default paths: + +- `~/Google Drive` — legacy Backup and Sync (before 2021) +- `~/Library/CloudStorage/GoogleDrive-email@domain.com/My Drive` — current (Drive for Desktop) +- `G:/My Drive` — Windows, Drive for Desktop with G: drive mapping +- Custom drive letter — Windows users can change the drive letter + +The provider checks all known paths. The `GoogleDrive-` prefix in CloudStorage +is the most reliable current indicator on Mac. On Windows, check for `G:/My Drive` +as well as `~/Google Drive`. + +If detection fails: `memsync config set sync_root /path/to/their/gdrive` + +--- + +## 7. Concurrent writes from two machines + +If the user runs `memsync refresh` on Mac and Windows at nearly the same time, +both will read the same `GLOBAL_MEMORY.md`, update independently, and the last +write wins — the other change is lost. + +This is an edge case (refresh is a deliberate manual action), not a background +sync, so the risk is low. Document it in the README. Do not add locking — it's +not worth the complexity for v1. + +If a user hits this: the backup from the losing write is in `backups/`. They +can manually merge. + +--- + +## 8. The system prompt is load-bearing — don't casually edit it + +The system prompt in `sync.py` was iterated over multiple sessions. Specific +phrases matter: + +- **"identity layer — not project docs, not cold storage"** — prevents the model + from treating this like a knowledge base and trying to be exhaustive. +- **"Preserve the user's exact voice, formatting, and section structure"** — without + this, the model reformats the memory into its own preferred style after a few + refreshes, eroding the user's structure. +- **"If nothing meaningful changed, return the file UNCHANGED"** — without this, + the model always makes small edits just to show it did something, creating + spurious diffs and unnecessary backups. +- **"RETURN: Only the updated GLOBAL_MEMORY.md content. No explanation, no preamble."** + — without this, the model occasionally prepends "Here is the updated memory file:" + which then gets written into the file. + +If you edit the prompt, test with `--dry-run` across several different notes +inputs before committing. Prompt changes are the highest-risk edits in this codebase. + +--- + +## 9. Empty notes should not trigger a refresh + +If `--notes ""` or a notes file that's all whitespace is passed, refuse with +a clear error. Don't send an empty notes payload to the API — it will either +change nothing (wasted tokens) or hallucinate something to change. + +This is handled in the prototype's `cmd_refresh`. Keep it in the refactor. + +--- + +## 10. The `max_tokens=4000` ceiling + +GLOBAL_MEMORY.md is capped at ~400 lines. At average prose density, 4000 tokens +is enough headroom. But if a user has a very dense memory file and writes extensive +notes, the response can get truncated — the file gets written with the truncation +mid-sentence. + +Mitigation: set `max_tokens` to 4096 (the safe maximum for most models) and +add a post-write check that the file ends with a complete line (no truncation mid-word). +If truncated, restore from backup and print an error. + +Not implemented in the prototype — add it in the refactor. + +--- + +## 11. Sessions log is append-only by design + +`sessions/.md` files are never pruned. They're the raw audit trail. +The `prune` command only touches `backups/`. This is intentional — session logs +are cheap (text only) and losing them removes the only way to recover if the +compaction drops something important. + +If a user asks for a way to prune sessions, direct them to delete manually. +Don't add a `--sessions` flag to `prune`. + +--- + +## 12. Test isolation — never touch real filesystem or real API in tests + +All tests must mock: +- The filesystem (use `tmp_path` from pytest) +- The Anthropic API (use `unittest.mock.patch`) + +Never create files in `~/.config`, `~/.claude`, or any cloud sync folder during tests. +Never make real API calls in tests. + +See REPO.md for the test structure and mock patterns. diff --git a/PROVIDERS.md b/PROVIDERS.md new file mode 100644 index 0000000..1d766a6 --- /dev/null +++ b/PROVIDERS.md @@ -0,0 +1,367 @@ +# PROVIDERS.md + +## The plugin contract + +Every provider implements this ABC. Nothing else in the codebase needs to change +when a new provider is added. + +```python +# memsync/providers/__init__.py + +from abc import ABC, abstractmethod +from pathlib import Path + + +class BaseProvider(ABC): + """ + A sync provider knows how to find the cloud storage root on the current machine. + That's its only job. Memory structure lives above it. + """ + + name: str # short id used in config: "onedrive", "icloud", "gdrive", "custom" + display_name: str # human-readable: "OneDrive", "iCloud Drive", "Google Drive", "Custom Path" + + @abstractmethod + def detect(self) -> Path | None: + """ + Try to find this provider's sync root on the current machine. + Returns the path if found and accessible, None otherwise. + Never raises — detection failure is not an error. + """ + + @abstractmethod + def is_available(self) -> bool: + """ + Quick check: is this provider installed and its sync folder accessible? + Should be fast — no API calls, just filesystem checks. + """ + + def get_memory_root(self, sync_root: Path) -> Path: + """ + Where inside the sync root to store memsync data. + Default is /.claude-memory + Providers can override if needed (e.g. iCloud has invisible dot-folders). + """ + return sync_root / ".claude-memory" + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(name={self.name!r})" + + +# Provider registry — add new providers here +_REGISTRY: dict[str, type[BaseProvider]] = {} + + +def register(cls: type[BaseProvider]) -> type[BaseProvider]: + """Decorator to register a provider.""" + _REGISTRY[cls.name] = cls + return cls + + +def get_provider(name: str) -> BaseProvider: + """Get a provider instance by name. Raises KeyError if not found.""" + if name not in _REGISTRY: + available = ", ".join(_REGISTRY.keys()) + raise KeyError(f"Unknown provider {name!r}. Available: {available}") + return _REGISTRY[name]() + + +def all_providers() -> list[BaseProvider]: + """Return one instance of each registered provider.""" + return [cls() for cls in _REGISTRY.values()] + + +def auto_detect() -> list[BaseProvider]: + """ + Return all providers that detect successfully on this machine, + in priority order: OneDrive, iCloud, Google Drive, Custom. + """ + return [p for p in all_providers() if p.detect() is not None] +``` + +--- + +## OneDrive provider + +```python +# memsync/providers/onedrive.py + +import os +import platform +from pathlib import Path +from . import BaseProvider, register + + +@register +class OneDriveProvider(BaseProvider): + name = "onedrive" + display_name = "OneDrive" + + def detect(self) -> Path | None: + try: + return self._find() + except Exception: + return None + + def is_available(self) -> bool: + return self.detect() is not None + + def _find(self) -> Path | None: + system = platform.system() + + if system == "Windows": + # Windows sets these env vars when OneDrive is running + for var in ("OneDrive", "ONEDRIVE", "OneDriveConsumer", "OneDriveCommercial"): + val = os.environ.get(var) + if val: + p = Path(val) + if p.exists(): + return p + # Fallback: common default paths + username = os.environ.get("USERNAME", "") + for candidate in [ + Path.home() / "OneDrive", + Path(f"C:/Users/{username}/OneDrive"), + ]: + if candidate.exists(): + return candidate + + elif system == "Darwin": + # Mac: OneDrive doesn't set env vars, check filesystem + # Personal OneDrive + personal = Path.home() / "OneDrive" + if personal.exists(): + return personal + + # OneDrive via CloudStorage (newer Mac client) + cloud_storage = Path.home() / "Library" / "CloudStorage" + if cloud_storage.exists(): + # Personal first, then business + for d in sorted(cloud_storage.iterdir()): + if d.name == "OneDrive-Personal": + return d + for d in sorted(cloud_storage.iterdir()): + if d.name.startswith("OneDrive") and d.is_dir(): + return d + + else: + # Linux: OneDrive via rclone or manual mount + candidates = [ + Path.home() / "OneDrive", + Path.home() / "onedrive", + ] + for c in candidates: + if c.exists(): + return c + + return None +``` + +--- + +## iCloud Drive provider + +```python +# memsync/providers/icloud.py + +import platform +from pathlib import Path +from . import BaseProvider, register + + +@register +class ICloudProvider(BaseProvider): + name = "icloud" + display_name = "iCloud Drive" + + def detect(self) -> Path | None: + try: + return self._find() + except Exception: + return None + + def is_available(self) -> bool: + return self.detect() is not None + + def _find(self) -> Path | None: + system = platform.system() + + if system == "Darwin": + # Primary path on Mac + icloud = Path.home() / "Library" / "Mobile Documents" / "com~apple~CloudDocs" + if icloud.exists(): + return icloud + + elif system == "Windows": + # iCloud for Windows installs here + import os + username = os.environ.get("USERNAME", "") + candidates = [ + Path.home() / "iCloudDrive", + Path(f"C:/Users/{username}/iCloudDrive"), + ] + for c in candidates: + if c.exists(): + return c + + # Linux: iCloud has no official client — not supported + return None + + def get_memory_root(self, sync_root: Path) -> Path: + # iCloud hides dot-folders on Mac — use a visible name instead + return sync_root / "claude-memory" +``` + +**Note on iCloud dot-folders:** iCloud Drive on Mac does not sync folders whose +names begin with `.` to other devices. Use `claude-memory` not `.claude-memory` +for the iCloud provider. The `get_memory_root` override handles this automatically. + +--- + +## Google Drive provider + +```python +# memsync/providers/gdrive.py + +import platform +from pathlib import Path +from . import BaseProvider, register + + +@register +class GoogleDriveProvider(BaseProvider): + name = "gdrive" + display_name = "Google Drive" + + def detect(self) -> Path | None: + try: + return self._find() + except Exception: + return None + + def is_available(self) -> bool: + return self.detect() is not None + + def _find(self) -> Path | None: + system = platform.system() + + if system == "Darwin": + # Google Drive for Desktop (current client) + cloud_storage = Path.home() / "Library" / "CloudStorage" + if cloud_storage.exists(): + for d in cloud_storage.iterdir(): + if d.name.startswith("GoogleDrive") and d.is_dir(): + # My Drive is inside the account folder + my_drive = d / "My Drive" + if my_drive.exists(): + return my_drive + return d + + # Legacy Backup and Sync path + legacy = Path.home() / "Google Drive" + if legacy.exists(): + return legacy + + elif system == "Windows": + import os + # Google Drive for Desktop on Windows + # Sets GDRIVE_ROOT or uses default path + gdrive_env = os.environ.get("GDRIVE_ROOT") + if gdrive_env: + p = Path(gdrive_env) + if p.exists(): + return p + + username = os.environ.get("USERNAME", "") + candidates = [ + Path.home() / "Google Drive", + Path(f"C:/Users/{username}/Google Drive"), + # Google Drive for Desktop default + Path("G:/My Drive"), + Path("G:/"), + ] + for c in candidates: + if c.exists(): + return c + + elif system == "Linux": + # Google Drive via google-drive-ocamlfuse or rclone + candidates = [ + Path.home() / "GoogleDrive", + Path.home() / "google-drive", + Path.home() / "gdrive", + ] + for c in candidates: + if c.exists(): + return c + + return None +``` + +**Note on Google Drive path instability:** Google Drive for Desktop changed its +mount path between versions. The `~/Library/CloudStorage/GoogleDrive-*` path is +current (2024+). The `~/Google Drive` path is legacy Backup and Sync. Both are +checked. If a user reports detection failure, first ask which Google Drive client +version they have. See `PITFALLS.md`. + +--- + +## Custom provider (manual path) + +```python +# memsync/providers/custom.py + +from pathlib import Path +from . import BaseProvider, register + + +@register +class CustomProvider(BaseProvider): + """ + Fallback for any sync service not explicitly supported. + User sets the path manually via: memsync config set sync_root /path/to/folder + """ + name = "custom" + display_name = "Custom Path" + + def __init__(self, path: Path | None = None): + self._path = path + + def detect(self) -> Path | None: + # Custom provider only works if path is explicitly configured + if self._path and self._path.exists(): + return self._path + return None + + def is_available(self) -> bool: + return self.detect() is not None +``` + +--- + +## Adding a new provider + +To add Dropbox, Box, Synology, etc.: + +1. Create `memsync/providers/dropbox.py` +2. Implement `BaseProvider` (detect + is_available) +3. Add `@register` decorator +4. Import it in `memsync/providers/__init__.py` (the import triggers registration) +5. Add tests in `tests/test_providers.py` using the mocked filesystem pattern +6. Update the providers table in README.md + +That's the complete list. No other files need to change. + +See `docs/adding-a-provider.md` for the full contributor guide. + +--- + +## Provider detection priority + +During `memsync init`, providers are tried in this order: +1. OneDrive +2. iCloud +3. Google Drive +4. Custom (only if path already configured) + +If multiple are detected, the user is prompted to choose. The choice is saved to config. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6c1ae98 --- /dev/null +++ b/README.md @@ -0,0 +1,184 @@ +# memsync + +Cross-platform global memory manager for Claude Code. + +Claude Code has no memory between sessions. memsync fixes that: it maintains one canonical `GLOBAL_MEMORY.md` in your cloud sync folder, linked to `~/.claude/CLAUDE.md` so Claude Code reads it at every session start. + +After a meaningful session, run `memsync refresh --notes "..."` and the Claude API merges your notes into the memory file automatically. + +--- + +## How it works + +``` +OneDrive/.claude-memory/ + GLOBAL_MEMORY.md ← source of truth, synced across all machines + backups/ ← automatic backups before every refresh + sessions/ ← raw session notes, append-only audit trail + +~/.claude/CLAUDE.md ← symlink → GLOBAL_MEMORY.md (Mac/Linux) + copy of GLOBAL_MEMORY.md (Windows) +``` + +Every Claude Code session starts by reading `~/.claude/CLAUDE.md`. memsync keeps it current. + +--- + +## Requirements + +- Python 3.11+ +- An Anthropic API key (`ANTHROPIC_API_KEY` env var) +- One of: OneDrive, iCloud Drive, Google Drive — or any folder you specify + +--- + +## Installation + +```bash +pip install memsync +``` + +--- + +## Quick start + +```bash +# 1. Initialize (auto-detects your cloud provider) +memsync init + +# 2. Edit your memory file — fill in who you are, active projects, preferences +# File is at: OneDrive/.claude-memory/GLOBAL_MEMORY.md + +# 3. After a Claude Code session, merge in your notes +memsync refresh --notes "Finished the auth module. Decided to use JWT tokens, not sessions." + +# 4. Check everything is wired up +memsync status +``` + +--- + +## Commands + +| Command | Description | +|---|---| +| `memsync init` | First-time setup: create directory structure, sync to CLAUDE.md | +| `memsync refresh --notes "..."` | Merge session notes into memory via Claude API | +| `memsync show` | Print current GLOBAL_MEMORY.md | +| `memsync diff` | Diff current memory vs last backup | +| `memsync status` | Show paths, provider, sync state | +| `memsync providers` | List all providers and detection status | +| `memsync config show` | Print current config | +| `memsync config set ` | Update a config value | +| `memsync prune` | Remove old backups | + +### `memsync refresh` options + +```bash +memsync refresh --notes "inline notes" +memsync refresh --file notes.txt +echo "notes" | memsync refresh +memsync refresh --notes "..." --dry-run # preview changes, no write +memsync refresh --notes "..." --model claude-opus-4-20250514 # one-off model override +``` + +### `memsync init` options + +```bash +memsync init # auto-detect provider +memsync init --provider icloud # use a specific provider +memsync init --sync-root /path/to/folder # use a custom path +memsync init --force # reinitialize even if already set up +``` + +### `memsync config set` keys + +```bash +memsync config set provider icloud +memsync config set model claude-opus-4-20250514 +memsync config set sync_root /path/to/custom/folder +memsync config set keep_days 60 +memsync config set max_memory_lines 300 +memsync config set claude_md_target ~/.claude/CLAUDE.md +``` + +--- + +## Cloud providers + +| Provider | macOS | Windows | Linux | +|---|---|---|---| +| OneDrive | ✓ | ✓ | ✓ (rclone) | +| iCloud Drive | ✓ | ✓ | ✗ | +| Google Drive | ✓ | ✓ | ✓ (rclone) | +| Custom path | ✓ | ✓ | ✓ | + +Detection is automatic. If multiple providers are found during `memsync init`, you'll be prompted to choose. + +**Windows note:** Symlinks require admin rights or Developer Mode on Windows. memsync copies `GLOBAL_MEMORY.md` to `~/.claude/CLAUDE.md` instead. The copy is refreshed on every `memsync refresh`. + +**iCloud note:** iCloud Drive doesn't sync dot-folders on Mac. memsync stores data in `claude-memory/` (no leading dot) when using the iCloud provider. + +--- + +## Configuration + +Config file location: +- macOS/Linux: `~/.config/memsync/config.toml` +- Windows: `%APPDATA%\memsync\config.toml` + +Config is machine-specific — two machines can use different providers pointing to the same cloud storage location. + +Example config: + +```toml +[core] +provider = "onedrive" +model = "claude-sonnet-4-20250514" +max_memory_lines = 400 + +[paths] +claude_md_target = "/Users/ian/.claude/CLAUDE.md" + +[backups] +keep_days = 30 +``` + +To update the model when Anthropic releases new ones: + +```bash +memsync config set model claude-sonnet-4-20250514 +``` + +--- + +## What belongs in GLOBAL_MEMORY.md + +The memory file is your **identity layer** — not a knowledge base, not project docs. + +Good things to include: +- Who you are, your roles, active projects +- Current priorities and focus +- Standing preferences (communication style, output format) +- Hard constraints (rules that must never be softened through compaction) + +See `docs/global-memory-guide.md` for a complete guide. + +--- + +## Known limitations + +- **Concurrent writes:** Running `memsync refresh` on two machines simultaneously will result in the last write winning. The losing write's backup is in `backups/`. Risk is low since refresh is a deliberate manual action. +- **Max memory size:** The memory file is kept under ~400 lines. Very dense files may hit the 4096 token response limit — reduce the file size if you see truncation errors. + +--- + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md). To add a new cloud provider, see [docs/adding-a-provider.md](docs/adding-a-provider.md). + +--- + +## License + +MIT diff --git a/REPO.md b/REPO.md new file mode 100644 index 0000000..4f4e68f --- /dev/null +++ b/REPO.md @@ -0,0 +1,292 @@ +# REPO.md + +## Target repository structure + +``` +memsync/ +├── memsync/ +│ ├── __init__.py # version string only +│ ├── cli.py # entry point, argument parsing, command routing +│ ├── config.py # Config dataclass, load/save, path resolution +│ ├── sync.py # Claude API call, compaction, hard constraint enforcement +│ ├── claude_md.py # CLAUDE.md symlink/copy management +│ ├── backups.py # backup, prune, list operations +│ └── providers/ +│ ├── __init__.py # BaseProvider ABC, registry, auto_detect() +│ ├── onedrive.py +│ ├── icloud.py +│ ├── gdrive.py +│ └── custom.py +├── tests/ +│ ├── conftest.py # shared fixtures (tmp_path wrappers, mock config) +│ ├── test_config.py # Config load/save, platform path resolution +│ ├── test_providers.py # each provider's detect() with mocked filesystem +│ ├── test_sync.py # refresh logic with mocked API +│ ├── test_backups.py # backup, prune, list +│ ├── test_claude_md.py # symlink + copy behavior +│ └── test_cli.py # CLI integration (subprocess or direct function calls) +├── docs/ +│ ├── adding-a-provider.md # contributor guide for new sync providers +│ └── global-memory-guide.md # what to put in GLOBAL_MEMORY.md (user guide) +├── .github/ +│ ├── workflows/ +│ │ ├── ci.yml # test matrix +│ │ └── release.yml # PyPI publish on tag +│ └── ISSUE_TEMPLATE/ +│ ├── bug_report.md +│ └── provider_request.md +├── pyproject.toml +├── README.md +└── CONTRIBUTING.md +``` + +--- + +## pyproject.toml (target) + +```toml +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "memsync" +version = "0.2.0" +description = "Cross-platform global memory manager for Claude Code" +readme = "README.md" +license = { text = "MIT" } +requires-python = ">=3.11" +keywords = ["claude", "claude-code", "ai", "memory", "cli"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Operating System :: MacOS", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX :: Linux", +] +dependencies = [ + "anthropic>=0.40.0", +] + +[project.urls] +Homepage = "https://github.com/YOUR_USERNAME/memsync" +Issues = "https://github.com/YOUR_USERNAME/memsync/issues" + +[project.scripts] +memsync = "memsync.cli:main" + +[tool.setuptools.packages.find] +where = ["."] +include = ["memsync*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ruff] +line-length = 100 +target-version = "py311" +``` + +--- + +## CI workflow + +```yaml +# .github/workflows/ci.yml +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + name: Test (${{ matrix.os }}, Python ${{ matrix.python-version }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + pip install -e ".[dev]" + + - name: Run tests + run: pytest tests/ -v +``` + +--- + +## Release workflow + +```yaml +# .github/workflows/release.yml +name: Release + +on: + push: + tags: + - "v*" + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + id-token: write # for trusted publishing + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Build + run: | + pip install build + python -m build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 +``` + +Use PyPI Trusted Publishing (OIDC) — no API keys stored in GitHub secrets. +Set up at: https://pypi.org/manage/account/publishing/ + +--- + +## Dev dependencies + +Add to pyproject.toml: + +```toml +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-mock>=3.12", + "ruff>=0.4", +] +``` + +Install with: `pip install -e ".[dev]"` + +--- + +## Test patterns + +### Mocking the filesystem + +```python +# tests/conftest.py +import pytest +from pathlib import Path +from memsync.config import Config + + +@pytest.fixture +def tmp_config(tmp_path, monkeypatch): + """Config pointing entirely to tmp_path — no real filesystem touched.""" + config = Config( + provider="custom", + sync_root=tmp_path / "sync", + ) + (tmp_path / "sync" / ".claude-memory" / "backups").mkdir(parents=True) + (tmp_path / "sync" / ".claude-memory" / "sessions").mkdir(parents=True) + monkeypatch.setattr("memsync.config.get_config_path", + lambda: tmp_path / "config.toml") + return config, tmp_path +``` + +### Mocking the Anthropic API + +```python +# tests/test_sync.py +from unittest.mock import MagicMock, patch +from memsync.sync import refresh_memory_content + + +def test_refresh_returns_updated_content(tmp_config): + config, tmp_path = tmp_config + mock_response = MagicMock() + mock_response.content = [MagicMock(text="# Updated memory\n\n## Identity\n- Test user")] + + with patch("anthropic.Anthropic") as mock_client: + mock_client.return_value.messages.create.return_value = mock_response + result = refresh_memory_content( + notes="Test session notes", + current_memory="# Global Memory\n\n## Identity\n- Test user", + config=config, + ) + + assert result["changed"] is False # content same after strip +``` + +### Mocking provider detection + +```python +# tests/test_providers.py +from memsync.providers.onedrive import OneDriveProvider + + +def test_onedrive_detects_personal_path(tmp_path, monkeypatch): + onedrive_dir = tmp_path / "OneDrive" + onedrive_dir.mkdir() + monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) + + provider = OneDriveProvider() + result = provider.detect() + assert result == onedrive_dir +``` + +--- + +## Build order (recommended) + +Work in this sequence to keep the code in a runnable state at each step: + +1. `memsync/providers/` — BaseProvider, registry, all 3 providers +2. `memsync/config.py` — Config dataclass, load/save +3. `memsync/backups.py` — extract from prototype sync.py +4. `memsync/claude_md.py` — extract sync_to_claude_md from prototype +5. `memsync/sync.py` — refactor to accept Config, fix hardcoded model +6. `memsync/cli.py` — refactor to wire config + providers through all commands, + add new commands: `providers`, `config show/set` +7. `tests/` — write tests for each module as you go +8. CI workflows +9. README, CONTRIBUTING, docs/adding-a-provider.md + +--- + +## GitHub repo conventions + +- **main** branch is always releasable +- PRs required for all changes (even from owner) +- Squash merge to keep history clean +- Version: semantic versioning. v0.x.y while in alpha. +- Changelog: keep a simple CHANGELOG.md, updated per release + +## Issue templates + +### bug_report.md +Ask for: OS, Python version, provider, `memsync status` output, error message. + +### provider_request.md +Ask for: provider name, OS, default install path, whether they're willing to +implement it (link to adding-a-provider.md). diff --git a/STYLE.md b/STYLE.md new file mode 100644 index 0000000..2dee932 --- /dev/null +++ b/STYLE.md @@ -0,0 +1,133 @@ +# STYLE.md + +## Non-negotiables + +- **Python 3.11+ only.** Use `tomllib` (stdlib), `match` statements where they clarify, + `Path` everywhere (never `os.path`), `from __future__ import annotations` at the top + of every module. +- **No dependencies beyond `anthropic`.** Everything else is stdlib. + Exception: `pytest`, `pytest-mock`, `ruff` in dev dependencies only. +- **Type hints everywhere.** Return types on all functions. No bare `dict` or `list` — + use `dict[str, Path]`, `list[Path]`, etc. +- **`Path` for all filesystem operations.** Never concatenate strings to build paths. + +--- + +## Module boundaries + +Each module has one job. Don't let them bleed: + +- `sync.py` calls the API and returns text. It does not write files. +- `cli.py` handles I/O (print, argparse). It does not contain business logic. +- `providers/` detect paths. They do not create directories or write files. +- `config.py` loads and saves config. It does not call the API. + +If you find yourself importing `cli` from `sync` or `sync` from `providers`, +stop and reconsider the design. + +--- + +## Function design + +Keep functions small and single-purpose. If a function is doing two things, +split it. The test for this: can you describe what it does in one sentence +without using "and"? + +Prefer explicit parameters over reading from global state: + +```python +# Good +def refresh_memory_content(notes: str, current_memory: str, config: Config) -> dict: + ... + +# Bad — reads global config internally, hard to test +def refresh_memory_content(notes: str) -> dict: + config = Config.load() # hidden dependency + ... +``` + +--- + +## Error handling + +- Use specific exceptions, not bare `except Exception`. +- Errors that the user can fix → print to stderr with a fix suggestion, return exit code 1. +- Errors that are bugs → let them propagate with a full traceback. +- Never swallow exceptions silently. + +```python +# Good +try: + path = provider.detect() +except PermissionError as e: + print(f"Error: can't access sync folder: {e}", file=sys.stderr) + print("Check folder permissions or run: memsync config set sync_root /path", file=sys.stderr) + return 4 + +# Bad +try: + path = provider.detect() +except Exception: + path = None +``` + +--- + +## CLI output + +- Success output → stdout +- Errors → stderr +- Keep success output minimal. Users will run this in terminal sessions — + wall-of-text output is noise. +- Use `✓` and `✗` for status indicators in `memsync status` and `memsync providers`. +- Emoji in output: only the two above, nowhere else. + +--- + +## Naming conventions + +| Thing | Convention | Example | +|---|---|---| +| Modules | snake_case | `claude_md.py` | +| Classes | PascalCase | `OneDriveProvider` | +| Functions | snake_case | `refresh_memory_content` | +| Constants | UPPER_SNAKE | `SYSTEM_PROMPT` | +| CLI commands | hyphen-case | `memsync dry-run` | +| Config keys | snake_case | `keep_days` | +| Provider names | lowercase, no hyphens | `"onedrive"`, `"icloud"`, `"gdrive"` | + +--- + +## What "done" looks like for a module + +A module is done when: +1. All functions have type hints +2. All functions have docstrings (one line is fine for obvious things) +3. Tests exist and pass on Mac, Windows, Linux (CI green) +4. No hardcoded paths, model strings, or magic numbers + +--- + +## Commit messages + +``` +feat: add iCloud provider detection +fix: restore hard constraints dropped by compaction +refactor: extract backup logic into backups.py +test: add provider detection tests with mocked filesystem +docs: update adding-a-provider guide +``` + +First word: `feat`, `fix`, `refactor`, `test`, `docs`, `chore`. +Present tense. No period at the end. + +--- + +## What to avoid + +- Don't use `print()` for debugging — use proper logging or remove before commit +- Don't use `os.path` — use `pathlib.Path` +- Don't use `open()` without `encoding="utf-8"` +- Don't write to `~/.claude/CLAUDE.md` without backing up first +- Don't call the Anthropic API in any path except `sync.py` +- Don't import from `cli.py` in any other module diff --git a/docs/DAEMON_SETUP.md b/docs/DAEMON_SETUP.md new file mode 100644 index 0000000..d67a1b0 --- /dev/null +++ b/docs/DAEMON_SETUP.md @@ -0,0 +1,226 @@ +# DAEMON_SETUP.md + +## Raspberry Pi setup guide + +This is the end-user guide for setting up the daemon on a Raspberry Pi. +It belongs in `docs/raspberry-pi.md` in the final repo. + +--- + +## What you need + +- Raspberry Pi 3B+ or newer (3B+ is fine, Pi 4 is better) +- Raspberry Pi OS Lite (no desktop needed) +- Your cloud sync folder accessible on the Pi (see options below) +- Python 3.11+ (comes with Raspberry Pi OS Bookworm and later) + +--- + +## Step 1: Get Python 3.11+ + +```bash +python3 --version +# If below 3.11: +sudo apt update && sudo apt install -y python3.11 python3.11-pip +``` + +--- + +## Step 2: Install memsync with daemon extras + +```bash +pip3 install memsync[daemon] --break-system-packages +``` + +--- + +## Step 3: Mount your cloud sync folder on the Pi + +The Pi needs to see your `GLOBAL_MEMORY.md` file. Three options: + +### Option A: rclone (OneDrive, Google Drive, iCloud via workaround) + +```bash +# Install rclone +curl https://rclone.org/install.sh | sudo bash + +# Configure (follow interactive prompts) +rclone config +# Choose your provider (OneDrive = "Microsoft OneDrive", Google Drive = "drive") + +# Mount (run at boot via cron or systemd) +rclone mount onedrive: ~/OneDrive --daemon --vfs-cache-mode full +``` + +Add to `/etc/rc.local` before `exit 0` to mount on boot: +```bash +sudo -u pi rclone mount onedrive: /home/pi/OneDrive --daemon --vfs-cache-mode full +``` + +### Option B: NFS mount from another machine on your LAN + +If your Mac or Windows machine is always on, share the OneDrive folder over +NFS and mount it on the Pi. Simpler than rclone for home LAN setups. + +### Option C: Manual sync via rsync + cron (simplest, less real-time) + +```bash +# Add to Pi's crontab — syncs from Mac every 15 minutes +*/15 * * * * rsync -az your-mac.local:/Users/ian/OneDrive/.claude-memory/ ~/claude-memory/ +``` + +Then point memsync at the local copy: +```bash +memsync config set sync_root ~/claude-memory +memsync config set provider custom +``` + +--- + +## Step 4: Configure memsync on the Pi + +```bash +# Initialize (uses OneDrive via rclone mount, or custom path from Option C) +memsync init + +# Set your API key +export ANTHROPIC_API_KEY="sk-ant-..." +# Add to ~/.bashrc to persist across reboots: +echo 'export ANTHROPIC_API_KEY="sk-ant-..."' >> ~/.bashrc + +# Check everything looks right +memsync status +``` + +--- + +## Step 5: Install and start the daemon + +```bash +# Install as a systemd service (starts on boot) +sudo memsync daemon install + +# The installer will print a warning about the API key in the unit file. +# Add it properly via override: +sudo systemctl edit memsync +``` + +In the editor that opens, add: +```ini +[Service] +Environment=ANTHROPIC_API_KEY=sk-ant-... +``` + +Save and close, then: +```bash +sudo systemctl restart memsync +sudo systemctl status memsync # should show "active (running)" +``` + +--- + +## Step 6: Set your timezone + +```bash +# Check current timezone +timedatectl + +# Set correct timezone (important for nightly refresh timing) +sudo timedatectl set-timezone America/Los_Angeles +# or America/New_York, Europe/London, etc. +``` + +--- + +## Step 7: Verify the nightly refresh + +The easiest way to test without waiting until 11:55pm: + +```bash +# Trigger a manual refresh to confirm everything works +memsync refresh --notes "Pi daemon setup and tested successfully" + +# Check it ran and updated the memory file +memsync show | head -20 +``` + +--- + +## Step 8: Set up the web UI (optional) + +The web UI starts automatically with the daemon. Access it from any browser +on your home network: + +``` +http://raspberrypi.local:5000 +``` + +If `raspberrypi.local` doesn't resolve, use the Pi's IP address instead: +```bash +hostname -I # shows Pi's IP +``` + +--- + +## Step 9: Set up mobile capture (optional) + +On iPhone, create a Shortcut: +1. Add action: "Get Contents of URL" +2. URL: `http://raspberrypi.local:5001/note` +3. Method: POST +4. Request body: JSON → `{"text": "Shortcut Input"}` +5. Add a "Text" input action before it so you can type the note + +Add to your home screen. One tap → type note → it goes into tonight's session log. + +If you want basic auth, set a token first: +```bash +memsync config set capture_token mytoken123 +``` + +Then add header to the Shortcut: `X-Memsync-Token: mytoken123` + +--- + +## Checking daemon health + +```bash +# See what's running and when jobs last fired +memsync daemon status + +# See daemon logs +sudo journalctl -u memsync -f + +# Check the schedule +memsync daemon schedule +``` + +--- + +## Troubleshooting + +**Daemon won't start:** +```bash +sudo journalctl -u memsync --no-pager | tail -30 +``` +Most common cause: ANTHROPIC_API_KEY not set in the systemd override. + +**Web UI not accessible from other devices:** +Check that `web_ui_host` is `0.0.0.0` not `127.0.0.1`: +```bash +memsync config show | grep web_ui_host +memsync config set web_ui_host 0.0.0.0 +sudo systemctl restart memsync +``` + +**Port 5000 already in use:** +```bash +memsync config set web_ui_port 5050 +sudo systemctl restart memsync +``` + +**OneDrive not syncing on Pi:** +```bash +rclone ls onedrive:.claude-memory/ # test rclone can see the files +``` +If this fails, reconfigure rclone: `rclone config reconnect onedrive:` diff --git a/docs/adding-a-provider.md b/docs/adding-a-provider.md new file mode 100644 index 0000000..763a57b --- /dev/null +++ b/docs/adding-a-provider.md @@ -0,0 +1,192 @@ +# Adding a new provider + +memsync supports any cloud storage service through a simple plugin interface. Adding a provider requires: + +1. One new file in `memsync/providers/` +2. One line in `memsync/providers/__init__.py` +3. Tests in `tests/test_providers.py` +4. A row in the README providers table + +That's the complete list. No other files need to change. + +--- + +## The provider contract + +Every provider implements two required methods and inherits one optional override: + +```python +class BaseProvider(ABC): + + name: str # short id: "dropbox", "box", etc. + display_name: str # human-readable: "Dropbox", "Box", etc. + + @abstractmethod + def detect(self) -> Path | None: + """ + Return the sync root path if found, None otherwise. + Must never raise — wrap _find() in try/except. + """ + + @abstractmethod + def is_available(self) -> bool: + """Quick check — is this provider's folder accessible?""" + + def get_memory_root(self, sync_root: Path) -> Path: + """ + Where inside the sync root to store memsync data. + Default: sync_root / ".claude-memory" + Override only if the provider hides dot-folders (e.g. iCloud). + """ + return sync_root / ".claude-memory" +``` + +--- + +## Worked example: Dropbox + +### Step 1 — Create `memsync/providers/dropbox.py` + +```python +from __future__ import annotations + +import os +import platform +from pathlib import Path + +from memsync.providers import BaseProvider, register + + +@register +class DropboxProvider(BaseProvider): + name = "dropbox" + display_name = "Dropbox" + + def detect(self) -> Path | None: + try: + return self._find() + except Exception: + return None + + def is_available(self) -> bool: + return self.detect() is not None + + def _find(self) -> Path | None: + system = platform.system() + + if system == "Darwin": + # Dropbox sets ~/.dropbox/info.json with the sync path + info = Path.home() / ".dropbox" / "info.json" + if info.exists(): + import json + data = json.loads(info.read_text(encoding="utf-8")) + path_str = data.get("personal", {}).get("path") + if path_str: + p = Path(path_str) + if p.exists(): + return p + # Fallback: common default + default = Path.home() / "Dropbox" + if default.exists(): + return default + + elif system == "Windows": + # Check Dropbox info.json on Windows + appdata = os.environ.get("APPDATA", "") + info = Path(appdata) / "Dropbox" / "info.json" + if info.exists(): + import json + data = json.loads(info.read_text(encoding="utf-8")) + path_str = data.get("personal", {}).get("path") + if path_str: + p = Path(path_str) + if p.exists(): + return p + default = Path.home() / "Dropbox" + if default.exists(): + return default + + elif system == "Linux": + # Dropbox info.json also exists on Linux + info = Path.home() / ".dropbox" / "info.json" + if info.exists(): + import json + data = json.loads(info.read_text(encoding="utf-8")) + path_str = data.get("personal", {}).get("path") + if path_str: + p = Path(path_str) + if p.exists(): + return p + default = Path.home() / "Dropbox" + if default.exists(): + return default + + return None +``` + +### Step 2 — Register it in `memsync/providers/__init__.py` + +Add one line at the bottom of the file, after the existing provider imports: + +```python +from memsync.providers import onedrive, icloud, gdrive, custom, dropbox # noqa: E402, F401 +``` + +The `@register` decorator handles the rest. The import order determines priority during `memsync init` auto-detection. + +### Step 3 — Add tests in `tests/test_providers.py` + +```python +from memsync.providers.dropbox import DropboxProvider + + +class TestDropboxProvider: + def test_detects_default_path(self, tmp_path, monkeypatch): + dropbox_dir = tmp_path / "Dropbox" + dropbox_dir.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setattr(platform, "system", lambda: "Darwin") + + provider = DropboxProvider() + result = provider.detect() + assert result == dropbox_dir + + def test_returns_none_when_not_found(self, tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setattr(platform, "system", lambda: "Darwin") + provider = DropboxProvider() + assert provider.detect() is None + + def test_never_raises(self, monkeypatch): + monkeypatch.setattr(DropboxProvider, "_find", lambda self: (_ for _ in ()).throw(Exception("boom"))) + provider = DropboxProvider() + assert provider.detect() is None +``` + +### Step 4 — Update the README + +Add a row to the providers table in `README.md`: + +```markdown +| Dropbox | ✓ | ✓ | ✓ | +``` + +--- + +## Things to get right + +**`detect()` must never raise.** Wrap all detection logic in `_find()` and call it from `detect()` inside `try/except Exception`. A provider that throws crashes `memsync providers` for everyone. + +**Check `exists()` before returning.** Always verify the path actually exists before returning it. A path that exists in the config but not on disk is wrong. + +**`info.json` vs env vars vs default paths.** Prefer provider-documented paths (like Dropbox's `info.json`) over guessing default paths. The guesses are a fallback. + +**Don't override `get_memory_root()` unless necessary.** The default (`.claude-memory`) is correct for most providers. Only override it if the provider has a technical reason not to sync dot-folders (like iCloud on Mac). + +**Detection priority.** Providers are detected in import order. If you want your provider to be checked before Google Drive but after iCloud, put it in that order in the import line. + +--- + +## Testing your provider without a real account + +Use `tmp_path` and `monkeypatch` to simulate the filesystem. See the existing provider tests in `tests/test_providers.py` for the pattern. Never create real files in `~`, `~/.config`, or any cloud folder during tests. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..6ca850d --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,590 @@ +# Getting started with memsync + +This guide walks you through installing and using memsync from scratch. It assumes you +have used a terminal before — you know how to open one and type commands — but no +Python experience or coding background is required. + +--- + +## What memsync does + +Claude Code has no memory between sessions. Every time you start a new session, Claude +starts fresh — it doesn't remember your name, your projects, your preferences, or what +you decided last time. + +The standard fix is a file at `~/.claude/CLAUDE.md`. Claude Code reads that file at the +start of every session, so anything you put there gets loaded automatically. But keeping +that file up to date by hand is tedious, it gets bloated, and if you work on more than +one computer the files drift apart. + +memsync solves this by: + +1. Storing one canonical memory file in your cloud sync folder (OneDrive, iCloud, Google Drive) +2. Keeping `~/.claude/CLAUDE.md` in sync with that file automatically +3. Using the Claude API to intelligently merge your session notes into the memory file + +After a good session you run one command and the memory updates itself. + +--- + +## Before you start: what you'll need + +- A computer running macOS, Windows, or Linux +- Python 3.11 or newer +- An Anthropic API key +- One of: OneDrive, iCloud Drive, or Google Drive (or any folder you can specify manually) + +The sections below walk you through each prerequisite. + +--- + +## Step 1 — Check if Python is installed + +Open a terminal: +- **Mac:** press `Cmd + Space`, type `Terminal`, press Enter +- **Windows:** press `Win + R`, type `cmd`, press Enter (or search for "PowerShell") +- **Linux:** you already know how to do this + +Type this and press Enter: + +``` +python --version +``` + +You should see something like `Python 3.12.1`. If you see `Python 3.11` or higher, you +are good. Skip to Step 2. + +If you see `Python 3.9` or lower, or `command not found`, you need to install or update +Python. + +### Installing Python + +Go to [python.org/downloads](https://www.python.org/downloads/) and download the +latest version (3.12 or 3.13). + +**Windows:** Run the installer. On the first screen, check **"Add Python to PATH"** +before clicking Install. This is easy to miss and important. + +**Mac:** The python.org installer works fine. Alternatively, if you use Homebrew: +`brew install python` + +**Linux:** `sudo apt install python3 python3-pip` (Debian/Ubuntu) or +`sudo dnf install python3` (Fedora). + +After installing, close and reopen your terminal, then run `python --version` again to +confirm. + +--- + +## Step 2 — Get an Anthropic API key + +memsync uses the Claude API to update your memory file. You need an API key to +authenticate with Anthropic's servers. + +1. Go to [console.anthropic.com](https://console.anthropic.com) and sign in or + create an account. +2. In the left sidebar, click **API Keys**. +3. Click **Create Key**, give it a name like "memsync", and copy the key. It starts + with `sk-ant-...`. + +Keep this key somewhere safe. You won't be able to see it again on the Anthropic website. + +### Setting the API key in your terminal + +memsync looks for the key in an environment variable called `ANTHROPIC_API_KEY`. +You need to set this so it's available every time you open a terminal. + +**Mac / Linux:** + +Open your shell config file in a text editor. If you use zsh (default on modern Macs): + +```bash +open -e ~/.zshrc +``` + +If you use bash: + +```bash +open -e ~/.bashrc +``` + +Add this line at the bottom, replacing `your-key-here` with your actual key: + +``` +export ANTHROPIC_API_KEY="sk-ant-your-key-here" +``` + +Save the file, then close and reopen your terminal. To verify it worked: + +```bash +echo $ANTHROPIC_API_KEY +``` + +You should see your key printed back. + +**Windows (PowerShell):** + +To set it permanently for your user account, run this in PowerShell (replacing the key): + +```powershell +[System.Environment]::SetEnvironmentVariable("ANTHROPIC_API_KEY", "sk-ant-your-key-here", "User") +``` + +Close and reopen PowerShell to pick up the change. To verify: + +```powershell +echo $env:ANTHROPIC_API_KEY +``` + +--- + +## Step 3 — Install memsync + +With Python installed and your API key set, installing memsync is one command: + +```bash +pip install memsync +``` + +This downloads memsync and its dependencies from the internet. + +> **If `pip` is not found on Mac/Linux**, try `pip3 install memsync` instead. +> +> **If you see a permissions error on Mac/Linux**, try: +> `pip install memsync --user` +> +> **If you see a permissions error on Windows**, right-click PowerShell and choose +> "Run as administrator", then try again. + +To confirm the installation worked: + +```bash +memsync --version +``` + +You should see something like `memsync 0.2.0`. + +--- + +## Step 4 — Set up your memory file + +Run the setup command: + +```bash +memsync init +``` + +memsync will look for your cloud sync folder automatically. What happens next depends +on what it finds: + +- **One provider detected:** It confirms the path and sets up immediately. +- **Multiple providers detected:** It lists them and asks you to choose one. +- **Nothing detected:** It asks you to specify a path manually (see below). + +### If auto-detection fails + +If memsync can't find your cloud folder, you can tell it where to look: + +```bash +memsync init --sync-root /path/to/your/cloud/folder +``` + +Some common paths: + +| Service | Mac | Windows | +|---|---|---| +| OneDrive | `~/OneDrive` | `C:\Users\YourName\OneDrive` | +| iCloud | `~/Library/Mobile Documents/com~apple~CloudDocs` | `C:\Users\YourName\iCloudDrive` | +| Google Drive | `~/Google Drive` | `G:\My Drive` | +| Dropbox | `~/Dropbox` | `C:\Users\YourName\Dropbox` | + +Example: + +```bash +memsync init --sync-root ~/OneDrive +``` + +### What init creates + +After a successful `init`, you'll see output like: + +``` +memsync initialized. + + Provider: OneDrive + Sync root: /Users/ian/OneDrive + Memory: /Users/ian/OneDrive/.claude-memory/GLOBAL_MEMORY.md + CLAUDE.md: /Users/ian/.claude/CLAUDE.md → (symlink) +``` + +Two important things were created: + +1. **`GLOBAL_MEMORY.md`** — your memory file, living in your cloud folder so it syncs + across all your machines. +2. **`~/.claude/CLAUDE.md`** — a link (or copy on Windows) that points to your memory + file. Claude Code reads this at the start of every session. + +--- + +## Step 5 — Fill in your memory file + +Your memory file starts empty with placeholder prompts. You need to fill it in. + +Find the file at the path shown by `memsync init` — it ends in +`/.claude-memory/GLOBAL_MEMORY.md`. Open it in any text editor: + +**Mac:** `open -a TextEdit ~/OneDrive/.claude-memory/GLOBAL_MEMORY.md` +**Windows:** Navigate to the file in File Explorer and open it in Notepad. + +You'll see this starter template: + +```markdown +# Global Memory + +> Loaded by Claude Code at session start on all machines and projects. +> Edit directly or run: memsync refresh --notes "..." + +## Identity & context +- (Fill this in — who you are, your roles, active projects) + +## Current priorities +- (What you're working on right now) + +## Standing preferences +- (How you like to work — communication style, output format, etc.) + +## Hard constraints +- (Rules that must never be lost or softened through compaction) +``` + +Replace the placeholders with real content. Here's an example of a filled-in file: + +```markdown +# Global Memory + +## Identity & context +- Jamie, product manager at a fintech startup +- Side project: building a personal budgeting app in Python +- Work machine: Windows laptop. Home machine: MacBook Pro. +- Comfortable reading code, less comfortable writing it from scratch + +## Current priorities +- Finish MVP of budgeting app by end of month +- Q2 roadmap presentation to leadership next Tuesday +- Onboarding new engineer starting Monday + +## Standing preferences +- Keep explanations concise — I can ask for more if needed +- When writing code, explain what each part does +- Prefer simple solutions over clever ones +- Always ask before deleting or overwriting anything + +## Hard constraints +- Never commit API keys or passwords to code +- Always confirm before making changes that can't be undone +``` + +A few tips: +- **Be specific.** "I'm a product manager" is less useful than "Jamie, PM at a fintech startup, working on the mobile app." +- **Keep it short.** This file has a soft limit of 400 lines. If it's getting long, you're putting too much in it. +- **Hard constraints are enforced.** Items in the `## Hard constraints` section are + never removed during automatic updates — memsync checks for this in code. + +For more guidance on what to include, see `docs/global-memory-guide.md`. + +--- + +## Step 6 — Verify everything is connected + +Run: + +```bash +memsync status +``` + +You should see something like: + +``` +Platform: macOS (Darwin) +Config: /Users/jamie/.config/memsync/config.toml ✓ +Provider: onedrive +Model: claude-sonnet-4-20250514 +Sync root: /Users/jamie/OneDrive ✓ +Memory: /Users/jamie/OneDrive/.claude-memory/GLOBAL_MEMORY.md ✓ +CLAUDE.md: /Users/jamie/.claude/CLAUDE.md → symlink ✓ +Backups: 0 file(s) +Session logs: 0 day(s) +``` + +Every line should show a `✓`. If anything shows `✗`, see the Troubleshooting section +at the end of this guide. + +You can also run the built-in health check: + +```bash +memsync doctor +``` + +This checks each component and tells you exactly what's wrong if something isn't set up. + +--- + +## Your daily workflow + +Once set up, using memsync takes about 30 seconds at the end of a session. + +### After a productive session + +At the end of a Claude Code session where something important happened — a decision was made, a problem was solved, a preference was discovered — run: + +```bash +memsync refresh --notes "What happened in this session" +``` + +Your notes can be as brief or as detailed as you want. Examples: + +```bash +memsync refresh --notes "Finished the auth module. Decided to use JWT tokens instead of sessions — simpler for our use case." + +memsync refresh --notes "Discovered that the CSV parser breaks on files with Windows line endings. Fixed it with universal newlines mode." + +memsync refresh --notes "Switched from Flask to FastAPI for the API. Flask felt too verbose." +``` + +The Claude API reads your notes and your current memory file, decides what to update, +and writes a new version of the file. The old version is backed up automatically. + +You'll see output like: + +``` +Refreshing global memory... done. + Backup: /Users/jamie/OneDrive/.claude-memory/backups/GLOBAL_MEMORY_20260321_143022.md + Memory: /Users/jamie/OneDrive/.claude-memory/GLOBAL_MEMORY.md + CLAUDE.md synced ✓ +``` + +### When nothing important changed + +If you had a routine session with no decisions or changes worth remembering, you don't +need to run refresh. It's a deliberate action for meaningful updates, not a mandatory +end-of-session ritual. + +### Preview before writing + +Not sure what the refresh will do? Use `--dry-run`: + +```bash +memsync refresh --notes "your notes" --dry-run +``` + +This shows you what the updated file would look like (as a diff) without writing +anything. Nothing is changed until you run the command without `--dry-run`. + +--- + +## Useful commands to know + +### See your current memory + +```bash +memsync show +``` + +Prints the full contents of your memory file to the terminal. + +### See what changed in the last refresh + +```bash +memsync diff +``` + +Shows a side-by-side comparison of the current memory file vs the most recent backup. +Lines starting with `+` were added; lines starting with `-` were removed. + +### Read notes from a file + +If you've been writing session notes in a text file as you go: + +```bash +memsync refresh --file my-notes.txt +``` + +### Pipe notes from another command + +```bash +echo "Switched to the new deploy pipeline, everything works" | memsync refresh +``` + +--- + +## Setting up on a second computer + +One of the main benefits of memsync is that your memory syncs across machines +through your cloud folder. + +On each new machine, you just need to: + +1. Install Python (Step 1) +2. Set your `ANTHROPIC_API_KEY` (Step 2) +3. Install memsync: `pip install memsync` +4. Run `memsync init` — it will find the same cloud folder, which already has + `GLOBAL_MEMORY.md` in it + +That's it. The memory file already exists; init just wires up the local link. + +--- + +## Troubleshooting + +### "memsync: command not found" + +Python installed the memsync command somewhere your terminal can't find it. + +**Mac/Linux fix:** +```bash +pip install memsync --user +``` +Then add the user bin directory to your PATH. On Mac, add this to `~/.zshrc`: +``` +export PATH="$HOME/.local/bin:$PATH" +``` + +**Windows fix:** Close and reopen PowerShell. If it still doesn't work, try running: +``` +python -m memsync --version +``` +If that works, Python's Scripts directory isn't in your PATH. Search online for +"add Python scripts to PATH Windows". + +--- + +### "Error: provider 'onedrive' could not find its sync folder" + +memsync can't find your cloud sync folder. Tell it where to look: + +```bash +memsync config set sync_root /full/path/to/your/cloud/folder +``` + +Then run `memsync status` to confirm it's found. + +--- + +### "ANTHROPIC_API_KEY" shows ✗ in memsync doctor + +The API key isn't set in your environment. Go back to Step 2 and follow the instructions +for your operating system. Remember to close and reopen the terminal after adding the key. + +To test it immediately without reopening: + +```bash +# Mac/Linux +export ANTHROPIC_API_KEY="sk-ant-your-key-here" +memsync doctor +``` + +--- + +### "Error: API request failed" + +Usually means the API key is wrong or has been revoked. Check it at +[console.anthropic.com](https://console.anthropic.com) under API Keys. + +If the key is correct, check your internet connection. + +--- + +### "CLAUDE.md: ✗ (not synced)" + +The link between your cloud memory file and Claude Code's config file is broken. +Re-run init to fix it: + +```bash +memsync init --force +``` + +--- + +### "Error: API response was truncated" + +Your memory file or session notes are very long, and the Claude API hit its response +limit before finishing. The file was NOT updated. + +Fix: edit your memory file (`memsync show`, then open the file in a text editor) and +remove anything that isn't pulling its weight. Aim for under 300 lines if you're +hitting this regularly. + +--- + +### On Windows: CLAUDE.md is a copy, not a link + +This is expected, not a bug. Windows requires administrator rights to create symlinks, +which memsync doesn't ask for. Instead it copies the file. The copy is updated every +time you run `memsync refresh`, so it stays current. + +--- + +### Something else went wrong + +Run the health check: + +```bash +memsync doctor +``` + +It checks every component and prints exactly what's wrong with a ✗. Fix the flagged +items and run it again. + +If you're still stuck, you can see more detail by running memsync with verbose Python +error output: + +```bash +python -m memsync status +``` + +--- + +## Keeping your memory file healthy + +A few habits that make the memory file more useful over time: + +**Update it after real changes, not routine sessions.** If you spent two hours debugging +but nothing changed about your goals or preferences, you don't need to refresh. + +**Keep the Hard constraints section intentional.** Only put things there that are +genuinely non-negotiable — rules you've been burned by before or preferences so +strong that "sometimes" isn't acceptable. This section is enforced in code; everything +in it persists forever. + +**Edit the file directly when needed.** The refresh command is for session notes, but +you can open the file in any text editor and change it by hand. If you do, sync the +copy afterward: + +```bash +memsync refresh --notes "Edited memory file directly — removed outdated project." +``` + +**Clean up old backups occasionally:** + +```bash +memsync prune --keep-days 30 +``` + +This removes backups older than 30 days. The default is already 30 days, so you can +also just run `memsync prune`. + +--- + +## What the memory file looks like from Claude's perspective + +Every time you open Claude Code in any project, it reads `~/.claude/CLAUDE.md` first. +Your memory file is loaded before any project-specific instructions. Claude sees +your identity, current priorities, and preferences before it reads a single line of +your project. + +This is why specific, personal content in the memory file works better than generic +descriptions. Claude doesn't need "I prefer clear code" — that's assumed. It does +benefit from "I'm building a budgeting app in Python, currently debugging the CSV +import, and I prefer not to have tests suggested unless I ask." + +The shorter and more specific the file, the more it helps. diff --git a/docs/global-memory-guide.md b/docs/global-memory-guide.md new file mode 100644 index 0000000..6d3837c --- /dev/null +++ b/docs/global-memory-guide.md @@ -0,0 +1,116 @@ +# What to put in GLOBAL_MEMORY.md + +This is the file Claude Code reads at the start of every session. It's your **identity layer** — not a project wiki, not a knowledge base. Keep it tight and personal. + +--- + +## The starter template + +When you run `memsync init`, you get this: + +```markdown + +# Global Memory + +> Loaded by Claude Code at session start on all machines and projects. +> Edit directly or run: memsync refresh --notes "..." + +## Identity & context +- (Fill this in — who you are, your roles, active projects) + +## Current priorities +- (What you're working on right now) + +## Standing preferences +- (How you like to work — communication style, output format, etc.) + +## Hard constraints +- (Rules that must never be lost or softened through compaction) +``` + +Fill it in. The section names are your structure — keep them. + +--- + +## Identity & context + +Who you are and what you're working on. Claude reads this cold at every session. + +```markdown +## Identity & context +- Ian, product leader at a B2B SaaS company +- Side projects: memsync (Python CLI), personal finance tracker (Go) +- Background: 10 years PM, comfortable with code but not a full-time engineer +- Working across: Mac (home), Windows (work) +``` + +--- + +## Current priorities + +What's active right now. This section gets updated most often by `memsync refresh`. + +```markdown +## Current priorities +- memsync v0.2: finish tests and CI, publish to PyPI +- Q2 planning deck due April 15 +- Hiring: two PM openings, first round interviews next week +``` + +Completed items get demoted to a brief "Recent completions" section automatically during refresh. They don't stay forever — that's what session logs are for. + +--- + +## Standing preferences + +How you like to work. These persist across all projects and sessions. + +```markdown +## Standing preferences +- Prefer concise output — skip the preamble, just give me the thing +- Code: Python 3.11+, pathlib everywhere, no magic, no cleverness +- Writing: active voice, short sentences, no bullet-point summaries unless asked +- Don't suggest tests unless I ask — I know when I need them +- When in doubt, ask one clarifying question rather than guessing +``` + +--- + +## Hard constraints + +Rules that must never be removed or softened, no matter how much the memory compacts. Claude checks this section in Python code — it's enforced, not just prompted. + +```markdown +## Hard constraints +- Never hardcode credentials or API keys in any code I write +- Always ask before deleting files or making destructive changes +- Never rewrite from scratch — refactor what exists +- Don't add emoji to output unless I explicitly ask +``` + +Good candidates: safety rules, things that bit you in the past, preferences so strong that "sometimes" isn't acceptable. + +--- + +## What NOT to put here + +- **Project-specific docs** — those go in each project's `CLAUDE.md` +- **Reference material** — API docs, schemas, architecture diagrams +- **Cold storage** — old project summaries, historical context +- **Everything** — this file has a soft cap of ~400 lines. If it's getting long, you have too much in it. + +The memory file should read like a dense briefing note, not a wiki. If Claude can derive something from the project files, it doesn't need to be here. + +--- + +## Keeping it current + +After any session where something important shifted — a decision made, a priority changed, a preference discovered — run: + +```bash +memsync refresh --notes "Decided to use JWT auth, not sessions. Slower but simpler for our use case." +``` + +The Claude API reads your notes and the current memory file, and updates it accordingly. The old version is backed up automatically. + +You can also edit `GLOBAL_MEMORY.md` directly at any time. Just run `memsync refresh` afterward (even with minimal notes) to sync the copy to `~/.claude/CLAUDE.md`. diff --git a/memsync.egg-info/PKG-INFO b/memsync.egg-info/PKG-INFO new file mode 100644 index 0000000..eeab28f --- /dev/null +++ b/memsync.egg-info/PKG-INFO @@ -0,0 +1,213 @@ +Metadata-Version: 2.4 +Name: memsync +Version: 0.2.0 +Summary: Cross-platform global memory manager for Claude Code +License: MIT +Project-URL: Homepage, https://github.com/YOUR_USERNAME/memsync +Project-URL: Issues, https://github.com/YOUR_USERNAME/memsync/issues +Keywords: claude,claude-code,ai,memory,cli +Classifier: Development Status :: 3 - Alpha +Classifier: Environment :: Console +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: MIT License +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Classifier: Operating System :: MacOS +Classifier: Operating System :: Microsoft :: Windows +Classifier: Operating System :: POSIX :: Linux +Requires-Python: >=3.11 +Description-Content-Type: text/markdown +License-File: LICENSE +Requires-Dist: anthropic>=0.40.0 +Provides-Extra: dev +Requires-Dist: pytest>=8.0; extra == "dev" +Requires-Dist: pytest-cov>=5.0; extra == "dev" +Requires-Dist: pytest-mock>=3.12; extra == "dev" +Requires-Dist: ruff>=0.4; extra == "dev" +Requires-Dist: bandit[toml]>=1.7; extra == "dev" +Dynamic: license-file + +# memsync + +Cross-platform global memory manager for Claude Code. + +Claude Code has no memory between sessions. memsync fixes that: it maintains one canonical `GLOBAL_MEMORY.md` in your cloud sync folder, linked to `~/.claude/CLAUDE.md` so Claude Code reads it at every session start. + +After a meaningful session, run `memsync refresh --notes "..."` and the Claude API merges your notes into the memory file automatically. + +--- + +## How it works + +``` +OneDrive/.claude-memory/ + GLOBAL_MEMORY.md ← source of truth, synced across all machines + backups/ ← automatic backups before every refresh + sessions/ ← raw session notes, append-only audit trail + +~/.claude/CLAUDE.md ← symlink → GLOBAL_MEMORY.md (Mac/Linux) + copy of GLOBAL_MEMORY.md (Windows) +``` + +Every Claude Code session starts by reading `~/.claude/CLAUDE.md`. memsync keeps it current. + +--- + +## Requirements + +- Python 3.11+ +- An Anthropic API key (`ANTHROPIC_API_KEY` env var) +- One of: OneDrive, iCloud Drive, Google Drive — or any folder you specify + +--- + +## Installation + +```bash +pip install memsync +``` + +--- + +## Quick start + +```bash +# 1. Initialize (auto-detects your cloud provider) +memsync init + +# 2. Edit your memory file — fill in who you are, active projects, preferences +# File is at: OneDrive/.claude-memory/GLOBAL_MEMORY.md + +# 3. After a Claude Code session, merge in your notes +memsync refresh --notes "Finished the auth module. Decided to use JWT tokens, not sessions." + +# 4. Check everything is wired up +memsync status +``` + +--- + +## Commands + +| Command | Description | +|---|---| +| `memsync init` | First-time setup: create directory structure, sync to CLAUDE.md | +| `memsync refresh --notes "..."` | Merge session notes into memory via Claude API | +| `memsync show` | Print current GLOBAL_MEMORY.md | +| `memsync diff` | Diff current memory vs last backup | +| `memsync status` | Show paths, provider, sync state | +| `memsync providers` | List all providers and detection status | +| `memsync config show` | Print current config | +| `memsync config set ` | Update a config value | +| `memsync prune` | Remove old backups | + +### `memsync refresh` options + +```bash +memsync refresh --notes "inline notes" +memsync refresh --file notes.txt +echo "notes" | memsync refresh +memsync refresh --notes "..." --dry-run # preview changes, no write +memsync refresh --notes "..." --model claude-opus-4-20250514 # one-off model override +``` + +### `memsync init` options + +```bash +memsync init # auto-detect provider +memsync init --provider icloud # use a specific provider +memsync init --sync-root /path/to/folder # use a custom path +memsync init --force # reinitialize even if already set up +``` + +### `memsync config set` keys + +```bash +memsync config set provider icloud +memsync config set model claude-opus-4-20250514 +memsync config set sync_root /path/to/custom/folder +memsync config set keep_days 60 +memsync config set max_memory_lines 300 +memsync config set claude_md_target ~/.claude/CLAUDE.md +``` + +--- + +## Cloud providers + +| Provider | macOS | Windows | Linux | +|---|---|---|---| +| OneDrive | ✓ | ✓ | ✓ (rclone) | +| iCloud Drive | ✓ | ✓ | ✗ | +| Google Drive | ✓ | ✓ | ✓ (rclone) | +| Custom path | ✓ | ✓ | ✓ | + +Detection is automatic. If multiple providers are found during `memsync init`, you'll be prompted to choose. + +**Windows note:** Symlinks require admin rights or Developer Mode on Windows. memsync copies `GLOBAL_MEMORY.md` to `~/.claude/CLAUDE.md` instead. The copy is refreshed on every `memsync refresh`. + +**iCloud note:** iCloud Drive doesn't sync dot-folders on Mac. memsync stores data in `claude-memory/` (no leading dot) when using the iCloud provider. + +--- + +## Configuration + +Config file location: +- macOS/Linux: `~/.config/memsync/config.toml` +- Windows: `%APPDATA%\memsync\config.toml` + +Config is machine-specific — two machines can use different providers pointing to the same cloud storage location. + +Example config: + +```toml +[core] +provider = "onedrive" +model = "claude-sonnet-4-20250514" +max_memory_lines = 400 + +[paths] +claude_md_target = "/Users/ian/.claude/CLAUDE.md" + +[backups] +keep_days = 30 +``` + +To update the model when Anthropic releases new ones: + +```bash +memsync config set model claude-sonnet-4-20250514 +``` + +--- + +## What belongs in GLOBAL_MEMORY.md + +The memory file is your **identity layer** — not a knowledge base, not project docs. + +Good things to include: +- Who you are, your roles, active projects +- Current priorities and focus +- Standing preferences (communication style, output format) +- Hard constraints (rules that must never be softened through compaction) + +See `docs/global-memory-guide.md` for a complete guide. + +--- + +## Known limitations + +- **Concurrent writes:** Running `memsync refresh` on two machines simultaneously will result in the last write winning. The losing write's backup is in `backups/`. Risk is low since refresh is a deliberate manual action. +- **Max memory size:** The memory file is kept under ~400 lines. Very dense files may hit the 4096 token response limit — reduce the file size if you see truncation errors. + +--- + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md). To add a new cloud provider, see [docs/adding-a-provider.md](docs/adding-a-provider.md). + +--- + +## License + +MIT diff --git a/memsync.egg-info/SOURCES.txt b/memsync.egg-info/SOURCES.txt new file mode 100644 index 0000000..9979cb0 --- /dev/null +++ b/memsync.egg-info/SOURCES.txt @@ -0,0 +1,26 @@ +LICENSE +README.md +pyproject.toml +memsync/__init__.py +memsync/backups.py +memsync/claude_md.py +memsync/cli.py +memsync/config.py +memsync/sync.py +memsync.egg-info/PKG-INFO +memsync.egg-info/SOURCES.txt +memsync.egg-info/dependency_links.txt +memsync.egg-info/entry_points.txt +memsync.egg-info/requires.txt +memsync.egg-info/top_level.txt +memsync/providers/__init__.py +memsync/providers/custom.py +memsync/providers/gdrive.py +memsync/providers/icloud.py +memsync/providers/onedrive.py +tests/test_backups.py +tests/test_claude_md.py +tests/test_cli.py +tests/test_config.py +tests/test_providers.py +tests/test_sync.py \ No newline at end of file diff --git a/memsync.egg-info/dependency_links.txt b/memsync.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/memsync.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/memsync.egg-info/entry_points.txt b/memsync.egg-info/entry_points.txt new file mode 100644 index 0000000..64b942e --- /dev/null +++ b/memsync.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[console_scripts] +memsync = memsync.cli:main diff --git a/memsync.egg-info/requires.txt b/memsync.egg-info/requires.txt new file mode 100644 index 0000000..d6ddded --- /dev/null +++ b/memsync.egg-info/requires.txt @@ -0,0 +1,8 @@ +anthropic>=0.40.0 + +[dev] +pytest>=8.0 +pytest-cov>=5.0 +pytest-mock>=3.12 +ruff>=0.4 +bandit[toml]>=1.7 diff --git a/memsync.egg-info/top_level.txt b/memsync.egg-info/top_level.txt new file mode 100644 index 0000000..0a34709 --- /dev/null +++ b/memsync.egg-info/top_level.txt @@ -0,0 +1 @@ +memsync diff --git a/memsync/__init__.py b/memsync/__init__.py new file mode 100644 index 0000000..d3ec452 --- /dev/null +++ b/memsync/__init__.py @@ -0,0 +1 @@ +__version__ = "0.2.0" diff --git a/memsync/__pycache__/__init__.cpython-313.pyc b/memsync/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..862e518 Binary files /dev/null and b/memsync/__pycache__/__init__.cpython-313.pyc differ diff --git a/memsync/__pycache__/backups.cpython-313.pyc b/memsync/__pycache__/backups.cpython-313.pyc new file mode 100644 index 0000000..a892224 Binary files /dev/null and b/memsync/__pycache__/backups.cpython-313.pyc differ diff --git a/memsync/__pycache__/claude_md.cpython-313.pyc b/memsync/__pycache__/claude_md.cpython-313.pyc new file mode 100644 index 0000000..090e959 Binary files /dev/null and b/memsync/__pycache__/claude_md.cpython-313.pyc differ diff --git a/memsync/__pycache__/cli.cpython-313.pyc b/memsync/__pycache__/cli.cpython-313.pyc new file mode 100644 index 0000000..9d5e0bb Binary files /dev/null and b/memsync/__pycache__/cli.cpython-313.pyc differ diff --git a/memsync/__pycache__/config.cpython-313.pyc b/memsync/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000..a4e8f7a Binary files /dev/null and b/memsync/__pycache__/config.cpython-313.pyc differ diff --git a/memsync/__pycache__/sync.cpython-313.pyc b/memsync/__pycache__/sync.cpython-313.pyc new file mode 100644 index 0000000..ab81109 Binary files /dev/null and b/memsync/__pycache__/sync.cpython-313.pyc differ diff --git a/memsync/backups.py b/memsync/backups.py new file mode 100644 index 0000000..854d31a --- /dev/null +++ b/memsync/backups.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import shutil +from datetime import datetime, timedelta +from pathlib import Path + + +def backup(source: Path, backup_dir: Path) -> Path: + """ + Copy source to backup_dir with a timestamp suffix. + Returns the path of the new backup file. + """ + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + dest = backup_dir / f"GLOBAL_MEMORY_{timestamp}.md" + shutil.copy2(source, dest) + return dest + + +def prune(backup_dir: Path, keep_days: int) -> list[Path]: + """ + Delete backups older than keep_days. Returns list of deleted paths. + """ + cutoff = datetime.now() - timedelta(days=keep_days) + deleted: list[Path] = [] + + for backup_file in backup_dir.glob("GLOBAL_MEMORY_*.md"): + try: + ts_str = backup_file.stem.replace("GLOBAL_MEMORY_", "") + ts = datetime.strptime(ts_str, "%Y%m%d_%H%M%S") + if ts < cutoff: + backup_file.unlink() + deleted.append(backup_file) + except ValueError: + pass # skip files with unexpected names + + return deleted + + +def list_backups(backup_dir: Path) -> list[Path]: + """Return all backups sorted newest-first.""" + backups = list(backup_dir.glob("GLOBAL_MEMORY_*.md")) + return sorted(backups, reverse=True) + + +def latest_backup(backup_dir: Path) -> Path | None: + """Return the most recent backup, or None if no backups exist.""" + backups = list_backups(backup_dir) + return backups[0] if backups else None diff --git a/memsync/claude_md.py b/memsync/claude_md.py new file mode 100644 index 0000000..61ccf83 --- /dev/null +++ b/memsync/claude_md.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import platform +import shutil +from pathlib import Path + + +def sync(memory_path: Path, target_path: Path) -> None: + """ + Keep target_path (CLAUDE.md) in sync with memory_path (GLOBAL_MEMORY.md). + + Mac/Linux: create a symlink. If a non-memsync file already exists at the + target, back it up first (.pre-memsync.bak) so user data is never lost. + + Windows: always copy — symlinks require admin/Developer Mode. The copy is + refreshed on every `memsync refresh`, so drift is acceptable in practice. + """ + target_path.parent.mkdir(parents=True, exist_ok=True) + + if platform.system() == "Windows": + shutil.copy2(memory_path, target_path) + return + + # Mac / Linux — prefer symlink + if target_path.is_symlink(): + if target_path.resolve() == memory_path.resolve(): + return # already correct + target_path.unlink() + + if target_path.exists(): + # Back up any existing file before replacing it + target_path.rename(target_path.with_suffix(".pre-memsync.bak")) + + try: + target_path.symlink_to(memory_path) + except OSError: + # Fallback to copy if symlink creation fails (e.g. cross-device) + shutil.copy2(memory_path, target_path) + + +def is_synced(memory_path: Path, target_path: Path) -> bool: + """ + Return True if target_path points at (or has the same content as) memory_path. + """ + if not target_path.exists(): + return False + + if target_path.is_symlink(): + return target_path.resolve() == memory_path.resolve() + + # Windows copy path — compare content + try: + return target_path.read_bytes() == memory_path.read_bytes() + except OSError: + return False diff --git a/memsync/cli.py b/memsync/cli.py new file mode 100644 index 0000000..40fc4b7 --- /dev/null +++ b/memsync/cli.py @@ -0,0 +1,953 @@ +from __future__ import annotations + +import argparse +import dataclasses +import difflib +import platform +import sys +from pathlib import Path + +import anthropic + +from memsync import __version__ +from memsync.backups import backup, latest_backup, list_backups, prune +from memsync.claude_md import sync as sync_claude_md +from memsync.config import Config, get_config_path +from memsync.providers import all_providers, auto_detect, get_provider +from memsync.sync import load_or_init_memory, log_session_notes, refresh_memory_content + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _resolve_memory_root(config: Config) -> Path | None: + """ + Return the .claude-memory root directory for this machine. + Uses config.sync_root if set, otherwise asks the configured provider to detect. + """ + if config.sync_root: + sync_root = config.sync_root + else: + try: + provider = get_provider(config.provider) + except KeyError as e: + print(f"Error: {e}", file=sys.stderr) + return None + sync_root = provider.detect() + if sync_root is None: + print( + f"Error: provider '{config.provider}' could not find its sync folder.\n" + "Run 'memsync init' or set a custom path with:\n" + " memsync config set sync_root /path/to/folder", + file=sys.stderr, + ) + return None + provider_instance = provider + return provider_instance.get_memory_root(sync_root) + + try: + provider = get_provider(config.provider) + return provider.get_memory_root(sync_root) + except KeyError: + # Custom path with unknown provider name — use default .claude-memory + return sync_root / ".claude-memory" + + +def _require_memory_root(config: Config) -> tuple[Path, int] | tuple[None, int]: + """ + Resolve memory root and check it exists. Returns (path, 0) or (None, exit_code). + """ + memory_root = _resolve_memory_root(config) + if memory_root is None: + return None, 4 + if not memory_root.exists(): + print( + "Error: memory directory not found. Run 'memsync init' first.", + file=sys.stderr, + ) + return None, 2 + return memory_root, 0 + + +# --------------------------------------------------------------------------- +# Commands +# --------------------------------------------------------------------------- + +def cmd_init(args: argparse.Namespace, config: Config) -> int: + """Set up memory structure for the first time.""" + # Check if already initialized (unless --force) + if get_config_path().exists() and not args.force: + print("memsync already initialized. Use --force to reinitialize.") + return 0 + + # Resolve provider + if args.sync_root: + sync_root = Path(args.sync_root).expanduser() + if not sync_root.exists(): + print(f"Error: path does not exist: {sync_root}", file=sys.stderr) + return 1 + provider_name = args.provider or "custom" + try: + provider = get_provider(provider_name) + except KeyError: + provider = get_provider("custom") + provider_name = "custom" + memory_root = provider.get_memory_root(sync_root) + + elif args.provider: + try: + provider = get_provider(args.provider) + except KeyError as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + sync_root = provider.detect() + if sync_root is None: + print( + f"Error: provider '{args.provider}' could not find its sync folder.\n" + f"Try: memsync init --sync-root /path/to/folder", + file=sys.stderr, + ) + return 4 + memory_root = provider.get_memory_root(sync_root) + provider_name = args.provider + + else: + # Auto-detect + detected = auto_detect() + if not detected: + print( + "Error: no cloud sync folder detected.\n" + "Run with --sync-root to specify a path manually:\n" + " memsync init --sync-root /path/to/sync/folder", + file=sys.stderr, + ) + return 4 + + if len(detected) == 1: + provider = detected[0] + sync_root = provider.detect() + memory_root = provider.get_memory_root(sync_root) + provider_name = provider.name + else: + # Multiple detected — ask user to choose + print("Multiple sync providers detected:") + for i, p in enumerate(detected, 1): + path = p.detect() + print(f" {i}. {p.display_name} ({path})") + while True: + choice = input(f"Choose [1-{len(detected)}]: ").strip() + if choice.isdigit() and 1 <= int(choice) <= len(detected): + provider = detected[int(choice) - 1] + sync_root = provider.detect() + memory_root = provider.get_memory_root(sync_root) + provider_name = provider.name + break + print("Invalid choice.") + + # Create directory structure + for subdir in (memory_root, memory_root / "backups", memory_root / "sessions"): + subdir.mkdir(parents=True, exist_ok=True) + + # Write starter memory if not present (--force skips this check) + global_memory = memory_root / "GLOBAL_MEMORY.md" + if not global_memory.exists() or args.force: + starter = load_or_init_memory(Path("/nonexistent/force-new")) + global_memory.write_text(starter, encoding="utf-8") + + # Write config + new_config = Config( + provider=provider_name, + sync_root=sync_root if provider_name == "custom" else None, + ) + new_config.save() + + # Sync to CLAUDE.md + sync_claude_md(global_memory, new_config.claude_md_target) + + print("memsync initialized.\n") + print(f" Provider: {provider.display_name}") + print(f" Sync root: {sync_root}") + print(f" Memory: {global_memory}") + target = new_config.claude_md_target + if target.is_symlink(): + print(f" CLAUDE.md: {target} → (symlink)") + else: + print(f" CLAUDE.md: {target}") + print() + print("Next: edit your memory file, then run:") + print(' memsync refresh --notes "initial setup complete"') + return 0 + + +def cmd_refresh(args: argparse.Namespace, config: Config) -> int: + """Merge session notes into GLOBAL_MEMORY.md via the Claude API.""" + # Gather notes + notes = "" + if args.notes: + notes = args.notes + elif args.file: + note_path = Path(args.file) + if not note_path.exists(): + print(f"Error: file not found: {args.file}", file=sys.stderr) + return 1 + notes = note_path.read_text(encoding="utf-8") + else: + if not sys.stdin.isatty(): + notes = sys.stdin.read() + else: + print( + "Error: provide --notes, --file, or pipe notes via stdin.", + file=sys.stderr, + ) + return 1 + + if not notes.strip(): + print("Error: notes are empty.", file=sys.stderr) + return 1 + + # Allow one-off model override without touching config + if args.model: + config = dataclasses.replace(config, model=args.model) + + # Resolve paths + memory_root, code = _require_memory_root(config) + if memory_root is None: + return code + + global_memory = memory_root / "GLOBAL_MEMORY.md" + if not global_memory.exists(): + print( + "Error: GLOBAL_MEMORY.md not found. Run 'memsync init' first.", + file=sys.stderr, + ) + return 3 + + current_memory = load_or_init_memory(global_memory) + + print("Refreshing global memory...", end=" ", flush=True) + + try: + result = refresh_memory_content(notes, current_memory, config) + except anthropic.BadRequestError as e: + if "model" in str(e).lower(): + print( + f"\nError: model '{config.model}' may be unavailable or misspelled.\n" + f"Update with: memsync config set model ", + file=sys.stderr, + ) + return 5 + raise + except anthropic.APIError as e: + print(f"\nError: API request failed: {e}", file=sys.stderr) + return 5 + + if args.dry_run: + print("\n[DRY RUN] No files written.\n") + if result["changed"]: + old_lines = current_memory.strip().splitlines(keepends=True) + new_lines = result["updated_content"].splitlines(keepends=True) + diff = difflib.unified_diff(old_lines, new_lines, fromfile="current", tofile="updated") + diff_text = "".join(diff) + if diff_text: + print("--- diff ---") + print(diff_text) + else: + print("No changes detected.") + return 0 + + if result["truncated"]: + print( + "\nError: API response was truncated (hit max_tokens limit).\n" + "Memory file was NOT updated. Try reducing your notes or memory file size.", + file=sys.stderr, + ) + return 5 + + if not result["changed"]: + print("no changes.") + return 0 + + # Backup then write + backup_path = backup(global_memory, memory_root / "backups") + global_memory.write_text(result["updated_content"], encoding="utf-8") + sync_claude_md(global_memory, config.claude_md_target) + log_session_notes(notes, memory_root / "sessions") + + print("done.") + print(f" Backup: {backup_path}") + print(f" Memory: {global_memory}") + print(" CLAUDE.md synced ✓") + return 0 + + +def cmd_show(args: argparse.Namespace, config: Config) -> int: + """Print current GLOBAL_MEMORY.md to stdout.""" + memory_root, code = _require_memory_root(config) + if memory_root is None: + return code + + global_memory = memory_root / "GLOBAL_MEMORY.md" + if not global_memory.exists(): + print("No global memory file yet. Run: memsync init", file=sys.stderr) + return 3 + + print(global_memory.read_text(encoding="utf-8")) + return 0 + + +def cmd_diff(args: argparse.Namespace, config: Config) -> int: + """Show unified diff between current memory and the most recent (or specified) backup.""" + memory_root, code = _require_memory_root(config) + if memory_root is None: + return code + + global_memory = memory_root / "GLOBAL_MEMORY.md" + if not global_memory.exists(): + print("No global memory file yet. Run: memsync init", file=sys.stderr) + return 3 + + backup_dir = memory_root / "backups" + + if args.backup: + backup_path = backup_dir / args.backup + if not backup_path.exists(): + print(f"Error: backup not found: {args.backup}", file=sys.stderr) + return 1 + else: + backup_path = latest_backup(backup_dir) + if backup_path is None: + print("No backups found.") + return 0 + + current = global_memory.read_text(encoding="utf-8").splitlines(keepends=True) + previous = backup_path.read_text(encoding="utf-8").splitlines(keepends=True) + + diff = list(difflib.unified_diff( + previous, current, + fromfile=f"backup ({backup_path.name})", + tofile="current", + )) + + if diff: + print("".join(diff)) + else: + print("No differences from last backup.") + return 0 + + +def cmd_status(args: argparse.Namespace, config: Config) -> int: + """Show paths, provider, and sync state.""" + system = platform.system() + _os_names = {"Darwin": "macOS (Darwin)", "Windows": "Windows", "Linux": "Linux"} + os_name = _os_names.get(system, system) + print(f"Platform: {os_name}") + + config_path = get_config_path() + config_marker = "✓" if config_path.exists() else "✗ (not found — run memsync init)" + print(f"Config: {config_path} {config_marker}") + print(f"Provider: {config.provider}") + print(f"Model: {config.model}") + + memory_root = _resolve_memory_root(config) + if memory_root is None: + return 4 + + if config.sync_root: + print(f"Sync root: {config.sync_root} {'✓' if config.sync_root.exists() else '✗'}") + else: + try: + provider = get_provider(config.provider) + sync_root = provider.detect() + label = str(sync_root) if sync_root else "(not detected)" + marker = "✓" if sync_root else "✗" + print(f"Sync root: {label} {marker}") + except KeyError: + print(f"Sync root: (unknown provider '{config.provider}')") + + global_memory = memory_root / "GLOBAL_MEMORY.md" + mem_marker = "✓" if global_memory.exists() else "✗ (run memsync init)" + print(f"Memory: {global_memory} {mem_marker}") + + target = config.claude_md_target + if target.is_symlink(): + print(f"CLAUDE.md: {target} → symlink ✓") + elif target.exists(): + print(f"CLAUDE.md: {target} ✓ (copy)") + else: + print(f"CLAUDE.md: {target} ✗ (not synced — run memsync init)") + + backup_dir = memory_root / "backups" + if backup_dir.exists(): + count = len(list_backups(backup_dir)) + print(f"Backups: {count} file(s)") + + session_dir = memory_root / "sessions" + if session_dir.exists(): + sessions = list(session_dir.glob("*.md")) + print(f"Session logs: {len(sessions)} day(s)") + + return 0 + + +def cmd_prune(args: argparse.Namespace, config: Config) -> int: + """Remove old backups.""" + memory_root, code = _require_memory_root(config) + if memory_root is None: + return code + + backup_dir = memory_root / "backups" + keep_days = args.keep_days if args.keep_days is not None else config.keep_days + + if args.dry_run: + from datetime import datetime, timedelta + cutoff = datetime.now() - timedelta(days=keep_days) + would_delete = [ + b for b in list_backups(backup_dir) + if _backup_timestamp(b) and _backup_timestamp(b) < cutoff + ] + if would_delete: + n = len(would_delete) + print(f"[DRY RUN] Would prune {n} backup(s) older than {keep_days} days:") + for p in would_delete: + print(f" {p.name}") + else: + print(f"[DRY RUN] No backups older than {keep_days} days.") + return 0 + + deleted = prune(backup_dir, keep_days=keep_days) + if deleted: + print(f"Pruned {len(deleted)} backup(s) older than {keep_days} days.") + for p in deleted: + print(f" removed: {p.name}") + else: + print(f"No backups older than {keep_days} days.") + return 0 + + +def _backup_timestamp(path: Path): + """Parse timestamp from backup filename, or return None.""" + from datetime import datetime + try: + ts_str = path.stem.replace("GLOBAL_MEMORY_", "") + return datetime.strptime(ts_str, "%Y%m%d_%H%M%S") + except ValueError: + return None + + +def cmd_providers(args: argparse.Namespace, config: Config) -> int: + """List all registered providers and their detection status.""" + print("Available providers:\n") + for provider in all_providers(): + detected_path = provider.detect() + if detected_path: + marker = f"✓ detected at {detected_path}" + else: + if provider.name == "custom": + marker = "✗ no path configured" + else: + marker = "✗ not detected" + print(f" {provider.name:<10} {provider.display_name:<18} {marker}") + + print(f"\nActive provider: {config.provider}") + return 0 + + +def cmd_doctor(args: argparse.Namespace, config: Config) -> int: + """ + Self-check: verify the installation is healthy without making any API calls. + Exits 0 if all checks pass, 1 if any check fails. + """ + import os + + checks: list[tuple[str, bool, str]] = [] # (label, ok, detail) + + # 1. Config file + config_path = get_config_path() + checks.append(("Config file", config_path.exists(), str(config_path))) + + # 2. ANTHROPIC_API_KEY set + api_key_set = bool(os.environ.get("ANTHROPIC_API_KEY")) + api_key_detail = "(set)" if api_key_set else "not set — refresh will fail" + checks.append(("ANTHROPIC_API_KEY", api_key_set, api_key_detail)) + + # 3. Provider / sync root accessible + if config.sync_root: + # Custom or explicit path — just verify it exists + provider_ok = config.sync_root.exists() + provider_detail = str(config.sync_root) + else: + try: + provider = get_provider(config.provider) + sync_root = provider.detect() + provider_ok = sync_root is not None + provider_detail = ( + str(sync_root) if sync_root else f"'{config.provider}' not detected on this machine" + ) + except KeyError: + provider_ok = False + provider_detail = f"unknown provider '{config.provider}'" + checks.append((f"Provider ({config.provider})", provider_ok, provider_detail)) + + # 4. Memory root exists + memory_root = _resolve_memory_root(config) + if memory_root: + mem_ok = memory_root.exists() + checks.append(("Memory directory", mem_ok, str(memory_root))) + + # 5. GLOBAL_MEMORY.md exists + global_memory = memory_root / "GLOBAL_MEMORY.md" + checks.append(("GLOBAL_MEMORY.md", global_memory.exists(), str(global_memory))) + + # 6. CLAUDE.md is synced + target = config.claude_md_target + from memsync.claude_md import is_synced + synced = global_memory.exists() and is_synced(global_memory, target) + detail = f"{target} → {'synced' if synced else 'not synced (run memsync init)'}" + checks.append(("CLAUDE.md synced", synced, detail)) + else: + checks.append(("Memory directory", False, "cannot resolve — fix provider first")) + + # Print results + all_ok = all(ok for _, ok, _ in checks) + print("memsync doctor\n") + for label, ok, detail in checks: + marker = "✓" if ok else "✗" + print(f" {marker} {label:<25} {detail}") + + print() + if all_ok: + print("All checks passed.") + else: + failed = [label for label, ok, _ in checks if not ok] + print(f"{len(failed)} check(s) failed: {', '.join(failed)}") + + return 0 if all_ok else 1 + + +def cmd_config_show(args: argparse.Namespace, config: Config) -> int: + """Print current config.toml contents.""" + config_path = get_config_path() + if not config_path.exists(): + print("No config file found. Run 'memsync init' first.", file=sys.stderr) + return 2 + print(config_path.read_text(encoding="utf-8")) + return 0 + + +def cmd_config_set(args: argparse.Namespace, config: Config) -> int: + """Update a single config value and save.""" + key = args.key + value = args.value + + valid_keys = { + "provider", "model", "sync_root", "claude_md_target", "max_memory_lines", "keep_days", + } + if key not in valid_keys: + print( + f"Error: unknown config key '{key}'.\n" + f"Valid keys: {', '.join(sorted(valid_keys))}", + file=sys.stderr, + ) + return 1 + + if key == "provider": + all_names = {p.name for p in all_providers()} + if value not in all_names: + print( + f"Error: unknown provider '{value}'.\n" + f"Available: {', '.join(sorted(all_names))}", + file=sys.stderr, + ) + return 1 + config = dataclasses.replace(config, provider=value) + + elif key == "sync_root": + path = Path(value).expanduser() + if not path.exists(): + print(f"Error: path does not exist: {path}", file=sys.stderr) + return 1 + config = dataclasses.replace(config, sync_root=path, provider="custom") + + elif key == "claude_md_target": + config = dataclasses.replace(config, claude_md_target=Path(value).expanduser()) + + elif key == "max_memory_lines": + if not value.isdigit(): + print("Error: max_memory_lines must be an integer.", file=sys.stderr) + return 1 + config = dataclasses.replace(config, max_memory_lines=int(value)) + + elif key == "keep_days": + if not value.isdigit(): + print("Error: keep_days must be an integer.", file=sys.stderr) + return 1 + config = dataclasses.replace(config, keep_days=int(value)) + + elif key == "model": + config = dataclasses.replace(config, model=value) + + config.save() + print(f"Set {key} = {value}") + return 0 + + +# --------------------------------------------------------------------------- +# Daemon commands (optional install — memsync[daemon]) +# --------------------------------------------------------------------------- + +_DAEMON_INSTALL_HINT = ( + "The daemon module is not installed.\n" + "Install it with: pip install memsync[daemon]" +) + +_PID_FILE = Path("~/.config/memsync/daemon.pid").expanduser() + + +def _daemon_import_guard() -> bool: + """Return True if daemon extras are installed, False (with error) if not.""" + try: + import apscheduler # noqa: F401 + import flask # noqa: F401 + return True + except ImportError: + print(_DAEMON_INSTALL_HINT, file=sys.stderr) + return False + + +def cmd_daemon_start(args: argparse.Namespace, config: Config) -> int: + """Start the daemon (foreground or detached).""" + if not _daemon_import_guard(): + return 1 + + if args.detach: + import subprocess + + script = [sys.executable, "-m", "memsync.cli", "daemon", "start"] + kwargs: dict = {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL} + if platform.system() == "Windows": + _flags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP + kwargs["creationflags"] = _flags + else: + kwargs["start_new_session"] = True + + proc = subprocess.Popen(script, **kwargs) # noqa: S603 + _PID_FILE.parent.mkdir(parents=True, exist_ok=True) + _PID_FILE.write_text(str(proc.pid), encoding="utf-8") + print(f"Daemon started (PID {proc.pid}).") + print("Stop with: memsync daemon stop") + return 0 + + # Foreground mode — run everything in threads, block until interrupted + import threading + + from memsync.daemon.scheduler import build_scheduler + + threads: list[threading.Thread] = [] + + if config.daemon.web_ui_enabled: + from memsync.daemon.web import run_web + + t = threading.Thread(target=run_web, args=[config], daemon=True, name="web-ui") + t.start() + threads.append(t) + print(f"Web UI: http://{config.daemon.web_ui_host}:{config.daemon.web_ui_port}/") + + if config.daemon.capture_enabled: + from memsync.daemon.capture import run_capture + + t = threading.Thread(target=run_capture, args=[config], daemon=True, name="capture") + t.start() + threads.append(t) + print(f"Capture: http://0.0.0.0:{config.daemon.capture_port}/note") + + scheduler = build_scheduler(config, blocking=False) + scheduler.start() + + job_count = len(scheduler.get_jobs()) + print(f"Scheduler: {job_count} job(s) running. Press Ctrl+C to stop.") + + try: + import time + while True: + time.sleep(1) + except (KeyboardInterrupt, SystemExit): + scheduler.shutdown(wait=False) + print("\nDaemon stopped.") + return 0 + + +def cmd_daemon_stop(args: argparse.Namespace, config: Config) -> int: + """Stop a detached daemon process.""" + if not _PID_FILE.exists(): + print("No running daemon found (PID file not present).", file=sys.stderr) + return 1 + + import signal + + pid_text = _PID_FILE.read_text(encoding="utf-8").strip() + try: + pid = int(pid_text) + except ValueError: + print(f"Invalid PID file: {_PID_FILE}", file=sys.stderr) + return 1 + + try: + if platform.system() == "Windows": + import subprocess + subprocess.run(["taskkill", "/PID", str(pid), "/F"], check=True) # noqa: S603,S607 + else: + import os + os.kill(pid, signal.SIGTERM) + _PID_FILE.unlink(missing_ok=True) + print(f"Daemon stopped (PID {pid}).") + except (ProcessLookupError, OSError): + _PID_FILE.unlink(missing_ok=True) + print(f"Process {pid} not found (already stopped?). PID file removed.") + return 0 + + +def cmd_daemon_status(args: argparse.Namespace, config: Config) -> int: + """Show daemon running status.""" + if not _daemon_import_guard(): + return 1 + + if _PID_FILE.exists(): + pid_text = _PID_FILE.read_text(encoding="utf-8").strip() + try: + pid = int(pid_text) + # Check if process is still running + if platform.system() == "Windows": + import subprocess + result = subprocess.run( + ["tasklist", "/FI", f"PID eq {pid}"], capture_output=True, text=True + ) + running = str(pid) in result.stdout + else: + import os + try: + os.kill(pid, 0) + running = True + except (ProcessLookupError, OSError): + running = False + + if running: + print(f"Daemon is running (PID {pid}).") + else: + print(f"Daemon is NOT running (stale PID file: {pid}).") + _PID_FILE.unlink(missing_ok=True) + except ValueError: + print(f"Invalid PID file: {_PID_FILE}", file=sys.stderr) + return 1 + else: + print("Daemon is not running.") + + print(f"\nWeb UI: {'enabled' if config.daemon.web_ui_enabled else 'disabled'}" + f" (port {config.daemon.web_ui_port})") + print(f"Capture: {'enabled' if config.daemon.capture_enabled else 'disabled'}" + f" (port {config.daemon.capture_port})") + print(f"Refresh: {'enabled' if config.daemon.refresh_enabled else 'disabled'}" + f" (schedule: {config.daemon.refresh_schedule})") + return 0 + + +def cmd_daemon_schedule(args: argparse.Namespace, config: Config) -> int: + """Show all scheduled jobs and their next run times.""" + if not _daemon_import_guard(): + return 1 + + from memsync.daemon.scheduler import build_scheduler + + scheduler = build_scheduler(config, blocking=False) + jobs = scheduler.get_jobs() + + if not jobs: + print("No jobs scheduled (check daemon config — all jobs may be disabled).") + return 0 + + print("Scheduled jobs:\n") + for job in jobs: + try: + next_run = job.next_run_time + except AttributeError: + next_run = None + next_str = ( + next_run.strftime("%Y-%m-%d %H:%M:%S") if next_run else "(pending — start daemon)" + ) + print(f" {job.name}") + print(f" ID: {job.id}") + print(f" Next run: {next_str}") + print() + return 0 + + +def cmd_daemon_install(args: argparse.Namespace, config: Config) -> int: + """Register the daemon as a system service (auto-starts on boot).""" + if not _daemon_import_guard(): + return 1 + + from memsync.daemon.service import install_service + + try: + install_service() + except NotImplementedError as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + except PermissionError: + print( + "Error: permission denied. Try: sudo memsync daemon install", + file=sys.stderr, + ) + return 1 + return 0 + + +def cmd_daemon_uninstall(args: argparse.Namespace, config: Config) -> int: + """Remove the daemon system service registration.""" + if not _daemon_import_guard(): + return 1 + + from memsync.daemon.service import uninstall_service + + try: + uninstall_service() + except NotImplementedError as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + return 0 + + +def cmd_daemon_web(args: argparse.Namespace, config: Config) -> int: + """Open the web UI in the default browser.""" + if not _daemon_import_guard(): + return 1 + + import webbrowser + + host = config.daemon.web_ui_host + # 0.0.0.0 means listening on all interfaces — open localhost for browser + browser_host = "localhost" if host in ("0.0.0.0", "") else host # noqa: S104 + url = f"http://{browser_host}:{config.daemon.web_ui_port}/" + print(f"Opening {url}") + webbrowser.open(url) + return 0 + + +# --------------------------------------------------------------------------- +# Argument parser +# --------------------------------------------------------------------------- + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="memsync", + description="Cross-platform global memory manager for Claude Code.", + ) + parser.add_argument("--version", action="version", version=f"memsync {__version__}") + subparsers = parser.add_subparsers(dest="command", required=True) + + # init + p_init = subparsers.add_parser("init", help="Set up memory structure for the first time") + p_init.add_argument("--force", action="store_true", help="Reinitialize even if already set up") + p_init.add_argument("--provider", help="Skip auto-detection, use this provider") + p_init.add_argument("--sync-root", help="Skip auto-detection, use this path directly") + p_init.set_defaults(func=cmd_init) + + # refresh + p_refresh = subparsers.add_parser("refresh", help="Merge session notes into global memory") + p_refresh.add_argument("--notes", "-n", help="Session notes as a string") + p_refresh.add_argument("--file", "-f", help="Path to a file containing session notes") + p_refresh.add_argument("--dry-run", action="store_true", help="Preview changes without writing") + p_refresh.add_argument("--model", help="One-off model override (doesn't change config)") + p_refresh.set_defaults(func=cmd_refresh) + + # show + p_show = subparsers.add_parser("show", help="Print current global memory") + p_show.set_defaults(func=cmd_show) + + # diff + p_diff = subparsers.add_parser("diff", help="Diff current memory vs last backup") + p_diff.add_argument("--backup", help="Diff against a specific backup filename") + p_diff.set_defaults(func=cmd_diff) + + # status + p_status = subparsers.add_parser("status", help="Show paths, provider, and sync state") + p_status.set_defaults(func=cmd_status) + + # prune + p_prune = subparsers.add_parser("prune", help="Remove old backups") + p_prune.add_argument("--keep-days", type=int, dest="keep_days", default=None, + help="Keep backups newer than this many days (default: from config)") + p_prune.add_argument("--dry-run", action="store_true", help="List what would be deleted") + p_prune.set_defaults(func=cmd_prune) + + # providers + p_providers = subparsers.add_parser("providers", help="List providers and detection status") + p_providers.set_defaults(func=cmd_providers) + + # doctor + p_doctor = subparsers.add_parser("doctor", help="Self-check: verify installation health") + p_doctor.set_defaults(func=cmd_doctor) + + # config + p_config = subparsers.add_parser("config", help="View or update config") + config_sub = p_config.add_subparsers(dest="config_command", required=True) + + p_config_show = config_sub.add_parser("show", help="Print current config.toml") + p_config_show.set_defaults(func=cmd_config_show) + + p_config_set = config_sub.add_parser("set", help="Update a config value") + p_config_set.add_argument("key", help="Config key to update") + p_config_set.add_argument("value", help="New value") + p_config_set.set_defaults(func=cmd_config_set) + + # daemon (requires memsync[daemon]) + p_daemon = subparsers.add_parser("daemon", help="Manage the optional daemon process") + daemon_sub = p_daemon.add_subparsers(dest="daemon_command", required=True) + + p_daemon_start = daemon_sub.add_parser("start", help="Start the daemon") + p_daemon_start.add_argument( + "--detach", action="store_true", help="Start as a background process" + ) + p_daemon_start.set_defaults(func=cmd_daemon_start) + + p_daemon_stop = daemon_sub.add_parser("stop", help="Stop the detached daemon") + p_daemon_stop.set_defaults(func=cmd_daemon_stop) + + p_daemon_status = daemon_sub.add_parser("status", help="Show daemon running status") + p_daemon_status.set_defaults(func=cmd_daemon_status) + + p_daemon_schedule = daemon_sub.add_parser( + "schedule", help="Show scheduled jobs and next run times" + ) + p_daemon_schedule.set_defaults(func=cmd_daemon_schedule) + + p_daemon_install = daemon_sub.add_parser( + "install", help="Register as a system service (auto-starts on boot)" + ) + p_daemon_install.set_defaults(func=cmd_daemon_install) + + p_daemon_uninstall = daemon_sub.add_parser( + "uninstall", help="Remove system service registration" + ) + p_daemon_uninstall.set_defaults(func=cmd_daemon_uninstall) + + p_daemon_web = daemon_sub.add_parser("web", help="Open web UI in browser") + p_daemon_web.set_defaults(func=cmd_daemon_web) + + return parser + + +def main() -> None: + # Ensure UTF-8 output on Windows (needed for ✓/✗ status indicators) + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8", errors="replace") + + parser = build_parser() + args = parser.parse_args() + config = Config.load() + sys.exit(args.func(args, config)) + + +if __name__ == "__main__": + main() diff --git a/memsync/config.py b/memsync/config.py new file mode 100644 index 0000000..fe3c087 --- /dev/null +++ b/memsync/config.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +import os +import platform +import tomllib +from dataclasses import dataclass, field +from pathlib import Path + + +@dataclass +class DaemonConfig: + """ + Configuration for the optional daemon module. + Only present in config.toml if the user has run 'memsync daemon install'. + All features default to reasonable values; none are on by default except + scheduled refresh and backup mirror (which requires a path to be set). + """ + enabled: bool = True + + # Scheduled refresh — reads today's session log and calls the Claude API + refresh_enabled: bool = True + refresh_schedule: str = "55 23 * * *" # 11:55pm daily + + # Backup mirror — local rsync copy of .claude-memory/ (empty = disabled) + backup_mirror_path: str = "" + backup_mirror_schedule: str = "0 * * * *" # hourly + + # Web UI — browser-based view/edit of GLOBAL_MEMORY.md + web_ui_enabled: bool = True + web_ui_port: int = 5000 + web_ui_host: str = "0.0.0.0" # noqa: S104 # 0.0.0.0 = LAN; 127.0.0.1 = localhost only + + # Mobile capture endpoint — REST POST for iPhone Shortcuts etc. + capture_enabled: bool = True + capture_port: int = 5001 + capture_token: str = "" # empty = no auth (local network only) + + # Drift detection — alerts when CLAUDE.md is stale + drift_check_enabled: bool = True + drift_check_interval_hours: int = 6 + drift_notify: str = "log" # "log", "email", or "file" + + # Weekly digest email + digest_enabled: bool = False + digest_schedule: str = "0 9 * * 1" # Monday 9am + digest_email_to: str = "" + digest_email_from: str = "" + digest_smtp_host: str = "" + digest_smtp_port: int = 587 + digest_smtp_user: str = "" + digest_smtp_password: str = "" # prefer MEMSYNC_SMTP_PASSWORD env var + + +@dataclass +class Config: + # [core] + provider: str = "onedrive" + model: str = "claude-sonnet-4-20250514" + max_memory_lines: int = 400 + + # [paths] + sync_root: Path | None = None # None = use provider auto-detect + claude_md_target: Path = None # set in __post_init__ + + # [backups] + keep_days: int = 30 + + # [daemon] — only populated when daemon is installed + daemon: DaemonConfig = field(default_factory=DaemonConfig) + + def __post_init__(self) -> None: + if self.claude_md_target is None: + self.claude_md_target = Path("~/.claude/CLAUDE.md").expanduser() + + @classmethod + def load(cls) -> Config: + """Load config from disk, returning defaults if the file doesn't exist.""" + path = get_config_path() + if not path.exists(): + return cls() + with open(path, "rb") as f: + raw = tomllib.load(f) + return cls._from_dict(raw) + + @classmethod + def _from_dict(cls, raw: dict) -> Config: + core = raw.get("core", {}) + paths = raw.get("paths", {}) + backups = raw.get("backups", {}) + + sync_root = paths.get("sync_root") + claude_md_target_str = paths.get("claude_md_target") + + # Daemon section — only present if user has run 'memsync daemon install' + daemon_raw = raw.get("daemon", {}) + daemon = DaemonConfig( + enabled=daemon_raw.get("enabled", True), + refresh_enabled=daemon_raw.get("refresh_enabled", True), + refresh_schedule=daemon_raw.get("refresh_schedule", "55 23 * * *"), + backup_mirror_path=daemon_raw.get("backup_mirror_path", ""), + backup_mirror_schedule=daemon_raw.get("backup_mirror_schedule", "0 * * * *"), + web_ui_enabled=daemon_raw.get("web_ui_enabled", True), + web_ui_port=daemon_raw.get("web_ui_port", 5000), + web_ui_host=daemon_raw.get("web_ui_host", "0.0.0.0"), # noqa: S104 + capture_enabled=daemon_raw.get("capture_enabled", True), + capture_port=daemon_raw.get("capture_port", 5001), + capture_token=daemon_raw.get("capture_token", ""), + drift_check_enabled=daemon_raw.get("drift_check_enabled", True), + drift_check_interval_hours=daemon_raw.get("drift_check_interval_hours", 6), + drift_notify=daemon_raw.get("drift_notify", "log"), + digest_enabled=daemon_raw.get("digest_enabled", False), + digest_schedule=daemon_raw.get("digest_schedule", "0 9 * * 1"), + digest_email_to=daemon_raw.get("digest_email_to", ""), + digest_email_from=daemon_raw.get("digest_email_from", ""), + digest_smtp_host=daemon_raw.get("digest_smtp_host", ""), + digest_smtp_port=daemon_raw.get("digest_smtp_port", 587), + digest_smtp_user=daemon_raw.get("digest_smtp_user", ""), + digest_smtp_password=daemon_raw.get("digest_smtp_password", ""), + ) + + instance = cls( + provider=core.get("provider", "onedrive"), + model=core.get("model", "claude-sonnet-4-20250514"), + max_memory_lines=core.get("max_memory_lines", 400), + sync_root=Path(sync_root) if sync_root else None, + claude_md_target=( + Path(claude_md_target_str).expanduser() if claude_md_target_str else None + ), + keep_days=backups.get("keep_days", 30), + daemon=daemon, + ) + return instance + + def save(self) -> None: + """Write config to disk, creating parent directories if needed.""" + path = get_config_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(self._to_toml(), encoding="utf-8") + + def _to_toml(self) -> str: + """ + Serialize config to TOML manually. + tomllib is read-only (stdlib). Schema is simple enough that manual + serialization avoids needing a tomli_w dependency. + """ + lines = [ + "[core]", + f'provider = "{self.provider}"', + f'model = "{self.model}"', + f"max_memory_lines = {self.max_memory_lines}", + "", + "[paths]", + f'claude_md_target = "{self.claude_md_target.as_posix()}"', + ] + if self.sync_root: + # TOML strings need forward slashes + lines.append(f'sync_root = "{self.sync_root.as_posix()}"') + lines += [ + "", + "[backups]", + f"keep_days = {self.keep_days}", + "", + ] + + # Only write [daemon] section if daemon is enabled (i.e. user ran daemon install) + if self.daemon.enabled: + d = self.daemon + lines += [ + "[daemon]", + f"enabled = {str(d.enabled).lower()}", + f'refresh_schedule = "{d.refresh_schedule}"', + f"refresh_enabled = {str(d.refresh_enabled).lower()}", + f'backup_mirror_path = "{d.backup_mirror_path}"', + f'backup_mirror_schedule = "{d.backup_mirror_schedule}"', + f"web_ui_enabled = {str(d.web_ui_enabled).lower()}", + f"web_ui_port = {d.web_ui_port}", + f'web_ui_host = "{d.web_ui_host}"', + f"capture_enabled = {str(d.capture_enabled).lower()}", + f"capture_port = {d.capture_port}", + f'capture_token = "{d.capture_token}"', + f"drift_check_enabled = {str(d.drift_check_enabled).lower()}", + f"drift_check_interval_hours = {d.drift_check_interval_hours}", + f'drift_notify = "{d.drift_notify}"', + f"digest_enabled = {str(d.digest_enabled).lower()}", + f'digest_schedule = "{d.digest_schedule}"', + f'digest_email_to = "{d.digest_email_to}"', + f'digest_email_from = "{d.digest_email_from}"', + f'digest_smtp_host = "{d.digest_smtp_host}"', + f"digest_smtp_port = {d.digest_smtp_port}", + f'digest_smtp_user = "{d.digest_smtp_user}"', + f'digest_smtp_password = "{d.digest_smtp_password}"', + "", + ] + + return "\n".join(lines) + + +def get_config_path() -> Path: + """Return the platform-appropriate config file path.""" + if platform.system() == "Windows": + appdata = os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming")) + return Path(appdata) / "memsync" / "config.toml" + else: + xdg_config = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")) + return Path(xdg_config) / "memsync" / "config.toml" diff --git a/memsync/daemon/__init__.py b/memsync/daemon/__init__.py new file mode 100644 index 0000000..8296f2d --- /dev/null +++ b/memsync/daemon/__init__.py @@ -0,0 +1,11 @@ +""" +memsync daemon — optional always-on companion module. + +Install with: pip install memsync[daemon] + +Core memsync never imports from this package. +This module only imports from memsync core, never the other way around. +""" +from memsync import __version__ + +DAEMON_VERSION = __version__ diff --git a/memsync/daemon/__pycache__/__init__.cpython-313.pyc b/memsync/daemon/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..f23cd54 Binary files /dev/null and b/memsync/daemon/__pycache__/__init__.cpython-313.pyc differ diff --git a/memsync/daemon/__pycache__/capture.cpython-313.pyc b/memsync/daemon/__pycache__/capture.cpython-313.pyc new file mode 100644 index 0000000..13d1a39 Binary files /dev/null and b/memsync/daemon/__pycache__/capture.cpython-313.pyc differ diff --git a/memsync/daemon/__pycache__/digest.cpython-313.pyc b/memsync/daemon/__pycache__/digest.cpython-313.pyc new file mode 100644 index 0000000..3fc2bb8 Binary files /dev/null and b/memsync/daemon/__pycache__/digest.cpython-313.pyc differ diff --git a/memsync/daemon/__pycache__/notify.cpython-313.pyc b/memsync/daemon/__pycache__/notify.cpython-313.pyc new file mode 100644 index 0000000..8de4212 Binary files /dev/null and b/memsync/daemon/__pycache__/notify.cpython-313.pyc differ diff --git a/memsync/daemon/__pycache__/scheduler.cpython-313.pyc b/memsync/daemon/__pycache__/scheduler.cpython-313.pyc new file mode 100644 index 0000000..c73776d Binary files /dev/null and b/memsync/daemon/__pycache__/scheduler.cpython-313.pyc differ diff --git a/memsync/daemon/__pycache__/service.cpython-313.pyc b/memsync/daemon/__pycache__/service.cpython-313.pyc new file mode 100644 index 0000000..2ac57a2 Binary files /dev/null and b/memsync/daemon/__pycache__/service.cpython-313.pyc differ diff --git a/memsync/daemon/__pycache__/watchdog.cpython-313.pyc b/memsync/daemon/__pycache__/watchdog.cpython-313.pyc new file mode 100644 index 0000000..b33c693 Binary files /dev/null and b/memsync/daemon/__pycache__/watchdog.cpython-313.pyc differ diff --git a/memsync/daemon/__pycache__/web.cpython-313.pyc b/memsync/daemon/__pycache__/web.cpython-313.pyc new file mode 100644 index 0000000..ded386d Binary files /dev/null and b/memsync/daemon/__pycache__/web.cpython-313.pyc differ diff --git a/memsync/daemon/capture.py b/memsync/daemon/capture.py new file mode 100644 index 0000000..c790efb --- /dev/null +++ b/memsync/daemon/capture.py @@ -0,0 +1,83 @@ +""" +REST endpoint for mobile note capture. + +Accepts POST /note with a JSON body {"text": "..."} and appends the note +to today's session log. Designed for iPhone Shortcuts, curl, or any HTTP client. + +iPhone Shortcut setup: + Action: "Get Contents of URL" + URL: http://pi.local:5001/note + Method: POST + Headers: X-Memsync-Token: (if capture_token is configured) + Body (JSON): {"text": "Shortcut Input"} + +Token auth is optional. When capture_token is empty, all requests are accepted +(safe for local-network-only use; do not expose port to internet). +""" +from __future__ import annotations + +from datetime import datetime +from pathlib import Path + +from flask import Flask, jsonify, request + +from memsync.config import Config + + +def create_capture_app(config: Config) -> Flask: + """Create and configure the capture endpoint Flask application.""" + app = Flask(__name__) + + def get_session_log() -> Path: + from memsync.providers import get_provider + + provider = get_provider(config.provider) + sync_root = config.sync_root or provider.detect() + memory_root = provider.get_memory_root(sync_root) + today = datetime.now().strftime("%Y-%m-%d") + return memory_root / "sessions" / f"{today}.md" + + def check_token() -> bool: + """Return True if the request is authorized.""" + token = config.daemon.capture_token + if not token: + return True # no auth configured — accept all (local network only) + return request.headers.get("X-Memsync-Token") == token + + @app.post("/note") + def add_note(): + if not check_token(): + return jsonify({"error": "unauthorized"}), 401 + + body = request.get_json(silent=True) + if not body or "text" not in body: + return jsonify({"error": "missing 'text' field"}), 400 + + text = body["text"].strip() + if not text: + return jsonify({"error": "empty note"}), 400 + + log_path = get_session_log() + log_path.parent.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now().strftime("%H:%M:%S") + + with open(log_path, "a", encoding="utf-8") as f: + f.write(f"\n---\n### {timestamp} (captured)\n{text}\n") + + return jsonify({"ok": True, "timestamp": timestamp}) + + @app.get("/health") + def health(): + return jsonify({"ok": True}) + + return app + + +def run_capture(config: Config) -> None: + """Start the capture endpoint server. Blocks until interrupted.""" + app = create_capture_app(config) + app.run( + host="0.0.0.0", # noqa: S104 # always local-network accessible + port=config.daemon.capture_port, + debug=False, + ) diff --git a/memsync/daemon/digest.py b/memsync/daemon/digest.py new file mode 100644 index 0000000..85d9f6e --- /dev/null +++ b/memsync/daemon/digest.py @@ -0,0 +1,76 @@ +""" +Weekly email digest for the memsync daemon. + +Collects the past 7 days of session logs, sends them to the Claude API +for summarization, and delivers the result via email. + +Only runs when config.daemon.digest_enabled is True and email is configured. +""" +from __future__ import annotations + +from datetime import date, timedelta +from pathlib import Path + +import anthropic + +from memsync.config import Config + +DIGEST_SYSTEM_PROMPT = ( + "You are summarizing a week of AI assistant session notes for the user. " + "Write a brief, plain-text weekly summary: what they worked on, " + "any notable decisions or completions, and anything that seems worth " + "following up on. 150-250 words. No headers. Direct and useful." +) + + +def generate_and_send(config: Config) -> None: + """Generate a weekly digest and send via configured email.""" + from memsync.daemon.notify import _send_email + from memsync.providers import get_provider + + provider = get_provider(config.provider) + sync_root = config.sync_root or provider.detect() + if not sync_root: + return + + memory_root = provider.get_memory_root(sync_root) + digest_text = generate_digest(memory_root, config) + + if digest_text: + _send_email( + config, + subject=f"memsync weekly digest — week of {date.today().strftime('%b %d')}", + body=digest_text, + ) + + +def generate_digest(memory_root: Path, config: Config) -> str: + """ + Collect the past 7 days of session logs and summarize via the Claude API. + Returns an empty string if there are no session logs this week. + """ + today = date.today() + week_ago = today - timedelta(days=7) + + session_logs: list[str] = [] + for i in range(7): + day = week_ago + timedelta(days=i + 1) + log_path = memory_root / "sessions" / f"{day.strftime('%Y-%m-%d')}.md" + if log_path.exists(): + day_label = day.strftime("%A %b %d") + session_logs.append(f"## {day_label}\n{log_path.read_text(encoding='utf-8')}") + + if not session_logs: + return "" + + all_notes = "\n\n".join(session_logs) + + client = anthropic.Anthropic() + response = client.messages.create( + model=config.model, + max_tokens=1000, + system=DIGEST_SYSTEM_PROMPT, + messages=[{"role": "user", "content": all_notes}], + ) + + return response.content[0].text.strip() diff --git a/memsync/daemon/notify.py b/memsync/daemon/notify.py new file mode 100644 index 0000000..6596d96 --- /dev/null +++ b/memsync/daemon/notify.py @@ -0,0 +1,68 @@ +""" +Notification abstraction for the memsync daemon. + +Sends alerts via the channel configured in config.daemon.drift_notify: + "log" — write to the daemon logger (default, always works) + "email" — send via SMTP + "file" — write a flag file to ~/.config/memsync/alerts/ + +Never raises — notification failure must not crash the daemon. +""" +from __future__ import annotations + +import logging +import os + +from memsync.config import Config + +logger = logging.getLogger("memsync.daemon") + + +def notify(config: Config, subject: str, body: str) -> None: + """ + Send a notification via the configured channel. + Silently logs any delivery error rather than propagating it. + """ + try: + match config.daemon.drift_notify: + case "email": + _send_email(config, subject, body) + case "file": + _write_flag_file(subject, body) + case _: + logger.warning("%s: %s", subject, body) + except Exception as e: + logger.error("Notification failed (%s): %s", config.daemon.drift_notify, e) + + +def _send_email(config: Config, subject: str, body: str) -> None: + """Send an alert via SMTP.""" + import smtplib + from email.message import EmailMessage + + # Prefer env var over plaintext config — see DAEMON_PITFALLS.md #9 + password = os.environ.get("MEMSYNC_SMTP_PASSWORD") or config.daemon.digest_smtp_password + + msg = EmailMessage() + msg["Subject"] = subject + msg["From"] = config.daemon.digest_email_from + msg["To"] = config.daemon.digest_email_to + msg.set_content(body) + + with smtplib.SMTP(config.daemon.digest_smtp_host, config.daemon.digest_smtp_port) as smtp: + smtp.starttls() + smtp.login(config.daemon.digest_smtp_user, password) + smtp.send_message(msg) + + +def _write_flag_file(subject: str, body: str) -> None: + """Write an alert to ~/.config/memsync/alerts/ as a timestamped text file.""" + from datetime import datetime + from pathlib import Path + + flag_dir = Path.home() / ".config" / "memsync" / "alerts" + flag_dir.mkdir(parents=True, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + flag_file = flag_dir / f"{ts}_alert.txt" + flag_file.write_text(f"{subject}\n\n{body}\n", encoding="utf-8") + logger.info("Alert written to %s", flag_file) diff --git a/memsync/daemon/scheduler.py b/memsync/daemon/scheduler.py new file mode 100644 index 0000000..46e37e0 --- /dev/null +++ b/memsync/daemon/scheduler.py @@ -0,0 +1,222 @@ +""" +APScheduler wrapper and job definitions for the memsync daemon. + +Four jobs: + nightly_refresh — reads today's session log and calls the Claude API + backup_mirror — copies .claude-memory/ to a local mirror path hourly + drift_check — checks whether CLAUDE.md is in sync with GLOBAL_MEMORY.md + weekly_digest — generates and emails a weekly summary + +All jobs return early gracefully when filesystem state is missing rather than +raising. This is load-bearing — see DAEMON_PITFALLS.md #2. +""" +from __future__ import annotations + +import logging +from pathlib import Path + +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.schedulers.blocking import BlockingScheduler +from apscheduler.triggers.cron import CronTrigger + +from memsync.config import Config + +logger = logging.getLogger("memsync.daemon") + + +def build_scheduler( + config: Config, blocking: bool = False +) -> BackgroundScheduler | BlockingScheduler: + """ + Build and configure the APScheduler instance from config. + + blocking=True → BlockingScheduler (foreground / testing) + blocking=False → BackgroundScheduler (daemon mode, runs in a thread) + """ + scheduler: BackgroundScheduler | BlockingScheduler = ( + BlockingScheduler() if blocking else BackgroundScheduler() + ) + + if config.daemon.refresh_enabled: + scheduler.add_job( + func=job_nightly_refresh, + trigger=CronTrigger.from_crontab(config.daemon.refresh_schedule), + args=[config], + id="nightly_refresh", + name="Nightly memory refresh", + misfire_grace_time=3600, # run even if missed by up to 1 hour + ) + + if config.daemon.backup_mirror_path: + scheduler.add_job( + func=job_backup_mirror, + trigger=CronTrigger.from_crontab(config.daemon.backup_mirror_schedule), + args=[config], + id="backup_mirror", + name="Backup mirror sync", + misfire_grace_time=3600, + ) + + if config.daemon.drift_check_enabled: + scheduler.add_job( + func=job_drift_check, + trigger="interval", + hours=config.daemon.drift_check_interval_hours, + args=[config], + id="drift_check", + name="CLAUDE.md drift check", + ) + + if config.daemon.digest_enabled: + scheduler.add_job( + func=job_weekly_digest, + trigger=CronTrigger.from_crontab(config.daemon.digest_schedule), + args=[config], + id="weekly_digest", + name="Weekly digest email", + ) + + return scheduler + + +def job_nightly_refresh(config: Config) -> None: + """ + Read today's session log and run a refresh if there are notes. + Silently skips if no session log exists for today (normal — rest days happen). + Never raises — a crash here would take down the whole scheduler. + """ + from datetime import date + + from memsync.backups import backup + from memsync.claude_md import sync as sync_claude_md + from memsync.providers import get_provider + from memsync.sync import refresh_memory_content + + try: + provider = get_provider(config.provider) + sync_root = config.sync_root or provider.detect() + if not sync_root: + logger.warning("nightly_refresh: sync_root not found, skipping") + return + + memory_root = provider.get_memory_root(sync_root) + today = date.today().strftime("%Y-%m-%d") + session_log = memory_root / "sessions" / f"{today}.md" + + if not session_log.exists(): + logger.debug("nightly_refresh: no session log for %s, skipping", today) + return + + notes = session_log.read_text(encoding="utf-8").strip() + if not notes: + logger.debug("nightly_refresh: session log empty for %s, skipping", today) + return + + memory_path = memory_root / "GLOBAL_MEMORY.md" + if not memory_path.exists(): + logger.warning("nightly_refresh: GLOBAL_MEMORY.md not found, skipping") + return + + current_memory = memory_path.read_text(encoding="utf-8") + result = refresh_memory_content(notes, current_memory, config) + + if result["changed"]: + backup(memory_path, memory_root / "backups") + memory_path.write_text(result["updated_content"], encoding="utf-8") + sync_claude_md(memory_path, config.claude_md_target) + logger.info("nightly_refresh: memory updated for %s", today) + else: + logger.info("nightly_refresh: no changes for %s", today) + + except Exception: + logger.exception("nightly_refresh: unexpected error") + + +def job_backup_mirror(config: Config) -> None: + """ + Copy all files from .claude-memory/ to the configured local mirror path. + Preserves timestamps. Creates the mirror directory if missing. + Never raises. + """ + import shutil + + from memsync.providers import get_provider + + try: + provider = get_provider(config.provider) + sync_root = config.sync_root or provider.detect() + if not sync_root: + logger.warning("backup_mirror: sync_root not found, skipping") + return + + memory_root = provider.get_memory_root(sync_root) + mirror = Path(config.daemon.backup_mirror_path).expanduser() + mirror.mkdir(parents=True, exist_ok=True) + + copied = 0 + for src in memory_root.rglob("*"): + if src.is_file(): + rel = src.relative_to(memory_root) + dst = mirror / rel + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + copied += 1 + + logger.info("backup_mirror: copied %d file(s) to %s", copied, mirror) + + except Exception: + logger.exception("backup_mirror: unexpected error") + + +def job_drift_check(config: Config) -> None: + """ + Check if CLAUDE.md is stale relative to GLOBAL_MEMORY.md. + Fires a notification via the configured channel if out of sync. + Never raises. + """ + from memsync.claude_md import is_synced + from memsync.daemon.notify import notify + from memsync.providers import get_provider + + try: + provider = get_provider(config.provider) + sync_root = config.sync_root or provider.detect() + if not sync_root: + return + + memory_root = provider.get_memory_root(sync_root) + memory_path = memory_root / "GLOBAL_MEMORY.md" + + if not memory_path.exists(): + return + + if not is_synced(memory_path, config.claude_md_target): + notify( + config, + subject="memsync: CLAUDE.md is out of sync", + body=( + f"CLAUDE.md at {config.claude_md_target} does not match " + f"GLOBAL_MEMORY.md at {memory_path}.\n" + "Run: memsync refresh to resync." + ), + ) + logger.warning("drift_check: CLAUDE.md is out of sync") + else: + logger.debug("drift_check: CLAUDE.md is in sync") + + except Exception: + logger.exception("drift_check: unexpected error") + + +def job_weekly_digest(config: Config) -> None: + """ + Generate and send a weekly digest of session logs. + Delegates to memsync.daemon.digest. Never raises. + """ + from memsync.daemon.digest import generate_and_send + + try: + generate_and_send(config) + logger.info("weekly_digest: digest sent") + except Exception: + logger.exception("weekly_digest: unexpected error") diff --git a/memsync/daemon/service.py b/memsync/daemon/service.py new file mode 100644 index 0000000..6b93bb1 --- /dev/null +++ b/memsync/daemon/service.py @@ -0,0 +1,141 @@ +""" +System service installation for the memsync daemon. + +Supports: + Linux — systemd unit file at /etc/systemd/system/memsync.service + Mac — launchd plist at ~/Library/LaunchAgents/com.memsync.daemon.plist + Windows — not supported (use Task Scheduler with 'memsync daemon start --detach') + +IMPORTANT: systemd install requires root (sudo memsync daemon install). +The unit file contains a placeholder for ANTHROPIC_API_KEY. After install, +use 'systemctl edit memsync' to add the key in an override file rather than +editing the unit file directly — override files survive package updates. +""" +from __future__ import annotations + +import platform +import subprocess +from pathlib import Path + +SYSTEMD_UNIT = """\ +[Unit] +Description=memsync daemon +After=network.target + +[Service] +Type=simple +ExecStart={memsync_bin} daemon start +Restart=on-failure +RestartSec=10 +Environment=ANTHROPIC_API_KEY= + +[Install] +WantedBy=multi-user.target +""" + +LAUNCHD_PLIST = """\ + + + + + Label + com.memsync.daemon + ProgramArguments + + {memsync_bin} + daemon + start + + RunAtLoad + + KeepAlive + + StandardOutPath + {log_dir}/memsync-daemon.log + StandardErrorPath + {log_dir}/memsync-daemon.err + + +""" + + +def install_service() -> None: + """Install the memsync daemon as a system service.""" + system = platform.system() + memsync_bin = _find_memsync_bin() + + if system == "Linux": + _install_systemd(memsync_bin) + elif system == "Darwin": + _install_launchd(memsync_bin) + else: + raise NotImplementedError( + "Service install is not supported on Windows.\n" + "Use Task Scheduler to run 'memsync daemon start --detach' on boot." + ) + + +def uninstall_service() -> None: + """Remove the memsync daemon system service registration.""" + system = platform.system() + if system == "Linux": + _uninstall_systemd() + elif system == "Darwin": + _uninstall_launchd() + else: + raise NotImplementedError("Service uninstall not supported on Windows.") + + +def _install_systemd(memsync_bin: str) -> None: + unit_path = Path("/etc/systemd/system/memsync.service") + unit_content = SYSTEMD_UNIT.format(memsync_bin=memsync_bin) + unit_path.write_text(unit_content, encoding="utf-8") + subprocess.run(["systemctl", "daemon-reload"], check=True) + subprocess.run(["systemctl", "enable", "memsync"], check=True) + subprocess.run(["systemctl", "start", "memsync"], check=True) + print(f"Service installed: {unit_path}") + print("Edit ANTHROPIC_API_KEY via: sudo systemctl edit memsync") + print("Then restart with: sudo systemctl restart memsync") + + +def _install_launchd(memsync_bin: str) -> None: + log_dir = Path.home() / "Library" / "Logs" / "memsync" + log_dir.mkdir(parents=True, exist_ok=True) + plist_dir = Path.home() / "Library" / "LaunchAgents" + plist_dir.mkdir(parents=True, exist_ok=True) + plist_path = plist_dir / "com.memsync.daemon.plist" + plist_content = LAUNCHD_PLIST.format(memsync_bin=memsync_bin, log_dir=log_dir) + plist_path.write_text(plist_content, encoding="utf-8") + subprocess.run(["launchctl", "load", str(plist_path)], check=True) + print(f"Service installed: {plist_path}") + print(f"Logs: {log_dir}/memsync-daemon.log") + + +def _uninstall_systemd() -> None: + subprocess.run(["systemctl", "stop", "memsync"], check=False) + subprocess.run(["systemctl", "disable", "memsync"], check=False) + unit_path = Path("/etc/systemd/system/memsync.service") + if unit_path.exists(): + unit_path.unlink() + subprocess.run(["systemctl", "daemon-reload"], check=True) + print("Service removed.") + + +def _uninstall_launchd() -> None: + plist_path = Path.home() / "Library" / "LaunchAgents" / "com.memsync.daemon.plist" + if plist_path.exists(): + subprocess.run(["launchctl", "unload", str(plist_path)], check=False) + plist_path.unlink() + print("Service removed.") + + +def _find_memsync_bin() -> str: + import shutil + + bin_path = shutil.which("memsync") + if not bin_path: + raise FileNotFoundError( + "memsync not found in PATH. Install with: pip install memsync[daemon]" + ) + return bin_path diff --git a/memsync/daemon/watchdog.py b/memsync/daemon/watchdog.py new file mode 100644 index 0000000..6cf2f44 --- /dev/null +++ b/memsync/daemon/watchdog.py @@ -0,0 +1,17 @@ +""" +Drift watchdog for the memsync daemon. + +Thin wrapper that exposes drift detection as a standalone callable. +The scheduler calls job_drift_check from scheduler.py directly; +this module exists for users who want to invoke drift checking outside +the scheduler (e.g. from a cron job or ad-hoc script). +""" +from __future__ import annotations + +from memsync.config import Config +from memsync.daemon.scheduler import job_drift_check + + +def run_drift_check(config: Config) -> None: + """Run a single drift check immediately, outside the scheduler.""" + job_drift_check(config) diff --git a/memsync/daemon/web.py b/memsync/daemon/web.py new file mode 100644 index 0000000..4eec27e --- /dev/null +++ b/memsync/daemon/web.py @@ -0,0 +1,109 @@ +""" +Flask web UI for memsync daemon. + +Provides a browser-based view/edit interface for GLOBAL_MEMORY.md, +accessible on the local network at http://:/ (default :5000). + +Intended for use on a home network only. Do not expose to the public internet. +See DAEMON.md for Flask-in-production guidance. +""" +from __future__ import annotations + +import datetime +from pathlib import Path + +from flask import Flask, redirect, render_template_string, request + +from memsync.backups import backup +from memsync.claude_md import sync as sync_claude_md +from memsync.config import Config + +# Inline template — no separate template files needed for this simple UI +TEMPLATE = """ + + + + memsync — Global Memory + + + + +

Global Memory

+
+ {{ memory_path }}
+ Last modified: {{ last_modified }} + {% if message %} — {{ message }}{% endif %} +
+
+ +
+ + Cancel +
+
+ + +""" + + +def create_app(config: Config) -> Flask: + """Create and configure the Flask web UI application.""" + app = Flask(__name__) + app.config["MEMSYNC_CONFIG"] = config + + def get_memory_path() -> Path: + from memsync.providers import get_provider + + provider = get_provider(config.provider) + sync_root = config.sync_root or provider.detect() + return provider.get_memory_root(sync_root) / "GLOBAL_MEMORY.md" + + @app.get("/") + def index() -> str: + path = get_memory_path() + content = path.read_text(encoding="utf-8") if path.exists() else "" + last_mod = ( + datetime.datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M") + if path.exists() + else "never" + ) + return render_template_string( + TEMPLATE, + content=content, + memory_path=path, + last_modified=last_mod, + message=request.args.get("message", ""), + message_class=request.args.get("cls", "saved"), + ) + + @app.post("/save") + def save(): + path = get_memory_path() + new_content = request.form["content"] + try: + if path.exists(): + backup(path, path.parent / "backups") + path.write_text(new_content, encoding="utf-8") + sync_claude_md(path, config.claude_md_target) + return redirect("/?message=Saved+successfully&cls=saved") + except Exception as e: + return redirect(f"/?message=Error:+{e}&cls=error") + + return app + + +def run_web(config: Config) -> None: + """Start the web UI server. Blocks until interrupted.""" + app = create_app(config) + app.run( + host=config.daemon.web_ui_host, + port=config.daemon.web_ui_port, + debug=False, + ) diff --git a/memsync/providers/__init__.py b/memsync/providers/__init__.py new file mode 100644 index 0000000..eba1afe --- /dev/null +++ b/memsync/providers/__init__.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from pathlib import Path + + +class BaseProvider(ABC): + """ + A sync provider knows how to find the cloud storage root on the current machine. + That's its only job. Memory structure lives above it. + """ + + name: str # short id used in config: "onedrive", "icloud", "gdrive", "custom" + display_name: str # human-readable: "OneDrive", "iCloud Drive", "Google Drive", "Custom Path" + + @abstractmethod + def detect(self) -> Path | None: + """ + Try to find this provider's sync root on the current machine. + Returns the path if found and accessible, None otherwise. + Never raises — detection failure is not an error. + """ + + @abstractmethod + def is_available(self) -> bool: + """ + Quick check: is this provider installed and its sync folder accessible? + Should be fast — no API calls, just filesystem checks. + """ + + def get_memory_root(self, sync_root: Path) -> Path: + """ + Where inside the sync root to store memsync data. + Default is /.claude-memory + Providers can override if needed (e.g. iCloud has invisible dot-folders). + """ + return sync_root / ".claude-memory" + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(name={self.name!r})" + + +# Provider registry — add new providers here +_REGISTRY: dict[str, type[BaseProvider]] = {} + + +def register(cls: type[BaseProvider]) -> type[BaseProvider]: + """Decorator to register a provider.""" + _REGISTRY[cls.name] = cls + return cls + + +def get_provider(name: str) -> BaseProvider: + """Get a provider instance by name. Raises KeyError if not found.""" + if name not in _REGISTRY: + available = ", ".join(_REGISTRY.keys()) + raise KeyError(f"Unknown provider {name!r}. Available: {available}") + return _REGISTRY[name]() + + +def all_providers() -> list[BaseProvider]: + """Return one instance of each registered provider.""" + return [cls() for cls in _REGISTRY.values()] + + +def auto_detect() -> list[BaseProvider]: + """ + Return all providers that detect successfully on this machine, + in priority order: OneDrive, iCloud, Google Drive, Custom. + """ + return [p for p in all_providers() if p.detect() is not None] + + +# Import providers to trigger registration — order determines priority +from memsync.providers import custom, gdrive, icloud, onedrive # noqa: E402, F401 diff --git a/memsync/providers/__pycache__/__init__.cpython-313.pyc b/memsync/providers/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..fd254fc Binary files /dev/null and b/memsync/providers/__pycache__/__init__.cpython-313.pyc differ diff --git a/memsync/providers/__pycache__/custom.cpython-313.pyc b/memsync/providers/__pycache__/custom.cpython-313.pyc new file mode 100644 index 0000000..8f6fc49 Binary files /dev/null and b/memsync/providers/__pycache__/custom.cpython-313.pyc differ diff --git a/memsync/providers/__pycache__/gdrive.cpython-313.pyc b/memsync/providers/__pycache__/gdrive.cpython-313.pyc new file mode 100644 index 0000000..00f5d43 Binary files /dev/null and b/memsync/providers/__pycache__/gdrive.cpython-313.pyc differ diff --git a/memsync/providers/__pycache__/icloud.cpython-313.pyc b/memsync/providers/__pycache__/icloud.cpython-313.pyc new file mode 100644 index 0000000..c2f65c2 Binary files /dev/null and b/memsync/providers/__pycache__/icloud.cpython-313.pyc differ diff --git a/memsync/providers/__pycache__/onedrive.cpython-313.pyc b/memsync/providers/__pycache__/onedrive.cpython-313.pyc new file mode 100644 index 0000000..a4bd397 Binary files /dev/null and b/memsync/providers/__pycache__/onedrive.cpython-313.pyc differ diff --git a/memsync/providers/custom.py b/memsync/providers/custom.py new file mode 100644 index 0000000..5ac93fb --- /dev/null +++ b/memsync/providers/custom.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from pathlib import Path + +from memsync.providers import BaseProvider, register + + +@register +class CustomProvider(BaseProvider): + """ + Fallback for any sync service not explicitly supported. + User sets the path manually via: memsync config set sync_root /path/to/folder + """ + name = "custom" + display_name = "Custom Path" + + def __init__(self, path: Path | None = None): + self._path = path + + def detect(self) -> Path | None: + # Custom provider only works if path is explicitly configured + if self._path and self._path.exists(): + return self._path + return None + + def is_available(self) -> bool: + return self.detect() is not None diff --git a/memsync/providers/gdrive.py b/memsync/providers/gdrive.py new file mode 100644 index 0000000..0faf69a --- /dev/null +++ b/memsync/providers/gdrive.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import os +import platform +from pathlib import Path + +from memsync.providers import BaseProvider, register + + +@register +class GoogleDriveProvider(BaseProvider): + name = "gdrive" + display_name = "Google Drive" + + def detect(self) -> Path | None: + try: + return self._find() + except Exception: + return None + + def is_available(self) -> bool: + return self.detect() is not None + + def _find(self) -> Path | None: + system = platform.system() + + if system == "Darwin": + # Google Drive for Desktop (current client) + cloud_storage = Path.home() / "Library" / "CloudStorage" + if cloud_storage.exists(): + for d in cloud_storage.iterdir(): + if d.name.startswith("GoogleDrive") and d.is_dir(): + # My Drive is inside the account folder + my_drive = d / "My Drive" + if my_drive.exists(): + return my_drive + return d + + # Legacy Backup and Sync path + legacy = Path.home() / "Google Drive" + if legacy.exists(): + return legacy + + elif system == "Windows": + # Google Drive for Desktop on Windows + gdrive_env = os.environ.get("GDRIVE_ROOT") + if gdrive_env: + p = Path(gdrive_env) + if p.exists(): + return p + + username = os.environ.get("USERNAME", "") + for candidate in [ + Path.home() / "Google Drive", + Path(f"C:/Users/{username}/Google Drive"), + # Google Drive for Desktop default + Path("G:/My Drive"), + Path("G:/"), + ]: + if candidate.exists(): + return candidate + + elif system == "Linux": + # Google Drive via google-drive-ocamlfuse or rclone + for candidate in [ + Path.home() / "GoogleDrive", + Path.home() / "google-drive", + Path.home() / "gdrive", + ]: + if candidate.exists(): + return candidate + + return None diff --git a/memsync/providers/icloud.py b/memsync/providers/icloud.py new file mode 100644 index 0000000..10fb261 --- /dev/null +++ b/memsync/providers/icloud.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import os +import platform +from pathlib import Path + +from memsync.providers import BaseProvider, register + + +@register +class ICloudProvider(BaseProvider): + name = "icloud" + display_name = "iCloud Drive" + + def detect(self) -> Path | None: + try: + return self._find() + except Exception: + return None + + def is_available(self) -> bool: + return self.detect() is not None + + def _find(self) -> Path | None: + system = platform.system() + + if system == "Darwin": + # Primary path on Mac + icloud = Path.home() / "Library" / "Mobile Documents" / "com~apple~CloudDocs" + if icloud.exists(): + return icloud + + elif system == "Windows": + # iCloud for Windows installs here + username = os.environ.get("USERNAME", "") + for candidate in [ + Path.home() / "iCloudDrive", + Path(f"C:/Users/{username}/iCloudDrive"), + ]: + if candidate.exists(): + return candidate + + # Linux: iCloud has no official client — not supported + return None + + def get_memory_root(self, sync_root: Path) -> Path: + # iCloud hides dot-folders on Mac — use a visible name instead + return sync_root / "claude-memory" diff --git a/memsync/providers/onedrive.py b/memsync/providers/onedrive.py new file mode 100644 index 0000000..8d59b90 --- /dev/null +++ b/memsync/providers/onedrive.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import os +import platform +from pathlib import Path + +from memsync.providers import BaseProvider, register + + +@register +class OneDriveProvider(BaseProvider): + name = "onedrive" + display_name = "OneDrive" + + def detect(self) -> Path | None: + try: + return self._find() + except Exception: + return None + + def is_available(self) -> bool: + return self.detect() is not None + + def _find(self) -> Path | None: + system = platform.system() + + if system == "Windows": + # Windows sets these env vars when OneDrive is running + for var in ("OneDrive", "ONEDRIVE", "OneDriveConsumer", "OneDriveCommercial"): + val = os.environ.get(var) + if val: + p = Path(val) + if p.exists(): + return p + # Fallback: common default paths + username = os.environ.get("USERNAME", "") + for candidate in [ + Path.home() / "OneDrive", + Path(f"C:/Users/{username}/OneDrive"), + ]: + if candidate.exists(): + return candidate + + elif system == "Darwin": + # Mac: OneDrive doesn't set env vars, check filesystem + # Personal OneDrive + personal = Path.home() / "OneDrive" + if personal.exists(): + return personal + + # OneDrive via CloudStorage (newer Mac client) + cloud_storage = Path.home() / "Library" / "CloudStorage" + if cloud_storage.exists(): + # Personal first, then business + for d in sorted(cloud_storage.iterdir()): + if d.name == "OneDrive-Personal": + return d + for d in sorted(cloud_storage.iterdir()): + if d.name.startswith("OneDrive") and d.is_dir(): + return d + + else: + # Linux: OneDrive via rclone or manual mount + for candidate in [ + Path.home() / "OneDrive", + Path.home() / "onedrive", + ]: + if candidate.exists(): + return candidate + + return None diff --git a/memsync/sync.py b/memsync/sync.py new file mode 100644 index 0000000..06aebb1 --- /dev/null +++ b/memsync/sync.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import re +from pathlib import Path + +import anthropic + +from memsync.config import Config + +# The system prompt is load-bearing — see PITFALLS.md #8 before editing. +# Specific phrases matter; don't casually reword them. +SYSTEM_PROMPT = """You are maintaining a persistent global memory file for an AI assistant user. +This file is loaded at the start of every Claude Code session, on every machine and project. +It is the user's identity layer — not project docs, not cold storage. + +YOUR JOB: +- Merge new session notes into the existing memory file +- Keep the file tight (under 400 lines) +- Update facts that have changed +- Demote completed items from "Current priorities" to a brief "Recent completions" section +- Preserve the user's exact voice, formatting, and section structure +- NEVER remove entries under any "Hard constraints" or "Constraints" section — only append +- If nothing meaningful changed, return the file UNCHANGED + +RETURN: Only the updated GLOBAL_MEMORY.md content. No explanation, no preamble.""" + + +def refresh_memory_content(notes: str, current_memory: str, config: Config) -> dict: + """ + Call the Claude API to merge notes into current_memory. + Returns a dict with keys: updated_content (str), changed (bool). + Does NOT write files — caller handles I/O. + """ + client = anthropic.Anthropic() + + user_prompt = f"""\ +CURRENT GLOBAL MEMORY: +{current_memory} + +SESSION NOTES: +{notes}""" + + response = client.messages.create( + model=config.model, + max_tokens=4096, + system=SYSTEM_PROMPT, + messages=[{"role": "user", "content": user_prompt}], + ) + + updated_content = response.content[0].text.strip() + + # Enforce hard constraints in code — model can silently drop them (PITFALLS #1) + updated_content = enforce_hard_constraints(current_memory, updated_content) + + changed = updated_content != current_memory.strip() + + # Detect truncation via stop_reason — more reliable than content heuristics (PITFALLS #10) + truncated = response.stop_reason == "max_tokens" + + return { + "updated_content": updated_content, + "changed": changed, + "truncated": truncated, + } + + +def enforce_hard_constraints(old: str, new: str) -> str: + """ + Re-append any hard constraint lines the model dropped. + Hard constraints are append-only by design — they must never be lost + through compaction. This is enforced in Python, not by prompt alone. + """ + old_constraints = _extract_constraints(old) + new_constraints = _extract_constraints(new) + + dropped = [line for line in old_constraints if line not in new_constraints] + if not dropped: + return new + + return _reinsert_constraints(new, dropped) + + +def _extract_constraints(text: str) -> list[str]: + """ + Extract bullet lines from the Hard constraints / Constraints section. + Returns list of non-empty stripped lines within the section. + """ + lines = text.splitlines() + in_section = False + constraints: list[str] = [] + + for line in lines: + if re.match(r"^##\s+(Hard constraints|Constraints)\s*$", line, re.IGNORECASE): + in_section = True + continue + if in_section: + # Another heading ends the section + if re.match(r"^#{1,6}\s+", line) and not re.match( + r"^##\s+(Hard constraints|Constraints)\s*$", line, re.IGNORECASE + ): + break + stripped = line.strip() + if stripped: + constraints.append(stripped) + + return constraints + + +def _reinsert_constraints(text: str, dropped: list[str]) -> str: + """ + Find the Hard constraints section in text and append the dropped lines to it. + If the section doesn't exist, append it at the end. + """ + lines = text.splitlines() + insert_idx: int | None = None + + in_section = False + for i, line in enumerate(lines): + if re.match(r"^##\s+(Hard constraints|Constraints)\s*$", line, re.IGNORECASE): + in_section = True + continue + if in_section: + if re.match(r"^#{1,6}\s+", line): + # Insert before the next heading + insert_idx = i + break + insert_idx = i + 1 # keep updating to end of section + + if insert_idx is not None: + for item in dropped: + lines.insert(insert_idx, item) + insert_idx += 1 + return "\n".join(lines) + + # Section not found — append it + appended = "\n".join(lines) + appended += "\n\n## Hard constraints\n" + appended += "\n".join(dropped) + return appended + + + +def load_or_init_memory(path: Path) -> str: + """ + Read memory file, or return the starter template if it doesn't exist yet. + """ + if path.exists(): + return path.read_text(encoding="utf-8") + + return """\ + +# Global Memory + +> Loaded by Claude Code at session start on all machines and projects. +> Edit directly or run: memsync refresh --notes "..." + +## Identity & context +- (Fill this in — who you are, your roles, active projects) + +## Current priorities +- (What you're working on right now) + +## Standing preferences +- (How you like to work — communication style, output format, etc.) + +## Hard constraints +- (Rules that must never be lost or softened through compaction) +""" + + +def log_session_notes(notes: str, session_dir: Path) -> None: + """Append session notes to today's dated log file. Append-only, never pruned.""" + from datetime import datetime + today = datetime.now().strftime("%Y-%m-%d") + timestamp = datetime.now().strftime("%H:%M:%S") + log_path = session_dir / f"{today}.md" + + with open(log_path, "a", encoding="utf-8") as f: + f.write(f"\n---\n### {timestamp}\n{notes}\n") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1e2c3af --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,94 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "memsync" +version = "0.2.0" +description = "Cross-platform global memory manager for Claude Code" +readme = "README.md" +license = { text = "MIT" } +requires-python = ">=3.11" +keywords = ["claude", "claude-code", "ai", "memory", "cli"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Operating System :: MacOS", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX :: Linux", +] +dependencies = [ + "anthropic>=0.40.0", +] + +[project.urls] +Homepage = "https://github.com/YOUR_USERNAME/memsync" +Issues = "https://github.com/YOUR_USERNAME/memsync/issues" + +[project.scripts] +memsync = "memsync.cli:main" + +[project.optional-dependencies] +daemon = [ + "apscheduler>=3.10", + "flask>=3.0", +] +dev = [ + "pytest>=8.0", + "pytest-cov>=5.0", + "pytest-mock>=3.12", + "ruff>=0.4", + "bandit[toml]>=1.7", +] + +[tool.setuptools.packages.find] +where = ["."] +include = ["memsync*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = [ + "smoke: fast read-only checks — run any time, no API calls, no filesystem writes", +] +addopts = "--cov=memsync --cov-fail-under=80 --cov-report=term-missing" + +[tool.coverage.run] +omit = [ + "memsync/daemon/service.py", # requires systemd/launchd — OS-specific privileged ops +] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "UP", # pyupgrade + "S", # flake8-bandit (security) + "B", # flake8-bugbear +] +ignore = [ + "S101", # assert used — acceptable in tests + "S603", # subprocess — not used here but avoid false positives + "S607", # subprocess partial path — not used here + "B008", # do not perform function calls in default arguments +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["S101", "S106"] # asserts and hardcoded passwords OK in tests + +[tool.bandit] +targets = ["memsync"] +skips = [ + "B101", # assert_used — we don't use asserts in production code + "B608", # hardcoded_sql_expressions — false positive on error message strings; no SQL in codebase +] +exclude_dirs = ["tests"] diff --git a/tests/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..c470a69 Binary files /dev/null and b/tests/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_backups.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_backups.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..e12bc0e Binary files /dev/null and b/tests/__pycache__/test_backups.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_claude_md.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_claude_md.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..b1fbe86 Binary files /dev/null and b/tests/__pycache__/test_claude_md.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_cli.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_cli.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..f69e92b Binary files /dev/null and b/tests/__pycache__/test_cli.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_config.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_config.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..69764e5 Binary files /dev/null and b/tests/__pycache__/test_config.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_daemon_capture.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_daemon_capture.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..54983d3 Binary files /dev/null and b/tests/__pycache__/test_daemon_capture.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_daemon_digest.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_daemon_digest.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..cbe9016 Binary files /dev/null and b/tests/__pycache__/test_daemon_digest.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_daemon_notify.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_daemon_notify.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..9123be8 Binary files /dev/null and b/tests/__pycache__/test_daemon_notify.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_daemon_scheduler.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_daemon_scheduler.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..21dc0bc Binary files /dev/null and b/tests/__pycache__/test_daemon_scheduler.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_daemon_watchdog.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_daemon_watchdog.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..386a9aa Binary files /dev/null and b/tests/__pycache__/test_daemon_watchdog.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_daemon_web.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_daemon_web.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..7c10a76 Binary files /dev/null and b/tests/__pycache__/test_daemon_web.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_providers.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_providers.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..600c317 Binary files /dev/null and b/tests/__pycache__/test_providers.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_sync.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_sync.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..ae06e39 Binary files /dev/null and b/tests/__pycache__/test_sync.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ee21b7a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import sys + +import pytest + +from memsync.config import Config + +# Ensure UTF-8 stdout/stderr for the entire test session on Windows. +# CLI commands print ✓/✗ which fail on cp1252 without this. +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") +if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8", errors="replace") + + +@pytest.fixture +def tmp_config(tmp_path, monkeypatch): + """ + Config pointing entirely to tmp_path — no real filesystem touched. + Creates the expected directory structure under tmp_path/sync/.claude-memory/ + """ + sync_root = tmp_path / "sync" + memory_root = sync_root / ".claude-memory" + (memory_root / "backups").mkdir(parents=True) + (memory_root / "sessions").mkdir(parents=True) + + config = Config( + provider="custom", + sync_root=sync_root, + claude_md_target=tmp_path / ".claude" / "CLAUDE.md", + ) + + monkeypatch.setattr( + "memsync.config.get_config_path", + lambda: tmp_path / "config.toml", + ) + + return config, tmp_path + + +@pytest.fixture +def memory_file(tmp_config): + """A tmp_config with a pre-written GLOBAL_MEMORY.md.""" + config, tmp_path = tmp_config + memory_root = config.sync_root / ".claude-memory" + global_memory = memory_root / "GLOBAL_MEMORY.md" + global_memory.write_text( + "\n" + "# Global Memory\n\n" + "## Identity & context\n" + "- Test user, software engineer\n\n" + "## Hard constraints\n" + "- Always backup before writing\n" + "- Never skip tests\n", + encoding="utf-8", + ) + return config, tmp_path, global_memory diff --git a/tests/test_backups.py b/tests/test_backups.py new file mode 100644 index 0000000..3af7544 --- /dev/null +++ b/tests/test_backups.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import time +from pathlib import Path + +import pytest + +from memsync.backups import backup, latest_backup, list_backups, prune + + +@pytest.fixture +def backup_env(tmp_path): + """A source file and backup directory in tmp_path.""" + source = tmp_path / "GLOBAL_MEMORY.md" + source.write_text("# memory content", encoding="utf-8") + backup_dir = tmp_path / "backups" + backup_dir.mkdir() + return source, backup_dir + + +class TestBackup: + def test_creates_file_with_timestamp_name(self, backup_env): + source, backup_dir = backup_env + result = backup(source, backup_dir) + assert result.exists() + assert result.name.startswith("GLOBAL_MEMORY_") + assert result.suffix == ".md" + + def test_backup_content_matches_source(self, backup_env): + source, backup_dir = backup_env + result = backup(source, backup_dir) + assert result.read_text(encoding="utf-8") == source.read_text(encoding="utf-8") + + def test_successive_backups_have_unique_names(self, backup_env): + source, backup_dir = backup_env + b1 = backup(source, backup_dir) + time.sleep(1) + b2 = backup(source, backup_dir) + assert b1.name != b2.name + + +class TestListBackups: + def test_returns_newest_first(self, backup_env): + source, backup_dir = backup_env + b1 = backup(source, backup_dir) + time.sleep(1) + b2 = backup(source, backup_dir) + listed = list_backups(backup_dir) + assert listed[0] == b2 + assert listed[1] == b1 + + def test_empty_dir_returns_empty_list(self, tmp_path): + d = tmp_path / "backups" + d.mkdir() + assert list_backups(d) == [] + + +class TestLatestBackup: + def test_returns_most_recent(self, backup_env): + source, backup_dir = backup_env + backup(source, backup_dir) + time.sleep(1) + b2 = backup(source, backup_dir) + assert latest_backup(backup_dir) == b2 + + def test_returns_none_when_no_backups(self, tmp_path): + d = tmp_path / "backups" + d.mkdir() + assert latest_backup(d) is None + + +class TestPrune: + def test_removes_old_backups(self, backup_env): + source, backup_dir = backup_env + b = backup(source, backup_dir) + deleted = prune(backup_dir, keep_days=0) + assert b in deleted + assert not b.exists() + + def test_keeps_recent_backups(self, backup_env): + source, backup_dir = backup_env + b = backup(source, backup_dir) + deleted = prune(backup_dir, keep_days=30) + assert b not in deleted + assert b.exists() + + def test_skips_files_with_unexpected_names(self, backup_env): + _, backup_dir = backup_env + stray = backup_dir / "not-a-backup.md" + stray.write_text("stray", encoding="utf-8") + # Should not raise, should not delete stray file + prune(backup_dir, keep_days=0) + assert stray.exists() + + def test_returns_list_of_deleted_paths(self, backup_env): + source, backup_dir = backup_env + backup(source, backup_dir) + time.sleep(1) + backup(source, backup_dir) + deleted = prune(backup_dir, keep_days=0) + assert len(deleted) == 2 + assert all(isinstance(p, Path) for p in deleted) diff --git a/tests/test_claude_md.py b/tests/test_claude_md.py new file mode 100644 index 0000000..be84ac0 --- /dev/null +++ b/tests/test_claude_md.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import platform + +import pytest + +from memsync.claude_md import is_synced, sync + + +@pytest.fixture +def memory_and_target(tmp_path): + memory = tmp_path / "sync" / ".claude-memory" / "GLOBAL_MEMORY.md" + memory.parent.mkdir(parents=True) + memory.write_text("# Global Memory\n- test content", encoding="utf-8") + target = tmp_path / ".claude" / "CLAUDE.md" + return memory, target + + +class TestSyncWindows: + def test_creates_copy_on_windows(self, memory_and_target, monkeypatch): + memory, target = memory_and_target + monkeypatch.setattr(platform, "system", lambda: "Windows") + + sync(memory, target) + + assert target.exists() + assert not target.is_symlink() + assert target.read_bytes() == memory.read_bytes() + + def test_copy_is_idempotent(self, memory_and_target, monkeypatch): + memory, target = memory_and_target + monkeypatch.setattr(platform, "system", lambda: "Windows") + + sync(memory, target) + sync(memory, target) # should not raise + + assert target.read_bytes() == memory.read_bytes() + + def test_creates_parent_dirs(self, memory_and_target, monkeypatch): + memory, target = memory_and_target + monkeypatch.setattr(platform, "system", lambda: "Windows") + # Target parent doesn't exist yet + assert not target.parent.exists() + + sync(memory, target) + assert target.parent.exists() + + +class TestSyncUnix: + @pytest.mark.skipif(platform.system() == "Windows", reason="symlinks require Unix") + def test_creates_symlink(self, memory_and_target, monkeypatch): + memory, target = memory_and_target + monkeypatch.setattr(platform, "system", lambda: "Darwin") + + sync(memory, target) + + assert target.is_symlink() + assert target.resolve() == memory.resolve() + + @pytest.mark.skipif(platform.system() == "Windows", reason="symlinks require Unix") + def test_symlink_is_idempotent(self, memory_and_target, monkeypatch): + memory, target = memory_and_target + monkeypatch.setattr(platform, "system", lambda: "Darwin") + + sync(memory, target) + sync(memory, target) # already correct — should not raise + + assert target.is_symlink() + + @pytest.mark.skipif(platform.system() == "Windows", reason="symlinks require Unix") + def test_backs_up_existing_file_before_linking(self, memory_and_target, monkeypatch): + memory, target = memory_and_target + monkeypatch.setattr(platform, "system", lambda: "Darwin") + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("old content", encoding="utf-8") + + sync(memory, target) + + bak = target.with_suffix(".pre-memsync.bak") + assert bak.exists() + assert bak.read_text(encoding="utf-8") == "old content" + assert target.is_symlink() + + +class TestIsSynced: + def test_false_when_target_missing(self, memory_and_target): + memory, target = memory_and_target + assert is_synced(memory, target) is False + + def test_true_after_sync_on_windows(self, memory_and_target, monkeypatch): + memory, target = memory_and_target + monkeypatch.setattr(platform, "system", lambda: "Windows") + sync(memory, target) + assert is_synced(memory, target) is True + + def test_false_when_content_differs(self, memory_and_target, monkeypatch): + memory, target = memory_and_target + monkeypatch.setattr(platform, "system", lambda: "Windows") + sync(memory, target) + memory.write_text("updated content", encoding="utf-8") + assert is_synced(memory, target) is False + + @pytest.mark.skipif(platform.system() == "Windows", reason="symlinks require Unix") + def test_true_after_symlink(self, memory_and_target, monkeypatch): + memory, target = memory_and_target + monkeypatch.setattr(platform, "system", lambda: "Darwin") + sync(memory, target) + assert is_synced(memory, target) is True diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..ddd7bef --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,655 @@ +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from memsync.cli import ( + build_parser, + cmd_config_set, + cmd_config_show, + cmd_diff, + cmd_doctor, + cmd_init, + cmd_providers, + cmd_prune, + cmd_refresh, + cmd_show, + cmd_status, +) +from memsync.config import Config + +SAMPLE_MEMORY = """\ + +# Global Memory + +## Identity & context +- Test user + +## Hard constraints +- Always backup before writing +""" + + +def _args(**kwargs): + """Build a minimal args namespace.""" + defaults = { + "notes": None, "file": None, "dry_run": False, "model": None, + "backup": None, "keep_days": None, + } + defaults.update(kwargs) + + class Namespace: + pass + + ns = Namespace() + for k, v in defaults.items(): + setattr(ns, k, v) + return ns + + +class TestCmdShow: + def test_prints_memory_content(self, memory_file, capsys): + config, tmp_path, global_memory = memory_file + result = cmd_show(_args(), config) + out = capsys.readouterr().out + assert result == 0 + assert "Global Memory" in out + + def test_returns_3_when_no_memory_file(self, tmp_config, capsys): + config, tmp_path = tmp_config + result = cmd_show(_args(), config) + assert result == 3 + + def test_returns_2_when_memory_root_missing(self, tmp_path, capsys): + config = Config(provider="custom", sync_root=tmp_path / "sync") + result = cmd_show(_args(), config) + assert result == 2 + + +class TestCmdStatus: + def test_shows_platform_info(self, memory_file, capsys): + config, tmp_path, _ = memory_file + result = cmd_status(_args(), config) + out = capsys.readouterr().out + assert result == 0 + assert "Platform:" in out + assert "Model:" in out + + def test_shows_memory_path(self, memory_file, capsys): + config, tmp_path, global_memory = memory_file + cmd_status(_args(), config) + out = capsys.readouterr().out + assert str(global_memory) in out + + +class TestCmdPrune: + def test_prunes_old_backups(self, memory_file, capsys): + config, tmp_path, global_memory = memory_file + backup_dir = config.sync_root / ".claude-memory" / "backups" + + # Create a backup manually by copying + from memsync.backups import backup + backup(global_memory, backup_dir) + + result = cmd_prune(_args(keep_days=0), config) + out = capsys.readouterr().out + assert result == 0 + assert "Pruned" in out + + def test_reports_nothing_to_prune(self, memory_file, capsys): + config, tmp_path, _ = memory_file + result = cmd_prune(_args(keep_days=30), config) + out = capsys.readouterr().out + assert result == 0 + assert "No backups" in out + + def test_dry_run_does_not_delete(self, memory_file, capsys): + config, tmp_path, global_memory = memory_file + backup_dir = config.sync_root / ".claude-memory" / "backups" + + from memsync.backups import backup + b = backup(global_memory, backup_dir) + + result = cmd_prune(_args(keep_days=0, dry_run=True), config) + assert result == 0 + assert b.exists() # not deleted + + +class TestCmdProviders: + def test_lists_all_providers(self, tmp_config, capsys): + config, _ = tmp_config + result = cmd_providers(_args(), config) + out = capsys.readouterr().out + assert result == 0 + assert "onedrive" in out + assert "icloud" in out + assert "gdrive" in out + assert "custom" in out + + def test_shows_active_provider(self, tmp_config, capsys): + config, _ = tmp_config + cmd_providers(_args(), config) + out = capsys.readouterr().out + assert "Active provider:" in out + + +class TestCmdRefresh: + def _mock_refresh_result(self, changed=True, truncated=False, content=SAMPLE_MEMORY): + return {"updated_content": content, "changed": changed, "truncated": truncated} + + def test_returns_1_on_empty_notes(self, memory_file, capsys): + config, tmp_path, _ = memory_file + result = cmd_refresh(_args(notes=" "), config) + assert result == 1 + + def test_dry_run_does_not_write(self, memory_file, capsys): + config, tmp_path, global_memory = memory_file + original = global_memory.read_text(encoding="utf-8") + + mock_result = self._mock_refresh_result(changed=True) + with patch("memsync.cli.refresh_memory_content", return_value=mock_result): + result = cmd_refresh(_args(notes="some notes", dry_run=True), config) + + assert result == 0 + assert global_memory.read_text(encoding="utf-8") == original # unchanged + + def test_no_change_prints_message(self, memory_file, capsys): + config, tmp_path, global_memory = memory_file + mock_result = self._mock_refresh_result(changed=False) + + with patch("memsync.cli.refresh_memory_content", return_value=mock_result): + result = cmd_refresh(_args(notes="some notes"), config) + + out = capsys.readouterr().out + assert result == 0 + assert "no changes" in out.lower() + + def test_truncation_returns_5(self, memory_file, capsys): + config, tmp_path, _ = memory_file + mock_result = self._mock_refresh_result(changed=True, truncated=True) + + with patch("memsync.cli.refresh_memory_content", return_value=mock_result): + result = cmd_refresh(_args(notes="some notes"), config) + + assert result == 5 + + def test_successful_refresh_writes_backup_and_memory(self, memory_file, capsys): + config, tmp_path, global_memory = memory_file + updated = SAMPLE_MEMORY + "\n- new item added" + mock_result = self._mock_refresh_result(changed=True, content=updated) + + with patch("memsync.cli.refresh_memory_content", return_value=mock_result): + result = cmd_refresh(_args(notes="some notes"), config) + + assert result == 0 + assert global_memory.read_text(encoding="utf-8") == updated + + backup_dir = config.sync_root / ".claude-memory" / "backups" + from memsync.backups import list_backups + assert len(list_backups(backup_dir)) == 1 + + def test_model_override_passed_to_refresh(self, memory_file): + config, tmp_path, _ = memory_file + mock_result = self._mock_refresh_result(changed=False) + + with patch("memsync.cli.refresh_memory_content", return_value=mock_result) as mock_fn: + cmd_refresh(_args(notes="notes", model="claude-haiku-4-5-20251001"), config) + + called_config = mock_fn.call_args.args[2] + assert called_config.model == "claude-haiku-4-5-20251001" + + +@pytest.mark.smoke +class TestParser: + def test_refresh_requires_notes_or_file(self): + parser = build_parser() + args = parser.parse_args(["refresh", "--notes", "hello"]) + assert args.notes == "hello" + + def test_prune_default_keep_days_is_none(self): + parser = build_parser() + args = parser.parse_args(["prune"]) + assert args.keep_days is None # falls back to config.keep_days + + def test_config_set_parses_key_value(self): + parser = build_parser() + args = parser.parse_args(["config", "set", "model", "claude-opus-4-20250514"]) + assert args.key == "model" + assert args.value == "claude-opus-4-20250514" + + def test_doctor_is_registered(self): + parser = build_parser() + args = parser.parse_args(["doctor"]) + assert args.func is cmd_doctor + + +# --------------------------------------------------------------------------- +# cmd_init +# --------------------------------------------------------------------------- + +class TestCmdInit: + def _init_args(self, **kwargs): + defaults = {"force": False, "provider": None, "sync_root": None} + defaults.update(kwargs) + + class Namespace: + pass + + ns = Namespace() + for k, v in defaults.items(): + setattr(ns, k, v) + return ns + + def test_init_with_sync_root(self, tmp_config, monkeypatch): + config, tmp_path = tmp_config + sync_dir = tmp_path / "my-sync" + sync_dir.mkdir() + + monkeypatch.setattr("memsync.cli.sync_claude_md", lambda src, dst: None) + + result = cmd_init(self._init_args(sync_root=str(sync_dir)), config) + assert result == 0 + + memory = sync_dir / ".claude-memory" / "GLOBAL_MEMORY.md" + assert memory.exists() + assert "" in memory.read_text(encoding="utf-8") + + def test_init_with_sync_root_creates_dirs(self, tmp_config, monkeypatch): + config, tmp_path = tmp_config + sync_dir = tmp_path / "sync-root" + sync_dir.mkdir() + + monkeypatch.setattr("memsync.cli.sync_claude_md", lambda src, dst: None) + cmd_init(self._init_args(sync_root=str(sync_dir)), config) + + assert (sync_dir / ".claude-memory" / "backups").exists() + assert (sync_dir / ".claude-memory" / "sessions").exists() + + def test_init_with_explicit_provider(self, tmp_config, monkeypatch): + config, tmp_path = tmp_config + fake_root = tmp_path / "onedrive" + fake_root.mkdir() + + from memsync.providers.onedrive import OneDriveProvider + monkeypatch.setattr(OneDriveProvider, "detect", lambda self: fake_root) + monkeypatch.setattr("memsync.cli.sync_claude_md", lambda src, dst: None) + + result = cmd_init(self._init_args(provider="onedrive"), config) + assert result == 0 + + def test_init_returns_4_when_provider_not_found(self, tmp_config, capsys): + config, tmp_path = tmp_config + result = cmd_init(self._init_args(provider="onedrive"), config) + # OneDrive not present in tmp_path → 4 (detection failed) + # OR 0 if OneDrive is detected on this machine; just check it ran + assert result in (0, 4) + + def test_init_sync_root_nonexistent_returns_1(self, tmp_config, capsys): + config, tmp_path = tmp_config + result = cmd_init(self._init_args(sync_root="/nonexistent/path/xyz"), config) + assert result == 1 + + def test_init_already_initialized_without_force(self, tmp_config, monkeypatch): + config, tmp_path = tmp_config + config_path = tmp_path / "config.toml" + config_path.write_text("[core]\nprovider = 'onedrive'\n", encoding="utf-8") + monkeypatch.setattr("memsync.config.get_config_path", lambda: config_path) + monkeypatch.setattr("memsync.cli.get_config_path", lambda: config_path) + + result = cmd_init(self._init_args(), config) + assert result == 0 # exits gracefully + + def test_init_force_overwrites_existing_memory(self, tmp_config, monkeypatch): + config, tmp_path = tmp_config + sync_dir = tmp_path / "sync-force" + sync_dir.mkdir() + memory_dir = sync_dir / ".claude-memory" + memory_dir.mkdir() + existing = memory_dir / "GLOBAL_MEMORY.md" + existing.write_text("# Old content", encoding="utf-8") + + monkeypatch.setattr("memsync.cli.sync_claude_md", lambda src, dst: None) + cmd_init(self._init_args(sync_root=str(sync_dir), force=True), config) + + new_content = existing.read_text(encoding="utf-8") + assert "" in new_content + + def test_init_writes_config_file(self, tmp_config, monkeypatch): + config, tmp_path = tmp_config + sync_dir = tmp_path / "sync-cfg" + sync_dir.mkdir() + + saved_configs = [] + + def capture_save(self): + saved_configs.append(self) + monkeypatch.setattr(Config, "save", capture_save) + monkeypatch.setattr("memsync.cli.sync_claude_md", lambda src, dst: None) + + cmd_init(self._init_args(sync_root=str(sync_dir)), config) + assert len(saved_configs) == 1 + + +# --------------------------------------------------------------------------- +# cmd_diff +# --------------------------------------------------------------------------- + +class TestCmdDiff: + def test_returns_3_when_no_memory_file(self, tmp_config, capsys): + config, tmp_path = tmp_config + result = cmd_diff(_args(), config) + assert result == 3 + + def test_prints_no_backups_message(self, memory_file, capsys): + config, tmp_path, _ = memory_file + result = cmd_diff(_args(), config) + out = capsys.readouterr().out + assert result == 0 + assert "No backups found" in out + + def test_shows_diff_against_latest_backup(self, memory_file, capsys): + config, tmp_path, global_memory = memory_file + backup_dir = config.sync_root / ".claude-memory" / "backups" + + # Create a backup of the original + from memsync.backups import backup + backup(global_memory, backup_dir) + + # Modify the current memory + global_memory.write_text( + global_memory.read_text(encoding="utf-8") + "\n- New item added", + encoding="utf-8", + ) + + result = cmd_diff(_args(), config) + out = capsys.readouterr().out + assert result == 0 + assert "New item added" in out + + def test_no_diff_when_identical(self, memory_file, capsys): + config, tmp_path, global_memory = memory_file + backup_dir = config.sync_root / ".claude-memory" / "backups" + + from memsync.backups import backup + backup(global_memory, backup_dir) + + result = cmd_diff(_args(), config) + out = capsys.readouterr().out + assert result == 0 + assert "No differences" in out + + def test_specific_backup_flag(self, memory_file, capsys): + config, tmp_path, global_memory = memory_file + backup_dir = config.sync_root / ".claude-memory" / "backups" + + from memsync.backups import backup + b = backup(global_memory, backup_dir) + + result = cmd_diff(_args(backup=b.name), config) + assert result == 0 + + def test_nonexistent_backup_returns_1(self, memory_file, capsys): + config, tmp_path, _ = memory_file + result = cmd_diff(_args(backup="GLOBAL_MEMORY_19991231_235959.md"), config) + assert result == 1 + + +# --------------------------------------------------------------------------- +# cmd_config_show +# --------------------------------------------------------------------------- + +class TestCmdConfigShow: + def test_returns_2_when_no_config(self, tmp_config, capsys): + config, tmp_path = tmp_config + result = cmd_config_show(_args(), config) + assert result == 2 + + def test_prints_config_contents(self, tmp_config, monkeypatch, capsys): + config, tmp_path = tmp_config + config_path = tmp_path / "config.toml" + config_path.write_text("[core]\nprovider = \"onedrive\"\n", encoding="utf-8") + monkeypatch.setattr("memsync.cli.get_config_path", lambda: config_path) + + result = cmd_config_show(_args(), config) + out = capsys.readouterr().out + assert result == 0 + assert "onedrive" in out + + +# --------------------------------------------------------------------------- +# cmd_config_set +# --------------------------------------------------------------------------- + +class TestCmdConfigSet: + def _set_args(self, key, value): + class Namespace: + pass + ns = Namespace() + ns.key = key + ns.value = value + return ns + + def test_set_model(self, tmp_config, monkeypatch): + config, tmp_path = tmp_config + saved = [] + monkeypatch.setattr(Config, "save", lambda self: saved.append(self)) + + result = cmd_config_set(self._set_args("model", "claude-opus-4-20250514"), config) + assert result == 0 + assert saved[0].model == "claude-opus-4-20250514" + + def test_set_provider(self, tmp_config, monkeypatch): + config, tmp_path = tmp_config + saved = [] + monkeypatch.setattr(Config, "save", lambda self: saved.append(self)) + + result = cmd_config_set(self._set_args("provider", "icloud"), config) + assert result == 0 + assert saved[0].provider == "icloud" + + def test_set_invalid_provider_returns_1(self, tmp_config, capsys): + config, tmp_path = tmp_config + result = cmd_config_set(self._set_args("provider", "dropbox"), config) + err = capsys.readouterr().err + assert result == 1 + assert "dropbox" in err + + def test_set_keep_days(self, tmp_config, monkeypatch): + config, tmp_path = tmp_config + saved = [] + monkeypatch.setattr(Config, "save", lambda self: saved.append(self)) + + result = cmd_config_set(self._set_args("keep_days", "60"), config) + assert result == 0 + assert saved[0].keep_days == 60 + + def test_set_keep_days_non_integer_returns_1(self, tmp_config, capsys): + config, tmp_path = tmp_config + result = cmd_config_set(self._set_args("keep_days", "thirty"), config) + assert result == 1 + + def test_set_max_memory_lines(self, tmp_config, monkeypatch): + config, tmp_path = tmp_config + saved = [] + monkeypatch.setattr(Config, "save", lambda self: saved.append(self)) + + result = cmd_config_set(self._set_args("max_memory_lines", "300"), config) + assert result == 0 + assert saved[0].max_memory_lines == 300 + + def test_set_sync_root(self, tmp_config, monkeypatch): + config, tmp_path = tmp_config + sync_dir = tmp_path / "new-sync" + sync_dir.mkdir() + saved = [] + monkeypatch.setattr(Config, "save", lambda self: saved.append(self)) + + result = cmd_config_set(self._set_args("sync_root", str(sync_dir)), config) + assert result == 0 + assert saved[0].sync_root == sync_dir + assert saved[0].provider == "custom" # auto-set when sync_root configured + + def test_set_sync_root_nonexistent_returns_1(self, tmp_config, capsys): + config, tmp_path = tmp_config + result = cmd_config_set(self._set_args("sync_root", "/nonexistent/xyz"), config) + assert result == 1 + + def test_set_unknown_key_returns_1(self, tmp_config, capsys): + config, tmp_path = tmp_config + result = cmd_config_set(self._set_args("unknown_key", "value"), config) + err = capsys.readouterr().err + assert result == 1 + assert "unknown_key" in err + + +# --------------------------------------------------------------------------- +# cmd_doctor +# --------------------------------------------------------------------------- + +class TestCmdDoctor: + def test_all_checks_pass_returns_0(self, memory_file, monkeypatch): + config, tmp_path, global_memory = memory_file + + # Sync CLAUDE.md first + from memsync.claude_md import sync as sync_claude_md + config.claude_md_target.parent.mkdir(parents=True, exist_ok=True) + sync_claude_md(global_memory, config.claude_md_target) + + monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key-abc") + monkeypatch.setattr("memsync.cli.get_config_path", + lambda: tmp_path / "config.toml") + (tmp_path / "config.toml").write_text("[core]\n", encoding="utf-8") + + result = cmd_doctor(_args(), config) + assert result == 0 + + def test_missing_api_key_fails(self, memory_file, monkeypatch, capsys): + config, tmp_path, global_memory = memory_file + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + + result = cmd_doctor(_args(), config) + out = capsys.readouterr().out + assert result == 1 + assert "ANTHROPIC_API_KEY" in out + + def test_missing_memory_file_fails(self, tmp_config, monkeypatch, capsys): + config, tmp_path = tmp_config + monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") + # Memory root exists but no GLOBAL_MEMORY.md + + result = cmd_doctor(_args(), config) + capsys.readouterr() + assert result == 1 + + def test_output_includes_all_check_labels(self, memory_file, monkeypatch, capsys): + config, tmp_path, _ = memory_file + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + + cmd_doctor(_args(), config) + out = capsys.readouterr().out + assert "Config file" in out + assert "ANTHROPIC_API_KEY" in out + assert "Provider" in out + + +# --------------------------------------------------------------------------- +# Daemon CLI commands +# --------------------------------------------------------------------------- + +class TestDaemonCLIGuard: + """When daemon extras are not installed, all commands print a hint.""" + + def test_guard_fails_gracefully_when_no_extras(self, tmp_config, capsys): + config, _ = tmp_config + from memsync.cli import cmd_daemon_start + + class FakeArgs: + detach = False + + with patch("memsync.cli._daemon_import_guard", return_value=False): + result = cmd_daemon_start(FakeArgs(), config) + assert result == 1 + + def test_stop_without_pid_file_returns_1(self, tmp_config, capsys, tmp_path, monkeypatch): + config, _ = tmp_config + from memsync.cli import cmd_daemon_stop + + class FakeArgs: + pass + + monkeypatch.setattr("memsync.cli._PID_FILE", tmp_path / "nonexistent.pid") + result = cmd_daemon_stop(FakeArgs(), config) + assert result == 1 + + def test_status_no_pid_file(self, tmp_config, capsys, tmp_path, monkeypatch): + config, _ = tmp_config + from memsync.cli import cmd_daemon_status + + class FakeArgs: + pass + + monkeypatch.setattr("memsync.cli._PID_FILE", tmp_path / "nonexistent.pid") + result = cmd_daemon_status(FakeArgs(), config) + out = capsys.readouterr().out + assert result == 0 + assert "not running" in out.lower() + + def test_schedule_shows_jobs(self, tmp_config, capsys): + config, _ = tmp_config + from memsync.cli import cmd_daemon_schedule + + class FakeArgs: + pass + + # daemon extras installed, config has refresh enabled — should show jobs + result = cmd_daemon_schedule(FakeArgs(), config) + capsys.readouterr() + assert result == 0 + + def test_install_raises_not_implemented_on_windows(self, tmp_config, capsys): + config, _ = tmp_config + from memsync.cli import cmd_daemon_install + + class FakeArgs: + pass + + with patch("memsync.daemon.service.install_service", + side_effect=NotImplementedError("Windows not supported")): + result = cmd_daemon_install(FakeArgs(), config) + assert result == 1 + + def test_uninstall_raises_not_implemented_on_windows(self, tmp_config, capsys): + config, _ = tmp_config + from memsync.cli import cmd_daemon_uninstall + + class FakeArgs: + pass + + with patch("memsync.daemon.service.uninstall_service", + side_effect=NotImplementedError("Windows not supported")): + result = cmd_daemon_uninstall(FakeArgs(), config) + assert result == 1 + + def test_web_opens_browser(self, tmp_config, capsys): + config, _ = tmp_config + from memsync.cli import cmd_daemon_web + + class FakeArgs: + pass + + with patch("webbrowser.open") as mock_open: + result = cmd_daemon_web(FakeArgs(), config) + assert result == 0 + mock_open.assert_called_once() + + def test_parser_has_daemon_subcommand(self): + parser = build_parser() + args = parser.parse_args(["daemon", "stop"]) + from memsync.cli import cmd_daemon_stop + assert args.func is cmd_daemon_stop + + def test_parser_daemon_start_has_detach_flag(self): + parser = build_parser() + args = parser.parse_args(["daemon", "start", "--detach"]) + assert args.detach is True diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..ca94c47 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import platform +from pathlib import Path + +import pytest + +from memsync.config import Config, get_config_path + + +@pytest.mark.smoke +class TestConfigDefaults: + def test_default_provider(self): + c = Config() + assert c.provider == "onedrive" + + def test_default_model(self): + c = Config() + assert c.model == "claude-sonnet-4-20250514" + + def test_default_max_memory_lines(self): + c = Config() + assert c.max_memory_lines == 400 + + def test_default_keep_days(self): + c = Config() + assert c.keep_days == 30 + + def test_default_sync_root_is_none(self): + c = Config() + assert c.sync_root is None + + def test_default_claude_md_target_is_set(self): + c = Config() + assert c.claude_md_target is not None + assert c.claude_md_target == Path("~/.claude/CLAUDE.md").expanduser() + + +class TestConfigPath: + def test_windows_path_uses_appdata(self, monkeypatch): + monkeypatch.setattr(platform, "system", lambda: "Windows") + monkeypatch.setenv("APPDATA", "C:/Users/test/AppData/Roaming") + path = get_config_path() + assert "memsync" in str(path) + assert path.suffix == ".toml" + + def test_linux_path_uses_xdg(self, monkeypatch): + monkeypatch.setattr(platform, "system", lambda: "Linux") + monkeypatch.setenv("XDG_CONFIG_HOME", "/home/test/.config") + path = get_config_path() + assert path.as_posix().endswith("memsync/config.toml") + + def test_mac_path_uses_dotconfig(self, monkeypatch, tmp_path): + monkeypatch.setattr(platform, "system", lambda: "Darwin") + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + path = get_config_path() + assert "memsync" in str(path) + assert path.suffix == ".toml" + + +class TestConfigRoundTrip: + def test_save_and_load(self, tmp_path, monkeypatch): + monkeypatch.setattr( + "memsync.config.get_config_path", + lambda: tmp_path / "config.toml", + ) + c = Config( + provider="icloud", + model="claude-haiku-4-5-20251001", + keep_days=60, + sync_root=tmp_path / "sync", + ) + c.save() + + loaded = Config.load() + assert loaded.provider == "icloud" + assert loaded.model == "claude-haiku-4-5-20251001" + assert loaded.keep_days == 60 + assert loaded.sync_root == tmp_path / "sync" + + def test_load_defaults_when_file_missing(self, tmp_path, monkeypatch): + monkeypatch.setattr( + "memsync.config.get_config_path", + lambda: tmp_path / "nonexistent.toml", + ) + c = Config.load() + assert c.provider == "onedrive" + + def test_toml_output_is_valid(self): + import tomllib + c = Config(provider="gdrive", keep_days=14) + toml_text = c._to_toml() + parsed = tomllib.loads(toml_text) + assert parsed["core"]["provider"] == "gdrive" + assert parsed["backups"]["keep_days"] == 14 + + def test_sync_root_serialized_with_forward_slashes(self, tmp_path): + c = Config(sync_root=tmp_path / "my sync" / "folder") + toml_text = c._to_toml() + # Forward slashes in path (TOML-safe) + assert "\\" not in toml_text.split("sync_root")[1].split("\n")[0] diff --git a/tests/test_daemon_capture.py b/tests/test_daemon_capture.py new file mode 100644 index 0000000..a450a32 --- /dev/null +++ b/tests/test_daemon_capture.py @@ -0,0 +1,190 @@ +""" +Tests for memsync.daemon.capture + +Uses Flask's built-in test client. Verifies auth, request validation, +and session log writing. +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from memsync.config import Config, DaemonConfig +from memsync.daemon.capture import create_capture_app + + +@pytest.fixture +def capture_config(tmp_path: Path) -> tuple[Config, Path]: + sync_root = tmp_path / "sync" + memory_root = sync_root / ".claude-memory" + (memory_root / "sessions").mkdir(parents=True) + + config = Config( + provider="custom", + sync_root=sync_root, + daemon=DaemonConfig( + capture_enabled=True, + capture_port=5001, + capture_token="", # no token by default + ), + ) + return config, memory_root + + +class TestHealth: + def test_health_returns_ok(self, capture_config: tuple) -> None: + config, _ = capture_config + app = create_capture_app(config) + with app.test_client() as client: + resp = client.get("/health") + assert resp.status_code == 200 + assert json.loads(resp.data)["ok"] is True + + +class TestAddNote: + def test_accepts_valid_note(self, capture_config: tuple) -> None: + config, _ = capture_config + app = create_capture_app(config) + with app.test_client() as client: + resp = client.post( + "/note", + data=json.dumps({"text": "Test note from iPhone"}), + content_type="application/json", + ) + assert resp.status_code == 200 + body = json.loads(resp.data) + assert body["ok"] is True + assert "timestamp" in body + + def test_rejects_empty_text(self, capture_config: tuple) -> None: + config, _ = capture_config + app = create_capture_app(config) + with app.test_client() as client: + resp = client.post( + "/note", + data=json.dumps({"text": " "}), + content_type="application/json", + ) + assert resp.status_code == 400 + assert b"empty" in resp.data + + def test_rejects_missing_text_field(self, capture_config: tuple) -> None: + config, _ = capture_config + app = create_capture_app(config) + with app.test_client() as client: + resp = client.post( + "/note", + data=json.dumps({"msg": "wrong key"}), + content_type="application/json", + ) + assert resp.status_code == 400 + + def test_rejects_non_json_body(self, capture_config: tuple) -> None: + config, _ = capture_config + app = create_capture_app(config) + with app.test_client() as client: + resp = client.post("/note", data="not json", content_type="text/plain") + assert resp.status_code == 400 + + def test_writes_to_session_log(self, capture_config: tuple) -> None: + config, memory_root = capture_config + app = create_capture_app(config) + with app.test_client() as client: + client.post( + "/note", + data=json.dumps({"text": "Important note captured"}), + content_type="application/json", + ) + from datetime import datetime + today = datetime.now().strftime("%Y-%m-%d") + log_path = memory_root / "sessions" / f"{today}.md" + assert log_path.exists() + content = log_path.read_text(encoding="utf-8") + assert "Important note captured" in content + assert "(captured)" in content + + def test_appends_to_existing_session_log(self, capture_config: tuple) -> None: + config, memory_root = capture_config + from datetime import datetime + today = datetime.now().strftime("%Y-%m-%d") + log_path = memory_root / "sessions" / f"{today}.md" + log_path.write_text("existing content\n", encoding="utf-8") + + app = create_capture_app(config) + with app.test_client() as client: + client.post( + "/note", + data=json.dumps({"text": "appended note"}), + content_type="application/json", + ) + content = log_path.read_text(encoding="utf-8") + assert "existing content" in content + assert "appended note" in content + + +class TestTokenAuth: + def test_accepts_without_token_when_none_configured(self, capture_config: tuple) -> None: + config, _ = capture_config # capture_token = "" + app = create_capture_app(config) + with app.test_client() as client: + resp = client.post( + "/note", + data=json.dumps({"text": "unauthenticated"}), + content_type="application/json", + ) + assert resp.status_code == 200 + + def test_requires_token_when_configured(self, capture_config: tuple) -> None: + import dataclasses + + config, _ = capture_config + config = dataclasses.replace( + config, + daemon=dataclasses.replace(config.daemon, capture_token="secret123"), + ) + app = create_capture_app(config) + with app.test_client() as client: + resp = client.post( + "/note", + data=json.dumps({"text": "no token"}), + content_type="application/json", + ) + assert resp.status_code == 401 + + def test_accepts_valid_token(self, capture_config: tuple) -> None: + import dataclasses + + config, _ = capture_config + config = dataclasses.replace( + config, + daemon=dataclasses.replace(config.daemon, capture_token="secret123"), + ) + app = create_capture_app(config) + with app.test_client() as client: + resp = client.post( + "/note", + data=json.dumps({"text": "authenticated"}), + content_type="application/json", + headers={"X-Memsync-Token": "secret123"}, + ) + assert resp.status_code == 200 + + def test_rejects_wrong_token(self, capture_config: tuple) -> None: + import dataclasses + + config, _ = capture_config + config = dataclasses.replace( + config, + daemon=dataclasses.replace(config.daemon, capture_token="secret123"), + ) + app = create_capture_app(config) + with app.test_client() as client: + resp = client.post( + "/note", + data=json.dumps({"text": "wrong token"}), + content_type="application/json", + headers={"X-Memsync-Token": "wrongtoken"}, + ) + assert resp.status_code == 401 diff --git a/tests/test_daemon_digest.py b/tests/test_daemon_digest.py new file mode 100644 index 0000000..4327ff1 --- /dev/null +++ b/tests/test_daemon_digest.py @@ -0,0 +1,123 @@ +""" +Tests for memsync.daemon.digest + +API calls are always mocked — no real Claude API calls in tests. +""" +from __future__ import annotations + +from datetime import date, timedelta +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from memsync.config import Config, DaemonConfig +from memsync.daemon.digest import generate_and_send, generate_digest + + +@pytest.fixture +def digest_memory_root(tmp_path: Path) -> Path: + memory_root = tmp_path / ".claude-memory" + (memory_root / "sessions").mkdir(parents=True) + return memory_root + + +def _write_session(memory_root: Path, day: date, content: str) -> None: + log = memory_root / "sessions" / f"{day.strftime('%Y-%m-%d')}.md" + log.write_text(content, encoding="utf-8") + + +@pytest.fixture +def digest_config(tmp_path: Path) -> Config: + sync_root = tmp_path / "sync" + (sync_root / ".claude-memory" / "sessions").mkdir(parents=True) + return Config( + provider="custom", + sync_root=sync_root, + daemon=DaemonConfig( + digest_enabled=True, + digest_email_to="to@example.com", + digest_email_from="from@example.com", + digest_smtp_host="smtp.example.com", + ), + ) + + +class TestGenerateDigest: + def test_returns_empty_string_when_no_logs(self, digest_memory_root: Path) -> None: + config = Config() + result = generate_digest(digest_memory_root, config) + assert result == "" + + def test_collects_past_7_days(self, digest_memory_root: Path) -> None: + today = date.today() + for i in range(1, 6): + day = today - timedelta(days=i) + _write_session(digest_memory_root, day, f"Notes for day -{i}") + + mock_response = MagicMock() + mock_response.content = [MagicMock(text="Weekly summary text")] + + with patch("anthropic.Anthropic") as mock_client: + mock_client.return_value.messages.create.return_value = mock_response + result = generate_digest(digest_memory_root, Config()) + + assert result == "Weekly summary text" + + def test_includes_today_in_window(self, digest_memory_root: Path) -> None: + today = date.today() + _write_session(digest_memory_root, today, "Today's notes") + + mock_response = MagicMock() + mock_response.content = [MagicMock(text="summary")] + + with patch("anthropic.Anthropic") as mock_client: + mock_client.return_value.messages.create.return_value = mock_response + result = generate_digest(digest_memory_root, Config()) + + # Today is in the 7-day window (week_ago + 7 days = today) + assert result == "summary" + + def test_passes_model_from_config(self, digest_memory_root: Path) -> None: + today = date.today() + _write_session(digest_memory_root, today - timedelta(days=1), "Yesterday's notes") + + mock_response = MagicMock() + mock_response.content = [MagicMock(text="summary")] + config = Config(model="claude-haiku-4-5-20251001") + + with patch("anthropic.Anthropic") as mock_client: + mock_client.return_value.messages.create.return_value = mock_response + generate_digest(digest_memory_root, config) + + call_kwargs = mock_client.return_value.messages.create.call_args[1] + assert call_kwargs["model"] == "claude-haiku-4-5-20251001" + + +class TestGenerateAndSend: + def test_sends_email_when_digest_available(self, digest_config: Config) -> None: + memory_root = digest_config.sync_root / ".claude-memory" + yesterday = date.today() - timedelta(days=1) + _write_session(memory_root, yesterday, "Worked on testing") + + mock_response = MagicMock() + mock_response.content = [MagicMock(text="Weekly digest text")] + + with patch("anthropic.Anthropic") as mock_client: + mock_client.return_value.messages.create.return_value = mock_response + with patch("memsync.daemon.notify._send_email") as mock_email: + generate_and_send(digest_config) + + mock_email.assert_called_once() + call_kwargs = mock_email.call_args + assert "weekly digest" in call_kwargs[1]["subject"].lower() + + def test_skips_send_when_no_logs(self, digest_config: Config) -> None: + with patch("memsync.daemon.notify._send_email") as mock_email: + generate_and_send(digest_config) + mock_email.assert_not_called() + + def test_skips_when_sync_root_missing(self) -> None: + config = Config(provider="custom", sync_root=None) + # Should not raise + generate_and_send(config) diff --git a/tests/test_daemon_notify.py b/tests/test_daemon_notify.py new file mode 100644 index 0000000..1f8a657 --- /dev/null +++ b/tests/test_daemon_notify.py @@ -0,0 +1,94 @@ +""" +Tests for memsync.daemon.notify + +Tests the three notification channels: log, email, file. +Email is always mocked — no real SMTP connections made. +""" +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from memsync.config import Config, DaemonConfig +from memsync.daemon.notify import _write_flag_file, notify + + +@pytest.fixture +def log_config() -> Config: + return Config(daemon=DaemonConfig(drift_notify="log")) + + +@pytest.fixture +def email_config() -> Config: + return Config( + daemon=DaemonConfig( + drift_notify="email", + digest_email_from="from@example.com", + digest_email_to="to@example.com", + digest_smtp_host="smtp.example.com", + digest_smtp_port=587, + digest_smtp_user="user", + digest_smtp_password="pass", + ) + ) + + +@pytest.fixture +def file_config() -> Config: + return Config(daemon=DaemonConfig(drift_notify="file")) + + +class TestNotifyLog: + def test_log_channel_does_not_raise(self, log_config: Config) -> None: + notify(log_config, "test subject", "test body") + + def test_unknown_channel_falls_back_to_log(self) -> None: + config = Config(daemon=DaemonConfig(drift_notify="unknown_channel")) + notify(config, "subject", "body") # must not raise + + +class TestNotifyEmail: + def test_email_channel_calls_smtp(self, email_config: Config) -> None: + with patch("smtplib.SMTP") as mock_smtp_class: + mock_smtp = MagicMock() + mock_smtp_class.return_value.__enter__ = MagicMock(return_value=mock_smtp) + mock_smtp_class.return_value.__exit__ = MagicMock(return_value=False) + notify(email_config, "subject", "body") + mock_smtp.send_message.assert_called_once() + + def test_email_failure_does_not_raise(self, email_config: Config) -> None: + with patch("smtplib.SMTP", side_effect=ConnectionRefusedError("no server")): + notify(email_config, "subject", "body") # must not raise + + def test_uses_env_var_password_over_config(self, email_config: Config, monkeypatch) -> None: + """MEMSYNC_SMTP_PASSWORD env var takes precedence over plaintext config.""" + monkeypatch.setenv("MEMSYNC_SMTP_PASSWORD", "env_secret") + with patch("smtplib.SMTP") as mock_smtp_class: + mock_smtp = MagicMock() + mock_smtp_class.return_value.__enter__ = MagicMock(return_value=mock_smtp) + mock_smtp_class.return_value.__exit__ = MagicMock(return_value=False) + from memsync.daemon.notify import _send_email + _send_email(email_config, "subject", "body") + mock_smtp.login.assert_called_once_with("user", "env_secret") + + +class TestNotifyFile: + def test_file_channel_writes_alert( + self, file_config: Config, tmp_path: Path, monkeypatch + ) -> None: + monkeypatch.setattr(Path, "home", lambda: tmp_path) + _write_flag_file("alert subject", "alert body") + alerts_dir = tmp_path / ".config" / "memsync" / "alerts" + files = list(alerts_dir.glob("*_alert.txt")) + assert len(files) == 1 + content = files[0].read_text(encoding="utf-8") + assert "alert subject" in content + assert "alert body" in content + + def test_file_channel_notify_does_not_raise( + self, file_config: Config, tmp_path: Path, monkeypatch + ) -> None: + monkeypatch.setattr(Path, "home", lambda: tmp_path) + notify(file_config, "subject", "body") # must not raise diff --git a/tests/test_daemon_scheduler.py b/tests/test_daemon_scheduler.py new file mode 100644 index 0000000..f141e84 --- /dev/null +++ b/tests/test_daemon_scheduler.py @@ -0,0 +1,294 @@ +""" +Tests for memsync.daemon.scheduler + +All jobs are tested in isolation by calling them directly with mocked filesystem +and mocked API. No real APScheduler scheduling occurs in these tests. +""" +from __future__ import annotations + +from datetime import date +from pathlib import Path +from unittest.mock import patch + +import pytest + +from memsync.config import Config, DaemonConfig +from memsync.daemon.scheduler import ( + build_scheduler, + job_backup_mirror, + job_drift_check, + job_nightly_refresh, + job_weekly_digest, +) + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def daemon_config(tmp_path: Path) -> Config: + """Config with daemon enabled, sync_root pointing to tmp_path.""" + sync_root = tmp_path / "sync" + memory_root = sync_root / ".claude-memory" + (memory_root / "sessions").mkdir(parents=True) + (memory_root / "backups").mkdir(parents=True) + (memory_root / "GLOBAL_MEMORY.md").write_text( + "# Global Memory\n\n## Identity\n- Test user\n\n## Hard constraints\n- Always test\n", + encoding="utf-8", + ) + claude_md = tmp_path / "claude" / "CLAUDE.md" + claude_md.parent.mkdir(parents=True) + claude_md.write_text("# Global Memory\n", encoding="utf-8") + + return Config( + provider="custom", + sync_root=sync_root, + claude_md_target=claude_md, + daemon=DaemonConfig( + enabled=True, + refresh_enabled=True, + backup_mirror_path="", + drift_check_enabled=True, + digest_enabled=False, + ), + ) + + +# --------------------------------------------------------------------------- +# build_scheduler +# --------------------------------------------------------------------------- + +class TestBuildScheduler: + def test_returns_background_scheduler_by_default(self, daemon_config: Config) -> None: + from apscheduler.schedulers.background import BackgroundScheduler + + scheduler = build_scheduler(daemon_config, blocking=False) + assert isinstance(scheduler, BackgroundScheduler) + + def test_returns_blocking_scheduler_when_requested(self, daemon_config: Config) -> None: + from apscheduler.schedulers.blocking import BlockingScheduler + + scheduler = build_scheduler(daemon_config, blocking=True) + assert isinstance(scheduler, BlockingScheduler) + + def test_refresh_job_added_when_enabled(self, daemon_config: Config) -> None: + scheduler = build_scheduler(daemon_config) + job_ids = [j.id for j in scheduler.get_jobs()] + assert "nightly_refresh" in job_ids + + def test_refresh_job_not_added_when_disabled(self, daemon_config: Config) -> None: + import dataclasses + + cfg = dataclasses.replace( + daemon_config, + daemon=dataclasses.replace(daemon_config.daemon, refresh_enabled=False), + ) + scheduler = build_scheduler(cfg) + job_ids = [j.id for j in scheduler.get_jobs()] + assert "nightly_refresh" not in job_ids + + def test_backup_mirror_job_added_when_path_set( + self, daemon_config: Config, tmp_path: Path + ) -> None: + import dataclasses + + mirror = tmp_path / "mirror" + cfg = dataclasses.replace( + daemon_config, + daemon=dataclasses.replace(daemon_config.daemon, backup_mirror_path=str(mirror)), + ) + scheduler = build_scheduler(cfg) + job_ids = [j.id for j in scheduler.get_jobs()] + assert "backup_mirror" in job_ids + + def test_backup_mirror_job_not_added_when_path_empty(self, daemon_config: Config) -> None: + scheduler = build_scheduler(daemon_config) + job_ids = [j.id for j in scheduler.get_jobs()] + assert "backup_mirror" not in job_ids + + def test_drift_check_job_added_when_enabled(self, daemon_config: Config) -> None: + scheduler = build_scheduler(daemon_config) + job_ids = [j.id for j in scheduler.get_jobs()] + assert "drift_check" in job_ids + + def test_digest_job_not_added_when_disabled(self, daemon_config: Config) -> None: + scheduler = build_scheduler(daemon_config) + job_ids = [j.id for j in scheduler.get_jobs()] + assert "weekly_digest" not in job_ids + + def test_digest_job_added_when_enabled(self, daemon_config: Config) -> None: + import dataclasses + + cfg = dataclasses.replace( + daemon_config, + daemon=dataclasses.replace(daemon_config.daemon, digest_enabled=True), + ) + scheduler = build_scheduler(cfg) + job_ids = [j.id for j in scheduler.get_jobs()] + assert "weekly_digest" in job_ids + + +# --------------------------------------------------------------------------- +# job_nightly_refresh +# --------------------------------------------------------------------------- + +class TestJobNightlyRefresh: + def test_skips_when_no_session_log(self, daemon_config: Config) -> None: + """No session log for today → early return, no API call.""" + with patch("memsync.sync.refresh_memory_content") as mock_refresh: + job_nightly_refresh(daemon_config) + mock_refresh.assert_not_called() + + def test_skips_when_session_log_empty(self, daemon_config: Config) -> None: + memory_root = daemon_config.sync_root / ".claude-memory" + today = date.today().strftime("%Y-%m-%d") + (memory_root / "sessions" / f"{today}.md").write_text(" \n", encoding="utf-8") + + with patch("memsync.sync.refresh_memory_content") as mock_refresh: + job_nightly_refresh(daemon_config) + mock_refresh.assert_not_called() + + def test_calls_api_when_notes_exist(self, daemon_config: Config) -> None: + memory_root = daemon_config.sync_root / ".claude-memory" + today = date.today().strftime("%Y-%m-%d") + (memory_root / "sessions" / f"{today}.md").write_text( + "Today I worked on testing.", encoding="utf-8" + ) + + mock_result = {"changed": False, "updated_content": "# Global Memory\n", "truncated": False} + + with patch("memsync.sync.refresh_memory_content", return_value=mock_result) as mock_refresh: + job_nightly_refresh(daemon_config) + mock_refresh.assert_called_once() + + def test_writes_updated_memory_when_changed(self, daemon_config: Config) -> None: + memory_root = daemon_config.sync_root / ".claude-memory" + today = date.today().strftime("%Y-%m-%d") + (memory_root / "sessions" / f"{today}.md").write_text( + "Worked on something new.", encoding="utf-8" + ) + + new_content = "# Global Memory\n\n## Identity\n- Updated user\n" + mock_result = {"changed": True, "updated_content": new_content, "truncated": False} + + with patch("memsync.sync.refresh_memory_content", return_value=mock_result): + with patch("memsync.claude_md.sync"): + job_nightly_refresh(daemon_config) + + written = (memory_root / "GLOBAL_MEMORY.md").read_text(encoding="utf-8") + assert written == new_content + + def test_does_not_raise_on_exception(self, daemon_config: Config) -> None: + """Job must never propagate exceptions — daemon would crash.""" + memory_root = daemon_config.sync_root / ".claude-memory" + today = date.today().strftime("%Y-%m-%d") + (memory_root / "sessions" / f"{today}.md").write_text("notes", encoding="utf-8") + + with patch( + "memsync.sync.refresh_memory_content", + side_effect=RuntimeError("boom"), + ): + job_nightly_refresh(daemon_config) # must not raise + + def test_skips_when_sync_root_missing(self, tmp_path: Path) -> None: + """No sync root → early return, no crash.""" + config = Config(provider="custom", sync_root=None) + job_nightly_refresh(config) # must not raise + + +# --------------------------------------------------------------------------- +# job_backup_mirror +# --------------------------------------------------------------------------- + +class TestJobBackupMirror: + def test_copies_files_to_mirror(self, daemon_config: Config, tmp_path: Path) -> None: + import dataclasses + + mirror = tmp_path / "mirror" + cfg = dataclasses.replace( + daemon_config, + daemon=dataclasses.replace(daemon_config.daemon, backup_mirror_path=str(mirror)), + ) + job_backup_mirror(cfg) + + assert (mirror / "GLOBAL_MEMORY.md").exists() + + def test_creates_mirror_directory(self, daemon_config: Config, tmp_path: Path) -> None: + import dataclasses + + mirror = tmp_path / "deep" / "mirror" + cfg = dataclasses.replace( + daemon_config, + daemon=dataclasses.replace(daemon_config.daemon, backup_mirror_path=str(mirror)), + ) + assert not mirror.exists() + job_backup_mirror(cfg) + assert mirror.exists() + + def test_does_not_raise_on_exception(self, daemon_config: Config) -> None: + import dataclasses + + cfg = dataclasses.replace( + daemon_config, + daemon=dataclasses.replace( + daemon_config.daemon, backup_mirror_path="/nonexistent/\x00bad" + ), + ) + # Should log the error, not raise + job_backup_mirror(cfg) # must not raise + + +# --------------------------------------------------------------------------- +# job_drift_check +# --------------------------------------------------------------------------- + +class TestJobDriftCheck: + def test_sends_notification_when_out_of_sync(self, daemon_config: Config) -> None: + with patch("memsync.claude_md.is_synced", return_value=False): + with patch("memsync.daemon.notify.notify") as mock_notify: + job_drift_check(daemon_config) + mock_notify.assert_called_once() + call_kwargs = mock_notify.call_args + assert "out of sync" in call_kwargs[1]["subject"].lower() + + def test_no_notification_when_in_sync(self, daemon_config: Config) -> None: + with patch("memsync.claude_md.is_synced", return_value=True): + with patch("memsync.daemon.notify.notify") as mock_notify: + job_drift_check(daemon_config) + mock_notify.assert_not_called() + + def test_skips_when_memory_missing(self, daemon_config: Config) -> None: + memory_path = daemon_config.sync_root / ".claude-memory" / "GLOBAL_MEMORY.md" + memory_path.unlink() + + with patch("memsync.daemon.notify.notify") as mock_notify: + job_drift_check(daemon_config) + mock_notify.assert_not_called() + + def test_does_not_raise_on_exception(self, daemon_config: Config) -> None: + with patch("memsync.claude_md.is_synced", side_effect=RuntimeError("boom")): + job_drift_check(daemon_config) # must not raise + + +# --------------------------------------------------------------------------- +# job_weekly_digest +# --------------------------------------------------------------------------- + +class TestJobWeeklyDigest: + def test_calls_generate_and_send(self, daemon_config: Config) -> None: + with patch("memsync.daemon.digest.generate_and_send") as mock_send: + import dataclasses + + cfg = dataclasses.replace( + daemon_config, + daemon=dataclasses.replace(daemon_config.daemon, digest_enabled=True), + ) + job_weekly_digest(cfg) + mock_send.assert_called_once_with(cfg) + + def test_does_not_raise_on_exception(self, daemon_config: Config) -> None: + with patch( + "memsync.daemon.digest.generate_and_send", + side_effect=RuntimeError("smtp error"), + ): + job_weekly_digest(daemon_config) # must not raise diff --git a/tests/test_daemon_watchdog.py b/tests/test_daemon_watchdog.py new file mode 100644 index 0000000..5557a51 --- /dev/null +++ b/tests/test_daemon_watchdog.py @@ -0,0 +1,19 @@ +"""Tests for memsync.daemon.watchdog""" +from __future__ import annotations + +from unittest.mock import patch + +from memsync.config import Config +from memsync.daemon.watchdog import run_drift_check + + +class TestRunDriftCheck: + def test_delegates_to_job_drift_check(self) -> None: + config = Config(provider="custom", sync_root=None) + with patch("memsync.daemon.watchdog.job_drift_check") as mock_job: + run_drift_check(config) + mock_job.assert_called_once_with(config) + + def test_does_not_raise_on_missing_sync_root(self) -> None: + config = Config(provider="custom", sync_root=None) + run_drift_check(config) # must not raise diff --git a/tests/test_daemon_web.py b/tests/test_daemon_web.py new file mode 100644 index 0000000..ee15ee2 --- /dev/null +++ b/tests/test_daemon_web.py @@ -0,0 +1,99 @@ +""" +Tests for memsync.daemon.web + +Uses Flask's built-in test client. No real filesystem needed for route tests +except for paths inside tmp_path. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from memsync.config import Config, DaemonConfig +from memsync.daemon.web import create_app + + +@pytest.fixture +def web_config(tmp_path: Path) -> tuple[Config, Path]: + sync_root = tmp_path / "sync" + memory_root = sync_root / ".claude-memory" + (memory_root / "backups").mkdir(parents=True) + memory_file = memory_root / "GLOBAL_MEMORY.md" + memory_file.write_text("# Global Memory\n\n## Identity\n- Test\n", encoding="utf-8") + + claude_md = tmp_path / "claude" / "CLAUDE.md" + claude_md.parent.mkdir(parents=True) + + config = Config( + provider="custom", + sync_root=sync_root, + claude_md_target=claude_md, + daemon=DaemonConfig(web_ui_enabled=True, web_ui_port=5000, web_ui_host="127.0.0.1"), + ) + return config, memory_file + + +class TestWebIndex: + def test_get_index_returns_200(self, web_config: tuple) -> None: + config, _ = web_config + app = create_app(config) + with app.test_client() as client: + resp = client.get("/") + assert resp.status_code == 200 + + def test_index_contains_memory_content(self, web_config: tuple) -> None: + config, _ = web_config + app = create_app(config) + with app.test_client() as client: + resp = client.get("/") + assert b"Global Memory" in resp.data + + def test_index_shows_never_when_file_missing(self, web_config: tuple) -> None: + config, memory_file = web_config + memory_file.unlink() + app = create_app(config) + with app.test_client() as client: + resp = client.get("/") + assert resp.status_code == 200 + assert b"never" in resp.data + + +class TestWebSave: + def test_save_writes_content(self, web_config: tuple) -> None: + config, memory_file = web_config + app = create_app(config) + new_content = "# Updated Memory\n\n- new item\n" + with app.test_client() as client: + resp = client.post("/save", data={"content": new_content}) + assert resp.status_code == 302 # redirect after save + assert memory_file.read_text(encoding="utf-8") == new_content + + def test_save_creates_backup(self, web_config: tuple) -> None: + config, memory_file = web_config + backup_dir = memory_file.parent / "backups" + assert len(list(backup_dir.glob("*.md"))) == 0 + + app = create_app(config) + with app.test_client() as client: + client.post("/save", data={"content": "# New Content\n"}) + + assert len(list(backup_dir.glob("*.md"))) == 1 + + def test_save_redirect_contains_success_message(self, web_config: tuple) -> None: + config, _ = web_config + app = create_app(config) + with app.test_client() as client: + resp = client.post("/save", data={"content": "# Content\n"}) + assert resp.status_code == 302 + location = resp.headers.get("Location", "") + assert "Saved" in location or "saved" in location.lower() + + def test_save_syncs_to_claude_md(self, web_config: tuple) -> None: + config, _ = web_config + app = create_app(config) + new_content = "# Synced Memory\n" + with app.test_client() as client: + client.post("/save", data={"content": new_content}) + # CLAUDE.md should have been written (copy on first run) + assert config.claude_md_target.exists() diff --git a/tests/test_providers.py b/tests/test_providers.py new file mode 100644 index 0000000..92a1113 --- /dev/null +++ b/tests/test_providers.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +import platform +from pathlib import Path + +import pytest + +from memsync.providers import all_providers, auto_detect, get_provider +from memsync.providers.custom import CustomProvider +from memsync.providers.gdrive import GoogleDriveProvider +from memsync.providers.icloud import ICloudProvider +from memsync.providers.onedrive import OneDriveProvider + + +def _raise_boom(self): + raise Exception("boom") + + +@pytest.mark.smoke +class TestRegistry: + def test_all_four_providers_registered(self): + names = {p.name for p in all_providers()} + assert names == {"onedrive", "icloud", "gdrive", "custom"} + + def test_get_provider_by_name(self): + p = get_provider("onedrive") + assert isinstance(p, OneDriveProvider) + + def test_get_provider_raises_for_unknown(self): + with pytest.raises(KeyError, match="dropbox"): + get_provider("dropbox") + + +class TestOneDriveProvider: + def test_detects_home_onedrive(self, tmp_path, monkeypatch): + onedrive_dir = tmp_path / "OneDrive" + onedrive_dir.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setattr(platform, "system", lambda: "Darwin") + + provider = OneDriveProvider() + result = provider.detect() + assert result == onedrive_dir + + def test_detects_cloudstore_personal(self, tmp_path, monkeypatch): + cloud = tmp_path / "Library" / "CloudStorage" + personal = cloud / "OneDrive-Personal" + personal.mkdir(parents=True) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setattr(platform, "system", lambda: "Darwin") + + provider = OneDriveProvider() + result = provider.detect() + assert result == personal + + def test_returns_none_when_not_found(self, tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setattr(platform, "system", lambda: "Darwin") + + provider = OneDriveProvider() + result = provider.detect() + assert result is None + + def test_never_raises(self, monkeypatch): + # detect() must never raise — patch _find to throw internally + monkeypatch.setattr(OneDriveProvider, "_find", _raise_boom) + provider = OneDriveProvider() + result = provider.detect() + assert result is None + + def test_memory_root_uses_dot_prefix(self, tmp_path): + provider = OneDriveProvider() + root = provider.get_memory_root(tmp_path) + assert root.name == ".claude-memory" + + def test_is_available_true_when_detected(self, tmp_path, monkeypatch): + onedrive_dir = tmp_path / "OneDrive" + onedrive_dir.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setattr(platform, "system", lambda: "Darwin") + provider = OneDriveProvider() + assert provider.is_available() is True + + +class TestICloudProvider: + def test_memory_root_has_no_dot(self, tmp_path): + """iCloud hides dot-folders — memory root must not start with '.'""" + provider = ICloudProvider() + root = provider.get_memory_root(tmp_path) + assert not root.name.startswith(".") + assert root.name == "claude-memory" + + def test_detects_mac_icloud(self, tmp_path, monkeypatch): + icloud_path = tmp_path / "Library" / "Mobile Documents" / "com~apple~CloudDocs" + icloud_path.mkdir(parents=True) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setattr(platform, "system", lambda: "Darwin") + + provider = ICloudProvider() + result = provider.detect() + assert result == icloud_path + + def test_returns_none_on_linux(self, monkeypatch): + monkeypatch.setattr(platform, "system", lambda: "Linux") + provider = ICloudProvider() + result = provider.detect() + assert result is None + + def test_never_raises(self, monkeypatch): + monkeypatch.setattr(ICloudProvider, "_find", _raise_boom) + provider = ICloudProvider() + result = provider.detect() + assert result is None + + +class TestGoogleDriveProvider: + def test_detects_legacy_path(self, tmp_path, monkeypatch): + gdrive = tmp_path / "Google Drive" + gdrive.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setattr(platform, "system", lambda: "Darwin") + + provider = GoogleDriveProvider() + result = provider.detect() + assert result == gdrive + + def test_detects_cloudstore_my_drive(self, tmp_path, monkeypatch): + cloud = tmp_path / "Library" / "CloudStorage" + gdrive_dir = cloud / "GoogleDrive-test@gmail.com" + my_drive = gdrive_dir / "My Drive" + my_drive.mkdir(parents=True) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setattr(platform, "system", lambda: "Darwin") + + provider = GoogleDriveProvider() + result = provider.detect() + assert result == my_drive + + def test_returns_none_when_not_found(self, tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setattr(platform, "system", lambda: "Darwin") + provider = GoogleDriveProvider() + result = provider.detect() + assert result is None + + def test_never_raises(self, monkeypatch): + monkeypatch.setattr(GoogleDriveProvider, "_find", _raise_boom) + provider = GoogleDriveProvider() + result = provider.detect() + assert result is None + + +class TestCustomProvider: + def test_detects_when_path_set_and_exists(self, tmp_path): + provider = CustomProvider(path=tmp_path) + assert provider.detect() == tmp_path + + def test_returns_none_when_path_not_set(self): + provider = CustomProvider() + assert provider.detect() is None + + def test_returns_none_when_path_missing(self, tmp_path): + provider = CustomProvider(path=tmp_path / "nonexistent") + assert provider.detect() is None + + +class TestAutoDetect: + def test_returns_only_detected_providers(self, tmp_path, monkeypatch): + # Only OneDrive folder exists + onedrive = tmp_path / "OneDrive" + onedrive.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setattr(platform, "system", lambda: "Darwin") + + detected = auto_detect() + names = [p.name for p in detected] + assert "onedrive" in names + assert "gdrive" not in names diff --git a/tests/test_sync.py b/tests/test_sync.py new file mode 100644 index 0000000..bf3bfd7 --- /dev/null +++ b/tests/test_sync.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from memsync.config import Config +from memsync.sync import ( + _extract_constraints, + enforce_hard_constraints, + load_or_init_memory, + log_session_notes, + refresh_memory_content, +) + +SAMPLE_MEMORY = """\ + +# Global Memory + +## Identity & context +- Test user, product leader + +## Current priorities +- Finish memsync + +## Hard constraints +- Never rewrite from scratch +- Always backup before writing + +## Standing preferences +- Concise output +""" + + +@pytest.mark.smoke +class TestExtractConstraints: + def test_extracts_bullet_lines(self): + constraints = _extract_constraints(SAMPLE_MEMORY) + assert "- Never rewrite from scratch" in constraints + assert "- Always backup before writing" in constraints + + def test_excludes_other_sections(self): + constraints = _extract_constraints(SAMPLE_MEMORY) + assert "- Test user, product leader" not in constraints + assert "- Finish memsync" not in constraints + + def test_empty_when_no_section(self): + text = "# Memory\n\n## Identity\n- Some user\n" + assert _extract_constraints(text) == [] + + def test_handles_constraints_heading_variant(self): + text = "# Memory\n\n## Constraints\n- Rule one\n- Rule two\n" + constraints = _extract_constraints(text) + assert "- Rule one" in constraints + assert "- Rule two" in constraints + + +@pytest.mark.smoke +class TestEnforceHardConstraints: + def test_no_op_when_nothing_dropped(self): + result = enforce_hard_constraints(SAMPLE_MEMORY, SAMPLE_MEMORY) + assert result == SAMPLE_MEMORY + + def test_reappends_dropped_constraint(self): + # Simulate model removing one constraint + dropped = SAMPLE_MEMORY.replace("- Never rewrite from scratch\n", "") + result = enforce_hard_constraints(SAMPLE_MEMORY, dropped) + assert "Never rewrite from scratch" in result + + def test_preserves_remaining_content(self): + dropped = SAMPLE_MEMORY.replace("- Never rewrite from scratch\n", "") + result = enforce_hard_constraints(SAMPLE_MEMORY, dropped) + assert "Always backup before writing" in result + assert "Test user, product leader" in result + + def test_handles_all_constraints_dropped(self): + # Remove entire section from new content + lines = [ln for ln in SAMPLE_MEMORY.splitlines() + if "Never rewrite" not in ln and "Always backup" not in ln] + stripped = "\n".join(lines) + result = enforce_hard_constraints(SAMPLE_MEMORY, stripped) + assert "Never rewrite from scratch" in result + assert "Always backup before writing" in result + + def test_handles_no_section_in_new(self): + old = "# Memory\n\n## Hard constraints\n- Keep this\n" + new = "# Memory\n\n## Identity\n- User\n" + result = enforce_hard_constraints(old, new) + assert "Keep this" in result + + +@pytest.mark.smoke +class TestLoadOrInitMemory: + def test_reads_existing_file(self, tmp_path): + p = tmp_path / "GLOBAL_MEMORY.md" + p.write_text("# existing", encoding="utf-8") + assert load_or_init_memory(p) == "# existing" + + def test_returns_template_when_missing(self, tmp_path): + p = tmp_path / "nonexistent.md" + result = load_or_init_memory(p) + assert result.startswith("") + assert "## Hard constraints" in result + + def test_template_has_version_comment(self, tmp_path): + p = tmp_path / "nonexistent.md" + result = load_or_init_memory(p) + assert "" in result + + +class TestLogSessionNotes: + def test_creates_dated_file(self, tmp_path): + sessions = tmp_path / "sessions" + sessions.mkdir() + log_session_notes("Worked on tests", sessions) + files = list(sessions.glob("*.md")) + assert len(files) == 1 + + def test_appends_on_same_day(self, tmp_path): + sessions = tmp_path / "sessions" + sessions.mkdir() + log_session_notes("First note", sessions) + log_session_notes("Second note", sessions) + files = list(sessions.glob("*.md")) + assert len(files) == 1 + content = files[0].read_text(encoding="utf-8") + assert "First note" in content + assert "Second note" in content + + def test_content_includes_notes(self, tmp_path): + sessions = tmp_path / "sessions" + sessions.mkdir() + log_session_notes("my session notes here", sessions) + content = list(sessions.glob("*.md"))[0].read_text(encoding="utf-8") + assert "my session notes here" in content + + +class TestRefreshMemoryContent: + def _make_mock_response(self, text: str, stop_reason: str = "end_turn") -> MagicMock: + mock_response = MagicMock() + mock_response.content = [MagicMock(text=text)] + mock_response.stop_reason = stop_reason + return mock_response + + def test_returns_changed_true_when_content_differs(self): + config = Config() + updated = SAMPLE_MEMORY.replace("- Finish memsync", "- Finish memsync\n- New priority") + mock_response = self._make_mock_response(updated) + + with patch("anthropic.Anthropic") as mock_client: + mock_client.return_value.messages.create.return_value = mock_response + result = refresh_memory_content("Added new priority", SAMPLE_MEMORY, config) + + assert result["changed"] is True + assert "New priority" in result["updated_content"] + + def test_returns_changed_false_when_content_same(self): + config = Config() + mock_response = self._make_mock_response(SAMPLE_MEMORY) + + with patch("anthropic.Anthropic") as mock_client: + mock_client.return_value.messages.create.return_value = mock_response + result = refresh_memory_content("Notes", SAMPLE_MEMORY, config) + + assert result["changed"] is False + + def test_uses_model_from_config(self): + config = Config(model="claude-haiku-4-5-20251001") + mock_response = self._make_mock_response(SAMPLE_MEMORY) + + with patch("anthropic.Anthropic") as mock_client: + mock_client.return_value.messages.create.return_value = mock_response + refresh_memory_content("Notes", SAMPLE_MEMORY, config) + + call_kwargs = mock_client.return_value.messages.create.call_args.kwargs + assert call_kwargs["model"] == "claude-haiku-4-5-20251001" + + def test_detects_truncation_via_stop_reason(self): + config = Config() + mock_response = self._make_mock_response(SAMPLE_MEMORY, stop_reason="max_tokens") + + with patch("anthropic.Anthropic") as mock_client: + mock_client.return_value.messages.create.return_value = mock_response + result = refresh_memory_content("Notes", SAMPLE_MEMORY, config) + + assert result["truncated"] is True + + def test_no_truncation_on_end_turn(self): + config = Config() + mock_response = self._make_mock_response(SAMPLE_MEMORY, stop_reason="end_turn") + + with patch("anthropic.Anthropic") as mock_client: + mock_client.return_value.messages.create.return_value = mock_response + result = refresh_memory_content("Notes", SAMPLE_MEMORY, config) + + assert result["truncated"] is False + + def test_hard_constraints_enforced_even_if_model_drops_them(self): + config = Config() + # Model drops one constraint + without_constraint = SAMPLE_MEMORY.replace("- Never rewrite from scratch\n", "") + mock_response = self._make_mock_response(without_constraint) + + with patch("anthropic.Anthropic") as mock_client: + mock_client.return_value.messages.create.return_value = mock_response + result = refresh_memory_content("Notes", SAMPLE_MEMORY, config) + + assert "Never rewrite from scratch" in result["updated_content"]