&lt;?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>kvsankar's Blog</title><link>https://blog.sankara.net/</link><description>A blog by kvsankar</description><language>en-us</language><lastBuildDate>Sat, 02 May 2026 09:08:16 UTC</lastBuildDate><item><title>Building dryscope: Narrowing Repositories Before AI Agents Clean Them Up</title><link>https://blog.sankara.net/posts/building-dryscope/</link><guid>https://blog.sankara.net/posts/building-dryscope/</guid><pubDate>Sat, 02 May 2026 00:00:00 UTC</pubDate><description>&lt;![CDATA[<p>I started using AI coding tools sometime in 2024. At first, that mostly meant Cursor-style code completion: useful, occasionally surprising, but not yet the kind of agentic workflow where I could hand over a repository-level task and expect sustained progress.</p><p>Then the tools became more agentic. The models were not strong enough yet, but the interaction pattern changed. I could ask for bigger changes. The agent could edit several files. It could follow a plan, run tests, and try again.</p><p>That made me faster. It also made some projects messier.</p><p>Across consulting projects and astronomy side projects, I kept seeing the same code failure mode. Similar helpers would appear in multiple places. Slightly different versions of the same validation path would accumulate. One agent would solve a local problem without noticing that another version of the solution already existed elsewhere in the repository.</p><p>The documentation problem was related, but different. I was also experimenting with spec-driven development using coding agents, which meant the repository filled up with requirements notes, design notes, implementation plans, research notes, and status documents. Those documents often described overlapping pieces of the same feature, but not always in the same way. Some were current. Some were half-obsolete. Some were useful only because they preserved the intent behind a decision.</p><p>By late 2025, coding agents crossed an inflection point. Around November 2025, they became strong enough that the problem changed. It was no longer just &ldquo;agents create messy code.&rdquo; The better question became:</p><blockquote><p>How do I give a strong agent the right slice of the repository so it can clean things up without wasting context on everything else?</p></blockquote><p>On May 1, 2026, I published<a href="https://pypi.org/project/dryscope/"><code>dryscope</code></a> to PyPI. It is my attempt to answer that question.</p><p>The name is a conflation of<strong>DRY</strong> (&ldquo;Don&rsquo;t Repeat Yourself&rdquo;) and<strong>telescope</strong>. DRY gives the target: repeated code, repeated explanations, and overlapping knowledge. Telescope gives the posture: look across a large repository and bring the interesting parts into view before deciding what to clean up.</p><p><code>dryscope</code> scans a repository and produces a shortlist: duplicate-code candidates, repeated documentation sections, and documentation intent overlap. It does not rewrite your code or decide the refactor for you. The point is narrower and more practical: before I ask an agent, stronger model, or human reviewer to clean up a repo, show me the files and sections worth reading first.</p><p>Repository:<a href="https://github.com/kvsankar/dryscope">github.com/kvsankar/dryscope</a></p><h2 id="the-problem">The Problem</h2><p>Large-repo cleanup often starts with a vague instruction:</p><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Clean up the duplication in this project.</span></span></code></pre></div><p>That sounds reasonable, but it is underspecified. A large repository has too much surface area. The agent has to decide which directories matter, which repeated code is real duplication, which repeated code is test scaffolding, and which docs are current enough to trust.</p><p>If the agent starts in the wrong place, it burns context before it reaches the useful part of the project.</p><p>There are two related problems here: documentation context and code duplication. The documentation problem usually shows up first because spec-driven AI-assisted development creates many documents before, during, and after implementation.</p><p>A project may contain:</p><ul><li>product requirements</li><li>architecture notes</li><li>research notes</li><li>implementation plans</li><li>status updates</li><li>generated summaries</li><li>ADRs or half-ADRs</li></ul><p>Those documents are not always textually duplicated. The overlap is often about intent. Two documents may both describe the same feature, but one frames it as requirements, another as design, and another as rollout status. If I hand all of them to an agent, the model gets an unfocused pile of partially overlapping context instead of one clear source of truth.</p><p>The code problem is the second half of the same context-management issue. Agent-assisted development makes it cheap to solve a local problem again. That is convenient in the moment, but over time similar logic appears in commands, services, parsers, UI branches, tests, and migration scripts.</p><p>I wanted a narrowing pass before the cleanup pass.</p><h2 id="from-doclens-to-dryscope">From doclens To dryscope</h2><p>The first version of this idea was not about code. It was a small project called<code>doclens</code>.</p><p><code>doclens</code> started as a documentation overlap detector. The earliest version used<a href="https://en.wikipedia.org/wiki/Normalized_compression_distance">normalized compression distance</a> as a fast first filter, then embeddings and LLM analysis for the pairs that survived. That was useful for finding repeated or near-repeated content, but it missed the more important problem: two sections can be about the same thing without saying it the same way.</p><p>The first step change was making embeddings the primary filter. The early design used a progressive pipeline: normalized compression distance first, API embeddings second, and LLM analysis after that. In<code>doclens</code> v0.5, I replaced that with a<a href="https://github.com/MinishLab/model2vec">Model2Vec</a> embeddings-first pipeline. That made the tool closer to how I already thought about<a href="https://en.wikipedia.org/wiki/Retrieval-augmented_generation">retrieval-augmented generation</a>: turn text into vectors, compare meaning, and use the closest matches as candidate context.</p><p>But pairwise similarity alone was not enough. A pile of similar section pairs is still a pile. The output had to answer practical questions:</p><ul><li>Is this a repeated explanation that should become one canonical section?</li><li>Are these two documents serving different readers and both worth keeping?</li><li>Is one document current while the other is historical?</li><li>Is this exact text overlap, or broader intent overlap?</li></ul><p>That pressure pushed the project from raw matching toward reports, labels, and judgment.</p><p>The second step change was adding code.<code>dryscope</code> grew beyond documentation and started parsing source code with<a href="https://tree-sitter.github.io/tree-sitter/">tree-sitter</a>. Instead of comparing whole files, it extracts code units: functions, classes, methods, constructors, and function-valued declarations.</p><p>The third step change was information architecture. Documentation overlap was not just &ldquo;section A resembles section B.&rdquo; I needed a Docs Map: document descriptors, canonical labels, topic groups, facets, diagnostics, and consolidation clusters.</p><p>That is when<code>doclens</code> effectively became one part of<code>dryscope</code>.</p><h2 id="how-the-code-path-works">How The Code Path Works</h2><p>The code pipeline is intentionally boring in the right places.</p><p>First,<code>dryscope</code> parses source files with<a href="https://tree-sitter.github.io/tree-sitter/">tree-sitter</a>. It currently supports Python, Go, Java, JavaScript, JSX, TypeScript, and TSX. The parser extracts code units rather than treating a file as one blob.</p><p>Then it normalizes each code unit. Comments and docstrings are removed. Identifiers and literals are replaced with placeholders. The goal is to make this kind of clone visible:</p><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span><span class="nf">load_user_config</span><span class="p">(</span><span class="n">path</span><span class="p">):</span></span></span><span class="line"><span class="cl"><span class="n">raw</span><span class="o">=</span><span class="n">path</span><span class="o">.</span><span class="n">read_text</span><span class="p">()</span></span></span><span class="line"><span class="cl"><span class="k">return</span><span class="n">json</span><span class="o">.</span><span class="n">loads</span><span class="p">(</span><span class="n">raw</span><span class="p">)</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="k">def</span><span class="nf">load_project_config</span><span class="p">(</span><span class="n">file_path</span><span class="p">):</span></span></span><span class="line"><span class="cl"><span class="n">content</span><span class="o">=</span><span class="n">file_path</span><span class="o">.</span><span class="n">read_text</span><span class="p">()</span></span></span><span class="line"><span class="cl"><span class="k">return</span><span class="n">json</span><span class="o">.</span><span class="n">loads</span><span class="p">(</span><span class="n">content</span><span class="p">)</span></span></span></code></pre></div><p>Those functions are not textually identical, but structurally they are the same operation.</p><p>After normalization,<code>dryscope</code> embeds each unit and compares candidates with a hybrid score: embedding<a href="https://en.wikipedia.org/wiki/Cosine_similarity">cosine similarity</a> plus token<a href="https://en.wikipedia.org/wiki/Jaccard_index">Jaccard similarity</a>, with size-ratio filtering so tiny helpers do not get matched against much larger functions. Candidate pairs are clustered with<a href="https://en.wikipedia.org/wiki/Disjoint-set_data_structure">Union-Find</a> and reported as exact, near-identical, or structural matches.</p><p>That gives a Code Match report.</p><p>Raw similarity is not enough, so there is an optional Code Review pass. That sends candidate clusters to an LLM reviewer and classifies them as:</p><ul><li><code>refactor</code></li><li><code>review</code></li><li><code>noise</code></li></ul><p>Then a deterministic escalation policy keeps all<code>review</code> findings and only keeps higher-value<code>refactor</code> findings. This matters because the enemy is not missing every possible duplicate. The enemy is handing the next agent a noisy list that causes another context burn.</p><h2 id="how-the-docs-path-works">How The Docs Path Works</h2><p>The docs path has two related but separate ideas.</p><p><strong>Section Match</strong> works at the microscopic level. It chunks Markdown, MDX, RST, AsciiDoc, and plaintext documents by headings, embeds the sections, and finds repeated or near-repeated section-level material across documents using embedding similarity.</p><p>For example, a requirements document and a design document may both contain a<code>Configuration</code> section explaining the same environment variables. Those documents should not necessarily be merged, but the repeated section may need one canonical reference.</p><p><strong>Docs Map</strong> works at the corpus level. It asks a different question:</p><blockquote><p>What are these documents about, what reader intent do they serve, and where do they overlap in purpose?</p></blockquote><p>For each document,<code>dryscope</code> can extract descriptors such as title, summary, aboutness labels, reader intents, document role, audience, lifecycle, content type, surface, and canonicality. Those raw labels are normalized into a corpus-level taxonomy.</p><p>A small example:</p><table><thead><tr><th>Document</th><th>Raw signal</th></tr></thead><tbody><tr><td><code>docs/search-requirements.md</code></td><td>search filters, ranking expectations, user-visible behavior</td></tr><tr><td><code>docs/search-design.md</code></td><td>indexing pipeline, query API, architecture</td></tr><tr><td><code>research/vector-search.md</code></td><td>embeddings, retrieval quality, ranking experiments</td></tr><tr><td><code>plans/search-rollout.md</code></td><td>rollout checklist, status, risks</td></tr></tbody></table><p>Docs Map can turn that into canonical labels like:</p><table><thead><tr><th>Area</th><th>Example labels</th></tr></thead><tbody><tr><td>Aboutness</td><td><code>search experience</code>,<code>indexing pipeline</code>,<code>ranking quality</code>,<code>vector retrieval</code></td></tr><tr><td>Reader intent</td><td><code>define requirements</code>,<code>explain architecture</code>,<code>compare approaches</code>,<code>track rollout</code></td></tr><tr><td>Facets</td><td><code>doc_role: requirements/design/research/plan</code>,<code>lifecycle: current/draft</code>,<code>audience: maintainer/agent</code></td></tr><tr><td>Cluster</td><td>documents that should share a source of truth or cross-reference each other</td></tr></tbody></table><p>That distinction matters. Section Match says, &ldquo;these sections repeat.&rdquo; Docs Map says, &ldquo;these documents are trying to describe overlapping parts of the same system.&rdquo;</p><p>Both are useful before giving context to an agent.</p><p><img alt="dryscope process diagram" loading="lazy" src="/images/dryscope/dryscope-process.png"/><h2 id="why-not-just-ask-the-agent">Why Not Just Ask The Agent?</h2><p>You can ask an agent to inspect a repository and find duplication. I do that too.</p><p>But repository-wide inspection is exactly where context management matters. The agent has to choose what to read before it knows what matters. It may spend most of its budget on framework boilerplate, generated files, examples, test fixtures, stale notes, or low-value similarity.</p><p><code>dryscope</code> tries to make that first pass cheaper.</p><p>It uses deterministic parsing, normalization, filtering, and clustering before optional LLM judgment. The LLM is not the whole product. It is one review stage after cheaper signals have narrowed the candidate set.</p><p>That design is deliberate. I do not want a tool that says, &ldquo;here is the final refactor.&rdquo; I want a tool that says:</p><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Start here.</span></span><span class="line"><span class="cl">These files probably deserve attention first.</span></span><span class="line"><span class="cl">These docs probably overlap in purpose.</span></span><span class="line"><span class="cl">These findings are likely noise.</span></span></code></pre></div><p>That is a better handoff to a coding agent.</p><h2 id="benchmarks-as-design-pressure">Benchmarks As Design Pressure</h2><p>I added public benchmark runs because it is too easy to fool yourself with examples.</p><p>The benchmark report in the repository is careful about what it claims. The labels are sparse. Unlabeled surfaced findings are not counted as false positives. The numbers describe a reviewed slice of benchmark output, not the universe of every possible duplicate in every repo.</p><p>The current aggregate looks like this:</p><table><thead><tr><th>Track</th><th style="text-align: right">TP</th><th style="text-align: right">FP</th><th style="text-align: right">FN</th><th style="text-align: right">Labeled precision</th><th style="text-align: right">Curated recall</th><th style="text-align: right">F1</th></tr></thead><tbody><tr><td>Code Review</td><td style="text-align: right">11</td><td style="text-align: right">7</td><td style="text-align: right">2</td><td style="text-align: right">0.61</td><td style="text-align: right">0.85</td><td style="text-align: right">0.71</td></tr><tr><td>Section Match</td><td style="text-align: right">3</td><td style="text-align: right">3</td><td style="text-align: right">0</td><td style="text-align: right">0.50</td><td style="text-align: right">1.00</td><td style="text-align: right">0.67</td></tr></tbody></table><p>That is not a victory lap. It is a useful alpha signal.</p><p>For the workflow I care about,<a href="https://en.wikipedia.org/wiki/Precision_and_recall">recall and precision</a> have different meanings. Recall matters because I want the shortlist to catch the real cleanup candidates. Precision still matters because every false positive wastes attention. The current state is exactly what I would expect from a public alpha: useful enough to narrow a repo, not clean enough to trust blindly.</p><p>Some public validation examples were encouraging:</p><table><thead><tr><th>Repo</th><th style="text-align: right">Structural candidates</th><th style="text-align: right">Verified shortlist from top 15</th></tr></thead><tbody><tr><td><code>CLI-Anything-WEB</code></td><td style="text-align: right">94</td><td style="text-align: right">5</td></tr><tr><td><code>nanowave</code></td><td style="text-align: right">82</td><td style="text-align: right">10</td></tr><tr><td><code>ClaudeCode_generated_app</code></td><td style="text-align: right">51</td><td style="text-align: right">6</td></tr><tr><td><code>VibesOS</code></td><td style="text-align: right">23</td><td style="text-align: right">4</td></tr></tbody></table><p>The important thing is not the raw candidate count. It is that a duplicate-rich repo can be reduced to a review queue small enough for a human or agent to inspect.</p><h2 id="packaging-was-product-work">Packaging Was Product Work</h2><p>The publish-readiness phase surfaced a very practical issue.</p><p>At one point, a fresh wheel install pulled in the full local embedding stack, including PyTorch and NVIDIA wheels. That may be acceptable for someone who explicitly wants local sentence-transformer embeddings. It is not acceptable as the default install path for a CLI that someone may want to try quickly.</p><p>So the default package now supports API embeddings through<a href="https://docs.litellm.ai/">LiteLLM</a>, and local embeddings live behind an optional extra:</p><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">uv tool install<span class="s2">"dryscope[local-embeddings]"</span></span></span><span class="line"><span class="cl">pipx install<span class="s2">"dryscope[local-embeddings]"</span></span></span><span class="line"><span class="cl">python -m pip install<span class="s2">"dryscope[local-embeddings]"</span></span></span></code></pre></div><p>That was a good reminder: for developer tools, packaging is part of the product. If the first install feels surprising, users may never reach the interesting part.</p><h2 id="getting-started">Getting Started</h2><p>For a one-off run:</p><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">uvx dryscope --help</span></span><span class="line"><span class="cl">uvx dryscope scan .</span></span></code></pre></div><p>For a persistent tool install:</p><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">uv tool install dryscope</span></span><span class="line"><span class="cl">dryscope --help</span></span></code></pre></div><p>Or with<code>pipx</code>:</p><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">pipx install dryscope</span></span><span class="line"><span class="cl">dryscope --help</span></span></code></pre></div><p>The default embedding model uses API embeddings through LiteLLM. Set the provider API key for the embedding model you use, such as<code>OPENAI_API_KEY</code> for<code>text-embedding-3-small</code>.</p><p>Some useful commands:</p><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Code Match</span></span></span><span class="line"><span class="cl">dryscope scan /path/to/project</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># Code Review</span></span></span><span class="line"><span class="cl">dryscope scan /path/to/project --verify --max-findings<span class="m">15</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># Section Match for docs</span></span></span><span class="line"><span class="cl">dryscope scan /path/to/docs --docs</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># Full docs report pack</span></span></span><span class="line"><span class="cl">dryscope scan /path/to/docs --docs --stage docs-report-pack --backend cli -f html</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># Agent-friendly JSON</span></span></span><span class="line"><span class="cl">dryscope scan /path/to/project -f json</span></span></code></pre></div><h2 id="what-i-think-it-is">What I Think It Is</h2><p><code>dryscope</code> is not a linter. It is not a perfect semantic clone detector. It is not a replacement for code review. It is also not a refactoring oracle.</p><p>It is a narrowing tool.</p><p>That framing matters because AI-assisted development has made it easier to create more code and more documentation than we can comfortably keep in our heads. Stronger agents help, but they still need context. If the context is duplicated, stale, scattered, or too broad, the agent spends effort reconstructing the project before it can improve it.</p><p>I built<code>dryscope</code> to make that first step more explicit:</p><ol><li>scan the repo</li><li>find likely repeated implementation shapes</li><li>find repeated sections</li><li>map overlapping documentation intent</li><li>hand the shortlist to a human or agent</li></ol><p>The goal is not to remove judgment. The goal is to spend judgment where it has leverage.</p><p>That is the part of AI coding I am most interested in right now. Not just asking better prompts, and not pretending agents can hold an entire project in working memory. The useful layer is tooling that shapes the context before the agent starts.</p><p><code>dryscope</code> is my first public alpha in that direction.</p><h2 id="discussion">Discussion</h2><ul><li>GitHub:<a href="https://github.com/kvsankar/dryscope">kvsankar/dryscope</a></li><li>PyPI:<a href="https://pypi.org/project/dryscope/">dryscope</a></li></ul>
]]></description><content:encoded>&lt;![CDATA[<p>I started using AI coding tools sometime in 2024. At first, that mostly meant Cursor-style code completion: useful, occasionally surprising, but not yet the kind of agentic workflow where I could hand over a repository-level task and expect sustained progress.</p><p>Then the tools became more agentic. The models were not strong enough yet, but the interaction pattern changed. I could ask for bigger changes. The agent could edit several files. It could follow a plan, run tests, and try again.</p><p>That made me faster. It also made some projects messier.</p><p>Across consulting projects and astronomy side projects, I kept seeing the same code failure mode. Similar helpers would appear in multiple places. Slightly different versions of the same validation path would accumulate. One agent would solve a local problem without noticing that another version of the solution already existed elsewhere in the repository.</p><p>The documentation problem was related, but different. I was also experimenting with spec-driven development using coding agents, which meant the repository filled up with requirements notes, design notes, implementation plans, research notes, and status documents. Those documents often described overlapping pieces of the same feature, but not always in the same way. Some were current. Some were half-obsolete. Some were useful only because they preserved the intent behind a decision.</p><p>By late 2025, coding agents crossed an inflection point. Around November 2025, they became strong enough that the problem changed. It was no longer just &ldquo;agents create messy code.&rdquo; The better question became:</p><blockquote><p>How do I give a strong agent the right slice of the repository so it can clean things up without wasting context on everything else?</p></blockquote><p>On May 1, 2026, I published<a href="https://pypi.org/project/dryscope/"><code>dryscope</code></a> to PyPI. It is my attempt to answer that question.</p><p>The name is a conflation of<strong>DRY</strong> (&ldquo;Don&rsquo;t Repeat Yourself&rdquo;) and<strong>telescope</strong>. DRY gives the target: repeated code, repeated explanations, and overlapping knowledge. Telescope gives the posture: look across a large repository and bring the interesting parts into view before deciding what to clean up.</p><p><code>dryscope</code> scans a repository and produces a shortlist: duplicate-code candidates, repeated documentation sections, and documentation intent overlap. It does not rewrite your code or decide the refactor for you. The point is narrower and more practical: before I ask an agent, stronger model, or human reviewer to clean up a repo, show me the files and sections worth reading first.</p><p>Repository:<a href="https://github.com/kvsankar/dryscope">github.com/kvsankar/dryscope</a></p><h2 id="the-problem">The Problem</h2><p>Large-repo cleanup often starts with a vague instruction:</p><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Clean up the duplication in this project.</span></span></code></pre></div><p>That sounds reasonable, but it is underspecified. A large repository has too much surface area. The agent has to decide which directories matter, which repeated code is real duplication, which repeated code is test scaffolding, and which docs are current enough to trust.</p><p>If the agent starts in the wrong place, it burns context before it reaches the useful part of the project.</p><p>There are two related problems here: documentation context and code duplication. The documentation problem usually shows up first because spec-driven AI-assisted development creates many documents before, during, and after implementation.</p><p>A project may contain:</p><ul><li>product requirements</li><li>architecture notes</li><li>research notes</li><li>implementation plans</li><li>status updates</li><li>generated summaries</li><li>ADRs or half-ADRs</li></ul><p>Those documents are not always textually duplicated. The overlap is often about intent. Two documents may both describe the same feature, but one frames it as requirements, another as design, and another as rollout status. If I hand all of them to an agent, the model gets an unfocused pile of partially overlapping context instead of one clear source of truth.</p><p>The code problem is the second half of the same context-management issue. Agent-assisted development makes it cheap to solve a local problem again. That is convenient in the moment, but over time similar logic appears in commands, services, parsers, UI branches, tests, and migration scripts.</p><p>I wanted a narrowing pass before the cleanup pass.</p><h2 id="from-doclens-to-dryscope">From doclens To dryscope</h2><p>The first version of this idea was not about code. It was a small project called<code>doclens</code>.</p><p><code>doclens</code> started as a documentation overlap detector. The earliest version used<a href="https://en.wikipedia.org/wiki/Normalized_compression_distance">normalized compression distance</a> as a fast first filter, then embeddings and LLM analysis for the pairs that survived. That was useful for finding repeated or near-repeated content, but it missed the more important problem: two sections can be about the same thing without saying it the same way.</p><p>The first step change was making embeddings the primary filter. The early design used a progressive pipeline: normalized compression distance first, API embeddings second, and LLM analysis after that. In<code>doclens</code> v0.5, I replaced that with a<a href="https://github.com/MinishLab/model2vec">Model2Vec</a> embeddings-first pipeline. That made the tool closer to how I already thought about<a href="https://en.wikipedia.org/wiki/Retrieval-augmented_generation">retrieval-augmented generation</a>: turn text into vectors, compare meaning, and use the closest matches as candidate context.</p><p>But pairwise similarity alone was not enough. A pile of similar section pairs is still a pile. The output had to answer practical questions:</p><ul><li>Is this a repeated explanation that should become one canonical section?</li><li>Are these two documents serving different readers and both worth keeping?</li><li>Is one document current while the other is historical?</li><li>Is this exact text overlap, or broader intent overlap?</li></ul><p>That pressure pushed the project from raw matching toward reports, labels, and judgment.</p><p>The second step change was adding code.<code>dryscope</code> grew beyond documentation and started parsing source code with<a href="https://tree-sitter.github.io/tree-sitter/">tree-sitter</a>. Instead of comparing whole files, it extracts code units: functions, classes, methods, constructors, and function-valued declarations.</p><p>The third step change was information architecture. Documentation overlap was not just &ldquo;section A resembles section B.&rdquo; I needed a Docs Map: document descriptors, canonical labels, topic groups, facets, diagnostics, and consolidation clusters.</p><p>That is when<code>doclens</code> effectively became one part of<code>dryscope</code>.</p><h2 id="how-the-code-path-works">How The Code Path Works</h2><p>The code pipeline is intentionally boring in the right places.</p><p>First,<code>dryscope</code> parses source files with<a href="https://tree-sitter.github.io/tree-sitter/">tree-sitter</a>. It currently supports Python, Go, Java, JavaScript, JSX, TypeScript, and TSX. The parser extracts code units rather than treating a file as one blob.</p><p>Then it normalizes each code unit. Comments and docstrings are removed. Identifiers and literals are replaced with placeholders. The goal is to make this kind of clone visible:</p><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span><span class="nf">load_user_config</span><span class="p">(</span><span class="n">path</span><span class="p">):</span></span></span><span class="line"><span class="cl"><span class="n">raw</span><span class="o">=</span><span class="n">path</span><span class="o">.</span><span class="n">read_text</span><span class="p">()</span></span></span><span class="line"><span class="cl"><span class="k">return</span><span class="n">json</span><span class="o">.</span><span class="n">loads</span><span class="p">(</span><span class="n">raw</span><span class="p">)</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="k">def</span><span class="nf">load_project_config</span><span class="p">(</span><span class="n">file_path</span><span class="p">):</span></span></span><span class="line"><span class="cl"><span class="n">content</span><span class="o">=</span><span class="n">file_path</span><span class="o">.</span><span class="n">read_text</span><span class="p">()</span></span></span><span class="line"><span class="cl"><span class="k">return</span><span class="n">json</span><span class="o">.</span><span class="n">loads</span><span class="p">(</span><span class="n">content</span><span class="p">)</span></span></span></code></pre></div><p>Those functions are not textually identical, but structurally they are the same operation.</p><p>After normalization,<code>dryscope</code> embeds each unit and compares candidates with a hybrid score: embedding<a href="https://en.wikipedia.org/wiki/Cosine_similarity">cosine similarity</a> plus token<a href="https://en.wikipedia.org/wiki/Jaccard_index">Jaccard similarity</a>, with size-ratio filtering so tiny helpers do not get matched against much larger functions. Candidate pairs are clustered with<a href="https://en.wikipedia.org/wiki/Disjoint-set_data_structure">Union-Find</a> and reported as exact, near-identical, or structural matches.</p><p>That gives a Code Match report.</p><p>Raw similarity is not enough, so there is an optional Code Review pass. That sends candidate clusters to an LLM reviewer and classifies them as:</p><ul><li><code>refactor</code></li><li><code>review</code></li><li><code>noise</code></li></ul><p>Then a deterministic escalation policy keeps all<code>review</code> findings and only keeps higher-value<code>refactor</code> findings. This matters because the enemy is not missing every possible duplicate. The enemy is handing the next agent a noisy list that causes another context burn.</p><h2 id="how-the-docs-path-works">How The Docs Path Works</h2><p>The docs path has two related but separate ideas.</p><p><strong>Section Match</strong> works at the microscopic level. It chunks Markdown, MDX, RST, AsciiDoc, and plaintext documents by headings, embeds the sections, and finds repeated or near-repeated section-level material across documents using embedding similarity.</p><p>For example, a requirements document and a design document may both contain a<code>Configuration</code> section explaining the same environment variables. Those documents should not necessarily be merged, but the repeated section may need one canonical reference.</p><p><strong>Docs Map</strong> works at the corpus level. It asks a different question:</p><blockquote><p>What are these documents about, what reader intent do they serve, and where do they overlap in purpose?</p></blockquote><p>For each document,<code>dryscope</code> can extract descriptors such as title, summary, aboutness labels, reader intents, document role, audience, lifecycle, content type, surface, and canonicality. Those raw labels are normalized into a corpus-level taxonomy.</p><p>A small example:</p><table><thead><tr><th>Document</th><th>Raw signal</th></tr></thead><tbody><tr><td><code>docs/search-requirements.md</code></td><td>search filters, ranking expectations, user-visible behavior</td></tr><tr><td><code>docs/search-design.md</code></td><td>indexing pipeline, query API, architecture</td></tr><tr><td><code>research/vector-search.md</code></td><td>embeddings, retrieval quality, ranking experiments</td></tr><tr><td><code>plans/search-rollout.md</code></td><td>rollout checklist, status, risks</td></tr></tbody></table><p>Docs Map can turn that into canonical labels like:</p><table><thead><tr><th>Area</th><th>Example labels</th></tr></thead><tbody><tr><td>Aboutness</td><td><code>search experience</code>,<code>indexing pipeline</code>,<code>ranking quality</code>,<code>vector retrieval</code></td></tr><tr><td>Reader intent</td><td><code>define requirements</code>,<code>explain architecture</code>,<code>compare approaches</code>,<code>track rollout</code></td></tr><tr><td>Facets</td><td><code>doc_role: requirements/design/research/plan</code>,<code>lifecycle: current/draft</code>,<code>audience: maintainer/agent</code></td></tr><tr><td>Cluster</td><td>documents that should share a source of truth or cross-reference each other</td></tr></tbody></table><p>That distinction matters. Section Match says, &ldquo;these sections repeat.&rdquo; Docs Map says, &ldquo;these documents are trying to describe overlapping parts of the same system.&rdquo;</p><p>Both are useful before giving context to an agent.</p><p><img alt="dryscope process diagram" loading="lazy" src="/images/dryscope/dryscope-process.png"/><h2 id="why-not-just-ask-the-agent">Why Not Just Ask The Agent?</h2><p>You can ask an agent to inspect a repository and find duplication. I do that too.</p><p>But repository-wide inspection is exactly where context management matters. The agent has to choose what to read before it knows what matters. It may spend most of its budget on framework boilerplate, generated files, examples, test fixtures, stale notes, or low-value similarity.</p><p><code>dryscope</code> tries to make that first pass cheaper.</p><p>It uses deterministic parsing, normalization, filtering, and clustering before optional LLM judgment. The LLM is not the whole product. It is one review stage after cheaper signals have narrowed the candidate set.</p><p>That design is deliberate. I do not want a tool that says, &ldquo;here is the final refactor.&rdquo; I want a tool that says:</p><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Start here.</span></span><span class="line"><span class="cl">These files probably deserve attention first.</span></span><span class="line"><span class="cl">These docs probably overlap in purpose.</span></span><span class="line"><span class="cl">These findings are likely noise.</span></span></code></pre></div><p>That is a better handoff to a coding agent.</p><h2 id="benchmarks-as-design-pressure">Benchmarks As Design Pressure</h2><p>I added public benchmark runs because it is too easy to fool yourself with examples.</p><p>The benchmark report in the repository is careful about what it claims. The labels are sparse. Unlabeled surfaced findings are not counted as false positives. The numbers describe a reviewed slice of benchmark output, not the universe of every possible duplicate in every repo.</p><p>The current aggregate looks like this:</p><table><thead><tr><th>Track</th><th style="text-align: right">TP</th><th style="text-align: right">FP</th><th style="text-align: right">FN</th><th style="text-align: right">Labeled precision</th><th style="text-align: right">Curated recall</th><th style="text-align: right">F1</th></tr></thead><tbody><tr><td>Code Review</td><td style="text-align: right">11</td><td style="text-align: right">7</td><td style="text-align: right">2</td><td style="text-align: right">0.61</td><td style="text-align: right">0.85</td><td style="text-align: right">0.71</td></tr><tr><td>Section Match</td><td style="text-align: right">3</td><td style="text-align: right">3</td><td style="text-align: right">0</td><td style="text-align: right">0.50</td><td style="text-align: right">1.00</td><td style="text-align: right">0.67</td></tr></tbody></table><p>That is not a victory lap. It is a useful alpha signal.</p><p>For the workflow I care about,<a href="https://en.wikipedia.org/wiki/Precision_and_recall">recall and precision</a> have different meanings. Recall matters because I want the shortlist to catch the real cleanup candidates. Precision still matters because every false positive wastes attention. The current state is exactly what I would expect from a public alpha: useful enough to narrow a repo, not clean enough to trust blindly.</p><p>Some public validation examples were encouraging:</p><table><thead><tr><th>Repo</th><th style="text-align: right">Structural candidates</th><th style="text-align: right">Verified shortlist from top 15</th></tr></thead><tbody><tr><td><code>CLI-Anything-WEB</code></td><td style="text-align: right">94</td><td style="text-align: right">5</td></tr><tr><td><code>nanowave</code></td><td style="text-align: right">82</td><td style="text-align: right">10</td></tr><tr><td><code>ClaudeCode_generated_app</code></td><td style="text-align: right">51</td><td style="text-align: right">6</td></tr><tr><td><code>VibesOS</code></td><td style="text-align: right">23</td><td style="text-align: right">4</td></tr></tbody></table><p>The important thing is not the raw candidate count. It is that a duplicate-rich repo can be reduced to a review queue small enough for a human or agent to inspect.</p><h2 id="packaging-was-product-work">Packaging Was Product Work</h2><p>The publish-readiness phase surfaced a very practical issue.</p><p>At one point, a fresh wheel install pulled in the full local embedding stack, including PyTorch and NVIDIA wheels. That may be acceptable for someone who explicitly wants local sentence-transformer embeddings. It is not acceptable as the default install path for a CLI that someone may want to try quickly.</p><p>So the default package now supports API embeddings through<a href="https://docs.litellm.ai/">LiteLLM</a>, and local embeddings live behind an optional extra:</p><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">uv tool install<span class="s2">"dryscope[local-embeddings]"</span></span></span><span class="line"><span class="cl">pipx install<span class="s2">"dryscope[local-embeddings]"</span></span></span><span class="line"><span class="cl">python -m pip install<span class="s2">"dryscope[local-embeddings]"</span></span></span></code></pre></div><p>That was a good reminder: for developer tools, packaging is part of the product. If the first install feels surprising, users may never reach the interesting part.</p><h2 id="getting-started">Getting Started</h2><p>For a one-off run:</p><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">uvx dryscope --help</span></span><span class="line"><span class="cl">uvx dryscope scan .</span></span></code></pre></div><p>For a persistent tool install:</p><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">uv tool install dryscope</span></span><span class="line"><span class="cl">dryscope --help</span></span></code></pre></div><p>Or with<code>pipx</code>:</p><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">pipx install dryscope</span></span><span class="line"><span class="cl">dryscope --help</span></span></code></pre></div><p>The default embedding model uses API embeddings through LiteLLM. Set the provider API key for the embedding model you use, such as<code>OPENAI_API_KEY</code> for<code>text-embedding-3-small</code>.</p><p>Some useful commands:</p><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Code Match</span></span></span><span class="line"><span class="cl">dryscope scan /path/to/project</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># Code Review</span></span></span><span class="line"><span class="cl">dryscope scan /path/to/project --verify --max-findings<span class="m">15</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># Section Match for docs</span></span></span><span class="line"><span class="cl">dryscope scan /path/to/docs --docs</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># Full docs report pack</span></span></span><span class="line"><span class="cl">dryscope scan /path/to/docs --docs --stage docs-report-pack --backend cli -f html</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># Agent-friendly JSON</span></span></span><span class="line"><span class="cl">dryscope scan /path/to/project -f json</span></span></code></pre></div><h2 id="what-i-think-it-is">What I Think It Is</h2><p><code>dryscope</code> is not a linter. It is not a perfect semantic clone detector. It is not a replacement for code review. It is also not a refactoring oracle.</p><p>It is a narrowing tool.</p><p>That framing matters because AI-assisted development has made it easier to create more code and more documentation than we can comfortably keep in our heads. Stronger agents help, but they still need context. If the context is duplicated, stale, scattered, or too broad, the agent spends effort reconstructing the project before it can improve it.</p><p>I built<code>dryscope</code> to make that first step more explicit:</p><ol><li>scan the repo</li><li>find likely repeated implementation shapes</li><li>find repeated sections</li><li>map overlapping documentation intent</li><li>hand the shortlist to a human or agent</li></ol><p>The goal is not to remove judgment. The goal is to spend judgment where it has leverage.</p><p>That is the part of AI coding I am most interested in right now. Not just asking better prompts, and not pretending agents can hold an entire project in working memory. The useful layer is tooling that shapes the context before the agent starts.</p><p><code>dryscope</code> is my first public alpha in that direction.</p><h2 id="discussion">Discussion</h2><ul><li>GitHub:<a href="https://github.com/kvsankar/dryscope">kvsankar/dryscope</a></li><li>PyPI:<a href="https://pypi.org/project/dryscope/">dryscope</a></li></ul>
]]></content:encoded></item><item><title>Investigating the Starlink Photo: When Could WorldView-3 Take the Shot?</title><link>https://blog.sankara.net/posts/starlink-photo-investigation/</link><guid>https://blog.sankara.net/posts/starlink-photo-investigation/</guid><pubDate>Fri, 26 Dec 2025 00:00:00 UTC</pubDate><description>&lt;![CDATA[<p>On December 17, 2025, Starlink satellite 35956 experienced an anomaly. Within a day, SpaceX had partnered with Vantor to photograph the tumbling satellite from orbit using WorldView-3. The image, taken from 241 km away, showed the satellite was &ldquo;largely intact.&rdquo;</p><p>A comment on LinkedIn caught my attention:<em>How quickly could they take this photo?</em></p><p>I decided to find out.</p><h2 id="the-event">The Event</h2><p>The<a href="https://x.com/Starlink/status/2001691802911289712">official Starlink announcement</a> reported that satellite 35956 had lost communications and was venting propellant. SpaceX would deorbit it.<a href="https://gizmodo.com/a-starlink-satellite-is-tumbling-toward-earth-after-a-strange-anomaly-in-orbit-2000701874">LeoLabs detected &ldquo;tens of objects&rdquo;</a> in the vicinity, though SpaceX described it as a &ldquo;small number&rdquo; of trackable debris.</p><p>The next day,<a href="https://x.com/michaelnicollsx/status/2002419447521562638">Michael Nicolls</a>, VP of Starlink Engineering, shared an image:</p><blockquote class="twitter-tweet"><p lang="en" dir="ltr">Imagery collected by Vantor’s WorldView-3 satellite about 1 day after the anomaly shows that<a href="https://twitter.com/Starlink?ref_src=twsrc%5Etfw">@starlink</a> Satellite 35956 is largely intact.  The 12-cm resolution image was collected over Alaska from 241 km away.  We appreciate the rapid response by<a href="https://twitter.com/vantortech?ref_src=twsrc%5Etfw">@vantortech</a> to provide this…<a href="https://t.co/8OcTZsk5Gx">https://t.co/8OcTZsk5Gx</a><a href="https://t.co/1PafjFwuRP">pic.twitter.com/1PafjFwuRP</a></p>&mdash; Michael Nicolls (@michaelnicollsx)<a href="https://twitter.com/michaelnicollsx/status/2002419447521562638?ref_src=twsrc%5Etfw">December 20, 2025</a></blockquote><script async= src="https://platform.twitter.com/widgets.js" charset="utf-8"/><blockquote><p>&ldquo;Imagery collected by Vantor&rsquo;s WorldView-3 satellite about 1 day after the anomaly shows that Starlink Satellite 35956 is largely intact. The 12-cm resolution image was collected over Alaska from 241 km away.&rdquo;</p></blockquote><p><a href="https://www.linkedin.com/posts/vantortech_we-partnered-with-spacex-to-rapidly-image-activity-7408186335267540992-68ML">Vantor&rsquo;s LinkedIn post</a> confirmed the rapid partnership.</p><p>This raised a natural question: What determines when two satellites can get close enough for imaging?</p><h2 id="building-sattosat">Building SatToSat</h2><p>I built SatToSat (<a href="https://github.com/kvsankar/sattosat">repo</a>,<a href="https://kvsankar.github.io/sattosat/">live</a>), a tool to explore satellite conjunctions. Given any two satellites, it finds their close approaches over a time window using public Two-Line Element (TLE) data and SGP4 propagation.</p><p>This was a<a href="https://simonwillison.net/2025/Oct/7/vibe-engineering/">&ldquo;vibe engineered&rdquo;</a> project - Simon Willison&rsquo;s term for responsible AI-assisted development, as distinct from Andrej Karpathy&rsquo;s<a href="https://x.com/karpathy/status/1886192184808149383">&ldquo;vibe coding&rdquo;</a> where you &ldquo;forget that the code even exists.&rdquo; I built it with<a href="https://claude.ai/code">Claude Code</a> and<a href="https://openai.com/index/openai-codex/">OpenAI Codex</a>. These AI coding tools have matured significantly over the past few months, and what might have taken weeks of wrestling with Three.js and orbital mechanics libraries came together in days. The iteration cycle of &ldquo;describe what I want → review generated code and app → refine&rdquo; has become remarkably productive.</p><p><a href="https://kvsankar.github.io/sattosat/"><img alt="SatToSat main view showing WorldView-3 and Starlink orbits" loading="lazy" src="/images/starlink-photo/main-view.png"/></p><p>The repository has two parts:</p><p><strong>1.<a href="https://kvsankar.github.io/sattosat/">Web UI</a></strong> - An interactive 3D visualization for exploring orbits and conjunctions manually. Select any two satellites from the catalog, see their orbital paths, and browse close approaches on a timeline.</p><p><strong>2.<a href="https://github.com/kvsankar/sattosat/blob/master/USAGE_SCRIPTS.md">Analysis Scripts</a></strong> - Python and TypeScript scripts for deeper investigation:</p><ul><li><em><a href="https://github.com/kvsankar/sattosat/blob/master/USAGE_SCRIPTS.md#find-conjunctions-using-a-profile">Conjunction analysis</a></em>: I implemented the conjunction algorithm in both TypeScript (for the web app) and Python (for verification). Comparing outputs confirmed they match within 27 meters.</li><li><em><a href="https://github.com/kvsankar/sattosat/blob/master/USAGE_SCRIPTS.md#analyze-envelope-periods">Envelope period analysis</a></em>: Scripts to analyze the periodic &ldquo;envelope&rdquo; pattern of close approaches between satellite pairs (more on this below).</li></ul><p>The core conjunction algorithm:</p><ol><li>Load TLEs for both satellites</li><li>Propagate positions at 30-second intervals over ±3 days</li><li>Find local minima in the distance function</li><li>Refine to 100ms precision using ternary search</li></ol><h2 id="the-investigation">The Investigation</h2><p>I attempted to reproduce the reported 241 km approach over Alaska using three different approaches. None succeeded.</p><h3 id="goal-1-find-all-close-approaches-on-dec-17-19">Goal 1: Find all close approaches on Dec 17-19</h3><p>Using public TLE data, I searched for all conjunctions &lt; 1000 km between WorldView-3 and Starlink-35956:</p><table><thead><tr><th>#</th><th>Time (UTC)</th><th>Distance</th><th>Location</th></tr></thead><tbody><tr><td>1</td><td>Dec 17 12:19</td><td>204 km</td><td>Atlantic Ocean (53°N, 17°W)</td></tr><tr><td>2</td><td>Dec 19 01:30</td><td>350 km</td><td><a href="https://www.google.com/maps/@54,146,5z">Sea of Okhotsk</a> (55°N, 146°E)</td></tr><tr><td>3</td><td>Dec 18 23:55</td><td>981 km</td><td>Pacific (47°N, 167°E)</td></tr></tbody></table><p><strong>Result:</strong> The closest approach was<strong>204 km on Dec 17</strong> - over the Atlantic, not Alaska. The Dec 19 01:30 UTC conjunction (350 km) is December 18 evening in US time zones, but it&rsquo;s over the Sea of Okhotsk, not Alaska.</p><h3 id="goal-2-find-approaches-over-alaska">Goal 2: Find approaches over Alaska</h3><p>I filtered specifically for times when WorldView-3 was over Alaska (including the Aleutian Islands):</p><table><thead><tr><th>Time (UTC)</th><th>Distance</th><th>Location</th></tr></thead><tbody><tr><td>Dec 18 23:54</td><td>1,157 km</td><td>Western Aleutians (50°N, 168°E)</td></tr></tbody></table><p><strong>Result:</strong> The closest approach while over Alaska was<strong>1,157 km</strong> - nowhere near 241 km.</p><h3 id="goal-3-test-with-post-anomaly-tle">Goal 3: Test with post-anomaly TLE</h3><p>The TLEs from Dec 18 still showed normal orbital parameters. The orbital decay only appeared in a TLE generated on Dec 19 22:47 UTC. I tested by back-propagating this post-anomaly TLE:</p><table><thead><tr><th>TLE Used</th><th>Dec 18 23:55 Distance</th><th>Best Approach</th></tr></thead><tbody><tr><td>Normal TLEs</td><td>981 km</td><td>204 km (Dec 17)</td></tr><tr><td>Post-anomaly TLE</td><td>650 km</td><td>190 km (Dec 19 00:42)</td></tr><tr><td><strong>Reported</strong></td><td><strong>241 km</strong></td><td><strong>Alaska</strong></td></tr></tbody></table><p><strong>Result:</strong> The post-anomaly TLE improves the Dec 18 distance (650 km vs 981 km), but still doesn&rsquo;t reproduce 241 km over Alaska.</p><h3 id="summary">Summary</h3><table><thead><tr><th>What Was Reported</th><th>What I Found</th></tr></thead><tbody><tr><td>241 km</td><td>204 km (Dec 17) or 350 km (Dec 18 US time)</td></tr><tr><td>Dec 18</td><td>Dec 17 12:19 UTC or Dec 19 01:30 UTC</td></tr><tr><td>Over Alaska</td><td>Atlantic Ocean or Sea of Okhotsk</td></tr></tbody></table><p>None of the three approaches could reproduce the reported geometry.</p><h2 id="whats-really-going-on">What&rsquo;s Really Going On</h2><p>Starlink orbital data comes from multiple sources:<a href="https://docs.space-safety.starlink.com/docs/tutorial-basics/trajectories/">SpaceX publishes ephemerides</a> to their space-safety portal (updated roughly every 30 minutes) and to Space-Track.org. The<a href="https://www.space-track.org/documentation">18th Space Defense Squadron</a> also tracks satellites using the Space Surveillance Network.<a href="https://celestrak.org/NORAD/elements/supplemental/">Celestrak&rsquo;s supplemental GP data</a> is derived from SpaceX&rsquo;s public ephemerides.</p><p>Two possibilities could explain the discrepancy:</p><ol><li><p><strong>Different ephemerides</strong>: SpaceX had real-time tracking of their satellite&rsquo;s actual position—not a smoothed orbital fit from hours ago. The anomaly (tank venting, tumbling) changed the orbit in ways that public TLEs never captured. Even public sources with update cadences measured in minutes to hours may not capture a satellite&rsquo;s true trajectory during rapid orbital changes. The 241 km approach over Alaska may have existed only in the satellite&rsquo;s true post-anomaly orbit—one that never appeared in any public data.</p></li><li><p><strong>A unit transcription error</strong>: What if the reported distance was 241<em>miles</em>, not kilometers? 241 miles converts to 388 km—remarkably close to our 350 km and 383 km approaches on Dec 19. The image could have been captured slightly before or after closest approach. Unit confusion between miles and kilometers has caused problems before (see:<a href="https://en.wikipedia.org/wiki/Mars_Climate_Orbiter">Mars Climate Orbiter</a>).</p></li></ol><h2 id="understanding-the-beat-period">Understanding the &ldquo;Beat Period&rdquo;</h2><p>Back to the original question:<em>How quickly could Vantor take this photo?</em></p><p>Finding close approaches was straightforward. But while building SatToSat&rsquo;s distance graph, I noticed something interesting—a clear scalloped pattern emerging in the data. The closest approaches weren&rsquo;t random; they followed a rhythm.</p><p><img alt="Distance envelope showing the scallop pattern over 6 days" loading="lazy" src="/images/starlink-photo/envelope-wv3-starlink-healthy.png"/><p>This graph shows the distance between WorldView-3 and a healthy Starlink satellite (Starlink-32153, NORAD 60330) over 6 days. The pattern is unmistakable:</p><ul><li>Close approaches occur roughly every<strong>47 minutes</strong> (half the orbital period)</li><li>But the<strong>closest</strong> approaches repeat every<strong>~51 hours</strong></li></ul><p>This &ldquo;envelope period&rdquo; - the time between the deepest dips - is what matters for imaging. You might get 8 close approaches within 6 hours, but only one of those will be close<em>enough</em>.</p><h3 id="the-physics">The Physics</h3><p>The envelope period follows the<strong>synodic period formula</strong>:</p>
$$T_{envelope} \approx \frac{T_a \times T_b}{|T_a - T_b|}$$<p>Where $T_a$ and $T_b$ are the orbital periods. For WorldView-3 (~97 min) and a healthy Starlink (~94 min), this gives ~51 hours.</p><p>Satellites in similar orbits have long envelope periods - the tiny speed difference means it takes days for one to &ldquo;lap&rdquo; the other. Different inclinations or altitudes create shorter periods.</p><table><thead><tr><th>Pair Type</th><th>Envelope Period</th><th>Why</th></tr></thead><tbody><tr><td>Sun-sync vs LEO (WV3-Starlink)</td><td>~51 hrs</td><td>45° inclination difference</td></tr><tr><td>Same constellation</td><td>Never close</td><td>Identical orbital planes</td></tr><tr><td>ISS vs NOAA-20</td><td>~18 hrs</td><td>47° inclination difference</td></tr></tbody></table><p><img alt="ISS vs NOAA-20 envelope - much shorter period due to inclination difference" loading="lazy" src="/images/starlink-photo/envelope-iss-noaa.png"/><h3 id="an-interesting-detail">An Interesting Detail</h3><p>On December 17 around 17:32 UTC, WorldView-3 performed an orbital adjustment - raising its altitude by 2.7 km and reducing eccentricity by 67% (a circularization burn). This<em>decreased</em> the beat period with Starlink by about 4 hours.</p><p>Was this routine station-keeping, or something else? Without Maxar&rsquo;s operational logs, I can&rsquo;t say.</p><h2 id="what-this-means">What This Means</h2><p>For the Starlink-35956 imaging:</p><ol><li><p><strong>The ~1-day timeline was achievable.</strong> With a ~42-hour envelope period (shorter due to the anomalous satellite&rsquo;s lower altitude), a close approach would occur within about 1 day 18 hours of any given moment—well within the reported timeline.</p></li><li><p><strong>Public TLE data has limits.</strong> During satellite anomalies, TLEs lag reality by hours to days. The 110 km discrepancy isn&rsquo;t a calculation error - it&rsquo;s a data accuracy problem.</p></li><li><p><strong>Internal tracking is essential.</strong> SpaceX knew the actual orbit; I was working with yesterday&rsquo;s data.</p></li></ol><h2 id="sattosat-the-tool">SatToSat: The Tool</h2><p>Beyond this investigation, SatToSat is useful for exploring satellite conjunctions generally:</p><ul><li><strong><a href="https://github.com/kvsankar/sattosat/blob/master/USAGE_UI.md#satellite-selection">Select any two satellites</a></strong> from the catalog (~14,000 tracked objects)</li><li><strong><a href="https://github.com/kvsankar/sattosat/blob/master/USAGE_UI.md#close-approaches-panel">Visualize close approaches</a></strong> on a 3D globe with orbital paths</li><li><strong><a href="https://github.com/kvsankar/sattosat/blob/master/USAGE_UI.md#fullscreen-distance-graph">Analyze distance patterns</a></strong> with zoomable graphs</li><li><strong><a href="https://github.com/kvsankar/sattosat/blob/master/USAGE_UI.md#ab-relative-view">View relative geometry</a></strong> - what Satellite A &ldquo;sees&rdquo; when looking at Satellite B</li></ul><p><img alt="Orbital parameters panel" loading="lazy" src="/images/starlink-photo/orbital-parameters.png"><em>Orbital parameters showing semi-major axis, eccentricity, inclination, and period for both satellites.</em></p><p><img alt="A→B relative view" loading="lazy" src="/images/starlink-photo/ab-view-panel.png"><em>The A→B view shows what WorldView-3 &ldquo;sees&rdquo; looking at Starlink—useful for understanding imaging geometry.</em></p><p>It&rsquo;s educational for understanding orbital mechanics: why some satellites get close frequently, why others never do, and how inclination, altitude, and RAAN affect conjunction patterns.</p><p><a href="https://kvsankar.github.io/sattosat/">Try SatToSat</a> |<a href="https://github.com/kvsankar/sattosat">Source Code</a></p><h2 id="conclusion">Conclusion</h2><p>What started as a simple question—<em>how quickly could they take this photo?</em>—led me down an unexpected path.</p><p>I tried three approaches to reproduce the reported 241 km distance over Alaska. None succeeded. The closest I found was 204 km over the Atlantic, or 350 km near Kamchatka. Whether this gap reflects different ephemerides, a miles-vs-kilometers transcription error, or something else entirely, I can&rsquo;t say for certain.</p><p>But the investigation was worth it. Building<a href="https://kvsankar.github.io/sattosat/">SatToSat</a> taught me how satellite conjunctions actually work—the rhythm of close approaches, the physics of envelope periods, why some satellite pairs meet frequently while others never do. The scalloped patterns in the distance graphs aren&rsquo;t just pretty; they encode real orbital mechanics.</p><p>Sometimes the most interesting answer to a question is discovering why you can&rsquo;t answer it.</p><h2 id="discussion">Discussion</h2><ul><li><a href="https://x.com/kvsankar/status/2004856705134592508">Twitter/X</a></li><li><a href="https://www.linkedin.com/posts/kvsankar_we-partnered-with-spacex-to-rapidly-image-activity-7410619632199467008-kvEd">LinkedIn</a></li><li><a href="https://www.reddit.com/r/SpaceXLounge/comments/1pwsvdm/investigating_the_vantorstarlink_photo/">Reddit r/SpaceXLounge</a></li></ul><hr><p><em>The<a href="https://github.com/kvsankar/sattosat/blob/master/python/investigation/README.md">investigation details</a>,<a href="https://github.com/kvsankar/sattosat/blob/master/USAGE_SCRIPTS.md#starlink-35956-investigation">analysis scripts</a>, and<a href="https://github.com/kvsankar/sattosat/blob/master/USAGE_SCRIPTS.md#analyze-envelope-periods">envelope analysis</a> are available in the<a href="https://github.com/kvsankar/sattosat">SatToSat repository</a>.</em></p>
]]></description><content:encoded>&lt;![CDATA[<p>On December 17, 2025, Starlink satellite 35956 experienced an anomaly. Within a day, SpaceX had partnered with Vantor to photograph the tumbling satellite from orbit using WorldView-3. The image, taken from 241 km away, showed the satellite was &ldquo;largely intact.&rdquo;</p><p>A comment on LinkedIn caught my attention:<em>How quickly could they take this photo?</em></p><p>I decided to find out.</p><h2 id="the-event">The Event</h2><p>The<a href="https://x.com/Starlink/status/2001691802911289712">official Starlink announcement</a> reported that satellite 35956 had lost communications and was venting propellant. SpaceX would deorbit it.<a href="https://gizmodo.com/a-starlink-satellite-is-tumbling-toward-earth-after-a-strange-anomaly-in-orbit-2000701874">LeoLabs detected &ldquo;tens of objects&rdquo;</a> in the vicinity, though SpaceX described it as a &ldquo;small number&rdquo; of trackable debris.</p><p>The next day,<a href="https://x.com/michaelnicollsx/status/2002419447521562638">Michael Nicolls</a>, VP of Starlink Engineering, shared an image:</p><blockquote class="twitter-tweet"><p lang="en" dir="ltr">Imagery collected by Vantor’s WorldView-3 satellite about 1 day after the anomaly shows that<a href="https://twitter.com/Starlink?ref_src=twsrc%5Etfw">@starlink</a> Satellite 35956 is largely intact.  The 12-cm resolution image was collected over Alaska from 241 km away.  We appreciate the rapid response by<a href="https://twitter.com/vantortech?ref_src=twsrc%5Etfw">@vantortech</a> to provide this…<a href="https://t.co/8OcTZsk5Gx">https://t.co/8OcTZsk5Gx</a><a href="https://t.co/1PafjFwuRP">pic.twitter.com/1PafjFwuRP</a></p>&mdash; Michael Nicolls (@michaelnicollsx)<a href="https://twitter.com/michaelnicollsx/status/2002419447521562638?ref_src=twsrc%5Etfw">December 20, 2025</a></blockquote><script async= src="https://platform.twitter.com/widgets.js" charset="utf-8"/><blockquote><p>&ldquo;Imagery collected by Vantor&rsquo;s WorldView-3 satellite about 1 day after the anomaly shows that Starlink Satellite 35956 is largely intact. The 12-cm resolution image was collected over Alaska from 241 km away.&rdquo;</p></blockquote><p><a href="https://www.linkedin.com/posts/vantortech_we-partnered-with-spacex-to-rapidly-image-activity-7408186335267540992-68ML">Vantor&rsquo;s LinkedIn post</a> confirmed the rapid partnership.</p><p>This raised a natural question: What determines when two satellites can get close enough for imaging?</p><h2 id="building-sattosat">Building SatToSat</h2><p>I built SatToSat (<a href="https://github.com/kvsankar/sattosat">repo</a>,<a href="https://kvsankar.github.io/sattosat/">live</a>), a tool to explore satellite conjunctions. Given any two satellites, it finds their close approaches over a time window using public Two-Line Element (TLE) data and SGP4 propagation.</p><p>This was a<a href="https://simonwillison.net/2025/Oct/7/vibe-engineering/">&ldquo;vibe engineered&rdquo;</a> project - Simon Willison&rsquo;s term for responsible AI-assisted development, as distinct from Andrej Karpathy&rsquo;s<a href="https://x.com/karpathy/status/1886192184808149383">&ldquo;vibe coding&rdquo;</a> where you &ldquo;forget that the code even exists.&rdquo; I built it with<a href="https://claude.ai/code">Claude Code</a> and<a href="https://openai.com/index/openai-codex/">OpenAI Codex</a>. These AI coding tools have matured significantly over the past few months, and what might have taken weeks of wrestling with Three.js and orbital mechanics libraries came together in days. The iteration cycle of &ldquo;describe what I want → review generated code and app → refine&rdquo; has become remarkably productive.</p><p><a href="https://kvsankar.github.io/sattosat/"><img alt="SatToSat main view showing WorldView-3 and Starlink orbits" loading="lazy" src="/images/starlink-photo/main-view.png"/></p><p>The repository has two parts:</p><p><strong>1.<a href="https://kvsankar.github.io/sattosat/">Web UI</a></strong> - An interactive 3D visualization for exploring orbits and conjunctions manually. Select any two satellites from the catalog, see their orbital paths, and browse close approaches on a timeline.</p><p><strong>2.<a href="https://github.com/kvsankar/sattosat/blob/master/USAGE_SCRIPTS.md">Analysis Scripts</a></strong> - Python and TypeScript scripts for deeper investigation:</p><ul><li><em><a href="https://github.com/kvsankar/sattosat/blob/master/USAGE_SCRIPTS.md#find-conjunctions-using-a-profile">Conjunction analysis</a></em>: I implemented the conjunction algorithm in both TypeScript (for the web app) and Python (for verification). Comparing outputs confirmed they match within 27 meters.</li><li><em><a href="https://github.com/kvsankar/sattosat/blob/master/USAGE_SCRIPTS.md#analyze-envelope-periods">Envelope period analysis</a></em>: Scripts to analyze the periodic &ldquo;envelope&rdquo; pattern of close approaches between satellite pairs (more on this below).</li></ul><p>The core conjunction algorithm:</p><ol><li>Load TLEs for both satellites</li><li>Propagate positions at 30-second intervals over ±3 days</li><li>Find local minima in the distance function</li><li>Refine to 100ms precision using ternary search</li></ol><h2 id="the-investigation">The Investigation</h2><p>I attempted to reproduce the reported 241 km approach over Alaska using three different approaches. None succeeded.</p><h3 id="goal-1-find-all-close-approaches-on-dec-17-19">Goal 1: Find all close approaches on Dec 17-19</h3><p>Using public TLE data, I searched for all conjunctions &lt; 1000 km between WorldView-3 and Starlink-35956:</p><table><thead><tr><th>#</th><th>Time (UTC)</th><th>Distance</th><th>Location</th></tr></thead><tbody><tr><td>1</td><td>Dec 17 12:19</td><td>204 km</td><td>Atlantic Ocean (53°N, 17°W)</td></tr><tr><td>2</td><td>Dec 19 01:30</td><td>350 km</td><td><a href="https://www.google.com/maps/@54,146,5z">Sea of Okhotsk</a> (55°N, 146°E)</td></tr><tr><td>3</td><td>Dec 18 23:55</td><td>981 km</td><td>Pacific (47°N, 167°E)</td></tr></tbody></table><p><strong>Result:</strong> The closest approach was<strong>204 km on Dec 17</strong> - over the Atlantic, not Alaska. The Dec 19 01:30 UTC conjunction (350 km) is December 18 evening in US time zones, but it&rsquo;s over the Sea of Okhotsk, not Alaska.</p><h3 id="goal-2-find-approaches-over-alaska">Goal 2: Find approaches over Alaska</h3><p>I filtered specifically for times when WorldView-3 was over Alaska (including the Aleutian Islands):</p><table><thead><tr><th>Time (UTC)</th><th>Distance</th><th>Location</th></tr></thead><tbody><tr><td>Dec 18 23:54</td><td>1,157 km</td><td>Western Aleutians (50°N, 168°E)</td></tr></tbody></table><p><strong>Result:</strong> The closest approach while over Alaska was<strong>1,157 km</strong> - nowhere near 241 km.</p><h3 id="goal-3-test-with-post-anomaly-tle">Goal 3: Test with post-anomaly TLE</h3><p>The TLEs from Dec 18 still showed normal orbital parameters. The orbital decay only appeared in a TLE generated on Dec 19 22:47 UTC. I tested by back-propagating this post-anomaly TLE:</p><table><thead><tr><th>TLE Used</th><th>Dec 18 23:55 Distance</th><th>Best Approach</th></tr></thead><tbody><tr><td>Normal TLEs</td><td>981 km</td><td>204 km (Dec 17)</td></tr><tr><td>Post-anomaly TLE</td><td>650 km</td><td>190 km (Dec 19 00:42)</td></tr><tr><td><strong>Reported</strong></td><td><strong>241 km</strong></td><td><strong>Alaska</strong></td></tr></tbody></table><p><strong>Result:</strong> The post-anomaly TLE improves the Dec 18 distance (650 km vs 981 km), but still doesn&rsquo;t reproduce 241 km over Alaska.</p><h3 id="summary">Summary</h3><table><thead><tr><th>What Was Reported</th><th>What I Found</th></tr></thead><tbody><tr><td>241 km</td><td>204 km (Dec 17) or 350 km (Dec 18 US time)</td></tr><tr><td>Dec 18</td><td>Dec 17 12:19 UTC or Dec 19 01:30 UTC</td></tr><tr><td>Over Alaska</td><td>Atlantic Ocean or Sea of Okhotsk</td></tr></tbody></table><p>None of the three approaches could reproduce the reported geometry.</p><h2 id="whats-really-going-on">What&rsquo;s Really Going On</h2><p>Starlink orbital data comes from multiple sources:<a href="https://docs.space-safety.starlink.com/docs/tutorial-basics/trajectories/">SpaceX publishes ephemerides</a> to their space-safety portal (updated roughly every 30 minutes) and to Space-Track.org. The<a href="https://www.space-track.org/documentation">18th Space Defense Squadron</a> also tracks satellites using the Space Surveillance Network.<a href="https://celestrak.org/NORAD/elements/supplemental/">Celestrak&rsquo;s supplemental GP data</a> is derived from SpaceX&rsquo;s public ephemerides.</p><p>Two possibilities could explain the discrepancy:</p><ol><li><p><strong>Different ephemerides</strong>: SpaceX had real-time tracking of their satellite&rsquo;s actual position—not a smoothed orbital fit from hours ago. The anomaly (tank venting, tumbling) changed the orbit in ways that public TLEs never captured. Even public sources with update cadences measured in minutes to hours may not capture a satellite&rsquo;s true trajectory during rapid orbital changes. The 241 km approach over Alaska may have existed only in the satellite&rsquo;s true post-anomaly orbit—one that never appeared in any public data.</p></li><li><p><strong>A unit transcription error</strong>: What if the reported distance was 241<em>miles</em>, not kilometers? 241 miles converts to 388 km—remarkably close to our 350 km and 383 km approaches on Dec 19. The image could have been captured slightly before or after closest approach. Unit confusion between miles and kilometers has caused problems before (see:<a href="https://en.wikipedia.org/wiki/Mars_Climate_Orbiter">Mars Climate Orbiter</a>).</p></li></ol><h2 id="understanding-the-beat-period">Understanding the &ldquo;Beat Period&rdquo;</h2><p>Back to the original question:<em>How quickly could Vantor take this photo?</em></p><p>Finding close approaches was straightforward. But while building SatToSat&rsquo;s distance graph, I noticed something interesting—a clear scalloped pattern emerging in the data. The closest approaches weren&rsquo;t random; they followed a rhythm.</p><p><img alt="Distance envelope showing the scallop pattern over 6 days" loading="lazy" src="/images/starlink-photo/envelope-wv3-starlink-healthy.png"/><p>This graph shows the distance between WorldView-3 and a healthy Starlink satellite (Starlink-32153, NORAD 60330) over 6 days. The pattern is unmistakable:</p><ul><li>Close approaches occur roughly every<strong>47 minutes</strong> (half the orbital period)</li><li>But the<strong>closest</strong> approaches repeat every<strong>~51 hours</strong></li></ul><p>This &ldquo;envelope period&rdquo; - the time between the deepest dips - is what matters for imaging. You might get 8 close approaches within 6 hours, but only one of those will be close<em>enough</em>.</p><h3 id="the-physics">The Physics</h3><p>The envelope period follows the<strong>synodic period formula</strong>:</p>
$$T_{envelope} \approx \frac{T_a \times T_b}{|T_a - T_b|}$$<p>Where $T_a$ and $T_b$ are the orbital periods. For WorldView-3 (~97 min) and a healthy Starlink (~94 min), this gives ~51 hours.</p><p>Satellites in similar orbits have long envelope periods - the tiny speed difference means it takes days for one to &ldquo;lap&rdquo; the other. Different inclinations or altitudes create shorter periods.</p><table><thead><tr><th>Pair Type</th><th>Envelope Period</th><th>Why</th></tr></thead><tbody><tr><td>Sun-sync vs LEO (WV3-Starlink)</td><td>~51 hrs</td><td>45° inclination difference</td></tr><tr><td>Same constellation</td><td>Never close</td><td>Identical orbital planes</td></tr><tr><td>ISS vs NOAA-20</td><td>~18 hrs</td><td>47° inclination difference</td></tr></tbody></table><p><img alt="ISS vs NOAA-20 envelope - much shorter period due to inclination difference" loading="lazy" src="/images/starlink-photo/envelope-iss-noaa.png"/><h3 id="an-interesting-detail">An Interesting Detail</h3><p>On December 17 around 17:32 UTC, WorldView-3 performed an orbital adjustment - raising its altitude by 2.7 km and reducing eccentricity by 67% (a circularization burn). This<em>decreased</em> the beat period with Starlink by about 4 hours.</p><p>Was this routine station-keeping, or something else? Without Maxar&rsquo;s operational logs, I can&rsquo;t say.</p><h2 id="what-this-means">What This Means</h2><p>For the Starlink-35956 imaging:</p><ol><li><p><strong>The ~1-day timeline was achievable.</strong> With a ~42-hour envelope period (shorter due to the anomalous satellite&rsquo;s lower altitude), a close approach would occur within about 1 day 18 hours of any given moment—well within the reported timeline.</p></li><li><p><strong>Public TLE data has limits.</strong> During satellite anomalies, TLEs lag reality by hours to days. The 110 km discrepancy isn&rsquo;t a calculation error - it&rsquo;s a data accuracy problem.</p></li><li><p><strong>Internal tracking is essential.</strong> SpaceX knew the actual orbit; I was working with yesterday&rsquo;s data.</p></li></ol><h2 id="sattosat-the-tool">SatToSat: The Tool</h2><p>Beyond this investigation, SatToSat is useful for exploring satellite conjunctions generally:</p><ul><li><strong><a href="https://github.com/kvsankar/sattosat/blob/master/USAGE_UI.md#satellite-selection">Select any two satellites</a></strong> from the catalog (~14,000 tracked objects)</li><li><strong><a href="https://github.com/kvsankar/sattosat/blob/master/USAGE_UI.md#close-approaches-panel">Visualize close approaches</a></strong> on a 3D globe with orbital paths</li><li><strong><a href="https://github.com/kvsankar/sattosat/blob/master/USAGE_UI.md#fullscreen-distance-graph">Analyze distance patterns</a></strong> with zoomable graphs</li><li><strong><a href="https://github.com/kvsankar/sattosat/blob/master/USAGE_UI.md#ab-relative-view">View relative geometry</a></strong> - what Satellite A &ldquo;sees&rdquo; when looking at Satellite B</li></ul><p><img alt="Orbital parameters panel" loading="lazy" src="/images/starlink-photo/orbital-parameters.png"><em>Orbital parameters showing semi-major axis, eccentricity, inclination, and period for both satellites.</em></p><p><img alt="A→B relative view" loading="lazy" src="/images/starlink-photo/ab-view-panel.png"><em>The A→B view shows what WorldView-3 &ldquo;sees&rdquo; looking at Starlink—useful for understanding imaging geometry.</em></p><p>It&rsquo;s educational for understanding orbital mechanics: why some satellites get close frequently, why others never do, and how inclination, altitude, and RAAN affect conjunction patterns.</p><p><a href="https://kvsankar.github.io/sattosat/">Try SatToSat</a> |<a href="https://github.com/kvsankar/sattosat">Source Code</a></p><h2 id="conclusion">Conclusion</h2><p>What started as a simple question—<em>how quickly could they take this photo?</em>—led me down an unexpected path.</p><p>I tried three approaches to reproduce the reported 241 km distance over Alaska. None succeeded. The closest I found was 204 km over the Atlantic, or 350 km near Kamchatka. Whether this gap reflects different ephemerides, a miles-vs-kilometers transcription error, or something else entirely, I can&rsquo;t say for certain.</p><p>But the investigation was worth it. Building<a href="https://kvsankar.github.io/sattosat/">SatToSat</a> taught me how satellite conjunctions actually work—the rhythm of close approaches, the physics of envelope periods, why some satellite pairs meet frequently while others never do. The scalloped patterns in the distance graphs aren&rsquo;t just pretty; they encode real orbital mechanics.</p><p>Sometimes the most interesting answer to a question is discovering why you can&rsquo;t answer it.</p><h2 id="discussion">Discussion</h2><ul><li><a href="https://x.com/kvsankar/status/2004856705134592508">Twitter/X</a></li><li><a href="https://www.linkedin.com/posts/kvsankar_we-partnered-with-spacex-to-rapidly-image-activity-7410619632199467008-kvEd">LinkedIn</a></li><li><a href="https://www.reddit.com/r/SpaceXLounge/comments/1pwsvdm/investigating_the_vantorstarlink_photo/">Reddit r/SpaceXLounge</a></li></ul><hr><p><em>The<a href="https://github.com/kvsankar/sattosat/blob/master/python/investigation/README.md">investigation details</a>,<a href="https://github.com/kvsankar/sattosat/blob/master/USAGE_SCRIPTS.md#starlink-35956-investigation">analysis scripts</a>, and<a href="https://github.com/kvsankar/sattosat/blob/master/USAGE_SCRIPTS.md#analyze-envelope-periods">envelope analysis</a> are available in the<a href="https://github.com/kvsankar/sattosat">SatToSat repository</a>.</em></p>
]]></content:encoded></item><item><title>Hello World</title><link>https://blog.sankara.net/posts/hello-world/</link><guid>https://blog.sankara.net/posts/hello-world/</guid><pubDate>Mon, 01 Dec 2025 00:00:00 UTC</pubDate><description>&lt;![CDATA[<h1 id="hello-world">Hello World</h1><p>Welcome to my blog! This is my first post, created using<a href="https://gohugo.io/">Hugo</a> and hosted on GitHub Pages.</p><h2 id="why-this-setup">Why This Setup?</h2><p>I wanted a blogging platform that:</p><ul><li>Lets me write in<strong>Markdown</strong></li><li>Stores content in<strong>Git</strong> for version control</li><li>Is<strong>free</strong> for readers</li><li>Gives me<strong>full ownership</strong> of my content</li></ul><p>GitHub Pages + Hugo checks all these boxes.</p><h2 id="adding-images">Adding Images</h2><p>Images are stored in the repo and referenced with public URLs:</p><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl">![<span class="nt">Description</span>](<span class="na">https://raw.githubusercontent.com/kvsankar/blog/main/static/images/example.jpg</span>)</span></span></code></pre></div><p>This also makes cross-posting to Medium seamless!</p><h2 id="whats-next">What&rsquo;s Next</h2><p>I&rsquo;ll be writing about topics that interest me. Stay tuned!</p><hr><p><em>This blog is built with Hugo and the PaperMod theme.</em></p>
]]></description><content:encoded>&lt;![CDATA[<h1 id="hello-world">Hello World</h1><p>Welcome to my blog! This is my first post, created using<a href="https://gohugo.io/">Hugo</a> and hosted on GitHub Pages.</p><h2 id="why-this-setup">Why This Setup?</h2><p>I wanted a blogging platform that:</p><ul><li>Lets me write in<strong>Markdown</strong></li><li>Stores content in<strong>Git</strong> for version control</li><li>Is<strong>free</strong> for readers</li><li>Gives me<strong>full ownership</strong> of my content</li></ul><p>GitHub Pages + Hugo checks all these boxes.</p><h2 id="adding-images">Adding Images</h2><p>Images are stored in the repo and referenced with public URLs:</p><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl">![<span class="nt">Description</span>](<span class="na">https://raw.githubusercontent.com/kvsankar/blog/main/static/images/example.jpg</span>)</span></span></code></pre></div><p>This also makes cross-posting to Medium seamless!</p><h2 id="whats-next">What&rsquo;s Next</h2><p>I&rsquo;ll be writing about topics that interest me. Stay tuned!</p><hr><p><em>This blog is built with Hugo and the PaperMod theme.</em></p>
]]></content:encoded></item><item><title>Building claude-history: A Tool for Cross-Platform Claude Code Sessions</title><link>https://blog.sankara.net/posts/building-claude-history/</link><guid>https://blog.sankara.net/posts/building-claude-history/</guid><pubDate>Mon, 01 Dec 2025 00:00:00 UTC</pubDate><description>&lt;![CDATA[<p>I&rsquo;ve been using Claude Code extensively for the past few months. One day, while working on a startup consulting project, I needed to reference an analysis Claude had helped me with. I had discussed alternatives, evaluated tradeoffs, and settled on a design approach for an Architecture Decision Record (ADR). But I hadn&rsquo;t asked Claude to generate the usual markdown report.</p><p>The analysis was gone from my immediate context. Somewhere in the conversation history, buried across multiple platforms where I work.</p><p>This is the story of how I built<code>claude-history</code> to solve this problem.</p><h2 id="the-inspiration">The Inspiration</h2><p>Simon Willison&rsquo;s<a href="https://simonwillison.net/tags/claude/">writing on Claude</a> has been consistently educational. His<a href="https://observablehq.com/@simonw/convert-claude-json-to-markdown">Observable notebook for converting Claude JSON to Markdown</a> and discussions about extracting learnings from conversation history to improve future interactions caught my attention.</p><p>The idea is simple: your conversations with Claude contain valuable context - design decisions, debugging sessions, research threads. If you can extract and reference them, you can work more effectively.</p><p>Tools like<a href="https://github.com/ZeroSumQuant/claude-conversation-extractor">claude-conversation-extractor</a> exist, but they didn&rsquo;t fit my workflow. I needed something that could:</p><ul><li>Filter by workspace/project, not just session IDs</li><li>Work across Windows, WSL, and Linux VMs (where I run the same projects)</li><li>Help me generate insights and track usage</li><li>Link related workspaces that had been renamed or moved</li></ul><h2 id="first-prevent-data-loss">First: Prevent Data Loss</h2><p>Before anything else, if you&rsquo;re using Claude Code, check your<code>~/.claude/settings.json</code>. By default, Claude Code<a href="https://github.com/anthropics/claude-code/issues/4172">deletes conversation history after 30 days</a>. I learned this the hard way.</p><p>Add this to preserve your history:</p><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span></span></span><span class="line"><span class="cl"><span class="nt">"cleanupPeriodDays"</span><span class="p">:</span><span class="mi">99999</span></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>No point building a history tool if your history keeps disappearing.</p><p>One more housekeeping item: if your Claude data lives on a different drive (common when running WSL or backing up to an external disk), set<code>CLAUDE_PROJECTS_DIR</code> before running the CLI so it knows where to look:</p><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">export</span><span class="nv">CLAUDE_PROJECTS_DIR</span><span class="o">=</span>/mnt/windows/Users/me/.claude/projects</span></span><span class="line"><span class="cl">claude-history lsw</span></span></code></pre></div><h2 id="my-cross-platform-reality">My Cross-Platform Reality</h2><p>I work on astronomy-related projects that need to run on Windows, WSL, and Linux. My typical workflow: push code from one platform, pull and continue on another. The<code>claude-history</code> tool itself was developed this way.</p><p>This means my conversation history for a single logical project gets scattered across environments. When I needed to find that missing ADR analysis, I had to figure out: which platform was I on? When did that conversation happen?</p><h2 id="what-i-built">What I Built</h2><p><code>claude-history</code> is a single-file Python CLI with zero external dependencies. It reads the JSONL files Claude Code stores in<code>~/.claude/projects/</code> and exports them to readable Markdown.</p><details><summary>Full CLI help (-h)</summary><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gdscript3" data-lang="gdscript3"><span class="line"><span class="cl"><span class="o">$</span><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="o">--</span><span class="n">help</span></span></span><span class="line"><span class="cl"><span class="n">usage</span><span class="p">:</span><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="p">[</span><span class="o">-</span><span class="n">h</span><span class="p">]</span><span class="p">[</span><span class="o">--</span><span class="n">version</span><span class="p">]</span></span></span><span class="line"><span class="cl"><span class="p">{</span><span class="n">lsw</span><span class="p">,</span><span class="n">lss</span><span class="p">,</span><span class="n">lsh</span><span class="p">,</span><span class="k">export</span><span class="p">,</span><span class="n">alias</span><span class="p">,</span><span class="n">stats</span><span class="p">,</span><span class="n">reset</span><span class="p">,</span><span class="n">install</span><span class="p">}</span><span class="o">...</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="n">Browse</span><span class="ow">and</span><span class="k">export</span><span class="n">Claude</span><span class="n">Code</span><span class="n">conversation</span><span class="n">history</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="n">positional</span><span class="n">arguments</span><span class="p">:</span></span></span><span class="line"><span class="cl"><span class="p">{</span><span class="n">lsw</span><span class="p">,</span><span class="n">lss</span><span class="p">,</span><span class="n">lsh</span><span class="p">,</span><span class="k">export</span><span class="p">,</span><span class="n">alias</span><span class="p">,</span><span class="n">stats</span><span class="p">,</span><span class="n">reset</span><span class="p">,</span><span class="n">install</span><span class="p">}</span></span></span><span class="line"><span class="cl"><span class="n">Command</span><span class="n">to</span><span class="n">execute</span></span></span><span class="line"><span class="cl"><span class="n">lsw</span><span class="n">List</span><span class="n">workspaces</span></span></span><span class="line"><span class="cl"><span class="n">lss</span><span class="n">List</span><span class="n">sessions</span></span></span><span class="line"><span class="cl"><span class="n">lsh</span><span class="n">List</span><span class="n">homes</span><span class="ow">and</span><span class="n">manage</span><span class="n">SSH</span><span class="n">remotes</span></span></span><span class="line"><span class="cl"><span class="k">export</span><span class="n">Export</span><span class="n">to</span><span class="n">markdown</span></span></span><span class="line"><span class="cl"><span class="n">alias</span><span class="n">Manage</span><span class="n">workspace</span><span class="n">aliases</span></span></span><span class="line"><span class="cl"><span class="n">stats</span><span class="n">Show</span><span class="n">usage</span><span class="n">statistics</span><span class="ow">and</span><span class="n">metrics</span></span></span><span class="line"><span class="cl"><span class="n">reset</span><span class="n">Reset</span><span class="n">stored</span><span class="n">data</span><span class="p">(</span><span class="n">database</span><span class="p">,</span><span class="n">settings</span><span class="p">,</span><span class="n">aliases</span><span class="p">)</span></span></span><span class="line"><span class="cl"><span class="n">install</span><span class="n">Install</span><span class="n">CLI</span><span class="ow">and</span><span class="n">Claude</span><span class="n">skill</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="n">options</span><span class="p">:</span></span></span><span class="line"><span class="cl"><span class="o">-</span><span class="n">h</span><span class="p">,</span><span class="o">--</span><span class="n">help</span><span class="n">show</span><span class="n">this</span><span class="n">help</span><span class="n">message</span><span class="ow">and</span><span class="n">exit</span></span></span><span class="line"><span class="cl"><span class="o">--</span><span class="n">version</span><span class="n">show</span><span class="n">program</span><span class="s1">'s version number and exit</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="n">EXAMPLES</span><span class="p">:</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="n">List</span><span class="n">workspaces</span><span class="p">:</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="n">lsw</span><span class="c1"># all local workspaces</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="n">lsw</span><span class="n">myproject</span><span class="c1"># filter by pattern</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="n">lsw</span><span class="o">-</span><span class="n">r</span><span class="n">user</span><span class="err">@</span><span class="n">server</span><span class="c1"># remote workspaces</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="n">List</span><span class="n">sessions</span><span class="p">:</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="n">lss</span><span class="c1"># current workspace</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="n">lss</span><span class="n">myproject</span><span class="c1"># specific workspace</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="n">lss</span><span class="n">myproject</span><span class="o">-</span><span class="n">r</span><span class="n">user</span><span class="err">@</span><span class="n">server</span><span class="c1"># remote sessions</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="n">Export</span><span class="p">(</span><span class="n">unified</span><span class="n">interface</span><span class="n">with</span><span class="n">orthogonal</span><span class="n">flags</span><span class="p">):</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="k">export</span><span class="c1"># current workspace, local home</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="k">export</span><span class="o">--</span><span class="n">ah</span><span class="c1"># current workspace, all homes</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="k">export</span><span class="o">--</span><span class="n">aw</span><span class="c1"># all workspaces, local home</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="k">export</span><span class="o">--</span><span class="n">ah</span><span class="o">--</span><span class="n">aw</span><span class="c1"># all workspaces, all homes</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="k">export</span><span class="n">myproject</span><span class="c1"># specific workspace, local</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="k">export</span><span class="n">myproject</span><span class="o">--</span><span class="n">ah</span><span class="c1"># specific workspace, all homes</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="k">export</span><span class="n">file</span><span class="o">.</span><span class="n">jsonl</span><span class="c1"># export single file</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="k">export</span><span class="o">-</span><span class="n">o</span><span class="o">/</span><span class="n">tmp</span><span class="o">/</span><span class="n">backup</span><span class="c1"># current workspace, custom output</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="k">export</span><span class="n">myproject</span><span class="o">-</span><span class="n">o</span><span class="o">./</span><span class="n">out</span><span class="c1"># specific workspace, custom output</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="k">export</span><span class="o">-</span><span class="n">r</span><span class="n">user</span><span class="err">@</span><span class="n">server</span><span class="c1"># current workspace, specific remote</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="k">export</span><span class="o">--</span><span class="n">ah</span><span class="o">-</span><span class="n">r</span><span class="n">user</span><span class="err">@</span><span class="n">vm01</span><span class="c1"># current workspace, all homes + SSH</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="n">Date</span><span class="n">filtering</span><span class="p">:</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="n">lss</span><span class="n">myproject</span><span class="o">--</span><span class="n">since</span><span class="mi">2025</span><span class="o">-</span><span class="mi">11</span><span class="o">-</span><span class="mi">01</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="k">export</span><span class="n">myproject</span><span class="o">--</span><span class="n">since</span><span class="mi">2025</span><span class="o">-</span><span class="mi">11</span><span class="o">-</span><span class="mi">01</span><span class="o">--</span><span class="n">until</span><span class="mi">2025</span><span class="o">-</span><span class="mi">11</span><span class="o">-</span><span class="mi">30</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="n">Export</span><span class="n">options</span><span class="p">:</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="k">export</span><span class="n">myproject</span><span class="o">--</span><span class="n">minimal</span><span class="c1"># minimal mode</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="k">export</span><span class="n">myproject</span><span class="o">--</span><span class="n">split</span><span class="mi">500</span><span class="c1"># split long conversations</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="k">export</span><span class="n">myproject</span><span class="o">--</span><span class="n">flat</span><span class="c1"># flat structure (no subdirs)</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="n">WSL</span><span class="n">access</span><span class="p">(</span><span class="n">Windows</span><span class="p">):</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="n">lsh</span><span class="o">--</span><span class="n">wsl</span><span class="c1"># list WSL distributions</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="n">lsw</span><span class="o">--</span><span class="n">wsl</span><span class="c1"># list WSL workspaces</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="n">lsw</span><span class="o">--</span><span class="n">wsl</span><span class="n">Ubuntu</span><span class="c1"># list from specific distro</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="n">lss</span><span class="n">myproject</span><span class="o">--</span><span class="n">wsl</span><span class="c1"># list WSL sessions</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="k">export</span><span class="n">myproject</span><span class="o">--</span><span class="n">wsl</span><span class="c1"># export from WSL</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="n">Windows</span><span class="n">access</span><span class="p">(</span><span class="n">from</span><span class="n">WSL</span><span class="p">):</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="n">lsh</span><span class="o">--</span><span class="n">windows</span><span class="c1"># list Windows users with Claude</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="n">lsw</span><span class="o">--</span><span class="n">windows</span><span class="c1"># list Windows workspaces</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="n">lss</span><span class="n">myproject</span><span class="o">--</span><span class="n">windows</span><span class="c1"># list Windows sessions</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="k">export</span><span class="n">myproject</span><span class="o">--</span><span class="n">windows</span><span class="c1"># export from Windows</span></span></span></code></pre></div></details><p>The key insight was making home (where) and workspace (which) filtering orthogonal:</p><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Current workspace, local only</span></span></span><span class="line"><span class="cl">claude-history<span class="nb">export</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># Current workspace, all homes (local + WSL + Windows + SSH remotes)</span></span></span><span class="line"><span class="cl">claude-history<span class="nb">export</span> --ah</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># All workspaces, all homes</span></span></span><span class="line"><span class="cl">claude-history<span class="nb">export</span> --ah --aw</span></span></code></pre></div><h2 id="workspace-aliases-linking-scattered-projects">Workspace Aliases: Linking Scattered Projects</h2><p>During development, I kept renaming the project -<code>claude-sessions</code>,<code>claude-conversations</code>,<code>claude-history</code>. Each rename created a new workspace directory in Claude&rsquo;s storage. Across three platforms, I had fragments everywhere.</p><p>The alias feature lets me group them:</p><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Create an alias</span></span></span><span class="line"><span class="cl">claude-history<span class="nb">alias</span> create claude-history</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># Add workspaces by pattern from different homes</span></span></span><span class="line"><span class="cl">claude-history<span class="nb">alias</span> add claude-history claude-sessions</span></span><span class="line"><span class="cl">claude-history<span class="nb">alias</span> add claude-history claude-conversations</span></span><span class="line"><span class="cl">claude-history<span class="nb">alias</span> add claude-history --windows claude-history</span></span><span class="line"><span class="cl">claude-history<span class="nb">alias</span> add claude-history -r user@vm01 claude-history</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># Now I can query everything together</span></span></span><span class="line"><span class="cl">claude-history lss @claude-history</span></span></code></pre></div><p>Here&rsquo;s what that looks like across my local workspaces (current name + older rename) and a remote Linux VM—paths are redacted with<code>...</code>:</p><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">$ claude-history lss --ah @claude-history</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl">Using alias @claude-history (use --this for current workspace only)</span></span><span class="line"><span class="cl">HOME WORKSPACE FILE MESSAGES DATE</span></span><span class="line"><span class="cl">local /home/.../projects/claude-history 3b4191bc-055f-4752-9029-1c69e29f5d3a.jsonl 2612 2025-12-04</span></span><span class="line"><span class="cl">local /home/.../projects/claude-sessions be3d3632-e442-436e-987a-d427e1d7b08b.jsonl 2347 2025-11-22</span></span><span class="line"><span class="cl">remote:sankar@ubuntuvm01 /remote_ubuntuvm01_home/.../claude-history 895cefac-8e43-4dfe-8574-7b6636fdd428.jsonl 890 2025-11-30</span></span><span class="line"><span class="cl">...</span></span></code></pre></div><p><img alt="Multi-home session listing showing local and remote rows" loading="lazy" src="/images/claude-history/claude-history-lss.png"/><p>One logical project, multiple homes, unified view.</p><h2 id="use-cases-i-didnt-expect">Use Cases I Didn&rsquo;t Expect</h2><h3 id="generating-specifications-from-conversations">Generating Specifications from Conversations</h3><p>The best example of this is my<a href="https://github.com/kvsankar/chandrayaan-mission-design">Chandrayaan Mission Design</a> project. It started as an astronomy outreach tool—an interactive sandbox that middle-school students could use to explore mission design ideas around Chandrayaan-3. I iterated quickly with Claude, but never paused to write a traditional spec.</p><p>Later, when I needed documentation, I exported the relevant sessions with<code>claude-history</code> and turned that transcript into the formal specification that now lives in<a href="https://github.com/kvsankar/chandrayaan-mission-design/blob/master/docs/specs/spec.md"><code>docs/specs/spec.md</code></a>. The history captured the<em>problem space</em>—what students needed, what constraints mattered, the educational goals. The code, meanwhile, only reflected the<em>solution</em>.</p><p>Here&rsquo;s an excerpt from those exports that made it straight into the spec:</p><blockquote><h3 id="problem-statement">Problem Statement</h3><p>We need a pared-down mission-design sandbox that middle-school students can use during outreach sessions.</p><ul><li>Inputs: launch date, target orbit, payload mass (preset presets OK)</li><li>Outputs: simple delta-v budget, textual mission plan, SVG visualization</li><li>Constraints: runs in browser, no install, offline fallback (PDF handout)</li></ul></blockquote><p>That text came directly from the conversation history—no rewriting, no guessing.<code>claude-history</code> let me capture the intent at the moment it was articulated and crystallize it into a working specification later.</p><h3 id="time-tracking">Time Tracking</h3><p>I originally added<code>stats --time</code> for curiosity, but it quickly became a practical tool. While wrapping up a consulting engagement, I wanted to understand how much of my deliverable time was spent collaborating with Claude versus research or meetings. Running<code>claude-history stats @consulting-project --time</code> produced a defensible record of “hands on keyboard with Claude” hours that I could compare against my invoice and status reports.</p><p>Here’s a representative output (I keep snapshots like this in the project notes):</p><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">$ claude-history stats @claude-history --time</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl">============================================================</span></span><span class="line"><span class="cl">TIME TRACKING</span></span><span class="line"><span class="cl">============================================================</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl">Time</span></span><span class="line"><span class="cl"> Total work time: 42h 43m</span></span><span class="line"><span class="cl"> Work periods: 51</span></span><span class="line"><span class="cl"> Session files: 68</span></span><span class="line"><span class="cl"> Date range: 2025-11-20 to 2025-12-03</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl">Daily Breakdown</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl">Date Work Time Periods Messages Bar (time)</span></span><span class="line"><span class="cl">----------------------------------------------------------</span></span><span class="line"><span class="cl">2025-12-03 4h 57m 2 1949 ####################</span></span><span class="line"><span class="cl">2025-12-02 1h 26m 2 506 ####</span></span><span class="line"><span class="cl">2025-12-01 1h 3m 1 243 ###</span></span><span class="line"><span class="cl">2025-11-30 9h 28m 5 3217 ####################</span></span><span class="line"><span class="cl">2025-11-29 4h 33m 7 1580 ###############</span></span><span class="line"><span class="cl">2025-11-24 6m 1 82 #</span></span><span class="line"><span class="cl">2025-11-23 3h 28m 7 597 ###########</span></span><span class="line"><span class="cl">2025-11-22 5h 32m 7 1727 ################</span></span><span class="line"><span class="cl">2025-11-21 6h 50m 10 1327 ####################</span></span><span class="line"><span class="cl">2025-11-20 5h 15m 9 1176 ################</span></span><span class="line"><span class="cl">----------------------------------------------------------</span></span><span class="line"><span class="cl">TOTAL 42h 43m 51 12404 ####################</span></span></code></pre></div><p><img alt="Time tracking output with ASCII bars for each day" loading="lazy" src="/images/claude-history/claude-stats-time.png"/><p>This helped with my consulting project - I had a rough sense of time spent, but Claude gave me realistic figures. Note that this measures<em>active interaction time</em>. Requirements gathering, meetings, and thinking happen outside the tool.</p><h2 id="a-design-decision-stdlib-only">A Design Decision: Stdlib Only</h2><p>Early in development, I explored adding CLI interactivity - things like interactive menus, colored output, progress bars. Libraries like<code>rich</code> or<code>click</code> could have helped, but they&rsquo;d introduce external dependencies.</p><p>I decided against it. The tool stays Python standard library only - a single file you can copy anywhere and run. No<code>pip install</code>, no virtual environments, no dependency conflicts.</p><p>This constraint shaped the design in good ways. The output is plain text that pipes well to other tools. The code stays focused. And when I need to run it on a new machine, I just copy the file.</p><h2 id="the-meta-moments">The Meta Moments</h2><p>Building a tool to analyze Claude Code conversations<em>using</em> Claude Code creates interesting loops:</p><ul><li>I used<code>claude-history</code> to test itself across platforms</li><li>I exported conversations and fed them back to Claude for insights on my own usage patterns</li><li>The development history became test data</li></ul><p>Looking at the exported sessions, you can see the project evolving - command-line arguments changing as I iterated on what felt intuitive, features emerging from actual use rather than upfront planning.</p><p>If you want the broader lessons from those transcripts—how to structure Claude collaborations, how to set expectations, and how to keep documentation in lockstep—I captured them in a public-facing<a href="https://github.com/kvsankar/claude-history/blob/master/docs/claude-collaboration-playbook.md">Claude Collaboration Playbook</a>. It’s the distilled playbook I now hand to every new Claude session before we start shipping.</p><h2 id="whats-next">What&rsquo;s Next</h2><p>Two threads are already underway:</p><ul><li><strong>Agent-agnostic history:</strong> Claude is still my primary coding partner, but I&rsquo;m experimenting with ingesting exports from other coding agents—Gemini CLI, Codex CLI, Cursor, etc.—so the tool can normalize them and give a unified view regardless of where the conversations started.</li><li><strong>Cross-home timelines:</strong> A combined conversation history that spans every environment (local, WSL, Windows, SSH) is in progress. The idea is a chronological feed—CLI + HTML export—that lets me replay a feature build from start to ship, regardless of which machine I was on.</li></ul><h2 id="getting-started">Getting Started</h2><p>The tool is available on GitHub:<a href="https://github.com/kvsankar/claude-history">github.com/kvsankar/claude-history</a> (MIT License)</p><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Download and make executable</span></span></span><span class="line"><span class="cl">curl -O https://raw.githubusercontent.com/kvsankar/claude-history/main/claude-history</span></span><span class="line"><span class="cl">chmod +x claude-history</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># List sessions from current project</span></span></span><span class="line"><span class="cl">./claude-history lss</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># Export current project to markdown</span></span></span><span class="line"><span class="cl">./claude-history<span class="nb">export</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># Optional: point at a different Claude data directory</span></span></span><span class="line"><span class="cl"><span class="nv">CLAUDE_PROJECTS_DIR</span><span class="o">=</span>/mnt/windows/Users/me/.claude/projects ./claude-history lss</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># Drop it in your PATH for convenience</span></span></span><span class="line"><span class="cl">mv claude-history ~/bin/</span></span><span class="line"><span class="cl"><span class="nb">hash</span> -r<span class="o">&amp;&amp;</span> claude-history --version</span></span></code></pre></div><p>For Windows, run with<code>python claude-history lss</code>.</p><h2 id="acknowledgments">Acknowledgments</h2><p>Thanks to Simon Willison for his consistently high-signal, educational writing on working with LLMs. His posts on conversation extraction and using history for better context directly inspired this project.</p><h2 id="discussion">Discussion</h2><ul><li><a href="https://x.com/kvsankar/status/1996964557353537565">Twitter/X</a></li><li><a href="https://www.linkedin.com/posts/kvsankar_claudeai-aiassisteddevelopment-devtools-activity-7402887542179872768-OVXn">LinkedIn</a></li><li><a href="https://www.reddit.com/r/ClaudeAI/comments/1pexehu/claude_code_history_exporter_with_crossplatform/">Reddit r/ClaudeAI</a></li></ul><hr><p>Your Claude Code conversation history is more valuable than you might think. It contains design decisions, debugging sessions, research threads, and iterative refinements. With a bit of tooling, you can search it, learn from it, and reference it when that context matters most.</p>
]]></description><content:encoded>&lt;![CDATA[<p>I&rsquo;ve been using Claude Code extensively for the past few months. One day, while working on a startup consulting project, I needed to reference an analysis Claude had helped me with. I had discussed alternatives, evaluated tradeoffs, and settled on a design approach for an Architecture Decision Record (ADR). But I hadn&rsquo;t asked Claude to generate the usual markdown report.</p><p>The analysis was gone from my immediate context. Somewhere in the conversation history, buried across multiple platforms where I work.</p><p>This is the story of how I built<code>claude-history</code> to solve this problem.</p><h2 id="the-inspiration">The Inspiration</h2><p>Simon Willison&rsquo;s<a href="https://simonwillison.net/tags/claude/">writing on Claude</a> has been consistently educational. His<a href="https://observablehq.com/@simonw/convert-claude-json-to-markdown">Observable notebook for converting Claude JSON to Markdown</a> and discussions about extracting learnings from conversation history to improve future interactions caught my attention.</p><p>The idea is simple: your conversations with Claude contain valuable context - design decisions, debugging sessions, research threads. If you can extract and reference them, you can work more effectively.</p><p>Tools like<a href="https://github.com/ZeroSumQuant/claude-conversation-extractor">claude-conversation-extractor</a> exist, but they didn&rsquo;t fit my workflow. I needed something that could:</p><ul><li>Filter by workspace/project, not just session IDs</li><li>Work across Windows, WSL, and Linux VMs (where I run the same projects)</li><li>Help me generate insights and track usage</li><li>Link related workspaces that had been renamed or moved</li></ul><h2 id="first-prevent-data-loss">First: Prevent Data Loss</h2><p>Before anything else, if you&rsquo;re using Claude Code, check your<code>~/.claude/settings.json</code>. By default, Claude Code<a href="https://github.com/anthropics/claude-code/issues/4172">deletes conversation history after 30 days</a>. I learned this the hard way.</p><p>Add this to preserve your history:</p><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span></span></span><span class="line"><span class="cl"><span class="nt">"cleanupPeriodDays"</span><span class="p">:</span><span class="mi">99999</span></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>No point building a history tool if your history keeps disappearing.</p><p>One more housekeeping item: if your Claude data lives on a different drive (common when running WSL or backing up to an external disk), set<code>CLAUDE_PROJECTS_DIR</code> before running the CLI so it knows where to look:</p><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">export</span><span class="nv">CLAUDE_PROJECTS_DIR</span><span class="o">=</span>/mnt/windows/Users/me/.claude/projects</span></span><span class="line"><span class="cl">claude-history lsw</span></span></code></pre></div><h2 id="my-cross-platform-reality">My Cross-Platform Reality</h2><p>I work on astronomy-related projects that need to run on Windows, WSL, and Linux. My typical workflow: push code from one platform, pull and continue on another. The<code>claude-history</code> tool itself was developed this way.</p><p>This means my conversation history for a single logical project gets scattered across environments. When I needed to find that missing ADR analysis, I had to figure out: which platform was I on? When did that conversation happen?</p><h2 id="what-i-built">What I Built</h2><p><code>claude-history</code> is a single-file Python CLI with zero external dependencies. It reads the JSONL files Claude Code stores in<code>~/.claude/projects/</code> and exports them to readable Markdown.</p><details><summary>Full CLI help (-h)</summary><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gdscript3" data-lang="gdscript3"><span class="line"><span class="cl"><span class="o">$</span><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="o">--</span><span class="n">help</span></span></span><span class="line"><span class="cl"><span class="n">usage</span><span class="p">:</span><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="p">[</span><span class="o">-</span><span class="n">h</span><span class="p">]</span><span class="p">[</span><span class="o">--</span><span class="n">version</span><span class="p">]</span></span></span><span class="line"><span class="cl"><span class="p">{</span><span class="n">lsw</span><span class="p">,</span><span class="n">lss</span><span class="p">,</span><span class="n">lsh</span><span class="p">,</span><span class="k">export</span><span class="p">,</span><span class="n">alias</span><span class="p">,</span><span class="n">stats</span><span class="p">,</span><span class="n">reset</span><span class="p">,</span><span class="n">install</span><span class="p">}</span><span class="o">...</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="n">Browse</span><span class="ow">and</span><span class="k">export</span><span class="n">Claude</span><span class="n">Code</span><span class="n">conversation</span><span class="n">history</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="n">positional</span><span class="n">arguments</span><span class="p">:</span></span></span><span class="line"><span class="cl"><span class="p">{</span><span class="n">lsw</span><span class="p">,</span><span class="n">lss</span><span class="p">,</span><span class="n">lsh</span><span class="p">,</span><span class="k">export</span><span class="p">,</span><span class="n">alias</span><span class="p">,</span><span class="n">stats</span><span class="p">,</span><span class="n">reset</span><span class="p">,</span><span class="n">install</span><span class="p">}</span></span></span><span class="line"><span class="cl"><span class="n">Command</span><span class="n">to</span><span class="n">execute</span></span></span><span class="line"><span class="cl"><span class="n">lsw</span><span class="n">List</span><span class="n">workspaces</span></span></span><span class="line"><span class="cl"><span class="n">lss</span><span class="n">List</span><span class="n">sessions</span></span></span><span class="line"><span class="cl"><span class="n">lsh</span><span class="n">List</span><span class="n">homes</span><span class="ow">and</span><span class="n">manage</span><span class="n">SSH</span><span class="n">remotes</span></span></span><span class="line"><span class="cl"><span class="k">export</span><span class="n">Export</span><span class="n">to</span><span class="n">markdown</span></span></span><span class="line"><span class="cl"><span class="n">alias</span><span class="n">Manage</span><span class="n">workspace</span><span class="n">aliases</span></span></span><span class="line"><span class="cl"><span class="n">stats</span><span class="n">Show</span><span class="n">usage</span><span class="n">statistics</span><span class="ow">and</span><span class="n">metrics</span></span></span><span class="line"><span class="cl"><span class="n">reset</span><span class="n">Reset</span><span class="n">stored</span><span class="n">data</span><span class="p">(</span><span class="n">database</span><span class="p">,</span><span class="n">settings</span><span class="p">,</span><span class="n">aliases</span><span class="p">)</span></span></span><span class="line"><span class="cl"><span class="n">install</span><span class="n">Install</span><span class="n">CLI</span><span class="ow">and</span><span class="n">Claude</span><span class="n">skill</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="n">options</span><span class="p">:</span></span></span><span class="line"><span class="cl"><span class="o">-</span><span class="n">h</span><span class="p">,</span><span class="o">--</span><span class="n">help</span><span class="n">show</span><span class="n">this</span><span class="n">help</span><span class="n">message</span><span class="ow">and</span><span class="n">exit</span></span></span><span class="line"><span class="cl"><span class="o">--</span><span class="n">version</span><span class="n">show</span><span class="n">program</span><span class="s1">'s version number and exit</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="n">EXAMPLES</span><span class="p">:</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="n">List</span><span class="n">workspaces</span><span class="p">:</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="n">lsw</span><span class="c1"># all local workspaces</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="n">lsw</span><span class="n">myproject</span><span class="c1"># filter by pattern</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="n">lsw</span><span class="o">-</span><span class="n">r</span><span class="n">user</span><span class="err">@</span><span class="n">server</span><span class="c1"># remote workspaces</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="n">List</span><span class="n">sessions</span><span class="p">:</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="n">lss</span><span class="c1"># current workspace</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="n">lss</span><span class="n">myproject</span><span class="c1"># specific workspace</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="n">lss</span><span class="n">myproject</span><span class="o">-</span><span class="n">r</span><span class="n">user</span><span class="err">@</span><span class="n">server</span><span class="c1"># remote sessions</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="n">Export</span><span class="p">(</span><span class="n">unified</span><span class="n">interface</span><span class="n">with</span><span class="n">orthogonal</span><span class="n">flags</span><span class="p">):</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="k">export</span><span class="c1"># current workspace, local home</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="k">export</span><span class="o">--</span><span class="n">ah</span><span class="c1"># current workspace, all homes</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="k">export</span><span class="o">--</span><span class="n">aw</span><span class="c1"># all workspaces, local home</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="k">export</span><span class="o">--</span><span class="n">ah</span><span class="o">--</span><span class="n">aw</span><span class="c1"># all workspaces, all homes</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="k">export</span><span class="n">myproject</span><span class="c1"># specific workspace, local</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="k">export</span><span class="n">myproject</span><span class="o">--</span><span class="n">ah</span><span class="c1"># specific workspace, all homes</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="k">export</span><span class="n">file</span><span class="o">.</span><span class="n">jsonl</span><span class="c1"># export single file</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="k">export</span><span class="o">-</span><span class="n">o</span><span class="o">/</span><span class="n">tmp</span><span class="o">/</span><span class="n">backup</span><span class="c1"># current workspace, custom output</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="k">export</span><span class="n">myproject</span><span class="o">-</span><span class="n">o</span><span class="o">./</span><span class="n">out</span><span class="c1"># specific workspace, custom output</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="k">export</span><span class="o">-</span><span class="n">r</span><span class="n">user</span><span class="err">@</span><span class="n">server</span><span class="c1"># current workspace, specific remote</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="k">export</span><span class="o">--</span><span class="n">ah</span><span class="o">-</span><span class="n">r</span><span class="n">user</span><span class="err">@</span><span class="n">vm01</span><span class="c1"># current workspace, all homes + SSH</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="n">Date</span><span class="n">filtering</span><span class="p">:</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="n">lss</span><span class="n">myproject</span><span class="o">--</span><span class="n">since</span><span class="mi">2025</span><span class="o">-</span><span class="mi">11</span><span class="o">-</span><span class="mi">01</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="k">export</span><span class="n">myproject</span><span class="o">--</span><span class="n">since</span><span class="mi">2025</span><span class="o">-</span><span class="mi">11</span><span class="o">-</span><span class="mi">01</span><span class="o">--</span><span class="n">until</span><span class="mi">2025</span><span class="o">-</span><span class="mi">11</span><span class="o">-</span><span class="mi">30</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="n">Export</span><span class="n">options</span><span class="p">:</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="k">export</span><span class="n">myproject</span><span class="o">--</span><span class="n">minimal</span><span class="c1"># minimal mode</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="k">export</span><span class="n">myproject</span><span class="o">--</span><span class="n">split</span><span class="mi">500</span><span class="c1"># split long conversations</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="k">export</span><span class="n">myproject</span><span class="o">--</span><span class="n">flat</span><span class="c1"># flat structure (no subdirs)</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="n">WSL</span><span class="n">access</span><span class="p">(</span><span class="n">Windows</span><span class="p">):</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="n">lsh</span><span class="o">--</span><span class="n">wsl</span><span class="c1"># list WSL distributions</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="n">lsw</span><span class="o">--</span><span class="n">wsl</span><span class="c1"># list WSL workspaces</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="n">lsw</span><span class="o">--</span><span class="n">wsl</span><span class="n">Ubuntu</span><span class="c1"># list from specific distro</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="n">lss</span><span class="n">myproject</span><span class="o">--</span><span class="n">wsl</span><span class="c1"># list WSL sessions</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="k">export</span><span class="n">myproject</span><span class="o">--</span><span class="n">wsl</span><span class="c1"># export from WSL</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="n">Windows</span><span class="n">access</span><span class="p">(</span><span class="n">from</span><span class="n">WSL</span><span class="p">):</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="n">lsh</span><span class="o">--</span><span class="n">windows</span><span class="c1"># list Windows users with Claude</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="n">lsw</span><span class="o">--</span><span class="n">windows</span><span class="c1"># list Windows workspaces</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="n">lss</span><span class="n">myproject</span><span class="o">--</span><span class="n">windows</span><span class="c1"># list Windows sessions</span></span></span><span class="line"><span class="cl"><span class="n">claude</span><span class="o">-</span><span class="n">history</span><span class="k">export</span><span class="n">myproject</span><span class="o">--</span><span class="n">windows</span><span class="c1"># export from Windows</span></span></span></code></pre></div></details><p>The key insight was making home (where) and workspace (which) filtering orthogonal:</p><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Current workspace, local only</span></span></span><span class="line"><span class="cl">claude-history<span class="nb">export</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># Current workspace, all homes (local + WSL + Windows + SSH remotes)</span></span></span><span class="line"><span class="cl">claude-history<span class="nb">export</span> --ah</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># All workspaces, all homes</span></span></span><span class="line"><span class="cl">claude-history<span class="nb">export</span> --ah --aw</span></span></code></pre></div><h2 id="workspace-aliases-linking-scattered-projects">Workspace Aliases: Linking Scattered Projects</h2><p>During development, I kept renaming the project -<code>claude-sessions</code>,<code>claude-conversations</code>,<code>claude-history</code>. Each rename created a new workspace directory in Claude&rsquo;s storage. Across three platforms, I had fragments everywhere.</p><p>The alias feature lets me group them:</p><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Create an alias</span></span></span><span class="line"><span class="cl">claude-history<span class="nb">alias</span> create claude-history</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># Add workspaces by pattern from different homes</span></span></span><span class="line"><span class="cl">claude-history<span class="nb">alias</span> add claude-history claude-sessions</span></span><span class="line"><span class="cl">claude-history<span class="nb">alias</span> add claude-history claude-conversations</span></span><span class="line"><span class="cl">claude-history<span class="nb">alias</span> add claude-history --windows claude-history</span></span><span class="line"><span class="cl">claude-history<span class="nb">alias</span> add claude-history -r user@vm01 claude-history</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># Now I can query everything together</span></span></span><span class="line"><span class="cl">claude-history lss @claude-history</span></span></code></pre></div><p>Here&rsquo;s what that looks like across my local workspaces (current name + older rename) and a remote Linux VM—paths are redacted with<code>...</code>:</p><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">$ claude-history lss --ah @claude-history</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl">Using alias @claude-history (use --this for current workspace only)</span></span><span class="line"><span class="cl">HOME WORKSPACE FILE MESSAGES DATE</span></span><span class="line"><span class="cl">local /home/.../projects/claude-history 3b4191bc-055f-4752-9029-1c69e29f5d3a.jsonl 2612 2025-12-04</span></span><span class="line"><span class="cl">local /home/.../projects/claude-sessions be3d3632-e442-436e-987a-d427e1d7b08b.jsonl 2347 2025-11-22</span></span><span class="line"><span class="cl">remote:sankar@ubuntuvm01 /remote_ubuntuvm01_home/.../claude-history 895cefac-8e43-4dfe-8574-7b6636fdd428.jsonl 890 2025-11-30</span></span><span class="line"><span class="cl">...</span></span></code></pre></div><p><img alt="Multi-home session listing showing local and remote rows" loading="lazy" src="/images/claude-history/claude-history-lss.png"/><p>One logical project, multiple homes, unified view.</p><h2 id="use-cases-i-didnt-expect">Use Cases I Didn&rsquo;t Expect</h2><h3 id="generating-specifications-from-conversations">Generating Specifications from Conversations</h3><p>The best example of this is my<a href="https://github.com/kvsankar/chandrayaan-mission-design">Chandrayaan Mission Design</a> project. It started as an astronomy outreach tool—an interactive sandbox that middle-school students could use to explore mission design ideas around Chandrayaan-3. I iterated quickly with Claude, but never paused to write a traditional spec.</p><p>Later, when I needed documentation, I exported the relevant sessions with<code>claude-history</code> and turned that transcript into the formal specification that now lives in<a href="https://github.com/kvsankar/chandrayaan-mission-design/blob/master/docs/specs/spec.md"><code>docs/specs/spec.md</code></a>. The history captured the<em>problem space</em>—what students needed, what constraints mattered, the educational goals. The code, meanwhile, only reflected the<em>solution</em>.</p><p>Here&rsquo;s an excerpt from those exports that made it straight into the spec:</p><blockquote><h3 id="problem-statement">Problem Statement</h3><p>We need a pared-down mission-design sandbox that middle-school students can use during outreach sessions.</p><ul><li>Inputs: launch date, target orbit, payload mass (preset presets OK)</li><li>Outputs: simple delta-v budget, textual mission plan, SVG visualization</li><li>Constraints: runs in browser, no install, offline fallback (PDF handout)</li></ul></blockquote><p>That text came directly from the conversation history—no rewriting, no guessing.<code>claude-history</code> let me capture the intent at the moment it was articulated and crystallize it into a working specification later.</p><h3 id="time-tracking">Time Tracking</h3><p>I originally added<code>stats --time</code> for curiosity, but it quickly became a practical tool. While wrapping up a consulting engagement, I wanted to understand how much of my deliverable time was spent collaborating with Claude versus research or meetings. Running<code>claude-history stats @consulting-project --time</code> produced a defensible record of “hands on keyboard with Claude” hours that I could compare against my invoice and status reports.</p><p>Here’s a representative output (I keep snapshots like this in the project notes):</p><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">$ claude-history stats @claude-history --time</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl">============================================================</span></span><span class="line"><span class="cl">TIME TRACKING</span></span><span class="line"><span class="cl">============================================================</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl">Time</span></span><span class="line"><span class="cl"> Total work time: 42h 43m</span></span><span class="line"><span class="cl"> Work periods: 51</span></span><span class="line"><span class="cl"> Session files: 68</span></span><span class="line"><span class="cl"> Date range: 2025-11-20 to 2025-12-03</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl">Daily Breakdown</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl">Date Work Time Periods Messages Bar (time)</span></span><span class="line"><span class="cl">----------------------------------------------------------</span></span><span class="line"><span class="cl">2025-12-03 4h 57m 2 1949 ####################</span></span><span class="line"><span class="cl">2025-12-02 1h 26m 2 506 ####</span></span><span class="line"><span class="cl">2025-12-01 1h 3m 1 243 ###</span></span><span class="line"><span class="cl">2025-11-30 9h 28m 5 3217 ####################</span></span><span class="line"><span class="cl">2025-11-29 4h 33m 7 1580 ###############</span></span><span class="line"><span class="cl">2025-11-24 6m 1 82 #</span></span><span class="line"><span class="cl">2025-11-23 3h 28m 7 597 ###########</span></span><span class="line"><span class="cl">2025-11-22 5h 32m 7 1727 ################</span></span><span class="line"><span class="cl">2025-11-21 6h 50m 10 1327 ####################</span></span><span class="line"><span class="cl">2025-11-20 5h 15m 9 1176 ################</span></span><span class="line"><span class="cl">----------------------------------------------------------</span></span><span class="line"><span class="cl">TOTAL 42h 43m 51 12404 ####################</span></span></code></pre></div><p><img alt="Time tracking output with ASCII bars for each day" loading="lazy" src="/images/claude-history/claude-stats-time.png"/><p>This helped with my consulting project - I had a rough sense of time spent, but Claude gave me realistic figures. Note that this measures<em>active interaction time</em>. Requirements gathering, meetings, and thinking happen outside the tool.</p><h2 id="a-design-decision-stdlib-only">A Design Decision: Stdlib Only</h2><p>Early in development, I explored adding CLI interactivity - things like interactive menus, colored output, progress bars. Libraries like<code>rich</code> or<code>click</code> could have helped, but they&rsquo;d introduce external dependencies.</p><p>I decided against it. The tool stays Python standard library only - a single file you can copy anywhere and run. No<code>pip install</code>, no virtual environments, no dependency conflicts.</p><p>This constraint shaped the design in good ways. The output is plain text that pipes well to other tools. The code stays focused. And when I need to run it on a new machine, I just copy the file.</p><h2 id="the-meta-moments">The Meta Moments</h2><p>Building a tool to analyze Claude Code conversations<em>using</em> Claude Code creates interesting loops:</p><ul><li>I used<code>claude-history</code> to test itself across platforms</li><li>I exported conversations and fed them back to Claude for insights on my own usage patterns</li><li>The development history became test data</li></ul><p>Looking at the exported sessions, you can see the project evolving - command-line arguments changing as I iterated on what felt intuitive, features emerging from actual use rather than upfront planning.</p><p>If you want the broader lessons from those transcripts—how to structure Claude collaborations, how to set expectations, and how to keep documentation in lockstep—I captured them in a public-facing<a href="https://github.com/kvsankar/claude-history/blob/master/docs/claude-collaboration-playbook.md">Claude Collaboration Playbook</a>. It’s the distilled playbook I now hand to every new Claude session before we start shipping.</p><h2 id="whats-next">What&rsquo;s Next</h2><p>Two threads are already underway:</p><ul><li><strong>Agent-agnostic history:</strong> Claude is still my primary coding partner, but I&rsquo;m experimenting with ingesting exports from other coding agents—Gemini CLI, Codex CLI, Cursor, etc.—so the tool can normalize them and give a unified view regardless of where the conversations started.</li><li><strong>Cross-home timelines:</strong> A combined conversation history that spans every environment (local, WSL, Windows, SSH) is in progress. The idea is a chronological feed—CLI + HTML export—that lets me replay a feature build from start to ship, regardless of which machine I was on.</li></ul><h2 id="getting-started">Getting Started</h2><p>The tool is available on GitHub:<a href="https://github.com/kvsankar/claude-history">github.com/kvsankar/claude-history</a> (MIT License)</p><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Download and make executable</span></span></span><span class="line"><span class="cl">curl -O https://raw.githubusercontent.com/kvsankar/claude-history/main/claude-history</span></span><span class="line"><span class="cl">chmod +x claude-history</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># List sessions from current project</span></span></span><span class="line"><span class="cl">./claude-history lss</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># Export current project to markdown</span></span></span><span class="line"><span class="cl">./claude-history<span class="nb">export</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># Optional: point at a different Claude data directory</span></span></span><span class="line"><span class="cl"><span class="nv">CLAUDE_PROJECTS_DIR</span><span class="o">=</span>/mnt/windows/Users/me/.claude/projects ./claude-history lss</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># Drop it in your PATH for convenience</span></span></span><span class="line"><span class="cl">mv claude-history ~/bin/</span></span><span class="line"><span class="cl"><span class="nb">hash</span> -r<span class="o">&amp;&amp;</span> claude-history --version</span></span></code></pre></div><p>For Windows, run with<code>python claude-history lss</code>.</p><h2 id="acknowledgments">Acknowledgments</h2><p>Thanks to Simon Willison for his consistently high-signal, educational writing on working with LLMs. His posts on conversation extraction and using history for better context directly inspired this project.</p><h2 id="discussion">Discussion</h2><ul><li><a href="https://x.com/kvsankar/status/1996964557353537565">Twitter/X</a></li><li><a href="https://www.linkedin.com/posts/kvsankar_claudeai-aiassisteddevelopment-devtools-activity-7402887542179872768-OVXn">LinkedIn</a></li><li><a href="https://www.reddit.com/r/ClaudeAI/comments/1pexehu/claude_code_history_exporter_with_crossplatform/">Reddit r/ClaudeAI</a></li></ul><hr><p>Your Claude Code conversation history is more valuable than you might think. It contains design decisions, debugging sessions, research threads, and iterative refinements. With a bit of tooling, you can search it, learn from it, and reference it when that context matters most.</p>
]]></content:encoded></item></channel></rss>