diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 578c8e4..fab7a78 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,9 +9,10 @@ permissions: jobs: unit: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: matrix: + os: [ubuntu-latest, macos-latest, windows-latest] python-version: ["3.11", "3.12"] steps: - uses: actions/checkout@v4 @@ -21,3 +22,13 @@ jobs: - run: python -m compileall -q scripts tests - run: python -m unittest discover -s tests -v - run: python scripts/agent_memory_check.py --skip-state-db + - name: Parse PowerShell adapters + if: runner.os == 'Windows' + shell: powershell + run: | + $scripts = @('scripts/stop-hook.ps1', 'scripts/audit-task.ps1', 'scripts/install-codex-hook.ps1', 'scripts/install-windows.ps1') + foreach ($script in $scripts) { + $errors = $null + [void][System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path $script), [ref]$null, [ref]$errors) + if ($errors.Count) { throw ($errors | Out-String) } + } diff --git a/.gitignore b/.gitignore index 7ccb94f..f26241f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ *.pem *.log __pycache__/ +.venv/ .pytest_cache/ .mypy_cache/ dist/ diff --git a/README.md b/README.md index 5ea024b..5d58d70 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,19 @@ python3 scripts/agent_memory_check.py python3 scripts/agent_memory_doctor.py ``` +### Windows Installation + +要求 Windows 10/11、Python 3.10+、Git,以及 PowerShell 7 或 Windows PowerShell 5.1。Obsidian 可选。 + +```powershell +git clone https://github.com/mcncarl/agent-memory-vault.git +cd agent-memory-vault +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\install-windows.ps1 ` + -MemoryRoot "$HOME\Documents\Agent Memory Vault" +``` + +这里的 `Bypass` 只对当前 PowerShell 进程生效,不会永久修改 Execution Policy。安装器真实初始化 Vault、SQLite/FTS、INDEX、Runtime 并运行 doctor;可用 `-InstallCodexHook -AutoCloseout -InstallAuditTask` 增加原生 Stop Hook 和 Task Scheduler audit。完整说明、诊断和故障排查见 [docs/windows.md](docs/windows.md),代码审计见 [docs/windows-compatibility-audit.md](docs/windows-compatibility-audit.md)。 + 需要让多个 Agent 从固定本机入口调用时,可把 GitHub 仓库作为唯一源码安装到 Runtime;升级时重复运行同一命令即可,私人 TOML 和本机适配器不会被覆盖: ```bash diff --git a/docs/automation.md b/docs/automation.md index ad68330..a9635cb 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -238,6 +238,19 @@ Unload it: launchctl unload ~/Library/LaunchAgents/com.example.agent-memory-vault-audit.plist ``` +## Windows Task Scheduler Fallback + +Windows 使用当前用户的 Limited 权限计划任务,不要求管理员权限: + +```powershell +.\scripts\audit-task.ps1 install +.\scripts\audit-task.ps1 status +.\scripts\audit-task.ps1 run +.\scripts\audit-task.ps1 uninstall +``` + +安装是幂等的,同名任务会被更新。任务直接执行配置的 Python 和 `agent_memory_audit_autorun.py`,不经过 shell 字符串拼接;含空格路径由 Scheduled Task action 分开保存。Codex Stop Hook 的 PowerShell 安装方式见 [windows.md](windows.md)。 + ## Reading Results The latest report is local: diff --git a/docs/windows-compatibility-audit.md b/docs/windows-compatibility-audit.md new file mode 100644 index 0000000..0ecf0f9 --- /dev/null +++ b/docs/windows-compatibility-audit.md @@ -0,0 +1,49 @@ +# Windows Compatibility Audit + +审计基线:`main` 分支,Windows 11、Python 3.11、Windows PowerShell 5.1。结论来自代码检查和本机测试,不根据需求描述推测。 + +## 已跨平台或可直接运行 + +- Markdown Vault、字段模型、SQLite/FTS 索引、搜索、claim 账本、结构检查和 bootstrap 主要使用 Python 标准库与 `pathlib`。 +- `.env` 已由 `agent_memory_env.py` 在 Python 内部解析,业务脚本不要求 shell 先执行 `source .env`。 +- Obsidian 只是打开 Markdown 目录的可选界面,没有运行时耦合。 +- Git 调用以参数数组传给 `subprocess`,没有使用 `shell=True`。 + +## Windows 基线会失败 + +| 范围 | 代码证据 | Windows 影响 | 修复 | +| --- | --- | --- | --- | +| 全局锁 | closeout、audit autorun、Zvec 直接导入 `fcntl` | 模块导入即失败 | 新增 `agent_memory_lock.py`,Unix 使用 `flock`,Windows 使用 `msvcrt.locking` | +| 命令分发 | `memoryctl` 直接执行无扩展名 shebang 文件 | `WinError 193` | 始终通过当前 `sys.executable` 启动目标脚本 | +| 默认路径 | 多处依赖 `$HOME` | Windows 未设置 `HOME` 时产生错误相对路径 | 统一 `expand_path()`,回退到 `USERPROFILE`/`Path.home()` | +| 中文 Git 路径 | Git 使用 UTF-8,`subprocess(text=True)` 使用系统代码页 | Stop Hook/closeout 丢失或误解中文路径 | Git 输出显式按 UTF-8 解码,内部相对路径统一为 POSIX 表示 | +| SQLite 生命周期 | `with sqlite3.connect()` 不会关闭连接 | Windows 临时库和 Vault 无法删除/移动 | 用 `contextlib.closing` 显式关闭连接 | +| 自动化 | 只有 macOS `launchd` 文档 | Windows 无周期 audit | 新增幂等 Task Scheduler 管理脚本 | +| Stop Hook | 文档命令硬编码 `/bin/zsh`、`source`、`python3` | Codex Hook 无法原生运行 | 新增 PowerShell wrapper 和安全合并安装器 | +| 安装 | 只有 Bash 风格命令 | Windows 无一键入口 | 新增 `install-windows.ps1` | +| 测试 | 两项测试调用 `cp -R`,CI 只跑 Ubuntu | Windows 套件失败且无持续验证 | 改为 `shutil.copytree`,CI 扩展到三系统 | + +## 潜在风险但非本次强制启用 + +- Zvec、Torch、EmbeddingGemma 是可选旁路;锁与路径已跨平台,但具体第三方 wheel 是否支持目标 Windows/Python 组合仍取决于其发布物。 +- Windows Task Scheduler 任务采用当前用户、Interactive、Limited 权限;用户未登录时不会运行,这是避免保存密码或要求管理员权限的安全取舍。 +- PowerShell 5.1 与 7 均使用同一脚本语法;CI 额外解析所有 `.ps1`,真实任务注册仍需要 Windows 主机。 + +## 路径与 Shell 结论 + +- Python 核心不再要求手工拼接 `/`;对外 JSON/索引相对路径统一使用 `as_posix()`,本机绝对路径仍由 `Path` 生成。 +- Unix 文档和 shebang 保留;Windows 逻辑隔离在小型 Python 平台适配器和 PowerShell 入口中。 +- macOS `launchd` 未删除或改写;Windows Task Scheduler 是并列适配层。 +- 未发现 `shell=True`、硬编码真实用户名、API key 或 Token。 + +## 最小架构 + +```text +Core Python (Memory / Search / SQLite / Closeout / Audit / Index) + + agent_memory_env.py (path/config adapter) + + agent_memory_lock.py (process-lock adapter) + + Unix shebang / macOS launchd + + Windows PowerShell / Task Scheduler +``` + +不改变 Memory Markdown 格式、SQLite 数据模型或去重决策规则。 diff --git a/docs/windows.md b/docs/windows.md new file mode 100644 index 0000000..90f3064 --- /dev/null +++ b/docs/windows.md @@ -0,0 +1,88 @@ +# Windows 原生使用指南 + +支持 Windows 10/11,优先 PowerShell 7,并兼容 Windows PowerShell 5.1。核心功能需要 Python 3.10+ 和 Git;Obsidian 可选。 + +## 安装 + +在仓库根目录运行: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\install-windows.ps1 ` + -MemoryRoot "$HOME\Documents\Agent Memory Vault" +``` + +`Bypass` 只作用于这一个进程,不会永久降低系统 Execution Policy。安装器会:检查 Python/Git、创建 `.venv`、安装本地 Runtime、初始化 Vault/SQLite/INDEX、运行 check 和 doctor。它不会安装可选的大型向量依赖。 + +可选同时安装 Codex 自动 closeout 和每周 audit: + +```powershell +.\scripts\install-windows.ps1 ` + -MemoryRoot "$HOME\Documents\Agent Memory Vault" ` + -InstallCodexHook -AutoCloseout -InstallAuditTask +``` + +所有路径均作为独立参数传递,含空格和中文路径无需手工转义成短路径。 + +## 日常命令 + +```powershell +$runtime = Join-Path $env:LOCALAPPDATA 'AgentMemoryVault' +$python = Join-Path $runtime '.venv\Scripts\python.exe' +$memoryctl = Join-Path $runtime 'scripts\memoryctl' +& $python $memoryctl --actor codex search "项目状态" --limit 5 +& $python $memoryctl --actor codex closeout --dry-run +& $python $memoryctl --actor codex closeout +& $python $memoryctl --actor human doctor +``` + +Python 会直接加载 Runtime TOML 或仓库 `.env`,PowerShell 不需要模拟 Bash 的 `source .env`。 + +## Codex Stop Hook + +单独安装(保留 `hooks.json` 中其他 Hook): + +```powershell +.\scripts\install-codex-hook.ps1 -AutoCloseout +``` + +Codex 默认启用 Hooks;如果你曾显式关闭过它,请确认 `%USERPROFILE%\.codex\config.toml` 没有设置 `hooks = false`。首次加载新命令时,Codex 会要求审查和信任该 Hook;在 CLI 中使用 `/hooks` 完成确认。 + +```toml +[features] +hooks = true +``` + +PowerShell wrapper 从 stdin 原样接收 Hook JSON,通过当前 Python 运行 `agent_memory_stop_hook.py`。Python 负责加载配置、按 session claim 收尾、更新 SQLite/INDEX、去重和可选 Git commit;失败会写 stderr 并返回非零状态,不会静默吞错。 + +## Task Scheduler audit + +```powershell +.\scripts\audit-task.ps1 install +.\scripts\audit-task.ps1 status +.\scripts\audit-task.ps1 run +.\scripts\audit-task.ps1 uninstall +``` + +默认任务名为 `AgentMemoryVaultAudit`,以当前用户、Limited 权限、交互登录方式运行。重复 `install` 会更新同名任务,不创建副本。自定义 Runtime 时传入 `-RuntimeRoot` 和 `-Python`。 + +## Obsidian + +在 Obsidian 中选择“Open folder as vault”,打开 `-MemoryRoot` 对应目录即可。Obsidian 不是索引或 closeout 的依赖;Markdown 仍是唯一事实源。 + +## Doctor + +```powershell +& $python (Join-Path $runtime 'scripts\agent_memory_doctor.py') +``` + +Windows 额外检查 Python、Git、PowerShell、Codex Stop Hook 和 Scheduled Task。Zvec 未启用时是可接受的警告,不影响 SQLite 搜索。 + +## 常见问题 + +- `running scripts is disabled`:使用上面的单进程 `-ExecutionPolicy Bypass`,不要设置 `Unrestricted`。 +- `python not found`:安装 Python 3.10+ 并启用 `py.exe` 或将 Python 加入 PATH。 +- 路径带空格:使用引号并把路径作为单个参数传入;不要手工拼命令字符串。 +- 中文乱码:使用仓库 PowerShell wrapper;它会设置 Python UTF-8 I/O,Git 路径也按 UTF-8 解码。 +- `.env`:Windows 不需要 dot-source;Python 自动加载。双引号 Windows 路径中的反斜杠也会按字面路径处理。 +- Task Scheduler 不运行:先执行 `status`,再确认用户已登录、Python 和 Runtime 路径仍存在。 +- Obsidian 看不到索引:先运行 `memoryctl index --init --scan --report`,再打开正确 Vault 目录。 diff --git a/scripts/agent_memory_audit.py b/scripts/agent_memory_audit.py index 4d2b670..1ef3431 100755 --- a/scripts/agent_memory_audit.py +++ b/scripts/agent_memory_audit.py @@ -8,29 +8,20 @@ import json import re import sqlite3 +from contextlib import closing from dataclasses import dataclass from pathlib import Path from typing import Any -from agent_memory_env import env_value - - -CONFIG_ROOT = Path( - os.path.expandvars(env_value("CONFIG_ROOT", "$HOME/.config/agent-memory")) -).expanduser().resolve() -STATE_DB = Path( - os.path.expandvars(env_value("STATE_DB", str(CONFIG_ROOT / "state.sqlite"))) -).expanduser().resolve() -AUDIT_DB = Path( - os.path.expandvars(env_value("AUDIT_DB", str(CONFIG_ROOT / "audit_decisions.sqlite"))) -).expanduser().resolve() -INVARIANTS_PATH = Path( - os.path.expandvars(env_value("INVARIANTS", str(CONFIG_ROOT / "config" / "system-invariants.json"))) -).expanduser().resolve() +from agent_memory_env import env_value, expand_path + + +CONFIG_ROOT = expand_path(env_value("CONFIG_ROOT", "$HOME/.config/agent-memory")).resolve() +STATE_DB = expand_path(env_value("STATE_DB", str(CONFIG_ROOT / "state.sqlite"))).resolve() +AUDIT_DB = expand_path(env_value("AUDIT_DB", str(CONFIG_ROOT / "audit_decisions.sqlite"))).resolve() +INVARIANTS_PATH = expand_path(env_value("INVARIANTS", str(CONFIG_ROOT / "config" / "system-invariants.json"))).resolve() REPO_ROOT = Path(__file__).resolve().parents[1] -VAULT_ROOT = Path( - os.path.expandvars(env_value("ROOT", str(REPO_ROOT / "templates" / "vault"))) -).expanduser().resolve() +VAULT_ROOT = expand_path(env_value("ROOT", str(REPO_ROOT / "templates" / "vault"))).resolve() @dataclass @@ -574,7 +565,7 @@ def collect_findings(args: argparse.Namespace) -> list[Finding]: if not STATE_DB.exists(): raise SystemExit(f"missing state db: {STATE_DB}") findings: list[Finding] = [] - with connect_state() as conn: + with closing(connect_state()) as conn, conn: add_stale_findings(conn, findings, args.stale_days) add_open_loop_findings(conn, findings, args.open_loop_threshold, args.risk_threshold) add_duplicate_title_findings(conn, findings) @@ -583,7 +574,7 @@ def collect_findings(args: argparse.Namespace) -> list[Finding]: add_index_parity_findings(conn, findings) add_current_fact_invariant_findings(conn, findings) findings.sort(key=lambda item: (severity_rank(item.severity), item.kind, item.rel_path), reverse=True) - with connect_audit() as audit_conn: + with closing(connect_audit()) as audit_conn, audit_conn: decisions = load_decisions(audit_conn) return apply_decisions(findings, decisions, args.include_acknowledged)[: args.limit] @@ -599,7 +590,7 @@ def record_decision(args: argparse.Namespace) -> dict[str, Any] | None: if not selected: return None decision, finding_id = selected[0] - with connect_audit() as conn: + with closing(connect_audit()) as conn, conn: conn.execute( """ INSERT INTO audit_decisions(finding_id, decision, note, snooze_until, decided_at) @@ -616,7 +607,7 @@ def record_decision(args: argparse.Namespace) -> dict[str, Any] | None: def list_decisions() -> list[dict[str, Any]]: - with connect_audit() as conn: + with closing(connect_audit()) as conn, conn: rows = conn.execute( "SELECT finding_id, decision, note, snooze_until, decided_at FROM audit_decisions ORDER BY decided_at DESC" ).fetchall() diff --git a/scripts/agent_memory_audit_autorun.py b/scripts/agent_memory_audit_autorun.py index 23a385e..5dfcba5 100755 --- a/scripts/agent_memory_audit_autorun.py +++ b/scripts/agent_memory_audit_autorun.py @@ -4,7 +4,6 @@ import argparse import contextlib import datetime as dt -import fcntl import json import os import subprocess @@ -12,22 +11,17 @@ from pathlib import Path from typing import Any -from agent_memory_env import env_value +from agent_memory_env import env_value, expand_path +from agent_memory_lock import try_lock, unlock SCRIPT_ROOT = Path(__file__).resolve().parent -CONFIG_ROOT = Path( - os.path.expandvars(env_value("CONFIG_ROOT", "$HOME/.config/agent-memory")) -).expanduser().resolve() +CONFIG_ROOT = expand_path(env_value("CONFIG_ROOT", "$HOME/.config/agent-memory")).resolve() AUDIT_SCRIPT = SCRIPT_ROOT / "agent_memory_audit.py" DOCTOR_SCRIPT = SCRIPT_ROOT / "agent_memory_doctor.py" PYTHON = env_value("PYTHON", sys.executable) -RUN_LOG = Path( - os.path.expandvars(env_value("AUDIT_RUN_LOG", str(CONFIG_ROOT / "logs" / "audit_runs.jsonl"))) -).expanduser().resolve() -LATEST_REPORT = Path( - os.path.expandvars(env_value("AUDIT_REPORT", str(CONFIG_ROOT / "reports" / "latest-audit.json"))) -).expanduser().resolve() +RUN_LOG = expand_path(env_value("AUDIT_RUN_LOG", str(CONFIG_ROOT / "logs" / "audit_runs.jsonl"))).resolve() +LATEST_REPORT = expand_path(env_value("AUDIT_REPORT", str(CONFIG_ROOT / "reports" / "latest-audit.json"))).resolve() LATEST_DOCTOR_REPORT = CONFIG_ROOT / "reports" / "latest-doctor.json" LOCK_PATH = CONFIG_ROOT / "locks" / "audit.lock" @@ -45,14 +39,16 @@ def audit_lock(): LOCK_PATH.parent.mkdir(parents=True, exist_ok=True) with LOCK_PATH.open("a+", encoding="utf-8") as handle: try: - fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) - except BlockingIOError: + acquired = try_lock(handle) + except OSError: + acquired = False + if not acquired: yield False return try: yield True finally: - fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + unlock(handle) def parse_time(value: str) -> dt.datetime | None: @@ -96,7 +92,8 @@ def run_command(command: list[str], timeout: int = 180) -> dict[str, Any]: if not any(token in key.upper() for token in ("KEY", "TOKEN", "SECRET", "PASSWORD", "COOKIE", "CREDENTIAL")) and "PROXY" not in key.upper() } - env.setdefault("PATH", "/usr/bin:/bin:/usr/sbin:/sbin") + if os.name != "nt": + env.setdefault("PATH", "/usr/bin:/bin:/usr/sbin:/sbin") try: completed = subprocess.run( command, @@ -170,6 +167,8 @@ def write_doctor_report(payload: dict[str, Any]) -> None: def notify(title: str, message: str) -> None: + if sys.platform != "darwin": + return safe_title = title.replace("\\", "\\\\").replace('"', '\\"') safe_message = message.replace("\\", "\\\\").replace('"', '\\"') subprocess.run( diff --git a/scripts/agent_memory_check.py b/scripts/agent_memory_check.py index 4e34737..8a9961e 100755 --- a/scripts/agent_memory_check.py +++ b/scripts/agent_memory_check.py @@ -6,21 +6,20 @@ import os import re import sqlite3 +from contextlib import closing import subprocess import sys from pathlib import Path -from agent_memory_env import env_value +from agent_memory_env import env_value, expand_path REPO_ROOT = Path(__file__).resolve().parents[1] SCRIPT_ROOT = REPO_ROOT / "scripts" DEFAULT_VAULT_ROOT = REPO_ROOT / "templates" / "vault" -VAULT_ROOT = Path(os.path.expandvars(env_value("ROOT", str(DEFAULT_VAULT_ROOT)))).expanduser().resolve() -GIT_ROOT = Path(os.path.expandvars(env_value("GIT_ROOT", str(REPO_ROOT)))).expanduser().resolve() -STATE_DB = Path( - os.path.expandvars(env_value("STATE_DB", "$HOME/.config/agent-memory/state.sqlite")) -).expanduser().resolve() +VAULT_ROOT = expand_path(env_value("ROOT", str(DEFAULT_VAULT_ROOT))).resolve() +GIT_ROOT = expand_path(env_value("GIT_ROOT", str(REPO_ROOT))).resolve() +STATE_DB = expand_path(env_value("STATE_DB", "$HOME/.config/agent-memory/state.sqlite")).resolve() PUBLIC_TEMPLATE_MODE = DEFAULT_VAULT_ROOT.is_dir() and VAULT_ROOT == DEFAULT_VAULT_ROOT.resolve() @@ -182,7 +181,7 @@ def check_state_db() -> tuple[bool, str]: if not STATE_DB.exists(): return False, "missing" try: - with sqlite3.connect(STATE_DB) as conn: + with closing(sqlite3.connect(STATE_DB)) as conn, conn: conn.execute("PRAGMA busy_timeout=10000") rows = conn.execute("SELECT name FROM sqlite_master WHERE type IN ('table', 'virtual table')").fetchall() except sqlite3.Error as exc: @@ -197,7 +196,7 @@ def check_state_db() -> tuple[bool, str]: def normalize_path(raw_path: str) -> Path: - path = Path(os.path.expandvars(raw_path)).expanduser() + path = expand_path(raw_path) if not path.is_absolute(): path = Path.cwd() / path return path.resolve() @@ -275,8 +274,12 @@ def check_public_repo_files() -> list[str]: failures: list[str] = [] forbidden_names = {".env"} forbidden_suffixes = {".sqlite", ".db", ".key", ".pem"} + local_only_dirs = { + ".agent-memory", ".venv", "build", "dist", "local-vault", "model-cache", + "node_modules", "private", "secrets", "tmp", "vector-store", "zvec", + } for path in REPO_ROOT.rglob("*"): - if ".git" in path.parts: + if ".git" in path.parts or any(part in local_only_dirs for part in path.parts): continue if path.is_file() and path.name in forbidden_names: failures.append(f"FORBIDDEN public_file {path}") diff --git a/scripts/agent_memory_claim.py b/scripts/agent_memory_claim.py index 46c14e8..c365910 100755 --- a/scripts/agent_memory_claim.py +++ b/scripts/agent_memory_claim.py @@ -7,19 +7,16 @@ import json import os import sqlite3 +from contextlib import closing from pathlib import Path from typing import Any -from agent_memory_env import env_value +from agent_memory_env import env_value, expand_path RUNTIME_ROOT = Path(__file__).resolve().parents[1] -VAULT_ROOT = Path( - os.path.expandvars(env_value("ROOT", str(RUNTIME_ROOT / "templates" / "vault"))) -).expanduser().resolve() -STATE_DB = Path( - os.path.expandvars(env_value("STATE_DB", "$HOME/.config/agent-memory/state.sqlite")) -).expanduser().resolve() +VAULT_ROOT = expand_path(env_value("ROOT", str(RUNTIME_ROOT / "templates" / "vault"))).resolve() +STATE_DB = expand_path(env_value("STATE_DB", "$HOME/.config/agent-memory/state.sqlite")).resolve() ACTOR_SESSION_ENV_KEYS = { "codex": ("AGENT_MEMORY_SESSION_ID", "CODEX_THREAD_ID"), "claude": ("AGENT_MEMORY_SESSION_ID", "CLAUDE_SESSION_ID", "CLAUDE_CODE_SESSION_ID"), @@ -114,7 +111,7 @@ def record_file_observations(raw_session_id: str, actor: str, paths: list[Path]) return 0 now = utc_now() hashed = session_hash(raw_session_id) - with connect() as conn: + with closing(connect()) as conn, conn: for path, rel_path, digest in rows: conn.execute( """ @@ -157,7 +154,7 @@ def claim_paths(actor: str, raw_session_id: str, paths: list[str]) -> list[dict[ raise ValueError("session id is required; pass --session-id or use a supported host session environment") normalized = [normalize_claim_path(raw) for raw in paths] now = utc_now() - with connect() as conn: + with closing(connect()) as conn, conn: for path, rel_path in normalized: conn.execute( """ @@ -190,7 +187,7 @@ def active_claim_rows(raw_session_id: str, actor: str = "") -> list[dict[str, st query += " AND actor=?" params.append(actor) query += " ORDER BY rel_path" - with connect() as conn: + with closing(connect()) as conn, conn: rows = conn.execute(query, params).fetchall() return [{key: str(row[key] or "") for key in row.keys()} for row in rows] @@ -208,7 +205,7 @@ def parsed_time(value: str) -> dt.datetime | None: def all_active_claim_rows(max_age_hours: float | None = None) -> list[dict[str, str]]: if max_age_hours is not None and max_age_hours <= 0: raise ValueError("max_age_hours must be positive") - with connect() as conn: + with closing(connect()) as conn, conn: rows = conn.execute( """ SELECT session_hash, actor, path, rel_path, status, claimed_at, updated_at @@ -238,7 +235,7 @@ def expire_stale_claims(max_age_hours: float = 24, apply: bool = False) -> tuple return rows, 0 now = utc_now() changed = 0 - with connect() as conn: + with closing(connect()) as conn, conn: for row in rows: cursor = conn.execute( """ @@ -258,7 +255,7 @@ def complete_claim_paths(raw_session_id: str, actor: str, paths: list[Path]) -> if not hashed or not paths: return 0 now = utc_now() - with connect() as conn: + with closing(connect()) as conn, conn: placeholders = ",".join("?" for _ in paths) params: list[str] = [now, now, hashed, actor, *(str(path.resolve()) for path in paths)] cursor = conn.execute( diff --git a/scripts/agent_memory_closeout.py b/scripts/agent_memory_closeout.py index c02f17f..de02c89 100755 --- a/scripts/agent_memory_closeout.py +++ b/scripts/agent_memory_closeout.py @@ -4,7 +4,6 @@ import argparse import contextlib import datetime as dt -import fcntl import hashlib import json import os @@ -18,25 +17,18 @@ from pathlib import Path from typing import Any -from agent_memory_env import env_value +from agent_memory_env import env_value, expand_path, load_config +from agent_memory_lock import try_lock, unlock from agent_memory_claim import active_claim_rows, complete_claim_paths, record_file_observations SCRIPT_ROOT = Path(__file__).resolve().parent TEMPLATE_REPO_ROOT = SCRIPT_ROOT.parent DEFAULT_VAULT_ROOT = TEMPLATE_REPO_ROOT / "templates" / "vault" -VAULT_ROOT = Path( - os.path.expandvars(env_value("ROOT", str(DEFAULT_VAULT_ROOT))) -).expanduser().resolve() -CONFIG_ROOT = Path( - os.path.expandvars(env_value("CONFIG_ROOT", "$HOME/.config/agent-memory")) -).expanduser().resolve() -STATE_DB = Path( - os.path.expandvars(env_value("STATE_DB", str(CONFIG_ROOT / "state.sqlite"))) -).expanduser().resolve() -LOG_PATH = Path( - os.path.expandvars(env_value("CLOSEOUT_LOG", str(CONFIG_ROOT / "logs" / "closeout.jsonl"))) -).expanduser().resolve() +VAULT_ROOT = expand_path(env_value("ROOT", str(DEFAULT_VAULT_ROOT))).resolve() +CONFIG_ROOT = expand_path(env_value("CONFIG_ROOT", "$HOME/.config/agent-memory")).resolve() +STATE_DB = expand_path(env_value("STATE_DB", str(CONFIG_ROOT / "state.sqlite"))).resolve() +LOG_PATH = expand_path(env_value("CLOSEOUT_LOG", str(CONFIG_ROOT / "logs" / "closeout.jsonl"))).resolve() LOCK_PATH = CONFIG_ROOT / "locks" / "closeout.lock" @@ -47,9 +39,7 @@ def find_default_git_root() -> Path: return VAULT_ROOT.parent.resolve() -REPO_ROOT = Path( - os.path.expandvars(env_value("GIT_ROOT", str(find_default_git_root()))) -).expanduser().resolve() +REPO_ROOT = expand_path(env_value("GIT_ROOT", str(find_default_git_root()))).resolve() CHECK_SCRIPT = SCRIPT_ROOT / "agent_memory_check.py" INDEX_SCRIPT = SCRIPT_ROOT / "agent_memory_index.py" @@ -59,6 +49,8 @@ def find_default_git_root() -> Path: AUDIT_AUTORUN_SCRIPT = SCRIPT_ROOT / "agent_memory_audit_autorun.py" PYTHON = env_value("PYTHON", sys.executable) ZVEC_PYTHON = env_value("ZVEC_PYTHON", PYTHON) +SEMANTIC_CONFIG = load_config().get("semantic_retrieval", {}) +SEMANTIC_ENABLED = bool(SEMANTIC_CONFIG.get("enabled", False)) if isinstance(SEMANTIC_CONFIG, dict) else False MEMORY_TOP_LEVELS = {"用户记忆", "项目", "工作流", "决策", "agent"} TOP_LEVEL_MEMORY_FILES = {"AGENTS.md", "INDEX.md", "README.md", "STRUCTURE.md"} @@ -148,15 +140,19 @@ def run_command( timeout: int = 120, env: dict[str, str] | None = None, ) -> dict[str, Any]: + command_env = os.environ.copy() if env is None else env.copy() + command_env.setdefault("PYTHONIOENCODING", "utf-8") started_at = utc_now() started = time.monotonic() try: completed = subprocess.run( command, text=True, + encoding="utf-8", + errors="replace", capture_output=True, timeout=timeout, - env=env, + env=command_env, check=False, ) return { @@ -200,16 +196,17 @@ def closeout_lock(timeout: float = 15.0): deadline = time.monotonic() + max(timeout, 0.0) while True: try: - fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) - break - except BlockingIOError: - if time.monotonic() >= deadline: - raise TimeoutError(f"another memory closeout is still running: {LOCK_PATH}") - time.sleep(0.1) + if try_lock(handle): + break + except OSError: + pass + if time.monotonic() >= deadline: + raise TimeoutError(f"another memory closeout is still running: {LOCK_PATH}") + time.sleep(0.1) try: yield finally: - fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + unlock(handle) def decode_status_line(line: str) -> GitEntry | None: @@ -370,7 +367,7 @@ def explicit_entries(paths: list[str]) -> tuple[list[GitEntry], list[str]]: def relative_to_vault(path: Path) -> str: try: - return str(path.relative_to(VAULT_ROOT)) + return path.relative_to(VAULT_ROOT).as_posix() except ValueError: return str(path) @@ -666,6 +663,8 @@ def run_index(args: argparse.Namespace) -> dict[str, Any]: def run_zvec(files: list[Path], args: argparse.Namespace) -> dict[str, Any]: + if not SEMANTIC_ENABLED: + return {"ok": True, "skipped": True, "detail": "semantic_retrieval_disabled"} if args.skip_zvec: return {"ok": True, "skipped": True, "detail": "skip_zvec"} if args.dry_run: @@ -778,9 +777,12 @@ def unobserved_history_entries(entries: list[GitEntry]) -> list[GitEntry]: if not entries or not STATE_DB.exists(): return entries try: - with sqlite3.connect(STATE_DB, timeout=5) as conn: + conn = sqlite3.connect(STATE_DB, timeout=5) + try: conn.execute("PRAGMA busy_timeout=5000") rows = conn.execute("SELECT path, sha256 FROM memory_file_observations").fetchall() + finally: + conn.close() except sqlite3.Error: return entries observed = {str(Path(str(path)).resolve()): str(digest) for path, digest in rows} @@ -1082,6 +1084,9 @@ def parse_args() -> argparse.Namespace: args.audit_limit = max(args.audit_limit, 1) args.audit_stale_days = max(args.audit_stale_days, 1) args.audit_open_loop_threshold = max(args.audit_open_loop_threshold, 1) + if not SEMANTIC_ENABLED: + args.no_zvec = True + args.skip_zvec = True if args.prewrite: args.dry_run = True return args diff --git a/scripts/agent_memory_doctor.py b/scripts/agent_memory_doctor.py index 1d80c65..eb7a624 100755 --- a/scripts/agent_memory_doctor.py +++ b/scripts/agent_memory_doctor.py @@ -7,25 +7,27 @@ import json import os import re +import shutil import socket import sqlite3 import subprocess +import sys from pathlib import Path from typing import Any from urllib.parse import urlparse -from agent_memory_env import env_value, load_config +from agent_memory_env import env_value, expand_path, load_config VERSION = "2.2" REPO_ROOT = Path(__file__).resolve().parents[1] -VAULT_ROOT = Path(os.path.expandvars(env_value("ROOT", str(REPO_ROOT / "templates" / "vault")))).expanduser().resolve() -GIT_ROOT = Path(os.path.expandvars(env_value("GIT_ROOT", str(REPO_ROOT)))).expanduser().resolve() -CONFIG_ROOT = Path(os.path.expandvars(env_value("CONFIG_ROOT", "$HOME/.config/agent-memory"))).expanduser().resolve() -STATE_DB = Path(os.path.expandvars(env_value("STATE_DB", str(CONFIG_ROOT / "state.sqlite")))).expanduser().resolve() +VAULT_ROOT = expand_path(env_value("ROOT", str(REPO_ROOT / "templates" / "vault"))).resolve() +GIT_ROOT = expand_path(env_value("GIT_ROOT", str(REPO_ROOT))).resolve() +CONFIG_ROOT = expand_path(env_value("CONFIG_ROOT", "$HOME/.config/agent-memory")).resolve() +STATE_DB = expand_path(env_value("STATE_DB", str(CONFIG_ROOT / "state.sqlite"))).resolve() SCRIPT_ROOT = REPO_ROOT / "scripts" -AUDIT_LOG = Path(os.path.expandvars(env_value("AUDIT_RUN_LOG", str(CONFIG_ROOT / "logs" / "audit_runs.jsonl")))).expanduser().resolve() -CLOSEOUT_LOG = Path(os.path.expandvars(env_value("CLOSEOUT_LOG", str(CONFIG_ROOT / "logs" / "closeout.jsonl")))).expanduser().resolve() +AUDIT_LOG = expand_path(env_value("AUDIT_RUN_LOG", str(CONFIG_ROOT / "logs" / "audit_runs.jsonl"))).resolve() +CLOSEOUT_LOG = expand_path(env_value("CLOSEOUT_LOG", str(CONFIG_ROOT / "logs" / "closeout.jsonl"))).resolve() RUNTIME_MANIFEST = CONFIG_ROOT / "config" / "runtime-manifest.json" HOST_CONFIG = load_config().get("host", {}) if not isinstance(HOST_CONFIG, dict): @@ -34,13 +36,11 @@ if not isinstance(SEMANTIC_CONFIG, dict): SEMANTIC_CONFIG = {} SEMANTIC_ENABLED = bool(SEMANTIC_CONFIG.get("enabled", False)) -ZVEC_PYTHON = Path( - os.path.expandvars(env_value("ZVEC_PYTHON", str(CONFIG_ROOT / ".venv" / "bin" / "python"))) -).expanduser() -EMBEDDING_MODEL = Path(os.path.expandvars(env_value("EMBEDDING_MODEL", ""))).expanduser() -MODEL_MANIFEST = Path(os.path.expandvars(env_value("MODEL_MANIFEST", str(CONFIG_ROOT / "models" / "embeddinggemma-300m" / "model-manifest.json")))).expanduser().resolve() +ZVEC_PYTHON = expand_path(env_value("ZVEC_PYTHON", str(CONFIG_ROOT / ".venv" / ("Scripts/python.exe" if os.name == "nt" else "bin/python")))) +EMBEDDING_MODEL = expand_path(env_value("EMBEDDING_MODEL", "")) +MODEL_MANIFEST = expand_path(env_value("MODEL_MANIFEST", str(CONFIG_ROOT / "models" / "embeddinggemma-300m" / "model-manifest.json"))).resolve() MODEL_REVISION = env_value("MODEL_REVISION", "") -DEPENDENCY_LOCK = Path(os.path.expandvars(env_value("DEPENDENCY_LOCK", str(CONFIG_ROOT / "requirements-vector.lock")))).expanduser().resolve() +DEPENDENCY_LOCK = expand_path(env_value("DEPENDENCY_LOCK", str(CONFIG_ROOT / "requirements-vector.lock"))).resolve() REQUIRE_LOCAL_MODEL = env_value("REQUIRE_LOCAL_MODEL", "false").strip().lower() in {"1", "true", "yes", "on"} EXCLUDED_VECTOR_TYPES = {"routing", "directory_index", "template", "agent_case_candidate", "skill_candidate"} EXCLUDED_VECTOR_STATUS = {"archived", "deleted", "obsolete", "outdated", "deprecated", "stale"} @@ -55,7 +55,10 @@ def utc_now() -> str: def run(command: list[str], timeout: int = 300, env: dict[str, str] | None = None) -> dict[str, Any]: try: - completed = subprocess.run(command, text=True, capture_output=True, timeout=timeout, env=env, check=False) + completed = subprocess.run( + command, text=True, encoding="utf-8", errors="replace", capture_output=True, + timeout=timeout, env=env, check=False, + ) except (OSError, subprocess.TimeoutExpired) as exc: return {"ok": False, "returncode": 127, "detail": type(exc).__name__} return {"ok": completed.returncode == 0, "returncode": completed.returncode, "stdout": completed.stdout, "detail": (completed.stderr or completed.stdout).strip()[:500]} @@ -73,6 +76,11 @@ def file_sha256(path: Path) -> str: return digest.hexdigest() +def markdown_sha256(path: Path) -> str: + text = path.read_text(encoding="utf-8", errors="replace") + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + def offline_env() -> dict[str, str]: env = os.environ.copy() for key in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"): @@ -84,7 +92,7 @@ def offline_env() -> dict[str, str]: def verify_model_manifest() -> tuple[bool, dict[str, Any]]: manifest = read_json_object(MODEL_MANIFEST) - root = Path(os.path.expandvars(str(manifest.get("root", "")))).expanduser().resolve() if manifest else Path() + root = expand_path(str(manifest.get("root", ""))).resolve() if manifest else Path() files = manifest.get("files") if isinstance(manifest, dict) else None missing: list[str] = [] size_mismatch: list[str] = [] @@ -248,7 +256,7 @@ def configured_path(name: str) -> Path | None: raw = HOST_CONFIG.get(name) if not isinstance(raw, str) or not raw.strip(): return None - return Path(os.path.expandvars(raw)).expanduser().resolve() + return expand_path(raw).resolve() def local_endpoint_reachable(raw_url: str) -> tuple[bool, dict[str, Any]]: @@ -453,6 +461,7 @@ def collect_checks(allow_dirty_memory: bool = False) -> list[dict[str, Any]]: checks: list[dict[str, Any]] = [] required = [ "agent_memory_index.py", + "agent_memory_lock.py", "agent_memory_search.py", "agent_memory_closeout.py", "agent_memory_check.py", @@ -468,6 +477,23 @@ def collect_checks(allow_dirty_memory: bool = False) -> list[dict[str, Any]]: ] missing = [name for name in required if not (SCRIPT_ROOT / name).is_file()] add(checks, "runtime_files", "fail" if missing else "pass", "Runtime files complete." if not missing else "Runtime files missing.", {"missing": missing}) + version_ok = sys.version_info >= (3, 10) + add(checks, "python_runtime", "pass" if version_ok else "fail", f"Python {sys.version.split()[0]} is active.") + git_ok = bool(shutil.which("git")) + add(checks, "git_runtime", "pass" if git_ok else "fail", "Git is available." if git_ok else "Git was not found in PATH.") + if os.name == "nt": + powershell = shutil.which("pwsh") or shutil.which("powershell") + add(checks, "windows_powershell", "pass" if powershell else "fail", "PowerShell is available." if powershell else "PowerShell was not found.") + hooks_path = Path.home() / ".codex" / "hooks.json" + hooks_text = hooks_path.read_text(encoding="utf-8-sig", errors="replace") if hooks_path.is_file() else "" + hook_ok = "stop-hook.ps1" in hooks_text or "agent_memory_stop_hook.py" in hooks_text + add(checks, "codex_stop_hook", "pass" if hook_ok else "warn", "Codex Stop Hook is configured." if hook_ok else "Codex Stop Hook is not installed.", {"path": str(hooks_path)}) + task_name = str(HOST_CONFIG.get("audit_task_name", "AgentMemoryVaultAudit")) + task_result = run( + [powershell, "-NoProfile", "-Command", f"Get-ScheduledTask -TaskName '{task_name}' -ErrorAction Stop | Out-Null"], + 15, + ) if powershell else {"ok": False} + add(checks, "audit_scheduled_task", "pass" if task_result.get("ok") else "warn", "Windows audit task is installed." if task_result.get("ok") else "Windows audit task is not installed.", {"task_name": task_name}) if REPO_ROOT.resolve() == CONFIG_ROOT.resolve(): manifest = read_json_object(RUNTIME_MANIFEST) expected = manifest.get("files") if isinstance(manifest, dict) else None @@ -526,7 +552,7 @@ def collect_checks(allow_dirty_memory: bool = False) -> list[dict[str, Any]]: db_by_path = {str(row["path"]): row for row in docs} missing_db = sorted(path.relative_to(VAULT_ROOT).as_posix() for raw, path in actual_by_path.items() if raw not in db_by_path) stale_db = sorted(str(row["rel_path"]) for raw, row in db_by_path.items() if raw not in actual_by_path) - mismatch = sorted(str(row["rel_path"]) for raw, row in db_by_path.items() if raw in actual_by_path and file_sha256(actual_by_path[raw]) != str(row["sha256"])) + mismatch = sorted(str(row["rel_path"]) for raw, row in db_by_path.items() if raw in actual_by_path and markdown_sha256(actual_by_path[raw]) != str(row["sha256"])) add(checks, "markdown_sqlite_parity", "pass" if not (missing_db or stale_db or mismatch) else "fail", f"Markdown={len(actual)}, SQLite={len(docs)}.", {"missing": missing_db, "stale": stale_db, "hash_mismatch": mismatch}) fts = {str(row[0]) for row in conn.execute("SELECT DISTINCT path FROM memory_fts")} add(checks, "sqlite_fts_parity", "pass" if fts == set(db_by_path) else "fail", f"FTS covers {len(fts)}/{len(docs)} docs.") diff --git a/scripts/agent_memory_env.py b/scripts/agent_memory_env.py index b385fd3..9807551 100644 --- a/scripts/agent_memory_env.py +++ b/scripts/agent_memory_env.py @@ -51,12 +51,21 @@ "MODEL_REVISION": ("semantic_retrieval", "model_revision"), "DEPENDENCY_LOCK": ("semantic_retrieval", "dependency_lock"), } +DEFAULT_HOME = Path.home() + + +def expand_path(value: str) -> Path: + """Expand user/environment paths consistently on Unix and Windows.""" + if ("$HOME" in value or "${HOME}" in value) and not os.environ.get("HOME"): + home = os.environ.get("USERPROFILE") or str(DEFAULT_HOME) + value = value.replace("${HOME}", home).replace("$HOME", home) + return Path(os.path.expandvars(value)).expanduser() def config_path() -> Path: explicit = os.environ.get("AGENT_MEMORY_CONFIG_FILE", "").strip() if explicit: - return Path(os.path.expandvars(explicit)).expanduser().resolve() + return expand_path(explicit).resolve() return RUNTIME_ROOT / "config" / "agent-memory.toml" @@ -85,8 +94,9 @@ def load_dotenv() -> dict[str, str]: try: parsed = ast.literal_eval(value) except (SyntaxError, ValueError): - continue - value = str(parsed) + value = value[1:-1] + else: + value = str(parsed) payload[key] = value return payload @@ -172,7 +182,7 @@ def local_path_default(name: str) -> str | None: or dotenv.get("AGENT_MEMORY_CONFIG_ROOT", "").strip() ) if configured_root: - root = Path(os.path.expandvars(configured_root)).expanduser() + root = expand_path(configured_root) elif (RUNTIME_ROOT / "config" / "runtime-manifest.json").is_file(): root = RUNTIME_ROOT else: diff --git a/scripts/agent_memory_evolution.py b/scripts/agent_memory_evolution.py index 71b852d..6bb462e 100755 --- a/scripts/agent_memory_evolution.py +++ b/scripts/agent_memory_evolution.py @@ -4,22 +4,20 @@ import argparse import datetime as dt import hashlib -import os import re import sqlite3 +from contextlib import closing from dataclasses import dataclass from pathlib import Path -from agent_memory_env import env_value +from agent_memory_env import env_value, expand_path REPO_ROOT = Path(__file__).resolve().parents[1] DEFAULT_VAULT_ROOT = REPO_ROOT / "templates" / "vault" -VAULT_ROOT = Path(os.path.expandvars(env_value("ROOT", str(DEFAULT_VAULT_ROOT)))).expanduser().resolve() +VAULT_ROOT = expand_path(env_value("ROOT", str(DEFAULT_VAULT_ROOT))).resolve() AGENT_ROOT = VAULT_ROOT / "agent" -STATE_DB = Path( - os.path.expandvars(env_value("STATE_DB", "$HOME/.config/agent-memory/state.sqlite")) -).expanduser().resolve() +STATE_DB = expand_path(env_value("STATE_DB", "$HOME/.config/agent-memory/state.sqlite")).resolve() CASE_CANDIDATE_DIR = AGENT_ROOT / "case-candidates" CASE_DIR = AGENT_ROOT / "cases" @@ -372,7 +370,7 @@ def main() -> int: args.scan = True args.report = True - with connect() as conn: + with closing(connect()) as conn, conn: if args.init or args.scan: init_db(conn) if args.scan: diff --git a/scripts/agent_memory_index.py b/scripts/agent_memory_index.py index 9d17fb8..e22b010 100755 --- a/scripts/agent_memory_index.py +++ b/scripts/agent_memory_index.py @@ -4,22 +4,20 @@ import argparse import datetime as dt import hashlib -import os import re import sqlite3 +from contextlib import closing import time from dataclasses import dataclass from pathlib import Path -from agent_memory_env import env_value +from agent_memory_env import env_value, expand_path REPO_ROOT = Path(__file__).resolve().parents[1] DEFAULT_VAULT_ROOT = REPO_ROOT / "templates" / "vault" -VAULT_ROOT = Path(os.path.expandvars(env_value("ROOT", str(DEFAULT_VAULT_ROOT)))).expanduser().resolve() -STATE_DB = Path( - os.path.expandvars(env_value("STATE_DB", "$HOME/.config/agent-memory/state.sqlite")) -).expanduser().resolve() +VAULT_ROOT = expand_path(env_value("ROOT", str(DEFAULT_VAULT_ROOT))).resolve() +STATE_DB = expand_path(env_value("STATE_DB", "$HOME/.config/agent-memory/state.sqlite")).resolve() DEFAULT_USER_ID = env_value("USER_ID", "demo-user") DEFAULT_AGENT_ID = env_value("AGENT_ID", "shared") DEFAULT_APP_ID = env_value("APP_ID", "agent-memory") @@ -930,7 +928,7 @@ def main() -> int: args.init = True args.scan = True args.report = True - with connect() as conn: + with closing(connect()) as conn, conn: if args.init: init_db(conn) if args.scan: diff --git a/scripts/agent_memory_lock.py b/scripts/agent_memory_lock.py new file mode 100644 index 0000000..1de6b0e --- /dev/null +++ b/scripts/agent_memory_lock.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import os +from typing import IO + + +if os.name == "nt": + import msvcrt +else: + import fcntl + + +def try_lock(handle: IO[str], *, exclusive: bool = True) -> bool: + """Try to acquire a non-blocking one-byte process lock.""" + if os.name == "nt": + handle.seek(0, os.SEEK_END) + if handle.tell() == 0: + handle.write("\0") + handle.flush() + handle.seek(0) + try: + msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) + except OSError: + return False + return True + operation = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH + try: + fcntl.flock(handle.fileno(), operation | fcntl.LOCK_NB) + except BlockingIOError: + return False + return True + + +def unlock(handle: IO[str]) -> None: + if os.name == "nt": + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + else: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) diff --git a/scripts/agent_memory_search.py b/scripts/agent_memory_search.py index 011a145..e4ece0e 100755 --- a/scripts/agent_memory_search.py +++ b/scripts/agent_memory_search.py @@ -8,6 +8,7 @@ import os import re import sqlite3 +from contextlib import closing import subprocess import sys import time @@ -16,16 +17,14 @@ from pathlib import Path from typing import Any -from agent_memory_env import env_value +from agent_memory_env import env_value, expand_path REPO_ROOT = Path(__file__).resolve().parents[1] SCRIPT_ROOT = REPO_ROOT / "scripts" DEFAULT_VAULT_ROOT = REPO_ROOT / "templates" / "vault" -VAULT_ROOT = Path(os.path.expandvars(env_value("ROOT", str(DEFAULT_VAULT_ROOT)))).expanduser().resolve() -STATE_DB = Path( - os.path.expandvars(env_value("STATE_DB", "$HOME/.config/agent-memory/state.sqlite")) -).expanduser().resolve() +VAULT_ROOT = expand_path(env_value("ROOT", str(DEFAULT_VAULT_ROOT))).resolve() +STATE_DB = expand_path(env_value("STATE_DB", "$HOME/.config/agent-memory/state.sqlite")).resolve() ZVEC_SCRIPT = SCRIPT_ROOT / "agent_memory_zvec_index.py" ZVEC_PYTHON = env_value("ZVEC_PYTHON", sys.executable) @@ -267,7 +266,7 @@ def zvec_search(args: argparse.Namespace) -> tuple[list[SearchResult], list[str] if not rows: return [], [] results: list[SearchResult] = [] - with connect() as conn: + with closing(connect()) as conn, conn: for rank, row in enumerate(rows, 1): if not isinstance(row, dict): continue @@ -313,7 +312,7 @@ def rg_search(args: argparse.Namespace) -> tuple[list[SearchResult], list[str]]: return [], [completed.stderr.strip() or f"rg failed: {completed.returncode}"] results: list[SearchResult] = [] seen: set[str] = set() - with connect() as conn: + with closing(connect()) as conn, conn: for line in completed.stdout.splitlines(): parts = line.split(":", 2) if len(parts) != 3: @@ -384,7 +383,7 @@ def result_matches_filters(result: SearchResult, args: argparse.Namespace) -> bo def log_search(query: str, rows: list[SearchResult], duration_ms: int) -> None: try: - with connect() as conn: + with closing(connect()) as conn, conn: memory_index.init_db(conn) digest = hashlib.sha256(query.encode("utf-8")).hexdigest() sources = sorted({source for row in rows for source in row.sources}) @@ -406,7 +405,7 @@ def log_search(query: str, rows: list[SearchResult], duration_ms: int) -> None: def redact_legacy_search_logs() -> dict[str, int]: """Irreversibly remove legacy query text while retaining useful metadata.""" - with connect() as conn: + with closing(connect()) as conn, conn: memory_index.init_db(conn) conn.execute("BEGIN IMMEDIATE") rows = conn.execute( diff --git a/scripts/agent_memory_stop_hook.py b/scripts/agent_memory_stop_hook.py index 23dba3e..4983fe8 100755 --- a/scripts/agent_memory_stop_hook.py +++ b/scripts/agent_memory_stop_hook.py @@ -12,15 +12,15 @@ from pathlib import Path from typing import Any -from agent_memory_env import env_value +from agent_memory_env import env_value, expand_path from agent_memory_claim import active_claim_rows, all_active_claim_rows REPO_ROOT = Path(__file__).resolve().parents[1] -VAULT_ROOT = Path(os.path.expandvars(env_value("ROOT", str(REPO_ROOT / "templates" / "vault")))).expanduser().resolve() -CONFIG_ROOT = Path(os.path.expandvars(env_value("CONFIG_ROOT", "$HOME/.config/agent-memory"))).expanduser().resolve() -STATE_DB = Path(os.path.expandvars(env_value("STATE_DB", str(CONFIG_ROOT / "state.sqlite")))).expanduser().resolve() -LOG_PATH = Path(os.path.expandvars(env_value("CLOSEOUT_LOG", str(CONFIG_ROOT / "logs" / "closeout.jsonl")))).expanduser().resolve() +VAULT_ROOT = expand_path(env_value("ROOT", str(REPO_ROOT / "templates" / "vault"))).resolve() +CONFIG_ROOT = expand_path(env_value("CONFIG_ROOT", "$HOME/.config/agent-memory")).resolve() +STATE_DB = expand_path(env_value("STATE_DB", str(CONFIG_ROOT / "state.sqlite"))).resolve() +LOG_PATH = expand_path(env_value("CLOSEOUT_LOG", str(CONFIG_ROOT / "logs" / "closeout.jsonl"))).resolve() CLOSEOUT_SCRIPT = REPO_ROOT / "scripts" / "agent_memory_closeout.py" AUDIT_AUTORUN = REPO_ROOT / "scripts" / "agent_memory_audit_autorun.py" STAMP_ROOT = CONFIG_ROOT / "hooks" @@ -33,7 +33,7 @@ def default_git_root() -> Path: return VAULT_ROOT.parent.resolve() -GIT_ROOT = Path(os.path.expandvars(env_value("GIT_ROOT", str(default_git_root())))).expanduser().resolve() +GIT_ROOT = expand_path(env_value("GIT_ROOT", str(default_git_root()))).resolve() def parse_args() -> argparse.Namespace: @@ -83,6 +83,8 @@ def run_git(args: list[str], timeout: int = 8) -> subprocess.CompletedProcess[st return subprocess.run( ["git", "-C", str(GIT_ROOT), "-c", "core.quotepath=false", *args], text=True, + encoding="utf-8", + errors="replace", capture_output=True, timeout=timeout, check=False, @@ -167,9 +169,12 @@ def unobserved_paths(paths: list[Path]) -> list[Path]: if not paths or not STATE_DB.exists(): return paths try: - with sqlite3.connect(STATE_DB, timeout=5) as conn: + conn = sqlite3.connect(STATE_DB, timeout=5) + try: conn.execute("PRAGMA busy_timeout=5000") rows = conn.execute("SELECT path, sha256 FROM memory_file_observations").fetchall() + finally: + conn.close() except (OSError, sqlite3.Error): return paths indexed = {str(Path(str(path)).resolve()): str(digest) for path, digest in rows} diff --git a/scripts/agent_memory_zvec_index.py b/scripts/agent_memory_zvec_index.py index 366aed9..5bcc607 100755 --- a/scripts/agent_memory_zvec_index.py +++ b/scripts/agent_memory_zvec_index.py @@ -4,12 +4,10 @@ import argparse import contextlib import datetime as dt -import fcntl import hashlib import importlib.util import json import math -import os import re import sqlite3 import sys @@ -18,22 +16,17 @@ from pathlib import Path from typing import Any -from agent_memory_env import env_value +from agent_memory_env import env_value, expand_path +from agent_memory_lock import try_lock, unlock REPO_ROOT = Path(__file__).resolve().parents[1] SCRIPT_ROOT = REPO_ROOT / "scripts" -STATE_DB = Path( - os.path.expandvars(env_value("STATE_DB", "$HOME/.config/agent-memory/state.sqlite")) -).expanduser().resolve() -DEFAULT_COLLECTION_PATH = Path( - os.path.expandvars( - env_value("VECTOR_DIR", "$HOME/.config/agent-memory/zvec/memory_chunks_embeddinggemma_768") - ) -).expanduser().resolve() -DEFAULT_LOCK_PATH = Path( - os.path.expandvars(env_value("ZVEC_LOCK", "$HOME/.config/agent-memory/locks/zvec.lock")) -).expanduser().resolve() +STATE_DB = expand_path(env_value("STATE_DB", "$HOME/.config/agent-memory/state.sqlite")).resolve() +DEFAULT_COLLECTION_PATH = expand_path( + env_value("VECTOR_DIR", "$HOME/.config/agent-memory/zvec/memory_chunks_embeddinggemma_768") +).resolve() +DEFAULT_LOCK_PATH = expand_path(env_value("ZVEC_LOCK", "$HOME/.config/agent-memory/locks/zvec.lock")).resolve() DEFAULT_MODEL = env_value("EMBEDDING_MODEL", "google/embeddinggemma-300m") DEFAULT_EMBEDDING_DIM = int(env_value("EMBEDDING_DIM", "768")) DEFAULT_DEVICE = env_value("EMBEDDING_DEVICE", "cpu") @@ -109,20 +102,20 @@ def utc_now() -> str: def zvec_lock(exclusive: bool, timeout: float): DEFAULT_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True) with DEFAULT_LOCK_PATH.open("a+", encoding="utf-8") as handle: - operation = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH deadline = time.monotonic() + max(timeout, 0.0) while True: try: - fcntl.flock(handle.fileno(), operation | fcntl.LOCK_NB) - break - except BlockingIOError: - if time.monotonic() >= deadline: - raise TimeoutError(f"zvec lock timed out: {DEFAULT_LOCK_PATH}") - time.sleep(0.05) + if try_lock(handle, exclusive=exclusive): + break + except OSError: + pass + if time.monotonic() >= deadline: + raise TimeoutError(f"zvec lock timed out: {DEFAULT_LOCK_PATH}") + time.sleep(0.05) try: yield finally: - fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + unlock(handle) def sha256_text(text: str) -> str: @@ -392,7 +385,7 @@ def load_changed_docs(conn: sqlite3.Connection, raw_paths: list[str], vault_root docs: list[IndexedDoc] = [] errors: list[str] = [] for raw_path in raw_paths: - path = Path(os.path.expandvars(raw_path)).expanduser() + path = expand_path(raw_path) if not path.is_absolute(): path = Path.cwd() / path path = path.resolve() diff --git a/scripts/audit-task.ps1 b/scripts/audit-task.ps1 new file mode 100644 index 0000000..308f4bf --- /dev/null +++ b/scripts/audit-task.ps1 @@ -0,0 +1,63 @@ +[CmdletBinding()] +param( + [Parameter(Position = 0)] + [ValidateSet('install', 'status', 'run', 'uninstall')] + [string]$Action = 'status', + [string]$TaskName = 'AgentMemoryVaultAudit', + [string]$Python = '', + [string]$RuntimeRoot = '', + [int]$DayOfWeek = 1, + [string]$At = '10:30' +) + +$ErrorActionPreference = 'Stop' +if (-not $RuntimeRoot) { $RuntimeRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) } +$scriptRoot = Join-Path $RuntimeRoot 'scripts' +$auditScript = Join-Path $scriptRoot 'agent_memory_audit_autorun.py' +if (-not $Python) { + $candidate = Join-Path $RuntimeRoot '.venv\Scripts\python.exe' + if (Test-Path -LiteralPath $candidate) { $Python = $candidate } + else { + $command = Get-Command python.exe -ErrorAction SilentlyContinue + if (-not $command) { $command = Get-Command py.exe -ErrorAction SilentlyContinue } + if (-not $command) { throw 'Python 3 was not found.' } + $Python = $command.Source + } +} + +switch ($Action) { + 'install' { + if (-not (Test-Path -LiteralPath $auditScript)) { throw "Audit script was not found: $auditScript" } + $days = @('Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday') + if ($DayOfWeek -lt 0 -or $DayOfWeek -gt 6) { throw 'DayOfWeek must be between 0 (Sunday) and 6 (Saturday).' } + $taskAction = New-ScheduledTaskAction -Execute $Python ` + -Argument ('"{0}" --reason task-scheduler --json' -f $auditScript) ` + -WorkingDirectory $RuntimeRoot + $trigger = New-ScheduledTaskTrigger -Weekly -WeeksInterval 1 -DaysOfWeek $days[$DayOfWeek] -At $At + $principal = New-ScheduledTaskPrincipal -UserId ([System.Security.Principal.WindowsIdentity]::GetCurrent().Name) ` + -LogonType Interactive -RunLevel Limited + $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 15) + Register-ScheduledTask -TaskName $TaskName -Action $taskAction -Trigger $trigger ` + -Principal $principal -Settings $settings -Description 'Agent Memory Vault weekly audit' -Force | Out-Null + Write-Output "[OK] installed task=$TaskName" + } + 'status' { + $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if (-not $task) { Write-Output "[WARN] task_missing name=$TaskName"; exit 1 } + $info = Get-ScheduledTaskInfo -TaskName $TaskName + Write-Output "[OK] task=$TaskName state=$($task.State) last_result=$($info.LastTaskResult) next_run=$($info.NextRunTime)" + } + 'run' { + if (-not (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue)) { throw "Scheduled task not found: $TaskName" } + Start-ScheduledTask -TaskName $TaskName + Write-Output "[OK] started task=$TaskName" + } + 'uninstall' { + if (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue) { + Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false + Write-Output "[OK] uninstalled task=$TaskName" + } else { + Write-Output "[OK] task_already_absent name=$TaskName" + } + } +} diff --git a/scripts/bootstrap.py b/scripts/bootstrap.py index 2cd99db..1520a5a 100755 --- a/scripts/bootstrap.py +++ b/scripts/bootstrap.py @@ -4,23 +4,22 @@ import argparse import os import shutil +import sys from pathlib import Path +from agent_memory_env import expand_path + REPO_ROOT = Path(__file__).resolve().parents[1] TEMPLATE_ROOT = REPO_ROOT / "templates" / "vault" -def expand_path(raw: str) -> Path: - return Path(os.path.expandvars(raw)).expanduser().resolve() - - def replacements(args: argparse.Namespace) -> dict[str, str]: return { "{{USER_ID}}": args.user_id, "{{AGENT_ID}}": args.agent_id, "{{APP_ID}}": args.app_id, - "{{STATE_DB}}": str(expand_path(args.state_db)), + "{{STATE_DB}}": str(expand_path(args.state_db).resolve()), } @@ -57,14 +56,14 @@ def write_env(args: argparse.Namespace, memory_root: Path) -> None: if env_path.exists() and not args.overwrite_env: print(f"SKIP env_exists {env_path}") return - config_root = expand_path(args.config_root) - git_root = expand_path(args.git_root) if args.git_root else memory_root + config_root = expand_path(args.config_root).resolve() + git_root = expand_path(args.git_root).resolve() if args.git_root else memory_root content = "\n".join( [ f"AGENT_MEMORY_ROOT={memory_root}", f"AGENT_MEMORY_GIT_ROOT={git_root}", f"AGENT_MEMORY_CONFIG_ROOT={config_root}", - f"AGENT_MEMORY_STATE_DB={expand_path(args.state_db)}", + f"AGENT_MEMORY_STATE_DB={expand_path(args.state_db).resolve()}", f"AGENT_MEMORY_USER_ID={args.user_id}", f"AGENT_MEMORY_AGENT_ID={args.agent_id}", f"AGENT_MEMORY_APP_ID={args.app_id}", @@ -115,7 +114,7 @@ def main() -> int: if not TEMPLATE_ROOT.is_dir(): raise SystemExit(f"Template root not found: {TEMPLATE_ROOT}") - memory_root = expand_path(args.memory_root) + memory_root = expand_path(args.memory_root).resolve() memory_root.mkdir(parents=True, exist_ok=True) created, skipped = copy_template(memory_root, replacements(args), args.overwrite) print(f"memory_root={memory_root}") @@ -126,16 +125,20 @@ def main() -> int: write_env(args, memory_root) print("next_commands:") - print(" source .env") - print(" git -C \"$AGENT_MEMORY_GIT_ROOT\" init # optional, if the vault is not already in a git repo") - print(" python3 scripts/agent_memory_evolution.py --init --scan --report") - print(" python3 scripts/agent_memory_index.py --init --scan --report") - print(" python3 scripts/agent_memory_closeout.py --dry-run") - print(" python3 scripts/agent_memory_check.py") - print(" python3 scripts/agent_memory_doctor.py") + if os.name == "nt": + print(" # .env is loaded by Python; no PowerShell import is required") + print(f' git -C "{memory_root}" init # optional, if the vault is not already in a git repo') + else: + print(" source .env") + print(" git -C \"$AGENT_MEMORY_GIT_ROOT\" init # optional, if the vault is not already in a git repo") + print(f" {sys.executable} scripts/agent_memory_evolution.py --init --scan --report") + print(f" {sys.executable} scripts/agent_memory_index.py --init --scan --report") + print(f" {sys.executable} scripts/agent_memory_closeout.py --dry-run") + print(f" {sys.executable} scripts/agent_memory_check.py") + print(f" {sys.executable} scripts/agent_memory_doctor.py") print("optional_semantic_retrieval:") - print(" python3 -m pip install -r requirements-vector.lock") - print(" python3 scripts/agent_memory_zvec_index.py --init --scan --prune") + print(f" {sys.executable} -m pip install -r requirements-vector.lock") + print(f" {sys.executable} scripts/agent_memory_zvec_index.py --init --scan --prune") return 0 diff --git a/scripts/install-codex-hook.ps1 b/scripts/install-codex-hook.ps1 new file mode 100644 index 0000000..e352db3 --- /dev/null +++ b/scripts/install-codex-hook.ps1 @@ -0,0 +1,35 @@ +[CmdletBinding()] +param( + [string]$RuntimeRoot = (Join-Path $env:LOCALAPPDATA 'AgentMemoryVault'), + [string]$HooksPath = (Join-Path $env:USERPROFILE '.codex\hooks.json'), + [switch]$AutoCloseout +) + +$ErrorActionPreference = 'Stop' +$wrapper = Join-Path $RuntimeRoot 'scripts\stop-hook.ps1' +if (-not (Test-Path -LiteralPath $wrapper)) { throw "Stop Hook wrapper was not found: $wrapper" } +$hooksDirectory = Split-Path -Parent $HooksPath +New-Item -ItemType Directory -Force -Path $hooksDirectory | Out-Null + +if (Test-Path -LiteralPath $HooksPath) { + try { $root = Get-Content -Raw -LiteralPath $HooksPath | ConvertFrom-Json } + catch { throw "Invalid Codex hooks JSON: $HooksPath" } +} else { + $root = [pscustomobject]@{} +} +if (-not $root.PSObject.Properties['hooks']) { $root | Add-Member -NotePropertyName hooks -NotePropertyValue ([pscustomobject]@{}) } +if (-not $root.hooks.PSObject.Properties['Stop']) { $root.hooks | Add-Member -NotePropertyName Stop -NotePropertyValue @() } + +$mode = if ($AutoCloseout) { ' -AutoCloseout' } else { '' } +$command = 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "{0}" -Actor codex -Protocol codex{1}' -f $wrapper, $mode +$existing = $root.hooks.Stop | ConvertTo-Json -Depth 20 -Compress +if (-not $existing -or $existing -notlike '*stop-hook.ps1*') { + $entry = [pscustomobject]@{ + hooks = @([pscustomobject]@{ type = 'command'; command = $command; timeout = $(if ($AutoCloseout) { 320 } else { 20 }) }) + } + $root.hooks.Stop = @($root.hooks.Stop) + @($entry) +} +$json = $root | ConvertTo-Json -Depth 20 +[System.IO.File]::WriteAllText($HooksPath, $json + [Environment]::NewLine, [System.Text.UTF8Encoding]::new($false)) +Write-Output "[OK] Codex Stop Hook installed: $HooksPath" +Write-Output '[WARN] Confirm that [features] hooks = true is enabled in ~/.codex/config.toml.' diff --git a/scripts/install-windows.ps1 b/scripts/install-windows.ps1 new file mode 100644 index 0000000..561e767 --- /dev/null +++ b/scripts/install-windows.ps1 @@ -0,0 +1,89 @@ +[CmdletBinding()] +param( + [string]$MemoryRoot = (Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'Agent Memory Vault'), + [string]$ConfigRoot = (Join-Path $env:LOCALAPPDATA 'AgentMemoryVault'), + [string]$UserId = 'demo-user', + [string]$AgentId = 'shared', + [string]$AppId = 'agent-memory', + [switch]$InstallCodexHook, + [switch]$AutoCloseout, + [switch]$InstallAuditTask +) + +$ErrorActionPreference = 'Stop' +$env:PYTHONUTF8 = '1' +$env:PYTHONIOENCODING = 'utf-8' +$repoRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +$venvRoot = Join-Path $ConfigRoot '.venv' +$venvPython = Join-Path $venvRoot 'Scripts\python.exe' + +function Invoke-Checked([string]$Executable, [string[]]$Arguments, [string]$Label) { + & $Executable @Arguments + if ($LASTEXITCODE -ne 0) { throw "$Label failed with exit code $LASTEXITCODE" } +} + +$git = Get-Command git.exe -ErrorAction SilentlyContinue +if (-not $git) { throw 'Git was not found in PATH.' } +$python = Get-Command py.exe -ErrorAction SilentlyContinue +$prefix = @('-3') +if (-not $python) { $python = Get-Command python.exe -ErrorAction SilentlyContinue; $prefix = @() } +if (-not $python) { throw 'Python 3 was not found in PATH.' } +$version = & $python.Source @prefix -c 'import sys; print(sys.version_info.major * 100 + sys.version_info.minor); raise SystemExit(sys.version_info < (3, 10))' +if ($LASTEXITCODE -ne 0) { throw "Python 3.10 or newer is required; detected version code $version" } + +New-Item -ItemType Directory -Force -Path $ConfigRoot | Out-Null +if (-not (Test-Path -LiteralPath $venvPython)) { + Invoke-Checked $python.Source ($prefix + @('-m', 'venv', $venvRoot)) 'virtual environment creation' +} +Invoke-Checked $venvPython @((Join-Path $repoRoot 'scripts\install_runtime.py'), '--config-root', $ConfigRoot) 'runtime installation' + +$stateDb = Join-Path $ConfigRoot 'state.sqlite' +Invoke-Checked $venvPython @( + (Join-Path $repoRoot 'scripts\bootstrap.py'), '--memory-root', $MemoryRoot, + '--config-root', $ConfigRoot, '--state-db', $stateDb, '--git-root', $MemoryRoot, + '--user-id', $UserId, '--agent-id', $AgentId, '--app-id', $AppId +) 'vault bootstrap' +if (-not (Test-Path -LiteralPath (Join-Path $MemoryRoot '.git'))) { & $git.Source -C $MemoryRoot init -q } + +function TomlPath([string]$Path) { return $Path.Replace('\', '/') } +$configDir = Join-Path $ConfigRoot 'config' +New-Item -ItemType Directory -Force -Path $configDir | Out-Null +$configPath = Join-Path $configDir 'agent-memory.toml' +$toml = @" +memory_root = "$(TomlPath $MemoryRoot)" +git_root = "$(TomlPath $MemoryRoot)" +config_root = "$(TomlPath $ConfigRoot)" +state_db = "$(TomlPath $stateDb)" +audit_db = "$(TomlPath (Join-Path $ConfigRoot 'audit_decisions.sqlite'))" +closeout_log = "$(TomlPath (Join-Path $ConfigRoot 'logs\closeout.jsonl'))" +audit_run_log = "$(TomlPath (Join-Path $ConfigRoot 'logs\audit_runs.jsonl'))" +audit_report = "$(TomlPath (Join-Path $ConfigRoot 'reports\latest-audit.json'))" +python = "$(TomlPath $venvPython)" +user_id = "$UserId" +agent_id = "$AgentId" +app_id = "$AppId" + +[semantic_retrieval] +enabled = false +python = "$(TomlPath $venvPython)" +"@ +[System.IO.File]::WriteAllText($configPath, $toml, [System.Text.UTF8Encoding]::new($false)) +$env:AGENT_MEMORY_CONFIG_FILE = $configPath +$runtimeScripts = Join-Path $ConfigRoot 'scripts' +Invoke-Checked $venvPython @((Join-Path $runtimeScripts 'agent_memory_evolution.py'), '--init', '--scan', '--report') 'evolution initialization' +Invoke-Checked $venvPython @((Join-Path $runtimeScripts 'agent_memory_index.py'), '--init', '--scan', '--report') 'SQLite index initialization' +Invoke-Checked $venvPython @((Join-Path $runtimeScripts 'agent_memory_check.py')) 'structure check' +Invoke-Checked $venvPython @((Join-Path $runtimeScripts 'agent_memory_doctor.py')) 'doctor' + +if ($InstallCodexHook) { + $hookArgs = @('-RuntimeRoot', $ConfigRoot) + if ($AutoCloseout) { $hookArgs += '-AutoCloseout' } + & (Join-Path $repoRoot 'scripts\install-codex-hook.ps1') @hookArgs +} +if ($InstallAuditTask) { + & (Join-Path $ConfigRoot 'scripts\audit-task.ps1') install -RuntimeRoot $ConfigRoot -Python $venvPython +} +Write-Output "[OK] Windows installation complete" +Write-Output "Vault: $MemoryRoot" +Write-Output "Runtime: $ConfigRoot" +Write-Output 'Open the Vault path in Obsidian if you want the optional visual editor.' diff --git a/scripts/install_runtime.py b/scripts/install_runtime.py index 4026245..1bab0d1 100755 --- a/scripts/install_runtime.py +++ b/scripts/install_runtime.py @@ -23,13 +23,16 @@ "agent_memory_env.py", "agent_memory_evolution.py", "agent_memory_index.py", + "agent_memory_lock.py", "agent_memory_retrieval_benchmark.py", "agent_memory_search.py", "agent_memory_session_hook.py", "agent_memory_stop_hook.py", "agent_memory_zvec_index.py", + "audit-task.ps1", "bootstrap.py", "install_runtime.py", + "stop-hook.ps1", "memoryctl", ) SUPPORT_FILES = ("requirements-vector.lock",) diff --git a/scripts/memoryctl b/scripts/memoryctl index 73a1306..c4386bb 100755 --- a/scripts/memoryctl +++ b/scripts/memoryctl @@ -8,7 +8,7 @@ import subprocess import sys from pathlib import Path -from agent_memory_env import env_value +from agent_memory_env import env_value, expand_path SCRIPT_ROOT = Path(__file__).resolve().parent @@ -54,7 +54,7 @@ def parse_args() -> argparse.Namespace: def main() -> int: args = parse_args() if args.command == "version": - config_root = Path(os.path.expandvars(env_value("CONFIG_ROOT", "$HOME/.config/agent-memory"))).expanduser() + config_root = expand_path(env_value("CONFIG_ROOT", "$HOME/.config/agent-memory")) manifest_path = config_root / "config" / "runtime-manifest.json" try: payload = json.loads(manifest_path.read_text(encoding="utf-8")) @@ -111,7 +111,7 @@ def main() -> int: forwarded.extend(["--agent-scope", args.actor]) env = os.environ.copy() env["MEMORY_ACTOR"] = args.actor - return subprocess.run([str(target), *forwarded], env=env, check=False).returncode + return subprocess.run([sys.executable, str(target), *forwarded], env=env, check=False).returncode if __name__ == "__main__": diff --git a/scripts/stop-hook.ps1 b/scripts/stop-hook.ps1 new file mode 100644 index 0000000..979756c --- /dev/null +++ b/scripts/stop-hook.ps1 @@ -0,0 +1,46 @@ +[CmdletBinding()] +param( + [ValidateSet('codex', 'claude')] + [string]$Actor = 'codex', + [ValidateSet('codex', 'claude')] + [string]$Protocol = 'codex', + [switch]$AutoCloseout, + [int]$Timeout = 300, + [string]$Python = '' +) + +$ErrorActionPreference = 'Stop' +$env:PYTHONUTF8 = '1' +$env:PYTHONIOENCODING = 'utf-8' +$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$hookScript = Join-Path $scriptRoot 'agent_memory_stop_hook.py' + +if (-not $Python) { + $venvPython = Join-Path (Split-Path -Parent $scriptRoot) '.venv\Scripts\python.exe' + if (Test-Path -LiteralPath $venvPython) { + $Python = $venvPython + } else { + $command = Get-Command python.exe -ErrorAction SilentlyContinue + if (-not $command) { $command = Get-Command py.exe -ErrorAction SilentlyContinue } + if (-not $command) { throw 'Python 3 was not found. Run scripts\install-windows.ps1 first.' } + $Python = $command.Source + } +} +if (-not (Test-Path -LiteralPath $hookScript)) { + throw "Stop Hook implementation was not found: $hookScript" +} + +$arguments = @($hookScript, '--actor', $Actor, '--protocol', $Protocol, '--timeout', $Timeout) +if ($AutoCloseout) { $arguments += '--auto-closeout' } +$payload = [Console]::In.ReadToEnd() +try { + if ($payload) { + $payload | & $Python @arguments + } else { + & $Python @arguments + } + exit $LASTEXITCODE +} catch { + Write-Error "Agent Memory Stop Hook failed: $($_.Exception.Message)" + exit 2 +} diff --git a/tests/test_agent_memory_env.py b/tests/test_agent_memory_env.py index 691a320..0cce2d0 100644 --- a/tests/test_agent_memory_env.py +++ b/tests/test_agent_memory_env.py @@ -87,7 +87,7 @@ def test_repo_dotenv_is_loaded_without_shell_export(self) -> None: self.assertEqual(env_value("ROOT", "/default"), "/dotenv/vault") self.assertEqual( env_value("STATE_DB", "/default/state.sqlite"), - "$HOME/.config/dotenv-memory/state.sqlite", + str(agent_memory_env.DEFAULT_HOME / ".config" / "dotenv-memory" / "state.sqlite"), ) reset_config_cache() diff --git a/tests/test_agent_scope.py b/tests/test_agent_scope.py index aa4d016..540e399 100644 --- a/tests/test_agent_scope.py +++ b/tests/test_agent_scope.py @@ -3,6 +3,7 @@ import json import os import sqlite3 +from contextlib import closing import subprocess import sys import tempfile @@ -68,7 +69,7 @@ def test_missing_scope_defaults_shared_without_overwriting_agent_id(self) -> Non ) self.assertEqual(indexed.returncode, 0, indexed.stdout + indexed.stderr) - with sqlite3.connect(state_db) as conn: + with closing(sqlite3.connect(state_db)) as conn, conn: row = conn.execute( "SELECT agent_id, agent_scope FROM memory_docs WHERE rel_path='工作流/shared.md'" ).fetchone() diff --git a/tests/test_closeout_git_history.py b/tests/test_closeout_git_history.py index fcd362c..fb4ace8 100644 --- a/tests/test_closeout_git_history.py +++ b/tests/test_closeout_git_history.py @@ -3,6 +3,7 @@ import importlib.util import hashlib import sqlite3 +from contextlib import closing import subprocess import sys import tempfile @@ -186,7 +187,7 @@ def test_history_requires_a_matching_closeout_observation(self) -> None: path=note, ) self.module.STATE_DB = self.vault.parent / "state.sqlite" - with sqlite3.connect(self.module.STATE_DB) as conn: + with closing(sqlite3.connect(self.module.STATE_DB)) as conn, conn: conn.execute( "CREATE TABLE memory_file_observations (path TEXT PRIMARY KEY, sha256 TEXT NOT NULL)" ) @@ -194,7 +195,7 @@ def test_history_requires_a_matching_closeout_observation(self) -> None: self.assertEqual(self.module.unobserved_history_entries([entry]), [entry]) digest = hashlib.sha256(note.read_bytes()).hexdigest() - with sqlite3.connect(self.module.STATE_DB) as conn: + with closing(sqlite3.connect(self.module.STATE_DB)) as conn, conn: conn.execute( "INSERT INTO memory_file_observations(path, sha256) VALUES (?, ?)", (str(note), digest), diff --git a/tests/test_cross_platform_runtime.py b/tests/test_cross_platform_runtime.py new file mode 100644 index 0000000..1f95a07 --- /dev/null +++ b/tests/test_cross_platform_runtime.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from scripts.agent_memory_env import expand_path +from scripts.agent_memory_lock import try_lock, unlock + + +class CrossPlatformRuntimeTests(unittest.TestCase): + def test_home_and_space_path_expansion(self) -> None: + with tempfile.TemporaryDirectory(prefix="Agent Memory ") as raw_tmp: + home = Path(raw_tmp) + with mock.patch.dict("os.environ", {"USERPROFILE": str(home)}, clear=True): + self.assertEqual(expand_path("$HOME/Vault With Spaces"), home / "Vault With Spaces") + + def test_process_lock_is_exclusive_and_reusable(self) -> None: + with tempfile.TemporaryDirectory() as raw_tmp: + lock_path = Path(raw_tmp) / "runtime.lock" + with lock_path.open("a+", encoding="utf-8") as first, lock_path.open("a+", encoding="utf-8") as second: + self.assertTrue(try_lock(first)) + self.assertFalse(try_lock(second)) + unlock(first) + self.assertTrue(try_lock(second)) + unlock(second) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_current_fact_invariants.py b/tests/test_current_fact_invariants.py index a55e5e9..e20c2ff 100644 --- a/tests/test_current_fact_invariants.py +++ b/tests/test_current_fact_invariants.py @@ -2,6 +2,7 @@ import json import os +import shutil import subprocess import sys import tempfile @@ -20,7 +21,7 @@ def test_audit_detects_and_clears_current_summary_conflicts(self) -> None: tmp = Path(raw_tmp) vault = tmp / "vault" runtime = tmp / "runtime" - subprocess.run(["cp", "-R", str(TEMPLATE), str(vault)], check=True) + shutil.copytree(TEMPLATE, vault) runtime.joinpath("config").mkdir(parents=True) invariants = runtime / "config" / "system-invariants.json" invariants.write_text( @@ -49,11 +50,11 @@ def test_audit_detects_and_clears_current_summary_conflicts(self) -> None: config.write_text( "\n".join( [ - f'memory_root = "{vault}"', - f'config_root = "{runtime}"', - f'state_db = "{runtime / "state.sqlite"}"', - f'audit_db = "{runtime / "audit.sqlite"}"', - f'invariants_file = "{invariants}"', + f'memory_root = "{vault.as_posix()}"', + f'config_root = "{runtime.as_posix()}"', + f'state_db = "{(runtime / "state.sqlite").as_posix()}"', + f'audit_db = "{(runtime / "audit.sqlite").as_posix()}"', + f'invariants_file = "{invariants.as_posix()}"', ] ) + "\n", diff --git a/tests/test_durability_guards.py b/tests/test_durability_guards.py index e692692..574e8b8 100644 --- a/tests/test_durability_guards.py +++ b/tests/test_durability_guards.py @@ -2,6 +2,7 @@ import datetime as dt import sqlite3 +from contextlib import closing import subprocess import sys import tempfile @@ -152,7 +153,7 @@ def test_stale_claim_preview_and_expiry_are_explicit(self) -> None: state_db = tmp / "state.sqlite" with mock.patch.object(claim, "VAULT_ROOT", vault), mock.patch.object(claim, "STATE_DB", state_db): claim.claim_paths("codex", "old-session", [str(note)]) - with sqlite3.connect(state_db) as conn: + with closing(sqlite3.connect(state_db)) as conn, conn: conn.execute( "UPDATE memory_session_claims SET updated_at='2000-01-01T00:00:00+00:00'" ) @@ -160,7 +161,7 @@ def test_stale_claim_preview_and_expiry_are_explicit(self) -> None: self.assertEqual(claim.all_active_claim_rows(max_age_hours=24), []) rows, applied = claim.expire_stale_claims(24, apply=False) self.assertEqual((len(rows), applied), (1, 0)) - with sqlite3.connect(state_db) as conn: + with closing(sqlite3.connect(state_db)) as conn, conn: conn.execute( "UPDATE memory_session_claims SET updated_at=?", (claim.utc_now(),), @@ -169,14 +170,14 @@ def test_stale_claim_preview_and_expiry_are_explicit(self) -> None: with mock.patch.object(claim, "stale_active_claim_rows", return_value=rows): _, applied = claim.expire_stale_claims(24, apply=True) self.assertEqual(applied, 0) - with sqlite3.connect(state_db) as conn: + with closing(sqlite3.connect(state_db)) as conn, conn: conn.execute( "UPDATE memory_session_claims SET updated_at='2000-01-01T00:00:00+00:00'" ) conn.commit() rows, applied = claim.expire_stale_claims(24, apply=True) self.assertEqual((len(rows), applied), (1, 1)) - with sqlite3.connect(state_db) as conn: + with closing(sqlite3.connect(state_db)) as conn, conn: status = conn.execute("SELECT status FROM memory_session_claims").fetchone()[0] self.assertEqual(status, "expired") diff --git a/tests/test_search_log_redaction.py b/tests/test_search_log_redaction.py index be9901a..2de93dd 100644 --- a/tests/test_search_log_redaction.py +++ b/tests/test_search_log_redaction.py @@ -3,6 +3,7 @@ import json import os import sqlite3 +from contextlib import closing import subprocess import sys import tempfile @@ -21,7 +22,8 @@ def test_legacy_query_text_is_replaced_with_hash_metadata(self) -> None: state_db = tmp / "state.sqlite" config = tmp / "agent-memory.toml" config.write_text( - f'memory_root = "{REPO_ROOT / "templates" / "vault"}"\nstate_db = "{state_db}"\n', + f'memory_root = "{(REPO_ROOT / "templates" / "vault").as_posix()}"\n' + f'state_db = "{state_db.as_posix()}"\n', encoding="utf-8", ) env = os.environ.copy() @@ -35,7 +37,7 @@ def test_legacy_query_text_is_replaced_with_hash_metadata(self) -> None: check=False, ) self.assertEqual(initialized.returncode, 0, initialized.stderr) - with sqlite3.connect(state_db) as conn: + with closing(sqlite3.connect(state_db)) as conn, conn: conn.execute( "INSERT INTO memory_search_log(query,result_count,created_at) VALUES (?,?,?)", ("private legacy query", 0, "2026-07-11T00:00:00+00:00"), @@ -50,7 +52,7 @@ def test_legacy_query_text_is_replaced_with_hash_metadata(self) -> None: ) self.assertEqual(redacted.returncode, 0, redacted.stderr) self.assertEqual(json.loads(redacted.stdout), {"redacted": 1, "remaining_raw": 0}) - with sqlite3.connect(state_db) as conn: + with closing(sqlite3.connect(state_db)) as conn, conn: query, digest, length = conn.execute( "SELECT query, query_sha256, query_length FROM memory_search_log" ).fetchone() diff --git a/tests/test_session_claims.py b/tests/test_session_claims.py index a67779d..da57ba5 100644 --- a/tests/test_session_claims.py +++ b/tests/test_session_claims.py @@ -3,6 +3,8 @@ import json import os import sqlite3 +import shutil +from contextlib import closing import subprocess import sys import tempfile @@ -25,7 +27,10 @@ def run(command: list[str], *, cwd: Path, env: dict[str, str], timeout: int = 120) -> subprocess.CompletedProcess[str]: - return subprocess.run(command, cwd=cwd, env=env, text=True, capture_output=True, timeout=timeout, check=False) + return subprocess.run( + command, cwd=cwd, env=env, text=True, encoding="utf-8", + capture_output=True, timeout=timeout, check=False, + ) class ActorSessionIsolationTest(unittest.TestCase): @@ -54,7 +59,7 @@ def test_two_sessions_commit_only_their_claimed_files(self) -> None: vault = git_root / "AgentMemory" runtime = tmp / "runtime" git_root.mkdir(parents=True) - subprocess.run(["cp", "-R", str(TEMPLATE), str(vault)], check=True) + shutil.copytree(TEMPLATE, vault) subprocess.run(["git", "init", "-q", str(git_root)], check=True) subprocess.run(["git", "-C", str(git_root), "config", "user.name", "Agent Memory Test"], check=True) subprocess.run(["git", "-C", str(git_root), "config", "user.email", "test@example.invalid"], check=True) @@ -67,17 +72,17 @@ def test_two_sessions_commit_only_their_claimed_files(self) -> None: config_path.write_text( "\n".join( [ - f'memory_root = "{vault}"', - f'git_root = "{git_root}"', - f'config_root = "{runtime}"', - f'state_db = "{runtime / "state.sqlite"}"', - f'closeout_log = "{runtime / "logs" / "closeout.jsonl"}"', - f'audit_run_log = "{runtime / "logs" / "audit_runs.jsonl"}"', - 'python = "' + sys.executable + '"', + f'memory_root = "{vault.as_posix()}"', + f'git_root = "{git_root.as_posix()}"', + f'config_root = "{runtime.as_posix()}"', + f'state_db = "{(runtime / "state.sqlite").as_posix()}"', + f'closeout_log = "{(runtime / "logs" / "closeout.jsonl").as_posix()}"', + f'audit_run_log = "{(runtime / "logs" / "audit_runs.jsonl").as_posix()}"', + f'python = "{Path(sys.executable).as_posix()}"', "", "[semantic_retrieval]", "enabled = false", - 'python = "' + sys.executable + '"', + f'python = "{Path(sys.executable).as_posix()}"', ] ) + "\n", @@ -85,6 +90,7 @@ def test_two_sessions_commit_only_their_claimed_files(self) -> None: ) env = os.environ.copy() env["AGENT_MEMORY_CONFIG_FILE"] = str(config_path) + env["PYTHONIOENCODING"] = "utf-8" evolved = run( [sys.executable, str(SCRIPTS / "agent_memory_evolution.py"), "--init", "--scan"], @@ -129,6 +135,7 @@ def test_two_sessions_commit_only_their_claimed_files(self) -> None: listed = run( [ + sys.executable, str(SCRIPTS / "memoryctl"), "--actor", "claude", @@ -175,6 +182,7 @@ def closeout_command(actor: str, session_id: str) -> list[str]: cwd=REPO_ROOT, env=env, text=True, + encoding="utf-8", stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) @@ -183,6 +191,7 @@ def closeout_command(actor: str, session_id: str) -> list[str]: cwd=REPO_ROOT, env=env, text=True, + encoding="utf-8", stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) @@ -220,7 +229,7 @@ def closeout_command(actor: str, session_id: str) -> list[str]: {"AgentMemory/项目/_模板-项目.md", "AgentMemory/工作流/Agent记忆收尾决策规则.md"}, ) - with sqlite3.connect(runtime / "state.sqlite") as conn: + with closing(sqlite3.connect(runtime / "state.sqlite")) as conn, conn: active = conn.execute( "SELECT COUNT(*) FROM memory_session_claims WHERE status='active'" ).fetchone()[0] diff --git a/tests/test_stop_hook.py b/tests/test_stop_hook.py index af473c6..8833a6f 100644 --- a/tests/test_stop_hook.py +++ b/tests/test_stop_hook.py @@ -6,6 +6,7 @@ import io import json import sqlite3 +from contextlib import closing import subprocess import sys import tempfile @@ -129,7 +130,7 @@ def test_pending_paths_ignores_content_with_matching_closeout_observation(self) encoding="utf-8", ) digest = hashlib.sha256(self.note.read_bytes()).hexdigest() - with sqlite3.connect(self.module.STATE_DB) as conn: + with closing(sqlite3.connect(self.module.STATE_DB)) as conn, conn: conn.execute("CREATE TABLE memory_file_observations (path TEXT PRIMARY KEY, sha256 TEXT NOT NULL)") conn.execute( "INSERT INTO memory_file_observations(path, sha256) VALUES (?, ?)",