Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) }
}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
*.pem
*.log
__pycache__/
.venv/
.pytest_cache/
.mypy_cache/
dist/
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions docs/automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
49 changes: 49 additions & 0 deletions docs/windows-compatibility-audit.md
Original file line number Diff line number Diff line change
@@ -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 数据模型或去重决策规则。
88 changes: 88 additions & 0 deletions docs/windows.md
Original file line number Diff line number Diff line change
@@ -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 目录。
35 changes: 13 additions & 22 deletions scripts/agent_memory_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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]

Expand All @@ -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)
Expand All @@ -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()
Expand Down
29 changes: 14 additions & 15 deletions scripts/agent_memory_audit_autorun.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,24 @@
import argparse
import contextlib
import datetime as dt
import fcntl
import json
import os
import subprocess
import sys
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"

Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
Loading