Searching Sessions, Messages, and History

ccsinfo has three different search commands because Claude Code data is stored in two different places: session JSONL files for conversations, and a separate .history.jsonl file for prompt history. The right search depends on what you remember.

Search Scans Does not scan Best when you remember
ccsinfo search sessions <query> Session-level metadata Message bodies and prompt-history text a project name, branch, slug, or part of a session identifier
ccsinfo search messages <query> Text from user and assistant messages Tool-call payloads and .history.jsonl prompts a phrase from the conversation itself
ccsinfo search history <query> Prompt text stored in .history.jsonl Assistant replies and session-level metadata something you typed, but not where you typed it

Tip: If you only remember the wording and do not remember whether you typed it or Claude said it, run both ccsinfo search history <query> and ccsinfo search messages <query>.

local mode

--server-url / CCSINFO_SERVER_URL

You

ccsinfo CLI

Search service

HTTP API

Session metadata search

Message text search

Prompt history search

Project `*.jsonl` session files

Per-project `.history.jsonl` files

By default, the CLI reads local files under ~/.claude. If you want the same commands to go through the server API instead, start the server with ccsinfo serve and point the CLI at it with --server-url or CCSINFO_SERVER_URL.

@app.command()
def serve(
    host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind to (use 0.0.0.0 for network access)"),
    port: int = typer.Option(8080, "--port", "-p", help="Port to bind"),
) -> None:
    """Start the API server."""
    uvicorn.run(fastapi_app, host=host, port=port)

# ...

@app.callback()
def main_callback(
    # ...
    server_url: str | None = typer.Option(
        None,
        "--server-url",
        "-s",
        envvar="CCSINFO_SERVER_URL",
        help="Remote server URL (e.g., http://localhost:8080). If not set, reads local files.",
    ),
) -> None:
    """Claude Code Session Info CLI."""
    state.server_url = server_url

The CLI and API line up directly:

CLI API
ccsinfo search sessions <query> GET /search?q=<query>&limit=<n>
ccsinfo search messages <query> GET /search/messages?q=<query>&limit=<n>
ccsinfo search history <query> GET /search/history?q=<query>&limit=<n>

All three API routes use q for the query. The API default limit is 50, and the HTTP routes enforce 1 through 500. The CLI also supports --limit and --json.

Search Sessions

ccsinfo search sessions <query> is the broadest search. It does a case-insensitive match against session metadata, including the session identifier, slug, branch information, and stored path information. It returns session summaries, not conversation text.

Use session search when:

  • you remember a project or path fragment
  • you remember a branch name or slug
  • you want to find the right session before opening it

Once you have the right match, follow up with ccsinfo sessions show <full-session-id> or ccsinfo sessions messages <full-session-id> --json.

Search Message Text

ccsinfo search messages <query> reads the session files themselves. The test fixture shows the kind of JSONL entries it searches:

{
    "type": "user",
    "uuid": "msg-001",
    "message": {
        "role": "user",
        "content": [{"type": "text", "text": "Hello"}],
    },
    "timestamp": "2024-01-15T10:00:00Z",
},
{
    "type": "assistant",
    "uuid": "msg-002",
    "parentMessageUuid": "msg-001",
    "message": {
        "role": "assistant",
        "content": [{"type": "text", "text": "Hi there!"}],
    },
    "timestamp": "2024-01-15T10:00:01Z",
},

When message search runs, it only scans user and assistant entries and only extracts text content. It then builds a context snippet around the match instead of returning the full message body:

for project_path, session in get_all_sessions():
    for entry in session.entries:
        if entry.type not in ("user", "assistant"):
            continue

        text_content = ""
        if entry.message and entry.message.content:
            if isinstance(entry.message.content, str):
                text_content = entry.message.content
            elif isinstance(entry.message.content, list):
                texts = []
                for content in entry.message.content:
                    if content.type == "text" and content.text:
                        texts.append(content.text)
                text_content = "\n".join(texts)

        if query_lower in text_content.lower():
            idx = text_content.lower().find(query_lower)
            start = max(0, idx - 50)
            end = min(len(text_content), idx + len(query) + 50)
            snippet = text_content[start:end]
            if start > 0:
                snippet = "..." + snippet
            if end < len(text_content):
                snippet = snippet + "..."

That makes message search good for finding where a phrase appeared in a conversation, but not for dumping the whole conversation. If you need the complete exchange after a hit, use ccsinfo sessions messages <session-id> --json.

Warning: search messages only searches extracted text blocks from user and assistant messages. Tool-call payloads, tool results, and other non-text content are not part of message search.

Search Prompt History

ccsinfo search history <query> is for finding what you asked, not what Claude answered. It searches the prompt text stored in each project's .history.jsonl file.

def search_prompts(self, query: str, *, case_sensitive: bool = False) -> list[HistoryEntry]:
    """Search for prompts containing a query string."""
    results: list[HistoryEntry] = []
    search_query = query if case_sensitive else query.lower()

    for entry in self.entries:
        if entry.prompt:
            prompt_text = entry.prompt if case_sensitive else entry.prompt.lower()
            if search_query in prompt_text:
                results.append(entry)

    return results

def get_history_file(project_dir: Path) -> Path:
    """Get the path to the history file for a project."""
    return project_dir / ".history.jsonl"

This is the best search when:

  • you remember part of a prompt but not the session it came from
  • you want to reuse an older prompt across projects
  • you want the stored prompt text itself

History search returns the project path, prompt text, timestamp, and the related session identifier. In table output the prompt preview is shortened; in JSON or API output the full stored prompt is returned.

Practical Limits and Behaviors

All three searches are simple, case-insensitive substring scans over JSONL data. There is no fuzzy matching, stemming, or semantic ranking, so broad queries can produce noisy results. If you expect lots of hits, use a more specific phrase or raise --limit.

Search also tolerates damaged files by skipping bad lines instead of failing the whole run:

if not file_path.exists():
    raise FileNotFoundError(f"JSONL file not found: {file_path}")

with file_path.open("rb") as f:
    for line_num, line in enumerate(f, start=1):
        line = line.strip()
        if not line:
            continue

        try:
            data = orjson.loads(line)
        except orjson.JSONDecodeError as e:
            if skip_malformed:
                logger.warning(
                    "Skipping malformed JSON at line %d in %s: %s",
                    line_num,
                    file_path,
                    e,
                )
                continue
            raise

        if model is not None:
            try:
                yield model.model_validate(data)
            except Exception as e:
                if skip_malformed:
                    logger.warning(
                        "Skipping invalid data at line %d in %s: %s",
                        line_num,
                        file_path,
                        e,
                    )
                    continue

Tip: Use --json when you want full identifiers, want to script against results, or want the full prompt text from history search. The pretty tables shorten IDs and previews.

Note: Project paths are reconstructed from encoded directory names under ~/.claude/projects, so treat them as best-effort labels rather than perfect originals.

Warning: Malformed JSONL lines are skipped rather than failing the whole search. That keeps the command usable, but it can also mean a damaged session or history file produces incomplete results instead of a hard error.