"""Carrying a long conversation forward without carrying all of it. Past a certain length every chat stops working: the window fills, and the only options are to lose the beginning or to start again. Compaction summarises the earlier turns and sends the summary in their place. **The messages are kept.** They stay in the transcript, collapsed behind a divider, and simply stop being part of the request. A summary that turned out badly is then a bad turn rather than a lost conversation, which is what makes the button safe to press and automatic compaction safe to have at all. **Stored on the Chat, not as a synthetic Message.** A synthetic row would need a role: `system` breaks the one-system-message rule the moment `build_messages` emits it beside the harness, and `user`/`assistant` makes it a turn people can edit, regenerate from and copy, indistinguishable from a real one in all four places a bubble is rendered. Worse, "editing rewinds, it does not branch" would silently delete it and leave no marker that compaction had ever happened. """ from __future__ import annotations import logging from datetime import UTC, datetime from sqlalchemy import select from sqlalchemy.orm import Session as DBSession from lembas.db.models import KIND_MESSAGES, ROLE_ASSISTANT, Chat, Message from lembas.services import metrics as metrics_service from lembas.services import settings_store, tokens log = logging.getLogger(__name__) # What the summariser is shown. Past this the oldest turns are dropped with a # marker: a transcript that does not fit the window it is protecting is no use. MAX_TRANSCRIPT_CHARS = 24_000 # Settings key, in the GENERAL group. 0 turns automatic compaction off; the # button still works, because a person asking for it does not need a threshold. THRESHOLD_KEY = "compact_threshold" DEFAULT_THRESHOLD = 95 def threshold(db: DBSession) -> int: value = settings_store.get(db, THRESHOLD_KEY) return int(value) if isinstance(value, (int, float)) else DEFAULT_THRESHOLD def moment(message: Message) -> datetime: """A message's timestamp, always comparable. SQLite does not store the offset, so a row loaded from disk comes back naive while one still in the session's identity map keeps the tzinfo it was created with. Comparing the two raises, and every comparison here is between exactly those: a cutoff fetched by id against history loaded in bulk. `files.sweep_orphans` already normalises for the same reason. """ created = message.created_at return created if created.tzinfo is not None else created.replace(tzinfo=UTC) def cutoff_message(db: DBSession, chat: Chat) -> Message | None: """The message compaction reached, or None if it never has. There is no foreign key to null this out on an upgraded database, so the check is load-bearing rather than defensive: an id pointing at a message that has been deleted means the boundary no longer describes anything, and the chat has to read as uncompacted. """ if not chat.compact_summary or not chat.compacted_through_id: return None message = db.get(Message, chat.compacted_through_id) if message is None or message.chat_id != chat.id: return None return message def reset(chat: Chat) -> None: """Forget that this chat was ever compacted.""" chat.compact_summary = "" chat.compacted_through_id = None chat.compacted_at = None def apply(chat: Chat, *, summary: str, upto: Message) -> None: """Record a summary and move the boundary. Caller commits.""" chat.compact_summary = summary.strip() chat.compacted_through_id = upto.id chat.compacted_at = datetime.now(UTC) def split( db: DBSession, chat: Chat, messages: list[Message] ) -> tuple[list[Message], list[Message]]: """(summarised, live) -- what is behind the divider, and what is not.""" cutoff = cutoff_message(db, chat) if cutoff is None: return [], list(messages) boundary = moment(cutoff) return ( # A prompt still waiting to be sent stays on the live side whatever its # timestamp says. Folding one into the "earlier messages" details would # hide the only place its Send now and Discard exist, and it has not # been part of any request to summarise. [m for m in messages if not m.queued and moment(m) <= boundary], [m for m in messages if m.queued or moment(m) > boundary], ) def last_complete(db: DBSession, chat: Chat) -> Message | None: """The newest finished assistant turn: where compaction should stop. Landing on a reply rather than a question means the kept history starts on a user turn, which is what every chat template expects. """ return db.scalar( select(Message) .where( Message.chat_id == chat.id, Message.role == ROLE_ASSISTANT, Message.complete.is_(True), ) .order_by(Message.created_at.desc()) ) def transcript(db: DBSession, chat: Chat, *, upto: Message) -> str: """The turns to summarise, oldest first, as plain text. Only the delta since the last compaction: the previous summary is supplied separately, and the instruction asks for one record, so each summary subsumes the one before it. Re-summarising the whole chat every time grows quadratically and eventually exceeds the very window this protects. """ previous = cutoff_message(db, chat) query = select(Message).where( Message.chat_id == chat.id, Message.created_at <= upto.created_at, Message.error == "", # Not yet sent to anything. Summarising it would fold words the model # has never seen into the record, and then deliver them again later. Message.queued.is_(False), ) if previous is not None: query = query.where(Message.created_at > previous.created_at) lines: list[str] = [] for message in db.scalars(query.order_by(Message.created_at)): body = message.content.strip() if not body: continue lines.append(f"{message.role}: {body}") text = "\n\n".join(lines) if len(text) > MAX_TRANSCRIPT_CHARS: # Keep the most recent part: the older it is, the more likely the # previous summary already covers it. text = "[earlier turns omitted]\n\n" + text[-MAX_TRANSCRIPT_CHARS:] return text def previous_summary_block(chat: Chat) -> str: """The earlier summary, headed, or "" on a first compaction. Empty is fine to pass straight through: `prompts.substitute` drops a line that held a known variable and expanded to nothing, so the prompt does not end up with a hole where a heading was. """ if not chat.compact_summary.strip(): return "" return "## Summary of even earlier turns\n\n" + chat.compact_summary.strip() def should_compact(db: DBSession, chat: Chat, *, pending: str = "") -> bool: """Whether the next request should be summarised first. Judged from the last reply's recorded usage plus an estimate of the new turn. True prompt_tokens are only knowable after a response, so a retrospective figure is the honest basis -- but on its own it is one turn stale, and fifty thousand characters pasted into the composer would overflow a window that measured 90% last time. The estimator covers only that delta. Never fires when the model's context length is unknown. Acting on a number nobody supplied is exactly what the 0-means-unknown rule exists to prevent. """ limit = threshold(db) if limit <= 0: return False # The Messages conversation bounds its own request mechanically, in # `build_messages`. Two mechanisms narrowing one transcript is how a summary # ends up summarising a summary -- and this one would be summarising turns # that are already outside the request, which achieves nothing at the cost # of a model call and a divider on a page that has no divider. if chat.kind == KIND_MESSAGES: return False last = last_complete(db, chat) if last is None: return False usage = metrics_service.from_message(last.usage_json) if usage.context_limit <= 0 or usage.context_tokens <= 0: return False projected = usage.context_tokens + tokens.estimate(pending) return projected >= usage.context_limit * limit / 100