> ## Documentation Index
> Fetch the complete documentation index at: https://praison.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Cross-Session Recall

> Let agents search their own past conversations across sessions

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    subgraph "Cross-Session Recall"
        Q[💬 Agent Query] --> M{🔍 Mode?}
        M -->|query=...| D[📚 Discovery]
        M -->|session_id+anchor| S[📖 Scroll]
        M -->|no args| B[🗂️ Browse]
        D --> R[✅ Sessions + Context]
        S --> R
        B --> R
    end
    classDef query fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef mode fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef shape fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef result fill:#10B981,stroke:#7C90A0,color:#fff
    class Q query
    class M mode
    class D,S,B shape
    class R result

```

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

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    subgraph "compact retention"
        A[💬 Active window] -->|compaction| AR[🗄️ Archived messages]
        A -->|replaced by| SUM[📝 Synthetic summary]
    end
    AR --> SS[🔍 session_search]
    A --> SS
    SS --> H[✅ Hits archived + active]
    classDef active fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef arch fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef search fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef result fill:#10B981,stroke:#7C90A0,color:#fff
    class A,SUM active
    class AR arch
    class SS search
    class H result
```

## Quick Start

<Steps>
  <Step title="Add session_search to an agent">
    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    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?")
    ```
  </Step>

  <Step title="Call the three shapes directly">
    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    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()
    ```
  </Step>

  <Step title="Scale to thousands of sessions">
    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    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?")
    ```
  </Step>

  <Step title="Recall a compacted (archived) detail">
    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    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']}")
    ```
  </Step>
</Steps>

***

## How It Works

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant U as 👤 User
    participant A as 🤖 Agent
    participant T as 🔧 session_search
    participant I as 🗂️ Index (SqliteSessionStore)
    participant S as 📁 JSON sessions
    U->>A: "What did we decide about billing?"
    A->>T: session_search(query="billing migration")
    T->>I: MATCH "billing OR migration" ORDER BY bm25
    I-->>T: candidate session_ids
    T->>S: read only matched sessions
    S-->>T: messages + score + bookends
    T-->>A: SessionHits (anchored, deduped)
    A-->>U: "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.

<Tip>
  A session-id that was never seen locally can be *hydrated* on demand from a configured mirror via `SessionMirrorProtocol.load(session_id)`. See [Session Mirror](/docs/features/session-mirror).
</Tip>

**Picking the right shape:**

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TD
    Q1["What do you want?"]
    Q1 -->|"Find a past discussion"| DISC["session_search(query='...')"]
    Q1 -->|"Read more around a hit"| SCRO["session_search(session_id, around_message_id)"]
    Q1 -->|"List recent sessions"| BROW["session_search()"]
    classDef q fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef a fill:#10B981,stroke:#7C90A0,color:#fff
    class Q1 q
    class DISC,SCRO,BROW a
```

**Which store should I use?**

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TD
    Q["How many stored sessions?"]
    Q -->|"Hundreds"| D["DefaultSessionStore<br/>(default; substring scan)"]
    Q -->|"Thousands+ / a long-lived gateway"| S["SqliteSessionStore<br/>(FTS5 index)"]
    D --> R["Bookends, demotion, lineage dedup<br/>apply to both stores"]
    S --> R
    classDef q fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef a fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef r fill:#10B981,stroke:#7C90A0,color:#fff
    class Q q
    class D,S a
    class R r
```

***

## 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

<Tabs>
  <Tab title="Discovery">
    Returned when `query` is set:

    ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    {
      "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.
  </Tab>

  <Tab title="Scroll">
    Returned when `session_id` is set (no `query`):

    ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    {
      "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.
  </Tab>

  <Tab title="Browse">
    Returned when called with no arguments:

    ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    {
      "success": true,
      "mode": "browse",
      "total_found": 3,
      "results": [
        {
          "session_id": "abc-123",
          "title": "Assistant",
          "when": "2026-06-22T18:00:00Z",
          "message_count": 24
        }
      ]
    }
    ```
  </Tab>
</Tabs>

### 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](/docs/features/session-store#behaviour) (PraisonAI PR [#5676](https://github.com/MervinPraison/PraisonAI/pull/5676) / issue [#5672](https://github.com/MervinPraison/PraisonAI/issues/5672)).

<Note>
  `search()` leaves the corrupt bytes untouched on disk — it only skips the file. The [corruption-quarantine on load](/docs/features/session-persistence#corruption-quarantine-on-load) path moves the file aside to `*.json.corrupt-<ts>` the next time that session is loaded.
</Note>

***

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

<Note>
  Before this change, cross-session recall indexed only a session's **active** `messages`; compacted raw turns were dropped from the index on the next write and became permanently unsearchable. `session_search` now spans archived + active turns.
</Note>

<Note>
  **Clearing a session preserves archived recall.** `store.clear_session(session_id)` empties only the active window — turns already in `archived_messages` from prior compactions stay searchable through both `search()` and `session_search`, and the session continues to show up in results. This holds for both `DefaultSessionStore` and `SqliteSessionStore` (PraisonAI PR [#5681](https://github.com/MervinPraison/PraisonAI/pull/5681) / issue [#5521](https://github.com/MervinPraison/PraisonAI/issues/5521)). To make a session unrecallable, call `delete_session(session_id)` instead.
</Note>

### SQLite index upgrade (INDEX\_CONTENT\_VERSION → 2)

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

* **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](/docs/features/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

| 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

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
# 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](/docs/cli/session-search) for the full command reference.

***

## Best Practices

<AccordionGroup>
  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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](/docs/docs/features/session-store#sqlitesessionstore).

    <Note>
      **Not to be confused with `SqliteTranscriptStore`.** `SqliteSessionStore` (this accordion) is an FTS5-backed **recall index** for cross-session search. `SqliteTranscriptStore` is a separate class that replaces the per-session JSON files under the gateway's `session.persist` path with one WAL SQLite row per session. See [SQLite Transcript Store](/docs/features/sqlite-transcript-store).
    </Note>
  </Accordion>
</AccordionGroup>

***

## Advanced: `SearchableSessionStoreProtocol`

The default store implements `SearchableSessionStoreProtocol`, a separate `runtime_checkable` protocol that adds search to any session store backend.

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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 |

<Note>
  `SessionStoreProtocol` (the core persistence contract) is unchanged and backward compatible. `SearchableSessionStoreProtocol` is an additive, separately runtime-checkable protocol.
</Note>

***

<Note>
  Session chat history is written to and read from the configured `SessionStore` first, then falls back to `Memory` for backward compatibility. Both restore paths now agree with the save path — if a resumed session comes back empty, check that the same store is configured on both save and restore.
</Note>

***

## Related

<CardGroup cols={2}>
  <Card title="Bot Default Tools" icon="toolbox" href="/docs/features/bot-default-tools">
    Where session\_search fits in the opt-in tool list for bots
  </Card>

  <Card title="Session Store" icon="database" href="/docs/features/session-store">
    How sessions are persisted in \~/.praisonai/sessions/
  </Card>

  <Card title="Memory" icon="brain" href="/docs/features/advanced-memory">
    Distilled long-term memory (different from raw session transcripts)
  </Card>

  <Card title="Share a SQLite file (db_path)" icon="database" href="/docs/features/advanced-memory#share-one-sqlite-file-across-agents-db-path">
    Point two agents at one `db_path` for cross-instance memory recall
  </Card>

  <Card title="Knowledge" icon="book" href="/docs/features/knowledge">
    RAG over documents (also different from session recall)
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.