diff --git a/.gitignore b/.gitignore index 917db79..06d7a09 100644 --- a/.gitignore +++ b/.gitignore @@ -68,7 +68,7 @@ vscode-extension/*.vsix ehthumbs.db Thumbs.db -# ── DevMem runtime data ─────────────────────────────────────────────────────── +# ── Recall runtime data ─────────────────────────────────────────────────────── # Local data directory — never commit user activity data *.db *.db-wal diff --git a/Dockerfile.sandbox b/Dockerfile.sandbox index 8718104..fbf95d1 100644 --- a/Dockerfile.sandbox +++ b/Dockerfile.sandbox @@ -13,7 +13,7 @@ USER devuser WORKDIR /home/devuser # Copy the project into the container -COPY --chown=devuser:devuser . /home/devuser/devmem/ +COPY --chown=devuser:devuser . /home/devuser/dev-recall/ # Install deps in separate layers so Docker caches each one. # sentence-transformers/PyTorch (~800 MB) is omitted — embeddings fail @@ -32,15 +32,15 @@ RUN /home/devuser/.venv/bin/pip install --quiet --timeout 60 --retries 3 \ RUN /home/devuser/.venv/bin/pip install --quiet --timeout 60 --retries 5 \ pytest pytest-cov pytest-mock ruff -# Install devmem itself (no ML deps) +# Install dev-recall itself (no ML deps) RUN /home/devuser/.venv/bin/pip install --quiet --no-deps \ - -e "/home/devuser/devmem" + -e "/home/devuser/dev-recall" ENV PATH="/home/devuser/.venv/bin:$PATH" # Pre-configure a fake git identity (required for git hooks to fire) -RUN git config --global user.email "sandbox@devmem.test" && \ +RUN git config --global user.email "sandbox@dev-recall.test" && \ git config --global user.name "Sandbox User" -# Default: drop into an interactive shell so you can run devmem commands manually +# Default: drop into an interactive shell so you can run recall commands manually CMD ["/bin/zsh"] diff --git a/README.md b/README.md index d87b38e..e4af0e2 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ -# DevMem +# Recall **Local-first developer memory layer.** Captures every developer activity — terminal commands, git commits, file edits, repo opens, AI chat sessions — into a structured SQLite database with a FAISS vector index. Enables natural language recall: ``` -devmem ask "what did I work on last Tuesday?" -devmem ask "how did I fix the auth bug?" -devmem today -devmem timeline +recall ask "what did I work on last Tuesday?" +recall ask "how did I fix the auth bug?" +recall today +recall timeline ``` --- @@ -14,12 +14,13 @@ devmem timeline ## Quick Start ```bash -pip install devmem -devmem init +pip install dev-recall +recall init +source .zshrc # or .bashrc ``` -`devmem init` will: -1. Create `~/.local/share/devmem/` and `~/.config/devmem/` +`recall init` will: +1. Create `~/.local/share/recall/` and `~/.config/recall/` 2. Initialize the SQLite database + FAISS vector index 3. Install the zsh/bash shell hook (appends `source` line to your rc file) 4. Set `git config --global core.hooksPath` to capture all commits @@ -31,21 +32,21 @@ devmem init | Command | Description | |---------|-------------| -| `devmem init` | First-time setup | -| `devmem ask ""` | Natural language search with LLM answer | -| `devmem today` | Summary of today's activity | -| `devmem week` | Summary of this week's activity | -| `devmem timeline` | Chronological event list for a day | -| `devmem search ""` | Raw hybrid search (no LLM) | -| `devmem repos` | List all tracked repos | -| `devmem stats` | Capture statistics | -| `devmem export` | Export events as JSON or CSV | -| `devmem config` | View/edit configuration | -| `devmem privacy list` | Show what's captured | -| `devmem privacy delete` | Delete captured events | -| `devmem privacy ignore --cmd "pattern"` | Add a privacy filter | -| `devmem daemon start/stop/status/logs` | Manage the background daemon | -| `devmem mcp-serve` | Start MCP server (for Claude Code / Copilot) | +| `recall init` | First-time setup | +| `recall ask ""` | Natural language search with LLM answer | +| `recall today` | Summary of today's activity | +| `recall week` | Summary of this week's activity | +| `recall timeline` | Chronological event list for a day | +| `recall search ""` | Raw hybrid search (no LLM) | +| `recall repos` | List all tracked repos | +| `recall stats` | Capture statistics | +| `recall export` | Export events as JSON or CSV | +| `recall config` | View/edit configuration | +| `recall privacy list` | Show what's captured | +| `recall privacy delete` | Delete captured events | +| `recall privacy ignore --cmd "pattern"` | Add a privacy filter | +| `recall daemon start/stop/status/logs` | Manage the background daemon | +| `recall mcp-serve` | Start MCP server (for Claude Code / Copilot) | --- @@ -68,18 +69,18 @@ CLI + MCP Server ## Data Sources ### Shell commands (zsh / bash) -Add to `~/.zshrc` (done automatically by `devmem init`): +Add to `~/.zshrc` (done automatically by `recall init`): ```bash -source ~/.config/devmem/hook.zsh +source ~/.config/dev-recall/hook.zsh ``` ### Git commits -`devmem init` sets `core.hooksPath` globally — all future commits in any repo are captured. +`recall init` sets `core.hooksPath` globally — all future commits in any repo are captured. ### VS Code activity Install the extension: ```bash -code --install-extension devmem.devmem-vscode +code --install-extension recall.recall-vscode ``` ### AI chat sessions @@ -93,27 +94,27 @@ Automatically scanned from: ## LLM Integration -DevMem uses [OpenRouter](https://openrouter.ai) for the `ask` command and daily summaries. +Recall uses [OpenRouter](https://openrouter.ai) for the `ask` command and daily summaries. ```bash export OPENROUTER_API_KEY=sk-or-... -devmem ask "what was I debugging yesterday?" +recall ask "what was I debugging yesterday?" ``` -Without an API key, `devmem ask` falls back to `--no-llm` mode (shows retrieved events directly). All other commands work fully offline. +Without an API key, `recall ask` falls back to `--no-llm` mode (shows retrieved events directly). All other commands work fully offline. --- ## MCP Server -Use DevMem as a context source in Claude Code or VS Code Copilot: +Use Recall as a context source in Claude Code or VS Code Copilot: **Claude Code** (`~/.config/claude/mcp.json`): ```json { "mcpServers": { - "devmem": { - "command": "devmem", + "dev-recall": { + "command": "dev-recall", "args": ["mcp-serve"] } } @@ -124,9 +125,9 @@ Use DevMem as a context source in Claude Code or VS Code Copilot: ```json { "servers": { - "devmem": { + "dev-recall": { "type": "stdio", - "command": "devmem", + "command": "dev-recall", "args": ["mcp-serve"] } } @@ -146,21 +147,21 @@ Available MCP tools: `recall`, `today_summary`, `recent_repos`, `find_command`, - Default retention: **90 days** (configurable) ```bash -devmem privacy list # see what's captured -devmem privacy delete --before 2026-01-01 -devmem privacy ignore --cmd "*mycompany*" +recall privacy list # see what's captured +recall privacy delete --before 2026-01-01 +recall privacy ignore --cmd "*mycompany*" ``` --- ## Configuration -Config file: `~/.config/devmem/config.json` +Config file: `~/.config/dev-recall/config.json` ```bash -devmem config # show all settings -devmem config daemon_port 8080 # change port -devmem config retention_days 30 # shorter retention +recall config # show all settings +recall config daemon_port 8080 # change port +recall config retention_days 30 # shorter retention ``` Key settings: @@ -179,14 +180,14 @@ Key settings: ## Data Storage ``` -~/.local/share/devmem/ +~/.local/share/dev-recall/ ├── events.db # SQLite (events + FTS5 + sessions + daily_summaries) ├── vectors.faiss # FAISS vector index ├── shell.tsv # shell hook ring buffer ├── git.tsv # git hook ring buffer └── daemon.pid # running daemon PID -~/.config/devmem/ +~/.config/dev-recall/ ├── config.json ├── hook.zsh / hook.bash └── git-hooks/post-commit + post-checkout @@ -198,14 +199,14 @@ Key settings: ```bash git clone -cd devmem +cd dev-recall pip install -e ".[dev]" pytest ``` ### Sandbox testing -`devmem init` makes system-wide changes (modifies `~/.zshrc`, sets a global `git config core.hooksPath`, starts a background daemon). Use the provided Docker sandbox to test safely without touching your host environment. +`recall init` makes system-wide changes (modifies `~/.zshrc`, sets a global `git config core.hooksPath`, starts a background daemon). Use the provided Docker sandbox to test safely without touching your host environment. **Prerequisites:** Docker @@ -216,11 +217,11 @@ pytest # Run the full test suite ./sandbox.sh test -# Run `devmem init` and inspect every file it creates +# Run `recall init` and inspect every file it creates ./sandbox.sh init # Run any arbitrary command -./sandbox.sh "devmem --help" +./sandbox.sh "recall --help" ``` What the sandbox isolates: @@ -233,7 +234,7 @@ What the sandbox isolates: | Network calls to OpenRouter | Blocked via `--network none` | | Privilege escalation | `--cap-drop ALL --security-opt no-new-privileges` | -Alternatively, use a throwaway VM: `multipass launch --name devmem-test` +Alternatively, use a throwaway VM: `multipass launch --name dev-recall-test` --- diff --git a/devmem/__init__.py b/devmem/__init__.py deleted file mode 100644 index 40352ce..0000000 --- a/devmem/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""DevMem — Local-first developer memory layer.""" - -__version__ = "0.1.0" diff --git a/git-hooks/post-checkout b/git-hooks/post-checkout index 9d04f9d..0e7b736 100644 --- a/git-hooks/post-checkout +++ b/git-hooks/post-checkout @@ -1,11 +1,11 @@ #!/bin/sh -# DevMem git post-checkout hook +# Recall git post-checkout hook # $1=prev ref, $2=new ref, $3=flag (1=branch, 0=file checkout) # Only record branch switches, not file checkouts [ "$3" = "1" ] || exit 0 -__devmem_dir="${DEVMEM_DATA_DIR:-$HOME/.local/share/devmem}" +__devrecall_dir="${DEV_RECALL_DATA_DIR:-$HOME/.local/share/devmem}" REPO_PATH=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0 NEW_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) OLD_BRANCH=$(git name-rev --name-only "$1" 2>/dev/null || echo "unknown") @@ -13,6 +13,6 @@ TS=$(date -u +%Y-%m-%dT%H:%M:%SZ) printf '%s\tbranch\t%s\t%s\t%s\n' \ "$TS" "$REPO_PATH" "$OLD_BRANCH" "$NEW_BRANCH" \ - >> "$__devmem_dir/git.tsv" 2>/dev/null + >> "$__devrecall_dir/git.tsv" 2>/dev/null exit 0 diff --git a/git-hooks/post-commit b/git-hooks/post-commit index 408a070..09da066 100644 --- a/git-hooks/post-commit +++ b/git-hooks/post-commit @@ -1,8 +1,8 @@ #!/bin/sh -# DevMem git post-commit hook +# Recall git post-commit hook # Installed globally via: git config --global core.hooksPath ~/.config/devmem/git-hooks/ -__devmem_dir="${DEVMEM_DATA_DIR:-$HOME/.local/share/devmem}" +__devrecall_dir="${DEV_RECALL_DATA_DIR:-$HOME/.local/share/devmem}" REPO_PATH=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0 HASH=$(git rev-parse HEAD 2>/dev/null) || exit 0 MSG=$(git log -1 --format="%s" 2>/dev/null) @@ -13,6 +13,6 @@ TS=$(date -u +%Y-%m-%dT%H:%M:%SZ) printf '%s\tcommit\t%s\t%s\t%s\t%s\t%s\t%s\n' \ "$TS" "$REPO_PATH" "$HASH" "$BRANCH" "$MSG" "$FILES" "$AUTHOR" \ - >> "$__devmem_dir/git.tsv" 2>/dev/null + >> "$__devrecall_dir/git.tsv" 2>/dev/null exit 0 diff --git a/git-hooks/post-merge b/git-hooks/post-merge index 5f31b43..a455154 100755 --- a/git-hooks/post-merge +++ b/git-hooks/post-merge @@ -1,10 +1,10 @@ #!/bin/sh -# DevMem git post-merge hook +# Recall git post-merge hook # Installed globally via: git config --global core.hooksPath ~/.config/devmem/git-hooks/ # Arguments: $1=1 if squash merge, 0 otherwise IS_SQUASH="${1:-0}" -__devmem_dir="${DEVMEM_DATA_DIR:-$HOME/.local/share/devmem}" +__devrecall_dir="${DEV_RECALL_DATA_DIR:-$HOME/.local/share/devmem}" REPO_PATH=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0 BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) TS=$(date -u +%Y-%m-%dT%H:%M:%SZ) @@ -15,6 +15,6 @@ MERGED_BRANCH=$(git reflog show --format="%gs" -1 HEAD 2>/dev/null \ printf '%s\tmerge\t%s\t%s\t%s\t%s\n' \ "$TS" "$REPO_PATH" "$BRANCH" "$MERGED_BRANCH" "$IS_SQUASH" \ - >> "$__devmem_dir/git.tsv" 2>/dev/null + >> "$__devrecall_dir/git.tsv" 2>/dev/null exit 0 diff --git a/git-hooks/pre-push b/git-hooks/pre-push index 84b5de8..2509145 100755 --- a/git-hooks/pre-push +++ b/git-hooks/pre-push @@ -1,11 +1,11 @@ #!/bin/sh -# DevMem git pre-push hook +# Recall git pre-push hook # Installed globally via: git config --global core.hooksPath ~/.config/devmem/git-hooks/ # Arguments: $1=remote_name $2=remote_url # Stdin: lines of "local_ref local_sha remote_ref remote_sha" REMOTE="$1" -__devmem_dir="${DEVMEM_DATA_DIR:-$HOME/.local/share/devmem}" +__devrecall_dir="${DEV_RECALL_DATA_DIR:-$HOME/.local/share/devmem}" REPO_PATH=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0 BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) TS=$(date -u +%Y-%m-%dT%H:%M:%SZ) @@ -26,6 +26,6 @@ done printf '%s\tpush\t%s\t%s\t%s\t%d\n' \ "$TS" "$REPO_PATH" "$REMOTE" "$BRANCH" "$COMMIT_COUNT" \ - >> "$__devmem_dir/git.tsv" 2>/dev/null + >> "$__devrecall_dir/git.tsv" 2>/dev/null exit 0 diff --git a/pyproject.toml b/pyproject.toml index 418b0bc..42bb0a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools>=42", "wheel"] build-backend = "setuptools.build_meta" [project] -name = "devmem" +name = "dev-recall" version = "0.1.0" description = "Local-first developer memory layer" readme = "README.md" @@ -25,14 +25,22 @@ dependencies = [ ] [project.scripts] -devmem = "devmem.cli:cli" +recall = "recall.cli:cli" [project.optional-dependencies] dev = ["pytest>=7.0", "pytest-cov", "pytest-mock", "ruff"] +# Optional collectors — install only the ones you want +linux = [ + "psutil>=5.9", # process/port tracking + "dbus-next>=0.2", # session lock/sleep events (Linux D-Bus) + "docker>=7.0", # Docker/Podman container events +] +# PyGObject (gi/Wnck) for X11 window tracking is a system package; +# install via: sudo apt install python3-gi gir1.2-wnck-3.0 [tool.setuptools.packages.find] where = ["."] -include = ["devmem*"] +include = ["recall*"] [tool.ruff] line-length = 100 diff --git a/recall/__init__.py b/recall/__init__.py new file mode 100644 index 0000000..924c856 --- /dev/null +++ b/recall/__init__.py @@ -0,0 +1,3 @@ +"""Recall — Local-first developer memory layer.""" + +__version__ = "0.1.0" diff --git a/devmem/_hooks.py b/recall/_hooks.py similarity index 56% rename from devmem/_hooks.py rename to recall/_hooks.py index 44b5d1f..6ffeaa5 100644 --- a/devmem/_hooks.py +++ b/recall/_hooks.py @@ -1,100 +1,95 @@ """Bundled shell hook scripts for pip-installed environments. -When devmem is installed via pip, the shell/ directory isn't available. -This module embeds the hook scripts as strings so `devmem init` can -write them to ~/.config/devmem/. +When dev-recall is installed via pip, the shell/ directory isn't available. +This module embeds the hook scripts as strings so `recall init` can +write them to ~/.config/dev-recall/. """ -ZSH_HOOK = r'''# DevMem shell hook — source this in ~/.zshrc -# Installed by: devmem init +ZSH_HOOK = r'''# Recall shell hook — source this in ~/.zshrc +# Installed by: recall init -__devmem_dir="${DEVMEM_DATA_DIR:-$HOME/.local/share/devmem}" -__devmem_shell_log="$__devmem_dir/shell.tsv" - -__devmem_preexec() { - __devmem_cmd="$1" - __devmem_start_ms=$(( $(date +%s) * 1000 )) +__devrecall_dir="${DEV_RECALL_DATA_DIR:-$HOME/.local/share/dev-recall}" { + __devrecall_cmd="$1" + __devrecall_start_ms=$(( $(date +%s) * 1000 )) } -__devmem_precmd() { +__devrecall_precmd() { local exit_code=$? - [[ -z "$__devmem_cmd" ]] && return + [[ -z "$__devrecall_cmd" ]] && return local end_ms=$(( $(date +%s) * 1000 )) - local dur=$(( end_ms - __devmem_start_ms )) + local dur=$(( end_ms - __devrecall_start_ms )) local ts ts=$(date -u +%Y-%m-%dT%H:%M:%SZ) # Tab-separated: timestamp TAB cwd TAB command TAB exit_code TAB duration_ms # Tabs inside cmd/cwd are replaced with space to avoid ambiguity - local safe_cmd="${__devmem_cmd//$'\t'/ }" + local safe_cmd="${__devrecall_cmd//$'\t'/ }" local safe_cwd="${PWD//$'\t'/ }" printf '%s\t%s\t%s\t%d\t%d\n' \ "$ts" "$safe_cwd" "$safe_cmd" "$exit_code" "$dur" \ - >> "$__devmem_shell_log" 2>/dev/null + >> "$__devrecall_shell_log" 2>/dev/null - unset __devmem_cmd + unset __devrecall_cmd } autoload -Uz add-zsh-hook -add-zsh-hook preexec __devmem_preexec -add-zsh-hook precmd __devmem_precmd +add-zsh-hook preexec __devrecall_preexec +add-zsh-hook precmd __devrecall_precmd ''' -BASH_HOOK = r'''# DevMem shell hook — source this in ~/.bashrc -# Installed by: devmem init +BASH_HOOK = r'''# Recall shell hook — source this in ~/.bashrc +# Installed by: recall init -__devmem_dir="${DEVMEM_DATA_DIR:-$HOME/.local/share/devmem}" -__devmem_shell_log="$__devmem_dir/shell.tsv" -__devmem_cmd="" -__devmem_start_ms=0 +__devrecall_dir="${DEV_RECALL_DATA_DIR:-$HOME/.local/share/dev-recall}" +__devrecall_start_ms=0 # trap DEBUG fires before each command executes -__devmem_debug_trap() { +__devrecall_debug_trap() { # BASH_COMMAND is set to the command string before it runs. # Skip the trap when it fires inside PROMPT_COMMAND itself. - if [[ "$BASH_COMMAND" != "__devmem_precmd"* ]]; then - __devmem_cmd="$BASH_COMMAND" - __devmem_start_ms=$(( $(date +%s) * 1000 )) + if [[ "$BASH_COMMAND" != "__devrecall_precmd"* ]]; then + __devrecall_cmd="$BASH_COMMAND" + __devrecall_start_ms=$(( $(date +%s) * 1000 )) fi } -trap '__devmem_debug_trap' DEBUG +trap '__devrecall_debug_trap' DEBUG -__devmem_precmd() { +__devrecall_precmd() { local exit_code=$? - [[ -z "$__devmem_cmd" ]] && return + [[ -z "$__devrecall_cmd" ]] && return local end_ms end_ms=$(( $(date +%s) * 1000 )) - local dur=$(( end_ms - __devmem_start_ms )) + local dur=$(( end_ms - __devrecall_start_ms )) local ts ts=$(date -u +%Y-%m-%dT%H:%M:%SZ) # Replace tabs with spaces to keep TSV well-formed - local safe_cmd="${__devmem_cmd//$'\t'/ }" + local safe_cmd="${__devrecall_cmd//$'\t'/ }" local safe_cwd="${PWD//$'\t'/ }" printf '%s\t%s\t%s\t%d\t%d\n' \ "$ts" "$safe_cwd" "$safe_cmd" "$exit_code" "$dur" \ - >> "$__devmem_shell_log" 2>/dev/null + >> "$__devrecall_shell_log" 2>/dev/null - __devmem_cmd="" + __devrecall_cmd="" } # Append to PROMPT_COMMAND — preserve existing hooks if [[ -z "$PROMPT_COMMAND" ]]; then - PROMPT_COMMAND="__devmem_precmd" -elif [[ "$PROMPT_COMMAND" != *"__devmem_precmd"* ]]; then - PROMPT_COMMAND="${PROMPT_COMMAND};__devmem_precmd" + PROMPT_COMMAND="__devrecall_precmd" +elif [[ "$PROMPT_COMMAND" != *"__devrecall_precmd"* ]]; then + PROMPT_COMMAND="${PROMPT_COMMAND};__devrecall_precmd" fi ''' GIT_POST_COMMIT = r'''#!/bin/sh -# DevMem git post-commit hook -# Installed globally via: git config --global core.hooksPath ~/.config/devmem/git-hooks/ +# Recall git post-commit hook +# Installed globally via: git config --global core.hooksPath ~/.config/dev-recall/git-hooks/ -__devmem_dir="${DEVMEM_DATA_DIR:-$HOME/.local/share/devmem}" +__devrecall_dir="${DEV_RECALL_DATA_DIR:-$HOME/.local/share/devmem}" REPO_PATH=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0 HASH=$(git rev-parse HEAD 2>/dev/null) || exit 0 MSG=$(git log -1 --format="%s" 2>/dev/null) @@ -105,19 +100,19 @@ printf '%s\tcommit\t%s\t%s\t%s\t%s\t%s\t%s\n' \ "$TS" "$REPO_PATH" "$HASH" "$BRANCH" "$MSG" "$FILES" "$AUTHOR" \ - >> "$__devmem_dir/git.tsv" 2>/dev/null + >> "$__devrecall_dir/git.tsv" 2>/dev/null exit 0 ''' GIT_POST_CHECKOUT = r'''#!/bin/sh -# DevMem git post-checkout hook +# Recall git post-checkout hook # $1=prev ref, $2=new ref, $3=flag (1=branch, 0=file checkout) # Only record branch switches, not file checkouts [ "$3" = "1" ] || exit 0 -__devmem_dir="${DEVMEM_DATA_DIR:-$HOME/.local/share/devmem}" +__devrecall_dir="${DEV_RECALL_DATA_DIR:-$HOME/.local/share/devmem}" REPO_PATH=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0 NEW_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) OLD_BRANCH=$(git name-rev --name-only "$1" 2>/dev/null || echo "unknown") @@ -125,17 +120,17 @@ printf '%s\tbranch\t%s\t%s\t%s\n' \ "$TS" "$REPO_PATH" "$OLD_BRANCH" "$NEW_BRANCH" \ - >> "$__devmem_dir/git.tsv" 2>/dev/null + >> "$__devrecall_dir/git.tsv" 2>/dev/null exit 0 ''' GIT_PRE_PUSH = r'''#!/bin/sh -# DevMem git pre-push hook -# Installed globally via: git config --global core.hooksPath ~/.config/devmem/git-hooks/ +# Recall git pre-push hook +# Installed globally via: git config --global core.hooksPath ~/.config/dev-recall/git-hooks/ REMOTE="$1" -__devmem_dir="${DEVMEM_DATA_DIR:-$HOME/.local/share/devmem}" +__devrecall_dir="${DEV_RECALL_DATA_DIR:-$HOME/.local/share/devmem}" REPO_PATH=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0 BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) TS=$(date -u +%Y-%m-%dT%H:%M:%SZ) @@ -144,8 +139,7 @@ while IFS=' ' read -r local_ref local_sha remote_ref remote_sha; do [ "$local_sha" = "0000000000000000000000000000000000000000" ] && continue if [ "$remote_sha" = "0000000000000000000000000000000000000000" ]; then - # New branch: count only commits not already present on the remote - COUNT=$(git rev-list --count "$local_sha" --not --remotes="$REMOTE" 2>/dev/null || echo 0) + COUNT=$(git rev-list "$local_sha" --count 2>/dev/null || echo 0) else COUNT=$(git rev-list "${remote_sha}..${local_sha}" --count 2>/dev/null || echo 0) fi @@ -154,17 +148,17 @@ printf '%s\tpush\t%s\t%s\t%s\t%d\n' \ "$TS" "$REPO_PATH" "$REMOTE" "$BRANCH" "$COMMIT_COUNT" \ - >> "$__devmem_dir/git.tsv" 2>/dev/null + >> "$__devrecall_dir/git.tsv" 2>/dev/null exit 0 ''' GIT_POST_MERGE = r'''#!/bin/sh -# DevMem git post-merge hook +# Recall git post-merge hook # $1=1 if squash merge, 0 otherwise IS_SQUASH="${1:-0}" -__devmem_dir="${DEVMEM_DATA_DIR:-$HOME/.local/share/devmem}" +__devrecall_dir="${DEV_RECALL_DATA_DIR:-$HOME/.local/share/devmem}" REPO_PATH=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0 BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) TS=$(date -u +%Y-%m-%dT%H:%M:%SZ) @@ -174,44 +168,44 @@ printf '%s\tmerge\t%s\t%s\t%s\t%s\n' \ "$TS" "$REPO_PATH" "$BRANCH" "$MERGED_BRANCH" "$IS_SQUASH" \ - >> "$__devmem_dir/git.tsv" 2>/dev/null + >> "$__devrecall_dir/git.tsv" 2>/dev/null exit 0 ''' -FISH_HOOK = r'''# DevMem shell hook — source this in ~/.config/fish/config.fish -# Installed by: devmem init +FISH_HOOK = r'''# Recall shell hook — source this in ~/.config/fish/config.fish +# Installed by: recall init -set -g __devmem_dir (set -q DEVMEM_DATA_DIR; and echo $DEVMEM_DATA_DIR; or echo "$HOME/.local/share/devmem") -set -g __devmem_shell_log "$__devmem_dir/shell.tsv" -set -g __devmem_cmd "" -set -g __devmem_start_ms 0 +set -g __devrecall_dir (set -q DEV_RECALL_DATA_DIR; and echo $DEV_RECALL_DATA_DIR; or echo "$HOME/.local/share/devmem") +set -g __devrecall_shell_log "$__devrecall_dir/shell.tsv" +set -g __devrecall_cmd "" +set -g __devrecall_start_ms 0 -function __devmem_preexec --on-event fish_preexec - set -g __devmem_cmd $argv[1] - set -g __devmem_start_ms (math (date +%s) \* 1000) +function __devrecall_preexec --on-event fish_preexec + set -g __devrecall_cmd $argv[1] + set -g __devrecall_start_ms (math (date +%s) \* 1000) end -function __devmem_postexec --on-event fish_postexec +function __devrecall_postexec --on-event fish_postexec set cmd $argv[1] set exit_code 0 if test (count $argv) -ge 2 set exit_code $argv[2] end - test -z "$__devmem_cmd"; and return + test -z "$__devrecall_cmd"; and return set end_ms (math (date +%s) \* 1000) - set dur (math $end_ms - $__devmem_start_ms) + set dur (math $end_ms - $__devrecall_start_ms) set ts (date -u +%Y-%m-%dT%H:%M:%SZ) - set safe_cmd (string replace --all \t ' ' "$__devmem_cmd") + set safe_cmd (string replace --all \t ' ' "$__devrecall_cmd") set safe_cwd (string replace --all \t ' ' "$PWD") printf '%s\t%s\t%s\t%d\t%d\n' \ "$ts" "$safe_cwd" "$safe_cmd" "$exit_code" "$dur" \ - >> "$__devmem_shell_log" 2>/dev/null + >> "$__devrecall_shell_log" 2>/dev/null - set -g __devmem_cmd "" + set -g __devrecall_cmd "" end ''' diff --git a/devmem/cli.py b/recall/cli.py similarity index 90% rename from devmem/cli.py rename to recall/cli.py index 937453a..c693555 100644 --- a/devmem/cli.py +++ b/recall/cli.py @@ -1,4 +1,4 @@ -"""DevMem CLI — all commands.""" +"""Recall CLI — all commands.""" from __future__ import annotations @@ -19,8 +19,8 @@ from rich import box from rich.text import Text -from devmem.config import load_config, save_config -from devmem.models import EventType +from recall.config import load_config, save_config +from recall.models import EventType console = Console() err_console = Console(stderr=True) @@ -68,7 +68,7 @@ @click.group() @click.version_option() def cli(): - """DevMem — local-first developer memory layer.""" + """Recall — local-first developer memory layer.""" pass @@ -80,10 +80,10 @@ def cli(): @cli.command() @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompts") def init(yes: bool): - """Set up DevMem: create dirs, install hooks, start daemon.""" + """Set up Recall: create dirs, install hooks, start daemon.""" config = load_config() - console.rule("[bold]DevMem — Developer Memory Layer[/bold]") + console.rule("[bold]Recall — Developer Memory Layer[/bold]") console.print() steps = [ @@ -103,8 +103,8 @@ def init(yes: bool): # Step 2: Init DB and FAISS _print_step(2, steps[1]) - from devmem.storage.db import DB - from devmem.storage.vectors import VectorStore + from recall.storage.db import DB + from recall.storage.vectors import VectorStore db = DB(config.db_path) db.close() @@ -126,15 +126,15 @@ def init(yes: bool): # Step 6: VS Code extension hint _print_step(6, steps[5]) - console.print(" → Run: [cyan]code --install-extension devmem.devmem-vscode[/cyan] (optional)") + console.print(" → Run: [cyan]code --install-extension recall.recall-vscode[/cyan] (optional)") console.print() - console.print("[bold green]Done. DevMem is running.[/bold green]") + console.print("[bold green]Done. Recall is running.[/bold green]") console.print() console.print("Try:") - console.print(" [cyan]devmem today[/cyan] — see today's activity") - console.print(' [cyan]devmem ask "what did I work on?"[/cyan]') - console.print(" [cyan]devmem timeline[/cyan]") + console.print(" [cyan]recall today[/cyan] — see today's activity") + console.print(' [cyan]recall ask "what did I work on?"[/cyan]') + console.print(" [cyan]recall timeline[/cyan]") def _print_step(n: int, label: str): @@ -162,7 +162,7 @@ def _install_shell_hook(config) -> None: continue # Append source line to rc file if not already present - source_line = f"\n# DevMem shell hook\nsource \"{hook_dst}\"\n" + source_line = f"\n# Recall shell hook\nsource \"{hook_dst}\"\n" rc_content = rc_file.read_text() if rc_file.exists() else "" if str(hook_dst) not in rc_content: rc_file.parent.mkdir(parents=True, exist_ok=True) @@ -179,7 +179,7 @@ def _install_shell_hook(config) -> None: def _write_hook_from_package(config, shell_name: str) -> None: """Write a single hook file when the package is installed (no source tree available).""" - from devmem._hooks import ZSH_HOOK, BASH_HOOK, FISH_HOOK # type: ignore[import] + from recall._hooks import ZSH_HOOK, BASH_HOOK, FISH_HOOK # type: ignore[import] if shell_name == "zsh": config.hook_zsh_path.parent.mkdir(parents=True, exist_ok=True) @@ -206,8 +206,8 @@ def _install_git_hooks(config) -> None: shutil.copy2(str(src), str(dst)) os.chmod(str(dst), 0o755) else: - # Installed via pip — hooks are bundled in devmem._hooks - from devmem._hooks import GIT_POST_COMMIT, GIT_POST_CHECKOUT, GIT_PRE_PUSH, GIT_POST_MERGE + # Installed via pip — hooks are bundled in recall._hooks + from recall._hooks import GIT_POST_COMMIT, GIT_POST_CHECKOUT, GIT_PRE_PUSH, GIT_POST_MERGE for name, content in [ ("post-commit", GIT_POST_COMMIT), ("post-checkout", GIT_POST_CHECKOUT), @@ -231,7 +231,7 @@ def _install_git_hooks(config) -> None: def _start_daemon(config) -> None: """Try to start via systemd, fallback to background subprocess.""" - from devmem.daemon import write_systemd_unit, is_running + from recall.daemon import write_systemd_unit, is_running if is_running(config): console.print(" → [dim]Daemon already running[/dim]") @@ -240,7 +240,7 @@ def _start_daemon(config) -> None: try: unit_path = write_systemd_unit(config) subprocess.run( - ["systemctl", "--user", "enable", "--now", "devmem"], + ["systemctl", "--user", "enable", "--now", "dev-recall"], check=True, capture_output=True, ) @@ -248,20 +248,20 @@ def _start_daemon(config) -> None: # Get PID time.sleep(1) result = subprocess.run( - ["systemctl", "--user", "show", "devmem", "--property=MainPID"], + ["systemctl", "--user", "show", "dev-recall", "--property=MainPID"], capture_output=True, text=True, ) pid = result.stdout.strip().split("=")[-1] - console.print(f" → devmem.service is running (PID {pid})") + console.print(f" → dev-recall.service is running (PID {pid})") except (subprocess.CalledProcessError, FileNotFoundError): # systemd not available — start as background process - from devmem.daemon import start_daemon_background + from recall.daemon import start_daemon_background try: pid = start_daemon_background(config) console.print(f" → [green]✓[/green] Started daemon in background (PID {pid})") except Exception as exc: console.print(f" → [yellow]Warning:[/yellow] could not start daemon: {exc}") - console.print(" Run manually: [cyan]devmem daemon start[/cyan]") + console.print(" Run manually: [cyan]recall daemon start[/cyan]") # --------------------------------------------------------------------------- @@ -271,7 +271,7 @@ def _start_daemon(config) -> None: @cli.group() def daemon(): - """Manage the DevMem background daemon.""" + """Manage the Recall background daemon.""" pass @@ -280,7 +280,7 @@ def daemon(): def daemon_start(foreground: bool): """Start the daemon.""" config = load_config() - from devmem.daemon import is_running, Daemon + from recall.daemon import is_running, Daemon if not foreground and is_running(config): console.print("[yellow]Daemon is already running.[/yellow]") @@ -294,7 +294,7 @@ def daemon_start(foreground: bool): d = Daemon(config) d.start(foreground=True) else: - from devmem.daemon import start_daemon_background + from recall.daemon import start_daemon_background pid = start_daemon_background(config) time.sleep(1) console.print(f"[green]Daemon started (PID {pid})[/green]") @@ -304,7 +304,7 @@ def daemon_start(foreground: bool): def daemon_stop(): """Stop the daemon.""" config = load_config() - from devmem.daemon import stop_daemon, is_running + from recall.daemon import stop_daemon, is_running if not is_running(config): console.print("[yellow]Daemon is not running.[/yellow]") @@ -319,7 +319,7 @@ def daemon_stop(): def daemon_status(): """Show daemon status and stats.""" config = load_config() - from devmem.daemon import is_running, read_pid + from recall.daemon import is_running, read_pid running = is_running(config) pid = read_pid(config) @@ -327,8 +327,8 @@ def daemon_status(): console.print(f"Status: {status_str}" + (f" (PID {pid})" if pid else "")) if config.db_path.exists(): - from devmem.storage.db import DB - from devmem.storage.vectors import VectorStore + from recall.storage.db import DB + from recall.storage.vectors import VectorStore db = DB(config.db_path) total = db.get_event_count() @@ -348,17 +348,17 @@ def daemon_status(): def daemon_install(): """Install and enable the systemd user service.""" config = load_config() - from devmem.daemon import write_systemd_unit + from recall.daemon import write_systemd_unit unit_path = write_systemd_unit(config) console.print(f"Written: {unit_path}") try: subprocess.run(["systemctl", "--user", "daemon-reload"], check=True) - subprocess.run(["systemctl", "--user", "enable", "--now", "devmem"], check=True) + subprocess.run(["systemctl", "--user", "enable", "--now", "dev-recall"], check=True) console.print("[green]Service enabled and started.[/green]") except (subprocess.CalledProcessError, FileNotFoundError) as exc: console.print(f"[yellow]systemctl failed: {exc}[/yellow]") - console.print("Start manually: [cyan]devmem daemon start[/cyan]") + console.print("Start manually: [cyan]recall daemon start[/cyan]") @daemon.command("logs") @@ -397,13 +397,13 @@ def ask(query: str, top_k: int, show_events: bool, no_llm: bool): config = load_config() _ensure_db(config) - from devmem.storage.db import DB - from devmem.storage.vectors import VectorStore - from devmem.processor.embedder import EmbedderQueue - from devmem.query.retriever import Retriever - from devmem.query.context import build_prompt_ask - from devmem.query.llm import is_available, ask as llm_ask, DevMemLLMError, configure - from devmem.query.timeparser import parse_time_expression, humanise_range + from recall.storage.db import DB + from recall.storage.vectors import VectorStore + from recall.processor.embedder import EmbedderQueue + from recall.query.retriever import Retriever + from recall.query.context import build_prompt_ask + from recall.query.llm import is_available, ask as llm_ask, DevMemLLMError, configure + from recall.query.timeparser import parse_time_expression, humanise_range configure(model=config.llm_model) @@ -464,9 +464,9 @@ def today(raw: bool): config = load_config() _ensure_db(config) - from devmem.storage.db import DB - from devmem.query.llm import is_available, ask as llm_ask, DevMemLLMError, configure - from devmem.query.context import build_prompt_summary + from recall.storage.db import DB + from recall.query.llm import is_available, ask as llm_ask, DevMemLLMError, configure + from recall.query.context import build_prompt_summary configure(model=config.llm_model) db = DB(config.db_path) @@ -475,7 +475,7 @@ def today(raw: bool): if not events: console.print("[yellow]No events recorded today yet.[/yellow]") - console.print("[dim]Make sure the daemon is running: devmem daemon status[/dim]") + console.print("[dim]Make sure the daemon is running: recall daemon status[/dim]") db.close() return @@ -526,9 +526,9 @@ def week(raw: bool): config = load_config() _ensure_db(config) - from devmem.storage.db import DB - from devmem.query.llm import is_available, ask as llm_ask, DevMemLLMError, configure - from devmem.query.context import build_prompt_summary + from recall.storage.db import DB + from recall.query.llm import is_available, ask as llm_ask, DevMemLLMError, configure + from recall.query.context import build_prompt_summary configure(model=config.llm_model) db = DB(config.db_path) @@ -588,7 +588,7 @@ def timeline(date: Optional[str], repo: Optional[str]): config = load_config() _ensure_db(config) - from devmem.storage.db import DB + from recall.storage.db import DB db = DB(config.db_path) date_str = date or datetime.now(timezone.utc).strftime("%Y-%m-%d") @@ -639,7 +639,7 @@ def repos(sort: str): config = load_config() _ensure_db(config) - from devmem.storage.db import DB + from recall.storage.db import DB db = DB(config.db_path) all_repos = db.get_all_repos() @@ -691,10 +691,10 @@ def search(query: str, event_type: Optional[str], repo: Optional[str], config = load_config() _ensure_db(config) - from devmem.storage.db import DB - from devmem.storage.vectors import VectorStore - from devmem.processor.embedder import EmbedderQueue - from devmem.query.retriever import Retriever + from recall.storage.db import DB + from recall.storage.vectors import VectorStore + from recall.processor.embedder import EmbedderQueue + from recall.query.retriever import Retriever db = DB(config.db_path) vectors = VectorStore.from_file(config.faiss_path, dim=config.embedding_dim) @@ -740,9 +740,9 @@ def stats(): config = load_config() _ensure_db(config) - from devmem.storage.db import DB - from devmem.storage.vectors import VectorStore - from devmem.daemon import is_running + from recall.storage.db import DB + from recall.storage.vectors import VectorStore + from recall.daemon import is_running db = DB(config.db_path) vectors = VectorStore.from_file(config.faiss_path, dim=config.embedding_dim) @@ -804,7 +804,7 @@ def privacy_list(): config = load_config() _ensure_db(config) - from devmem.storage.db import DB + from recall.storage.db import DB db = DB(config.db_path) counts = db.get_event_counts_by_type() @@ -838,7 +838,7 @@ def privacy_delete(before: Optional[str], event_type: Optional[str]): config = load_config() _ensure_db(config) - from devmem.storage.db import DB + from recall.storage.db import DB db = DB(config.db_path) @@ -926,7 +926,7 @@ def export(from_date: Optional[str], to_date: Optional[str], config = load_config() _ensure_db(config) - from devmem.storage.db import DB + from recall.storage.db import DB db = DB(config.db_path) etypes = [EventType(event_type)] if event_type else None @@ -980,7 +980,7 @@ def export(from_date: Optional[str], to_date: Optional[str], @cli.command("mcp-serve") def mcp_serve(): """Start the MCP server on stdio (for Claude Code / Copilot integration).""" - from devmem.mcp_server import run_mcp_server + from recall.mcp_server import run_mcp_server run_mcp_server() @@ -993,7 +993,7 @@ def mcp_serve(): def _ensure_db(config) -> None: if not config.db_path.exists(): err_console.print( - "[red]DevMem database not found. Run: devmem init[/red]" + "[red]Recall database not found. Run: recall init[/red]" ) sys.exit(1) diff --git a/devmem/collectors/__init__.py b/recall/collectors/__init__.py similarity index 100% rename from devmem/collectors/__init__.py rename to recall/collectors/__init__.py diff --git a/devmem/collectors/ai_chat.py b/recall/collectors/ai_chat.py similarity index 98% rename from devmem/collectors/ai_chat.py rename to recall/collectors/ai_chat.py index b95c357..70ec388 100644 --- a/devmem/collectors/ai_chat.py +++ b/recall/collectors/ai_chat.py @@ -15,7 +15,7 @@ from watchdog.events import FileModifiedEvent, FileSystemEventHandler from watchdog.observers import Observer -from devmem.models import Event, EventType, Source, build_content +from recall.models import Event, EventType, Source, build_content logger = logging.getLogger(__name__) @@ -74,6 +74,8 @@ def start(self) -> None: # Aider: watch each git repo root found at startup so live edits are captured for repo_root in _find_git_repos_shallow(Path.home(), depth=3): + if not self._observer.is_alive() if hasattr(self._observer, 'is_alive') else False: + break handler = _AIChatHandler(self, repo_root) try: self._observer.schedule(handler, str(repo_root), recursive=False) @@ -529,12 +531,8 @@ def _build_ai_event( ) def _is_duplicate(self, event: Event) -> bool: - """Return True if this event's content hash has been seen before. - - The hash includes both content and timestamp so that the same message - sent in two different sessions is not incorrectly deduplicated. - """ - h = hashlib.sha256(f"{event.timestamp}\x00{event.content}".encode()).hexdigest()[:16] + """Return True if this event's (content, timestamp) hash has been seen before.""" + h = hashlib.sha256(f"{event.content}|{event.timestamp}".encode()).hexdigest()[:16] seen_json = self._get_kv(_HASH_SET_KEY) or "[]" try: seen: list[str] = json.loads(seen_json) diff --git a/recall/collectors/containers.py b/recall/collectors/containers.py new file mode 100644 index 0000000..4e21805 --- /dev/null +++ b/recall/collectors/containers.py @@ -0,0 +1,164 @@ +"""Docker / Podman container event collector. + +Streams Docker events filtered to start/die/create/destroy and emits +CONTAINER_START / CONTAINER_STOP events. + +Supports both Docker (default socket) and Podman (rootless socket at +/run/user//podman/podman.sock) by checking both sockets. + +Gracefully becomes a no-op when: + - The `docker` SDK is not installed + - No accessible Docker/Podman socket is found +""" + +from __future__ import annotations + +import logging +import os +import threading +from datetime import datetime, timezone +from pathlib import Path +from typing import Callable, Optional + +from recall.models import Event, EventType, Source + +logger = logging.getLogger(__name__) + +try: + import docker # type: ignore + + _DOCKER_SDK_AVAILABLE = True +except ImportError: + _DOCKER_SDK_AVAILABLE = False + +# Canonical socket paths in preference order +_DOCKER_SOCKETS = [ + "/var/run/docker.sock", + "/run/docker.sock", +] + + +def _podman_socket() -> Optional[str]: + uid = os.getuid() + p = Path(f"/run/user/{uid}/podman/podman.sock") + return str(p) if p.exists() else None + + +def _find_socket() -> Optional[str]: + for s in _DOCKER_SOCKETS: + if os.path.exists(s) and os.access(s, os.R_OK | os.W_OK): + return s + ps = _podman_socket() + if ps and os.access(ps, os.R_OK | os.W_OK): + return ps + return None + + +def _now_ts() -> tuple[str, str]: + now = datetime.now(timezone.utc) + return now.strftime("%Y-%m-%dT%H:%M:%SZ"), now.strftime("%Y-%m-%d") + + +# Container actions that map to CONTAINER_START +_START_ACTIONS = {"start", "restart"} +# Container actions that map to CONTAINER_STOP +_STOP_ACTIONS = {"die", "stop", "destroy", "kill"} + + +class ContainerCollector: + """Stream Docker/Podman events and emit container lifecycle events.""" + + def __init__(self, event_callback: Callable[[Event], None]) -> None: + self._cb = event_callback + self._thread: Optional[threading.Thread] = None + self._stop_event = threading.Event() + self._client: Optional["docker.DockerClient"] = None # type: ignore[name-defined] + + def start(self) -> None: + if not _DOCKER_SDK_AVAILABLE: + logger.debug("docker SDK unavailable — container tracking disabled") + return + + socket = _find_socket() + if socket is None: + logger.debug("No accessible Docker/Podman socket — container tracking disabled") + return + + try: + self._client = docker.DockerClient(base_url=f"unix://{socket}") + # Quick ping to verify it's alive + self._client.ping() + except Exception as exc: + logger.debug("Docker/Podman socket not responding (%s) — disabled", exc) + self._client = None + return + + self._thread = threading.Thread( + target=self._run, + daemon=True, + name="devmem-containers", + ) + self._thread.start() + logger.info("ContainerCollector started on socket %s", socket) + + def stop(self) -> None: + self._stop_event.set() + # Close client to unblock the blocking events() generator + if self._client is not None: + try: + self._client.close() + except Exception: + pass + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _run(self) -> None: + if self._client is None: + return + try: + for raw in self._client.events( + decode=True, + filters={"type": "container"}, + ): + if self._stop_event.is_set(): + break + self._handle_raw(raw) + except Exception: + if not self._stop_event.is_set(): + logger.exception("ContainerCollector stream crashed") + + def _handle_raw(self, raw: dict) -> None: + action = raw.get("Action", "") + attrs = raw.get("Actor", {}).get("Attributes", {}) + container_id = raw.get("Actor", {}).get("ID", "")[:12] + name = attrs.get("name", container_id) + image = attrs.get("image", "") + + if action in _START_ACTIONS: + event_type = EventType.CONTAINER_START + elif action in _STOP_ACTIONS: + event_type = EventType.CONTAINER_STOP + else: + return + + ts, date = _now_ts() + event_data = { + "name": name, + "container_id": container_id, + "image": image, + "action": action, + } + event = Event( + timestamp=ts, + date=date, + event_type=event_type, + source=Source.CONTAINER_EVENTS, + content="", + raw_data=event_data, + ) + from recall.models import build_content + + event.content = build_content(event_type, event_data) + self._cb(event) diff --git a/devmem/collectors/git.py b/recall/collectors/git.py similarity index 99% rename from devmem/collectors/git.py rename to recall/collectors/git.py index e42523f..860cdd3 100644 --- a/devmem/collectors/git.py +++ b/recall/collectors/git.py @@ -14,7 +14,7 @@ from watchdog.events import FileModifiedEvent, FileSystemEventHandler from watchdog.observers import Observer -from devmem.models import Event, EventType, Source, build_content +from recall.models import Event, EventType, Source, build_content logger = logging.getLogger(__name__) @@ -42,7 +42,7 @@ class GitCollector: Watches git.tsv written by the global git hooks. Also starts a poller as a fallback for repos that already had - core.hooksPath set before devmem was installed. + core.hooksPath set before dev-recall was installed. """ def __init__( @@ -86,7 +86,7 @@ def start(self) -> None: self._poller_thread = threading.Thread( target=self._poller_loop, daemon=True, - name="devmem-git-poller", + name="dev-recall-git-poller", ) self._poller_thread.start() logger.info("GitCollector watching %s + poller started", self._path) diff --git a/recall/collectors/linux_process.py b/recall/collectors/linux_process.py new file mode 100644 index 0000000..675b72b --- /dev/null +++ b/recall/collectors/linux_process.py @@ -0,0 +1,230 @@ +"""Process and port tracker using psutil. + +Polls every 30 s to detect: + - Dev server processes starting/stopping (by process name allowlist) + - Listening TCP ports opening/closing in the dev-server range + +Emits PORT_OPEN / PORT_CLOSE events when a listening port appears or +disappears for a tracked process. + +Gracefully becomes a no-op when psutil is not installed. +""" + +from __future__ import annotations + +import logging +import threading +import time +from datetime import datetime, timezone +from typing import Callable, Optional + +from recall.models import Event, EventType, Source + +logger = logging.getLogger(__name__) + +try: + import psutil # type: ignore + + _PSUTIL_AVAILABLE = True +except ImportError: + _PSUTIL_AVAILABLE = False + +# Process names that indicate a dev server or interesting background service. +# Matched as a substring of the process name or first cmdline argument. +_DEV_PROCESS_NAMES = { + "node", + "npm", + "npx", + "yarn", + "pnpm", + "bun", + "deno", + "next", + "vite", + "webpack", + "parcel", + "rollup", + "esbuild", + "turbopack", + "python", + "python3", + "uvicorn", + "gunicorn", + "hypercorn", + "fastapi", + "flask", + "django", + "starlette", + "tornado", + "aiohttp", + "ruby", + "rails", + "puma", + "unicorn", + "thin", + "java", + "mvn", + "gradle", + "go", + "air", + "gin", + "fiber", + "php", + "artisan", + "symfony", + "cargo", + "rust-analyzer", + "dotnet", +} + +# Only track ports in this range (typical dev / ephemeral service ports). +_PORT_MIN = 1024 +_PORT_MAX = 65535 + +# Ports to ignore (databases, system services, etc.) +_PORT_IGNORE = { + 3306, # MySQL + 5432, # PostgreSQL + 6379, # Redis + 27017, # MongoDB + 11211, # Memcached + 2181, # ZooKeeper + 2375, # Docker daemon (unencrypted) + 2376, # Docker daemon (TLS) + 22, # SSH + 25, # SMTP + 53, # DNS + 80, # HTTP (production) + 443, # HTTPS (production) +} + +_POLL_INTERVAL = 30 # seconds + + +def _now_ts() -> tuple[str, str]: + now = datetime.now(timezone.utc) + return now.strftime("%Y-%m-%dT%H:%M:%SZ"), now.strftime("%Y-%m-%d") + + +def _is_dev_process(proc: "psutil.Process") -> bool: # type: ignore[name-defined] + """Return True if the process looks like a dev server.""" + try: + name = proc.name().lower() + if name in _DEV_PROCESS_NAMES: + return True + # Also check the first cmdline argument (e.g. "python manage.py runserver") + cmdline = proc.cmdline() + if cmdline: + exe_base = cmdline[0].rsplit("/", 1)[-1].lower() + if exe_base in _DEV_PROCESS_NAMES: + return True + except (psutil.NoSuchProcess, psutil.AccessDenied): + pass + return False + + +def _snapshot_ports() -> dict[int, str]: + """Return {port: process_name} for all listening TCP sockets owned by dev processes.""" + result: dict[int, str] = {} + try: + connections = psutil.net_connections(kind="tcp") + except psutil.AccessDenied: + return result + + for conn in connections: + if conn.status != psutil.CONN_LISTEN: + continue + port = conn.laddr.port + if port < _PORT_MIN or port > _PORT_MAX or port in _PORT_IGNORE: + continue + pid = conn.pid + if pid is None: + continue + try: + proc = psutil.Process(pid) + if _is_dev_process(proc): + result[port] = proc.name() + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + return result + + +class ProcessCollector: + """Poll psutil every 30 s and emit PORT_OPEN / PORT_CLOSE events.""" + + def __init__(self, event_callback: Callable[[Event], None]) -> None: + self._cb = event_callback + self._thread: Optional[threading.Thread] = None + self._stop_event = threading.Event() + + def start(self) -> None: + if not _PSUTIL_AVAILABLE: + logger.debug("psutil unavailable — process/port tracking disabled") + return + + self._thread = threading.Thread( + target=self._run, + daemon=True, + name="dev-recall-process", + ) + self._thread.start() + logger.info("ProcessCollector started (poll interval %ds)", _POLL_INTERVAL) + + def stop(self) -> None: + self._stop_event.set() + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _run(self) -> None: + prev: dict[int, str] = {} + try: + prev = _snapshot_ports() + except Exception: + logger.exception("ProcessCollector initial snapshot failed") + + while not self._stop_event.wait(_POLL_INTERVAL): + try: + curr = _snapshot_ports() + self._diff(prev, curr) + prev = curr + except Exception: + logger.exception("ProcessCollector poll failed") + + def _diff(self, prev: dict[int, str], curr: dict[int, str]) -> None: + ts, date = _now_ts() + + # Newly opened ports + for port, proc_name in curr.items(): + if port not in prev: + raw = {"port": port, "process_name": proc_name} + event = Event( + timestamp=ts, + date=date, + event_type=EventType.PORT_OPEN, + source=Source.PROCESS_TRACKER, + content="", + raw_data=raw, + ) + from recall.models import build_content + + event.content = build_content(EventType.PORT_OPEN, raw) + self._cb(event) + + # Closed ports + for port, proc_name in prev.items(): + if port not in curr: + raw = {"port": port, "process_name": proc_name} + event = Event( + timestamp=ts, + date=date, + event_type=EventType.PORT_CLOSE, + source=Source.PROCESS_TRACKER, + content="", + raw_data=raw, + ) + from recall.models import build_content + + event.content = build_content(EventType.PORT_CLOSE, raw) + self._cb(event) diff --git a/recall/collectors/linux_session.py b/recall/collectors/linux_session.py new file mode 100644 index 0000000..668495a --- /dev/null +++ b/recall/collectors/linux_session.py @@ -0,0 +1,229 @@ +"""Linux session event collector via D-Bus logind. + +Subscribes to org.freedesktop.login1.Manager signals: + - PrepareForSleep(before: bool) → SYSTEM_SUSPEND / SYSTEM_RESUME + - org.freedesktop.login1.Session Lock / Unlock → SESSION_LOCK / SESSION_UNLOCK + +Uses dbus-next with an asyncio event loop running in a daemon thread. + +Gracefully becomes a no-op when dbus-next is not installed or the D-Bus +session bus is not available. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import time +import threading +from datetime import datetime, timezone +from typing import Callable, Optional + +from recall.models import Event, EventType, Source + +logger = logging.getLogger(__name__) + +try: + from dbus_next.aio import MessageBus # type: ignore + from dbus_next import BusType, Message, MessageType # type: ignore + + _DBUS_AVAILABLE = True +except ImportError: + _DBUS_AVAILABLE = False + + +def _now_ts() -> tuple[str, str]: + now = datetime.now(timezone.utc) + return now.strftime("%Y-%m-%dT%H:%M:%SZ"), now.strftime("%Y-%m-%d") + + +class LinuxSessionCollector: + """Collect screen lock/unlock and system sleep/resume events from D-Bus.""" + + def __init__(self, event_callback: Callable[[Event], None]) -> None: + self._cb = event_callback + self._thread: Optional[threading.Thread] = None + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._lock_time: Optional[float] = None + self._suspend_time: Optional[float] = None + + def start(self) -> None: + if not _DBUS_AVAILABLE: + logger.debug("dbus-next unavailable — session event tracking disabled") + return + + # Only works with a session D-Bus socket + if not os.environ.get("DBUS_SESSION_BUS_ADDRESS") and not os.path.exists( + "/run/user/%d/bus" % os.getuid() + ): + logger.debug("No D-Bus session bus — session event tracking disabled") + return + + self._thread = threading.Thread( + target=self._run, + daemon=True, + name="devmem-session", + ) + self._thread.start() + + def stop(self) -> None: + if self._loop is not None: + self._loop.call_soon_threadsafe(self._loop.stop) + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _run(self) -> None: + self._loop = asyncio.new_event_loop() + try: + self._loop.run_until_complete(self._subscribe()) + except Exception: + logger.exception("LinuxSessionCollector crashed") + + async def _subscribe(self) -> None: + try: + bus = await MessageBus(bus_type=BusType.SYSTEM).connect() + except Exception: + logger.debug("Could not connect to system D-Bus — session tracking disabled") + return + + # ---------------------------------------------------------------- + # Subscribe to PrepareForSleep (system suspend/resume) + # ---------------------------------------------------------------- + await bus.call( + Message( + destination="org.freedesktop.DBus", + path="/org/freedesktop/DBus", + interface="org.freedesktop.DBus", + member="AddMatch", + signature="s", + body=[ + "type='signal'," + "sender='org.freedesktop.login1'," + "interface='org.freedesktop.login1.Manager'," + "member='PrepareForSleep'" + ], + ) + ) + + # ---------------------------------------------------------------- + # Subscribe to session Lock/Unlock + # ---------------------------------------------------------------- + await bus.call( + Message( + destination="org.freedesktop.DBus", + path="/org/freedesktop/DBus", + interface="org.freedesktop.DBus", + member="AddMatch", + signature="s", + body=[ + "type='signal'," + "sender='org.freedesktop.login1'," + "interface='org.freedesktop.login1.Session'," + "member='Lock'" + ], + ) + ) + await bus.call( + Message( + destination="org.freedesktop.DBus", + path="/org/freedesktop/DBus", + interface="org.freedesktop.DBus", + member="AddMatch", + signature="s", + body=[ + "type='signal'," + "sender='org.freedesktop.login1'," + "interface='org.freedesktop.login1.Session'," + "member='Unlock'" + ], + ) + ) + + logger.info("LinuxSessionCollector subscribed to D-Bus logind signals") + + bus.add_message_handler(self._handle_message) + + # Keep running until stop() is called + await asyncio.get_event_loop().run_in_executor(None, self._thread.join if self._thread else lambda: None) + + def _handle_message(self, message: "Message") -> None: # type: ignore[name-defined] + if message.message_type != MessageType.SIGNAL: + return + member = message.member + if member == "PrepareForSleep": + before = message.body[0] if message.body else True + self._on_prepare_for_sleep(before) + elif member == "Lock": + self._on_session_lock() + elif member == "Unlock": + self._on_session_unlock() + + def _on_prepare_for_sleep(self, before: bool) -> None: + ts, date = _now_ts() + if before: + # Going to sleep + self._suspend_time = time.monotonic() + event = Event( + timestamp=ts, + date=date, + event_type=EventType.SYSTEM_SUSPEND, + source=Source.LINUX_SESSION, + content="", + raw_data={}, + ) + else: + # Waking up + raw: dict = {} + if self._suspend_time is not None: + raw["suspended_seconds"] = round(time.monotonic() - self._suspend_time) + self._suspend_time = None + event = Event( + timestamp=ts, + date=date, + event_type=EventType.SYSTEM_RESUME, + source=Source.LINUX_SESSION, + content="", + raw_data=raw, + ) + from recall.models import build_content + + event.content = build_content(event.event_type, event.raw_data) + self._cb(event) + + def _on_session_lock(self) -> None: + self._lock_time = time.monotonic() + ts, date = _now_ts() + event = Event( + timestamp=ts, + date=date, + event_type=EventType.SESSION_LOCK, + source=Source.LINUX_SESSION, + content="", + raw_data={}, + ) + from recall.models import build_content + + event.content = build_content(EventType.SESSION_LOCK, event.raw_data) + self._cb(event) + + def _on_session_unlock(self) -> None: + ts, date = _now_ts() + raw: dict = {} + if self._lock_time is not None: + raw["locked_seconds"] = round(time.monotonic() - self._lock_time) + self._lock_time = None + event = Event( + timestamp=ts, + date=date, + event_type=EventType.SESSION_UNLOCK, + source=Source.LINUX_SESSION, + content="", + raw_data=raw, + ) + from recall.models import build_content + + event.content = build_content(EventType.SESSION_UNLOCK, event.raw_data) + self._cb(event) diff --git a/recall/collectors/linux_window.py b/recall/collectors/linux_window.py new file mode 100644 index 0000000..3efbe16 --- /dev/null +++ b/recall/collectors/linux_window.py @@ -0,0 +1,199 @@ +"""Linux window/app tracker using libwnck (X11/XWayland). + +Tracks application open/close, window focus changes, and virtual workspace +switches via Wnck signals on a GLib main loop in a daemon thread. + +Gracefully becomes a no-op when: + - PyGObject / libwnck is not installed + - No X display is available (pure Wayland without XWayland) + - Wnck.Screen.get_default() returns None +""" + +from __future__ import annotations + +import logging +import os +import threading +import time +from datetime import datetime, timezone +from typing import Callable, Optional + +from recall.models import Event, EventType, Source + +logger = logging.getLogger(__name__) + +try: + import gi + + gi.require_version("Wnck", "3.0") + gi.require_version("GLib", "2.0") + from gi.repository import GLib, Wnck # type: ignore + + _WNCK_AVAILABLE = True +except Exception: + _WNCK_AVAILABLE = False + + +def _now_ts() -> tuple[str, str]: + """Return (ISO timestamp, YYYY-MM-DD).""" + now = datetime.now(timezone.utc) + return now.strftime("%Y-%m-%dT%H:%M:%SZ"), now.strftime("%Y-%m-%d") + + +class LinuxWindowCollector: + """Collect application and window events from the X11/XWayland session.""" + + def __init__(self, event_callback: Callable[[Event], None]) -> None: + self._cb = event_callback + self._thread: Optional[threading.Thread] = None + self._loop: Optional["GLib.MainLoop"] = None # type: ignore[name-defined] + # Track app open times for duration calculation + self._app_open_times: dict[int, float] = {} + # Track active workspace name for switch events + self._current_workspace: Optional[str] = None + + def start(self) -> None: + if not _WNCK_AVAILABLE: + logger.debug("libwnck unavailable — window tracking disabled") + return + + display = os.environ.get("DISPLAY", "") + if not display: + logger.debug("No DISPLAY — window tracking disabled") + return + + self._thread = threading.Thread( + target=self._run, + daemon=True, + name="devmem-window", + ) + self._thread.start() + + def stop(self) -> None: + if self._loop is not None: + self._loop.quit() + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _run(self) -> None: + try: + self._loop = GLib.MainLoop() + + # Wnck.Screen.get_default() must be called inside the GLib loop thread + GLib.idle_add(self._setup_wnck) + self._loop.run() + except Exception: + logger.exception("LinuxWindowCollector loop crashed") + + def _setup_wnck(self) -> bool: + try: + screen = Wnck.Screen.get_default() + if screen is None: + logger.debug("Wnck.Screen.get_default() returned None — disabling window tracking") + if self._loop: + self._loop.quit() + return False + + screen.force_update() + + # Seed current workspace + ws = screen.get_active_workspace() + self._current_workspace = ws.get_name() if ws else None + + screen.connect("application-opened", self._on_app_opened) + screen.connect("application-closed", self._on_app_closed) + screen.connect("active-window-changed", self._on_window_focus) + screen.connect("active-workspace-changed", self._on_workspace_changed) + logger.info("Wnck window tracking active on DISPLAY=%s", os.environ.get("DISPLAY")) + except Exception: + logger.exception("Wnck setup failed") + return False # remove idle callback + + def _on_app_opened(self, screen: "Wnck.Screen", app: "Wnck.Application") -> None: # type: ignore[name-defined] + xid = app.get_xid() + self._app_open_times[xid] = time.monotonic() + ts, date = _now_ts() + name = app.get_name() or "" + event = Event( + timestamp=ts, + date=date, + event_type=EventType.APP_OPEN, + source=Source.LINUX_WINDOW, + content="", + raw_data={"app_name": name, "xid": xid}, + ) + from recall.models import build_content + + event.content = build_content(EventType.APP_OPEN, event.raw_data) + self._cb(event) + + def _on_app_closed(self, screen: "Wnck.Screen", app: "Wnck.Application") -> None: # type: ignore[name-defined] + xid = app.get_xid() + ts, date = _now_ts() + name = app.get_name() or "" + raw: dict = {"app_name": name, "xid": xid} + open_t = self._app_open_times.pop(xid, None) + if open_t is not None: + raw["duration_seconds"] = round(time.monotonic() - open_t) + event = Event( + timestamp=ts, + date=date, + event_type=EventType.APP_CLOSE, + source=Source.LINUX_WINDOW, + content="", + raw_data=raw, + ) + from recall.models import build_content + + event.content = build_content(EventType.APP_CLOSE, event.raw_data) + self._cb(event) + + def _on_window_focus( + self, + screen: "Wnck.Screen", # type: ignore[name-defined] + prev_window: Optional["Wnck.Window"], # type: ignore[name-defined] + ) -> None: + window = screen.get_active_window() + if window is None: + return + ts, date = _now_ts() + title = window.get_name() or "" + app = window.get_application() + app_name = app.get_name() if app else "" + event = Event( + timestamp=ts, + date=date, + event_type=EventType.WINDOW_FOCUS, + source=Source.LINUX_WINDOW, + content="", + raw_data={"window_title": title, "app_name": app_name}, + ) + from recall.models import build_content + + event.content = build_content(EventType.WINDOW_FOCUS, event.raw_data) + self._cb(event) + + def _on_workspace_changed( + self, + screen: "Wnck.Screen", # type: ignore[name-defined] + prev_ws: Optional["Wnck.Workspace"], # type: ignore[name-defined] + ) -> None: + from_name = prev_ws.get_name() if prev_ws else (self._current_workspace or "") + ws = screen.get_active_workspace() + to_name = ws.get_name() if ws else "" + self._current_workspace = to_name + ts, date = _now_ts() + event = Event( + timestamp=ts, + date=date, + event_type=EventType.WORKSPACE_SWITCH, + source=Source.LINUX_WINDOW, + content="", + raw_data={"from_workspace": from_name, "to_workspace": to_name}, + ) + from recall.models import build_content + + event.content = build_content(EventType.WORKSPACE_SWITCH, event.raw_data) + self._cb(event) diff --git a/devmem/collectors/shell.py b/recall/collectors/shell.py similarity index 98% rename from devmem/collectors/shell.py rename to recall/collectors/shell.py index e3433c6..30341a6 100644 --- a/devmem/collectors/shell.py +++ b/recall/collectors/shell.py @@ -13,7 +13,7 @@ from watchdog.events import FileModifiedEvent, FileSystemEventHandler from watchdog.observers import Observer -from devmem.models import Event, EventType, Source, build_content +from recall.models import Event, EventType, Source, build_content logger = logging.getLogger(__name__) @@ -45,7 +45,7 @@ ("phpunit", "test"), ("rspec ", "test"), ("flutter test", "test"), - # build — match bare command first, then with arguments + # build ("make", "build"), ("cmake", "build"), ("cargo build", "build"), @@ -87,11 +87,11 @@ ("uv add", "install"), ("uv pip install", "install"), # deploy + ("docker", "deploy"), ("kubectl ", "deploy"), ("terraform ", "deploy"), ("helm ", "deploy"), ("ansible", "deploy"), - ("docker ", "deploy"), ("fly deploy", "deploy"), ("vercel ", "deploy"), ("netlify deploy", "deploy"), diff --git a/devmem/collectors/vscode.py b/recall/collectors/vscode.py similarity index 98% rename from devmem/collectors/vscode.py rename to recall/collectors/vscode.py index 5b0f32e..219e14d 100644 --- a/devmem/collectors/vscode.py +++ b/recall/collectors/vscode.py @@ -7,7 +7,7 @@ from datetime import datetime, timezone from typing import Callable, Optional -from devmem.models import Event, EventType, Source, build_content +from recall.models import Event, EventType, Source, build_content logger = logging.getLogger(__name__) diff --git a/devmem/config.py b/recall/config.py similarity index 96% rename from devmem/config.py rename to recall/config.py index 8c36a54..61329f2 100644 --- a/devmem/config.py +++ b/recall/config.py @@ -1,4 +1,4 @@ -"""DevMem configuration — load, save, and default settings.""" +"""Recall configuration — load, save, and default settings.""" from __future__ import annotations @@ -9,7 +9,7 @@ from platformdirs import user_config_dir, user_data_dir -_APP_NAME = "devmem" +_APP_NAME = "dev-recall" # --------------------------------------------------------------------------- # Defaults @@ -214,8 +214,8 @@ def __repr__(self) -> str: def _config_file_path() -> Path: - """Return the path to the config JSON file, respecting DEVMEM_CONFIG env override.""" - env_path = os.environ.get("DEVMEM_CONFIG") + """Return the path to the config JSON file, respecting DEV_RECALL_CONFIG env override.""" + env_path = os.environ.get("DEV_RECALL_CONFIG") if env_path: return Path(env_path).expanduser() return Path(user_config_dir(_APP_NAME)) / "config.json" diff --git a/devmem/daemon.py b/recall/daemon.py similarity index 79% rename from devmem/daemon.py rename to recall/daemon.py index 3a07933..ee21757 100644 --- a/devmem/daemon.py +++ b/recall/daemon.py @@ -1,4 +1,4 @@ -"""DevMem background daemon — orchestrates all collectors and background threads.""" +"""Recall background daemon — orchestrates all collectors and background threads.""" from __future__ import annotations @@ -12,19 +12,19 @@ from pathlib import Path from typing import Optional -from devmem.config import Config -from devmem.models import Event +from recall.config import Config +from recall.models import Event logger = logging.getLogger(__name__) _SYSTEMD_UNIT_TEMPLATE = """\ [Unit] -Description=DevMem developer activity daemon +Description=Recall developer activity daemon After=default.target [Service] Type=simple -ExecStart={devmem_bin} daemon start --foreground +ExecStart={recall_bin} daemon start --foreground Restart=on-failure RestartSec=5 @@ -53,11 +53,11 @@ def __init__(self, config: Config) -> None: self._stop_event = threading.Event() # Lazy imports to keep startup fast - from devmem.storage.db import DB - from devmem.storage.vectors import VectorStore - from devmem.processor.embedder import EmbedderQueue - from devmem.processor.enricher import Enricher - from devmem.processor.session import SessionDetector + from recall.storage.db import DB + from recall.storage.vectors import VectorStore + from recall.processor.embedder import EmbedderQueue + from recall.processor.enricher import Enricher + from recall.processor.session import SessionDetector self._db = DB(config.db_path) self._vectors = VectorStore.from_file(config.faiss_path, dim=config.embedding_dim) @@ -93,6 +93,10 @@ def __init__(self, config: Config) -> None: self._git_collector = None self._ai_collector = None self._flask_thread = None + self._window_collector = None + self._session_event_collector = None + self._container_collector = None + self._process_collector = None # ------------------------------------------------------------------ # Event pipeline @@ -124,7 +128,7 @@ def _handle_raw_event(self, event: Event) -> None: def start(self, foreground: bool = False) -> None: """Start the daemon (blocks if foreground=True).""" - logger.info("DevMem daemon starting (PID %d)", os.getpid()) + logger.info("Recall daemon starting (PID %d)", os.getpid()) if not foreground: self._write_pid() @@ -136,11 +140,11 @@ def start(self, foreground: bool = False) -> None: threading.Thread( target=self._embedder.embed_pending, daemon=True, - name="devmem-embed-pending", + name="dev-recall-embed-pending", ).start() # Shell collector - from devmem.collectors.shell import ShellCollector + from recall.collectors.shell import ShellCollector self._shell_collector = ShellCollector( shell_tsv=self._config.shell_tsv_path, @@ -152,7 +156,7 @@ def start(self, foreground: bool = False) -> None: self._shell_collector.start() # Git collector - from devmem.collectors.git import GitCollector + from recall.collectors.git import GitCollector self._git_collector = GitCollector( git_tsv=self._config.git_tsv_path, @@ -165,7 +169,7 @@ def start(self, foreground: bool = False) -> None: # AI chat collector if self._config.capture.get("ai_chat", True): - from devmem.collectors.ai_chat import AIChatCollector + from recall.collectors.ai_chat import AIChatCollector self._ai_collector = AIChatCollector( event_callback=self._handle_raw_event, @@ -180,10 +184,40 @@ def start(self, foreground: bool = False) -> None: self._flask_thread = threading.Thread( target=self._run_flask, daemon=True, - name="devmem-flask", + name="dev-recall-flask", ) self._flask_thread.start() + # Window tracking (Linux X11/XWayland via libwnck, optional) + if self._config.capture.get("window_tracking", True): + from recall.collectors.linux_window import LinuxWindowCollector + + self._window_collector = LinuxWindowCollector(event_callback=self._handle_raw_event) + self._window_collector.start() + + # Session events (Linux D-Bus logind, optional) + if self._config.capture.get("session_events", True): + from recall.collectors.linux_session import LinuxSessionCollector + + self._session_event_collector = LinuxSessionCollector( + event_callback=self._handle_raw_event + ) + self._session_event_collector.start() + + # Container events (Docker/Podman, optional) + if self._config.capture.get("container_events", True): + from recall.collectors.containers import ContainerCollector + + self._container_collector = ContainerCollector(event_callback=self._handle_raw_event) + self._container_collector.start() + + # Process/port tracking (psutil, optional) + if self._config.capture.get("process_tracking", True): + from recall.collectors.linux_process import ProcessCollector + + self._process_collector = ProcessCollector(event_callback=self._handle_raw_event) + self._process_collector.start() + # Daily summarizer threading.Thread( target=self._daily_summarizer_loop, @@ -202,7 +236,7 @@ def start(self, foreground: bool = False) -> None: signal.signal(signal.SIGTERM, self._on_signal) signal.signal(signal.SIGINT, self._on_signal) - logger.info("DevMem daemon running") + logger.info("Recall daemon running") if foreground: self._stop_event.wait() @@ -212,13 +246,21 @@ def stop(self) -> None: self._stop_event.set() def _shutdown(self) -> None: - logger.info("DevMem daemon shutting down…") + logger.info("Recall daemon shutting down…") if self._shell_collector: self._shell_collector.stop() if self._git_collector: self._git_collector.stop() if self._ai_collector: self._ai_collector.stop() + if self._window_collector: + self._window_collector.stop() + if self._session_event_collector: + self._session_event_collector.stop() + if self._container_collector: + self._container_collector.stop() + if self._process_collector: + self._process_collector.stop() self._embedder.stop() # Final FAISS save try: @@ -227,7 +269,7 @@ def _shutdown(self) -> None: logger.exception("Final FAISS save failed") self._db.close() self._remove_pid() - logger.info("DevMem daemon stopped") + logger.info("Recall daemon stopped") def _on_signal(self, signum, frame) -> None: logger.info("Received signal %d — stopping", signum) @@ -239,7 +281,7 @@ def _on_signal(self, signum, frame) -> None: def _run_flask(self) -> None: from flask import Flask, request, jsonify - from devmem.collectors.vscode import parse_vscode_event + from recall.collectors.vscode import parse_vscode_event app = Flask(__name__) @@ -293,8 +335,8 @@ def _generate_daily_summary(self, date: str) -> None: if not events: return - from devmem.query.context import build_prompt_summary - from devmem.query.llm import ask as llm_ask, is_available + from recall.query.context import build_prompt_summary + from recall.query.llm import ask as llm_ask, is_available if not is_available(): logger.debug("No LLM key — skipping daily summary for %s", date) @@ -396,12 +438,12 @@ def write_systemd_unit(config: Config) -> Path: """Write the systemd user unit file and return its path.""" import shutil - devmem_bin = shutil.which("devmem") or sys.executable + " -m devmem" - unit_content = _SYSTEMD_UNIT_TEMPLATE.format(devmem_bin=devmem_bin) + recall_bin = shutil.which("recall") or sys.executable + " -m devmem" + unit_content = _SYSTEMD_UNIT_TEMPLATE.format(recall_bin=recall_bin) unit_dir = Path.home() / ".config" / "systemd" / "user" unit_dir.mkdir(parents=True, exist_ok=True) - unit_path = unit_dir / "devmem.service" + unit_path = unit_dir / "dev-recall.service" unit_path.write_text(unit_content) return unit_path @@ -412,7 +454,7 @@ def start_daemon_background(config: Config) -> int: log_fh = open(str(config.log_path), "a") # noqa: SIM115 proc = subprocess.Popen( - [sys.executable, "-m", "devmem.daemon_main"], + [sys.executable, "-m", "recall.daemon_main"], start_new_session=True, stdout=log_fh, stderr=subprocess.STDOUT, diff --git a/devmem/daemon_main.py b/recall/daemon_main.py similarity index 72% rename from devmem/daemon_main.py rename to recall/daemon_main.py index 673938d..c3db961 100644 --- a/devmem/daemon_main.py +++ b/recall/daemon_main.py @@ -1,9 +1,9 @@ -"""Entry point for running the daemon as a module: python -m devmem.daemon_main""" +"""Entry point for running the daemon as a module: python -m recall.daemon_main""" import logging import os -from devmem.config import load_config -from devmem.daemon import Daemon +from recall.config import load_config +from recall.daemon import Daemon logging.basicConfig( level=logging.INFO, @@ -15,7 +15,7 @@ config = load_config() config.data_dir.mkdir(parents=True, exist_ok=True) config.config_dir.mkdir(parents=True, exist_ok=True) - # Write PID file so `devmem daemon status/stop` can find this process. + # Write PID file so `dev-recall daemon status/stop` can find this process. # start(foreground=True) skips _write_pid(), so we do it here. try: config.pid_path.write_text(str(os.getpid())) diff --git a/devmem/mcp_server.py b/recall/mcp_server.py similarity index 85% rename from devmem/mcp_server.py rename to recall/mcp_server.py index 3731cb9..d9125e0 100644 --- a/devmem/mcp_server.py +++ b/recall/mcp_server.py @@ -1,4 +1,4 @@ -"""MCP server — exposes DevMem tools via the Model Context Protocol (stdio).""" +"""MCP server — exposes Recall tools via the Model Context Protocol (stdio).""" from __future__ import annotations @@ -10,7 +10,7 @@ logger = logging.getLogger(__name__) -mcp = FastMCP("devmem") +mcp = FastMCP("dev-recall") # --------------------------------------------------------------------------- @@ -31,16 +31,16 @@ def recall(query: str, days: int = 7, top_k: int = 10) -> str: top_k: Number of results to return (default 10) """ try: - from devmem.config import load_config - from devmem.storage.db import DB - from devmem.storage.vectors import VectorStore - from devmem.processor.embedder import EmbedderQueue - from devmem.query.retriever import Retriever - from devmem.query.timeparser import to_iso + from recall.config import load_config + from recall.storage.db import DB + from recall.storage.vectors import VectorStore + from recall.processor.embedder import EmbedderQueue + from recall.query.retriever import Retriever + from recall.query.timeparser import to_iso config = load_config() if not config.db_path.exists(): - return "DevMem database not found. Run: devmem init" + return "Recall database not found. Run: recall init" now = datetime.now(timezone.utc) from datetime import timedelta @@ -81,14 +81,14 @@ def today_summary() -> str: Get a summary of what the developer worked on today. """ try: - from devmem.config import load_config - from devmem.storage.db import DB - from devmem.query.llm import is_available, ask as llm_ask, DevMemLLMError, configure - from devmem.query.context import build_prompt_summary + from recall.config import load_config + from recall.storage.db import DB + from recall.query.llm import is_available, ask as llm_ask, DevMemLLMError, configure + from recall.query.context import build_prompt_summary config = load_config() if not config.db_path.exists(): - return "DevMem database not found. Run: devmem init" + return "Recall database not found. Run: recall init" configure(model=config.llm_model) db = DB(config.db_path) @@ -136,13 +136,13 @@ def recent_repos(days: int = 7) -> str: days: How many days back to look (default 7) """ try: - from devmem.config import load_config - from devmem.storage.db import DB + from recall.config import load_config + from recall.storage.db import DB from datetime import timedelta config = load_config() if not config.db_path.exists(): - return "DevMem database not found. Run: devmem init" + return "Recall database not found. Run: recall init" db = DB(config.db_path) now = datetime.now(timezone.utc) @@ -185,16 +185,16 @@ def find_command(description: str, repo: Optional[str] = None) -> str: repo: Optional: limit search to a specific repo name """ try: - from devmem.config import load_config - from devmem.storage.db import DB - from devmem.storage.vectors import VectorStore - from devmem.processor.embedder import EmbedderQueue - from devmem.query.retriever import Retriever - from devmem.models import EventType + from recall.config import load_config + from recall.storage.db import DB + from recall.storage.vectors import VectorStore + from recall.processor.embedder import EmbedderQueue + from recall.query.retriever import Retriever + from recall.models import EventType config = load_config() if not config.db_path.exists(): - return "DevMem database not found. Run: devmem init" + return "Recall database not found. Run: recall init" db = DB(config.db_path) vectors = VectorStore.from_file(config.faiss_path, dim=config.embedding_dim) @@ -239,12 +239,12 @@ def timeline(date: Optional[str] = None) -> str: date: Date in YYYY-MM-DD format. Defaults to today. """ try: - from devmem.config import load_config - from devmem.storage.db import DB + from recall.config import load_config + from recall.storage.db import DB config = load_config() if not config.db_path.exists(): - return "DevMem database not found. Run: devmem init" + return "Recall database not found. Run: recall init" db = DB(config.db_path) date_str = date or datetime.now(timezone.utc).strftime("%Y-%m-%d") diff --git a/devmem/models.py b/recall/models.py similarity index 100% rename from devmem/models.py rename to recall/models.py diff --git a/devmem/processor/__init__.py b/recall/processor/__init__.py similarity index 100% rename from devmem/processor/__init__.py rename to recall/processor/__init__.py diff --git a/devmem/processor/embedder.py b/recall/processor/embedder.py similarity index 98% rename from devmem/processor/embedder.py rename to recall/processor/embedder.py index c6648fc..f30c072 100644 --- a/devmem/processor/embedder.py +++ b/recall/processor/embedder.py @@ -14,8 +14,8 @@ np = None # type: ignore[assignment] if TYPE_CHECKING: - from devmem.storage.db import DB - from devmem.storage.vectors import VectorStore + from recall.storage.db import DB + from recall.storage.vectors import VectorStore logger = logging.getLogger(__name__) @@ -80,7 +80,7 @@ def start(self) -> None: self._worker_thread = threading.Thread( target=self._worker, daemon=True, - name="devmem-embedder", + name="dev-recall-embedder", ) self._worker_thread.start() logger.info("EmbedderQueue worker started (model=%s)", self._model_name) diff --git a/devmem/processor/enricher.py b/recall/processor/enricher.py similarity index 99% rename from devmem/processor/enricher.py rename to recall/processor/enricher.py index c840fbf..8c4e329 100644 --- a/devmem/processor/enricher.py +++ b/recall/processor/enricher.py @@ -10,7 +10,7 @@ from pathlib import Path from typing import Callable, Optional -from devmem.models import Event, EventType, build_content +from recall.models import Event, EventType, build_content logger = logging.getLogger(__name__) diff --git a/devmem/processor/session.py b/recall/processor/session.py similarity index 99% rename from devmem/processor/session.py rename to recall/processor/session.py index 528359b..d2fdf5e 100644 --- a/devmem/processor/session.py +++ b/recall/processor/session.py @@ -7,7 +7,7 @@ from datetime import datetime, timezone from typing import Callable, Optional -from devmem.models import Event +from recall.models import Event logger = logging.getLogger(__name__) diff --git a/devmem/query/__init__.py b/recall/query/__init__.py similarity index 100% rename from devmem/query/__init__.py rename to recall/query/__init__.py diff --git a/devmem/query/context.py b/recall/query/context.py similarity index 98% rename from devmem/query/context.py rename to recall/query/context.py index ae5c50c..9faa5f5 100644 --- a/devmem/query/context.py +++ b/recall/query/context.py @@ -7,7 +7,7 @@ import humanize -from devmem.models import Event +from recall.models import Event # --------------------------------------------------------------------------- @@ -43,7 +43,7 @@ def build_prompt_ask( time_range: Optional[str] = None, ) -> list[dict]: """ - Build messages for `devmem ask ""`. + Build messages for `recall ask ""`. Parameters ---------- diff --git a/devmem/query/llm.py b/recall/query/llm.py similarity index 98% rename from devmem/query/llm.py rename to recall/query/llm.py index c4da900..e581ade 100644 --- a/devmem/query/llm.py +++ b/recall/query/llm.py @@ -45,7 +45,7 @@ def ask(messages: list[dict], model: Optional[str] = None) -> str: key = _api_key or os.environ.get("OPENROUTER_API_KEY") or os.environ.get("DEVMEM_LLM_KEY") if not key: raise DevMemLLMError( - "No LLM API key found. Set OPENROUTER_API_KEY or run: devmem config llm_key " + "No LLM API key found. Set OPENROUTER_API_KEY or run: recall config llm_key " ) effective_model = model or _model or _DEFAULT_MODEL diff --git a/devmem/query/retriever.py b/recall/query/retriever.py similarity index 95% rename from devmem/query/retriever.py rename to recall/query/retriever.py index abf5ac2..35920ff 100644 --- a/devmem/query/retriever.py +++ b/recall/query/retriever.py @@ -6,13 +6,13 @@ from datetime import datetime from typing import TYPE_CHECKING, Optional -from devmem.models import Event, EventType -from devmem.query.timeparser import parse_time_expression, to_iso +from recall.models import Event, EventType +from recall.query.timeparser import parse_time_expression, to_iso if TYPE_CHECKING: - from devmem.processor.embedder import EmbedderQueue - from devmem.storage.db import DB - from devmem.storage.vectors import VectorStore + from recall.processor.embedder import EmbedderQueue + from recall.storage.db import DB + from recall.storage.vectors import VectorStore logger = logging.getLogger(__name__) diff --git a/devmem/query/timeparser.py b/recall/query/timeparser.py similarity index 100% rename from devmem/query/timeparser.py rename to recall/query/timeparser.py diff --git a/devmem/storage/__init__.py b/recall/storage/__init__.py similarity index 100% rename from devmem/storage/__init__.py rename to recall/storage/__init__.py diff --git a/devmem/storage/db.py b/recall/storage/db.py similarity index 97% rename from devmem/storage/db.py rename to recall/storage/db.py index 2452111..1717184 100644 --- a/devmem/storage/db.py +++ b/recall/storage/db.py @@ -9,7 +9,7 @@ from pathlib import Path from typing import Generator, Optional -from devmem.models import Event, EventType, Source +from recall.models import Event, EventType, Source # --------------------------------------------------------------------------- # SQL DDL @@ -137,9 +137,7 @@ def _migrate_remove_event_type_check(self) -> None: if row is None or "CONSTRAINT valid_type CHECK" not in (row[0] or ""): return _log.info("DB migration: removing event_type CHECK constraint") - # SQLite requires a full table rebuild to drop a CHECK constraint. - # After rebuilding we must also recreate all indexes and FTS triggers - # that were defined on the original table. + # SQLite requires a full table rebuild to drop a CHECK constraint self._conn.executescript(""" ALTER TABLE events RENAME TO _events_bak; CREATE TABLE events ( @@ -158,23 +156,17 @@ def _migrate_remove_event_type_check(self) -> None: ); INSERT INTO events SELECT * FROM _events_bak; DROP TABLE _events_bak; - - -- Recreate indexes (IF NOT EXISTS is safe if they already exist) CREATE INDEX IF NOT EXISTS idx_events_date ON events(date); CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events(timestamp); CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type); CREATE INDEX IF NOT EXISTS idx_events_repo ON events(repo_name); CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id); CREATE INDEX IF NOT EXISTS idx_events_embedding ON events(embedding_id); - - -- Recreate FTS sync triggers - DROP TRIGGER IF EXISTS events_ai; - CREATE TRIGGER events_ai AFTER INSERT ON events BEGIN + CREATE TRIGGER IF NOT EXISTS events_ai AFTER INSERT ON events BEGIN INSERT INTO events_fts(rowid, content, repo_name) VALUES (new.id, new.content, COALESCE(new.repo_name, '')); END; - DROP TRIGGER IF EXISTS events_ad; - CREATE TRIGGER events_ad AFTER DELETE ON events BEGIN + CREATE TRIGGER IF NOT EXISTS events_ad AFTER DELETE ON events BEGIN INSERT INTO events_fts(events_fts, rowid, content, repo_name) VALUES ('delete', old.id, old.content, COALESCE(old.repo_name, '')); END; diff --git a/devmem/storage/vectors.py b/recall/storage/vectors.py similarity index 100% rename from devmem/storage/vectors.py rename to recall/storage/vectors.py diff --git a/sandbox.sh b/sandbox.sh index d7d08c4..610df1a 100755 --- a/sandbox.sh +++ b/sandbox.sh @@ -1,14 +1,14 @@ #!/usr/bin/env bash -# sandbox.sh — build and run devmem in an isolated Docker container +# sandbox.sh — build and run recall in an isolated Docker container # Usage: # ./sandbox.sh → interactive shell # ./sandbox.sh test → run pytest suite -# ./sandbox.sh init → run `devmem init` and observe output -# ./sandbox.sh → run any devmem command, e.g. ./sandbox.sh "devmem today" +# ./sandbox.sh init → run `recall init` and observe output +# ./sandbox.sh → run any recall command, e.g. ./sandbox.sh "recall today" set -euo pipefail -IMAGE="devmem-sandbox:local" +IMAGE="dev-recall-sandbox:local" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" echo "=== Building sandbox image ===" @@ -30,17 +30,17 @@ case "${1:-shell}" in --cap-drop ALL \ --security-opt no-new-privileges \ "$IMAGE" \ - bash -c "cd /home/devuser/devmem && python -m pytest -v" + bash -c "cd /home/devuser/dev-recall && python -m pytest -v" ;; init) - echo "=== Running 'devmem init' inside sandbox ===" + echo "=== Running 'recall init' inside sandbox ===" # systemd is not available in Docker, so the daemon will fall back to subprocess mode docker run --rm -it \ --network none \ --cap-drop ALL \ --security-opt no-new-privileges \ "$IMAGE" \ - bash -c "devmem init; echo '--- Files written ---'; find ~/.local ~/.config -type f 2>/dev/null | sort" + bash -c "recall init; echo '--- Files written ---'; find ~/.local ~/.config -type f 2>/dev/null | sort" ;; *) # Pass arbitrary command diff --git a/shell/hook.bash b/shell/hook.bash index 39b469f..092d8d8 100644 --- a/shell/hook.bash +++ b/shell/hook.bash @@ -1,46 +1,46 @@ -# DevMem shell hook — source this in ~/.bashrc -# Installed by: devmem init +# Recall shell hook — source this in ~/.bashrc +# Installed by: recall init -__devmem_dir="${DEVMEM_DATA_DIR:-$HOME/.local/share/devmem}" -__devmem_shell_log="$__devmem_dir/shell.tsv" -__devmem_cmd="" -__devmem_start_ms=0 +__devrecall_dir="${DEV_RECALL_DATA_DIR:-$HOME/.local/share/dev-recall}" +__devrecall_shell_log="$__devrecall_dir/shell.tsv" +__devrecall_cmd="" +__devrecall_start_ms=0 # trap DEBUG fires before each command executes -__devmem_debug_trap() { +__devrecall_debug_trap() { # BASH_COMMAND is set to the command string before it runs. # Skip the trap when it fires inside PROMPT_COMMAND itself. - if [[ "$BASH_COMMAND" != "__devmem_precmd"* ]]; then - __devmem_cmd="$BASH_COMMAND" - __devmem_start_ms=$(( $(date +%s) * 1000 )) + if [[ "$BASH_COMMAND" != "__devrecall_precmd"* ]]; then + __devrecall_cmd="$BASH_COMMAND" + __devrecall_start_ms=$(( $(date +%s) * 1000 )) fi } -trap '__devmem_debug_trap' DEBUG +trap '__devrecall_debug_trap' DEBUG -__devmem_precmd() { +__devrecall_precmd() { local exit_code=$? - [[ -z "$__devmem_cmd" ]] && return + [[ -z "$__devrecall_cmd" ]] && return local end_ms end_ms=$(( $(date +%s) * 1000 )) - local dur=$(( end_ms - __devmem_start_ms )) + local dur=$(( end_ms - __devrecall_start_ms )) local ts ts=$(date -u +%Y-%m-%dT%H:%M:%SZ) # Replace tabs with spaces to keep TSV well-formed - local safe_cmd="${__devmem_cmd//$'\t'/ }" + local safe_cmd="${__devrecall_cmd//$'\t'/ }" local safe_cwd="${PWD//$'\t'/ }" printf '%s\t%s\t%s\t%d\t%d\n' \ "$ts" "$safe_cwd" "$safe_cmd" "$exit_code" "$dur" \ - >> "$__devmem_shell_log" 2>/dev/null + >> "$__devrecall_shell_log" 2>/dev/null - __devmem_cmd="" + __devrecall_cmd="" } # Append to PROMPT_COMMAND — preserve existing hooks if [[ -z "$PROMPT_COMMAND" ]]; then - PROMPT_COMMAND="__devmem_precmd" -elif [[ "$PROMPT_COMMAND" != *"__devmem_precmd"* ]]; then - PROMPT_COMMAND="${PROMPT_COMMAND};__devmem_precmd" + PROMPT_COMMAND="__devrecall_precmd" +elif [[ "$PROMPT_COMMAND" != *"__devrecall_precmd"* ]]; then + PROMPT_COMMAND="${PROMPT_COMMAND};__devrecall_precmd" fi diff --git a/shell/hook.fish b/shell/hook.fish index 51f67e8..1d157b6 100644 --- a/shell/hook.fish +++ b/shell/hook.fish @@ -1,17 +1,17 @@ -# DevMem shell hook — source this in ~/.config/fish/config.fish -# Installed by: devmem init +# Recall shell hook — source this in ~/.config/fish/config.fish +# Installed by: recall init -set -g __devmem_dir (set -q DEVMEM_DATA_DIR; and echo $DEVMEM_DATA_DIR; or echo "$HOME/.local/share/devmem") -set -g __devmem_shell_log "$__devmem_dir/shell.tsv" -set -g __devmem_cmd "" -set -g __devmem_start_ms 0 +set -g __devrecall_dir (set -q DEV_RECALL_DATA_DIR; and echo $DEV_RECALL_DATA_DIR; or echo "$HOME/.local/share/dev-recall") +set -g __devrecall_shell_log "$__devrecall_dir/shell.tsv" +set -g __devrecall_cmd "" +set -g __devrecall_start_ms 0 -function __devmem_preexec --on-event fish_preexec - set -g __devmem_cmd $argv[1] - set -g __devmem_start_ms (math (date +%s) \* 1000) +function __devrecall_preexec --on-event fish_preexec + set -g __devrecall_cmd $argv[1] + set -g __devrecall_start_ms (math (date +%s) \* 1000) end -function __devmem_postexec --on-event fish_postexec +function __devrecall_postexec --on-event fish_postexec # fish_postexec passes the command as $argv[1]; exit code is in $status # but $status here reflects the function's own invocation, not the last command. # fish 3.4+ passes exit_code as $argv[2]; use it when available. @@ -21,18 +21,18 @@ function __devmem_postexec --on-event fish_postexec set exit_code $argv[2] end - test -z "$__devmem_cmd"; and return + test -z "$__devrecall_cmd"; and return set end_ms (math (date +%s) \* 1000) - set dur (math $end_ms - $__devmem_start_ms) + set dur (math $end_ms - $__devrecall_start_ms) set ts (date -u +%Y-%m-%dT%H:%M:%SZ) - set safe_cmd (string replace --all \t ' ' "$__devmem_cmd") + set safe_cmd (string replace --all \t ' ' "$__devrecall_cmd") set safe_cwd (string replace --all \t ' ' "$PWD") printf '%s\t%s\t%s\t%d\t%d\n' \ "$ts" "$safe_cwd" "$safe_cmd" "$exit_code" "$dur" \ - >> "$__devmem_shell_log" 2>/dev/null + >> "$__devrecall_shell_log" 2>/dev/null - set -g __devmem_cmd "" + set -g __devrecall_cmd "" end diff --git a/shell/hook.zsh b/shell/hook.zsh index 1523e42..fde9513 100644 --- a/shell/hook.zsh +++ b/shell/hook.zsh @@ -1,35 +1,35 @@ -# DevMem shell hook — source this in ~/.zshrc -# Installed by: devmem init +# Recall shell hook — source this in ~/.zshrc +# Installed by: recall init -__devmem_dir="${DEVMEM_DATA_DIR:-$HOME/.local/share/devmem}" -__devmem_shell_log="$__devmem_dir/shell.tsv" +__devrecall_dir="${DEV_RECALL_DATA_DIR:-$HOME/.local/share/dev-recall}" +__devrecall_shell_log="$__devrecall_dir/shell.tsv" -__devmem_preexec() { - __devmem_cmd="$1" - __devmem_start_ms=$(( $(date +%s) * 1000 )) +__devrecall_preexec() { + __devrecall_cmd="$1" + __devrecall_start_ms=$(( $(date +%s) * 1000 )) } -__devmem_precmd() { +__devrecall_precmd() { local exit_code=$? - [[ -z "$__devmem_cmd" ]] && return + [[ -z "$__devrecall_cmd" ]] && return local end_ms=$(( $(date +%s) * 1000 )) - local dur=$(( end_ms - __devmem_start_ms )) + local dur=$(( end_ms - __devrecall_start_ms )) local ts ts=$(date -u +%Y-%m-%dT%H:%M:%SZ) # Tab-separated: timestamp TAB cwd TAB command TAB exit_code TAB duration_ms # Tabs inside cmd/cwd are replaced with space to avoid ambiguity - local safe_cmd="${__devmem_cmd//$'\t'/ }" + local safe_cmd="${__devrecall_cmd//$'\t'/ }" local safe_cwd="${PWD//$'\t'/ }" printf '%s\t%s\t%s\t%d\t%d\n' \ "$ts" "$safe_cwd" "$safe_cmd" "$exit_code" "$dur" \ - >> "$__devmem_shell_log" 2>/dev/null + >> "$__devrecall_shell_log" 2>/dev/null - unset __devmem_cmd + unset __devrecall_cmd } autoload -Uz add-zsh-hook -add-zsh-hook preexec __devmem_preexec -add-zsh-hook precmd __devmem_precmd +add-zsh-hook preexec __devrecall_preexec +add-zsh-hook precmd __devrecall_precmd diff --git a/tests/test_collectors.py b/tests/test_collectors.py index 5b0a010..e4aca99 100644 --- a/tests/test_collectors.py +++ b/tests/test_collectors.py @@ -7,12 +7,12 @@ import pytest -from devmem.models import EventType, Source +from recall.models import EventType, Source class TestShellCollector: def test_parse_valid_line(self, tmp_path): - from devmem.collectors.shell import ShellCollector + from recall.collectors.shell import ShellCollector events = [] tsv = tmp_path / "shell.tsv" @@ -39,7 +39,7 @@ def test_parse_valid_line(self, tmp_path): assert event.date == "2026-05-21" def test_privacy_filter_sensitive_cmd(self, tmp_path): - from devmem.collectors.shell import ShellCollector + from recall.collectors.shell import ShellCollector events = [] tsv = tmp_path / "shell.tsv" @@ -59,7 +59,7 @@ def test_privacy_filter_sensitive_cmd(self, tmp_path): assert collector._parse_line("2026-05-21T10:00:00Z\t/cwd\tls -la\t0\t10") is not None def test_parse_malformed_line(self, tmp_path): - from devmem.collectors.shell import ShellCollector + from recall.collectors.shell import ShellCollector events = [] tsv = tmp_path / "shell.tsv" @@ -77,7 +77,7 @@ def test_parse_malformed_line(self, tmp_path): assert collector._parse_line("only_one_column") is None def test_incremental_read(self, tmp_path): - from devmem.collectors.shell import ShellCollector + from recall.collectors.shell import ShellCollector events = [] tsv = tmp_path / "shell.tsv" @@ -105,7 +105,7 @@ def test_incremental_read(self, tmp_path): class TestGitCollector: def test_parse_commit_line(self, tmp_path): - from devmem.collectors.git import GitCollector + from recall.collectors.git import GitCollector events = [] tsv = tmp_path / "git.tsv" @@ -130,7 +130,7 @@ def test_parse_commit_line(self, tmp_path): assert event.repo_name == "myapp" def test_parse_branch_line(self, tmp_path): - from devmem.collectors.git import GitCollector + from recall.collectors.git import GitCollector events = [] tsv = tmp_path / "git.tsv" @@ -152,7 +152,7 @@ def test_parse_branch_line(self, tmp_path): assert event.raw_data["new_branch"] == "feature/new-ui" def test_parse_empty_line(self, tmp_path): - from devmem.collectors.git import GitCollector + from recall.collectors.git import GitCollector events = [] tsv = tmp_path / "git.tsv" @@ -169,7 +169,7 @@ def test_parse_empty_line(self, tmp_path): assert collector._parse_line(" ") is None def test_parse_push_line(self, tmp_path): - from devmem.collectors.git import GitCollector + from recall.collectors.git import GitCollector events = [] tsv = tmp_path / "git.tsv" @@ -193,7 +193,7 @@ def test_parse_push_line(self, tmp_path): assert event.repo_name == "myapp" def test_parse_merge_line(self, tmp_path): - from devmem.collectors.git import GitCollector + from recall.collectors.git import GitCollector events = [] tsv = tmp_path / "git.tsv" @@ -217,7 +217,7 @@ def test_parse_merge_line(self, tmp_path): assert event.repo_name == "myapp" def test_parse_squash_merge_line(self, tmp_path): - from devmem.collectors.git import GitCollector + from recall.collectors.git import GitCollector events = [] tsv = tmp_path / "git.tsv" @@ -239,7 +239,7 @@ def test_parse_squash_merge_line(self, tmp_path): class TestCmdCategorizer: def _cat(self, cmd): - from devmem.collectors.shell import _categorize_cmd + from recall.collectors.shell import _categorize_cmd return _categorize_cmd(cmd) def test_test_commands(self): @@ -280,7 +280,7 @@ def test_other_commands(self): assert self._cat("cat README.md") == "other" def test_cmd_category_in_event_metadata(self, tmp_path): - from devmem.collectors.shell import ShellCollector + from recall.collectors.shell import ShellCollector events = [] tsv = tmp_path / "shell.tsv" @@ -305,7 +305,7 @@ def test_cmd_category_in_event_metadata(self, tmp_path): class TestAIChatDedup: def _make_collector(self): - from devmem.collectors.ai_chat import AIChatCollector + from recall.collectors.ai_chat import AIChatCollector store = {} return AIChatCollector( event_callback=lambda e: None, @@ -314,7 +314,7 @@ def _make_collector(self): ) def _make_ai_event(self, timestamp, content, repo_name="repo"): - from devmem.models import Event, EventType, Source + from recall.models import Event, EventType, Source return Event( timestamp=timestamp, date=timestamp[:10], diff --git a/tests/test_enricher.py b/tests/test_enricher.py index 30cb3c6..97bddb1 100644 --- a/tests/test_enricher.py +++ b/tests/test_enricher.py @@ -4,11 +4,11 @@ import pytest -from devmem.models import Event, EventType, Source +from recall.models import Event, EventType, Source def _make_terminal_event(cmd: str, cwd: str = "/home/user/myapp") -> Event: - from devmem.models import build_content + from recall.models import build_content raw = {"cmd": cmd, "cwd": cwd, "exit_code": 0, "duration_ms": 100} return Event( @@ -23,7 +23,7 @@ def _make_terminal_event(cmd: str, cwd: str = "/home/user/myapp") -> Event: class TestEnricher: def _make_enricher(self, cmd_ignore=None, file_ignore=None, repo_ignore=None): - from devmem.processor.enricher import Enricher + from recall.processor.enricher import Enricher repos = {} @@ -58,7 +58,7 @@ def test_content_rebuilt_with_repo_name(self): assert "git status" in result.content def test_language_detected_for_file_save(self): - from devmem.models import build_content + from recall.models import build_content enricher, _ = self._make_enricher() raw = { @@ -80,7 +80,7 @@ def test_language_detected_for_file_save(self): assert result.raw_data.get("language") == "python" def test_sensitive_file_dropped(self): - from devmem.models import build_content + from recall.models import build_content enricher, _ = self._make_enricher(file_ignore=["*.env"]) raw = {"filename": ".env", "file_path": "/home/user/repo/.env", "language": ""} diff --git a/tests/test_query.py b/tests/test_query.py index ccf286b..1a9dec8 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -6,7 +6,7 @@ import pytest -from devmem.query.timeparser import parse_time_expression, _day_range +from recall.query.timeparser import parse_time_expression, _day_range class TestTimeParser: @@ -71,11 +71,11 @@ class TestRetriever: @pytest.fixture def setup(self, tmp_path): - from devmem.storage.db import DB - from devmem.storage.vectors import VectorStore - from devmem.processor.embedder import EmbedderQueue - from devmem.query.retriever import Retriever - from devmem.models import Event, EventType, Source, build_content + from recall.storage.db import DB + from recall.storage.vectors import VectorStore + from recall.processor.embedder import EmbedderQueue + from recall.query.retriever import Retriever + from recall.models import Event, EventType, Source, build_content db = DB(tmp_path / "test.db") vectors = VectorStore(dim=384) diff --git a/tests/test_session.py b/tests/test_session.py index de18510..4811da9 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -4,11 +4,11 @@ import pytest -from devmem.models import Event, EventType, Source +from recall.models import Event, EventType, Source def _make_event(ts: str, repo: str = "/home/user/myapp") -> Event: - from devmem.models import build_content + from recall.models import build_content raw = {"cmd": "git status", "cwd": repo, "exit_code": 0, "duration_ms": 10} return Event( @@ -25,7 +25,7 @@ def _make_event(ts: str, repo: str = "/home/user/myapp") -> Event: class TestSessionDetector: def _make_detector(self, idle_minutes: int = 30): - from devmem.processor.session import SessionDetector + from recall.processor.session import SessionDetector sessions = {} diff --git a/tests/test_storage.py b/tests/test_storage.py index da4eeb2..91de03e 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -7,7 +7,7 @@ import pytest -from devmem.models import Event, EventType, Source +from recall.models import Event, EventType, Source def _make_event( @@ -30,7 +30,7 @@ def _make_event( class TestDB: @pytest.fixture def db(self, tmp_path): - from devmem.storage.db import DB + from recall.storage.db import DB d = DB(tmp_path / "test.db") yield d @@ -135,7 +135,7 @@ class TestDBMigration: def test_migration_preserves_indexes_and_triggers(self, tmp_path): """After migrating an old-style DB, indexes and FTS triggers must still exist.""" import sqlite3 - from devmem.storage.db import DB + from recall.storage.db import DB db_path = tmp_path / "legacy.db" @@ -191,7 +191,7 @@ def test_migration_preserves_indexes_and_triggers(self, tmp_path): assert events[0].content == "old event" # Verify new event types are now accepted (CHECK constraint gone) - from devmem.models import Event, EventType, Source + from recall.models import Event, EventType, Source new_event = Event( timestamp="2026-05-01T00:00:00Z", date="2026-05-01", @@ -227,7 +227,7 @@ def test_migration_preserves_indexes_and_triggers(self, tmp_path): def test_new_db_not_migrated(self, tmp_path): """A fresh database must not trigger the migration path.""" import sqlite3 - from devmem.storage.db import DB + from recall.storage.db import DB db_path = tmp_path / "fresh.db" db = DB(db_path) diff --git a/vscode-extension/package.json b/vscode-extension/package.json index 0668a94..3363460 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -1,9 +1,9 @@ { - "name": "devmem-vscode", - "displayName": "DevMem", - "description": "Developer memory layer — captures workspace activity for devmem", + "name": "recall-vscode", + "displayName": "Recall", + "description": "Developer memory layer — captures workspace activity for dev-recall", "version": "0.1.0", - "publisher": "devmem", + "publisher": "dev-recall", "engines": { "vscode": "^1.85.0" }, @@ -12,17 +12,17 @@ "main": "./out/extension.js", "contributes": { "configuration": { - "title": "DevMem", + "title": "Recall", "properties": { - "devmem.port": { + "recall.port": { "type": "number", "default": 27182, - "description": "Port for the DevMem daemon HTTP server" + "description": "Port for the Recall daemon HTTP server" }, - "devmem.enabled": { + "recall.enabled": { "type": "boolean", "default": true, - "description": "Enable/disable the DevMem VS Code extension" + "description": "Enable/disable the Recall VS Code extension" } } } diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index b2fd7bf..da252f5 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -10,7 +10,7 @@ function getPort(): number { // Try reading from config file first, then VS Code setting const cfgFile = path.join( process.env.HOME || process.env.USERPROFILE || '', - '.config', 'devmem', 'config.json' + '.config', 'dev-recall', 'config.json' ); try { const raw = fs.readFileSync(cfgFile, 'utf-8'); @@ -18,12 +18,12 @@ function getPort(): number { if (cfg.daemon_port) { return cfg.daemon_port; } } catch { /* ignore */ } - const vsCfg = vscode.workspace.getConfiguration('devmem'); + const vsCfg = vscode.workspace.getConfiguration('devrecall'); return vsCfg.get('port', 27182); } function isEnabled(): boolean { - return vscode.workspace.getConfiguration('devmem').get('enabled', true); + return vscode.workspace.getConfiguration('devrecall').get('enabled', true); } // ---------------------------------------------------------------------------