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 memory/hooks/README-codex-omp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Codex and OMP/Pi memory capture

Claude Code capture is wired automatically by `dotagents setup`. Codex and
OMP/Pi have no session-end hook that dotagents installs for them, so their
automatic capture is opt-in and wired manually. Both route through the same
`session-end.sh` dispatcher, which classifies a Codex/OMP payload and writes the
local `basic_memory` digest — the same digest Claude produces.

Reminder for both harnesses: automatic digests are a safety net, not a
substitute for deliberate capture. Record durable cross-harness facts explicitly
with `rem add -src codex "<fact>"` / `rem add -src omp "<fact>"`.

## Codex

Codex emits Claude-compatible Stop/SessionEnd payloads (with `transcript_path`
and `model`). Add a hook to `~/.codex/hooks.json` on `SessionEnd` (preferred) or
`Stop`, setting `DOTAGENTS_MEMORY_SOURCE=codex` so the digest is labelled and so
the `Stop`-wired path captures rather than passing through:

```json
{
"type": "command",
"command": "DOTAGENTS_MEMORY_SOURCE=codex ~/.agents/memory/hooks/session-end.sh",
"timeout": 30
}
```

Reminder: also `rem add -src codex "<fact>"` for facts worth promoting.

## OMP / Pi

OMP/Pi has no session-end hook but supports extensions that fire on
`agent_end`. Install the shipped extension:

```sh
cp ~/.agents/memory/hooks/omp-memory.ts ~/.omp/agent/extensions/
```

It pipes an `agent: "omp"` payload into `session-end.sh` on every `agent_end`.
Set `DOTAGENTS_MEMORY_HOOKS` if your dotagents checkout is not at `~/.agents`.

Reminder: also `rem add -src omp "<fact>"` for facts worth promoting.
93 changes: 93 additions & 0 deletions memory/hooks/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,96 @@ index_memory_top_level() {
done
memsearch index "$@" --collection "$MEMSEARCH_COLLECTION" >/dev/null 2>&1
}

