Conversation Search
jp conversation grep searches the text of your conversations — what you asked, what the assistant answered, what it reasoned about, and what its tools returned.
Its job is to locate, not to display. Every hit carries a coordinate that the rest of JP already understands, so a search result is a starting point rather than a dead end.
jp c grep 'httpmock'jp-c17727547754 Flaky openrouter multi-turn test 3 matches · 142 turns
142:user:2026-03-06T09:27:11.044875Z INFO httpmock::server::server: started
--
142:user:2026-03-06T09:29:39.907268Z INFO httpmock::server::server: started
jp-c17727953962 Debugging llamacpp reasoning 1 match · 17 turns
..:title:Debugging llamacpp reasoningThe coordinate
Each hit is located by three fields: the conversation, the turn, and the scope.
jp-c17727547754 : 142 : user : <the matching line>
↓ ↓ ↓
conversation turn scopeThe turn field is a value jp conversation print --turn accepts, so a hit feeds straight back into the commands that show conversations:
jp c print jp-c17727547754 --turn 142 # read the turn the match came from
jp c use jp-c17727547754 # switch to that conversation
jp query --attach 'jp-c17727547754?a:142' # attach that turn to a new questionA hit in the conversation title isn't turn-scoped, so its turn field is .. — "all turns", which --turn also accepts. jp c print <id> --turn .. prints the whole conversation.
Reading the output
The fourth field is m for a line that matched and c for a context line pulled in by --context:
jp c grep --context 1 'MATCH'jp-c17727547754:142:user:c:the line before
jp-c17727547754:142:user:m:the MATCH itself
jp-c17727547754:142:user:c:the line afterUnlike grep, the marker is a field rather than a change of separator. A separator-based marker can't be parsed: you would have to know a line's kind before you could find its field boundaries, and parse the fields to learn its kind. As a field, it costs one character and leaves every record splittable the same way.
A -- on its own line separates non-adjacent groups of hits — between context blocks, between turns, and between conversations. These separators belong to --context output; without it, every line is a matching record. They are the one thing in the stream that isn't a record, so a script that only wants records can drop them by requiring five fields.
Terminal versus pipe
In a terminal, hits are grouped under a per-conversation heading and lines are fitted to the terminal width. The heading shows how many matches the conversation gave you and how many turns it has in total, so you can judge whether it's worth opening.
Only matching rows carry a coordinate. Context rows leave that column blank, padded so the text still lines up — every hit in a block comes from one event, so the turn and scope would be identical on every row anyway, and printing them once per block leaves the coordinate as the thing that marks a match:
jp-c17835864469 Store temporary files in workspace 1 match · 12 turns
_think_ we should add it here, since it solves the durable
storage problem:
3:user:# Store temporary files in workspace
#projects/jp #ideaWhen piped, each hit becomes one self-contained line, with no styling and no truncation:
ID:TURN:SCOPE:KIND:TEXTFour :-delimited fields, always in the same order and always present, then the matched text verbatim. None of the four can contain a :, and the text is last, so every line parses the same way no matter which flags produced it:
jp c grep 'httpmock' | while IFS=: read -r id turn scope kind text; do
echo "$kind $id turn $turn: $text"
doneThe text may contain colons of its own — timestamps and Rust paths both do — so split into exactly five fields and keep the last whole, as the read loop above does. cut -d: -f5- keeps fields five through the end; a plain fifth-field selection like awk -F: '{print $5}' truncates at the next colon.
Force either shape with --heading or --no-heading.
Because the fields are fixed, fzf can browse the results with jp c print as the preview:
jp c grep -F text-pretty --no-heading 'httpmock' \
| fzf --ansi --delimiter : \
--preview 'jp c print {1} --turn {2} -F text-pretty --width $FZF_PREVIEW_COLUMNS'-F text-pretty keeps the styling that a pipe would otherwise turn off, and --ansi tells fzf to render it rather than print the escapes. Placeholders are unaffected: fzf strips styling before substituting, so {1} and {2} are the plain ID and turn.
The preview needs --width because a pipe reports no terminal size, so nothing is laid out to the pane. $FZF_PREVIEW_COLUMNS is the size fzf exports for it.
--context records work here — a context line carries the same coordinate as any other, so {1} and {2} resolve on them — but --context also emits -- separator rows, and selecting one gives fzf no ID or turn to substitute, so the preview shows an error. Filter them out (| grep -v '^--$') if you want context here.
Simpler to skip --context altogether: the preview pane already shows the whole turn each hit came from, which is more surrounding text than --context gives you.
Restricting the search
--scope limits which parts of a conversation are searched. It accepts the concrete scopes title, user, assistant, reasoning, structured, tool-call, tool-result, and inquiry, plus two shorthands:
| Scope | Expands to |
|---|---|
chat | user, assistant, reasoning, structured |
tool | tool-call, tool-result |
all | every scope (the default) |
jp c grep --scope chat 'retry' # only what was said
jp c grep --scope tool-call 'fs_modify' # only tool invocations
jp c grep --scope title 'triage' # only titlesFor more than one scope, repeat the flag or comma-separate the values:
jp c grep --scope user --scope assistant 'retry'
jp c grep --scope user,assistant 'retry'Searching title alone never reads the event streams, so it stays fast across a large workspace.
Every conversation in the workspace is searched unless --id narrows it:
jp c grep -i. 'error' # the conversation you're in
jp c grep --id recent 'error' # the most recently activated one
jp c grep --id +pinned 'error' # every pinned conversation. (long form active) is the session's active conversation — the one jp query would continue and jp c print would show. +l (long form +live) is every live conversation, which is what you get without --id.
Run jp c grep --help for the full target grammar.
Matching
Patterns are literal by default — a.c matches the three characters a.c, not abc. Pass --regex for a regular expression, with look-around and backreferences available:
jp c grep --regex '\Atriage-\d{3}\z' --scope titleCase follows smart-case: an all-lowercase pattern matches case-insensitively, and any uppercase character makes the whole pattern case-sensitive. Override with --ignore-case or --case-sensitive.
"Any uppercase character" counts characters anywhere in the pattern, including inside regex syntax — \W, \S, \D, \A, \z all make a pattern case-sensitive even when the text you're searching for is lowercase. The example above is case-sensitive for that reason. Pass --ignore-case explicitly when a regex needs those escapes and case-insensitive matching.
What to emit
--output picks which records you get. It composes with the global --format flag, which picks how they're encoded.
--output | Emits |
|---|---|
hits (default) | matching lines with their coordinates |
ids | the conversation ID only, one per line |
count | ID:COUNT — matching lines per conversation |
text | matched and context lines, no coordinates or separators |
jp c grep --output ids 'error' | jp c archive -
jp c grep --output count 'retry'
jp c grep --output text 'TODO' > todos.txt
jp c grep --output count 'retry' --format json | jq '.[0].count'--output hits --format json gives one object per hit, with submatches carrying the byte offsets of each match within the line:
[
{
"id": "jp-c17727547754",
"turn": 142,
"scope": "user",
"timestamp": "2026-03-06T09:27:11.044875Z",
"title": "Flaky openrouter multi-turn test",
"text": "... INFO httpmock::server::server: started",
"match": true,
"submatches": [{ "match": "httpmock", "start": 10, "end": 18 }]
}
]A title hit reports "turn": null.
Limits
| Flag | Caps |
|---|---|
--limit N | conversations shown, in sort order |
--max-matches N | matching lines per conversation |
Long lines are truncated to the output width: the terminal's when stdout is a terminal, unlimited when piped. The global --width sets it explicitly.
Scripting
Exit status follows grep:
| Status | Meaning |
|---|---|
0 | at least one match |
1 | no matches |
2 | the pattern was invalid, or a failure |
Splitting 1 from 2 lets a script tell "nothing matched" apart from "the pattern was broken". With no matches, nothing is written to stdout when piped, so --output ids is safe to feed onward unconditionally.
Under the global --quiet, the exit status is the whole answer: no hits are printed, and the search stops at the first match instead of reading every conversation.
if jp c grep --quiet 'panic'; then
echo "a conversation mentions a panic"
fiSorting
--sort orders conversations by created (the default), activated (last switched to), or updated (last event). --descending reverses it.