fix(mem0-agent): detached hooks lost their payload, so nothing was ever written
Observed live: real sessions flagged durable candidates (a preference, two insights, a convention) but every session showed 'flush events: 0'. The manifest backgrounded flush and assist-error with '( cmd &)', so the parent exited and closed stdin before the child could read it -- the child then had no session_id, fell back to 'no-session', and drained an empty buffer. Now the CLI reads the payload itself and re-execs a detached worker with start_new_session, so the hook still returns in ~0.27s. Verified end to end: sent=1, session_state=created. Also records a surface hint per event and shows it in 'sessions', since the manifest pins MEM0_EDITOR to the same value on every client. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
10b8fc4bed
commit
1ea8c2e5fe
@@ -30,7 +30,7 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "(MEM0_EDITOR=claude-code ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent assist-error >/dev/null 2>&1 &)",
|
"command": "MEM0_EDITOR=claude-code ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent assist-error",
|
||||||
"timeout": 5
|
"timeout": 5
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "(MEM0_EDITOR=claude-code ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush >/dev/null 2>&1 &)",
|
"command": "MEM0_EDITOR=claude-code ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush",
|
||||||
"timeout": 5
|
"timeout": 5
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "(MEM0_EDITOR=claude-code ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush --reason precompact >/dev/null 2>&1 &)",
|
"command": "MEM0_EDITOR=claude-code ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush --reason precompact",
|
||||||
"timeout": 5
|
"timeout": 5
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "(MEM0_EDITOR=claude-code ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush --reason end >/dev/null 2>&1 &)",
|
"command": "MEM0_EDITOR=claude-code ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush --reason end",
|
||||||
"timeout": 5
|
"timeout": 5
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ hooks:
|
|||||||
matcher: "Bash"
|
matcher: "Bash"
|
||||||
command: "${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent assist-error"
|
command: "${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent assist-error"
|
||||||
timeout: 5
|
timeout: 5
|
||||||
background: true
|
background: false
|
||||||
blocking: false
|
blocking: false
|
||||||
local_only: false
|
local_only: false
|
||||||
status_message: null
|
status_message: null
|
||||||
@@ -106,7 +106,7 @@ hooks:
|
|||||||
matcher: null
|
matcher: null
|
||||||
command: "${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush"
|
command: "${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush"
|
||||||
timeout: 5
|
timeout: 5
|
||||||
background: true
|
background: false
|
||||||
blocking: false
|
blocking: false
|
||||||
local_only: false
|
local_only: false
|
||||||
status_message: null
|
status_message: null
|
||||||
@@ -117,7 +117,7 @@ hooks:
|
|||||||
matcher: null
|
matcher: null
|
||||||
command: "${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush --reason precompact"
|
command: "${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush --reason precompact"
|
||||||
timeout: 5
|
timeout: 5
|
||||||
background: true
|
background: false
|
||||||
blocking: false
|
blocking: false
|
||||||
local_only: false
|
local_only: false
|
||||||
status_message: null
|
status_message: null
|
||||||
@@ -128,7 +128,7 @@ hooks:
|
|||||||
matcher: null
|
matcher: null
|
||||||
command: "${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush --reason end"
|
command: "${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush --reason end"
|
||||||
timeout: 5
|
timeout: 5
|
||||||
background: true
|
background: false
|
||||||
blocking: false
|
blocking: false
|
||||||
local_only: false
|
local_only: false
|
||||||
status_message: null
|
status_message: null
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -69,6 +70,47 @@ def emit(text: str) -> None:
|
|||||||
PENDING_FILE = "pending_context.jsonl"
|
PENDING_FILE = "pending_context.jsonl"
|
||||||
|
|
||||||
|
|
||||||
|
def detach_worker(subcommand: str, payload: dict, extra: list[str] | None = None) -> bool:
|
||||||
|
"""Read the hook payload here, then hand it to a detached child.
|
||||||
|
|
||||||
|
A hook backgrounded in the manifest (`cmd &`) loses its stdin the moment the parent
|
||||||
|
exits, so the child sees no session_id and no transcript_path -- it then drains the
|
||||||
|
wrong buffer and writes nothing. Reading the payload first and passing it by FILE is
|
||||||
|
what makes a detached write reliable.
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import sys as _sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
fd, path = tempfile.mkstemp(prefix="mem0-hook-", suffix=".json")
|
||||||
|
with os.fdopen(fd, "w") as fh:
|
||||||
|
json.dump(payload, fh)
|
||||||
|
cmd = [_sys.executable, "-m", "mem0_agent.cli", subcommand,
|
||||||
|
"--worker", "--payload-file", path, *(extra or [])]
|
||||||
|
subprocess.Popen(cmd, stdin=subprocess.DEVNULL,
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
|
start_new_session=True) # survives the hook's process group
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def worker_payload(args, fallback: dict) -> dict:
|
||||||
|
if getattr(args, "payload_file", None):
|
||||||
|
try:
|
||||||
|
with open(args.payload_file) as fh:
|
||||||
|
data = json.load(fh)
|
||||||
|
try:
|
||||||
|
os.unlink(args.payload_file)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return data
|
||||||
|
except Exception:
|
||||||
|
return fallback
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
def queue_context(c, block: str) -> None:
|
def queue_context(c, block: str) -> None:
|
||||||
"""Park a block for the next prompt hook to deliver.
|
"""Park a block for the next prompt hook to deliver.
|
||||||
|
|
||||||
@@ -172,7 +214,13 @@ def cmd_observe(args) -> int:
|
|||||||
|
|
||||||
def cmd_flush(args) -> int:
|
def cmd_flush(args) -> int:
|
||||||
"""Stop / PreCompact / SessionEnd: the only place writes happen."""
|
"""Stop / PreCompact / SessionEnd: the only place writes happen."""
|
||||||
payload = hook_input()
|
payload = worker_payload(args, hook_input()) if args.worker else hook_input()
|
||||||
|
if not args.worker:
|
||||||
|
# Return to the editor in milliseconds; the child does the network work.
|
||||||
|
if detach_worker("flush", payload, ["--reason", args.reason]):
|
||||||
|
return 0
|
||||||
|
# Could not fork: do it inline rather than lose the session's memories.
|
||||||
|
|
||||||
c = build(args, payload)
|
c = build(args, payload)
|
||||||
if not c.ready:
|
if not c.ready:
|
||||||
return 0
|
return 0
|
||||||
@@ -201,7 +249,11 @@ def cmd_flush(args) -> int:
|
|||||||
|
|
||||||
def cmd_assist_error(args) -> int:
|
def cmd_assist_error(args) -> int:
|
||||||
"""PostToolUse on Bash: a targeted lookup, or silence."""
|
"""PostToolUse on Bash: a targeted lookup, or silence."""
|
||||||
payload = hook_input()
|
payload = worker_payload(args, hook_input()) if args.worker else hook_input()
|
||||||
|
if not args.worker and not args.emit:
|
||||||
|
if detach_worker("assist-error", payload):
|
||||||
|
return 0
|
||||||
|
|
||||||
c = build(args, payload)
|
c = build(args, payload)
|
||||||
if not c.ready:
|
if not c.ready:
|
||||||
return 0
|
return 0
|
||||||
@@ -308,12 +360,14 @@ def cmd_sessions(args) -> int:
|
|||||||
if not events and not args.all:
|
if not events and not args.all:
|
||||||
continue
|
continue
|
||||||
editors = sorted({e.get("editor") or "?" for e in events})
|
editors = sorted({e.get("editor") or "?" for e in events})
|
||||||
|
surfaces = sorted({e.get("surface") for e in events if e.get("surface")})
|
||||||
packs = [e for e in events if e.get("event") == "context"]
|
packs = [e for e in events if e.get("event") == "context"]
|
||||||
flushes = [e for e in events if e.get("event") == "flush"]
|
flushes = [e for e in events if e.get("event") == "flush"]
|
||||||
obs = [e for e in events if e.get("event") == "capture_observe"]
|
obs = [e for e in events if e.get("event") == "capture_observe"]
|
||||||
rows.append({
|
rows.append({
|
||||||
"session": d.name,
|
"session": d.name,
|
||||||
"editor": ",".join(editors) or "-",
|
"editor": ",".join(editors) or "-",
|
||||||
|
"surface": ",".join(surfaces) or "?",
|
||||||
"app": (events[-1].get("app_id") if events else "-") or "-",
|
"app": (events[-1].get("app_id") if events else "-") or "-",
|
||||||
"last": (events[-1].get("at") if events else "") or "",
|
"last": (events[-1].get("at") if events else "") or "",
|
||||||
"packs": len(packs),
|
"packs": len(packs),
|
||||||
@@ -328,12 +382,13 @@ def cmd_sessions(args) -> int:
|
|||||||
print("no sessions with recorded activity yet")
|
print("no sessions with recorded activity yet")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
print(f"{'session':22s} {'editor':14s} {'project':18s} {'packs':>5s} {'served':>6s} {'turns':>5s} {'wrote':>5s} last")
|
print(f"{'session':20s} {'ran via':12s} {'project':17s} {'packs':>5s} {'served':>6s} {'turns':>5s} {'wrote':>5s} last")
|
||||||
for r in rows:
|
for r in rows:
|
||||||
print(f"{r['session'][:22]:22s} {r['editor'][:14]:14s} {r['app'][:18]:18s} "
|
print(f"{r['session'][:20]:20s} {r['surface'][:12]:12s} {r['app'][:17]:17s} "
|
||||||
f"{r['packs']:5d} {r['rows']:6d} {r['turns']:5d} {r['sent']:5d} {r['last']}")
|
f"{r['packs']:5d} {r['rows']:6d} {r['turns']:5d} {r['sent']:5d} {r['last']}")
|
||||||
editors = sorted({e for r in rows for e in r["editor"].split(",") if e and e != "-"})
|
seen = sorted({s for r in rows for s in r["surface"].split(",") if s and s != "?"})
|
||||||
print(f"\nsurfaces seen: {', '.join(editors) or 'none'}")
|
print(f"\nsurfaces seen: {', '.join(seen) or 'unknown'} "
|
||||||
|
f"(editor: {', '.join(sorted({r['editor'] for r in rows}))})")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@@ -468,12 +523,16 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
sp.add_argument("--transcript")
|
sp.add_argument("--transcript")
|
||||||
sp.add_argument("--reason", default="stop")
|
sp.add_argument("--reason", default="stop")
|
||||||
sp.add_argument("--json", action="store_true")
|
sp.add_argument("--json", action="store_true")
|
||||||
|
sp.add_argument("--worker", action="store_true", help=argparse.SUPPRESS)
|
||||||
|
sp.add_argument("--payload-file", help=argparse.SUPPRESS)
|
||||||
sp.set_defaults(fn=cmd_flush)
|
sp.set_defaults(fn=cmd_flush)
|
||||||
|
|
||||||
sp = sub.add_parser("assist-error", help="look up a past fix for an error")
|
sp = sub.add_parser("assist-error", help="look up a past fix for an error")
|
||||||
sp.add_argument("--text")
|
sp.add_argument("--text")
|
||||||
sp.add_argument("--emit", action="store_true",
|
sp.add_argument("--emit", action="store_true",
|
||||||
help="print the block instead of queueing it for the next prompt")
|
help="print the block instead of queueing it for the next prompt")
|
||||||
|
sp.add_argument("--worker", action="store_true", help=argparse.SUPPRESS)
|
||||||
|
sp.add_argument("--payload-file", help=argparse.SUPPRESS)
|
||||||
sp.set_defaults(fn=cmd_assist_error)
|
sp.set_defaults(fn=cmd_assist_error)
|
||||||
|
|
||||||
sp = sub.add_parser("remember", help="store a fact verbatim")
|
sp = sub.add_parser("remember", help="store a fact verbatim")
|
||||||
|
|||||||
@@ -34,6 +34,16 @@ class Ctx:
|
|||||||
def editor(self) -> str:
|
def editor(self) -> str:
|
||||||
return os.environ.get("MEM0_EDITOR", "claude-code")
|
return os.environ.get("MEM0_EDITOR", "claude-code")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def surface(self) -> str:
|
||||||
|
"""A hint at WHICH client ran the hook, since the manifest pins `editor` to the
|
||||||
|
same value everywhere. The desktop app launches hooks without a terminal, so
|
||||||
|
TERM_PROGRAM/TERM is the practical discriminator. A hint, not a guarantee."""
|
||||||
|
prog = os.environ.get("TERM_PROGRAM") or ""
|
||||||
|
if prog:
|
||||||
|
return prog.lower()
|
||||||
|
return "terminal" if os.environ.get("TERM") else "app"
|
||||||
|
|
||||||
def provenance(self, mtype: str) -> dict:
|
def provenance(self, mtype: str) -> dict:
|
||||||
"""Metadata stamped on every write. `type` is what reads filter on -- platform
|
"""Metadata stamped on every write. `type` is what reads filter on -- platform
|
||||||
categories arrive hours later and cannot be relied on at read time."""
|
categories arrive hours later and cannot be relied on at read time."""
|
||||||
@@ -55,7 +65,8 @@ class Ctx:
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
self.state.append("events.jsonl", {
|
self.state.append("events.jsonl", {
|
||||||
"event": event, "editor": self.editor, "app_id": self.app_id,
|
"event": event, "editor": self.editor, "surface": self.surface,
|
||||||
|
"app_id": self.app_id,
|
||||||
"at": time.strftime("%Y-%m-%dT%H:%M:%S"), **fields,
|
"at": time.strftime("%Y-%m-%dT%H:%M:%S"), **fields,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ from mem0_agent.settings import SessionState, Settings
|
|||||||
class Args:
|
class Args:
|
||||||
def __init__(self, **kw):
|
def __init__(self, **kw):
|
||||||
self.session_id = "sess-cli"
|
self.session_id = "sess-cli"
|
||||||
|
self.worker = True # tests exercise the worker path directly
|
||||||
|
self.payload_file = None
|
||||||
for k, v in kw.items():
|
for k, v in kw.items():
|
||||||
setattr(self, k, v)
|
setattr(self, k, v)
|
||||||
|
|
||||||
|
|||||||
@@ -106,11 +106,18 @@ def test_user_prompt_submit_is_declared_local_only(spec, manifest):
|
|||||||
assert entry["background"] is False and entry["blocking"] is True
|
assert entry["background"] is False and entry["blocking"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_flush_and_assist_hooks_are_detached(spec, manifest):
|
def test_write_hooks_are_not_shell_backgrounded(spec, manifest):
|
||||||
|
"""These hooks must NOT be wrapped in `( ... &)`.
|
||||||
|
|
||||||
|
A shell-backgrounded hook loses stdin the instant the parent exits, so the child
|
||||||
|
sees no session_id and no transcript_path and drains the wrong buffer -- observed
|
||||||
|
live as flagged candidates that were never written. The CLI now reads the payload
|
||||||
|
first and re-execs itself detached, so the hook still returns in milliseconds.
|
||||||
|
"""
|
||||||
for event in ("PostToolUse", "Stop", "PreCompact", "SessionEnd"):
|
for event in ("PostToolUse", "Stop", "PreCompact", "SessionEnd"):
|
||||||
command = manifest["hooks"][event][0]["hooks"][0]["command"]
|
command = manifest["hooks"][event][0]["hooks"][0]["command"]
|
||||||
assert command.startswith("(") and command.endswith("&)"), event
|
assert not command.strip().endswith("&)"), f"{event} must not be backgrounded by the shell"
|
||||||
assert ">/dev/null" in command, event
|
assert "mem0-agent" in command, event
|
||||||
|
|
||||||
|
|
||||||
def test_editor_env_is_pinned(manifest):
|
def test_editor_env_is_pinned(manifest):
|
||||||
|
|||||||
Reference in New Issue
Block a user