Cross-Session Recall
Let agents search their own past conversations across sessions
from praisonaiagents import Agent
agent = Agent(
name="recall-agent",
instructions="Search past sessions when the user asks about earlier decisions.",
tools=["session_search"],
)
agent.start("What did we decide about the billing migration?")An agent can now search its own past conversation transcripts — asking “what did we decide about the billing migration?” across every stored session, including older turns that have been compacted out of the active window.
The user asks what was decided last week; session search recalls matching turns from prior stored conversations — even ones summarised out of the live context by the default retention="compact" policy.
Search scans both archived (compacted) turns and the active window, so a detail summarised out of the live context is still recalled:
Quick Start
- Add session_search to an agent
python from praisonaiagents import Agent agent = Agent( name="Gateway Assistant", instructions="Recall past conversations when the user asks 'what did we decide about X?'", tools=["session_search"] ) agent.start("What did we decide about the billing migration?") - Call the three shapes directly
python from praisonaiagents.tools import session_search # 1. Discovery — find sessions matching a keyword session_search(query="billing migration") # 2. Scroll — read ±N messages around an anchor session_search(session_id="abc-123", around_message_id="42", window=10) # 3. Browse — most recent sessions ("what was I working on?") session_search() - Scale to thousands of sessions
python from praisonaiagents import Agent from praisonaiagents.session import SqliteSessionStore # Indexed store: FTS5 lookup instead of full-directory scan. # Falls back to a substring scan if sqlite3/FTS5 is unavailable. store = SqliteSessionStore(db_path="~/.praisonai/sessions.db") agent = Agent( name="Gateway Assistant", instructions="Recall past conversations across thousands of stored sessions.", tools=["session_search"], session_store=store, ) agent.start("What did we decide about the billing migration?") - Recall a compacted (archived) detail
python from praisonaiagents.tools import session_search import json # Under the default retention="compact" policy, older turns are moved into # archived_messages and replaced in the active window by a synthetic summary — # but they stay searchable. A hit on an archived turn is flagged archived=True. result = json.loads(session_search(query="deploy token XZ99")) for hit in result["results"]: for msg in hit["messages"]: marker = " (archived)" if msg["archived"] else "" print(f"[{msg['role']}]{marker} {msg['content']}")
How It Works
Sessions live at ~/.praisonai/sessions/*.json — one JSON file per session. The default DefaultSessionStore search is a dependency-free substring/keyword scan with no setup. The index hop above appears only when a SqliteSessionStore is active; the default flow reads the JSON files directly.
Both stores scan a single archived + active projection of each session: archived_messages (turns compacted out of the live window) are scanned ahead of the active messages, so a compacted conversation stays fully recallable rather than losing its raw turns. Every returned context message carries an archived: bool flag so the caller can see exactly where the compaction boundary falls instead of having archived turns silently blended into the current window.
Picking the right shape:
Which store should I use?
Configuration Options
session_search Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
query | str | "" | Free-text query for discovery mode |
session_id | str | "" | Session to read in scroll mode |
around_message_id | str | "" | Anchor message index (as string) for scroll mode |
limit | int | 5 | Maximum sessions/results to return |
window | int | 5 | Messages to include around a hit/anchor |
Return Shapes
Discovery
Returned when query is set:
{
"success": true,
"mode": "discovery",
"query": "billing migration",
"total_found": 2,
"results": [
{
"session_id": "abc-123",
"title": "Assistant",
"when": "2026-06-10T14:32:00Z",
"snippet": "…we agreed to migrate billing from Stripe…",
"score": 4.0,
"anchor_index": 12,
"messages": [
{"index": 7, "role": "user", "content": "...", "timestamp": "...", "archived": true},
{"index": 8, "role": "assistant", "content": "...", "timestamp": "...", "archived": false}
],
"bookends": {
"opening": [
{"index": 0, "role": "user", "content": "Kick off billing review", "timestamp": "..."},
{"index": 1, "role": "assistant", "content": "Here is the plan...", "timestamp": "..."}
],
"closing": [
{"index": 22, "role": "user", "content": "Confirm we're going with Adyen", "timestamp": "..."},
{"index": 23, "role": "assistant", "content": "Confirmed — migrating to Adyen.", "timestamp": "..."}
]
}
}
]
}The bookends key is present only when the session has more than BOOKEND_SIZE (2) user+assistant messages — mirroring SessionHit.as_dict().
Each entry in messages includes index, role, content, timestamp, and an archived boolean. archived: true marks a turn recalled from archived_messages (compacted out of the active window); archived: false marks a turn still in the live window.
Scroll
Returned when session_id is set (no query):
{
"success": true,
"mode": "scroll",
"session_id": "abc-123",
"around_message_id": "42",
"messages": [
{"index": 37, "role": "user", "content": "...", "timestamp": "...", "archived": false}
]
}Scroll walks the same archived + active projection as discovery, so an anchor_index handed back from a hit — including one landing on an archived turn — resolves to the exact same message here. Each entry keeps its archived flag.
Browse
Returned when called with no arguments:
{
"success": true,
"mode": "browse",
"total_found": 3,
"results": [
{
"session_id": "abc-123",
"title": "Assistant",
"when": "2026-06-22T18:00:00Z",
"message_count": 24
}
]
}Scoring
Discovery mode scores hits using keyword matching:
| Match type | Score added |
|---|---|
| Exact substring match | +2 per message |
| Per-term keyword match | +1 per term per message |
Ties are broken by recency (updated_at descending). Scores are heuristic — treat them as relative rankings, not probabilities.
Snippets are centred on the first match and trimmed to ~120 characters with … ellipses.
Resilience to Corrupt Transcripts
session_search keeps returning matching sessions even when some transcripts on disk cannot be read.
A transcript is skipped — not quarantined, not deleted — when its file is unreadable (OSError), is not valid JSON (JSONDecodeError), or contains invalid UTF-8 bytes (UnicodeDecodeError). One corrupt transcript (a truncated write, mixed encoding, or filesystem corruption) can no longer abort recall across the remaining valid sessions.
| Boundary condition | Search behaviour |
|---|---|
| Valid transcript | Scanned normally; may contribute hits |
File read error (OSError) | Skipped; search continues with the remaining sessions |
Malformed JSON (JSONDecodeError) | Skipped; search continues |
Invalid UTF-8 bytes (UnicodeDecodeError) | Skipped; search continues |
This applies to both DefaultSessionStore and SqliteSessionStore (on both the FTS5 and LIKE candidate paths), so backends behave identically.
This resilience extends beyond search() to SessionData.from_dict itself, which skips any non-object entry (null, a string, a number) in messages or archived_messages — so every reconstruction path (gateway routing, portable import, get_working_history) survives a malformed turn. See Session Store → Malformed-turn tolerance (PraisonAI PR #5676 / issue #5672).
Recall Across Compacted History
Under the default retention="compact" policy, older raw turns are moved into archived_messages and replaced in the active window by a single synthetic summary. Those archived turns stay searchable: search() and window() both scan an archived + active projection, with archived turns scanned first so a hit anchors in context exactly like an active-turn hit.
archived: Trueon a returned context message marks a turn recalled fromarchived_messages. The compaction boundary stays visible to the caller rather than being silently blended into the current window.window()shares the coordinate space. Ananchor_indexfrom a discovery hit — including one landing on an archived turn — resolves to the exact same message when you scroll around it.- Both built-in stores use one projection.
DefaultSessionStore,SqliteSessionStore, and the exportedSqliteTranscriptStoreshare the same archived + active projection, so results cannot drift between backends.
Scoring is stable across compactions. Both stores skip the synthetic [Session Summary] row (metadata["compaction"]=True) when computing score. The summary paraphrases archived turns that are already scored verbatim, so counting it would make a session’s rank depend on how many times it had been compacted. The summary is still included in each hit’s messages, in window(), and in bookends for replay — only the scorer and the SQLite FTS index ignore it. A hit on a compacted session anchors on the actual archived turn, not the summary row (Issue #5051 / PR #6112).
SQLite index upgrade (INDEX_CONTENT_VERSION → 2, then 3)
SqliteSessionStore bumps its INDEX_CONTENT_VERSION to 2 because _flatten now indexes archived_messages alongside the active messages. On the first startup after upgrade, the store detects the stale version and rebuilds every session’s FTS row once, so archived-only queries work immediately rather than only after each session’s next write. Issue #5051 bumps it again to 3 so compaction summaries are no longer indexed; the same one-time rebuild runs.
- No user action required. Startup performs a bounded one-time backfill, then persists the new version so subsequent startups fall back to the cheap “skip already-indexed sessions” path.
- New
session_index_metatable. An internal key/value table (content_versionkey) tracks the persisted index-content version. Operators upgrading an existing store should expect this table to appear in the SQLite index file.
See Session Store → SqliteSessionStore for the full index schema.
Anchored, Demoted, Deduped Results
Every discovery hit — from either store — carries anchored context, demotes noisy automated runs, and collapses continuations of the same conversation.
- Bookends:
{"opening": [...], "closing": [...]}— the first and last 2 user+assistant messages of the session, so the agent sees goal → match → resolution in one call. - Automated demotion: sessions tagged as automated/scheduled or whose message rate exceeds ~60 msg/hour have their score multiplied by
0.25. - Lineage dedup: reset/compacted continuations sharing
lineage_id/root_session_id/thread_idcollapse to the single best-scoring hit.
Automated-session heuristics
| Signal | Trigger |
|---|---|
data["source"] or data["metadata"]["source"] | one of scheduled, automated, cron, system, batch |
data["metadata"]["automated"] | True |
| Message rate | > 60 messages / hour over the session span (needs ≥ 4 timestamped messages) |
Demotion multiplies the session’s total score by 0.25 (constant AUTOMATED_DEMOTION).
Bookends
Bookends return the first / last 2 user+assistant messages (BOOKEND_SIZE = 2). They are omitted when the session has ≤ 2 conversational messages, to avoid duplicating messages already in the context window.
Lineage keys
| Key | Read from | Note |
|---|---|---|
lineage_id | data or metadata | Explicit chain identifier |
root_session_id | data or metadata | Root of a continuation chain |
thread_id | data or metadata | Thread identifier |
parent_session_id is intentionally not a lineage key — it points at an immediate parent, so sibling sessions forked from one parent stay distinct instead of suppressing each other.
Common Patterns
Gateway assistant recalling a past decision
from praisonaiagents.tools import session_search
import json
# Step 1: find the session
result = json.loads(session_search(query="billing migration"))
if result["success"] and result["results"]:
hit = result["results"][0]
anchor = str(hit["anchor_index"])
session_id = hit["session_id"]
# Step 2: read more context around the hit
context = json.loads(session_search(
session_id=session_id,
around_message_id=anchor,
window=10
))
print(context["messages"])“What was I working on?” at the start of a new session
from praisonaiagents import Agent
agent = Agent(
name="Daily Assistant",
instructions="Start each session by reviewing what was worked on recently.",
tools=["session_search"]
)
agent.start("Give me a quick summary of what I worked on yesterday.")Programmatic access via the store directly
from praisonaiagents.session.store import get_default_session_store
from praisonaiagents.session import SearchableSessionStoreProtocol
store = get_default_session_store()
assert isinstance(store, SearchableSessionStoreProtocol)
# Discovery
hits = store.search("billing migration", limit=5, window=5)
for hit in hits:
print(hit.session_id, hit.snippet)
# Scroll
messages = store.window("abc-123", around_message_id="42", window=5)
# Browse
summaries = store.recent(limit=10)
for s in summaries:
print(s.session_id, s.message_count)CLI: praisonai session search
The same engine (SqliteSessionStore / FTS5+bm25) is reachable from the terminal — not just from an agent tool:
# Ranked search across every stored session, with snippets:
praisonai session search "billing migration" --limit 5 --window 5Results are ranked by score, deduped by session_id across the project and global stores, and printed as a table (or JSON with --json). See Session Search for the full command reference.
Best Practices
Keep window small for fast scans
Use window=3 to window=5 for discovery. Widen only when you need more surrounding context after finding a hit — use scroll mode for that.
Treat scores as relative rankings
Scores are heuristic keyword counts, not probability-calibrated relevance scores. A score of 4.0 beats 2.0 but says nothing absolute. Always let the agent reason about the returned snippets.
Scope sessions per user in multi-user deployments
The default search is per-store, not per-user. In multi-user deployments, prefix session_id values with a user identifier (e.g. user_42_session_xyz) and search within those sessions explicitly.
Scale-out: swap in SqliteSessionStore
For long-lived gateway bots with thousands of sessions, swap in SqliteSessionStore — a stdlib sqlite3 + FTS5 index turns recall into a bounded MATCH ... ORDER BY bm25 lookup instead of a full-directory scan. It transparently falls back to the substring scan if sqlite3 or FTS5 is unavailable, so nothing breaks. See Session Store.
Advanced: SearchableSessionStoreProtocol
The default store implements SearchableSessionStoreProtocol, a separate runtime_checkable protocol that adds search to any session store backend.
from praisonaiagents.session import SearchableSessionStoreProtocol
from praisonaiagents.session.store import get_default_session_store
store = get_default_session_store()
assert isinstance(store, SearchableSessionStoreProtocol)Protocol Methods
| Method | Signature | Description |
|---|---|---|
search | search(query, *, limit=5, window=5) -> List[SessionHit] | Keyword scan across archived + active turns of all stored sessions |
window | window(session_id, around_message_id=None, *, window=5) -> List[Dict] | ±N messages around an anchor in the archived + active projection; uses most recent if anchor omitted |
recent | recent(*, limit=10) -> List[SessionSummary] | Most recently updated sessions |
SessionHit Fields
| Field | Type | Default | Description |
|---|---|---|---|
session_id | str | required | The session that matched |
title | str | "" | Agent name or first user message snippet |
when | Optional[str] | None | updated_at or created_at timestamp |
snippet | str | "" | Short snippet centred on the first match |
score | float | 0.0 | Match score (higher = better) |
anchor_index | int | -1 | Index of the best-matching message |
messages | List[Dict] | [] | Context window around the hit; each entry carries index, role, content, timestamp, and archived: bool |
bookends | Dict[str, List[Dict]] | {} | {"opening": [...], "closing": [...]} — first/last user+assistant turns; empty when the session has ≤ BOOKEND_SIZE conversational messages |
SessionSummary Fields
| Field | Type | Default | Description |
|---|---|---|---|
session_id | str | required | Session identifier |
title | str | "" | Agent name or session id |
when | Optional[str] | None | Last update timestamp |
message_count | int | 0 | Number of messages in the session |
Related
Where session_search fits in the opt-in tool list for bots
Session StoreHow sessions are persisted in ~/.praisonai/sessions/
MemoryDistilled long-term memory (different from raw session transcripts)
Share a SQLite file (db_path)Point two agents at one db_path for cross-instance memory recall
RAG over documents (also different from session recall)