fix(vault,conversations): the envelope diff drops query blocks and frontmatter, history hands the envelope apart from the user's words

This commit is contained in:
hh committed 2026-10-13 01:22:36 +02:00
1 parent 1d1285de46
commit 65564d8541
5 files changed
+139 -10

No files matched your search

+6 -4
View File
@@ -164,9 +164,9 @@ def build_entries(
def messages_from_entries( def messages_from_entries(
entries: Iterable[Mapping[str, Any]], *, with_ts: bool = False entries: Iterable[Mapping[str, Any]], *, stamped: bool = False
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Messages as the model saw them; ``with_ts`` adds when each one was written.""" """Messages as the model saw them; ``stamped`` adds each one's entry id and time."""
out: list[dict[str, Any]] = [] out: list[dict[str, Any]] = []
last_message_id: str | None = None last_message_id: str | None = None
for entry in entries: for entry in entries:
@@ -189,7 +189,8 @@ def messages_from_entries(
"role": "user", "role": "user",
"content": _user_content(content), "content": _user_content(content),
} }
if with_ts: if stamped:
message_out["id"] = entry.get("uuid")
message_out["ts"] = entry.get("timestamp") message_out["ts"] = entry.get("timestamp")
out.append(message_out) out.append(message_out)
last_message_id = None last_message_id = None
@@ -205,7 +206,8 @@ def messages_from_entries(
out[-1]["content"].extend(blocks) out[-1]["content"].extend(blocks)
else: else:
message_out: dict[str, Any] = {"role": "assistant", "content": blocks} message_out: dict[str, Any] = {"role": "assistant", "content": blocks}
if with_ts: if stamped:
message_out["id"] = entry.get("uuid")
message_out["ts"] = entry.get("timestamp") message_out["ts"] = entry.get("timestamp")
out.append(message_out) out.append(message_out)
last_message_id = message_id last_message_id = message_id
+25 -2
View File
@@ -19,6 +19,7 @@ from beaver_gateway.backends.transcript import (
) )
from beaver_gateway.conversations.kinds import KINDS, Kind from beaver_gateway.conversations.kinds import KINDS, Kind
from beaver_gateway.conversations.state import State, iso from beaver_gateway.conversations.state import State, iso
from beaver_gateway.conversations.texts import split_service
from beaver_gateway.frontends.markdown.history import load_messages from beaver_gateway.frontends.markdown.history import load_messages
from beaver_gateway.storage.models import ( from beaver_gateway.storage.models import (
Conversation, Conversation,
@@ -482,9 +483,31 @@ class Rows(State):
return await load_messages( return await load_messages(
session, conversation_id=cast("int", conv.id) session, conversation_id=cast("int", conv.id)
) )
return messages_from_entries( messages = messages_from_entries(
cast("Any", await self.entries(conv)), with_ts=True cast("Any", await self.entries(conv)), stamped=True
) )
return [self._unwrapped(message) for message in messages]
def _unwrapped(self, message: dict[str, Any]) -> dict[str, Any]:
if message["role"] != "user":
return message
content = message["content"]
if isinstance(content, str):
text, service = split_service(content, self._texts)
return (
message
if service is None
else {**message, "content": text, "service": service}
)
for index, block in enumerate(content):
if block.get("type") != "text":
continue
text, service = split_service(str(block.get("text") or ""), self._texts)
if service is not None:
blocks = [*content]
blocks[index] = {**block, "text": text}
return {**message, "content": blocks, "service": service}
return message
async def entries(self, conv: Conversation, *, subpath: str = "") -> list[Any]: async def entries(self, conv: Conversation, *, subpath: str = "") -> list[Any]:
if conv.session_id is None: if conv.session_id is None:
+11
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -22,6 +23,7 @@ __all__ = [
"NewDayContext", "NewDayContext",
"SeedContext", "SeedContext",
"UserSaid", "UserSaid",
"split_service",
] ]
@@ -127,3 +129,12 @@ class ConversationTexts:
"; {fixed} open tool calls received tool_result «{interrupted}»" "; {fixed} open tool calls received tool_result «{interrupted}»"
) )
envelope: EnvelopeTexts = field(default_factory=EnvelopeTexts) envelope: EnvelopeTexts = field(default_factory=EnvelopeTexts)
def split_service(text: str, texts: ConversationTexts) -> tuple[str, str | None]:
bundle = re.escape(texts.bundle_header).replace(r"\{since\}", ".+")
heads = f"{re.escape(texts.envelope.header)}|{bundle}"
match = re.search(rf"(?:\A|\n\n)(?=(?:{heads})$)", text, re.MULTILINE)
if match is None:
return text, None
return text[: match.start()], text[match.end() :]
+43 -3
View File
@@ -13,6 +13,7 @@ import difflib
import fnmatch import fnmatch
import logging import logging
import os import os
import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
@@ -30,6 +31,8 @@ __all__ = ["Change", "VaultFilter", "VaultWatch", "WatchRules"]
_log = logging.getLogger("beaver_gateway.vault.watch") _log = logging.getLogger("beaver_gateway.vault.watch")
_FENCE = re.compile(r"^\s*(`{3,}|~{3,})\s*(.*?)\s*$")
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class WatchRules: class WatchRules:
@@ -44,6 +47,8 @@ class WatchRules:
names: tuple[str, ...] = () names: tuple[str, ...] = ()
ignore: tuple[str, ...] = () ignore: tuple[str, ...] = ()
suffixes: tuple[str, ...] = (".md",) suffixes: tuple[str, ...] = (".md",)
skip_fences: tuple[str, ...] = ("tasks", "dataview", "dataviewjs")
"""Fenced blocks of these languages are queries: ``full`` files drop them."""
def kind(self, rel: str, *, today: str) -> str | None: def kind(self, rel: str, *, today: str) -> str | None:
"""The most specific matching pattern wins, whichever list it is in.""" """The most specific matching pattern wins, whichever list it is in."""
@@ -140,7 +145,7 @@ class VaultWatch:
if kind is None: if kind is None:
continue continue
with contextlib.suppress(OSError): with contextlib.suppress(OSError):
lines = _read_lines(path) lines = self._lines(path, full=kind == "full")
self._seen[rel] = _Seen( self._seen[rel] = _Seen(
lines=lines if kind == "full" else None, count=len(lines) lines=lines if kind == "full" else None, count=len(lines)
) )
@@ -158,7 +163,7 @@ class VaultWatch:
self._pending.pop(rel, None) self._pending.pop(rel, None)
return None return None
stat = path.stat() stat = path.stat()
lines = _read_lines(path) lines = self._lines(path, full=kind == "full")
seen = self._seen.get(rel) seen = self._seen.get(rel)
change = Change( change = Change(
path=rel, path=rel,
@@ -186,12 +191,16 @@ class VaultWatch:
for change in changes: for change in changes:
path = self.root / change.path path = self.root / change.path
with contextlib.suppress(OSError): with contextlib.suppress(OSError):
lines = _read_lines(path) lines = self._lines(path, full=change.full)
self._seen[change.path] = _Seen( self._seen[change.path] = _Seen(
lines=lines if change.full else None, count=len(lines) lines=lines if change.full else None, count=len(lines)
) )
return changes return changes
def _lines(self, path: Path, *, full: bool) -> list[str]:
lines = _read_lines(path)
return _prose(lines, self.rules.skip_fences) if full else lines
def _rel(self, path: Path) -> str: def _rel(self, path: Path) -> str:
return path.resolve().relative_to(self.root.resolve()).as_posix() return path.resolve().relative_to(self.root.resolve()).as_posix()
@@ -216,6 +225,37 @@ def _read_lines(path: Path) -> list[str]:
return path.read_text(encoding="utf-8", errors="replace").splitlines() return path.read_text(encoding="utf-8", errors="replace").splitlines()
def _prose(lines: list[str], skip_fences: tuple[str, ...]) -> list[str]:
start = 0
if lines and lines[0].strip() == "---":
closing = next(
(i for i, line in enumerate(lines[1:], 1) if line.strip() == "---"), None
)
start = closing + 1 if closing is not None else 0
out: list[str] = []
fence: str | None = None
skipping = False
for line in lines[start:]:
match = _FENCE.match(line)
if fence is None and match:
fence = match.group(1)
language = match.group(2).split(maxsplit=1)[0] if match.group(2) else ""
skipping = language.lower() in skip_fences
elif fence is not None and match and _closes(match, fence):
fence = None
if skipping:
skipping = False
continue
if not skipping and line.strip():
out.append(line)
return out
def _closes(match: re.Match[str], fence: str) -> bool:
mark = match.group(1)
return mark[0] == fence[0] and len(mark) >= len(fence) and not match.group(2)
def _added(old: list[str], new: list[str]) -> list[str]: def _added(old: list[str], new: list[str]) -> list[str]:
matcher = difflib.SequenceMatcher(a=old, b=new, autojunk=False) matcher = difflib.SequenceMatcher(a=old, b=new, autojunk=False)
added: list[str] = [] added: list[str] = []
+54 -1
View File
@@ -7,7 +7,11 @@ from test_conversations import ScriptedClient, World, world
from beaver_gateway.conversations.service import UserSaid from beaver_gateway.conversations.service import UserSaid
from beaver_gateway.conversations.envelope import Envelope, RecallContext, render from beaver_gateway.conversations.envelope import Envelope, RecallContext, render
from beaver_gateway.conversations.texts import EnvelopeTexts from beaver_gateway.conversations.texts import (
ConversationTexts,
EnvelopeTexts,
split_service,
)
from beaver_gateway.vault.watch import Change, VaultWatch, WatchRules from beaver_gateway.vault.watch import Change, VaultWatch, WatchRules
__all__ = ["world"] __all__ = ["world"]
@@ -77,6 +81,37 @@ def test_watch_reports_only_added_lines_with_mtime() -> None:
assert only.added == ["- 14:00 снова"] assert only.added == ["- 14:00 снова"]
def test_a_templated_note_shows_its_text_without_queries_or_frontmatter() -> None:
root, watch = vault()
rel = f"дни/{watch.today()}.md"
diary = root / rel
watch._seen.pop(rel)
diary.write_text(
"---\ntags:\n - дни\n---\n## сводка\n- \n\n\n---\n\n# планы\nпросрочены:\n"
"```tasks\nnot done\ndue before 2026-10-13\n```\n"
"обязательно сегодня:\n~~~dataview\nlist\n~~~\n"
"```python\nprint(1)\n```\n06:04 - спать\n"
)
watch.note(diary)
(change,) = watch.take()
assert change.added == [
"## сводка",
"- ",
"---",
"# планы",
"просрочены:",
"обязательно сегодня:",
"```python",
"print(1)",
"```",
"06:04 - спать",
]
append(diary, "```tasks\ndone on 2026-10-13\n```\n07:10 - встал\n")
watch.note(diary)
(later,) = watch.take()
assert later.added == ["07:10 - встал"]
def test_envelope_respects_ceilings_and_names_only_window() -> None: def test_envelope_respects_ceilings_and_names_only_window() -> None:
root, watch = vault() root, watch = vault()
today = watch.today() today = watch.today()
@@ -129,6 +164,24 @@ def test_render_hits_total_ceiling() -> None:
assert lines[1] == "time: 2026-08-26 13:04 (Warsaw)" assert lines[1] == "time: 2026-08-26 13:04 (Warsaw)"
def test_the_users_words_part_from_what_the_gateway_put_under_them() -> None:
texts = ConversationTexts()
envelope = f"{HEADER}\ntime: 2026-10-13 10:00 (UTC)"
bundle = texts.bundle_header.format(since="2026-10-13T10:00:00+00:00")
injects = f"{bundle}\n- [cron] tick"
assert split_service(f"hello\n\n{envelope}", texts) == ("hello", envelope)
assert split_service(f"hello\n\n{envelope}\n\n{injects}", texts) == (
"hello",
f"{envelope}\n\n{injects}",
)
assert split_service(f"hello\n\n{injects}", texts) == ("hello", injects)
assert split_service(f"\n\n{envelope}", texts) == ("", envelope)
assert split_service("hello\n\n[a note of mine]\nmore", texts) == (
"hello\n\n[a note of mine]\nmore",
None,
)
async def test_master_turn_gets_envelope_after_text_and_before_injects( async def test_master_turn_gets_envelope_after_text_and_before_injects(
world: World, world: World,
) -> None: ) -> None: