diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..529e49a --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -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" +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..9e72abf --- /dev/null +++ b/.claude-plugin/plugin.json @@ -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" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index c5a0a4b..377d8e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `** prints a whole conversation without resuming it. This was already there as the TUI's Ctrl-O, reachable only as an internal subcommand; it is now a documented command, so a search hit can actually be opened. diff --git a/README.md b/README.md index 1f8087a..f98265a 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 diff --git a/agsearch b/agsearch index 0caafed..711bb57 100755 --- a/agsearch +++ b/agsearch @@ -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 `. +with `agsearch read `. 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 @@ -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): @@ -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 "" | 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 diff --git a/skills/agsearch/SKILL.md b/skills/agsearch/SKILL.md new file mode 100644 index 0000000..15ddea0 --- /dev/null +++ b/skills/agsearch/SKILL.md @@ -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 + +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 "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 `. +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. diff --git a/tests/test_agent_output.py b/tests/test_agent_output.py index 0446181..f08f965 100644 --- a/tests/test_agent_output.py +++ b/tests/test_agent_output.py @@ -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)) diff --git a/tests/test_skill.py b/tests/test_skill.py new file mode 100644 index 0000000..dd76e83 --- /dev/null +++ b/tests/test_skill.py @@ -0,0 +1,87 @@ +"""The skill is documentation an agent executes, so a stale line in it is a bug. + +It ships as a Claude Code plugin: the marketplace manifest points at this repo, and the +skill sits where Claude Code expects to find it. These tests pin the manifests to the file +they promise, and pin the skill to the CLI it teaches. +""" + +import json +import pathlib +import re +import unittest + +from load_agsearch import load_agsearch + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SKILL = ROOT / "skills" / "agsearch" / "SKILL.md" +MARKETPLACE = ROOT / ".claude-plugin" / "marketplace.json" +PLUGIN = ROOT / ".claude-plugin" / "plugin.json" + + +def frontmatter(text): + m = re.match(r"^---\n(.*?)\n---\n", text, re.S) + out = {} + for line in (m.group(1).splitlines() if m else []): + if ": " in line: + k, v = line.split(": ", 1) + out[k.strip()] = v.strip() + return out + + +class SkillTests(unittest.TestCase): + def setUp(self): + self.text = SKILL.read_text() + self.script = (ROOT / "agsearch").read_text() + + def test_frontmatter_names_the_skill(self): + self.assertEqual(frontmatter(self.text).get("name"), "agsearch") + + def test_the_description_says_when_to_fire(self): + """The description is the only part always in context. It has to carry the triggers.""" + desc = frontmatter(self.text).get("description", "") + self.assertIn("session", desc.lower()) + self.assertGreater(len(desc), 80, desc) + + def test_every_flag_the_skill_teaches_exists_in_the_cli(self): + """A skill naming a removed flag sends the agent to a dead command.""" + for flag in sorted(set(re.findall(r"agsearch [^\n]*?(--[a-z][a-z-]+)", self.text))): + self.assertIn(f'"{flag}"', self.script, + f"the skill teaches {flag}, the CLI has no such flag") + + def test_the_skill_says_what_to_do_without_the_binary(self): + """The plugin installs on its own, so a reader can hold the skill without the CLI. + + It ships the binary too, so the answer is to use that rather than to give up. + """ + self.assertIn("CLAUDE_PLUGIN_ROOT", self.text) + self.assertIn("brew install", self.text) + + def test_every_subcommand_the_skill_teaches_exists(self): + for sub in sorted(set(re.findall(r"agsearch (read|-n) ", self.text))): + self.assertIn(f'"{sub}"', self.script) + + +class PluginManifestTests(unittest.TestCase): + """`/plugin install` fails silently-ish on a manifest that points at nothing.""" + + def test_the_marketplace_lists_this_plugin(self): + m = json.loads(MARKETPLACE.read_text()) + names = [p["name"] for p in m["plugins"]] + self.assertIn(json.loads(PLUGIN.read_text())["name"], names) + + def test_every_plugin_source_exists(self): + for p in json.loads(MARKETPLACE.read_text())["plugins"]: + self.assertTrue((ROOT / p["source"]).is_dir(), p["source"]) + + def test_the_source_actually_contains_the_skill(self): + for p in json.loads(MARKETPLACE.read_text())["plugins"]: + found = list((ROOT / p["source"]).glob("skills/*/SKILL.md")) + self.assertTrue(found, f"{p['source']} has no skills/*/SKILL.md") + + def test_the_plugin_version_is_a_version(self): + """Users only get updates when this changes, so a missing one freezes them.""" + self.assertRegex(json.loads(PLUGIN.read_text())["version"], r"^\d+\.\d+\.\d+$") + + +if __name__ == "__main__": + unittest.main()