diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5de7f03..7ee5457 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,3 +39,45 @@ jobs: run: node --test "integrations/tests/"*.test.mjs - name: Hermes provider compiles run: python3 -m py_compile integrations/hermes/daimon/__init__.py + + # Windows is Tier-2 (see README "Platform support"): server in Docker Desktop/WSL2, + # clients via Git Bash. This job is the Git Bash half — we develop on macOS/Linux, so + # this runner is the only place the installers meet MSYS sed, the py launcher, and + # Windows paths before a user does. + windows-smoke: + runs-on: windows-latest + defaults: + run: + shell: bash # Git Bash, same environment the client installers target + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Hook scripts parse and exit 0 with no backend + run: | + for f in integrations/*/plugins/daimon-memory/scripts/*.mjs; do + node --check "$f" + echo '{}' | DAIMON_ENDPOINT=http://127.0.0.1:9 node "$f" >/dev/null + done + - name: Save-nudge unit tests + cross-copy parity + state paths + run: node --test "integrations/tests/"*.test.mjs + - name: Codex installer end-to-end under Git Bash (stubbed codex CLI) + run: | + mkdir -p "$HOME/.codex" stub + printf '#!/bin/sh\nexit 0\n' > stub/codex && chmod +x stub/codex + PATH="$PWD/stub:$PATH" bash integrations/codex/install.sh \ + --endpoint http://localhost:9 --api-key smoke-test-key --yes + PLUGIN="$HOME/.codex/daimon-memory-marketplace/plugins/daimon-memory" + ! grep -q "__DAIMON" "$PLUGIN/hooks/hooks.json" + ! grep -q "__DAIMON" "$PLUGIN/.mcp.json" + grep -q "smoke-test-key" "$PLUGIN/.mcp.json" + node -e "JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'))" \ + "$PLUGIN/scripts/lib/daimon.config.json" + - name: Hermes installer under Git Bash (stubbed hermes CLI) + run: | + mkdir -p "$HOME/.hermes" stub + printf '#!/bin/sh\nexit 0\n' > stub/hermes && chmod +x stub/hermes + PATH="$PWD/stub:$PATH" bash integrations/hermes/install.sh \ + --endpoint http://localhost:9 --no-activate --yes + grep -q "^DAIMON_ENDPOINT=" "$HOME/.hermes/.env" diff --git a/README.md b/README.md index b752825..6f398ae 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,15 @@ cargo run --bin daimon-mcp # serves :8080 (/v1 + /mcp) cargo run --bin daimon-indexer # outbox to Qdrant (separate process) ``` +### Platform support + +- **Linux / macOS** — fully supported; this is what we develop and run on. +- **Windows** — supported via **Docker Desktop (WSL2)** for the server and **Git Bash** for the + client installers. CI runs the installers and hook tests on a `windows-latest` runner, but we + don't use Windows day-to-day — issue reports may take a round-trip to reproduce. Client state + lives under `%LOCALAPPDATA%\daimon-memory\` (or `$XDG_STATE_HOME` if set). Native PowerShell + installers are out of scope for now. + ## Connect your tools Each client installer wires three things: **session-start** (persona + disciplines + recent context), **per-turn recall + save-nudge**, and **capture** (mirroring the tool's own memory and/or the guided tools). diff --git a/integrations/claude-code/plugins/daimon-memory/scripts/lib/state-paths.mjs b/integrations/claude-code/plugins/daimon-memory/scripts/lib/state-paths.mjs new file mode 100644 index 0000000..87d5cea --- /dev/null +++ b/integrations/claude-code/plugins/daimon-memory/scripts/lib/state-paths.mjs @@ -0,0 +1,15 @@ +// Platform-appropriate per-user state directory root, shared by every script that +// persists session state (recall/nudge/precompact). XDG_STATE_HOME always wins so a +// user can redirect state explicitly; on Windows the fallback is %LOCALAPPDATA% (the +// idiomatic per-user state location) instead of a ~/.local/state shadow tree that only +// Git Bash users would ever find. +import { homedir } from "node:os"; +import { join } from "node:path"; + +export function stateBase() { + if (process.env.XDG_STATE_HOME) return process.env.XDG_STATE_HOME; + if (process.platform === "win32") { + return process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local"); + } + return join(homedir(), ".local", "state"); +} diff --git a/integrations/claude-code/plugins/daimon-memory/scripts/nudge-lib.mjs b/integrations/claude-code/plugins/daimon-memory/scripts/nudge-lib.mjs index 044b825..ff2f1a9 100644 --- a/integrations/claude-code/plugins/daimon-memory/scripts/nudge-lib.mjs +++ b/integrations/claude-code/plugins/daimon-memory/scripts/nudge-lib.mjs @@ -4,7 +4,8 @@ // the record; this only governs TIMING (the Save Discipline's "hooks back-stop you"). import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { join } from "node:path"; -import { homedir, tmpdir } from "node:os"; +import { tmpdir } from "node:os"; +import { stateBase } from "./lib/state-paths.mjs"; // --- config (per-install, env-overridable) --- export const NUDGE_ON = String(process.env.DAIMON_NUDGE || "on").toLowerCase() !== "off"; @@ -54,7 +55,7 @@ export function scanSignal(text) { // --- per-session state (hooks are separate processes; the counter must persist to disk) --- function statePath(sessionId) { const safe = String(sessionId || "default").replace(/[^a-zA-Z0-9_-]/g, "_"); - const base = process.env.XDG_STATE_HOME || join(homedir(), ".local", "state"); + const base = stateBase(); try { const dir = join(base, "daimon-memory", "nudge"); mkdirSync(dir, { recursive: true }); diff --git a/integrations/claude-code/plugins/daimon-memory/scripts/precompact-state.mjs b/integrations/claude-code/plugins/daimon-memory/scripts/precompact-state.mjs index 6f3fb0b..7d32b0d 100644 --- a/integrations/claude-code/plugins/daimon-memory/scripts/precompact-state.mjs +++ b/integrations/claude-code/plugins/daimon-memory/scripts/precompact-state.mjs @@ -6,11 +6,12 @@ // within one session). import { readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; -import { homedir, tmpdir } from "node:os"; +import { tmpdir } from "node:os"; +import { stateBase } from "./lib/state-paths.mjs"; function statePath(sessionId) { const safe = String(sessionId || "default").replace(/[^a-zA-Z0-9_-]/g, "_"); - const base = process.env.XDG_STATE_HOME || join(homedir(), ".local", "state"); + const base = stateBase(); try { const dir = join(base, "daimon-memory", "precompact"); mkdirSync(dir, { recursive: true }); diff --git a/integrations/claude-code/plugins/daimon-memory/scripts/recall-state.mjs b/integrations/claude-code/plugins/daimon-memory/scripts/recall-state.mjs index c6becee..a6f7a0a 100644 --- a/integrations/claude-code/plugins/daimon-memory/scripts/recall-state.mjs +++ b/integrations/claude-code/plugins/daimon-memory/scripts/recall-state.mjs @@ -4,11 +4,12 @@ // SessionStart, which re-fires on compaction, so anything compaction dropped is refreshed. import { readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; -import { homedir, tmpdir } from "node:os"; +import { tmpdir } from "node:os"; +import { stateBase } from "./lib/state-paths.mjs"; function statePath(sessionId) { const safe = String(sessionId || "default").replace(/[^a-zA-Z0-9_-]/g, "_"); - const base = process.env.XDG_STATE_HOME || join(homedir(), ".local", "state"); + const base = stateBase(); try { const dir = join(base, "daimon-memory", "recall"); mkdirSync(dir, { recursive: true }); diff --git a/integrations/codex/install.sh b/integrations/codex/install.sh index 9e31faf..4d473b4 100755 --- a/integrations/codex/install.sh +++ b/integrations/codex/install.sh @@ -34,13 +34,23 @@ ask(){ local v="$1" p="$2" d="${3:-}" cur ans=""; eval "cur=\${$v}"; [ -n "$cur" if is_tty && [ "$ASSUME_YES" != 1 ]; then read -r -p " $p [$d]: " ans || true; fi printf -v "$v" '%s' "${ans:-$d}"; } +# Portable Python runner: Git Bash on Windows ships no `python3` on PATH, but the `py` +# launcher is standard there. An alias would not expand in a non-interactive script, so +# wrap the dispatch in a function instead. +have_python(){ command -v python3 >/dev/null 2>&1 || command -v py >/dev/null 2>&1; } +pyrun(){ + if command -v python3 >/dev/null 2>&1; then python3 "$@" + elif command -v py >/dev/null 2>&1; then py -3 "$@" + else return 127; fi +} + # Idempotently enable Codex native memory ([features] memories = true) without clobbering the # existing [features] table. Handles all four states (already-on / present-but-false / table -# exists / table missing). Uses python3 for a safe targeted edit; no-op if python3 is absent. +# exists / table missing). Uses Python for a safe targeted edit; no-op if Python is absent. enable_native_memory(){ local cfg="$CODEX_HOME/config.toml" - command -v python3 >/dev/null 2>&1 || { echo " (python3 not found; enable manually: Codex Settings -> Memory)"; return 1; } - python3 - "$cfg" <<'PY' + have_python || { echo " (python3 not found; enable manually: Codex Settings -> Memory)"; return 1; } + pyrun - "$cfg" <<'PY' import sys, os, re p = sys.argv[1] s = open(p).read() if os.path.exists(p) else "" @@ -86,16 +96,36 @@ rm -rf "$STABLE"; mkdir -p "$STABLE" cp -R "$SELF_DIR/.claude-plugin" "$SELF_DIR/plugins" "$STABLE/" PLUGIN_DIR="$STABLE/plugins/daimon-memory" -# Substitute placeholders (Codex doesn't inject CODEX_PLUGIN_ROOT into hooks). -sed -i.bak "s|__DAIMON_PLUGIN_ROOT__|$PLUGIN_DIR|g" "$PLUGIN_DIR/hooks/hooks.json"; rm -f "$PLUGIN_DIR/hooks/hooks.json.bak" -sed -i.bak "s|__DAIMON_MCP_URL__|$ENDPOINT/mcp|g" "$PLUGIN_DIR/.mcp.json"; rm -f "$PLUGIN_DIR/.mcp.json.bak" -sed -i.bak "s|__DAIMON_API_KEY__|$API_KEY|g" "$PLUGIN_DIR/.mcp.json"; rm -f "$PLUGIN_DIR/.mcp.json.bak" +# Substitute placeholders (Codex doesn't inject CODEX_PLUGIN_ROOT into hooks). Python is +# preferred: MSYS sed (Git Bash for Windows) handles -i.bak differently from GNU/BSD sed +# and can mangle Windows paths; sed remains the fallback for minimal POSIX hosts. +if have_python; then + pyrun - "$PLUGIN_DIR" "$ENDPOINT" "$API_KEY" <<'PY' +import sys, pathlib +plugin_dir, endpoint, api_key = sys.argv[1], sys.argv[2], sys.argv[3] +substitutions = [ + ("hooks/hooks.json", [("__DAIMON_PLUGIN_ROOT__", plugin_dir)]), + (".mcp.json", [("__DAIMON_MCP_URL__", endpoint + "/mcp"), + ("__DAIMON_API_KEY__", api_key)]), +] +for rel, pairs in substitutions: + p = pathlib.Path(plugin_dir) / rel + text = p.read_text(encoding="utf-8") + for placeholder, value in pairs: + text = text.replace(placeholder, value) + p.write_text(text, encoding="utf-8") +PY +else + sed -i.bak "s|__DAIMON_PLUGIN_ROOT__|$PLUGIN_DIR|g" "$PLUGIN_DIR/hooks/hooks.json"; rm -f "$PLUGIN_DIR/hooks/hooks.json.bak" + sed -i.bak "s|__DAIMON_MCP_URL__|$ENDPOINT/mcp|g" "$PLUGIN_DIR/.mcp.json"; rm -f "$PLUGIN_DIR/.mcp.json.bak" + sed -i.bak "s|__DAIMON_API_KEY__|$API_KEY|g" "$PLUGIN_DIR/.mcp.json"; rm -f "$PLUGIN_DIR/.mcp.json.bak" +fi # Config the hooks read (Codex hook subprocesses don't get env reliably). lib/daimon.mjs # loads this file as the fallback between env and the dev defaults. Prefer python3 so a key # containing JSON-special characters can't silently produce a malformed (= ignored) config. -if command -v python3 >/dev/null 2>&1; then - ENDPOINT="$ENDPOINT" TENANT="$TENANT" API_KEY="$API_KEY" CFG="$PLUGIN_DIR/scripts/lib/daimon.config.json" python3 - <<'PY' +if have_python; then + ENDPOINT="$ENDPOINT" TENANT="$TENANT" API_KEY="$API_KEY" CFG="$PLUGIN_DIR/scripts/lib/daimon.config.json" pyrun - <<'PY' import json, os json.dump({"endpoint": os.environ["ENDPOINT"], "tenant": os.environ["TENANT"], "apiKey": os.environ["API_KEY"]}, open(os.environ["CFG"], "w")) diff --git a/integrations/codex/plugins/daimon-memory/scripts/lib/state-paths.mjs b/integrations/codex/plugins/daimon-memory/scripts/lib/state-paths.mjs new file mode 100644 index 0000000..87d5cea --- /dev/null +++ b/integrations/codex/plugins/daimon-memory/scripts/lib/state-paths.mjs @@ -0,0 +1,15 @@ +// Platform-appropriate per-user state directory root, shared by every script that +// persists session state (recall/nudge/precompact). XDG_STATE_HOME always wins so a +// user can redirect state explicitly; on Windows the fallback is %LOCALAPPDATA% (the +// idiomatic per-user state location) instead of a ~/.local/state shadow tree that only +// Git Bash users would ever find. +import { homedir } from "node:os"; +import { join } from "node:path"; + +export function stateBase() { + if (process.env.XDG_STATE_HOME) return process.env.XDG_STATE_HOME; + if (process.platform === "win32") { + return process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local"); + } + return join(homedir(), ".local", "state"); +} diff --git a/integrations/codex/plugins/daimon-memory/scripts/nudge-lib.mjs b/integrations/codex/plugins/daimon-memory/scripts/nudge-lib.mjs index 044b825..ff2f1a9 100644 --- a/integrations/codex/plugins/daimon-memory/scripts/nudge-lib.mjs +++ b/integrations/codex/plugins/daimon-memory/scripts/nudge-lib.mjs @@ -4,7 +4,8 @@ // the record; this only governs TIMING (the Save Discipline's "hooks back-stop you"). import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { join } from "node:path"; -import { homedir, tmpdir } from "node:os"; +import { tmpdir } from "node:os"; +import { stateBase } from "./lib/state-paths.mjs"; // --- config (per-install, env-overridable) --- export const NUDGE_ON = String(process.env.DAIMON_NUDGE || "on").toLowerCase() !== "off"; @@ -54,7 +55,7 @@ export function scanSignal(text) { // --- per-session state (hooks are separate processes; the counter must persist to disk) --- function statePath(sessionId) { const safe = String(sessionId || "default").replace(/[^a-zA-Z0-9_-]/g, "_"); - const base = process.env.XDG_STATE_HOME || join(homedir(), ".local", "state"); + const base = stateBase(); try { const dir = join(base, "daimon-memory", "nudge"); mkdirSync(dir, { recursive: true }); diff --git a/integrations/codex/plugins/daimon-memory/scripts/recall-state.mjs b/integrations/codex/plugins/daimon-memory/scripts/recall-state.mjs index c6becee..a6f7a0a 100644 --- a/integrations/codex/plugins/daimon-memory/scripts/recall-state.mjs +++ b/integrations/codex/plugins/daimon-memory/scripts/recall-state.mjs @@ -4,11 +4,12 @@ // SessionStart, which re-fires on compaction, so anything compaction dropped is refreshed. import { readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; -import { homedir, tmpdir } from "node:os"; +import { tmpdir } from "node:os"; +import { stateBase } from "./lib/state-paths.mjs"; function statePath(sessionId) { const safe = String(sessionId || "default").replace(/[^a-zA-Z0-9_-]/g, "_"); - const base = process.env.XDG_STATE_HOME || join(homedir(), ".local", "state"); + const base = stateBase(); try { const dir = join(base, "daimon-memory", "recall"); mkdirSync(dir, { recursive: true }); diff --git a/integrations/tests/nudge-lib.test.mjs b/integrations/tests/nudge-lib.test.mjs index 9cfd98f..e443508 100644 --- a/integrations/tests/nudge-lib.test.mjs +++ b/integrations/tests/nudge-lib.test.mjs @@ -65,11 +65,35 @@ test("decide: cadence nudge fires at exactly N quiet turns then resets", () => { assert.equal(st.quietTurns, 0, "counter resets after firing"); }); +// --- state path resolution (issue #5/#8: %LOCALAPPDATA% on Windows, XDG elsewhere) --- +const { stateBase } = await import(CC + "lib/state-paths.mjs"); + +test("stateBase: XDG_STATE_HOME wins on every platform", () => { + const prev = process.env.XDG_STATE_HOME; + process.env.XDG_STATE_HOME = "/tmp/xdg-test"; + try { assert.equal(stateBase(), "/tmp/xdg-test"); } + finally { prev === undefined ? delete process.env.XDG_STATE_HOME : process.env.XDG_STATE_HOME = prev; } +}); + +test("stateBase: platform fallback is LOCALAPPDATA on win32, ~/.local/state elsewhere", () => { + const prev = process.env.XDG_STATE_HOME; + delete process.env.XDG_STATE_HOME; + try { + const base = stateBase(); + if (process.platform === "win32") { + assert.ok(/AppData[\\/]Local|%?LOCALAPPDATA/i.test(base) || base === process.env.LOCALAPPDATA, + `expected a LOCALAPPDATA-style path, got ${base}`); + } else { + assert.match(base, /\.local[\\/]state$/); + } + } finally { if (prev !== undefined) process.env.XDG_STATE_HOME = prev; } +}); + // --- cross-copy parity: the genuinely shared scripts MUST stay byte-identical between // plugins. (session-start.mjs and mirror-memory.mjs are intentionally per-client and are // NOT listed here - Codex has no PreCompact hook and reads native memory from SQLite.) const SHARED = ["nudge-lib.mjs", "nudge.mjs", "auto-recall.mjs", "recall-state.mjs", - "lib/daimon.mjs"]; + "lib/daimon.mjs", "lib/state-paths.mjs"]; for (const f of SHARED) { test(`parity: ${f} identical across claude-code and codex`, () => { assert.equal(read(CC + f), read(CX + f), `${f} drifted between the two plugin copies`);