mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-08 22:20:54 +08:00
- Add SQLAlchemy Core repos, pg adapter/compat, assistant_store factory, Alembic bootstrap and migration/cutover scripts. - Harden chat_message writes (NUL scrub for PG), turn_uuid on attempt failure, WS turn_runner fallbacks and gateway executed_turn_uuid init. - start_gateway: log paths, PS7 stderr handling via cmd, background stdout/stderr redirect; runtime assistant_runtime_log_dir export. - Ops: clear_all_chat_sessions with PG-only --postgresql and env-gated wipe; clear_postgres_chat_sessions.ps1. - Tests: SA repos, pg compat, persist fallback, smoke env isolation; CI and docs touch-ups. Co-authored-by: Cursor <cursoragent@cursor.com>
167 lines
5 KiB
Python
167 lines
5 KiB
Python
from __future__ import annotations
|
|
|
|
import datetime as _dt
|
|
import os
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
from svc.persistence.assistant_store import get_assistant_store
|
|
|
|
|
|
HOOK_KEY = "session-memory"
|
|
|
|
|
|
def _ensure_repo_imports() -> None:
|
|
# Allow importing repository modules when hook is loaded by path.
|
|
# handler.py is under oclaw/hooks/bundled/session-memory/
|
|
repo = Path(__file__).resolve().parents[5]
|
|
if str(repo) not in sys.path:
|
|
sys.path.insert(0, str(repo))
|
|
|
|
|
|
def _resolve_hook_cfg(cfg: Any) -> Dict[str, Any]:
|
|
if not isinstance(cfg, dict):
|
|
return {}
|
|
hooks = cfg.get("hooks") if isinstance(cfg.get("hooks"), dict) else {}
|
|
internal = hooks.get("internal") if isinstance(hooks.get("internal"), dict) else {}
|
|
entries = internal.get("entries") if isinstance(internal.get("entries"), dict) else {}
|
|
row = entries.get(HOOK_KEY)
|
|
return row if isinstance(row, dict) else {}
|
|
|
|
|
|
_SLUG_SAFE_RE = re.compile(r"[^a-z0-9]+")
|
|
|
|
|
|
def _slugify(text: str, *, max_len: int = 40) -> str:
|
|
s = (text or "").strip().lower()
|
|
s = _SLUG_SAFE_RE.sub("-", s).strip("-")
|
|
if not s:
|
|
return ""
|
|
s = s[:max_len].strip("-")
|
|
return s or ""
|
|
|
|
|
|
def _fallback_time_slug(ts: _dt.datetime) -> str:
|
|
return ts.strftime("%H%M")
|
|
|
|
|
|
def _workspace_dir_from_event(event: Any) -> Optional[Path]:
|
|
ctx = getattr(event, "context", None)
|
|
if not isinstance(ctx, dict):
|
|
return None
|
|
ws = ctx.get("workspaceDir")
|
|
if isinstance(ws, str) and ws.strip():
|
|
return Path(ws).expanduser()
|
|
env_ws = str(os.getenv("OCLAW_WORKSPACE") or "").strip()
|
|
if env_ws:
|
|
return Path(env_ws).expanduser()
|
|
return None
|
|
|
|
|
|
def _recent_conversation_lines(msgs: List[Any], *, max_pairs: int) -> str:
|
|
"""
|
|
Render a minimal markdown "conversation" block.
|
|
"""
|
|
# Keep only user/assistant/tool-ish messages; show role prefixes.
|
|
lines: list[str] = []
|
|
for m in msgs[-max(1, max_pairs * 2) :]:
|
|
role = str(getattr(m, "role", "") or "").strip() or "unknown"
|
|
content = str(getattr(m, "content", "") or "").strip()
|
|
if not content:
|
|
continue
|
|
if role.lower() == "assistant":
|
|
prefix = "Assistant"
|
|
elif role.lower() == "user":
|
|
prefix = "User"
|
|
else:
|
|
prefix = role
|
|
lines.append(f"- **{prefix}**: {content}")
|
|
return "\n".join(lines).strip()
|
|
|
|
|
|
def handle(event: Any) -> None:
|
|
# Only trigger on command new/reset
|
|
if getattr(event, "type", None) != "command":
|
|
return
|
|
action = str(getattr(event, "action", "") or "").strip().lower()
|
|
if action not in {"new", "reset"}:
|
|
return
|
|
|
|
ctx = getattr(event, "context", None)
|
|
if not isinstance(ctx, dict):
|
|
ctx = {}
|
|
|
|
cfg = ctx.get("cfg")
|
|
hook_cfg = _resolve_hook_cfg(cfg)
|
|
if hook_cfg.get("enabled") is False:
|
|
return
|
|
|
|
max_msgs = hook_cfg.get("messages")
|
|
try:
|
|
max_msgs_n = int(max_msgs) if max_msgs is not None else 15
|
|
except Exception:
|
|
max_msgs_n = 15
|
|
max_msgs_n = max(5, min(max_msgs_n, 200))
|
|
|
|
ts = getattr(event, "timestamp", None)
|
|
if not isinstance(ts, _dt.datetime):
|
|
ts = _dt.datetime.now(tz=_dt.timezone.utc)
|
|
if ts.tzinfo is None:
|
|
ts = ts.replace(tzinfo=_dt.timezone.utc)
|
|
|
|
ws_dir = _workspace_dir_from_event(event)
|
|
if ws_dir is None:
|
|
# Nothing to do without a workspace dir target.
|
|
return
|
|
ws_dir.mkdir(parents=True, exist_ok=True)
|
|
mem_dir = ws_dir / "memory"
|
|
mem_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
session_id = str(getattr(event, "sessionKey", "") or "").strip() or "unknown"
|
|
|
|
# Fetch recent messages from sqlite.
|
|
try:
|
|
_ensure_repo_imports()
|
|
from svc.config.paths import db_path # type: ignore
|
|
from svc.persistence.sqlite_store import SqliteStore # type: ignore
|
|
|
|
store = get_assistant_store()
|
|
msgs = store.get_messages(session_id=session_id, limit=max_msgs_n)
|
|
except Exception:
|
|
msgs = []
|
|
|
|
convo = _recent_conversation_lines(list(msgs or []), max_pairs=max_msgs_n)
|
|
|
|
# Build slug from first user message in window.
|
|
base_slug = ""
|
|
for m in list(msgs or []):
|
|
if str(getattr(m, "role", "") or "").strip().lower() != "user":
|
|
continue
|
|
t = str(getattr(m, "content", "") or "").strip()
|
|
if t:
|
|
base_slug = _slugify(t)
|
|
break
|
|
if not base_slug:
|
|
base_slug = _fallback_time_slug(ts)
|
|
|
|
date_str = ts.date().isoformat()
|
|
filename = f"{date_str}-{base_slug}.md"
|
|
target = mem_dir / filename
|
|
|
|
header = [
|
|
f"# Session: {date_str} {ts.strftime('%H:%M:%S')} UTC",
|
|
"",
|
|
f"- **Session Key**: {session_id}",
|
|
f"- **Action**: {action}",
|
|
"",
|
|
]
|
|
body: list[str] = []
|
|
if convo:
|
|
body.extend(["## Conversation Summary", "", convo, ""])
|
|
else:
|
|
body.extend(["## Conversation Summary", "", "- (no messages found)", ""])
|
|
|
|
target.write_text("\n".join(header + body).strip() + "\n", encoding="utf-8")
|
|
|