feat(core,frontends,agents): agent kinds, frontend routing, anthropic on conversations, vault mirror

This commit is contained in:
hh
2026-08-28 03:50:33 +02:00
parent 33ccc78fec
commit 1d8d65b69a
17 changed files with 1009 additions and 292 deletions
@@ -38,14 +38,16 @@ if TYPE_CHECKING:
_log = logging.getLogger("beaver_gateway.frontends.markdown.crossfront")
# User hook: take a turn + vault root, return where the new file should
# live. Returning a relative ``Path`` is treated as relative to the
# vault. ``None`` (the default) keeps the built-in
# ``{vault}/{logged_subdir}/{agent}/{YYYY-MM-DD}_{hex8}.md`` layout.
LogPathFn = "Callable[[TurnRecord, Path], Path]"
__all__ = [
"ChatPathFn",
"CrossFrontendLogger",
"fingerprint_messages",
"strip_trailing_user_scaffold",
]
__all__ = ["CrossFrontendLogger", "LogPathFn", "fingerprint_messages"]
ChatPathFn = "Callable[[str, str, Path], Path]"
"""``(title, agent, vault) -> path`` of a new chat file; relative = under the
vault. ``None`` keeps ``{vault}/{logged_subdir}/{agent}/{YYYY-MM-DD}_{slug}.md``."""
def fingerprint_messages(messages: Iterable[MessageParam]) -> str:
@@ -96,18 +98,18 @@ class CrossFrontendLogger:
*,
vault_path: Path,
logged_subdir: str,
log_path: Callable[[TurnRecord, Path], Path] | None = None,
chat_path: Callable[[str, str, Path], Path] | None = None,
) -> None:
self._vault = vault_path
self._root = vault_path / logged_subdir
self._index: dict[str, Path] = {}
self._lock = asyncio.Lock()
self._log_path_fn = log_path
self._chat_path_fn = chat_path
# When the user supplies a custom path function, files can land
# anywhere in the vault — so we have to scan the whole vault on
# startup to rebuild the fingerprint→path map. With the default
# layout we can bound the scan to ``_logs/``.
self._scan_root = vault_path if log_path is not None else self._root
self._scan_root = vault_path if chat_path is not None else self._root
def warm_index(self) -> None:
"""Scan logged files synchronously, populating the fingerprint map.
@@ -171,7 +173,7 @@ class CrossFrontendLogger:
if target.exists():
existing = target.read_text(encoding="utf-8")
parsed = frontmatter.loads(existing)
body = _strip_trailing_user_scaffold(parsed.content)
body = strip_trailing_user_scaffold(parsed.content)
# We append only the *new* user turn (the last one in
# input_messages, since prior turns are already on disk)
# plus the assistant reply.
@@ -206,13 +208,15 @@ class CrossFrontendLogger:
def _new_file_path(self, record: TurnRecord) -> Path:
"""Pick a fresh filename for a brand-new conversation.
With a user-supplied ``log_path`` we delegate to it (joining a
With a user-supplied ``chat_path`` we delegate to it (joining a
relative result with the vault root). Without one, we fall back
to ``{logged_subdir}/{agent}/{date}_{hex8}.md`` and ensure the
``.md`` suffix in case the user picks a non-md extension by hand.
"""
if self._log_path_fn is not None:
result = self._log_path_fn(record, self._vault)
if self._chat_path_fn is not None:
result = self._chat_path_fn(
record.first_user_text, record.agent_name, self._vault
)
if not result.is_absolute():
result = self._vault / result
if result.suffix != ".md":
@@ -270,7 +274,7 @@ def _render_full_history(messages: list[MessageParam], assistant: Any) -> str:
return body
def _strip_trailing_user_scaffold(body: str) -> str:
def strip_trailing_user_scaffold(body: str) -> str:
"""Drop a trailing empty ``### User:`` block if present.
Cross-frontend turns aren't typed into the file by the human — they