Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "agsearch",
"owner": {
"name": "Dev Dalia",
"url": "https://github.com/devcodes9"
},
"plugins": [
{
"name": "agsearch",
"source": "./",
"description": "Search your past Claude Code and Codex sessions from inside Claude"
}
],
"description": "agsearch: search your past Claude Code and Codex sessions from inside Claude"
}
8 changes: 8 additions & 0 deletions .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "agsearch",
"description": "Search your past Claude Code and Codex sessions from inside Claude",
"version": "0.1.0",
"author": { "name": "Dev Dalia" },
"homepage": "https://github.com/devcodes9/agsearch",
"license": "MIT"
}
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ migration in the same line.

### Added

- **A Claude Code skill**, installed as a plugin (`/plugin marketplace add devcodes9/agsearch`,
then `/plugin install agsearch@agsearch`) or by copying `skills/agsearch/` into
`~/.claude/skills/`. With it Claude searches your transcripts itself when you refer to
earlier work, rather than answering that it has no record of the conversation, and it can
carry an old session's context forward into the session you are in now, which resuming
cannot do. The skill teaches query construction because that is where an agent fails: on a
247-query benchmark, content words alone rank the right session first 49% of the time, and
the same words left inside the question that carried them score 10% to 22%.

- **`agsearch read <session-id>`** prints a whole conversation without resuming it. This
was already there as the TUI's <kbd>Ctrl-O</kbd>, reachable only as an internal
subcommand; it is now a documented command, so a search hit can actually be opened.
Expand Down
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ uvx agsearch -n "stripe tax id"
- **Ranked results.** BM25 ranking favors focused sessions and shows matching lines in context.
- **Preview, read, or resume.** Inspect a match, open the transcript in a pager, or return to the
original session.
- **Your agent can search too.** A Claude Code skill, so Claude finds the earlier conversation
itself instead of answering that it has no record of it.
- **Fully local.** No uploads, API keys, hosted index, or network calls.
- **Fast warm searches.** A per-file cache reparses only transcripts that changed.

