poller.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  1. """Background poller that archives files observed in hermes state.db messages.
  2. This is the "save everything that passes between agent and user" mechanism.
  3. It reads each profile's ``state.db`` read-only (WAL safe, ``query_only=1``)
  4. and copies any ``MEDIA:<path>`` referenced in assistant/user messages, plus
  5. any local file paths in tool results (``image_generate``, ``tts_tool``, ...)
  6. into the plugin's content-addressed store.
  7. Profile isolation
  8. -----------------
  9. Each profile (agent) - ``default``, ``yaoyao``, ``yaoer``, ``gril``,
  10. ``succubus`` ... - has its OWN ``state.db`` and its OWN plugin data dir
  11. (``<profile_home>/plugin-data/yaoyao``). The poller keeps one watcher state
  12. per profile: a separate RO connection to that profile's state.db, its own
  13. mtime watermark, and its own waterline. Files are ingested into that
  14. profile's store with ``owner_profile=<profile name>``.
  15. Profiles are discovered via ``profiles_to_serve(multiplex=True)`` and
  16. re-scanned periodically so newly created profiles are picked up.
  17. Lightweight polling (no full-content scan every cycle)
  18. ------------------------------------------------------
  19. Each cycle, per profile:
  20. 1. ``stat(state.db)`` - if mtime unchanged since last cycle, SKIP entirely
  21. (no SQL). Near-zero cost. [unchanged from the original poller]
  22. 2. ``SELECT id FROM messages WHERE id > <waterline> LIMIT N`` - reads only
  23. the rowid, never the content blob. Cheap even on a huge messages table.
  24. 3. Drop ids already in the plugin's ``processed_messages`` side table
  25. (in-process set lookup) so a replayed/re-flushed row isn't re-read.
  26. 4. Only for the remaining NEW ids: ``SELECT id, session_id, role, content,
  27. tool_name FROM messages WHERE id IN (...)``. This is the only place we
  28. touch message content, and only for genuinely new rows.
  29. Why a side table instead of state.db triggers: we never modify state.db
  30. (cross-process safety; state.db is owned by the gateway). The
  31. ``processed_messages`` table lives in the plugin's OWN index.sqlite3, which
  32. the dashboard process owns exclusively.
  33. """
  34. from __future__ import annotations
  35. import json
  36. import logging
  37. import os
  38. import re
  39. import sqlite3
  40. import threading
  41. import time
  42. from pathlib import Path
  43. from typing import Optional
  44. import sys
  45. # When loaded by the dashboard plugin loader (web_server.py:17256) this file is
  46. # imported by path as a standalone module, so __package__ is None and relative
  47. # imports fail. Make this directory importable so `import store` works both
  48. # here and from plugin_api.py.
  49. _THIS_DIR = str(Path(__file__).resolve().parent)
  50. if _THIS_DIR not in sys.path:
  51. sys.path.insert(0, _THIS_DIR)
  52. import store # noqa: E402
  53. log = logging.getLogger("yaoyao.poller")
  54. POLL_INTERVAL = 2.0 # seconds between cycles (per profile: stat-first, so cheap)
  55. BATCH_LIMIT = 1000
  56. PROFILE_RESCAN_EVERY = 30 # cycles between profile re-discovery passes
  57. # Bump this whenever extraction learns a new persisted message shape. Each
  58. # profile then replays its complete history once; Store deduplication keeps the
  59. # archive idempotent while newly-recognized references are added.
  60. SCANNER_CONTRACT_VERSION = 1
  61. # MEDIA paths are normally bare tokens. Quoted paths additionally support
  62. # spaces. A quote terminates the bare form so MEDIA references embedded in a
  63. # GROUP_CONTEXT_JSON string do not swallow the following JSON fields.
  64. MEDIA_RE = re.compile(
  65. r"MEDIA:\s*(?:\"(?P<double>[^\"]+)\"|'(?P<single>[^']+)'|"
  66. r"(?P<plain>[^\s\"'\\]+))"
  67. )
  68. # Tools whose result JSON carries a local file path we should archive.
  69. # Keys are the tool_name; values are the candidate JSON fields that may hold
  70. # a local path (URLs are skipped - only real local files get archived).
  71. MEDIA_TOOL_FIELDS = {
  72. "image_generate": ("image", "host_image", "agent_visible_image", "outputpath", "filepath"),
  73. "video_generate": ("video", "outputpath", "filepath", "path"),
  74. "text_to_speech": ("audio", "outputpath", "filepath", "path"),
  75. "tts_tool": ("audio", "outputpath", "filepath", "path"),
  76. "write_file": (
  77. "path",
  78. "filepath",
  79. "file_path",
  80. "output_path",
  81. "resolved_path",
  82. ),
  83. }
  84. # Roles we inspect. 'session_meta'/'system' carry no user-visible files.
  85. TRACKED_ROLES = ("assistant", "tool", "user")
  86. # ---------------------------------------------------------------------------
  87. # state.db resolution
  88. # ---------------------------------------------------------------------------
  89. def _profile_state_db(profile_home: Path) -> Path:
  90. """Path to a profile's state.db (profile_home / state.db)."""
  91. return profile_home / "state.db"
  92. def _open_state_db(path: Path) -> Optional[sqlite3.Connection]:
  93. """Open a read-only connection. Returns None if the db doesn't exist."""
  94. if not path.is_file():
  95. return None
  96. try:
  97. conn = sqlite3.connect(
  98. f"file:{path}?mode=ro", uri=True, timeout=5.0, check_same_thread=False
  99. )
  100. conn.row_factory = sqlite3.Row
  101. conn.execute("PRAGMA query_only=1")
  102. conn.execute("PRAGMA busy_timeout=5000")
  103. return conn
  104. except sqlite3.Error as e:
  105. log.warning("poller: cannot open %s: %s", path, e)
  106. return None
  107. # ---------------------------------------------------------------------------
  108. # Path extraction (profile-independent)
  109. # ---------------------------------------------------------------------------
  110. def _is_local_path(s: str) -> bool:
  111. """True if s looks like a real local file path (not a URL, not empty)."""
  112. if not s or len(s) < 2:
  113. return False
  114. if "://" in s[:12]: # http:// https:// file:// oss url etc.
  115. return False
  116. if s.startswith("data:"):
  117. return False
  118. # Must have a path-ish shape: absolute, or has an extension.
  119. if s.startswith("/"):
  120. return True
  121. if "." in os.path.basename(s):
  122. return True
  123. return False
  124. def _paths_from_assistant(content: str) -> list[str]:
  125. """MEDIA: tags in assistant/user text content."""
  126. found: list[str] = []
  127. for match in MEDIA_RE.finditer(content):
  128. path = next((value for value in match.groups() if value is not None), "")
  129. if _is_local_path(path):
  130. found.append(path)
  131. return found
  132. def _paths_from_tool(content: str, tool_name: str) -> list[str]:
  133. """Local paths buried in a tool result JSON body."""
  134. fields = MEDIA_TOOL_FIELDS.get(tool_name)
  135. if not fields:
  136. return []
  137. try:
  138. data = json.loads(content)
  139. except (json.JSONDecodeError, TypeError):
  140. return []
  141. found: list[str] = []
  142. if isinstance(data, dict):
  143. for f in fields:
  144. v = data.get(f)
  145. if isinstance(v, str) and _is_local_path(v):
  146. found.append(v)
  147. elif isinstance(v, list):
  148. for item in v:
  149. if isinstance(item, str) and _is_local_path(item):
  150. found.append(item)
  151. elif isinstance(v, dict):
  152. for sub in v.values():
  153. if isinstance(sub, str) and _is_local_path(sub):
  154. found.append(sub)
  155. return found
  156. # ---------------------------------------------------------------------------
  157. # Profile discovery
  158. # ---------------------------------------------------------------------------
  159. def _discover_profiles() -> list[tuple[str, Path]]:
  160. """Return ``(profile_name, profile_home)`` for every profile whose
  161. ``state.db`` exists.
  162. Always includes ``default`` (-> ``~/.hermes``) when its state.db exists.
  163. Named profiles come from ``profiles_to_serve(multiplex=True)``; if that
  164. import is unavailable (e.g. stripped-down runtime), falls back to a
  165. directory scan of ``~/.hermes/profiles/``.
  166. """
  167. try:
  168. from hermes_cli.profiles import profiles_to_serve # type: ignore
  169. pairs = profiles_to_serve(multiplex=True)
  170. except Exception:
  171. # Fallback: directory scan of ~/.hermes/profiles/ + default.
  172. try:
  173. env = os.environ.get("HERMES_HOME", "").strip()
  174. root = Path(env) if env else Path.home() / ".hermes"
  175. except Exception:
  176. root = Path.home() / ".hermes"
  177. pairs = [("default", root)]
  178. prof_root = root / "profiles"
  179. if prof_root.is_dir():
  180. for entry in sorted(prof_root.iterdir()):
  181. if entry.is_dir() and re.match(r"^[a-z0-9][a-z0-9_-]{0,63}$", entry.name):
  182. pairs.append((entry.name, entry))
  183. # Keep only profiles whose state.db exists (a profile dir may exist but
  184. # never have been started -> no state.db -> nothing to watch).
  185. out: list[tuple[str, Path]] = []
  186. for name, home in pairs:
  187. try:
  188. home = Path(home)
  189. except Exception:
  190. continue
  191. if _profile_state_db(home).is_file():
  192. out.append((name, home))
  193. if not out:
  194. log.debug("poller: no profiles with an existing state.db found")
  195. return out
  196. # ---------------------------------------------------------------------------
  197. # Per-profile watcher state
  198. # ---------------------------------------------------------------------------
  199. class _ProfileWatcher:
  200. """State for one profile: RO state.db conn + waterline + mtime."""
  201. __slots__ = ("profile", "home", "db_path", "data_root", "store",
  202. "conn", "last_id", "last_mtime", "max_known_id")
  203. def __init__(self, profile: str, home: Path):
  204. self.profile = profile
  205. self.home = Path(home)
  206. self.db_path = _profile_state_db(self.home)
  207. self.data_root = store.data_root_for_profile(profile)
  208. self.store = store.get_store(self.data_root)
  209. self.conn: Optional[sqlite3.Connection] = None
  210. self.last_id = 0
  211. self.last_mtime: int = -1
  212. # Highest message id known to exist at connect time. While last_id <
  213. # max_known_id we're still backfilling history and must NOT short-circuit
  214. # on mtime (the db isn't changing during a historical catch-up, but
  215. # there's still work to do).
  216. self.max_known_id: int = 0
  217. def ensure_connection(self) -> Optional[sqlite3.Connection]:
  218. if self.conn is not None:
  219. return self.conn
  220. conn = _open_state_db(self.db_path)
  221. if conn is None:
  222. return None
  223. self.conn = conn
  224. self._establish_baseline()
  225. log.info("poller[%s]: connected to %s, waterline=%d",
  226. self.profile, self.db_path, self.last_id)
  227. return conn
  228. def _establish_baseline(self) -> None:
  229. """Set the starting waterline for this profile.
  230. On a fresh profile we start at id=0 so the ENTIRE history is archived
  231. - existing files referenced by old messages are exactly what users
  232. expect to see in the library ("谁的文件" includes files exchanged in
  233. past conversations). The ``processed_messages`` side table keeps this
  234. idempotent: a one-time backfill, after which each cycle only touches
  235. genuinely new ids.
  236. If we already scanned some messages (resumed after a restart), the
  237. waterline is taken from ``processed_messages`` so we never re-read
  238. content for already-archived rows.
  239. """
  240. if self.store.prepare_scanner_version(SCANNER_CONTRACT_VERSION):
  241. log.info(
  242. "poller[%s]: scanner contract v%d requires a full-session replay",
  243. self.profile,
  244. SCANNER_CONTRACT_VERSION,
  245. )
  246. wl = self.store.processed_waterline()
  247. if wl > 0:
  248. self.last_id = wl
  249. # Even on resume we need max_known_id to detect catch-up. Probe it
  250. # lazily on first poll if it's still 0.
  251. try:
  252. row = self.conn.execute("SELECT MAX(id) FROM messages").fetchone()
  253. self.max_known_id = int(row[0] or 0)
  254. except Exception:
  255. self.max_known_id = 0
  256. log.info("poller[%s]: resumed at message id=%d (from processed_messages, max=%d)",
  257. self.profile, wl, self.max_known_id)
  258. return
  259. # Fresh: start from 0 to backfill history.
  260. try:
  261. row = self.conn.execute("SELECT MAX(id) FROM messages").fetchone()
  262. total = int(row[0] or 0)
  263. except Exception:
  264. total = 0
  265. self.last_id = 0
  266. self.max_known_id = total
  267. log.info("poller[%s]: backfill from id=0 (history has %d messages)",
  268. self.profile, total)
  269. def close(self) -> None:
  270. if self.conn is not None:
  271. try:
  272. self.conn.close()
  273. except Exception:
  274. pass
  275. self.conn = None
  276. # ---------------------------------------------------------------------------
  277. # Poll loop (one cycle, one profile)
  278. # ---------------------------------------------------------------------------
  279. def _process_rows(watcher: _ProfileWatcher, rows: list[sqlite3.Row]) -> None:
  280. """Extract + ingest every file path in ``rows``; mark them processed."""
  281. profile = watcher.profile
  282. st = watcher.store
  283. for row in rows:
  284. mid = int(row["id"])
  285. role = row["role"]
  286. content = row["content"] or ""
  287. session_id = row["session_id"]
  288. tool_name = row["tool_name"]
  289. if role == "tool":
  290. paths = _paths_from_tool(content, tool_name or "")
  291. sender = "agent"
  292. kind = "tool_result"
  293. else:
  294. paths = _paths_from_assistant(content)
  295. sender = "user" if role == "user" else "agent"
  296. kind = "media_tag"
  297. for p in paths:
  298. try:
  299. st.ingest(
  300. p,
  301. sender=sender,
  302. source_kind=kind,
  303. source_message_id=mid,
  304. session_id=session_id,
  305. tool_name=tool_name,
  306. display_name=os.path.basename(p) or Path(p).name,
  307. owner_profile=profile,
  308. )
  309. except Exception as e:
  310. log.debug("poller[%s]: ingest %s failed: %s", profile, p, e)
  311. # Mark processed even if no paths were found, so we never re-read it.
  312. st.mark_processed(mid)
  313. def _poll_profile(watcher: _ProfileWatcher) -> None:
  314. """One poll cycle for one profile. Lightweight: stat -> id-only probe ->
  315. fetch content only for genuinely new, unprocessed rows."""
  316. conn = watcher.ensure_connection()
  317. if conn is None:
  318. return
  319. # mtime short-circuit: if state.db hasn't changed since last cycle, skip
  320. # the SQL entirely. This is the common steady-state case (no new messages).
  321. # BUT only when we've finished the historical backfill (last_id >=
  322. # max_known_id). While catching up on history the db isn't changing, yet
  323. # there's still work to do, so we must keep probing.
  324. try:
  325. mtime = int(watcher.db_path.stat().st_mtime)
  326. except OSError:
  327. return
  328. backfilling = watcher.last_id < watcher.max_known_id
  329. if not backfilling and mtime == watcher.last_mtime:
  330. return
  331. # id-only probe: read just rowids for messages past the waterline. Never
  332. # touches the content blob, so it's cheap even on a huge messages table.
  333. try:
  334. id_rows = conn.execute(
  335. "SELECT id FROM messages "
  336. "WHERE id > ? AND role IN ('assistant','tool','user') "
  337. "ORDER BY id LIMIT ?",
  338. (watcher.last_id, BATCH_LIMIT),
  339. ).fetchall()
  340. except sqlite3.Error as e:
  341. log.warning("poller[%s]: id probe failed: %s; reopening", watcher.profile, e)
  342. watcher.close()
  343. return
  344. if not id_rows:
  345. watcher.last_mtime = mtime
  346. return
  347. new_ids = [int(r["id"]) for r in id_rows]
  348. # Drop ids we've already scanned (replayed / re-flushed rows).
  349. already = watcher.store.processed_set(new_ids)
  350. fresh = [i for i in new_ids if i not in already]
  351. if not fresh:
  352. # All already processed; just advance waterline + mtime.
  353. watcher.last_id = max(new_ids)
  354. watcher.last_mtime = mtime
  355. return
  356. # Fetch content ONLY for genuinely new rows.
  357. placeholders = ",".join("?" * len(fresh))
  358. try:
  359. rows = conn.execute(
  360. f"SELECT id, session_id, role, content, tool_name FROM messages "
  361. f"WHERE id IN ({placeholders})",
  362. fresh,
  363. ).fetchall()
  364. except sqlite3.Error as e:
  365. log.warning("poller[%s]: content fetch failed: %s", watcher.profile, e)
  366. return
  367. _process_rows(watcher, rows)
  368. watcher.last_id = max(new_ids)
  369. watcher.last_mtime = mtime
  370. log.debug("poller[%s]: processed %d new messages, waterline %d",
  371. watcher.profile, len(fresh), watcher.last_id)
  372. # ---------------------------------------------------------------------------
  373. # Lifecycle
  374. # ---------------------------------------------------------------------------
  375. _lock = threading.Lock()
  376. _watchers: dict[str, _ProfileWatcher] = {}
  377. _cycle_count = 0
  378. _stop_event: Optional[threading.Event] = None
  379. _thread: Optional[threading.Thread] = None
  380. def _refresh_profiles() -> None:
  381. """Reconcile the watcher set with the on-disk profiles.
  382. Adds watchers for newly-seen profiles; leaves removed profiles' watchers
  383. in place (their state.db still exists on disk - we keep reading it until
  384. the dir is gone, then they just never get new data).
  385. """
  386. discovered = _discover_profiles()
  387. for name, home in discovered:
  388. if name not in _watchers:
  389. try:
  390. w = _ProfileWatcher(name, home)
  391. # init the store so the data dir exists immediately
  392. w.store.init()
  393. _watchers[name] = w
  394. log.info("poller[%s]: watching %s", name, w.db_path)
  395. except Exception as e:
  396. log.warning("poller[%s]: failed to set up watcher: %s", name, e)
  397. def poll_now() -> None:
  398. """Run one poll cycle across all profiles. Idempotent / thread-safe."""
  399. global _cycle_count
  400. with _lock:
  401. _cycle_count += 1
  402. if _cycle_count % PROFILE_RESCAN_EVERY == 0:
  403. _refresh_profiles()
  404. elif not _watchers:
  405. _refresh_profiles()
  406. for name, watcher in list(_watchers.items()):
  407. try:
  408. _poll_profile(watcher)
  409. except Exception as e:
  410. log.debug("poller[%s]: cycle error: %s", name, e)
  411. def _loop(stop_event: threading.Event) -> None:
  412. log.info("poller: started (interval=%.1fs)", POLL_INTERVAL)
  413. while not stop_event.is_set():
  414. try:
  415. poll_now()
  416. except Exception as e:
  417. log.exception("poller: unexpected error: %s", e)
  418. stop_event.wait(POLL_INTERVAL)
  419. # Clean up connections on stop.
  420. for watcher in _watchers.values():
  421. watcher.close()
  422. _watchers.clear()
  423. log.info("poller: stopped")
  424. def start() -> None:
  425. """Start the poller daemon thread. Idempotent."""
  426. global _stop_event, _thread
  427. if _thread is not None and _thread.is_alive():
  428. return
  429. with _lock:
  430. _refresh_profiles()
  431. _stop_event = threading.Event()
  432. _thread = threading.Thread(
  433. target=_loop, args=(_stop_event,), name="yaoyao-poller", daemon=True
  434. )
  435. _thread.start()
  436. log.info("poller: starting (profiles=%s)", sorted(_watchers.keys()))
  437. def stop() -> None:
  438. """Signal the poller to stop (for tests / clean shutdown)."""
  439. global _stop_event
  440. if _stop_event is not None:
  441. _stop_event.set()