Skip to content
Merged
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
42 changes: 42 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
48 changes: 39 additions & 9 deletions integrations/codex/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand Down Expand Up @@ -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"))
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
26 changes: 25 additions & 1 deletion integrations/tests/nudge-lib.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down
Loading