"""Raw conversation chunks — the append-only record of what compression dropped.
Long-term memory used to have three shapes: curated facts, per-session sketches,
and the live context. When the context was compressed, everything that did not
fit the summary was simply gone — the message rows stay in the database, but
nothing searched them by meaning, and after a reload the session carried only the
summary. "We discussed this earlier" was unanswerable.
This table is the missing store: every message that compression demotes is
written here once, verbatim (a very large single message is elided in the
middle), with the turn range and timestamps it came from and the breadcrumb keys
extracted for it. Three properties are load-bearing:
* **Append-only and immutable.** A row is never updated. A correction is a new
chunk written later; `UNIQUE(session_id, content_hash)` only prevents the same
dropped run from being indexed twice, which is what makes a compression replay
idempotent.
* **Read together with facts and sketches**, through `UnifiedMemorySearch`, so an
agent has one place to ask. Writing stays separate: chunks are written only by
the compressor, never by a tool.
* **Provenance is part of the record** — session, turn range, timestamps — because
an answer built on a raw excerpt must say when it was said, and a curated fact
from last week must win over a raw line from last month.
"""
from __future__ import annotations
import hashlib
import uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING
import structlog
from ._embeddings import _vector_to_str
from ._facts import _normalize_query
if TYPE_CHECKING:
from navi.llm.base import Message
log = structlog.get_logger()
# One chunk is one searchable unit. 4000 chars is roughly a long tool result or
# a dozen chat messages — small enough that a hit is about one topic, large
# enough that the answer's context is not split across three rows.
CHUNK_MAX_CHARS = 4000
# A single message can be megabytes (a full file read). Keep its head and tail:
# the middle of a dump is the part nobody asks for later, while the command line
# at the top and the error at the bottom are usually the breadcrumbs.
CHUNK_MESSAGE_MAX_CHARS = 12_000
# Cosine distance cutoff for a chunk match. Facts use 0.3 because a fact value is
# one short sentence; a 4 KB passage against a short query naturally sits farther
# away, and 0.45 is where the pilot's paraphrased queries still landed. Anything
# past it goes to the lexical fallback instead — a wrong vector hit is worse than
# a keyword hit for a long passage.
VECTOR_CUTOFF_DISTANCE = 0.45
_ROLE_PREFIX = {
"user": "User",
"assistant": "Assistant",
"tool": "Tool result",
}
def build_chunks(
messages: list[Message],
breadcrumbs: list[str] | None = None,
max_chars: int = CHUNK_MAX_CHARS,
) -> list[dict]:
"""Group dropped messages into immutable chunks.
Consecutive messages are packed together until `max_chars`, so a turn's user
message, tool calls and the assistant reply land in one chunk. Summary and
compression marker messages are skipped — a summary is not raw history, and
indexing old summaries again would make each compression index its own past.
"""
chunks: list[dict] = []
current: list[str] = []
current_len = 0
start_seq: int | None = None
end_seq: int | None = None
started_at: datetime | None = None
ended_at: datetime | None = None
keys = list(breadcrumbs or [])
def flush() -> None:
nonlocal current, current_len, start_seq, end_seq, started_at, ended_at
if not current:
return
text = "\n\n".join(current).strip()
if text:
chunks.append({
"content": text,
"content_hash": hashlib.sha256(text.encode("utf-8", "replace")).hexdigest(),
"start_seq": start_seq if start_seq is not None else -1,
"end_seq": end_seq if end_seq is not None else -1,
"started_at": started_at,
"ended_at": ended_at,
"breadcrumbs": keys,
})
current, current_len = [], 0
start_seq = end_seq = None
started_at = ended_at = None
for msg in messages:
if msg.role == "system" or msg.is_summary or msg.is_compression:
continue
text = _format_message(msg)
if not text:
continue
if current and current_len + len(text) > max_chars:
flush()
current.append(text)
current_len += len(text) + 2
if start_seq is None:
start_seq = msg.sequence_number
started_at = msg.created_at
if msg.sequence_number >= 0:
end_seq = msg.sequence_number
if msg.created_at is not None:
ended_at = msg.created_at
flush()
return chunks
def _format_message(msg: Message) -> str:
content = (msg.content or "").strip()
if not content:
# An assistant message that only requested tool calls still records what
# was asked for; the result follows in its own message.
if msg.tool_calls:
names = ", ".join(tc.name for tc in msg.tool_calls if getattr(tc, "name", None))
return f"Assistant requested tools: {names}" if names else ""
return ""
if len(content) > CHUNK_MESSAGE_MAX_CHARS:
head = CHUNK_MESSAGE_MAX_CHARS * 3 // 4
tail = CHUNK_MESSAGE_MAX_CHARS - head - 40
content = f"{content[:head]}\n…[middle elided]…\n{content[-tail:]}"
prefix = _ROLE_PREFIX.get(msg.role, msg.role.title())
if msg.role == "tool" and msg.name:
prefix = f"Tool result ({msg.name})"
return f"{prefix}: {content}"
class ChunkMixin:
"""Storage and search for dropped-raw-history chunks.
Expected on the composite class:
_get_pool() -> asyncpg.Pool
_generate_embedding(text) -> list[float] | None
_generate_embeddings(texts) -> list[list[float] | None]
_has_pgvector() -> bool
"""
async def add_chunks(
self,
chunks: list[dict],
user_id: str | None = None,
session_id: str | None = None,
) -> int:
"""Write chunks, skipping ones already indexed for this session."""
if not chunks or not session_id:
return 0
# The embedding column exists only where pgvector does; on a plain Postgres
# the `::vector` cast in the INSERT would fail the whole write, so the row
# is written text-only and found through FTS.
use_vectors = await self._has_pgvector()
embeddings = (
await self._generate_embeddings([c["content"] for c in chunks])
if use_vectors else [None] * len(chunks)
)
now = datetime.now(timezone.utc)
pool = await self._get_pool()
written = 0
async with pool.acquire() as conn:
for chunk, embedding in zip(chunks, embeddings):
vec_str = _vector_to_str(embedding) if embedding else None
if use_vectors:
result = await conn.execute(
"""INSERT INTO memory_chunks
(id, user_id, session_id, start_seq, end_seq, started_at, ended_at,
content, breadcrumbs, content_hash, created_at, embedding)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::vector)
ON CONFLICT (session_id, content_hash) DO NOTHING""",
str(uuid.uuid4()), user_id, session_id,
chunk["start_seq"], chunk["end_seq"],
chunk.get("started_at"), chunk.get("ended_at"),
chunk["content"], list(chunk.get("breadcrumbs") or []),
chunk["content_hash"], now, vec_str,
)
else:
result = await conn.execute(
"""INSERT INTO memory_chunks
(id, user_id, session_id, start_seq, end_seq, started_at, ended_at,
content, breadcrumbs, content_hash, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (session_id, content_hash) DO NOTHING""",
str(uuid.uuid4()), user_id, session_id,
chunk["start_seq"], chunk["end_seq"],
chunk.get("started_at"), chunk.get("ended_at"),
chunk["content"], list(chunk.get("breadcrumbs") or []),
chunk["content_hash"], now,
)
if result == "INSERT 0 1":
written += 1
if written:
log.info(
"memory.chunks_written",
session_id=session_id,
written=written,
skipped=len(chunks) - written,
)
return written
async def search_chunks(
self,
query: str,
user_id: str | None = None,
session_id: str | None = None,
limit: int = 8,
) -> list[dict]:
"""Search raw chunks: vector first, lexical fallback when that misses.
`session_id` narrows to one conversation; omitting it searches every
session of the user. Anonymous rows (`user_id IS NULL`) are their own
scope, exactly like facts.
"""
if limit <= 0:
return []
rows: list[dict] = []
if self._embedding_backend and await self._has_pgvector():
query_embedding = await self._generate_embedding(query)
vec_str = _vector_to_str(query_embedding) if query_embedding else None
if vec_str:
try:
rows = await self._vector_chunks(
vec_str, user_id, session_id, limit,
)
except Exception:
log.warning("memory.chunk_vector_failed", query=query[:40], exc_info=True)
if rows:
log.debug("memory.chunk_vector_search", hits=len(rows), query=query[:40])
return rows
rows = await self._lexical_chunks(query, user_id, session_id, limit)
if rows:
log.debug("memory.chunk_lexical_search", hits=len(rows), query=query[:40])
return rows
async def _vector_chunks(
self,
vec_str: str,
user_id: str | None,
session_id: str | None,
limit: int,
) -> list[dict]:
where, params = _chunk_filter(user_id, session_id)
params.append(vec_str)
vec_idx = len(params)
params.append(limit * 3)
lim_idx = len(params)
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
f"""SELECT id, session_id, start_seq, end_seq, started_at, ended_at,
content, breadcrumbs, created_at,
embedding <=> ${vec_idx}::vector AS distance
FROM memory_chunks
WHERE {where}
ORDER BY embedding <=> ${vec_idx}::vector
LIMIT ${lim_idx}""",
*params,
)
hits: list[dict] = []
for row in rows:
distance = row["distance"]
if distance is None or distance >= VECTOR_CUTOFF_DISTANCE:
continue
hit = _row_to_chunk(row)
hit["score"] = max(0.0, 1.0 - float(distance))
hits.append(hit)
if len(hits) >= limit:
break
return hits
async def _lexical_chunks(
self,
query: str,
user_id: str | None,
session_id: str | None,
limit: int,
) -> list[dict]:
terms = _normalize_query(query)
where, params = _chunk_filter(user_id, session_id)
if not terms:
return await self._recent_chunks(where, params, limit)
ts_query = " ".join(terms)
like_patterns = [f"%{term}%" for term in terms]
params.append(ts_query)
ts_idx = len(params)
params.append(like_patterns)
like_idx = len(params)
params.append(limit * 4)
lim_idx = len(params)
match_clause = (
f"(to_tsvector('simple', content) @@ plainto_tsquery('simple', ${ts_idx}) "
f"OR content ILIKE ANY(${like_idx}::text[]) "
f"OR array_to_string(breadcrumbs, ' ') ILIKE ANY(${like_idx}::text[]))"
)
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
f"""SELECT id, session_id, start_seq, end_seq, started_at, ended_at,
content, breadcrumbs, created_at,
ts_rank(to_tsvector('simple', content),
plainto_tsquery('simple', ${ts_idx})) AS rank
FROM memory_chunks
WHERE {where} AND {match_clause}
ORDER BY rank DESC, ended_at DESC NULLS LAST
LIMIT ${lim_idx}""",
*params,
)
hits = []
normalized_terms = {t.lower() for t in terms if len(t) > 2}
for row in rows:
hit = _row_to_chunk(row)
haystack = f"{hit['content']} {' '.join(hit['breadcrumbs'])}".lower()
hit["terms_hit"] = sum(1 for term in normalized_terms if term in haystack)
hit["score"] = None
hits.append(hit)
# A row matched one long term by accident; a row matching several query
# terms is the one the caller meant. Rank on that before the DB order.
hits.sort(key=lambda h: (-h["terms_hit"], -_ts(h.get("rank"))))
return hits[:limit]
async def _recent_chunks(self, where: str, params: list, limit: int) -> list[dict]:
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
f"""SELECT id, session_id, start_seq, end_seq, started_at, ended_at,
content, breadcrumbs, created_at
FROM memory_chunks
WHERE {where}
ORDER BY ended_at DESC NULLS LAST
LIMIT ${len(params) + 1}""",
*params, limit,
)
hits = []
for index, row in enumerate(rows):
hit = _row_to_chunk(row)
hit["score"] = 1.0 - index * 0.05
hit["terms_hit"] = 0
hits.append(hit)
return hits
async def latest_summary_at(self, session_id: str) -> datetime | None:
"""When this session was last compressed — raw chunks before it are verbatim
history that the current summary has already curated."""
pool = await self._get_pool()
async with pool.acquire() as conn:
try:
return await conn.fetchval(
"SELECT max(created_at) FROM session_messages "
"WHERE session_id = $1 AND is_summary = TRUE",
session_id,
)
except Exception:
log.debug("memory.latest_summary_failed", session_id=session_id, exc_info=True)
return None
async def chunk_count(self, session_id: str | None = None, user_id: str | None = None) -> int:
where, params = _chunk_filter(user_id, session_id)
pool = await self._get_pool()
async with pool.acquire() as conn:
return int(await conn.fetchval(
f"SELECT count(*) FROM memory_chunks WHERE {where}", *params
) or 0)
def _chunk_filter(user_id: str | None, session_id: str | None) -> tuple[str, list]:
"""WHERE clause for the (user, session) scope. Anonymous rows are NULL-owned."""
clauses = []
params: list = []
if user_id is None:
clauses.append("user_id IS NULL")
else:
params.append(user_id)
clauses.append(f"user_id = ${len(params)}")
if session_id is not None:
params.append(session_id)
clauses.append(f"session_id = ${len(params)}")
return " AND ".join(clauses) or "TRUE", params
def _row_to_chunk(row) -> dict:
return {
"rank": row["rank"] if "rank" in row.keys() else None,
"id": row["id"],
"session_id": row["session_id"],
"start_seq": row["start_seq"],
"end_seq": row["end_seq"],
"started_at": row["started_at"],
"ended_at": row["ended_at"],
"content": row["content"],
"breadcrumbs": list(row["breadcrumbs"] or []),
"created_at": row["created_at"],
"terms_hit": 0,
}
def _ts(rank) -> float:
try:
return float(rank or 0.0)
except (TypeError, ValueError):
return 0.0