Cross-Session Recall

Let agents search their own past conversations across sessions

python
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.

Diagram

Cross-Session Recall

query=…

session_id+anchor

no args

💬 Agent Query

🔍 Mode?

📚 Discovery

📖 Scroll

🗂️ Browse

✅ Sessions + Context

Search scans both archived (compacted) turns and the active window, so a detail summarised out of the live context is still recalled:

Diagram

compact retention

compaction

replaced by

💬 Active window

🗄️ Archived messages

📝 Synthetic summary

🔍 session_search

✅ Hits archived + active

Quick Start

  1. 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?")
  2. 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()
  3. 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?")
  4. 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

Diagram
📁 JSON sessions🗂️ Index (SqliteSessionStore)🔧 session_search🤖 Agent👤 User📁 JSON sessions🗂️ Index (SqliteSessionStore)🔧 session_search🤖 Agent👤 User“What did we decide about billing?”session_search(query=”billing migration”)MATCH “billing OR migration” ORDER BY bm25candidate session_idsread only matched sessionsmessages + score + bookendsSessionHits (anchored, deduped)“On 2026-06-10 we decided to migrate Stripe → …”

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:

Diagram

Find a past discussion

Read more around a hit

List recent sessions

What do you want?

session_search(query=’…’)

session_search(session_id, around_message_id)

session_search()

Which store should I use?

Diagram

Hundreds

Thousands+ / a long-lived gateway

How many stored sessions?

DefaultSessionStore
(default; substring scan)

SqliteSessionStore
(FTS5 index)

Bookends, demotion, lineage dedup
apply to both stores


Configuration Options

session_search Parameters

ParameterTypeDefaultDescription
querystr""Free-text query for discovery mode
session_idstr""Session to read in scroll mode
around_message_idstr""Anchor message index (as string) for scroll mode
limitint5Maximum sessions/results to return
windowint5Messages to include around a hit/anchor

Return Shapes

Discovery

Returned when query is set:

json
{
  "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):

json
{
  "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:

json
{
  "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 typeScore 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 conditionSearch behaviour
Valid transcriptScanned 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: True on a returned context message marks a turn recalled from archived_messages. The compaction boundary stays visible to the caller rather than being silently blended into the current window.
  • window() shares the coordinate space. An anchor_index from 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 exported SqliteTranscriptStore share 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_meta table. An internal key/value table (content_version key) 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_id collapse to the single best-scoring hit.

Automated-session heuristics

SignalTrigger
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

KeyRead fromNote
lineage_iddata or metadataExplicit chain identifier
root_session_iddata or metadataRoot of a continuation chain
thread_iddata or metadataThread 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

python
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

python
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

python
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:

bash
# Ranked search across every stored session, with snippets:
praisonai session search "billing migration" --limit 5 --window 5

Results 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.

python
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

MethodSignatureDescription
searchsearch(query, *, limit=5, window=5) -> List[SessionHit]Keyword scan across archived + active turns of all stored sessions
windowwindow(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
recentrecent(*, limit=10) -> List[SessionSummary]Most recently updated sessions

SessionHit Fields

FieldTypeDefaultDescription
session_idstrrequiredThe session that matched
titlestr""Agent name or first user message snippet
whenOptional[str]Noneupdated_at or created_at timestamp
snippetstr""Short snippet centred on the first match
scorefloat0.0Match score (higher = better)
anchor_indexint-1Index of the best-matching message
messagesList[Dict][]Context window around the hit; each entry carries index, role, content, timestamp, and archived: bool
bookendsDict[str, List[Dict]]{}{"opening": [...], "closing": [...]} — first/last user+assistant turns; empty when the session has ≤ BOOKEND_SIZE conversational messages

SessionSummary Fields

FieldTypeDefaultDescription
session_idstrrequiredSession identifier
titlestr""Agent name or session id
whenOptional[str]NoneLast update timestamp
message_countint0Number of messages in the session


Related