# Best-effort vault reindex fired after a session digest is written. It never
# blocks the hook (all work is backgrounded), is bounded by a watchdog so a
# hung memsearch can't run forever, and refuses to overlap a refresh that is
# already running via an atomic mkdir lock. No-op when memsearch is absent.
refresh_index_async() {
command -v memsearch >/dev/null 2>&1 || return 0
prepare_memory_index_env

reindex_lock="${MEMSEARCH_STATE_DIR%/}/reindex.lock"
# mkdir is atomic: it fails when a refresh already holds the lock, so we never
# spawn overlapping reindexers.
if ! mkdir "$reindex_lock" 2>/dev/null; then
# The lock exists. Reclaim it only if its owner is gone (e.g. the refresher
# was SIGKILLed mid-run), so a dead process can't suppress reindex forever.
owner="$(cat "$reindex_lock/pid" 2>/dev/null || true)"
if [ -n "$owner" ] && kill -0 "$owner" 2>/dev/null; then
return 0
fi
rm -f "$reindex_lock/pid" 2>/dev/null || true
rmdir "$reindex_lock" 2>/dev/null || true
mkdir "$reindex_lock" 2>/dev/null || return 0
fi
Comment thread
sourcery-ai[bot] marked this conversation as resolved.

(
trap 'rm -f "$reindex_lock/pid" 2>/dev/null || true; rmdir "$reindex_lock" 2>/dev/null || true' EXIT
set -- "$NOTES_DIR" "$PROFILE_DIR"
for path in "$SESSIONS_DIR"/*.md "$SESSIONS_DIR"/*.markdown; do
[ -f "$path" ] && set -- "$@" "$path"
done
memsearch index "$@" --collection "$MEMSEARCH_COLLECTION" >/dev/null 2>&1 &
index_pid=$!
( sleep "${MEMSEARCH_REINDEX_TIMEOUT:-120}"; kill "$index_pid" 2>/dev/null || true ) &
watchdog_pid=$!
wait "$index_pid" 2>/dev/null || true
kill "$watchdog_pid" 2>/dev/null || true
wait "$watchdog_pid" 2>/dev/null || true
) >/dev/null 2>&1 &
# Record the worker's PID (portable across bash 3.2, unlike $BASHPID) so a
# later call can tell a live refresh from a lock stranded by a killed one.
printf '%s\n' "$!" >"$reindex_lock/pid" 2>/dev/null || true

return 0
}

# Classify a hook payload file into a dispatch kind. Codex and OMP capture route
# through the local basic_memory digest (never the Claude plugin), detected from
# an explicit source hint or the payload's own agent/platform marker.
classify_payload() {
MEMORY_SOURCE_HINT="${DOTAGENTS_MEMORY_SOURCE:-}" python3 - "$1" <<'PY'
import os
import json
import sys
from pathlib import Path

try:
data = json.load(open(sys.argv[1]))
except Exception:
print("unknown")
raise SystemExit

hint = (os.environ.get("MEMORY_SOURCE_HINT") or "").strip().lower()
agent = str(data.get("agent") or data.get("platform") or "").strip().lower()
transcript = os.path.expanduser(str(data.get("transcript_path") or ""))

if hint in {"codex", "omp"}:
print(hint)
elif agent in {"codex", "omp"}:
print(agent)
elif transcript and "/.factory/" in transcript and Path(transcript).suffix == ".jsonl":
print("factory-jsonl")
elif data.get("platform") == "amp" or data.get("amp_thread_id"):
print("amp-json")
elif data.get("session_id") and not transcript:
print("hermes-json")
else:
print("claude-plugin")
PY
}

# Write a local basic_memory digest for the payload, then fire a bounded,
# non-overlapping reindex only when a new digest was actually appended. Used by
# the Claude fallback and by Codex/OMP capture so the logic lives in one place.
dispatch_basic_digest() {
if digest_output="$(python3 "$MEMORY_DIR/hooks/basic-session-end.py" <"$1")"; then
printf '%s\n' "$digest_output"
case "$digest_output" in
*'"systemMessage":"basic memory appended'*) refresh_index_async ;;
esac
else
printf '{"continue":true,"suppressOutput":true}\n'
fi
}
65 changes: 65 additions & 0 deletions memory/hooks/omp-memory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// dotagents OMP/Pi memory extension.
//
// OMP/Pi exposes no session-end hook of its own, so this extension captures a
// basic_memory digest on `agent_end` by piping an OMP-tagged payload into the
// shared session-end dispatch (memory/hooks/session-end.sh), which classifies
// `agent: "omp"` and writes the same local digest Claude and Codex use.
//
// Manual wiring (dotagents does not install OMP extensions automatically):
// cp ~/.agents/memory/hooks/omp-memory.ts ~/.omp/agent/extensions/
// Override the hook location with DOTAGENTS_MEMORY_HOOKS if your dotagents
// checkout is not at ~/.agents.
//
// Reminder: automatic digests are a net, not a replacement. Capture deliberate
// cross-harness facts explicitly with: rem add -src omp "<fact>"
import { spawn } from "node:child_process";
import * as os from "node:os";
import * as path from "node:path";
import type { AgentEndEvent, ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";

function hooksDir(): string {
return process.env.DOTAGENTS_MEMORY_HOOKS || path.join(os.homedir(), ".agents", "memory", "hooks");
}

function digestMessages(event: AgentEndEvent): Array<{ role: string; content: unknown }> {
const messages: Array<{ role: string; content: unknown }> = [];
for (const message of event.messages || []) {
if (!message || typeof message !== "object") continue;
const typed = message as { role?: unknown; content?: unknown };
if (typeof typed.role !== "string" || typed.content == null) continue;
messages.push({ role: typed.role, content: typed.content });
}
return messages;
}

function captureDigest(ctx: ExtensionContext, event: AgentEndEvent): void {
const sessionId = ctx.sessionManager.getSessionId();
if (!sessionId) return;
const cwd = ctx.cwd || process.cwd();
const payload = JSON.stringify({
agent: "omp",
hook_event_name: "SessionEnd",
session_id: sessionId,
cwd,
messages: digestMessages(event),
});
try {
const child = spawn(path.join(hooksDir(), "session-end.sh"), [], {
env: { ...process.env, DOTAGENTS_MEMORY_SOURCE: "omp" },
stdio: ["pipe", "ignore", "ignore"],
detached: true,
});
child.on("error", () => {});
child.unref();
child.stdin.on("error", () => {});
child.stdin.end(payload);
} catch (_) {
// Best-effort: never let memory capture break the session.
}
}

export default function dotagentsOmpMemoryExtension(api: ExtensionAPI) {
api.on("agent_end", async (event, ctx) => {
captureDigest(ctx, event);
});
}
34 changes: 10 additions & 24 deletions memory/hooks/session-end.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,29 +15,7 @@ payload="$(mktemp)"
trap 'rm -f "$payload"' EXIT
cat >"$payload"

kind="$(python3 - "$payload" <<'PY'
import os
import json
import sys
from pathlib import Path

try:
data = json.load(open(sys.argv[1]))
except Exception:
print("unknown")
raise SystemExit

transcript = os.path.expanduser(str(data.get("transcript_path") or ""))
if transcript and "/.factory/" in transcript and Path(transcript).suffix == ".jsonl":
print("factory-jsonl")
elif data.get("platform") == "amp" or data.get("amp_thread_id"):
print("amp-json")
elif data.get("session_id") and not transcript:
print("hermes-json")
else:
print("claude-plugin")
PY
)"
kind="$(classify_payload "$payload")"

case "$kind" in
amp-json)
Expand All @@ -49,12 +27,20 @@ case "$kind" in
hermes-json)
python3 "$MEMORY_DIR/lib/hermes_digest.py" <"$payload"
;;
codex|omp)
# Codex/OMP capture always uses the local basic_memory digest.
dispatch_basic_digest "$payload"
;;
*)
plugin_dir="$(resolve_claude_memory_plugin || true)"
if [ -n "$plugin_dir" ]; then
bash "$plugin_dir/hooks/session-end.sh" <"$payload" >/dev/null 2>&1 || true
index_memory_top_level || true
printf '{"continue":true,"suppressOutput":true}\n'
else
# No memsearch claude-code plugin (e.g. memsearch 0.2.x): fall back to the
# local basic_memory digest so Claude capture keeps working.
dispatch_basic_digest "$payload"
fi
printf '{"continue":true,"suppressOutput":true}\n'
;;
esac
5 changes: 3 additions & 2 deletions memory/hooks/session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ fi
load_memory_config
plugin_dir="$(resolve_claude_memory_plugin || true)"
if [ -z "$plugin_dir" ]; then
printf '{"continue":true,"suppressOutput":true}\n'
exit 0
# No memsearch claude-code plugin: fall back to the local basic_memory
# implementation so recent session digests are injected as additionalContext.
exec python3 "$MEMORY_DIR/hooks/basic-session-start.py"
fi

prepare_memory_index_env
Expand Down
19 changes: 18 additions & 1 deletion memory/hooks/stop.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,23 @@ if [ "${MEMSEARCH_SKIP_CLAUDE_HOOKS:-}" = "1" ]; then
fi

load_memory_config

payload="$(mktemp)"
trap 'rm -f "$payload"' EXIT
cat >"$payload"

# Codex/OMP capture may be wired to their Stop event (they expose no reliable
# session-end hook); route those to the local basic_memory digest. Claude Stop
# fires once per response, so it stays a clean continuation and lets the Claude
# SessionEnd hook own the full-session digest.
kind="$(classify_payload "$payload")"
case "$kind" in
codex|omp)
dispatch_basic_digest "$payload"
exit 0
;;
esac

plugin_dir="$(resolve_claude_memory_plugin || true)"
if [ -z "$plugin_dir" ]; then
printf '{"continue":true,"suppressOutput":true}\n'
Expand All @@ -17,4 +34,4 @@ fi

prepare_memory_index_env
export MEMSEARCH_SKIP_CLAUDE_HOOKS=1
exec bash "$plugin_dir/hooks/stop.sh"
exec bash "$plugin_dir/hooks/stop.sh" <"$payload"
10 changes: 9 additions & 1 deletion memory/lib/basic_memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,15 @@ def build_digest(payload: dict[str, Any], messages: list[dict[str, Any]], starte
users = collect_user_turns(messages)
assistant = last_assistant_text(messages)
paths = extract_paths(messages, payload)
platform = payload.get("platform") or payload.get("agent") or payload.get("hook_event_name") or "basic"
# An explicit source hint wins over payload markers so a conflicting
# platform/agent field in the payload cannot mislabel the digest.
platform = (
os.environ.get("DOTAGENTS_MEMORY_SOURCE")
or payload.get("platform")
or payload.get("agent")
or payload.get("hook_event_name")
or "basic"
)
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
model = payload.get("model")

lines = []
Expand Down
Loading
Loading