"""Claude Code hook: PostToolUse — append tool I/O to oG-Memory session buffer.
After each successful tool call, serializes tool_name / tool_input / tool_response
into one message with role ``tool`` and POSTs to /api/v1/sessions/<id>/messages.
Complements call_after_turn (user/assistant from transcript) with tool context.
HTTP headers and identity: :mod:`ogm_plugin_request`.
Input (stdin): PostToolUse JSON (Claude Code hooks)
Output (stdout): none (exit 0); errors logged to stderr only
"""
import json
import sys
from pathlib import Path
import urllib.error
import urllib.request
_SCRIPT_DIR = Path(__file__).resolve().parent
if str(_SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPT_DIR))
from ogm_plugin_request import base_api_url, base_ctx, http_plugin_headers
POST_TIMEOUT = 8
_SIDE_EFFECT_TOOLS = frozenset(
{"Write", "Edit", "MultiEdit", "Bash", "NotebookEdit"}
)
_MAX_CHUNK = 10_000
_PREVIEW = 400
def log(msg: str) -> None:
print(f"[call_add_session_message] {msg}", file=sys.stderr)
def _truncate(text: str, max_len: int = _MAX_CHUNK) -> str:
if len(text) <= max_len:
return text
return text[:max_len] + f"\n… [{len(text) - max_len} more chars omitted]"
def _json_chunk(obj: object) -> str:
try:
raw = json.dumps(obj, ensure_ascii=False, default=str)
except TypeError:
raw = str(obj)
return _truncate(raw)
def _build_tool_text(data: dict) -> str:
name = data.get("tool_name") or "unknown_tool"
t_in = data.get("tool_input")
t_out = data.get("tool_response")
lines = [
f"[PostToolUse] {name}",
f"tool_input: {_json_chunk(t_in)}",
f"tool_response: {_json_chunk(t_out)}",
]
return "\n".join(lines)
def post_session_message(session_id: str, role: str, content: str) -> bool:
url = f"{base_api_url()}/api/v1/sessions/{session_id}/messages"
body = {**base_ctx(session_id), "role": role, "content": content}
payload = json.dumps(body, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
url,
data=payload,
headers=http_plugin_headers(),
method="POST",
)
log(f"POST {url} role={role} content_len={len(content)}")
try:
with urllib.request.urlopen(req, timeout=POST_TIMEOUT) as resp:
raw = resp.read().decode("utf-8")
log(f"Response {resp.status}: {raw[:200]}")
return 200 <= resp.status < 300
except urllib.error.URLError as e:
log(f"HTTP error: {e}")
return False
except Exception as e:
log(f"Request failed: {e}")
return False
def _preview_val(obj: object) -> str:
try:
s = json.dumps(obj, ensure_ascii=False, default=str)
except TypeError:
s = str(obj)
if len(s) > _PREVIEW:
s = s[:_PREVIEW] + f"…(+{len(s) - _PREVIEW})"
return s
def main() -> None:
raw = sys.stdin.read()
try:
data = json.loads(raw) if raw.strip() else {}
except json.JSONDecodeError:
log("Invalid JSON on stdin")
sys.exit(0)
session_id = str(data.get("session_id") or "unknown")
if session_id == "unknown":
log("No session_id, skip")
sys.exit(0)
tool_name = data.get("tool_name") or "unknown_tool"
if tool_name not in _SIDE_EFFECT_TOOLS:
log(f"skip non-side-effect tool (matcher widened or stale config): {tool_name}")
sys.exit(0)
t_in = data.get("tool_input")
t_out = data.get("tool_response")
log(
f"tool={tool_name} input: {_preview_val(t_in)} output: {_preview_val(t_out)}"
)
text = _build_tool_text(data)
if not text.strip():
sys.exit(0)
ok = post_session_message(session_id, "tool", text)
if not ok:
log(f"Failed to post tool message for session={session_id}, tool={tool_name}")
sys.exit(0)
if __name__ == "__main__":
main()