Expand Down Expand Up @@ -112,7 +114,38 @@ agsearch read 3f2a1c4e-... # the whole conversation, no resume,
```

Ranking is the same as the interactive list, so a term you half-remember or mistype finds
the same session either way.
the same session either way. Piped, the session ids shorten to a unique prefix, the columns
lose their padding, and `read` prints the start and end of a long session rather than all of
it. A terminal sees none of that.

### Let Claude search for you

agsearch ships a Claude Code skill. Install it from inside Claude Code:

```
/plugin marketplace add devcodes9/agsearch
/plugin install agsearch@agsearch
```

From the next session on, Claude searches your transcripts itself when you refer to work from
an earlier conversation:

> **you:** what did we decide about the webhook retry backoff?
>
> **Claude:** *runs `agsearch -n "webhook retry backoff"`, reads the top hit, answers from it*

It also covers handoff, which resuming cannot do. `claude --resume` moves you back into the old
session in its own directory; the skill carries that session's context forward into the one you
are in now, so you can pick the work up in a different repository or on a different branch.

The skill is [a single markdown file](https://github.com/devcodes9/agsearch/blob/main/skills/agsearch/SKILL.md).
Read it before installing. If you would rather not add a marketplace, copy it instead:

```sh
mkdir -p ~/.claude/skills && cp -r skills/agsearch ~/.claude/skills/
```

Either way it needs the `agsearch` binary, which the installation section above covers.

### Interactive keys

Expand Down
54 changes: 32 additions & 22 deletions agsearch
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ Usage:

`-n` prints one entry per session, led by its session id, and drops colour when it is
not writing to a terminal — so a script or a coding agent can search, then read a hit
with `agsearch read <session-id>`.
with `agsearch read <session-id>`. Piped, ids shorten to a unique prefix, the columns
lose their padding and `read` caps its output; a terminal sees none of that.

Claude can drive that loop itself. `/plugin marketplace add devcodes9/agsearch` then
`/plugin install agsearch@agsearch` installs a skill that searches these sessions when
you refer to earlier work, instead of answering that it has no record of it.

In the TUI the right pane previews the matched session, auto-scrolled to your match
(marked ▶) with a "match N of M" header. Enter resumes the session (and copies your
Expand Down Expand Up @@ -1015,24 +1020,27 @@ AGENT_READ_CHARS = 12_000 # what a piped `read` spends before it starts el
OPENING_TURNS = 2 # kept whatever the budget: the opening states the goal


def _elide(blocks, budget):
"""Keep the opening turns and as many closing turns as fit, drop the middle.
def _elide(blocks, budget, matched=()):
"""Fit a transcript into `budget` characters, keeping the turns worth keeping.

A session is read to answer one of two questions: what were we doing, and where did we
stop. The first lives in the opening turns and the second in the closing ones, so a
transcript too long to print whole loses least from the middle. Returns (blocks, dropped).
stop. Those live at the two ends, so the middle is what a too-long transcript can afford
to lose. A query changes the question to a third one, what was said about this, and then
the turns that matched outrank the ending: asking for matches and getting the last twenty
messages instead is the wrong answer to the question that was asked.

Returns (blocks, dropped).
"""
if sum(len(b) for b in blocks) <= budget:
return blocks, 0
head = blocks[:OPENING_TURNS]
spent = sum(len(b) for b in head)
tail = []
for b in reversed(blocks[OPENING_TURNS:]):
if spent + len(b) > budget:
break
tail.insert(0, b)
spent += len(b)
return head + tail, len(blocks) - len(head) - len(tail)
n = len(blocks)
keep = set(range(min(OPENING_TURNS, n))) # the opening states the goal
spent = sum(len(blocks[i]) for i in keep)
for i in list(matched) + list(range(n - 1, -1, -1)): # matches, then the ending
if i not in keep and spent + len(blocks[i]) <= budget:
keep.add(i)
spent += len(blocks[i])
return [blocks[i] for i in sorted(keep)], n - len(keep)


def render_transcript(sid, thinking="0", query="", color=None, budget=None):
Expand Down Expand Up @@ -1063,22 +1071,24 @@ def render_transcript(sid, thinking="0", query="", color=None, budget=None):
+ (f" · {n_sub} subagent" if n_sub else "") + fork_line(sid) + "\033[0m",
f"\033[2m{resume_line(sid, r0[1])}\033[0m\n"]

blocks = []
blocks, matched = [], []
for r, sub in tagged:
hit = bool(keys) and all(k in r[7].lower() for k in keys)
mark = "\033[1;33m▶\033[0m " if hit else " "
if hit:
matched.append(len(blocks))
blocks.append(mark + _turn_header(r[4], source, sub) + "\n"
+ f"\033[2m{r[3][11:16]}\033[0m " + _snippet(r[7], keys, 100_000) + "\n")

dropped = 0
total, dropped = len(blocks), 0
if budget:
blocks, dropped = _elide(blocks, budget)
blocks, dropped = _elide(blocks, budget, matched)
if dropped:
note = (f"[{dropped} messages elided from the middle. "
f"To see them, search inside this session: "
f'agsearch -n "<terms>" | grep {sid[:13]}, '
f"or read it whole: agsearch read {sid[:13]} --full]")
blocks.insert(OPENING_TURNS, "\033[2m" + note + "\033[0m\n")
kept = ("the opening, every turn matching your query that fits, and the ending"
if matched else "the opening and the ending")
note = (f"[{dropped} of {total} messages elided; kept {kept}. "
f"Whole transcript: agsearch read {sid[:13]} --full]")
blocks.insert(min(OPENING_TURNS, len(blocks)), "\033[2m" + note + "\033[0m\n")
_emit("\n".join(out + blocks) + "\n", color)
return 0

Expand Down
101 changes: 101 additions & 0 deletions skills/agsearch/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
---
name: agsearch
description: Search past Claude Code and Codex sessions saved on this machine. Use when the user refers to earlier work ("what did we decide about X", "we hit this error before"), asks to continue or hand off work started in another session, or when you are about to say you have no record of a conversation that happened before this one.
---

# agsearch

Every Claude Code and Codex session on this machine is a transcript on disk. `agsearch`
searches what was said inside them and prints ranked hits. You do not remember those
sessions. The transcripts do.

## The command

Use `agsearch` from the PATH. When it is not there, this plugin ships a copy at
`"$CLAUDE_PLUGIN_ROOT/agsearch"`. Same program either way. Resolve it once and use that for
every command below.

If neither exists, say so and name `brew install devcodes9/tap/agsearch`, rather than
retrying or reading the transcripts some other way.

## Build the query from content words

Pass the nouns, error strings and identifiers. Question words match hundreds of sessions
and outweigh the two or three words that identify one.

user: what did we decide about the webhook retry backoff?
you: agsearch -n "webhook retry backoff"

Two to four content words is the target. On a 247-query benchmark, content words alone put
the right session first 49% of the time; the same words left inside the question that carried
them scored 10% to 22%.

Pass the user's own vocabulary. Inflections and typos are handled, so `migration` finds
`migrate` and a misspelling still ranks.

## Read the results

Each hit is one session: id, date, agent, matched/total terms, project, title, then the line
that matched underneath.

The top hit is usually the right session, not always. Read the matched line before trusting
it, and name the session you are answering from so the user can check you.

**Too many hits.** Add a content word. Narrowing beats paging: the output caps and reports how
many it withheld.

**No hits.** Drop to the single most distinctive word, usually an error string, a library name
or an identifier. If that finds nothing, stop and say the conversation is not on this machine.
That is a real answer, and a more useful one than a guess.

Reach for the transcripts through `agsearch` rather than reading them directly. They are JSONL
with tool calls, diffs and base64 attachments interleaved, so grepping them returns matches
from machine noise rather than from anything anyone said.

## Open a session

agsearch read <id>

The id is the first field of a result row. Any unambiguous prefix works. Output is capped for
you: you get the opening turns and the closing turns, which is what tells you the goal and the
outcome.

To find something specific inside a long session, pass the terms as well:

agsearch read <id> "retry backoff"

The turns that matched are marked and are kept ahead of the ending when the cap applies.
`--full` prints everything when you need the middle too.

## Handoff

When the user asks to continue work from an earlier session:

1. Find the session with the search above.
2. `agsearch read <id>`.
3. Report the goal, what was already done, and where it stopped.
4. Continue the work.

Step 3 is done when you can state all three from the transcript. If any of them is a guess,
read again with the terms you are missing, or with `--full`.

Where the work left commits or a pull request, read those too: the transcript carries the
reasoning and the discarded options, and git carries what actually shipped. Neither is the
whole story on its own.

This is not `claude --resume`. Resume moves you back into the old session in its own
directory. Handoff brings that context forward into the session you are in now.

## Scope a search

agsearch -n "..." --here # sessions from this directory's project only
agsearch -n "..." --project myapp # sessions whose path matches myapp

Use these when a query returns the right topic from the wrong repository.

## What this cannot do

Matching is lexical, not semantic. A session that discussed an idea in different words will
not surface, so a search returning nothing is weak evidence that a conversation never
happened. Everything runs locally against files already on disk, and nothing leaves the
machine.
36 changes: 36 additions & 0 deletions tests/test_agent_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,42 @@ def test_the_output_names_the_command_that_opens_a_hit(self):
self.assertIn("agsearch read", out)


class ElideTests(unittest.TestCase):
"""A capped `read` has to drop the right turns, not merely few enough of them."""

def blocks(self, n, size=100):
return [f"{i:03d}" + "x" * (size - 3) for i in range(n)]

def test_a_transcript_inside_the_budget_is_untouched(self):
b = self.blocks(5)
self.assertEqual(ag._elide(b, 10_000), (b, 0))

def test_the_opening_and_the_ending_survive(self):
"""What were we doing, and where did we stop."""
b = self.blocks(50)
kept, dropped = ag._elide(b, 1000)
self.assertIn(b[0], kept)
self.assertIn(b[-1], kept)
self.assertEqual(dropped, 50 - len(kept))

def test_matched_turns_outrank_the_ending(self):
"""Asking what was said about X and getting the last twenty messages is a wrong answer."""
b = self.blocks(50)
kept, _dropped = ag._elide(b, 1000, matched=[20, 21, 22])
for i in (20, 21, 22):
self.assertIn(b[i], kept)

def test_kept_turns_stay_in_order(self):
b = self.blocks(50)
kept, _dropped = ag._elide(b, 1000, matched=[30, 10])
self.assertEqual(kept, [x for x in b if x in set(kept)])

def test_a_budget_smaller_than_one_turn_still_returns(self):
b = self.blocks(10)
kept, dropped = ag._elide(b, 1)
self.assertEqual(len(kept) + dropped, 10)


class ResolveSidTests(unittest.TestCase):
def test_a_whole_id_resolves_to_itself(self):
self.assertEqual(resolve(UUID4, UUID4[0]), (UUID4[0], None))
Expand Down
Loading